はじめに
メディフォンでエンジニアをしている竹内です。クラウド型健康管理システム「mediment」の開発に携わっています。
以前の記事「mediment」のCloud Runへの移行:コンテナ化からCI/CDパイプラインの構築で、medimentのインフラをGCPのVM環境からCloud Runへ移し、CI/CDを整備した話を公開しました。
その記事の最後で、私たちは1つだけ「宿題」を残していました。ステージングまでは自動化したが、本番へのデプロイだけは自動化を見送り、手動運用にとどめている、という点です。理由として「Celery Workerのタイムアウト制約」を挙げていました。
この記事はその続きです。「なぜ本番だけ手動なのか」「代わりに何を作ったのか」、そして「この方法が適している構成・不向きな構成」について書きます。
結論から言うと、私たちは本番デプロイ用に実行中のCeleryタスクを安全に消化しきってからデプロイするPythonスクリプトを作り、それを手元で実行するという半自動の運用に落ち着かせました。自動化の潮流に逆らうように見えるかもしれませんが、これは現状のシステム構成において合理的な選択だと考えています。その理由も含めて説明します。
現状の構成
簡単なおさらいとして、Cloud Runに移行した現状のmedimentの構成の概要を再掲します。
この図はWebリクエストの経路を示したものですが、Celery Workerも同じくCloud Runのコンテナとして動作しています。そのためデプロイは「コンテナの置き換え」になり、負荷に応じてインスタンスが「自動で増減(オートスケーリング)」します。この2点が、後述するタスクの強制終了問題とスケールアウト対策の前提になります。
なぜ本番デプロイだけ手動なのか
Cloud Runのデプロイは「コンテナの置き換え」である
Cloud Runで新しいリビジョンをデプロイするということは、稼働中のコンテナを新しいコンテナに置き換えるということです。Web(同期リクエスト処理)だけであれば、これは非常に相性が良い仕組みです。処理は数百ミリ秒〜数秒で終わるので、リクエストを捌ききってから古いインスタンスを落とせば、ユーザーへの影響はほぼありません。
問題はCelery Worker(非同期タスク処理)です。
Workerのコンテナを置き換えるとき、稼働中のインスタンスにはプラットフォームから終了シグナル(SIGTERM)が送られ、一定の猶予時間のあと強制終了(SIGKILL)されます。もしこの猶予時間内に実行中のタスクが終わらなければ、タスクは途中で破棄されます。
SIGTERMとCeleryのwarm shutdown(環境依存の補足)
CeleryのWorkerは、SIGTERMを受け取ると「新規タスクの受付を止め、実行中のタスクは完走させてから終了する」という「warm shutdown」を行います。理屈のうえでは、これだけでグレースフルにシャットダウンできそうに見えます。
ただし、ここは環境依存の落とし穴が多い領域です。
- SIGTERMを受けた挙動(warm / cold shutdown)は、Celeryのバージョンや実行プール(prefork / gevent / solo など)によって差があります。
- Cloud Runのようなマネージド環境では、SIGTERMからSIGKILLまでの猶予時間が短く(既定では秒オーダー)、しかも上限があります。「warm shutdown」が「タスクの完走を待つ」といっても、プラットフォーム側の猶予時間を超えれば結局SIGKILLで強制終了します。
このあたりの正確な挙動は、Celery 公式ドキュメント(Worker: Stopping the worker)と、利用しているプラットフォームの終了シグナル仕様を必ず参照してください。「SIGTERM を送れば勝手に安全に止まる」という前提は、環境によっては成り立ちません。
mediment固有の事情:ロストが許されない × 数時間タスク
medimentでは、この問題がさらに厳しくなる2つの事情があります。
1. タスクのロストが許されない構成である。一部のタスクは「acks_late=False」(Worker がタスクを受け取った時点でackする)で動いており、途中で破棄されたタスクは再実行されずそのまま失われます。健康診断や面談に関わるデータ処理でこれが起きると、静かにデータ不整合が生まれ、サービスとして致命的な問題になりえます。
2. 数時間かかる重いタスクが存在する。大量データの集計・生成系のタスクには、数時間走り続けるものがあります。解決すべき課題ではありますが、現状はプラットフォームの猶予時間(秒〜分オーダー)で待てる性質ではありません。
つまり、「SIGTERM の猶予時間に任せて止める」というアプローチは、medimentでは原理的に成立しないわけです。だからこそ、デプロイの「前段」で、能動的にタスクを消化しきる仕組みが必要になりました。
作ったもの:ドレイン付き停止スクリプト
作ったのは、本番デプロイの担当者が一時的に権限を持った状態で手元で実行する、対話型のPythonスクリプトです。おおまかな流れは次のとおりです(セキュリティ上、インフラ構成や構成依存の内容は割愛します)。
1. リリースブランチ名を受け取り、対応するコミット/コンテナイメージがpush済みかを確認する
2. ブランチの鮮度(先端コミットの日付)をチェックし、古すぎるブランチの取り違えを警告する
3. デプロイ先リソースへの参照可否をドレイン前に事前チェックする(止めてから権限不足に気づく事故を防ぐ)
4. Workerに受付停止を指示し、実行中タスクが空になるまで待つ(ここが本題)
5. タスクが空になったのを確認してからデプロイを実行する
6. 途中で中断された場合は、進行状況に応じて安全な状態へ戻す
このうち、本記事で共有したいのは4番「ドレイン(消化待ち)」の設計です。ここに一番ノウハウが詰まっています。
コア:cancel_consumer で受付を止め、消化を待つ
SIGTERMに頼らず、Celeryのリモートコントロールを使って能動的に受付を止めます。「cancel_consumer」をWorkerにブロードキャストすると、Workerは生きたまま「キューからの新規fetch」だけを止めます。実行中のタスク(in-flight)はそのまま完走します。これが実質的なwarm shutdown相当の動きになります。
補足:なぜブローカー側で止めないのか
「ワーカーに指示を配るより、ブローカー側で配信を止めるほうが確実では?」と考える方もいるかもしれません。ブローカーによってはそれが可能です。たとえばRabbitMQ(AMQP)のように、ブローカーがメッセージをconsumerへpushし、consumerを登録・管理する方式なら、キューやconsumer単位で配信を止める中央集権的な制御の余地があります。
ただmedimentのブローカーはRedisです。Redisはキューをリストとして持つだけの受動的なデータストアで、「どのconsumerが繋がっているか」を管理したり、配信を押しとどめたりする仕組みを持ちません。ワーカー側が自発的にリストをpull(BRPOP)してタスクを取りに来る方式のため、止める指示はワーカーに届けるしかなく、「cancel_consumer」のブロードキャストが実質唯一のレバーになります。本記事のドレインがワーカー側の制御に寄っているのは、この制約が理由です。
そのうえで「inspect active」で実行中タスク数をポーリングし、0になるのを待ちます。ロジックを簡略化すると、こんな形です(コマンドの詳細やインフラ名は伏せています)。
# python
# --- 設定値(値の根拠は本文参照) ---
DRAIN_POLL_INTERVAL = 15 # 秒。ポーリング間隔
DRAIN_MAX_WAIT = 1200 # 秒(20分)。消化待ちの上限
DRAIN_REQUIRED_EMPTY = 2 # 連続で「空」を確認する回数
# 連続で「空」を確認できたらドレイン完了とみなす
elapsed = 0
empty_streak = 0
drained = False
while elapsed < DRAIN_MAX_WAIT:
broadcast_cancel_consumer() # ① 毎回、受付停止を再送する
n = count_active_tasks() # inspect active で実行中タスク数を取得
if n == 0:
empty_streak += 1
if empty_streak >= DRAIN_REQUIRED_EMPTY: # ② 連続で空を確認
drained = True
break
elif n is None: # ③ 取得失敗は「不明」扱い(空とみなさない)
empty_streak = 0
else:
empty_streak = 0 # 1件でも残っていたらカウントリセット
time.sleep(DRAIN_POLL_INTERVAL)
elapsed += DRAIN_POLL_INTERVAL
if not drained:
die("消化待ちがタイムアウト。実行中タスクが残っているため中止します。")
# ← ここで強制killはしない。中断して人間に委ねる一見単純ですが、この短いループには3つの工夫が埋め込まれています。
① 受付停止をポーリングのたびに再送する
Cloud RunのWorkerは負荷に応じてインスタンスが増減(スケールアウト)します。ここが厄介で、「cancel_consumer」は「そのとき生きているWorker」にしか届きません。ドレイン中に新しいWorkerインスタンスが立ち上がると、そのインスタンスは受付停止の指示を受けていないため、平然とキューからfetchを始めてしまいます。せっかく空にしたのに、また埋まるわけです。
そこで、毎回のポーリングで「cancel_consumer」を再ブロードキャストし、「新しく現れたインスタンスにもすぐ受付を止めさせる」ようにしています。「cancel_consumer」は冪等なので、既に止まっているインスタンスに何度送っても副作用はありません。「止め続ける」ことで、初めてスケールアウト環境でドレインが成立します。
② 「連続で2回空」を要求する(スケーリング対策の肝)
ここが今回いちばん伝えたい設計です。「実行中タスクが0を1回確認できた」だけでデプロイに進むと、取りこぼしが発生します。
理由はスケールアウトのタイミングにあります。「inspect active」で「0件」を観測した瞬間と、①で受付停止を送るタイミングの間には、どうしても隙間があります。ちょうどその隙間で、直前にスケールアウトが始まっていた新インスタンスがキューからタスクをfetchすると、「0を見た直後に、また1件動き出す」という状態が起こり得ます。1回きりの確認では、この新インスタンスの起動を検知できません。
対策として、間隔(15秒)を空けて連続2回、続けて0を確認できたときだけドレイン完了とみなします。1回でも0以外を観測したら「empty_streak」を0にリセットし、最初からやり直します。「0を見て→15秒待つ間に①で受付を再送し→もう一度見てもまだ0」であれば、その15秒の間に新インスタンスが現れてfetchする余地がなかったことの傍証になります。
なぜ「2回」で「15秒間隔」なのか。これはCloud Runのスケールアウト遅延(新インスタンスが立ち上がってタスクを掴むまでのラグ)より十分に長い間隔を取れば、その1サイクルの中でスケールアウトを捕捉できる、という考え方です。過剰に回数を増やしても待ち時間が延びるだけなので、「遅延を1回のサイクルで確実にまたげる間隔 × 2回」を最小構成として採用しています。連続確認の回数と間隔は、利用環境のスケールアウト遅延に合わせて調整すべきパラメータです。
なお、スケールアウトの取りこぼしを防ぐ手段は、この「連続確認」以外にもいくつか検討しました。たとえば、稼働中のWorkerインスタンス数を直接数えて「これ以上増えていないこと」を条件に加える案や、デプロイ中はオートスケーリングの上限を絞って新規インスタンスの立ち上がり自体を抑える案です。
ただ前者は、インスタンス数の取得経路(メトリクスや inspect)にも結局はラグがあり、かつ「今生きているものしか見えない」というactive件数と同じ盲点を抱えているため、判定材料が増えるだけで本質的な隙間は埋まりません。後者は、消化しきる前にインスタンスを絞るとin-flightタスクの継続を脅かすおそれがあり、制御も煩雑になります。
結局、私たちが本当に知りたいのは「実行中タスクが0か」そのものであって、インスタンス数はその代理指標にすぎません。であれば代理指標を増やすより、知りたい値(active=0)を、受付停止の再送とセットで時間差を空けて2回直接確認するほうが、シンプルかつ確実だと判断しました。少ない仕組みで狙った1点を突く、という設計方針です。
③ タイムアウトしても絶対にkillしない
消化待ちの上限は20分(DRAIN_MAX_WAIT = 1200)に設定していますが、この時間を超えても残っているタスクを強制終了することは絶対にしません。前述のとおりロストが許されないためです。タイムアウト時はデプロイを中止し、受付を再開して元の状態に戻し、あとは人間の判断に委ねます。
20分という値は「通常の運用でここまで待って空にならないなら、いま重いタスクが走っている=デプロイすべきタイミングではない」と人間が判断するための線引きです。実際 medimentには数時間走るタスクがあるため、これは「待ちきる」ための上限ではなく、「今は見送る」と判断させるためのしきい値として機能しています。手元での実行なら、状況を見てタスクの完了を待ってから再実行すればよいだけです。ここでも一貫して「迷ったら安全側(=中止)に倒す」を徹底しています。
中断時に安全な状態へ戻す
もう1つのこだわりが、処理が途中で止まったとき(Ctrl-C や途中の失敗)の復旧です。デプロイは複数のステップに分かれるため、「どこまで進んだか」によって取るべき対応が変わります。先ほどの抜粋では割愛していますが、スクリプト内で「try/finally」を活用することで進行状況に応じて対応を変えています。
- 受付を止める前に中断 → 何も戻す必要はない
- 受付は止めたが、まだデプロイに着手していない → 受付を再開して元に戻す
- 新Workerのデプロイまで完了した → 新Workerが消費を再開しているので何もしない
このように、中断された地点に応じて「戻すべきか/戻すべきでないか」を判定し、下手に触って状況を悪化させないようにしています。特に「止めたまま放置される」状態を避けることを最優先にしています。
この方法が適している構成・不向きな構成
この手のドレイン処理は、万能ではありません。自分たちの構成に当てはめる前に、前提を確認してください。
適している構成
この方法が意味を持つのは、次の条件がそろっているときです。
- 非同期タスク(Celery等)が、ロストを許されない形で動いている。特に「acks_late=False」、あるいは失敗時の再実行が保証されていない構成。もし「acks_late=True」かつ冪等で、途中で破棄されても安全に再実行されるなら、ここまで丁寧にドレインする必要は薄れます。
- デプロイがWorkerプロセスの「置き換え」を伴う。Cloud Runのようなコンテナ差し替え型が典型です。
- プラットフォームの終了猶予(SIGTERM → SIGKILL の間)が、タスクの実行時間に対して短い。猶予が十分長く、「warm shutdown」だけで完走を待てるなら、そもそもスクリプトは不要です。
- リモートコントロール(cancel_consumer / inspect)が使えるブローカー構成である。
特に効果的なケース
- タスクの実行時間が長い・かつバラつく(数分〜数時間)。猶予時間で線引きできないケースほど、能動的ドレインの価値が出ます。
- デプロイの頻度がそこまで高くなく、1回1回を丁寧に成功させたい。
- 「デプロイ担当=障害時のトラブルシューター」で、実行者が状況を見て判断できる運用。
不向きな構成
- タスクがすべて短時間(数秒)で終わる → 猶予時間内にwarm shutdownが完走するので不要。
- タスクが冪等で再実行安全 → 破棄しても問題ないので、標準のローリングアップデートで十分。
- 重いタスク専用のキュー/Worker がデプロイ対象から分離されている → デプロイ時に止めるのは軽いキューだけになり、ドレインは数分で終わる(=もっと自動化しやすくなる)。
最後の点は重要で、裏を返すと「キューを分離すれば、この重厚なスクリプトは不要に近づく」ということでもあります。私たちが今後CD化を目指すなら、まず着手すべきはここだと考えています。
補足:ドレインの守備範囲について
ここで扱ったドレインが守るのは「実行中(in-flight)のタスク」だけです。 「cancel_consumer」は消費を止めますが生成は止めないため、デプロイ中に積まれたタスクは新ワーカーが拾うことになります。リリースでタスクの引数や意味を変える場合に「旧コードが積んだタスクを新ワーカーが処理してしまう」不整合はドレインの管轄外です。これはDBマイグレーションの後方互換と同じく、アプリ側で担保すべき別問題として切り分けています。
その他、詳細は割愛しますが現場ならではの課題も残っており、今の半自動の運用は、これらの制約の中での現実解だと考えています。自動化できる土台がないうちに自動化しても、「自動で事故る」「自動で詰まる」システムができるだけです。まず土台を整える、というのが私たちの立場です。
まとめ
- Cloud Run のようなコンテナ置き換え型のデプロイはWebとは相性が良いが、ロストが許されない非同期タスクとは相性が悪い。
- 「SIGTERM」の挙動やプラットフォームの終了猶予は環境依存が大きい。「送れば安全に止まる」と決めつけず、公式ドキュメントで挙動を確認すること。
- medimentでは「cancel_consumer」で能動的に受付を止め、「連続で空を確認」してからデプロイし、タイムアウトしてもkillしないドレインスクリプトで安全性を担保している。
- この方法が適切かどうかは前提次第。キューを最適化すれば不要に近づく。CD化の前に返すべき技術的負債がある。
medimentでは、こうした「教科書どおりにはいかない現場の制約」と向き合いながら、健康管理プラットフォームを支えるインフラ・バックエンドを一緒に育ててくれる仲間を募集しています。興味を持っていただけた方は、ぜひ採用情報をご覧ください。