Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Sidekiq Enterpriseの同時実行数制御を理解する

こんにちは。サーバーサイドグループの山田です。 最近クロスバイクを買って自転車で走ることにはまっています。

弊社ではRailsアプリケーションの非同期処理やバッチ処理でSidekiq/Sidekiq Enterpriseを使用しています。
tech.studyplus.co.jp

Sidekiq Enterpirseには便利な機能が多くありますが、今回は Rate limiting の Concurrent について書いていきます。
なぜConcurrentかというと、先日この設定内容が原因で想定外に大量の待機ジョブを発生させてしまったためです。 その失敗を交えて紹介していきます。

TL;DR

Rate LimitingのConcurrentを使う場合

  • ジョブの処理時間に合わせたlock_timeoutを設定する
  • ジョブの処理を修正する場合はlock_timeoutも見直す

Sidekiq / Sidekiq Enterpriseとは

Sidekiqは非同期処理を扱うRuby製のライブラリです。
基本機能に加えてRolling RestartsRate Limitingなど便利な機能が使えるようにした有料版のライブラリがSidekiq Enterpriseです。

Rate limitingとは

Sidekiq Enterpirseで使えるジョブの実行数を制限する機能です。 以下の4種類が準備されています。

Studyplusでは外部APIアクセスへの同時実行制御などでConcurrentを使用しているので、ここではConcurrentについて見ていきます。

Concurrent

ジョブの同時実行数を制限する機能です。
例えば、以下のような設定することでERP_LIMIT.within_limitのブロック内の処理の同時実行数をSidekiq::Limiter.concurrentの第二引数の値(この例では5)に制限できます。

ERP_LIMIT = Sidekiq::Limiter.concurrent('erp', 5, wait_timeout: 3, lock_timeout: 30)

def perform(...)
  ERP_LIMIT.within_limit do
    # call ERP
  end
end

ERP_LIMITで制限した5個のジョブが実行された状態で6個目を実行しようとした場合、wait_timeoutの3秒待った後にジョブは再スケジュールされキューに戻されます。

lock_timeoutは後ほど説明します。

発生した問題

先日Concurrentで同時実行数を制限できていると思っていたジョブが、一定の条件で制限を無視して実行されていました。それにより、他のジョブの実行をせき止めてしまうという問題が発生しました。

以下のような設定をしていました。(実際の設定値や名称からは変更しています。)

DELIVER_LIMIT = Sidekiq::Limiter.concurrent('upload-hoge', 5, wait_timeout: 5, lock_timeout: 900)

第2引数に5を設定することで、Sidekiqのスレッドを使い切らないように同時実行を5つまでに制限していました。しかし、実際の振る舞いとしては制限を超えてジョブが実行され、Sidekiqが持つ全てのスレッドを制限したかった処理が占有してしまいました。 それぞれのジョブは長いもので1時間以上処理を行なっていたため、それらが終わるまで他のジョブは待機状態となりました。

制限を超えてjobが実行される理由

原因はlock_timeoutの設定でした。 lock_timeoutとは何かというと、実行中のジョブがこの設定時間を超えるまで同時実行数を制限するための機能です。つまり、この時間を超えると実行数の制限を超えてジョブが実行されることなります。 通常はクラッシュしたRubyのプロセスが永遠にロックを持たないように設定する値となり、ジョブの処理にかかる時間をこの時間より短くする必要があります。

今回ジョブの処理がこの時間を超える場合があったため、同時実行の上限数を超えてスレッドを占有していました。

Conccurentを設定した当初は問題なかったのですが、扱うデータ量の増加や処理に何度か修正を入れていくうちにジョブの実行時間がlock_timeoutを超えるようになっていました。

簡単なコードで事象を再現

Concurrentの挙動を理解するために簡単なコードで事象を再現して確認します。

  • Sidekiqのプロセス: 1
  • Sidekiqの1プロセスのconcurrency(スレッド数): 5

の条件で以下のようにlock_timeoutを100秒として、100秒以上かかるジョブを並列に実行させます。

class ConcurrentTestWorker
  include Sidekiq::Worker

  DELIVER_LIMIT = Sidekiq::Limiter.concurrent('concurrent_test', 3, wait_timeout: 5, lock_timeout: 100)

  def perform
    DELIVER_LIMIT.within_limit do
      sleep 250
    end
  end
end
# スレッド数を超える適当な数ジョブをエンキューする
30.times { |i| ConcurrentTestWorker.perform_async }

数十秒後

数十秒後にSidekiqのダッシュボードで実行中のジョブを確認します。

  • 制限内の3つのジョブは処理が行われるているため実行時間が数十秒になっている
  • それ以外の2つのジョブは制限に引っかかっているため「少し前」となっている
    • この「少し前」となっているのはジョブ実行時にwait_timeoutの間ロックが解放されるのを待つが、解放されなければ予定に移動する動きをします。そのため、実際にDELIVER_LIMIT内の処理が行われているジョブのような実行時間になっていません。
    • ログに以下のようにRate Limitを超えているので再スケジュールすると出ていることからもわかります。
level:INFO      message:[class:ConcurrentTestWorker jid:aaf3159df503959c6f69bf6a] Limiter 'concurrent_test' over rate limit, rescheduling for later

約180秒後

続いてlock_timeoutを超えた180秒後ぐらいにSidekiqのダッシュボードで実行中のジョブを確認します。

  • lock_timeoutに設定した100秒を超えたため、残りの2つのスレッドで制限を超えてジョブが実行されています。
  • 先ほどとは異なり、2つのジョブの実行時間が数十秒を超えており、その間全てのスレッドをConcurrentTestWorkerが占有しているのがわかります。

このようにlock_timeoutを超えると同時実行数の制限を無視してジョブが実行されていることが確認できました。

問題の対応

上記の検証の通り、lock_timeoutを超えた場合に制限を超えてジョブが処理されることを確認できました。 問題となったジョブの実行時間を確認して、lock_timeoutの値をジョブの処理時間より十分に長い秒数に変更する修正を入れました。

この修正により、無事にジョブがスレッドを占有する問題は解消されました🎉

まとめ

Sidekiq Enterpirseで同時実行制御を行う際に使用するRate LimitingのConcurrentについて、lock_timeoutを超えた場合の挙動の検証を交えて紹介しました。

Concurrent以外にもいくつか実行数の制御をする仕組みがありますが、仕組みを理解した上で問題を引き起こさないようにうまく活用していきたいです。