💀

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_exitensurerescue すべて実行される

SIGKILL(強制)

「黙って死ね」

  • OSが即座に強制終了。プロセスに選択肢なし

  • 実行中のコードはどこであっても即死

  • ensurerescueat_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時)

  1. 実行中ジョブを :timeout 内に終わらせようとする
  2. 終わらなければ ジョブをキューに戻して(re-enqueue) 終了
  3. 新コンテナがキューから取り出して 最初から再実行

→ この経路ならジョブは消えない。

しかし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ウィンドウ内に収めること が最も確実な防御。

本当にちゃんと防ぐには

  1. ジョブを短く保つ(最重要)
  • 無意味な sleep を削除

  • 1件ごとのupdate / broadcast等の重い呼び出しはN件単位でbatch化

  • 大きなジョブはchunkに分割してN個のジョブとしてenqueue

  1. 冪等にする
  • 最初から再実行しても同じ結果になるように

  • chunk単位で進捗をDBに記録 → 再実行時に途中再開

  1. Reliable fetchの導入
  • Sidekiq Pro super_fetch(有料)

  • sidekiq-reliable-fetch(OSS、メンテナンス状況に注意)

  • SIGKILL時もジョブ損失を防ぐ

  1. 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とみなす
    • 進捗があればループ内の updateupdated_at が更新され続けるので誤検知の心配なし
    • status をfailedに落として再enqueue → 画面の「実行中」固着も解除される
    • ジョブ自体がidempotentである必要あり(最初から再実行しても安全)
  • 間隔は状況に合わせて。1時間ならユーザーが体感するstuck時間は最大1時間。敏感な画面なら10~15分に短縮

  1. ensure 保険(補助)
  • SIGKILL以外の経路ではstatus復旧可能

  • SIGKILL自体は防げないが、壊れた終了経路の一部をカバー

まとめ

  • 非同期ジョブは「リトライされるから安全」ではない。OSS Sidekiqデフォルト設定ではSIGKILL時にジョブは消える

  • デプロイタイミングとジョブの長さは直結する。ジョブが長いほどデプロイの度に死ぬ確率が上がる

  • 「ジョブが永遠に実行中」のような画面は、たいていSIGKILL + ensure未実行 + ジョブ自体の長さ の組み合わせ

  • 最も効果的な防御はかっこいいインフラではなく、ジョブを短くすることである

キーポイント

1

sleep 0.5 × 6424件 ≈ 54分のジョブを想定

2

実行中にdevelopへのマージとデプロイが発生

3

ECSが旧コンテナにSIGTERMを送信

4

Sidekiqが:timeout(25秒)以内に終了を試みる → 間に合わない

5

stopTimeout超過 → ECSがSIGKILL

6

SIGKILLのためensure/rescueは実行されず → DBにstatus=1が残存

7

OSS Sidekiqのデフォルトfetcherはnon-reliableのため、ジョブ自体もキューに戻らない

8

ユーザーはリロードしても永遠に「実行中」の画面を見る

メリット

  • ジョブ分離はユーザー応答時間を短くする(本来のメリット)
  • 失敗復旧をretryで自動化できる
  • ジョブの長さに気を配るだけで大半のstuck事故は防げる

デメリット

  • OSSデフォルトのSidekiqはSIGKILL時にジョブを失う
  • ジョブが長いほどデプロイの度に死ぬ確率が非線形に上がる
  • SIGKILLはensure/rescueを完全にバイパスするため、コードレベルでの100%保証は不可能
  • reliable fetchの導入は有料、またはOSSのメンテナンスリスクあり

ユースケース

大量データ同期ジョブ CSVインポート/エクスポート レポート集計ジョブ 外部API大量呼出ジョブ 誤ったthrottling(sleep)の入ったジョブ