Google Cloud Runで新リビジョンをデプロイしても、旧リビジョンのSidekiqジョブを中断したくない

Posted on

いろいろ事情があり、Cloud Runのリビジョンで動いているSidekiqの「実行中ジョブの完了を待ってから」旧リビジョンを止めたい、という欲望がありました。
アイデアを試してみたので結果を書きます。

問題

CloudRunの新しいリビジョンのデプロイによって Sidekiq プロセスがSIGTERMを受け取ると、タイムアウトまでに完了しなかった実行中ジョブは Redis に戻され、デプロイ後に再実行されます。 手動でSidekiqにSIGSTOP -> SIGTERMを行った後新しいリビジョンをデプロイすればこの挙動を避けられますが、それも「長期間のジョブが完了しないとデプロイできない」という問題が残ります。 Sidekiq Enterprise には、この問題を避けるための Ent Rolling Restarts という仕組みがあります。Ent Rolling Restarts では、旧プロセスは新規ジョブの取得を止めつつ実行中ジョブが完了するまで動き続け、その間に新プロセスが起動して新規ジョブの処理を引き継ぎます。
ただしこの機能は、einhorn で Sidekiq を起動・管理し、同一ホスト上に新コードをデプロイした後にプロセスを管理する運用が前提です。そのため、コンテナを新しい Docker イメージで置き換える方式のデプロイでは、Ent Rolling Restarts をそのまま適用して解決するのは難しくなります。

アイデア

悩んでいたら @kawaida さんにアイデアを教えてもらいました。

  1. Sidekiq を動かすCloud Runサービスはリビジョンにトラフィックタグを付けてデプロイする
  2. Sidekiq プロセスに Cloud Run のリビジョン名(K_REVISION)を tag として付ける
  3. 自分より古いリビジョンの Sidekiq プロセスを検査する
  4. 旧リビジョン側のプロセスが quiet(新規取得停止)かつ busy=0 になったら、そのリビジョンのタグを外す
  5. タグが外れ、トラフィック 0 の旧リビジョンはスケールダウンし、結果として Sidekiq も停止する(余計な課金を避ける)

手順の解説

1. Sidekiq を動かすCloud Runサービスはリビジョンにトラフィックタグを付けてデプロイする

通常、Cloud Run では新リビジョンへトラフィックを切り替えると旧リビジョンはトラフィック 0 になり、スケールダウンのタイミングでインスタンスが終了します。インスタンス終了時には SIGTERM が送られ、短い猶予(10秒)の後に強制終了されます。その結果、実行中ジョブが中断されると再実行になり、ユーザー体験やコスト面でつらくなります。
そこで、旧リビジョンをアプリケーション側で意図的に止めるための目印としてリビジョンにタグを付けてデプロイします。

ここで使うのは“リビジョン単位の min-instances”で、Cloud Run の traffic tag が付いている限り、そのリビジョンはトラフィック 0 でも min-instances が効いてインスタンスが維持されます

gcloud run deploy practice-worker \
  --tag=gen1 \
  --min-instances=1

2. Sidekiq のプロセスに Cloud Run のリビジョン名を tag として付ける

後の手順でプロセスを判別できるように、Sidekiq のプロセスに Cloud Run のリビジョン名を tag として付けます。
少しややこしいですが、SidekiqプロセスタグにはCloudRunのリビジョン名をつけています。先程記述したCloudRunのトラフィックリビジョンタグgen1 ではありません。

config[:tag] = ENV["K_REVISION"]

3〜4. 古いリビジョンのSidekiqを検査し、条件を満たしたらタグを外す

以下のようなクラスを、一定間隔で実行するジョブとしてスケジューリングしておきます。
ログの出力とかは省いてます。

class OldRevisionCleaner
  def initialize
    @my_revision = ENV.fetch("K_REVISION")
    @cloud_run = CloudRunClient.new
  end

  def run
    # タグ付きリビジョンだけを取得
    tagged = @cloud_run.tagged_revisions

    # 自分のリビジョン情報だけを個別取得
    my_info = @cloud_run.get_revision(@my_revision)

    process_set = Sidekiq::ProcessSet.new

    tagged.each do |tag_info|
      rev_name = tag_info[:revision]
      next if rev_name == @my_revision

      rev_info = @cloud_run.get_revision(rev_name)

      # 自分より新しいリビジョンは触らない(連続デプロイ対策)
      next if rev_info[:create_time] >= my_info[:create_time]

      # 該当リビジョンの Sidekiq プロセスを拾う(複数ある可能性に備える)
      procs = process_set.select { |p| p["tag"] == rev_name }
      next if procs.empty?

      all_quiet = procs.all? { |p| p["quiet"] }
      total_busy = procs.inject(0) { |acc, p| acc + p["busy"].to_i }

      # quiet かつ busy=0 になったら、そのリビジョンのタグを外す
      next unless all_quiet && total_busy.zero?

      @cloud_run.remove_tag!(tag_info[:tag])
    end
  end
end

5. タグを外し、トラフィック0の旧リビジョンをスケールダウンさせる

タグを外して、トラフィック0の旧リビジョンがスケールダウンすれば、結果としてSidekiqコンテナも消えます。
余計な課金を避けられます。

実行結果とサンプルレポジトリ

以下の流れで「旧リビジョンの実行中ジョブを完走させた後に、安全に旧リビジョンを落とす」ことができました。

  1. 旧リビジョンで 180 秒ジョブを開始
  2. 旧リビジョンにSIGSTOP を送りquietにする、新規ジョブを取らなくなる(実行中ジョブは継続)
  3. デプロイにより新リビジョンのインスタンスが起動する
  4. 新リビジョン側で CleanupOldRevisionsJob が 30 秒ごとに旧リビジョンを監視する
    • quiet=true, busy=1 の間は「まだ停止できません」
  5. 旧リビジョンのジョブが完了して busy=0 になる
    • 新リビジョン側が quiet=true && busy=0 を検知
    • 旧リビジョンのタグを削除 → 旧インスタンスが SIGTERM で自然停止

実際のログ(抜粋)は以下です。

# 1) 旧リビジョンでジョブ開始
INFO  2026-02-07T09:50:53.677Z(旧リビジョン) ... class=LongRunningJob: start
INFO  2026-02-07T09:50:53.695Z(旧リビジョン) ... 🟢 Job started ... duration=180s
INFO  2026-02-07T09:51:03.701Z(旧リビジョン) ... ⏳ Progress: 10/180

# 2) SIGSTOPを送り旧リビジョンを quiet 化(新規取得停止)
INFO  2026-02-07T09:51:07.264Z(旧リビジョン) ... Received TSTP, no longer accepting new work
INFO  2026-02-07T09:51:07.264Z(旧リビジョン) ... Scheduler exiting...

# 3) 新リビジョン起動(デプロイ)
Starting new instance. Reason: DEPLOYMENT_ROLLOUT ...
=== docker-entrypoint started ===
Starting Sidekiq worker...
INFO  2026-02-07T09:51:39.596Z ... Booted Rails ...
INFO  2026-02-07T09:51:39.838Z ... Scheduling cleanup_old_revisions {"every" => "30s", ...}

# 4) 新リビジョンのCleanupOldRevisionsJobで旧リビジョン監視(まだ busy=1 なので止めない)
INFO  2026-02-07T09:52:03.745Z(旧リビジョン) ⏳ Progress: 70/180
INFO  2026-02-07T09:52:09.923Z ... start
INFO  2026-02-07T09:52:12.306Z ... 自分: practice-worker-00004-g68 (2026-02-07T09:51:20+00:00)
INFO  2026-02-07T09:52:12.376Z ... [OldRevisionCleaner] practice-worker-00003-xsc: quiet=true, busy=1
INFO  2026-02-07T09:52:12.376Z ... [OldRevisionCleaner] practice-worker-00003-xsc: まだ停止できません(quiet=true, busy=1)

# 5) 旧リビジョンのジョブ完了 → busy=0 を検知してタグ削除
INFO  2026-02-07T09:53:53.822Z(旧リビジョン) ... ✅ Job completed ...
INFO  2026-02-07T09:54:09.991Z ... start
INFO  2026-02-07T09:54:10.159Z ... 自分: practice-worker-00004-g68 (2026-02-07T09:51:20+00:00)
INFO  2026-02-07T09:54:10.207Z ... practice-worker-00003-xsc: quiet=true, busy=0
INFO  2026-02-07T09:54:10.208Z ... 🗑️ practice-worker-00003-xsc のタグ 'gen1' を削除します
INFO  2026-02-07T09:54:10.785Z ... タグ 'gen1' を削除しました

# 旧インスタンスが停止へ
(旧リビジョン) Shutting down user disabled instance

# 過去のタグつきリビジョンがなければスキップ
INFO  2026-02-07T09:54:40.077Z ... start
INFO  2026-02-07T09:54:40.157Z ... タグ付きリビジョンなし → スキップ

サンプルレポジトリ-> https://github.com/QWYNG/dont_kill_busy_sidekiq

ほとんどLLMに書かせたのでコメントが多めです。

参考

Cloud Run 上のコンテナのライフサイクル | Google Cloud 公式ブログ

Graceful shutdowns on Cloud Run: Deep dive | Google Cloud Blog

Set minimum instances for services  |  Cloud Run  |  Google Cloud Documentation

おまけ

LLMにハンズオンを考えてもらうと気分いい

アイデアを仕事に取り入れたい時、「この理論は完璧! PRドーン!」では流石に不安です。
そこで、 やりたいことを書いて「こういうことを個人で試したいんだけど、ハンズオン作って」とLLMに任せると、コマンド付きでハンズオンを作ってくれて便利。  

ハンズオン記事のスクショ