勘違いしがちな Kubernetes の Job のリトライの挙動を紐解く

この記事は、CYBOZU SUMMER BLOG FES '24 (クラウド基盤本部 Stage) DAY 9 の記事です。

こんにちは。DBRE チーム の山下です。 サイボウズではサービスを運用する上で多くのバッチ処理を実行しています。 Kubernetes 基盤を利用するにあたって、バッチ処理によく使われるのが Job だと思います。サイボウズでも多くの Job を活用しています。

そんな Kubernetes の Job ですが、リトライに関して想定と異なる挙動が見つかったためコードリーディング、kind での検証の両面から調査しました。 今回は調査によって得られた Kubernetes の Job のリトライの挙動に関する知見をご紹介します。

Job の概要

まずは簡単に Job について説明します。 Job は単発のジョブを実現するための Kubernetes リソースです。

Job リソースの例を以下に示します。

apiVersion: batch/v1
kind: Job
metadata:
  name: job-example
spec:
  template:
    spec:
      restartPolicy: OnFailure
      containers:
      - name: main
        image: ghcr.io/cybozu/ubuntu:22.04
        command: ["bash"]
        args:
        - -c
        - echo "Hello world!"
  backoffLimit: 1

このように .spec.template に Pod のテンプレートを指定することで、定義に則った Pod を起動してくれます。 今回の場合、Pod は "Hello world!" を出力後正常終了し、Job が完了となります。 Job リソースでは completions や parallelism, podFailurePolicy などのフィールドによって Job の実行を制御できます。

Job のリトライにおける期待と異なる挙動

Job リソースでは backoffLimit フィールドで Job の失敗時のリトライ回数を指定できます。

我々は Job の実行が失敗しても一度はリトライされることを期待して backoffLimit を 1 に設定していました。 この設定によって Job から起動された Pod が失敗したとき、Pod が再起動することによって Job の実行がリトライされるところまでは期待通りでした。 しかしその後、予想に反して、Job から起動された Pod が途中で Terminate されてしまいました。 Job はリトライで起動した Pod の完了を保証してくれると予想していたので、これは想定外の挙動です。

この backoffLimit に関連するリトライの挙動については Job のドキュメント に以下のような記述があります。

The number of retries is calculated in two ways: The number of Pods with .status.phase = "Failed". When using restartPolicy = "OnFailure", the number of retries in all the containers of Pods with .status.phase equal to Pending or Running.

基本的には .status.phase が Failed となっている Pod の数を、restartPolicy = "OnFailure" の場合は .status.phase が Pending または Running の Pod に属するすべてのコンテナのリトライ回数の合計をリトライ回数としているようです。

これはリトライの挙動に対して具体的にどのような影響を及ぼすのでしょうか?

本記事では今回着目した挙動に関連する以下の二点についてコードリーディング、kind での検証の二つの側面から調査しようと思います。 なお、今回は Job を 1 並列で動かした場合に限って考えます。

  1. Job が backoffLimit に達していることをどう判定しているのか
  2. Job がリトライで起動した Pod の完了は保証されているのか

コードリーディング

Kubernetes では、ユーザがアプリケーションの動作設定について記述したマニフェストを適用することで宣言的なデプロイが可能です。 各種リソースにはそのリソースの状態を watch し、適用された状態を実現するコントローラが存在し、これがリソースに対応する実体を作成しています。

Job リソースのコントローラは Job Controller です。 これらのビルトインのコントローラは通常 kube-controller-manager としてデプロイされています。

今回読んでいく Job Controller の実装は以下のファイルにあります。Kubernetes のバージョンは 1.29.4 です。 https://github.com/kubernetes/kubernetes/blob/v1.29.4/pkg/controller/job/job_controller.go

まずは、1 について、Job Controller が backoffLimit をからめた Job の失敗をどのように判定しているかを調べていきます。

Job Controller の初期化処理を見ると、Pod, Job の状態を watch する Informer(client-go のキャッシュ機構) を作成して、それぞれ Add, Delete, Update のイベントハンドラーを登録することで Job の制御を実現しているようです。 https://github.com/kubernetes/kubernetes/blob/v1.29.4/pkg/controller/job/job_controller.go#L150

backOffLimit の判定をしているのはこちらです。 https://github.com/kubernetes/kubernetes/blob/v1.29.4/pkg/controller/job/job_controller.go#L821

exceedsBackoffLimitpastBackoffLimitOnFailure(&job, pods) のいずれかを満たしている場合、 Condition に Failed のステータスを登録しています。 つまり、このいずれかの条件を満たした場合、リトライ回数が backoffLimit に達して Job が失敗したとみなされます。 ここに以下のようなコメントがあります。

check if the number of pod restart exceeds backoff (for restart OnFailure only) OR if the number of failed jobs increased since the last syncJob

どうやら、restartPolicy が OnFailure の場合はコンテナがリスタートした回数を確認し、restartPolicy が Never であれば最後の syncJob の実行からの失敗した Job の増分を計算しているようです。

実装を読むと pastBackoffLimitOnFailure が前者をexceedsBackoffLimit が後者を計算していることがわかります。

Pod リソースにおける restartPolicy の設定による挙動の違いは以下のようになります。Always は Job リソースでは指定できないので省略しています。

  • OnFailure
    • Pod が Fail した時は Pod を作りなおさず、コンテナを再起動する
    • 再起動すると .status.containerStatuses.restartCount がインクリメントされる
  • Never
    • Pod が Fail した時は新たに Pod を作成する
    • 失敗すると .status.failed がインクリメントされる

このように、restartPolicy の違いによって失敗した場合の Pod の挙動が変わります。 Job のリトライの計算方法が複数あるのはこれが理由のようです。 よって、Job のリトライ回数は以下のようになると考えられます。

  • OnFailure
    • backoffLimit 回リトライされるが、最後のリトライで Job が Fail する
    • .status.containerStatuses.restartCount が backOffLimit に達していれば終了する
  • Never
    • backoffLimit 回リトライされる
    • .status.failed が backOffLimit を超えていれば終了する

2のリトライで起動した Pod の完了が保証されているのかについても見ていきます。

Job の実行が終了している場合は deleteActivePods 関数が呼ばれています。 これによって、起動中の Pod も削除されてしまうようです。 https://github.com/kubernetes/kubernetes/blob/v1.29.4/pkg/controller/job/job_controller.go#L852

そのため、各 restartPolicy における Pod の完了は以下のようになると考えられます。

  • OnFailure
    • Job が Fail したタイミングで Job に対応する Pod が deleteActivePods によって消されてしまう
    • 最後に起動されたコンテナが完了する前に Pod ごと削除される可能性がある
  • Never
    • 完全に実行され、削除されずに残る
    • Job が削除されない限りは最後まで実行される

我々のケースでは restartPolicy を OnFailure にしていたため、リトライした Pod が完了前に Terminate されていたようです。 この結果と合わせて kind での検証で挙動を確かめていこうと思います。

kind での検証

今回は kind で以下を検証しました。検証環境は以下です。

  • kind version v0.23.0
  • k8s version v1.29.4

検証項目は以下です。 - restartPolicy: OnFailure で何回実行/完了されるか確認する - restartPolicy: Never で何回実行/完了されるか確認する

実行する Job のマニフェストは以下を利用しました。

apiVersion: batch/v1
kind: Job
metadata:
  name: job-pod-failure-policy-example
spec:
  template:
    spec:
      # kubectl delete が送信されたらすぐに SIGKILL して Pod を削除するため
      terminationGracePeriodSeconds: 0
      # restartPolicy: Never の場合と比較する
      restartPolicy: OnFailure
      containers:
      - name: main
        image: ghcr.io/cybozu/ubuntu:22.04
        command: ["bash"]
        # コマンドが途中で中断されたのか、完了したのかを確認するため sleep して文字列を出力する
        args:
        - -c
        - echo "Hello world!" && sleep 20 && echo "completed" && exit 1
  backoffLimit: 3

restartPolicy: OnFailure の場合

pod を watch すると以下のようになります。 4回実行されているようです。

$ kubectl get po --watch
NAME                                   READY   STATUS    RESTARTS   AGE
job-pod-failure-policy-example-njt5c   1/1     Running   0          3s
job-pod-failure-policy-example-njt5c   0/1     Error     0          30s
job-pod-failure-policy-example-njt5c   1/1     Running   1 (1s ago)   31s
job-pod-failure-policy-example-njt5c   0/1     Error     1 (31s ago)   61s
job-pod-failure-policy-example-njt5c   0/1     CrashLoopBackOff   1 (13s ago)   73s
job-pod-failure-policy-example-njt5c   1/1     Running            2 (13s ago)   73s
job-pod-failure-policy-example-njt5c   0/1     Error              2 (43s ago)   103s
job-pod-failure-policy-example-njt5c   0/1     CrashLoopBackOff   2 (12s ago)   115s
job-pod-failure-policy-example-njt5c   1/1     Running            3 (28s ago)   2m11s
job-pod-failure-policy-example-njt5c   1/1     Terminating        3 (29s ago)   2m12s
job-pod-failure-policy-example-njt5c   1/1     Terminating        3 (29s ago)   2m12s

Job の status をみると type: Failed の condition が追加されています。

  status:
    conditions:
    - lastProbeTime: "2024-08-08T05:36:57Z"
      lastTransitionTime: "2024-08-08T05:36:57Z"
      message: Job has reached the specified backoff limit
      reason: BackoffLimitExceeded
      status: "True"
      type: Failed
    failed: 1
    ready: 1
    startTime: "2024-08-08T05:34:45Z"
    terminating: 0
    uncountedTerminatedPods: {}

ログを確認すると最後のコンテナの起動は完了せずに終了しています。 最後の実行が Error にならず Terminating となっているのはそのためで、この場合は起動された Pod は終了を待たず削除されています。

$ kubectl logs job/job-pod-failure-policy-example -f
Hello world!

restartPolicy: Never の場合

pod を watch すると以下のようになります。(状態の変わってない行を一部省略しています。) 全ての Pod が完了し、それぞれの Pod の status が Error となっています。 これらの Pod はデフォルトでは Job が削除されるまで削除されないようです。

$ kubectl get po --watch
NAME                                   READY   STATUS    RESTARTS   AGE
job-pod-failure-policy-example-2fd9z   1/1     Running   0          2s
job-pod-failure-policy-example-2fd9z   0/1     Error     0          31s
job-pod-failure-policy-example-tkffb   0/1     Pending   0          0s
job-pod-failure-policy-example-tkffb   0/1     ContainerCreating   0          0s
job-pod-failure-policy-example-tkffb   1/1     Running             0          1s
job-pod-failure-policy-example-tkffb   0/1     Error               0          31s
job-pod-failure-policy-example-rtnrk   0/1     Pending             0          0s
job-pod-failure-policy-example-rtnrk   0/1     ContainerCreating   0          0s
job-pod-failure-policy-example-rtnrk   1/1     Running             0          0s
job-pod-failure-policy-example-rtnrk   0/1     Error               0          30s
job-pod-failure-policy-example-k2mhl   0/1     Pending             0          0s
job-pod-failure-policy-example-k2mhl   0/1     ContainerCreating   0          0s
job-pod-failure-policy-example-k2mhl   1/1     Running             0          0s
job-pod-failure-policy-example-k2mhl   0/1     Error               0          30s

Job の status を見ると、こちらも condition が追加されています。 restartPolicy: OnFailure の場合と異なり、 failed: 4 が記載されています。 この場合は4回分実行され、各 Pod の status が Error になってから Job が Failed となります。

  status:
    conditions:
    - lastProbeTime: "2024-08-08T05:41:33Z"
      lastTransitionTime: "2024-08-08T05:41:33Z"
      message: Job has reached the specified backoff limit
      reason: BackoffLimitExceeded
      status: "True"
      type: Failed
    failed: 4
    ready: 0
    startTime: "2024-08-08T05:38:21Z"
    terminating: 0
    uncountedTerminatedPods: {}

まとめ

これらの結果 Job のリトライの挙動は pod の restartPolicy に依存しており、以下のようになっていることがわかりました。

restartPolicy=OnFailure の場合

  • BackoffLimit - 1 回リトライされる。
  • JobController によって作られた Pod が Fail したさいに pod.status.containerStatuses.restartCount がインクリメントされ、Pod はそのままでコンテナが再起動される
  • (podの .status.containerStatuses.restartCount の合計) >= (Job の .spec.BackoffLimit) のとき、Job の .status.conditions に reason: BackoffLimitExceeded が追加され、Job が Fail する
  • Fail してしまった場合もリトライ処理が行われ新たにコンテナが起動するが、Job Controller のクリーンナップ処理が走るのですぐに Pod が Terminating になり、コンテナは終了する
  • 厳密には BackoffLimit 回リトライされているが、最後のリトライに関してはすぐに Terminate される

restartPolicy=Never の場合

  • BackoffLimit 回リトライされる。
  • Job Controller によって作られた Pod が Fail したさいに Jobの .status.failed がインクリメントされ、Fail した Pod は残したまま新たな Pod が作成される
  • (Job の .status.failed) > (Job の .spec.backoffLimit) のとき Job の .status.conditions に reason: BackoffLimitExceeded のものが追加され、Job が Fail する
  • Fail してしまった場合は新たな Pod は作られない