「Ready」と「実際に動く」は別の命題だ
一言でいうと
プローブは、アプリケーションの状態をKubernetesが理解できる言葉に翻訳するインターフェースです。livenessは「死んだので起動し直せ」、readinessは「いまは送るな」、startupは「まだ起動中なので待て」を意味し、3つの結果はまったく異なる動作につながります。
なぜ必要なのか
コンテナのプロセスが生きていることと、サービスが正常であることは別です。JVMがデッドロックに陥ると、プロセスは平然と存在し、PIDもそのままです。キャッシュを埋めている最中のアプリは、プロセスも生きていてポートも開いていますが、リクエストを処理するとすべてエラーになります。Kubernetesはコンテナの中をのぞけないので、アプリケーションが自分の状態を知らせる窓口が必要でした。
ところが「状態」は1種類ではありません。再起動すれば良くなる状態(デッドロック、コネクションプールの枯渇)と待てば良くなる状態(キャッシュのウォームアップ、依存サービスの一時的な障害)は、対応が正反対です。後者に再起動をかけると、いつまでも準備ができません。そのため、プローブが2つに分かれました。
3つ目があとから追加された理由も明確です。レガシーアプリは起動に5分かかることもありますが、livenessのinitialDelaySecondsを300秒にすると、通常運用中も障害の検知が5分遅れます。startupプローブはこの2つを分離します。起動している間は緩く、起動した後は細かく検査できます。
どう動くのか
| プローブ | 失敗すると | 成功するまで |
|---|---|---|
livenessProbe |
コンテナを再起動します | 検査を続けます |
readinessProbe |
Serviceのエンドポイントから除外します(再起動はしません) | トラフィックを受け取りません |
startupProbe |
コンテナを再起動します | livenessとreadinessをそもそも開始しません |
検査方式は3つとも同じです。httpGet(200–399なら成功)、tcpSocket(接続できれば成功)、exec(終了コードが0なら成功)があります。gRPCのヘルスチェック用のgrpcもあります。
タイミングのフィールドは、計算して決める必要があります。
initialDelaySeconds: コンテナの開始後、最初の検査までの待機(デフォルト0)periodSeconds: 検査の周期(デフォルト10)timeoutSeconds: 1回の検査の制限時間(デフォルト1)failureThreshold: 連続で何回失敗したら対処するか(デフォルト3)successThreshold: 連続で何回成功したら回復とみなすか(liveness/startupは1で固定)
検知までの最悪の遅延 ≈ initialDelaySeconds + periodSeconds × failureThresholdです。再起動が頻繁に起きるならfailureThresholdを上げ、障害の検知が遅いならperiodSecondsを減らします。startupプローブの予算はperiodSeconds × failureThresholdで、この値がアプリの最大起動時間より大きい必要があります。
終了側も対称に設計されています。Podを削除すると、kubeletがSIGTERMを送り、terminationGracePeriodSeconds(デフォルト30)の間待ってからSIGKILLを送ります。同時にエンドポイントからも削除されますが、この2つの処理は非同期なので、すでにルーティングされたリクエストがしばらく入ってくることがあります。そのため、preStopフックに短いsleepを入れて、エンドポイントの伝播を待つパターンを使います。preStopが動く時間もgrace periodの中で消費される点を、忘れてはいけません。
コンテナがなぜ終了したかは、terminationMessagePath(デフォルトは/dev/termination-log)に残ります。terminationMessagePolicy: FallbackToLogsOnErrorを指定すると、そのファイルが空のときにコンテナのログの末尾が代わりに入ります。kubectl describe podのLast Stateに表示される値が、これです。
PodDisruptionBudgetは自発的な中断(ノードのdrain、アップグレード)にだけ適用されます。ノードが突然停止する非自発的な中断は防げません。minAvailable: 2を3レプリカに設定すると、drainが一度に1つずつしか進みません。
現場での姿
ホームラボのクラスターで、この教訓を3度確認しました。KubeVirtを導入したとき、すべてのコンポーネントがAllComponentsReadyだったのにVMは起動しませんでした。virt-launcherのPodを詳しく調べると、initコンテナが実行するバイナリを入れたボリュームマウントが抜けていました。状態フィールドがReadyであることと、機能が動作することは別の命題です。そのため、GPU Operatorを導入したあとも「Pod 32個すべてRunning」で止まらず、実際にPodの中でnvidia-smiがGPUを認識しているかまで確認しました。結果はNVIDIA GeForce RTX 5090, 32607 MiB, 570.195.03で、ここまで見て初めて検証が終わります。
ログ側でも、同じ原則が当てはまります。障害の真っ只中で実際に必要なのは、読むことではなくフィルタリングと集計です。そのため、メッセージの文字列は定数にして、変わる値はすべてフィールドに出します。
// 나쁨 — 같은 사건을 세려면 정규식이 필요하다
logger.info(`user ${userId} checkout failed after ${ms}ms`)
// 좋음 — 메시지는 상수, 값은 필드
logger.error({ event: 'checkout.failed', user_id: userId, duration_ms: ms }, 'checkout failed')
レベルの判断基準も1つに固定すれば、議論は終わります。ERRORは、自分たちのシステムが自分の責任を果たせなかったという意味です。400番台のレスポンス(不正なリクエスト、期限切れのトークン、権限なし)は、システムが正常に動作した結果なのでERRORではありません。これらをERRORとして記録した途端、ERRORログの大部分が正常なトラフィックで埋まり、本物の欠陥がその中に埋もれます。
調査の順序も決まっています。メトリクス → トレース → ログ。メトリクスが「いつから何がどれだけ」に答え、トレースが「どのサービスのどのスパンか」に答え、ログが「その中で正確にどの値でどの分岐を通ったか」に答えます。trace_idでフィルタリングして絞り込むと、読むべき行が数十行に減ります。
次のラボですること
ckad-obsネームスペースでhttpGet・tcpSocket・execの3方式のプローブを作成し、startupプローブの起動予算を計算して埋め、Deploymentにプローブを付けて準備状態を制御し、PodDisruptionBudgetを設定します。
このラボ環境では、実際のログ参照とkubectl execはできません。kubectl logs --previous、-c、--since、--tail、kubectl exec -it POD -c CONTAINER -- sh、kubectl debugのような調査コマンドは必ず知っておく必要がありますが、ここでは手で実行できないので、クイズで扱います。試験会場ではこれらのコマンドがそのまま出てくるので、文法を正確に覚えておいてください。