はじめに — 何の問題もない Pod が数分ごとに再起動します
アプリケーションは正常に動作しているのに、RESTARTS だけが静かに増えていきます。
kubectl get pod -n payments
NAME READY STATUS RESTARTS AGE
checkout-7d9f6c5b4d-2xk9p 1/1 Running 11 (4m2s ago) 3h18m
checkout-7d9f6c5b4d-lm4vz 1/1 Running 9 (12m ago) 3h18m
ログには終了の直前まで正常なリクエスト処理の記録しかありません。答えはイベントにあります。
kubectl describe pod checkout-7d9f6c5b4d-2xk9p -n payments | tail -5
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Unhealthy 4m12s (x33 over 3h17m) kubelet Liveness probe failed: Get "http://10.42.3.17:8080/healthz": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Normal Killing 4m2s (x11 over 3h17m) kubelet Container checkout failed liveness probe, will be restarted
Kubernetes がこのコンテナを殺しています。正確には、私たちがそうしろと指示しておいたのです。
3つのプローブはそれぞれ異なる質問に答えます
3つのプローブは文法がまったく同じなので紛らわしいのですが、失敗したときにすることはまったく違います。
- startupProbe — 「起動は終わったか」。成功するまで liveness と readiness を完全に停止させます。一度成功すれば、そのコンテナの生涯で二度と実行されません。failureThreshold を使い切るとコンテナを殺します
- readinessProbe — 「今トラフィックを受けてよいか」。失敗するとサービスのエンドポイントから Pod のアドレスが外れます。コンテナを再起動しません。成功すれば再び入ります
- livenessProbe — 「このプロセスを再起動すべきか」。失敗すると kubelet がコンテナを殺し、restartPolicy に従って起動し直します
ここで最も頻繁に出てくる誤解を先に整理します。readiness の失敗は絶対に再起動を引き起こしません。Pod がずっと 0/1 のままなのに再起動しないと慌てるケースが多いのですが、それは設計どおりに動作しているだけです。再起動は liveness と startup だけの権限です。
readiness の失敗はエンドポイントで確認します。
kubectl get endpointslice -n payments -l kubernetes.io/service-name=checkout
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
checkout-x7k2m IPv4 8080 10.42.3.17,10.42.1.9 3h20m
3つの Pod のうち2つだけが入っています。残りの1つが readiness に失敗した Pod です。
kubectl get pod -n payments -l app=checkout \
-o custom-columns='POD:.metadata.name,IP:.status.podIP,READY:.status.containerStatuses[*].ready'
POD IP READY
checkout-7d9f6c5b4d-2xk9p 10.42.3.17 true
checkout-7d9f6c5b4d-lm4vz 10.42.1.9 true
checkout-7d9f6c5b4d-q8w2n 10.42.2.31 false
誤って使うと起きる3つの事故
| 症状 | 原因 | 診断 | 解決 |
|---|---|---|---|
| 正常な Pod が定期的に再起動 | liveness が過敏、特に timeoutSeconds のデフォルト 1 秒 | Events の Unhealthy と Killing | timeoutSeconds を引き上げ、ヘルスエンドポイントを軽量化 |
| デプロイ直後の 502 とコネクションリセット | readiness がない、または実際の準備状態を反映できていない | Pod がいつ EndpointSlice に入るか | readiness を追加、maxUnavailable 0 |
| 起動の遅いアプリが Ready に到達できない | startup なしで liveness が起動中のコンテナを殺す | Started と Killing イベントの間隔 | startupProbe を追加 |
| データベース障害時に全 Pod が同時に再起動 | liveness に依存関係の検査が含まれる | ヘルスハンドラが何を呼び出すか | liveness から依存関係を外す |
| エンドポイント 0 個でサービス全面停止 | readiness に全 Pod が共有する依存関係が含まれる | エンドポイント数の時系列 | 部分的な劣化を許容、依存関係を分離 |
| 終了中のリクエスト失敗 | エンドポイントの伝播より先に SIGTERM が到達 | Pod の終了時刻と失敗時刻を突き合わせる | preStop の遅延を挿入 |
| 終了時の Exit Code 137 | 猶予時間内に終了できない | Reason Error に 137 | ドレイン時間を短縮、猶予時間を引き上げ |
| kubelet の CPU 上昇 | exec プローブが多すぎる | プローブ種別の集計 | httpGet または grpc に置き換え |
事故 1 — 過敏な liveness が生む自己破壊
最も危険な組み合わせです。負荷が集中するとアプリケーションの応答が遅くなります。プローブも一緒に遅くなります。タイムアウトすると Kubernetes がコンテナを殺します。残った Pod にさらに負荷が集中します。その Pod もタイムアウトします。負荷が高いほど多く死ぬ構造になり、システムが自分自身を崩していきます。
このシナリオで liveness は安定装置ではなく増幅器です。診断は再起動の時刻とトラフィックのグラフを重ねて見れば即座に浮かび上がります。ピーク時間だけに再起動が集中しているなら確定です。
事故 2 — readiness なしでデプロイする
readiness がなければ、コンテナのプロセスが起動した瞬間に Pod は Ready とみなされ、そのままエンドポイントに入ります。アプリケーションがまだコネクションプールを作りキャッシュを埋めている最中でもリクエストが入ってきます。
kubectl get events -n payments --field-selector reason=Started --sort-by=.lastTimestamp | tail -2
デプロイ直後の数秒だけ 5xx が跳ね、すぐ正常に戻るなら、ほぼ必ずこの問題です。ローリングアップデートのたびに繰り返されるので、1日に何度もデプロイするチームでは常時のエラー率として固定化します。
事故 3 — 遅い起動を liveness が虐殺する場合
起動に 90 秒かかるアプリケーションに initialDelaySeconds 30 の liveness を付けると、30 秒の時点からプローブが失敗し始めてコンテナが死に、また起動し、また死にます。永遠に Ready に到達できません。
kubectl describe pod ledger-0 -n payments | grep -E "Started|Killing"
Normal Started 2m11s (x5 over 7m32s) kubelet Started container ledger
Normal Killing 91s (x4 over 6m52s) kubelet Container ledger failed liveness probe, will be restarted
Started と Killing の間隔がおよそ 40 秒で一定です。起動が完了する前に繰り返し死んでいるという明白なシグナルです。
よくある誤答: initialDelaySeconds を 200 に引き上げること。2つの代償が伴います。第一に、本当に死んだコンテナも最初の 200 秒間は検知されません。第二に、その値は最も遅い起動に合わせなければならないので、ノードが忙しい日にはやはり足りません。正解は startupProbe です。
パラメータ計算 — 実際に何秒後に死ぬのか
5つのパラメータのデフォルト値と意味を先に確定します。
- initialDelaySeconds (デフォルト 0) — コンテナの開始から最初のプローブまでの待機
- periodSeconds (デフォルト 10) — プローブの実行周期
- timeoutSeconds (デフォルト 1) — 1回のプローブが応答を待つ時間
- failureThreshold (デフォルト 3) — 連続何回の失敗を失敗とみなすか
- successThreshold (デフォルト 1) — 連続何回の成功を成功とみなすか。liveness と startup では 1 だけが許可されます
timeoutSeconds のデフォルト値 1 秒が事故の最もありふれた種です。GC の一時停止、瞬間的なスレッドプールの飽和、ディスクの遅延、どれもが 1 秒を超えさせることがあります。ヘルスエンドポイントがどれほど軽くても、プロセス全体が止まれば応答は遅れます。
では計算します。以下の設定でコンテナが t 秒に完全に停止したと仮定します。
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
- 最悪の場合、最初の失敗プローブは t の直後ではなく次の周期、つまり最大で t たす 10 秒の時点から始まります
- 3回連続で失敗する必要があるので 10 秒間隔で3回、最後の失敗は t たす 30 秒のあたりです
- 各プローブはタイムアウトの 3 秒を消費するので、実際の判定はそれより数秒遅れます
つまり検知遅延の最悪値はおおよそ periodSeconds かける failureThreshold たす timeoutSecondsです。上の設定は約 33 秒です。この値を SLO と合わせなければなりません。30 秒以内に復旧しなければならないサービスなら periodSeconds を 5 に減らす必要があります。
startupProbe の予算は掛け算で決めます。
startupProbe:
httpGet:
path: /healthz
port: 8080
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
5 秒かける 60 回で最大 300 秒の起動を許容します。この 300 秒のあいだ liveness はまったく実行されません。起動が終わると startup は永久に無効化され、liveness が短い周期で引き継ぎます。緩やかな起動と俊敏な検知を同時に手に入れる唯一の方法がこれです。
3つのプローブを一緒に使う完成した形はこうなります。
containers:
- name: checkout
image: registry.example.com/checkout:1.14.2
ports:
- name: http
containerPort: 8080
startupProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
readinessProbe:
httpGet:
path: /readyz
port: http
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
successThreshold: 1
livenessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
3つの設計意図が入っています。readiness は速く外れて速く戻るように、周期としきい値を小さく取りました。liveness は逆に寛容に取り、60 秒以上確実に死んでいる場合にだけ再起動します。パスも違います。liveness は /healthz、readiness は /readyz に分離しました。
liveness に依存関係を入れると障害が全面化します
ヘルスチェックを初めて作るとき、最も自然に見える実装がこれです。
GET /healthz
→ データベースに SELECT 1
→ Redis に PING
→ 決済ゲートウェイに HEAD リクエスト
→ すべて成功すれば 200
正直に見えますが、プロダクションでは災難です。データベースが3分間ぐらついたら何が起きるのかを順に見ていきます。
- すべての Pod の liveness が同時に失敗します。同じ依存関係を見ているので例外なく全部です
- kubelet がすべてのコンテナを殺します
- Pod たちが同時に再起動してコネクションプールを作り直し、キャッシュがすべて空になります
- データベースが回復した瞬間、すべての Pod が同時に接続とクエリを浴びせます
- データベースが再び倒れます
- 繰り返されます
3分のデータベース障害が 30 分のサービス障害へ拡大します。しかも再起動のせいで処理中だったリクエスト、インメモリのキュー、ウォームアップされたキャッシュがすべて消え、回復はさらに遅くなります。
liveness が答えるべき質問はただ一つ、このプロセスを再起動すれば状況が良くなるのかどうかだけです。データベースが死んだときにプロセスを再起動しても良くなるものは何もありません。したがってデータベースは liveness に入れてはいけません。
liveness に入れてよいのは再起動で実際に解決する状態だけです。イベントループの停止、デッドロック、復旧不可能な内部状態の破損くらいです。
GET /healthz
→ プロセスがリクエストを受けて応答を作れるかだけを確認
→ 常に 200、ただしイベントループが詰まっていれば応答自体が出ていかない
このシンプルなハンドラがほとんどの場合で最善です。そしてここから一歩進んだ結論も可能です。再起動で直る障害モードがないなら liveness プローブを一切置かないほうが安全です。プローブがなければコンテナはプロセスが死んだときだけ再起動され、それはたいてい正しい動作です。
readiness にはどこまで入れてよいのか
readiness は再起動を引き起こさないので、依存関係の検査を入れる余地があります。基準はこれです。その依存関係なしでこの Pod が処理できるリクエストが一つでもあるか。
- すべてのリクエストがデータベースを必要とするなら readiness に入れるのが合理的です。どうせ失敗するリクエストを受けないほうがましです
- 一部だけが必要なら入れるべきではありません。静的な応答やキャッシュで処理できるリクエストまで遮断されます
そして入れると決めたなら必ず一つ確認しなければなりません。すべての Pod が同時に NotReady になるとサービスのエンドポイントが 0 個になり、クライアントは接続そのものを拒否されます。これは 5xx より悪い体験になりえますし、回復の時点でヘルスのシグナルがまったくないため診断も難しくなります。
kubectl get endpointslice -n payments -l kubernetes.io/service-name=checkout -o wide
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
checkout-x7k2m IPv4 8080 <unset> 3h44m
ENDPOINTS が空ならこの状態です。依存関係の検査を readiness に入れるときは、最低 1 台は残すポリシーや、依存関係の失敗を部分的な劣化として扱う設計を併せて検討する必要があります。
プローブ種別の選択と exec のコスト
4つの方式があり、コストと信頼度が異なります。
httpGet — 基本の選択肢です。kubelet が Pod の IP へ直接リクエストします。200 から 399 までを成功とみなします。ここに罠が一つあります。認証プロキシがヘルスのパスをログインページへ 302 リダイレクトするとプローブは成功と判定されます。アプリケーションが死んでいても成功します。ヘルスのパスは必ず認証から除外し、200 を直接返さなければなりません。
tcpSocket — ポートが開いているかだけを見ます。リスナーのソケットはカーネルが維持するので、アプリケーションのスレッドがすべて止まっても成功します。信頼度が最も低く、ほかに選択肢がないときだけ使います。
grpc — gRPC サービスなら、標準のヘルスプロトコルを kubelet が直接呼び出します。以前のようにヘルスチェック用のバイナリをイメージに入れて exec で呼ぶ必要はありません。
readinessProbe:
grpc:
port: 9000
service: readiness
periodSeconds: 5
exec — 最も高価です。プローブが回るたびにコンテナの中でプロセスを新しく作ります。コンテナランタイムがタスクを生成し、名前空間を設定し、プロセスを回収するコストが毎回かかります。周期 10 秒の exec プローブを 500 個の Pod に付けると、クラスタ全体で毎秒 50 回のプロセス生成が常時発生します。
コストは2か所に現れます。ノードの kubelet とランタイムの CPU が上がり、プローブのプロセスはコンテナの cgroup に属するのでアプリケーションの CPU とメモリの予算を侵食します。シェルスクリプトを呼ぶ exec プローブなら、シェル自体のメモリもここに含まれます。メモリの上限がぎりぎりのコンテナでは、プローブが OOM の引き金になることもあります。
現状を数えてみるとたいてい驚きます。
kubectl get pods -A -o json | \
jq -r '.items[].spec.containers[].livenessProbe | select(.!=null) | keys[]' | \
grep -v Seconds | grep -v Threshold | sort | uniq -c | sort -rn
412 httpGet
87 exec
23 tcpSocket
exec 87 個のうちかなりの数は curl を呼ぶスクリプトであり、それは httpGet に変えればそのまま消えるコストです。
サービスメッシュを使っているならもう一つあります。kubelet は Pod の IP へ直接リクエストするので、プローブはサイドカーの mTLS ポリシーを迂回します。メッシュが厳格な相互 TLS を要求するとプローブが失敗するため、ほとんどのメッシュはプローブのパスをサイドカーが代わりに受けるよう書き換えるオプションを提供します。プローブが突然すべて失敗し始めたのにアプリケーションは無事なら、こちらを疑います。
終了パス — エンドポイント削除と SIGTERM の競合状態
プローブをどれだけうまく決めても、ローリングアップデートのたびにエラーが跳ねるなら原因は終了パスにあります。
Pod が削除されると、2つのことが同時に、互いに調整されることなく始まります。
- kubelet のパス — preStop フックを実行し、コンテナの主プロセスに SIGTERM を送り、猶予時間が終われば SIGKILL を送ります
- エンドポイントのパス — コントローラが EndpointSlice から Pod を削除し、その変更がすべてのノードの kube-proxy とイングレスコントローラ、サービスメッシュのサイドカーへ伝播します
問題は2つ目が遅いことです。コントローラが検知し、EndpointSlice を更新し、すべてのノードがそれを受け取ってルールを書き直すまでに数百ミリ秒から数秒かかります。大きなクラスタや外部のロードバランサが絡むとさらにかかります。
そのあいだにコンテナはすでに SIGTERM を受けてリスナーを閉じています。まだこの Pod へルーティングされるリクエストはコネクション拒否に出会います。デプロイ中に少量の 502 とコネクションリセットが出る理由がまさにこれです。
解決はSIGTERM をわざと遅らせることです。preStop フックは SIGTERM より先に実行され、完了するまで SIGTERM が保留されるので、ここに伝播を待つ遅延を入れます。
spec:
terminationGracePeriodSeconds: 60
containers:
- name: checkout
lifecycle:
preStop:
exec:
command: ['sh', '-c', 'sleep 15']
最新のバージョンではシェルを呼ばない専用のハンドラを使えます。イメージにシェルがない distroless 環境で特に有用です。
lifecycle:
preStop:
sleep:
seconds: 15
ここで猶予時間の予算を必ず計算しなければなりません。terminationGracePeriodSeconds はpreStop の実行時間まで含めた全体の予算です。
- preStop の遅延 15 秒
- 処理中リクエストのドレイン 最大 30 秒
- 余裕 15 秒
- 合計 60 秒
この計算をしないと、preStop が終わった途端に猶予時間が尽きて SIGKILL が飛びます。終了コード 137 に Reason が Error と刻まれる状況がそれで、OOMKilled 編で扱ったメモリの問題と終了コードが同じなので誤診しやすいです。
アプリケーション側も SIGTERM を正しく処理しなければなりません。新しい接続は拒否しつつ処理中のリクエストは終わらせ、バックグラウンドのワーカーは今の作業を仕上げてから終了します。SIGTERM ハンドラのないアプリケーションはデフォルトの動作で即座に死ぬので、preStop をどれだけ長く取っても処理中のリクエストが切られます。
kubectl exec -it checkout-7d9f6c5b4d-2xk9p -n payments -- \
sh -c 'grep SigCgt /proc/1/status'
SigCgt: 0000000000014002
このビットマスクに 15 番のビットが立っているかどうかで、SIGTERM ハンドラの登録有無を確認できます。シェルを通して実行されるコンテナでは、シェルが PID 1 になってシグナルを子に渡さないという古典的な問題も併せて確認する必要があります。
標準テンプレートとチェックリスト
新しいサービスを作るときは以下を出発点として使い、値だけをサービスの特性に合わせて調整します。
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
lifecycle:
preStop:
sleep:
seconds: 15
startupProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 5
failureThreshold: 60
timeoutSeconds: 3
readinessProbe:
httpGet: { path: /readyz, port: http }
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 2
livenessProbe:
httpGet: { path: /healthz, port: http }
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
デプロイ前に5つを確認します。
- liveness のハンドラが外部の依存関係を呼び出していないか
- timeoutSeconds が 1 のまま残っていないか
- 起動時間の最悪値が startupProbe の予算に収まるか
- terminationGracePeriodSeconds が preStop の遅延とドレイン時間の合計より大きいか
- アプリケーションが SIGTERM を受けてドレインを開始するか
再発防止の指標としては、プローブの失敗そのものを追跡するのが有用です。再起動につながる前の段階で捕まえられるからです。
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: probe-alerts
namespace: monitoring
spec:
groups:
- name: probes
rules:
- alert: LivenessProbeFailing
expr: increase(prober_probe_total{probe_type="Liveness",result="failure"}[15m]) > 5
for: 10m
labels:
severity: warning
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.pod }} liveness failing without restart yet"
- alert: ServiceHasNoEndpoints
expr: kube_endpoint_address_available == 0
for: 3m
labels:
severity: critical
おわりに — プローブは安全装置ではなく権限の委譲です
プローブを設定するということは、Kubernetes にトラフィックを切る権限とプロセスを殺す権限を渡すことです。その権限をどんな根拠で行使するのかを私たちが決めます。根拠が脆弱なら、何の問題もないシステムが自らの防御装置によって崩れます。
覚えておくべき一文はこれです。liveness は「再起動すれば良くなるか」にだけ答えるべきであり、その答えがノーであるすべての条件は readiness へ送るか、いっそプローブから外さなければなりません。
현재 단락 (1/242)
アプリケーションは正常に動作しているのに、RESTARTS だけが静かに増えていきます。