💥

[Kubernetes 1.27] Pod 停止時のフェーズ遷移の変更

2023/12/13に公開

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 から SucceededFailed に遷移するようになりました。しかし、この変更が以下の予期せぬ問題を引き起こすことになります。

本記事では 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 を再度実行する可能性がある
  • KEP-3329 の PodFailurePolicy のルール (e.g. exit code) を考慮して、Pod を再作成するか判断できない

KEP-3329 は、削除時の Pod のフェーズを Running から SucceededFailed に遷移させることで、その情報を使って上記の問題を解決しようとしました。

Kubernetes <= 1.26

1.26 以前は RestartPolicy に OnFailure or Always を指定している Pod は FailedSucceeded のフェーズに遷移することがありませんでした。

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 は FailedSucceeded のフェーズに遷移するようになりました。

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 の状態 (ステータス) が CompletedFailedError になります。ここで言う 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 を起動していきます。podManagementPolicyParallel を指定することで、並列に 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#119684k/k#119685 で StatefulSet のローリング更新に関するバグの報告があがられました。StatefulSet controller はローリング更新が完了したかを判断するために status.replicas に依存していたからです。

そして、StatefulSet controller の Pod が Succeeded 状態の Pod を再起動する k/k#120398 の修正がマージされます。これにより、これまで検知できなかった k/k#117865 による潜在的なバグを検知できるようになります。これが StatefulSet の Pod が再作成される修正が、StatefulSet の Conformance テストを壊した要因です。

  1. Pod のフェーズ遷移の変更が StatefulSet の Conformance テストにバグの抜け道を作ってしまう
  2. StatefulSet の並列で Pod を作成・削除する機能の修正に含まれていた regression を Conformance テストで検知できず、1.25 - 1.28 に cherry-pick される
  3. Pod のフェーズ遷移の変更により Pod が再作成されないバグを修正する
  4. 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