Claude Codeのshutdown-drain-secでorchestrator停止時にフック完了を待つ
orchestratorの--shutdown-drain-secは、SIGTERMでポーリングを止め、処理中のポーリングとspawnフックの完了を指定秒数まで待ちます。待つ範囲と、フックのタイムアウト・停止猶予との関係を解説します。
claude self-hosted-runner orchestratorに--shutdown-drain-secを付けると、SIGTERMを受けたオーケストレーターはポーリングを止め、処理中のポーリングと実行中のspawn-runnerフックが終わるまで指定秒数を上限に待ちます。v2.1.299で追加されたフラグです。ローリング更新のたびにスポーン要求が中途半端に切れる構成で効きます。
このフラグが待つ対象は2つだけ
changelogの記述は1行です。SIGTERMでポーリングを止め、進行中のポーリングと実行中のspawnフックに最大N秒の猶予を与える、という内容です。待つ対象を表にします。
| 対象 | SIGTERM後の扱い |
|---|---|
| 新しいポーリング | SIGTERM後の扱い止まる。以降は新しいスポーン要求を取りに行かない |
| 進行中のポーリング | SIGTERM後の扱い指定秒数を上限に完了を待つ |
| 実行中のspawnフック | SIGTERM後の扱い指定秒数を上限に完了を待つ |
| 起動済みのRunner | SIGTERM後の扱いこのフラグの対象外 |
最後の行は、役割分担から読み取れる整理です。spawn-runnerフックはKubernetesのJobやEC2インスタンスといった実行基盤にワークロードを投入するだけで、投入後のRunnerはオーケストレーターとは別のプロセスとして動きます。フックは非同期に投入して戻る契約なので、フックが終わってもRunnerの終了は待ちません。
起動コマンドと設定値
オンデマンドRunner構成の起動コマンドに、フラグを1つ足します。
claude self-hosted-runner orchestrator \
--environment-secret-file /etc/claude/environment-secret \
--hooks-dir /etc/claude/hooks \
--shutdown-drain-sec 65単位は秒です。既定値と上限は、changelogにもself-hosted environmentsのreferenceにある「Orchestrator CLI flags」の表にも記載がありません。同じ表には--hook-concurrency・--hook-timeout・--expected-spawn-seconds・--min-idle・--debug-dirが並んでいますが、--shutdown-drain-secは載っていないため、claude self-hosted-runner orchestrator --helpで手元のバージョンの表示を見てから使う手順が確実です。
65秒という値の出どころ
spawn-runnerフックは--hook-timeoutの既定60秒で打ち切られ、さらに5秒のkill猶予があります。フックが最長まで走る場合を丸ごと待つなら、60 + 5 = 65秒が一つの目安になります。上の例の65はこの足し算です。
値の置き方は運用で分かれます。
- フックが数秒で終わる構成なら、10〜20秒程度でも待ち残しは出にくい
- フックが外部APIの混雑で伸びやすい構成なら、
--hook-timeoutの上限に合わせる --hook-timeoutを伸ばしたときは、drainの秒数も合わせて見直す
--shutdown-drain-secを超えてフックが走っていた場合にその場で何が起きるかは、changelogにもdocsにも書かれていません。上限を超えたぶんは待たれない、という読み方が自然ですが、挙動として固定したい場合は手元で確かめてください。
停止タイムアウトより短くする
オーケストレーターを止める側にも猶予があります。KubernetesのterminationGracePeriodSeconds、Docker Composeのstop_grace_periodです。この猶予が切れるとSIGKILLが届くので、drainの秒数はそれより短く置く必要があります。
公式docsのShutdown timingはRunner向けの説明で、「SIGTERM後にセッションを片付ける時間をログに出すので、停止タイムアウトをそれ以上にする」と案内しています。Kubernetesの既定は30秒で、何も設定しないとRunnerの片付けが終わる前にPodが止まりうる、とも書かれています。オーケストレーターにも同じ構造が当てはまります。既定の30秒のままでは、65秒のdrainを置いても途中で切られます。
例として、次のような組み合わせになります(値は上の65秒を前提にした一例です)。
spec:
terminationGracePeriodSeconds: 90
containers:
- name: orchestrator
args:
- self-hosted-runner
- orchestrator
- --environment-secret-file=/etc/claude/environment-secret
- --hooks-dir=/etc/claude/hooks
- --shutdown-drain-sec=65drainの65秒に対して猶予を90秒にしているのは、drain完了後のプロセス終了に余裕を持たせるためです。余白の大きさは構成に依存します。
Docker Composeで動かしている場合は、同じ考え方でstop_grace_periodを伸ばします。docker compose stopやdownは、この猶予のあいだにコンテナが終わらなければSIGKILLを送ります。
services:
orchestrator:
stop_grace_period: 90s
command:
- self-hosted-runner
- orchestrator
- --environment-secret-file=/etc/claude/environment-secret
- --hooks-dir=/etc/claude/hooks
- --shutdown-drain-sec=65KubernetesでもComposeでも、見るのは「停止側の猶予 > drain秒数 > --hook-timeout」という並びです。どちらか一方だけ伸ばすと、もう一方が先に切れます。なお、commandの先頭にバイナリ名を書くか、イメージ側のentrypointに任せるかは、使うイメージの作りで変わります。上の2つの断片は引数部分の一例として読んでください。
ローリング更新でスポーンが切れる場面
オーケストレーターは状態を持たず、同じEnvironmentに2台以上のレプリカを立てられます。各スポーン要求はサーバー側でちょうど1つのレプリカが受け持ちます。ポーリング間隔も、レプリカが同時に取りに行かないよう4〜6秒の幅でゆらぐ実装です。
この構成でレプリカを入れ替えると、古いレプリカがフックを実行している最中にSIGTERMを受けることがあります。フラグが無いと、実行中のフックの完了を待たずに終わる可能性があります。この場合の後始末は、フックの契約が受け持ちます。
- フックは
CLAUDE_RUNNER_ORDER_IDで冪等にする。同じ要求の再配信でも、起動するRunnerは多くて1台 - ワークロード投入のリトライはフック側でしない。Runnerが登録されなければ、
--expected-spawn-secondsの経過後に新しいorder IDで再要求される - 終了コード1、または
--hook-timeout時点で実行中のフックは再試行可能な失敗として扱われる
つまりdrainを付けなくても要求は最終的に再配信されますが、再要求まではセッションが待たされます。--shutdown-drain-secは、その待ちを入れ替えのたびに起こさないための手当てです。
手元でdrainの動きを確かめる
秒数を本番に入れる前に、遅いフックで挙動を見る方法があります。spawn-runnerの代わりに、数十秒眠るだけのダミーフックを置きます。
mkdir -p /tmp/hooks
cat > /tmp/hooks/spawn-runner <<'HOOK'
#!/bin/sh
echo "spawn start: order=$CLAUDE_RUNNER_ORDER_ID" >&2
sleep 20
echo "spawn end" >&2
HOOK
chmod +x /tmp/hooks/spawn-runnerこのフックは実行基盤に何も投入しません。ログに開始と終了を出して20秒眠るだけです。テスト用のEnvironmentで--hooks-dir /tmp/hooks --shutdown-drain-sec 30を付けて起動し、スポーン要求が来た直後にkill -TERMを送ります。見るのは次の点です。
- フックの終了行(
spawn end)がオーケストレーターのログに出てから、プロセスが終わるか --shutdown-drain-sec 5に絞って同じことをしたとき、終了行が出る前にプロセスが終わるか- SIGTERM後に新しいスポーン要求を受け取っていないか
フックの標準出力と標準エラーはオーケストレーターのログに、認証情報をマスクした形で出ます。本番のフックに入れる前に、ここで秒数の効きを見ておけます。
Prometheusメトリクスで見る
オーケストレーターは、/healthzと同じポートの/metricsにPrometheus形式のメトリクスを出します。ヘルスポートは--health-portで変えられ、既定は8080です。drainの確認に使えるのは次の系列です。
| メトリクス | 見えるもの |
|---|---|
claude_code_self_hosted_orchestrator_spawn_hooks_total{result} | 見えるものspawnフックの結果。ok・retryable・non_retryableの累計 |
claude_code_self_hosted_orchestrator_spawn_hook_duration_seconds | 見えるものフック実行時間のヒストグラム |
claude_code_self_hosted_orchestrator_connected | 見えるもの直近のポーリングが成功していれば1、失敗すると0 |
claude_code_self_hosted_orchestrator_last_poll_age_seconds | 見えるもの最後のポーリング試行からの秒数 |
ダミーフックで試すなら、spawn_hook_duration_secondsに20秒前後の観測が入るかを、SIGTERMの前後で比べます。spawn_hooks_totalのretryableが増えていれば、フックが完了を待たれずに切られた可能性があります。ただしSIGTERM後にこれらの値がどう推移するかは、docsに書かれていません。プロセスが終わればメトリクスの取得自体ができなくなるため、終了直前の値は、フックが書くログと突き合わせて判断するのが確実です。
last_poll_age_secondsは、オーケストレーターのポーリングがフック実行を待つ作りなので、アラートの閾値を--hook-timeoutに余白を足した値(既定のままなら90秒前後)に置く、とreferenceにあります。drain中のフックが長引いているだけの場面を、ポーリング停止と取り違えないための目安です。
待機Runnerの先回り起動との関係
--min-idleを設定していると、オーケストレーターはセッションが来る前から待機用Runnerをスポーンします。この要求もフックを通ります。SIGTERM後はポーリング自体が止まるため、drain中に新しい先回りの要求は来ません。すでに走っている先回りのフックは、他のフックと同じ扱いで待たれます。
余った待機Runnerは、Runner側の--exit-if-unused-minで自分から消えます。この値はオーケストレーターのdrainとは独立した設定です。
Runner側のdrain系フラグとの違い
名前が近いフラグがRunner側にもあります。止める対象が違うので、混同しないでください。
| フラグ | 付ける先 | 待つもの |
|---|---|---|
--shutdown-drain-sec | 付ける先orchestrator | 待つもの進行中のポーリングと実行中のspawnフック |
--drain-wait-sec | 付ける先runner | 待つもの各セッションで実行中のターンとバックグラウンドタスク |
--defer-shutdown-max-min | 付ける先runner | 待つもの最初のシグナル後もセッションを提供し続け、N分後に解放する |
--drain-grace-sec | 付ける先runner | 待つものセッション終了後、次の仕事を待ってRunnerを残す秒数 |
Runner側のdrainは、ターンの完了待ち、プロセスツリーの終了、post-sessionフックの実行という3段階で進みます。既定の--drain-wait-secは0で、ローリング再起動では実行中のターンが切られます。ターンを守りたい場合は、オーケストレーターではなくRunnerの起動引数を、フックの中で渡すことになります。
Runnerではなく単発のclaude -pがSIGTERMを受けたときの挙動は、claude -pをSIGTERMで止めるとどうなるに書いています。
つまずきやすい点
- v2.1.299より前では使えない: 古いバージョンのオーケストレーターに付けると、未知のフラグとして扱われる可能性があります。イメージのバージョンを固定している構成では、先に更新が要ります
- ログを読む側がいないとき: v2.1.300で、出力を誰も読まない間にRunnerとオーケストレーターが固まり、ポーリングも
/healthzもSIGTERMでの停止も効かなくなる問題が修正されました。drainが効かない場合は、バージョンも確認してください
オンデマンドRunner構成の全体像はオンデマンドRunnerの起動手順に、固定Runner群との選び分けはself-hosted environmentsとself-hosted-runnerの違いにあります。このフラグが入ったリリースの全体はClaude Code v2.1.299にまとめています。
まとめ
--shutdown-drain-secが守るのは、オーケストレーターが抱えているポーリングとspawnフックの2つで、起動済みのRunnerのセッションは守りません。値は--hook-timeoutと5秒のkill猶予を足した65秒が一つの目安で、停止タイムアウトはそれより長く取ります。ターンやバックグラウンドタスクを守るのはRunner側の--drain-wait-secです。