Sidekiq: デプロイと非同期ジョブの落とし穴
SIGTERM/SIGKILL — 長いSidekiqジョブがデプロイの度に死ぬ理由
はじめに — 実際の事故
ある日、画面が「同期中」表示のまま永遠に固着しているのを発見した。DBを見ると:
integ.status # => 1 (実行中)
integ.success_count # => 4808 / 6424
integ.latest_sync_at # => 昨日
SidekiqのWorkers / Queue / Retry / Deadのどこにもそのジョブが存在しない。ジョブは消えているのにDB状態は「実行中」のまま — つまりジョブが途中で死んだということ。
原因を辿るとOSシグナルに行き着く
OSがプロセスを終了させる方法は2つ:
SIGTERM(graceful)
「おい、仕事を片付けてから終了してくれ」
OSは信号だけ送る。プロセスが自分で終了処理を行う
Sidekiq側: 新規ジョブの受付停止 → 現在のジョブが終わるまで待つ → 終わらなければretryキューに戻して終了
Ruby側:
at_exit、ensure、rescueすべて実行される
SIGKILL(強制)
「黙って死ね」
OSが即座に強制終了。プロセスに選択肢なし
実行中のコードはどこであっても即死
ensure、rescue、at_exitすべて実行されないDBトランザクションはcommitされないまま終わる
シグナルハンドラでも捕まえられない
ECS / k8sのデプロイで何が起きるか
1. 新コンテナ起動
2. ヘルスチェックOK → 旧コンテナにSIGTERM送信
3. stopTimeout(例: 30~120秒)の間待機
4. それでも生きていれば → SIGKILL
この「stopTimeoutの間待機」する時間が graceful shutdownウィンドウ。
Sidekiqの動作:
1. polling停止
2. 実行中ジョブがあれば :timeout(デフォルト25秒)の間待つ
3. 25秒以内に終われば → 正常終了 ✅
4. 終わらなければ → ジョブをretryキューに戻して終了を試みる
5. それでもstopTimeoutを超えれば → ECSがSIGKILL → 即死、ensureは走らない ❌
1時間かかるジョブはどうstuckになるか
class HeavySyncService
def run
@integ.update(status: 1) # 開始時に実行中マーク
begin
6424.times do |i|
sleep 0.5 # 1件あたり0.5秒
process_one(i) # 実際の処理時間も追加
end
@integ.update(status: 77) # 正常終了時にsuccess
rescue => e
@integ.update(status: 44) # 例外発生時にfailed
raise e
end
end
end
6424件 × 0.5秒 ≈ 54分。このジョブが走っている最中に誰かがdevelopにマージしてデプロイが落ちると:
12:00 ジョブ開始 → status=1
12:30 デプロイ → ECSが旧コンテナにSIGTERM
→ Sidekiq、:timeout(25秒)以内に終わらせようとする。間に合わない。
12:31 stopTimeout超過 → ECSがSIGKILL
→ ジョブ即死。ensure走らず。status=1のまま。
ユーザーが見るのは? 画面をリロードしても永遠に「同期中(4808/6424)」。
「でもジョブが死んでもキューに戻されて再実行されるんじゃ?」
半分正解、半分間違い。
Sidekiqの標準メカニズム(SIGTERM時)
- 実行中ジョブを
:timeout内に終わらせようとする - 終わらなければ ジョブをキューに戻して(re-enqueue) 終了
- 新コンテナがキューから取り出して 最初から再実行
→ この経路ならジョブは消えない。
しかしSIGKILLでは
Sidekiq自体が即死 → ジョブをキューに戻す主体がいない
OSS SidekiqのデフォルトfetcherはBRPOPでジョブを取得した瞬間にRedisから削除する(non-reliable)
→ ワーカーがSIGKILLされるとそのジョブは消失する
Sidekiq Proの super_fetch または sidekiq-reliable-fetch(OSS)を使えばジョブを別setに保管しワーカーの死を検知して復元するが、OSSデフォルトのSidekiqはそうではない。
60秒ジョブと1時間ジョブの違い
| ジョブ長 | デプロイ時の挙動 |
|---|---|
| 1秒 | ほぼ常にgracefulウィンドウ内で終わる。無視可。 |
| 60秒 | stopTimeout内に終わる確率が極めて高い。SIGKILLされても次回定期実行で自然回復。 |
| 5分 | 時々gracefulウィンドウに収まらない。運用上気になる。 |
| 30分~1時間 | ほぼ毎回SIGKILL。毎回stuck。時限爆弾。 |
核心: ジョブの長さをgraceful shutdownウィンドウ内に収めること が最も確実な防御。
本当にちゃんと防ぐには
- ジョブを短く保つ(最重要)
無意味な
sleepを削除1件ごとのupdate / broadcast等の重い呼び出しはN件単位でbatch化
大きなジョブはchunkに分割してN個のジョブとしてenqueue
- 冪等にする
最初から再実行しても同じ結果になるように
chunk単位で進捗をDBに記録 → 再実行時に途中再開
- Reliable fetchの導入
Sidekiq Pro
super_fetch(有料)sidekiq-reliable-fetch(OSS、メンテナンス状況に注意)SIGKILL時もジョブ損失を防ぐ
- Stuck検知 & 自動再実行ジョブ(現実的なベストアンサー)
発想: 「Sidekiqが途中で死んだなら、DBに
status=1で止まったレコードが残っているはず。それを定期的に探して再実行すればよい」例: 1時間ごとにスケジューラ(cron / sidekiq-cron / whenever 等)で以下のジョブを回す
class RecoverStuckSyncsJob < ApplicationJob
queue_as :default
STALE_AFTER = 1.hour
def perform
Integration.where(status: :running)
.where('updated_at < ?', STALE_AFTER.ago)
.find_each do |integ|
Rails.logger.warn("[StuckRecovery] #{integ.id} stale since #{integ.updated_at}")
integ.update!(status: :failed, alert: '自動復旧: 前回の実行が中断されました')
SyncJob.perform_later(integ.id) # 再enqueue
end
end
end
ポイント:
updated_atが一定時間以上動いていなければ(進捗がなければ)stuckとみなす- 進捗があればループ内の
updateでupdated_atが更新され続けるので誤検知の心配なし statusをfailedに落として再enqueue → 画面の「実行中」固着も解除される- ジョブ自体がidempotentである必要あり(最初から再実行しても安全)
間隔は状況に合わせて。1時間ならユーザーが体感するstuck時間は最大1時間。敏感な画面なら10~15分に短縮
ensure保険(補助)
SIGKILL以外の経路ではstatus復旧可能
SIGKILL自体は防げないが、壊れた終了経路の一部をカバー
まとめ
非同期ジョブは「リトライされるから安全」ではない。OSS Sidekiqデフォルト設定ではSIGKILL時にジョブは消える
デプロイタイミングとジョブの長さは直結する。ジョブが長いほどデプロイの度に死ぬ確率が上がる
「ジョブが永遠に実行中」のような画面は、たいていSIGKILL + ensure未実行 + ジョブ自体の長さ の組み合わせ
最も効果的な防御はかっこいいインフラではなく、ジョブを短くすることである
キーポイント
sleep 0.5 × 6424件 ≈ 54分のジョブを想定
実行中にdevelopへのマージとデプロイが発生
ECSが旧コンテナにSIGTERMを送信
Sidekiqが:timeout(25秒)以内に終了を試みる → 間に合わない
stopTimeout超過 → ECSがSIGKILL
SIGKILLのためensure/rescueは実行されず → DBにstatus=1が残存
OSS Sidekiqのデフォルトfetcherはnon-reliableのため、ジョブ自体もキューに戻らない
ユーザーはリロードしても永遠に「実行中」の画面を見る
メリット
- ✓ ジョブ分離はユーザー応答時間を短くする(本来のメリット)
- ✓ 失敗復旧をretryで自動化できる
- ✓ ジョブの長さに気を配るだけで大半のstuck事故は防げる
デメリット
- ✗ OSSデフォルトのSidekiqはSIGKILL時にジョブを失う
- ✗ ジョブが長いほどデプロイの度に死ぬ確率が非線形に上がる
- ✗ SIGKILLはensure/rescueを完全にバイパスするため、コードレベルでの100%保証は不可能
- ✗ reliable fetchの導入は有料、またはOSSのメンテナンスリスクあり