TECHSCORE BLOG

クラウドCRMを提供するシナジーマーケティングのエンジニアブログです。

CronJobを100個作って得た設定の定石3選 + 01日深夜に月次バッチを起動させるTips

crontab で起動させていたバッチプログラムを CronJob に移行せよ、というお仕事を拝命し、この春に完遂したばかり。割り当てられたその数、100個以上。 新しい CronJob を本番環境に送り出す時は、今でも緊張から指が震えます。それでも流石に100個も CronJob を作っていると、自分なりの定石やレシピが固まるものです。 これから CronJob に取り組むよ、という方のお役に立てれば嬉しいです。

梶原 知恵(カジハラ チエ)
息をするように typo するおかげで、閉じ括弧「 } 」忘れに時間を取られる不幸なプログラマー。
リモートワーク時に自宅で作るランチのお料理レシピも、そういえば3個ぐらいで運用中。お買い物が楽なのでむしろ楽でいいと思っている。
好物はビールとワインとお砂糖。苦手は茹でたカニとハンバーグと無花果。


運用から得られる知見が無い間は、設定の多さに悩むもの

多種多様なオプションの組み合わせで細やかな設定が可能なのが、Kubernetes の魅力。 ・・・なのですが、経験が無い間はただの情報の洪水。

CronJob に取り組んだ最初の頃に悩んだ設定を3点と Tips を1点とりあげます。

これらは特に重要な内容だ、という訳ではありません。 ただ、私が悩んだポイントと設定を選ぶに至った過程、運用開始した後の様子をお話します。

本題の前に、Pod と Job と CronJob の関係性

まずは、超ざっくりな概要。Pod と Job と CronJob の関係性です。

  • コンテナと Pod
    • アプリケーションを動作させるのに必要な諸々を1つにまとめたものがコンテナ
    • Pod は Kubernetes が扱うデプロイ可能な最小単位
      • コンテナは Pod の中に存在
      • 複数コンテナを管理可能ですが、私が移行を担当した全てのバッチでは 1 Pod に 1 コンテナ
  • Job は Pod を管理
    • 決められた数の Pod が正常に終了することを保証
    • 後述するエラー時のリトライ管理はここ
  • CronJob は Job を管理

毎分起動する CronJob の例。

apiVersion: batch/v1
kind: CronJob
metadata:
  name: test-cronjob
spec:
  schedule: "* * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: test-cronjob
            image: busybox
            command:
            - /bin/sh
            - -c
            - date
          restartPolicy: Never

CronJob を確認。

$ kubectl get cronjobs.batch
NAME           SCHEDULE    SUSPEND   ACTIVE   LAST SCHEDULE   AGE
test-cronjob   * * * * *   False     0        31s             3m47s

Job を確認。

  • Job 名は末尾に数字が自動付与
  • 数字は起動時刻 scheduledTime の エポックタイム(UTC時間の1970年1月1日午前0時0分0秒からの経過時間)なので、大きい方が新しい Job
$ kubectl get jobs.batch
NAME                    COMPLETIONS   DURATION   AGE
test-cronjob-27600885   1/1           2s         2m32s
test-cronjob-27600886   1/1           4s         92s
test-cronjob-27600887   1/1           4s         32s

Pod を確認。

  • どの Job から生まれた Pod なのかは名前で判断
  • 詳細情報を取得して確認することも可能
$ kubectl get pods
NAME                          READY   STATUS      RESTARTS   AGE
test-cronjob-27600885-sw4rm   0/1     Completed   0          2m35s
test-cronjob-27600886-xlplv   0/1     Completed   0          95s
test-cronjob-27600887-f79w6   0/1     Completed   0          35s

$ kubectl describe pods test-cronjob-27600887-f79w6 | grep Job
Controlled By:  Job/test-cronjob-27600887

その1:過去履歴の保持

公式ドキュメント「CronJobを使用して自動化タスクを実行する」:Job History Limit

  • .spec.successfulJobsHistoryLimit
    • 終了したジョブの保持数を指定
    • デフォルトは「3」
  • .spec.failedJobsHistoryLimit
    • 失敗したジョブの保持数を指定
    • デフォルトは「1」

毎分起動する CronJob の例に、各々「10」を追加した例。

apiVersion: batch/v1
kind: CronJob
metadata:
  name: test-cronjob
spec:
  schedule: "* * * * *"
  successfulJobsHistoryLimit: 10 ★ここ★
  failedJobsHistoryLimit: 10 ★ここ★
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: test-cronjob
            image: busybox
            command:
            - /bin/sh
            - -c
            - date
          restartPolicy: Never

悩んだ理由は、何が嬉しいのかがワカラナイ

まず私達の運用の前提として、ログはいつでも参照できるようにすべての履歴で保存をしています。

Job の履歴が残せる、というのは crontab の設計とは決定的に異なる部分です。 「残せる、ということは残したいと思うシチュエーションがあり得るんだろうなぁ」とは察せます。でも、何か良いことあるのか?が分からない。 「ああ、残しておけば良かった」と後悔するシチュエーションがあるのか?も分からない。 実行完了しているとはいえ、多数の Job(と、生み出された Pod )を残すにはそれなりに資源を使います。 残すべきか、残さざるべきか。

私の定石

デフォルト設定に従う、です。

  • 終了したジョブの保持数は「3」
    • 最後の実行の様子を確認したい時もあるだろう、またその直前はどうだったのか?で履歴を遡りたいこともあるだろう、との理由で「残さない」はない
    • 履歴が3、は、多すぎず少なすぎず、良い塩梅なのかも
  • 失敗したジョブの保持数は「1」
    • 直前の失敗だけ分かればよい
    • 複数の理由で失敗している場合は 1つずつ解決していけば良いので、「今」のエラー原因はこれ、を知るという方針

デフォルト値はマニフェストへの記載も無いので、とてもすっきりします。 書き漏れが起きる心配もありません。 すべての CronJob が同じ挙動をするシンプル設計を採用しました。

履歴を多く残して確認する運用をしたい場合は、上記例のように limit を上げる設定を追記すれば OK です。

運用をしてみて

困ったことは何も起きていません。 エラーが起きた場合、例えば Java のバッチを起動している場合は

$ kubectl exec test-cronjob-error-99999999-xxxxx -- jstack プロセスID

等の JDK 付属ツールで最新のエラー Pod を調査しています。 アプリが出力するログは全履歴を採取・保存しているため、ログからも調査出来るようにしています。

ネットワーク系のエラーだと、時間の経過と共に原因が変わっていく場合もあります。常に最新のエラーを確認する、という運用が私達のバッチには合っているようです。

その2:Job の並行実行

公式ドキュメント:CronJobを使用して自動化タスクを実行する .spec.concurrencyPolicy

  • Allow
    • デフォルト
    • 同時実行を許可
  • Forbid
    • 前のジョブが終了していない場合は、次のジョブの実行をスキップ
  • Replace
    • 前のジョブが終了していない場合は、前のジョブをキャンセルして次のジョブを実行

毎分起動する CronJob の例に、concurrencyPolicy: Forbid を追加した例。

apiVersion: batch/v1
kind: CronJob
metadata:
  name: test-concurrency-policy-forbid
spec:
  schedule: "* * * * *"
  concurrencyPolicy: Forbid   ★ここ★
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: test-concurrency-policy-forbid
            image: busybox
            command:
            - /bin/sh
            - -c
            - date; sleep 100s; date
          restartPolicy: Never

悩んだ理由は、Allow 以外の使いどころがワカラナイ

CronJob 以外ではユースケースはありそうなのですが。 上記の CronJob の例は、100秒スリープする毎分バッチです。続けて起動する場合には40秒の重複期間が発生します。

concurrencyPolicy: Allow の場合は、前の Job の終了を待たずに次の Job が起動します。

$ kubectl get jobs.batch
NAME                                     COMPLETIONS   DURATION   AGE
test-concurrency-policy-allow-27574986   1/1           103s       106s
test-concurrency-policy-allow-27574987   0/1           46s        46s ★前Jobの終了前に開始★

concurrencyPolicy: Forbid の場合は、前の Job が終了するのを待って次の Job が起動します。

$ kubectl get jobs.batch
NAME                                      COMPLETIONS   DURATION   AGE
test-concurrency-policy-forbid-27575009   0/1           63s        63s ★1分経過(終了前)

↓ 終了を待って・・・

$ kubectl get jobs.batch
NAME                                      COMPLETIONS   DURATION   AGE
test-concurrency-policy-forbid-27575009   1/1           103s       104s
test-concurrency-policy-forbid-27575010   0/1           1s         1s ★前Jobの終了後に開始★
  • 定期実行するバッチは、起動時刻で集計期間をアプリ内で判断させるのが定石
  • concurrencyPolicy: Forbid ではこの定石が使えない

concurrencyPolicy: Replace の場合は、前の Job が終了していない場合はキャンセルして次の Job が起動します。 Terminating と ContainerCreating といった STATUS が確認できないと何が起きたのか分からずに、運用が混乱するのではないか、という怖さがあります。

$ kubectl get pods
NAME                                             READY   STATUS              RESTARTS   AGE
test-concurrency-policy-replace-27575044-8zx6w   1/1     Terminating         0          60s
test-concurrency-policy-replace-27575045-sggzx   0/1     ContainerCreating   0          0s

私の定石

concurrencyPolicy は「Allow」です。

運用をしてみて

困ったことは何も起きていません。 ForbidReplace は Pod を扱うからこそできる 、crontab での運用では実現が難しかったことが簡単な設定で反映できそうです。何か面白いことができそうなのですが、私が管理している業務ではユースケースがなく、この先も出番はなさそうです。

その3:Pod の再実行

上記の毎分起動する CronJob の例に記載しているので、Pod のライフサイクル設定についても1点触れておきます。

公式ドキュメント:Pod backoff failure policy
.spec.restartPolicy

  • リトライ回数は backoffLimit で指定(デフォルトは「6」)
    • 何回実行しても同じ結果を得られるバッチについては デフォルト値「6」
    • 自動で再実行させたくない場合だけ backoffLimit: 0 を設定し、1ショット実行を実現
  • Always
    • デフォルト値
    • 動き続けることを期待(よって、CronJob では使用しません)
  • OnFailure
    • 終了することを期待している
    • 同じ Pod を再起動(同じノードに留まります)
    • リトライ回数分の起動が終わると Pod は削除されます
$ kubectl get pods
NAME                             READY   STATUS             RESTARTS   AGE
test-error-job-onfailure-zh465   0/1     CrashLoopBackOff   5          6m7s

↓ 再起動数が最大(例えば、デフォルト値の「6」)に達した時に Terminating

$ kubectl get pods
NAME                             READY   STATUS        RESTARTS   AGE
test-error-job-onfailure-zh465   0/1     Terminating   6          6m9s

↓ そして、Pod は削除されました

$ kubectl get pods
No resources found
  • Never
    • 終了することを期待している
    • 新しい Pod で再起動(別のノードに移る可能性があります)
    • リトライ回数分だけ新しい Pod 作成され、削除されずに残ります
      • 再起動数が最大(例えば、デフォルト値の「6」)に達した、最終ステータスでの例
      • backoff する際の遅延(10秒、20秒、40秒、80秒)が分かりやすいように並べ替えています
$ kubectl get po
NAME                   READY   STATUS   RESTARTS   AGE
test-error-job-7947b   0/1     Error    0          13s  ★80秒経過★
test-error-job-llh4v   0/1     Error    0          93s  ★40秒経過★
test-error-job-87jx5   0/1     Error    0          2m13s ★20秒経過★
test-error-job-ztc5g   0/1     Error    0          2m33s ★10秒経過★
test-error-job-26bj7   0/1     Error    0          2m43s ↑(ここを起点に・・・)↑
test-error-job-rg76b   0/1     Error    0          2m46s

毎分起動する CronJob の例に、1ショット実行を追加した例。

apiVersion: batch/v1
kind: CronJob
metadata:
  name: test-cronjob
spec:
  schedule: "* * * * *"
  jobTemplate:
    spec:
      backoffLimit: 0   ★ここ★
      template:
        spec:
          containers:
          - name: test-cronjob
            image: busybox
            command:
            - /bin/sh
            - -c
            - date
          restartPolicy: Never

私の定石

restartPolicy は「Never」です。 ただし、これには「crontab から移行した」という特殊な事情があります。バッチを設計する時に、起動するノードを意識させる必要があるか、という視点が必要ありませんでした。

また、もしもノードに異常が発生した場合に、 Pod がエラーで終了 → backoffLimit が「0」ではない場合は別のノードで起動して成功することを期待しています。

運用をしてみて

困ったことは何も起きていません。 正直に言いますと、「 restartPolicy: Never で助かった!」という経験がまだ無いのです(ありがたいことです)。 まだ運用経験が3ヵ月ですので、知見を貯めるのはこれからだと思います。 restartPolicy: OnFailure は上記の例での通り、Pod が削除されてしまうので運用に混乱をきたさないか怖い、という思いもあり、今後も実装しない予感がしています。

Tips:月初01日の09時以前に定期起動させる方法

先にも触れましたが、私の使命は「crontab で起動させていたバッチプログラムを CronJob に移行」でした。 crontab で当たり前に出来ていたことが出来なくて、一番困ったことは、月初の夜間バッチ起動です。

オンプレサーバでは、タイムゾーンは JST でした。 深夜01時0分起動の月次バッチは、crontab では例えば以下のスケジュールです。

0 1 1 * *

「分・時・日・月・曜日」指定なので、こうですね。

ところが。 CronJob のスケジュール時刻はジョブが開始された kube-controller-manager のタイムゾーンに基づいており、つまり タイムゾーン UTC で設定しないといけません。

0 0 1 * *

としても、01日9時0分となります。 以下の例では image: busybox を使用していますが、Pod のコンテナ内でのタイムゾーンは JST と仮定します。

私の定石

  • 毎日、日次で起動させる
  • commandで日付を判断させ、01日以外は実行させない

01日の深夜01時0分起動の月次バッチは、以下のようになりました。

apiVersion: batch/v1
kind: CronJob
metadata:
  name: test-cronjob
spec:
  schedule: "0 16 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: test-cronjob
            image: busybox
            command:
            - /bin/sh
            - -c
            - if [ $(date +\%d) -eq 01 ]; then date ; fi
          restartPolicy: Never

もちろんこれで正しく動きますが、「これじゃない」という残念さを感じています。 いつか kube-controller-manager のタイムゾーンを克服する改訂が入ることを願っています。

おわりに

crontab を動かしていたサーバーも撤去され、CronJob での運用を始めてようやく3ヵ月。 CronJob のスペックをフルに活かした仕様でマニフェストを作れているかというと、まだまだ。 くじけないように「私には伸びしろが多いのだ」と鼓舞しながら、学習を日々進めていこうと思います。

上記の設定3選に選んだものは、他の設定にもあまり影響しないような比較的検証しやすい内容です。それでも、私にとっては「Pod を起動してバッチを実行 → 正常終了させる運用とはこういうことか」を理解するきっかけになったものでもあります。 これから CronJob に取り組む方にも何かのきっかけになれば嬉しいです。

シナジーマーケティング株式会社では一緒に働く仲間を募集しています。