[Kubernetes 1.27] Pod 停止時のフェーズ遷移の変更
Kubernetes 1.27 で KEP-3329: Retriable and non-retriable Pod failures for Jobs の一部として実装された [k/k#115331]: Give terminal phase correctly to all pods that will not be restarted により、Pod 停止時のフェーズが Running
から Succeeded
か Failed
に遷移するようになりました。しかし、この変更が以下の予期せぬ問題を引き起こすことになります。
- [k/k#117018]: daemonset stuck in Completed state after a reboot (with graceful kubelet shutdown)
- [k/k#116925]: E2eNode Suite. Keeps device plugin assignments across pod and kubelet restarts
- [k/k#118310] : Pod phase changed when container exits with 0 in 1.27
- [k/k#118472]: Pod graceful deletion hangs if the kubelet already rejected that pod at admission time
本記事では Kubernetes 1.27 で変更された Pod 停止時のフェーズ遷移の変更がもたらした影響を見ていきます。
KEP-3329 Retriable and non-retriable Pod failures for Jobs
KEP-3329 は WG-Batch が取り組んでいるバッチ処理基盤としての Kubernetes を改善するための機能の一つです。Pod Conditions に新しく DisruptionTarget
が追加され、kube-scheduler の Preemption や Kubelet の Eviction、Taint Manager による削除などを区別できるようになりました。
- PreemptionByKubeScheduler
- DeletionByTaintManager
- EvictionByEvictionAPI
- DeletionByPodGC
- TerminationByKubelet
また、Pod Failure Policy を設定することで、Job の失敗の条件をより柔軟に表現できるようになりました。
apiVersion: batch/v1
kind: Job
metadata:
name: job-pod-failure-policy-example
spec:
completions: 12
parallelism: 3
template:
spec:
restartPolicy: Never
containers:
- name: main
image: docker.io/library/bash:5
# 起動後に exit code 42 で終了する処理
command: ["bash"]
args:
- -c
- echo "Hello world!" && sleep 5 && exit 42
backoffLimit: 6
podFailurePolicy:
rules:
# exit code の条件によって Job を失敗扱いにするルール
# action には Ignore, FailJob, Count を指定できる
- action: FailJob
onExitCodes:
# どのコンテナの exit code を見るかを指定
containerName: main
# In なので exit code が 42 の場合
operator: In
values: [42]
# Pod Conditions が DisruptionTarget という条件で失敗した Pod は
# backoffLimit のカウンタを増やさずに Pod を再作成するルール
- action: Ignore
onPodConditions:
- type: DisruptionTarget
Pod のフェーズ遷移
Kubernetes <= 1.26 の場合、Pod を削除しても Pod のフェーズは Running
から遷移せず、Pod 内の全てのコンテナが停止しても Pod のフェーズは Running
のままでした。
削除中の Job を考えます。Job コントローラが Pod 内の処理の成功もしくは失敗を判断しようにも、判断する基準がありません。そのため、仕方なく deletionTimestamp
が設定されている削除中の Pod は全て失敗として扱っていたのですが、以下の問題がありました。
- Job の Pod を削除すると古い Pod が実行中にも関わらず、新しい Pod がすぐに起動してしまう
- Indexed Job と ML 系のフレームワークを使用したアプリケーションで新旧の Pod が同時に実行されてエラーになる
- 正常に終了したジョブの Pod も再起動されてしまう
- 停止中の Pod 内のジョブが
terminationGracePeriodSeconds
で指定した期間内で処理を完了しても、deletionTimestamp
を基準に判断しているため、成功した Job の Pod を再度実行する可能性がある
- 停止中の Pod 内のジョブが
- KEP-3329 の PodFailurePolicy のルール (e.g. exit code) を考慮して、Pod を再作成するか判断できない
KEP-3329 は、削除時の Pod のフェーズを Running
から Succeeded
か Failed
に遷移させることで、その情報を使って上記の問題を解決しようとしました。
Kubernetes <= 1.26
1.26 以前は RestartPolicy に OnFailure
or Always
を指定している Pod は Failed
や Succeeded
のフェーズに遷移することがありませんでした。
kind v0.20.0 を使って、Kubernetes 1.26 のクラスタを起動して挙動を確認してみます。
kind create cluster --image=kindest/node:v1.26.6@sha256:6e2d8b28a5b601defe327b98bd1c2d1930b49e5d8c512e1895099e4504007adb
適当な finalizer を指定した Pod を作成します。
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: running-pod
finalizers: ["example.com/my-finalizer"]
spec:
restartPolicy: Always
containers:
- name: main
image: alpine:3.18.4
command: ["ash"]
args: ["-c", 'echo "Hello world" && sleep infinity']
EOF
Pod が起動しました。
❯ kubectl get pods
NAME READY STATUS RESTARTS AGE
running-pod 1/1 Running 0 47s
起動した Pod を削除します。
❯ kubectl delete pods running-pod
pod "running-pod" deleted
適当な finalizer を指定しているため、Pod の削除は Terminating
状態のままスタックします。
❯ kubectl get pods
NAME READY STATUS RESTARTS AGE
running-pod 1/1 Terminating 0 86s
Pod のフェーズを確認すると Running
となっていることが分かります。
❯ kubectl get pods running-pod -ojsonpath='{.status.phase}'
Running
Pod 内の全てのコンテナが停止した後も Pod のフェーズは Running
のままです。
Kubernetes >= 1.27
1.27 以降は RestartPolicy に OnFailure
or Always
を指定している Pod は Failed
や Succeeded
のフェーズに遷移するようになりました。
kind create cluster --image=kindest/node:v1.27.3@sha256:3966ac761ae0136263ffdb6cfd4db23ef8a83cba8a463690e98317add2c9ba72
適当な finalizer を指定した Pod を再度作成して見ます。
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: running-pod
finalizers: ["example.com/my-finalizer"]
spec:
restartPolicy: Always
containers:
- name: main
image: alpine:3.18.4
command: ["ash"]
args: ["-c", 'echo "Hello world" && sleep infinity']
EOF
起動した Pod を削除します。
❯ kubectl delete pods running-pod
pod "running-pod" deleted
適当な finalizer を指定しているため、Pod の削除は Terminating
状態のままスタックします。
❯ kubectl get pods
NAME READY STATUS RESTARTS AGE
running-pod 1/1 Terminating 0 29s
しばらくすると、Pod 内の全てのコンテナが停止して Pod のフェーズが Failed
に遷移します。
❯ kubectl get pods
NAME READY STATUS RESTARTS AGE
running-pod 0/1 Terminating 0 2m48s
❯ kubectl get pods running-pod -ojsonpath='{.status.phase}'
Failed
これは SIGTERM のハンドリングができていないのが原因です。プロセスは terminationGracePeriodSeconds 経過後に Kubelet から SIGKILL され、exit code 137 でコンテナが終了しているからです。
❯ kubectl describe pods running-pod
...
State: Terminated
Reason: Error
Exit Code: 137
Started: Mon, 11 Dec 2023 08:51:15 +0900
Finished: Mon, 11 Dec 2023 08:51:52 +090
...
SIGTERM をハンドリングする処理を追加した Pod で試してみます。
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: running-pod
finalizers: ["example.com/my-finalizer"]
spec:
restartPolicy: Always
containers:
- name: main
image: alpine:3.18.4
command: ["ash", "-c", "trap 'echo Terminated; exit' TERM; echo Started; while true; do sleep 1; done"]
EOF
今度は Pod のフェーズが Running
から Succeeded
に遷移します。
❯ kubectl get pods
NAME READY STATUS RESTARTS AGE
running-pod 0/1 Terminating 0 18s
❯ kubectl get pods running-pod -ojsonpath='{.status.phase}'
Succeeded
❯ kubectl describe pods running-pod
...
State: Terminated
Reason: Completed
Exit Code: 0
Started: Mon, 11 Dec 2023 09:08:47 +0900
Finished: Mon, 11 Dec 2023 09:08:54 +0900
...
DaemonSet の Pod が再起動されない問題
Kubernetes 1.27.0-rc.0 で DaemonSet の Pod が Completed
状態のままスタックして、新しい Pod が作成されないという問題が k/k#117018 で報告されました。ノードを再起動すると Graceful Node Shutdown の機能により Pod は安全に停止し、プロセスの終了時の exit code に応じて Pod の状態 (ステータス) が Completed
や Failed
、 Error
になります。ここで言う Pod の状態は Pod のフェーズと必ずしも一致しない点に注意が必要です。kubectl get pods
で表示される Pod の状態はいろいろな条件で変わってきます。詳細は Kubernetes: kubectl 上の Pod のステータス表記についてを参照して下さい。Kubernetes 1.27.0-rc.0 で DaemonSet controller が Completed
な Pod を再起動しなくなった原因を見ていきます。
まず、Graceful Node Shutdown は Kubelet がノードの停止イベントを検知して最終的に Pod を停止します。この時に Pod に deletionTimestamp
は設定されません。deletionTimestamp
が設定されていないので、Pod GC によってお掃除もされません。Orphan な Pod として残ってしまいます。こういった Pod の取り扱いは各種 controller に委ねられています。
Kubernetes <= 1.26.4 の DaemonSet controller は正常に実行できていない Pod を削除して次の reconcile 処理で Pod を新しく作り直そうとします。Graceful Node Shutdown の機能により、Pod の状態は Failed
に遷移していたため、これまでは Pod のフェーズが Failed
の場合の分岐処理で対応できていました。しかし、Kubernetes >= 1.27 から Graceful Node Shutdown の機能で Pod の状態が Succeeded
にも遷移するようになったため、その場合の分岐処理がなく Pod が削除されなくなっていたのです。
// https://github.com/kubernetes/kubernetes/blob/v1.26.4/pkg/controller/daemon/daemon_controller.go#L771-L888
// podsShouldBeOnNode figures out the DaemonSet pods to be created and deleted on the given node:
// - nodesNeedingDaemonPods: the pods need to start on the node
// - podsToDelete: the Pods need to be deleted on the node
// - err: unexpected error
func (dsc *DaemonSetsController) podsShouldBeOnNode(
logger klog.Logger,
node *v1.Node,
nodeToDaemonPods map[string][]*v1.Pod,
ds *apps.DaemonSet,
hash string,
) (nodesNeedingDaemonPods, podsToDelete []string) {
shouldRun, shouldContinueRunning := NodeShouldRunDaemonPod(node, ds)
daemonPods, exists := nodeToDaemonPods[node.Name]
switch {
case shouldRun && !exists:
// If daemon pod is supposed to be running on node, but isn't, create daemon pod.
nodesNeedingDaemonPods = append(nodesNeedingDaemonPods, node.Name)
case shouldContinueRunning:
// If a daemon pod failed, delete it
// If there's non-daemon pods left on this node, we will create it in the next sync loop
var daemonPodsRunning []*v1.Pod
for _, pod := range daemonPods {
if pod.DeletionTimestamp != nil {
continue
}
if pod.Status.Phase == v1.PodFailed {
...
msg := fmt.Sprintf("Found failed daemon pod %s/%s on node %s, will try to kill it", pod.Namespace, pod.Name, node.Name)
logger.V(2).Info("Found failed daemon pod on node, will try to kill it", "pod", klog.KObj(pod), "node", klog.KObj(node))
...
podsToDelete = append(podsToDelete, pod.Name)
} else {
daemonPodsRunning = append(daemonPodsRunning, pod)
}
}
...
}
case !shouldContinueRunning && exists:
// If daemon pod isn't supposed to run on node, but it is, delete all daemon pods on node.
for _, pod := range daemonPods {
if pod.DeletionTimestamp != nil {
continue
}
podsToDelete = append(podsToDelete, pod.Name)
}
}
}
k/k#117073 で DaemonSet controller の Pod の Succeeded
フェーズの処理を追加することで問題は修正されました。他の controller でも同様の実装漏れにより影響を受ける可能性があります。そのため、k/k#115331 のリリースノートに controller の実装者に向けたコメントが追加されました。
ACTION REQUIRED: Users who maintain controllers which relied on the fact that pods with RestartPolicy=Always
never enter the Succeeded phase may need to adapt their controllers. This is because as a consequence of
the change pods which use RestartPolicy=Always may end up in the Succeeded phase in two scenarios: pod
deletion and graceful node shutdown.
StatefulSet の Pod が再起動されない問題
k/k#118310 で今度は StatefulSet の Pod の状態が Completed
でスタックして再起動されないとの報告がありました。こちらもノードの再起動後に StatefulSet controller が Pod を再作成しないことが原因でした。
// https://github.com/kubernetes/kubernetes/blob/v1.26.11/pkg/controller/statefulset/stateful_set_control.go#L366-L393
func (ssc *defaultStatefulSetControl) processReplica(ctx context.Context, set *apps.StatefulSet, currentRevision *apps.ControllerRevision, updateRevision *apps.ControllerRevision, currentSet *apps.StatefulSet, updateSet *apps.StatefulSet, monotonic bool, replicas []*v1.Pod, i int) (bool, error) {
// delete and recreate failed pods
if isFailed(replicas[i]) {
...
if err := ssc.podControl.DeleteStatefulPod(set, replicas[i]); err != nil {
return true, err
}
...
}
...
}
この問題も DaemonSet controller と同様に Succeeded
なフェーズの Pod を考慮する形で k/k#120398 で修正されました。ただ、StatefulSet controller の問題はこれで終わりではありませんでした。
StatefulSet の Conformance テストの破壊
k/k#120398 をマージしてから StatefulSet の基本的な挙動を確認する Conformance テスト が Flaky になりました。k/k#120700 で報告があるように、最初 StatefulSet の Pod が再起動しない問題との関連が疑われていましたが、その原因までは分かっていませんでした。comment - k/k#120731 のまとめを見ると、その後の調査で StatefulSet controller に対する別の修正が影響していたことが分かります。
StatefulSet はデフォルトで 1 台ずつ Pod を起動していきます。podManagementPolicy
に Parallel
を指定することで、並列に Pod を起動・削除することができます。ただ、k/k#117071 で報告されているように、実際の挙動だと直列で Pod を起動・削除していたようです。この問題を修正するために、k/k#117865 で StatefulSet controller が複数の Pod を並列で起動・削除するように修正が入りました。しかし、この修正により StatefulSet の status.replicas
のフィールドの挙動が意図せず変わってしまいます。StatefulSet から Pod を最初に作成した時に小さな値が status.replicas
に最初に指定され、次の reconcile で正しい値に遷移するようになりました。k/k#115331 で混入した StatefulSet の Pod が再起動されないバグにより、この挙動の変更は E2E テストで検知できませんでした。更に悪いことに、この修正は 1.25 - 1.28 のバージョンに cherry-pick されていました。
status.replicas
の挙動が変わる regression を含んだ Kubernetes のバージョンがリリースされたことで、k/k#119684 や k/k#119685 で StatefulSet のローリング更新に関するバグの報告があがられました。StatefulSet controller はローリング更新が完了したかを判断するために status.replicas
に依存していたからです。
そして、StatefulSet controller の Pod が Succeeded
状態の Pod を再起動する k/k#120398 の修正がマージされます。これにより、これまで検知できなかった k/k#117865 による潜在的なバグを検知できるようになります。これが StatefulSet の Pod が再作成される修正が、StatefulSet の Conformance テストを壊した要因です。
- Pod のフェーズ遷移の変更が StatefulSet の Conformance テストにバグの抜け道を作ってしまう
- StatefulSet の並列で Pod を作成・削除する機能の修正に含まれていた regression を Conformance テストで検知できず、1.25 - 1.28 に cherry-pick される
- Pod のフェーズ遷移の変更により Pod が再作成されないバグを修正する
- StatefulSet の Conformance テストで StatefulSet の並列で Pod を作成・削除する機能の regression を検知できるようになる
何を revert するべきか
Kubernetes で regression が発生するとその原因を突き止めると同時に、原因となる PR を revert するのが一般的です。そのため、k/k#117865 を revert するべきですが、既に他の Kubernetes バージョンに cherry-pick されているため、更に戻すことが難しいです。そのため、Flaky なテストが起きないように k/k#120398 の修正を revert するという方法を取りました。StatefulSet の Pod が再起動しないバグを蘇らせる形です。そして、k/k#117865 による regression を解消するために、StatefulSet controller のローリング更新の完了の判定に status.replicas
ではなく spec.replicas
を使用する k/k#120731 を先にマージしました。その後で revert していた StatefulSet の Pod を再起動する変更を k/k#121389 で取り込んで問題が解決しました。
すぐに修正できるはずだったバグの修正に余計に時間を使うことになりました。
まとめ
- Kubernetes 1.27 で Pod のフェーズが
Succeeded
もしくはFailed
に遷移するようになった - Pod のフェーズの遷移を考慮していなかった DaemonSet controller と StatefulSet controller がバグを踏み抜いた
- Pod のライフサイクルに関わる挙動に手を加えると必ず何かが壊れる
- E2E テストに抜け道を作るバグや regression を含んだ修正が cherry-pick されると大変
Discussion