Sidekiqが謎の理由でスタックしたので状態監視して強制再起動させた

Sidekiq、非常に便利でRailsでキューイングするとしたらSidekiq一択っていうくらい好きなんですが、とあるプロジェクトで実行中のキューがいつまでも終わらず、ワーカーが埋まってしまいキューがたまり続ける…という謎の現象に出くわしました。

サーバ負荷自体も特になく、ジョブ内容も変な処理などはないし、タイムアウトにすらならないという…。根本原因を探って直すのが良いのですが、再現性もなく突発的に起きるため、まずはサービス提供が継続できるようにSidekiqの状態を監視して、ワーカーが停止していたら強制再起動させて復帰させるというアプローチを取りました。

## Sidekiqの状態取得

Sidekiqのワーカーを取得するには`Sidekiq::Workers.new`を呼びます。この中でワーカーがいつ実行開始されたのかが`work["run_at"]`に記録されています。

```ruby
workers = Sidekiq::Workers.new
workers.each do |process_id, thread_id, work|
  p "at: #{Time.at(work["run_at"])}"
end
```

こいつを監視して実行時間が想定以上になっていたらSidekiqを強制再起動させます。

- 参考 [Sidekiq API#Workers](https://github.com/mperham/sidekiq/wiki/API#workers)

## Sidekiqの停止

停止のAPIも用意されています。

```ruby
ps = Sidekiq::ProcessSet.new
ps.each(&:quiet!) #USR1
ps.each(&:stop!) #TERM
```

通常であれば、ゆるやかな停止（URS1）を選ぶところですが今回はワーカー自体が動かなくなってしまっているので強制停止（TERM）にします。

## Sidekiqの起動

Rubyからシステムコールするにはバッククオートすれば簡単に呼べます。ただこれだけだと正常に起動したかどうかが分からないためプロセス管理ツールmonitを併用することにしました。

```ruby
`bundle exec sidekiq`
```

### monitでSidekiqを監視

yumなどでmonitをサクッとインストールします。

```
sudo yum install monit -y
sudo chkconfig monit on
sudo service monit start
sudo vi /etc/monit.d/sidekiq
```

Sidekiqの起動と停止コマンドを設定します。そして今回はSidekiqを強制停止した時にSlackなどに通知したかったので`if does not exist`の条件式を使っています。ここで、rakeタスクを指定して通知してあげます。

```
check process sidekiq with pidfile /var/www/rails/tmp/pids/sidekiq.pid
  every 2 cycles
  start program = "/bin/su - webmaster -c 'cd /var/www/rails/; bundle exec sidekiq -d -C config/sidekiq.yml -e production'"
  stop program = "/bin/su - webmaster -c 'cd /var/www/rails/; bundle exec sidekiqctl stop tmp/pids/sidekiq.pid'"
  if does not exist
    then exec "/bin/su - webmaster -c 'cd /var/www/rails/; RAILS_ENV=production bundle exec rake monitoring_sidekiq:stopped_sidekiq'"
    else if succeeded for 1 cycle then exec "/bin/su - webmaster -c 'cd /var/www/rails/; RAILS_ENV=production bundle exec rake monitoring_sidekiq:start_sidekiq'"
```

### rakeタスクサンプル

30分以上実行中のワーカーがあったら強制終了させるタスクです。これをwheneverなどでcronに設定すればOKです。

```ruby
namespace :monitoring_sidekiq do
  desc "Sidekiqのジョブが指定時間以上処理中の場合、強制的にSidekiqを終了させる"

  task :check_run_at => :environment do
    threshold_min = 30
    now = Time.now.to_i

    do_stop = false
    data = []

    workers = Sidekiq::Workers.new
    workers.each do |process_id, thread_id, work|
      data << work
      diff = now - work["run_at"]
      if diff >= threshold_min * 60
        do_stop = true
      end
    end

    if do_stop
      report_slack(format_message(data))
      ps = Sidekiq::ProcessSet.new
      ps.each(&:stop!)
    end
  end

  desc "Sidekiqの停止を検知したらSlackへ通知する"
  task :stopped_sidekiq => :environment do
    report_slack("sidekiqの停止を検知しました。再起動をします。")
    `bundle exec sidekiq -d -C config/sidekiq.yml -e #{Rails.env}`
  end

  desc "Sidekiqが起動したらSlackへ通知する"
  task :start_sidekiq => :environment do
    report_slack("sidekiqの再起動が完了しました")
  end

  def format_message(data)
    message = ""
    data.each do |val|
      message += <<-EOM
class: #{val["payload"]["class"]}
jid: #{val["payload"]["jid"]}
args: #{val["payload"]["args"]}
run_at: #{Time.at(val["run_at"])}
---
      EOM
    end
    message
  end

  def report_slack(data)
    Slack.chat_postMessage(
      text: data,
      username: 'slack_bot',
      channel: '#sidekiq'
    )
  end
end
```

今回の例だと`stopped_sidekiq`タスクの中でSidekiqを起動していますが、これはmonitで`if does not exist`などを使うとmonitの`start program`が呼ばれず、自前のスクリプトにすべての処理を任せる（？）という仕様のためです。通知方法を標準のメールにするのであればrakeタスク内でsidekiqを起動せずとも全てmonitに任せることができます。

最後に手動でsidekiqのプロセスをkillして再起動がされるかの動作確認をすれば完了です。