Kubernetes の CronJob/Job の仕組みをひもとく

これは [リクルートライフスタイル Advent Calendar 2018](https://qiita.com/advent-calendar/2018/recruitlifestyle) の5日目の記事です。


[前日](https://qiita.com/mihirat/items/e40e2f99a9d7c977cfa0) に引き続き [CET チーム](https://engineer.recruit-lifestyle.co.jp/techblog/2018-02-21-rls-meetup-7/) から、本日は @tmshn がお送りします。

## はじめに

- データベースのバックアップを定期的に取る
- npm audit を定期的に実行する
- 放置されている issue/pull request を定期的に Slack に通知する

などなど、日常の中で何かを定期実行したくなることはよくあります。

そんなとき、素朴なソリューションとして真っ先に思いつくのは、ジョブ用のサーバーを用意してその中で cron を実行するというやり方でしょうか。

でも、Kubernetes（以下 k8s）をお使いなら、CronJob というリソースを使うことができます。 [^1]

[^1]: 偶然にも [前日の記事](https://qiita.com/mihirat/items/e40e2f99a9d7c977cfa0) でも CronJob の話をしていますが、そちらは GAE のもので、今日話すものとは別物です

K8s CronJob ではコントローラーがスケジュールを管理し、実行ごとに Pod を作成して、終了したらそれを破棄します。おかげでリソース効率もよいですし、コンテナを stateless, immutable, ephemeral に保てます。cron の k8s 版というわけですね！

## 本記事の目的

CronJob は cron の k8s 版と書きましたが、cron にはない自動リトライや重複実行の制御などの機能があり、Rundeck や Airflow など同様「ジョブスケジューラー」と呼ぶのが正しいです。 [^1]

[^1]: ジョブ間の依存関係を定義したりなど、「ワークフローエンジン」としての機能はありません（必要に応じて [Argo](https://github.com/argoproj/argo) などのツールと組み合わせて利用します）

そのため、その付加機能を正しく理解せずただの cron だと思っている  と、まれに「あれ？　ジョブが実行されない！」となることがありえます。

本記事では CronJob のスケジューラーを実装からひもときながら、どんなときにジョブが実行されないのかを明らかにしていきます。

### 他のジョブスケジューラー

余談。

世の中には、「ジョブスケジューラー」と呼ばれるツールはいっぱいあります。

Ref: [cronの代替になりそうなジョブ管理ツールのまとめ - Qiita](https://qiita.com/shrkw/items/5c3d53358b0016a09504)

私たちのチームでも、時期や用途によって Rundeck や Airflow を運用したり、AWS の CloudWatch Event + Lambda や GCP の GAE Cron などの cloud-native なソリューションを使ったりしています。 [^2]

[^2]: そういえば GCP でマネージド Airflow である [Cloud Composer](https://cloud.google.com/composer/) がリリースされましたね


そして、k8s の CronJob がそれらと比べてすごく目新しかったり高機能だったりするわけではありません。また、K8s クラスター自体の運用は必要になるので、完全にサーバーーレスというわけにもいきません。

でも、すでに k8s クラスターを運用しているチームには親和性が高いでしょうし、機能が比較的シンプルな実装なのでとっつきやすいと思います。

## CronJob の役割

先ほど

> K8s CronJob ではコントローラーがスケジュールを管理し、実行ごとに Pod を作成して、終了したらそれを破棄します

と書きましたが、これは嘘です、ごめんなさい。

実際には、CronJob は Job というリソースを作成します。そしてこの Job が Pod を作成し、処理を実行します。
役割としては、

- CronJob
  - スケジューリング
  - スケジュールまたがっての重複実行の制御
- Job
  - リトライ処理（Pod の再作成）
  - 並列実行（複数 Pod の作成）
  - タイムアウト管理（Pod の強制終了）

という分担になっています。

```
┌─ CronJob ─────────────────────────────────────────────┐
│                                                       │
│      ┌─ Job ────────────────┐                         │
│      │┌─ Pod ─┐ ┌ Pod ─────┐│                         │
│      │└────── ☓ └───────── ✓│                         │
│      │┌ Pod ────────────┐   │                         │
│      │└──────────────── ✓   │                         │
│      └──────────────────────┘                         │
│              ┌─ Job ──────────────┐                   │
│              └────────────────────┘                   │
│                      ┌─ Job ───────────────┐          │
│                      └─────────────────────┘          │
│                              ┌─ Job ───────────────┐  │
│                              └─────────────────────┘  │
│ (time)                                                │
│   ───┴───────┴───────┴───────┴───────┴────── …… ──→   │
└───────────────────────────────────────────────────────┘
```

Job とはつまり、「終わりがくるもの」のために Pod を管理するコントローラーなんですね（ちょうど Deployment が web サーバーのような「実行し続けるもの」のために Pod を管理するのと対応しています）。

なので、Job は必ずしも CronJob と合わせて使う必要はなく、webhook から作成したりなどイベントドリブンな使い方も想定されています。

## 前提知識

閑話休題、CronJob のスケジューラーの話に戻りましょう。

今回は、Kubernetes の最新リリースである v1.13.0 のソースコードを見ていきます。 [^3]

なお、今回はソースコードベースで話をしていくため、細かい挙動は将来的に変更になる可能性が大いにあることをご承知おきください。

[^3]: 途中まで v1.11.5 で記事を書いていたのですが、最新版がリリースされたので切り替えました。とはいえ大きな差分はないので、少し前のバージョンのコードを読んでも問題ないと思います。

:octocat: [kubernetes/kubernetes at v1.13.0](https://github.com/kubernetes/kubernetes/tree/v1.13.0)

具体的には、下記のファイルが今回ターゲットです。

- [pkg/controller/cronjob/cronjob_controller.go](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/cronjob_controller.go)
- [pkg/controller/cronjob/utils.go](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/utils.go)

また、API ドキュメントの CronJobSpec の項から、必要な項目とその拙訳を記載しておきます。

| Field | Description |
| :---- | :---------- |
| `concurrencyPolicy` string | Specifies how to treat concurrent executions of a Job. Valid values are: - "Allow" (default): allows CronJobs to run concurrently; - "Forbid": forbids concurrent runs, skipping next run if previous run hasn't finished yet; - "Replace": cancels currently running job and replaces it with a new one |
| | 同時実行をどのように取り扱うか。選択肢は以下のいずれか：「許可」（既定値）…同時実行を許可する。「禁止」…同時実行を禁止する。以前のジョブがまだ完了していなければ、今回の実行をスキップする。「置換」…現在実行中のジョブをキャンセルし、新しいジョブで置き換える |
| `schedule` string | The schedule in Cron format, see https://en.wikipedia.org/wiki/Cron. |
| | Cron 書式のスケジュール。 https://ja.wikipedia.org/wiki/Crontab 参照 |
| `startingDeadlineSeconds` integer | Optional deadline in seconds for starting the job if it misses scheduled time for any reason. Missed jobs executions will be counted as failed ones. |
| | なんらかの理由でジョブがスケジュール通り実行できなかった場合、何秒後までは実行してよいかという期限（オプション）。最終的に実行できなかったジョブは失敗とみなされる |
| `suspend` boolean | This flag tells the controller to suspend subsequent executions, it does not apply to already started executions. Defaults to false. |
| | このフラグを指定すると、今後の実行を中止する（すでに開始されているものは中止されない）。既定値は false |

引用元： https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#cronjobspec-v1beta1-batch

## ジョブがスケジュールされないのはどんなときか

以下の文で「実行する」と書かれている場合、それは「Job リソースを作成した」という意味だと思ってください。

### ケース1: `concurrentPolicy: Forbid`

ドキュメントに書いてあるとおりなので、比較的分かりやすいでしょうか。

`concurrentPolicy: Forbid` を設定した場合、実行時間が来てもまだ前の時間のジョブが終わっていなかったら、今回分は実行しないということです。

たとえば1時間に1回の CronJob の場合、10時の分が終わらないまま11時になってしまったら、11時にはジョブが実行されません。

```
      ┌─ Job ───┐
      └─────────┘
                  ┌─ Job ─────────┐
                  └───────────────┘
                              ┌ ─ ─ ─ ─ ─
(time)                         ─ ─ ─ ─ ─ ┘
      ✓           ✓           ☓
  ────┴───────────┴───────────┴───────────┴─── …… ──→
      9:00        10:00       11:00       12:00
```

[pkg/controller/cronjob/cronjob_controller.go#L280-L292](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/cronjob_controller.go#L280-L292)

```go
if sj.Spec.ConcurrencyPolicy == batchv1beta1.ForbidConcurrent && len(sj.Status.Active) > 0 {
	// （コメントは引用者が省略）
	klog.V(4).Infof("Not starting job for %s because of prior execution still running and concurrency policy is Forbid", nameForLog)
	return
}
```

では問題です。10時分のジョブが終わったあと、ジョブが実行されるのはいつでしょうか？

答えは、「10時分のジョブが終わった直後」です。たとえば10時分のジョブが11:30に終わった場合、次のジョブは12:00の回を待たず、11:30にすぐ始まります。

正 🙆‍

```
      ┌─ Job ───┐
      └─────────┘
                  ┌─ Job ───────────┐
                  └─────────────────┘
                                    ┌─ Job ───┐
(time)                              └─────────┘
      ✓           ✓                 ✓
  ────┴───────────┴───────────┴───────────┴─── …… ──→
      9:00        10:00       11:00       12:00
```

誤 🙅‍

```
      ┌─ Job ───┐
      └─────────┘
                  ┌─ Job ───────────┐
                  └─────────────────┘
                                          ┌─ Job ───┐
(time)                                    └─────────┘
      ✓           ✓                       ✓
  ────┴───────────┴───────────┴───────────┴─── …… ──→
      9:00        10:00       11:00       12:00
```

というのも、実行しなかった場合に「この回をスキップしましたよ」というのを記録するわけではなく、実行した回のみを記録し、10秒おきに未実行のスケジュールがないかを確認しているからなのです。

ちなみに、未実行のスケジュールが複数あったとき、実行されるのは最新の1件のみです。

[pkg/controller/cronjob/cronjob_controller.go#L259-L263](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/cronjob_controller.go#L259-L263)

```go
if len(times) > 1 {
	klog.V(4).Infof("Multiple unmet start times for %s so only starting last one", nameForLog)
}

scheduledTime := times[len(times)-1]
```

つまり、10時分の実行が12:30までかかってしまった場合、11時分は実行されることなく、12時分が12:30から実行されるというわけです。

```
      ┌─ Job ───┐
      └─────────┘
                  ┌─ Job ─────────────────────┐
                  └───────────────────────────┘
                                              ┌─ Job ───┐
(time)                                        └─────────┘
      ✓           ✓                           ✓
  ────┴───────────┴───────────┴───────────┴────── …… ──→
      9:00        10:00       11:00       12:00
```

なお「何時実行分のつもりで作成された Job なのか」は Job 名を見ればわかります。これが、 `scheduledTime` の UNIX タイムスタンプになっているのです。

[pkg/controller/cronjob/utils.go#L156](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/utils.go#L156)

```go
name := fmt.Sprintf("%s-%d", sj.Name, getTimeHash(scheduledTime))
```

### ケース2: `suspended`

お次は `suspended` オプション。こいつが `true` になっていたら、ジョブは実行されません。

というかまぁ、ジョブを実行「しないため」の機能なので、当たり前ですね。

[pkg/controller/cronjob/cronjob_controller.go#L243-L246](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/cronjob_controller.go#L243-L246)

```go
if sj.Spec.Suspend != nil && *sj.Spec.Suspend {
	klog.V(4).Infof("Not starting job for %s because it is suspended", nameForLog)
	return
}
```

ユースケースとしては、DB のメンテナンス中はバックアップを止めておくとか、年末年始は放置 issue の通知を止めておくとか、「一時停止」用途で使うものです。

`suspended: true` を解除した場合、やはりすぐに溜まっていたスケジュール（の最新分）がすぐに実行されます。

### ケース3: `startingDeadlineSeconds`

ドキュメントに記載されている説明を再掲します。

> Optional deadline in seconds for starting the job if it misses scheduled time for any reason. Missed jobs executions will be counted as failed ones.
>
> （拙訳）なんらかの理由でジョブがスケジュール通り実行できなかった場合、何秒後までは実行してよいかという期限（オプション）。最終的に実行できなかったジョブは失敗とみなされる

https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#cronjobspec-v1beta1-batch

「なんらかの理由でジョブがスケジュール通り実行できなかった場合」というのは、まさにこの記事で説明しているような状況が起きた場合、ということです。

先ほどの毎時実行の例を思い出してください。10時実行分が11:30までかかってしまった場合、11:30にすぐ11時分が実行されると書きました。しかしジョブの内容や後続ジョブとの関係で、11:30になっていまさらジョブを始めても無駄だったり、むしろ問題が出てしまう場合もありえるでしょう。

そんなとき、「実行開始があんまり遅れるくらいなら、いっそ実行しなくていいよ」というのを指定するのが `startingDeadlineSeconds` です。


[pkg/controller/cronjob/cronjob_controller.go#L264-L279](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/cronjob_controller.go#L264-L279)

```go
tooLate := false
if sj.Spec.StartingDeadlineSeconds != nil {
	tooLate = scheduledTime.Add(time.Second * time.Duration(*sj.Spec.StartingDeadlineSeconds)).Before(now)
}
if tooLate {
	klog.V(4).Infof("Missed starting window for %s", nameForLog)
	recorder.Eventf(sj, v1.EventTypeWarning, "MissSchedule", "Missed scheduled time to start a job: %s", scheduledTime.Format(time.RFC1123Z))
	// （コメントは引用者が省略）
	return
}
```

たとえば `startingDeadlineSeconds: 1200` （20分）を指定したとします。10時実行分が11:10までかかった場合はすぐに11時分を実行しますが、11:30までかかってしまった場合は11時分を諦めて12時分まで待つようになります。

11:20 に間に合った場合

```
      ┌─ Job ───┐                 11:20
      └─────────┘                 :
                  ┌─ Job ───────┐ :
                  └─────────────┘ :
                                ┌─ Job ───┐
(time)                          └─────────┘
      ✓           ✓             ✓ :
  ────┴───────────┴───────────┴───────────┴─── …… ──→
      9:00        10:00       11:00       12:00
```

11:20 に間に合わなかった場合

```
      ┌─ Job ───┐                 11:20
      └─────────┘                 :
                  ┌─ Job ──────────┐
                  └────────────────┘
                                  :       ┌─ Job ───┐
(time)                            :       └─────────┘
      ✓           ✓               :       ✓
  ────┴───────────┴───────────┴───────────┴─── …… ──→
      9:00        10:00       11:00       12:00
```

なお、

> Missed jobs executions will be counted as failed ones.

とありますが、前掲のソースから分かる通り、別に "failed" ステータスの Job が11時分として作成されるわけではなく単に "MissSchedule" というイベントが記録されるだけです（果たしてこれが実装者の意図した通りなのかは分かりませんが……）。

### ケース4: "Too many missed start time"

最後のケースです。

ここまで、スケジュールどおりに実行されないケースをいろいろと見てきました。そうして実行されなかったスケジュールがどんどんたまっていくと、ある日

> Cannot determine if job needs to be started. Too many missed start time (> 100). Set or decrease .spec.startingDeadlineSeconds or check clock skew.

というエラーが出るようになります。

これは実行されなかったスケジュールが多すぎる（100件以上ある）ときに、クラスター（の時計）に異常がある場合を考慮して警告してくれるエラーです。

たとえば毎分実行のジョブで、実行日時を誤ってエポック時間（1970年1月1日）で記録してしまったとします。次回スケジューラが実行要否を判断しようとして未実行のスケジュールをリストアップしようとすると、合計でおよそ250万件のリストを計算するハメになります。それは CPU・メモリを食いつぶしてしまう可能性があり困るので、リストアップの途中で100件を超えたら諦めよう、というわけです。

リストアップは古い方から順に行われ、その途中で諦めるので、もっとも最近いつ実行を逃したかは分かりません。そういうわけで、リストすら取得できないので「実行すべきか判断できない」"Cannot determine" というエラーになるわけです。

[pkg/controller/cronjob/utils.go#L126-L146](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/utils.go#L126-L146)

```go
// An object might miss several starts. For example, if
// controller gets wedged on friday at 5:01pm when everyone has
// gone home, and someone comes in on tuesday AM and discovers
// the problem and restarts the controller, then all the hourly
// jobs, more than 80 of them for one hourly scheduledJob, should
// all start running with no further intervention (if the scheduledJob
// allows concurrency and late starts).
//
// However, if there is a bug somewhere, or incorrect clock
// on controller's server or apiservers (for setting creationTimestamp)
// then there could be so many missed start times (it could be off
// by decades or more), that it would eat up all the CPU and memory
// of this controller. In that case, we want to not try to list
// all the missed start times.
//
// I've somewhat arbitrarily picked 100, as more than 80,
// but less than "lots".
if len(starts) > 100 {
	// We can't get the most recent times so just return an empty slice
	return []time.Time{}, fmt.Errorf("Too many missed start time (> 100). Set or decrease .spec.startingDeadlineSeconds or check clock skew.")
}
```

さて、しかしこの100という数字はけっこう曲者です。コメントを読むと「毎時実行のジョブが3連休中実行できないくらいなら問題ない数字」として100を選んだと書いてあります。

でも、4連休だとギリギリアウトです。5分に1回のジョブだったら、一晩でアウトです。

しかし、これには回避策があります。

スケジュールをリストアップするとき、`startingDeadlineSeconds` を過ぎてる分はリストアップ対象になりません（現在の`startingDeadlineSeconds` 秒前からリストアップをはじめる）。

[pkg/controller/cronjob/utils.go#L112-L119](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/utils.go#L112-L119)

```go
if sj.Spec.StartingDeadlineSeconds != nil {
	// Controller is not going to schedule anything below this point
	schedulingDeadline := now.Add(-time.Second * time.Duration(*sj.Spec.StartingDeadlineSeconds))

	if schedulingDeadline.After(earliestTime) {
		earliestTime = schedulingDeadline
	}
}
```

ということは、`startingDeadlineSeconds` が十分短ければ、"Too many missed start time" のエラーに悩まされることはなくなります。

たとえば先ほどの毎時実行の例でいえば、「スケジュール間隔は1時間で開始期限は20分」なので、リストアップ対象はかならず1件以下になります。

また開始期限に意思がない場合も、この問題を避けるため、個人的にはスケジュール間隔×100を設定しておくことをおすすめします（たとえば毎時実行なら100時間、など）。ついでにいうと、そもそも同時実行を抑制している（`concurrentPolicy` を `Forbid` か `Replace` にしている）場合は、`startingDeadlineSeconds` をスケジュール間隔より長くする意味はありません。

### ケース5: スケジュール書式が間違っている

根本的なやつですが、スケジュール書式が誤っていると動きません。

[pkg/controller/cronjob/utils.go#L95-L98](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/utils.go#L95-L98)

```go
sched, err := cron.ParseStandard(sj.Spec.Schedule)
if err != nil {
	return starts, fmt.Errorf("Unparseable schedule: %s : %s", sj.Spec.Schedule, err)
}
```

## おわりに

以上、k8s の CronJob のスケジューラーをソースからひもとき、ジョブが実行されないケースを5つほど見てきました。

思ったより長文になってしまいましたが、どなたかのお役に立てば幸いです。

最後に、本文中で参考リンクをまとめておきます

- ドキュメント
  - [Running Automated Tasks with a CronJob - Kubernetes](https://kubernetes.io/docs/tasks/job/automated-tasks-with-cron-jobs/#starting-deadline)
  - [CronJob - Kubernetes](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)
- ソースコード
  - [kubernetes/cronjob_controller.go at v1.13.0 · kubernetes/kubernetes](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/cronjob_controller.go)
  - [kubernetes/utils.go at v1.13.0 · kubernetes/kubernetes](https://github.com/kubernetes/kubernetes/blob/v1.13.0/pkg/controller/cronjob/utils.go#L95-L98)
- API ドキュメント
  - [Kubernetes API Reference Docs](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#cronjobspec-v1beta1-batch)