Pod を消しただけなのに、なぜ 502 が数件だけ出るのか?
kubectl rollout や CI/CD 経由のデプロイのあと、ダッシュボードのエラー率グラフに一瞬だけ小さな山ができるのを見たことがあるはずだ。件数は数件から十数件、時間にして数秒。アプリのコードにもロードバランサーの設定にも、それらしいエラーは見当たらない。この数秒のズレは実装のバグではなく、Kubernetes の Pod 終了処理そのものに組み込まれた設計上の競合状態が引き起こしているものだ。
- Pod を削除すると「エンドポイントからの除去」と「アプリへの SIGTERM 送信」という 2 つの処理が同時並行で始まり、どちらが先に終わるかの保証がない
- kube-proxy や Ingress コントローラーへの除去情報の伝播にはラグがあり、そのわずかな間に新しいリクエストが終了処理中の Pod へ送られてしまう
terminationGracePeriodSecondsの既定値は 30 秒で、preStop フックで SIGTERM 送信を数秒遅らせて伝播ラグを吸収するのが定石の対策だ
Pod 削除は「エンドポイント除去」と「シグナル送信」を同時に走らせる
kubectl delete pod や Deployment のロールアウトで Pod が削除対象になると、API サーバーはその Pod オブジェクトに deletionTimestamp を刻み、Pod は Terminating 状態になる。ここから 2 本の処理が同時に走り始める。1 本目は制御プレーン側の処理で、Pod を Service の EndpointSlice(どの Pod にトラフィックを転送するかを保持する API オブジェクト)から除外し、それを watch している kube-proxy や Ingress コントローラーに変更を伝える。2 本目は Pod が乗っているノードの kubelet の処理で、preStop フック(設定されていれば)を実行したあと、コンテナの PID 1 に SIGTERM を送る。
Kubernetes の公式ドキュメントは、この 2 本の処理の間に順序の保証はないと明記している。エンドポイント除去の通知が kube-proxy や Ingress に届いて実際のルーティングが更新されるより先に、kubelet 側の SIGTERM 送信と後続の停止処理が終わってしまうことがある。つまり「もうトラフィックを流していないはず」の Pod へ、外側からはまだリクエストが飛んでくる時間帯が存在する。
kubectl delete pod 要求
▼
deletionTimestamp 付与
(Pod は Terminating に)
┌──────┴───────┐
▼ ▼
制御プレーン kubelet:
Endpoint除去を preStop 実行後
kube-proxy/Ingress SIGTERM 送信
へ伝播 (ラグあり) │
│ ▼
│ 猶予内(既定30s)に
│ 終了しなければ
│ SIGKILL で強制終了
└────────┬────────┘
同時並行 = 順序保証なし
SIGTERM を受けてから強制終了までの猶予は、既定 30 秒
kubelet が送る SIGTERM を受け取ったアプリケーションプロセスには、後片付けをして自発的に終了するための猶予期間が与えられる。この長さは Pod の spec.terminationGracePeriodSeconds で決まり、既定値は 30 秒だ。設定しなければ、SIGTERM から 30 秒経ってもプロセスが終了していない場合、kubelet は SIGKILL を送って強制終了させる。SIGKILL はプロセスに後片付けの機会を一切与えないため、処理中だったリクエストのコネクションはその場で切断される。
preStop フックがある場合、それは SIGTERM の前に実行される。ここで注意したいのは、preStop の実行時間は terminationGracePeriodSeconds の 30 秒に上乗せされるのではなく、その 30 秒の予算の中に含まれるという点だ。preStop に 25 秒かかる sleep を書けば、残り 5 秒しかアプリ本体の終了処理に使えない。
閉店の張り紙を出すタイミングと、実際にシャッターを下ろすタイミングが別々の店を考えるとよい。本部が「この店は閉店します」とチェーン全体の店舗検索アプリへの反映を依頼するのと、店員がレジを締めてシャッターを下ろすのは、まったく別の担当者が別々に進める作業だ。反映が済むまでの間は、検索アプリを見た客がまだ「営業中」だと思って来店してしまう——Kubernetes のエンドポイント除去と SIGTERM 送信も、同じ理由で足並みが揃わないことがある。
伝播のラグが、なぜ数件だけの 502 に直結するのか
この記事の疑問「なぜデプロイのたびに 502 が数件だけ出るのか」に答えると、原因の多くはこのラグの窓に着弾したリクエストだ。EndpointSlice の変更が kube-proxy のルーティングルールや Ingress コントローラー・クラウドのロードバランサーの設定に反映されるまでには、watch 通知の伝達やコントローラー自身の再計算処理を挟むぶん、ゼロではない時間がかかる。この間に来た新規リクエストは、まだ古いルーティング情報に従って、SIGTERM 後・SIGKILL 前の(あるいはすでに消えた)Pod へ送られてしまう。
ここまで読むと「じゃあ伝播が終わるまで待てば解決する」と単純に思うかもしれない。しかし実際にその待ち時間を作るのはアプリでも Kubernetes 本体でもなく、次に説明する preStop フックというワンクッションだ。
定石の対策: preStop で SIGTERM 送信を数秒遅らせる
実務で最もよく使われる対策は、preStop フックに sleep を仕込んで SIGTERM の到達自体を遅らせることだ。
spec:
terminationGracePeriodSeconds: 45
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
sleep 5 を挟むと、Pod が Terminating になってから実際に SIGTERM が届くまで 5 秒の猶予ができ、その間にエンドポイント除去がクラスタ内に伝播する時間を稼げる。同時に terminationGracePeriodSeconds を既定の 30 秒より長い 45 秒に伸ばしているのは、preStop の 5 秒とアプリ本体の終了処理の時間を合わせても 30 秒に収まらない場合、SIGKILL が先に発動してしまうのを避けるためだ。伸ばさずに sleep だけ足すと、preStop の待ち時間がそのままアプリの終了処理時間を圧迫する。
ただし sleep が効くのは、待っている間もアプリがリクエストを普通に受け付け続けている場合に限る。SIGTERM を受けた瞬間にリスニングソケットを閉じる実装になっていると、preStop の sleep 中であってもその Pod への新規リクエストはやはり失敗する。preStop はあくまで「除去の伝播を待つための時間稼ぎ」であって、アプリ側のシグナルハンドリングを代替するものではない。
Datadog や New Relic のようなオブザーバビリティ基盤では、デプロイイベントのマーカーがタイムライン上に打たれ、その直後の数秒〜十数秒だけエラー率がわずかに跳ねているのを見つけることがある。厄介なのは、原因の Pod はすでに SIGKILL で強制終了済みでログが残っていないことが多く、アプリ側のスタックトレースを追っても手がかりが出てこない点だ。この場合、疑うべきはアプリのバグではなく、preStop の有無や terminationGracePeriodSeconds の設定である。
SIGTERM を無視するプロセスが、既定の猶予期間ぶんだけ生き延びてから SIGKILL される様子を、実際の待ち時間として確認する。
kind create cluster --name shutdown-demo 2>/dev/null || true kubectl run stubborn --image=busybox --restart=Never \ --command -- sh -c "trap '' TERM; sleep 3600" kubectl wait --for=condition=Ready pod/stubborn --timeout=60s time kubectl delete pod stubborn
trap '' TERM で SIGTERM を無視させているため、time の出力する real の値はおよそ 30 秒になるはずだ。Pod の spec に terminationGracePeriodSeconds を指定していないので既定値の 30 秒が使われ、その間 kubelet は SIGTERM を送ったまま待ち続け、期限が来て初めて SIGKILL を送って強制終了させている。
- SIGTERM を送ればプロセスはすぐ消える — 実際には
terminationGracePeriodSeconds(既定 30 秒)が経過するまでプロセスの自発的な終了を待っており、それでも終了しない場合に限って SIGKILL で強制終了される。 - preStop フックさえ入れれば 502 は完全になくなる — preStop はエンドポイント除去の伝播を待つための時間稼ぎに過ぎず、アプリが SIGTERM を受けた瞬間にリスニングを止める実装だと、sleep 中でも新規リクエストの失敗は防げない。
- 「Terminating」と表示された Pod にはもうトラフィックは来ない — エンドポイント除去と SIGTERM 送信は並行して進む設計上の競合状態のため、表示が変わったあとも伝播ラグの間は新規リクエストが届くことがある。
- deletionTimestamp
- Pod 削除要求を受けて API サーバーが刻む削除時刻。この時点から Pod は Terminating になる。
- EndpointSlice
- Service がどの Pod にトラフィックを転送するかを保持する API オブジェクト。kube-proxy や Ingress コントローラーが watch する。
- preStop フック
- kubelet が SIGTERM 送信前に実行するコンテナライフサイクルフック。
- terminationGracePeriodSeconds
- SIGTERM 送信から SIGKILL までの猶予秒数。既定値は 30 秒。
- SIGKILL
- 猶予期限までに終了しなかったプロセスを kubelet が強制終了させるシグナル。後片付けの機会を与えない。
- Pod Lifecycle | Kubernetes — 終了シーケンスと terminationGracePeriodSeconds の既定値を定義する公式ドキュメント。
- Container Lifecycle Hooks | Kubernetes — preStop / postStart フックの実行タイミングを定めた公式仕様。
- Getting 502 when running a deployment rolling update · Issue #1124 — 実際に報告されたロールアウト中の 502 事例と原因分析。
- Minimizing Downtime During Deployments | AGIC — preStop を使ったダウンタイム対策のベンダー公式ガイド。