ある朝、バックアップジョブの最終成功時刻を確認して青ざめたエンジニアがいた。ダッシュボード上のCronJobステータスはずっと「Succeeded」のまま。ところが実際には、そのバックアップは24日間一度も実行されていなかった。標準的な監視では、この種の障害はまったく健全に見える。
K3sやKubernetesのCronJobは、定期実行の仕組みとしては単純に見える。しかしconcurrencyPolicy、startingDeadlineSeconds、100回ミス上限、Exit Codeの意味という4つの要素が特定の組み合わせで重なると、ステータスは正常なまま実行だけが止まるという厄介な障害を引き起こす。単体で見れば公式ドキュメントに書いてある仕様通りの挙動なのに、組み合わさると誰にも気づかれない沈黙になる。
この記事では、実際に報告されているインシデント事例をもとに、K3s上のCronJobが静かに止まる4つの罠を1つずつ分解し、最後にKubernetesネイティブな監視だけでは不十分な理由と、気づける監視への変え方を整理する。
1. CronJobのステータスは緑のまま、バックアップは24日間止まっていた

ある事例では、夜間バックアップ用のCronJobが24日間にわたって実行されずに放置されていたことが報告されている。CronJobのステータス自体は「Succeeded」を示し続けており、外形的な異常は一切見られなかった。気づいたきっかけは、たまたま最終成功時刻を手動で確認したことだったという(dev.to の分析記事)。
この事例が示すのは、CronJobコントローラが「異常」だと自己申告してくれるとは限らないということだ。Kubernetesクラスタ自体が健全であっても、CronJobだけが静かに止まっているケースは十分にあり得る。この手の"静かな失敗"に気づけるかどうかは、CronJobの監視が最初から標準で組み込まれているかどうかで大きく変わる(Kubo Cloudのようにモニタリング基盤を標準搭載したK3s環境かどうかも、その分かれ目の一つだ)。以降の章では、この沈黙がどのような設定の組み合わせから生まれるのかを、公式ドキュメントの仕様に沿って分解していく。
2. 罠1: concurrencyPolicy: Forbid は「静かにスキップ」を選ぶ

CronJobのconcurrencyPolicyには3つの選択肢がある。Kubernetes公式ドキュメントによれば、それぞれの挙動は次の通りだ。
- Allow(デフォルト): 前回のJobが実行中でも、新しいJobを並行して起動する
- Forbid: 前回のJobが終わっていなければ、新しいJobの起動自体をスキップする
- Replace: 実行中のJobを打ち切り、新しいJobに置き換える
多くの現場では、バックアップやデータ同期のように「二重実行されると困る」処理に対してForbidを選ぶ。実際、ZOZO TECH BLOGのCronJob運用事例でも、監視対象のCronJobにconcurrencyPolicyと合わせてactiveDeadlineSeconds・backoffLimitを組み合わせた設定例が紹介されている。
問題は、Forbidが選んだ「スキップ」が、Job自体を作らない静かな挙動だという点にある。前段のJobがいつまでも終わらなければ、その間のスケジュールは記録も残らずに消えていく。ここに次の罠が重なる。
3. 罠2: startingDeadlineSeconds が「守ってくれる」とは限らない

startingDeadlineSecondsは、スケジュール時刻からJobの起動が何秒遅れたら諦めるかを定義するフィールドだ。公式ドキュメントには次のように明記されている。
Jobが予定時刻に開始できず、
startingDeadlineSecondsで指定した秒数を超えた場合、そのJobの実行はスキップされる。未指定の場合は期限なしとして扱われる。
一見単純な設定に見えるが、concurrencyPolicy: Forbidと組み合わせると挙動が複雑になる。前回のJobが実行中の間はスキップされ続け、ようやく前回Jobが完了した時点でstartingDeadlineSecondsが再評価される。その時点ですでに期限を過ぎていれば、待たされていた実行はそのまま消える。
Mercari Engineeringのブログでは、このstartingDeadlineSecondsを「実質必須」の設定として扱うべきだと述べられている。未設定のまま長時間の障害やメンテナンスウィンドウが発生すると、次章で説明する上限に直結してしまうからだ。
4. 罠3: 100回ミス上限とクロックスキューの罠

CronJobコントローラには、連続100回のスケジュールミスでJob生成自体を止めるという仕様がある。Platform9のナレッジベースには、実際に出力されるエラーメッセージが記録されている。
Cannot determine if job needs to be started: too many missed start time (> 100).
Set or decrease .spec.startingDeadlineSeconds or check clock skew
Mercari Engineeringの解説によれば、この上限に到達する経路は必ずしも「本当に100回失敗した」場合だけではない。CronJobをsuspend: trueにして数日放置しただけでも、その間のスケジュールがミスとしてカウントされ、上限に達してしまうという。
さらにPlatform9の記録では、status.lastScheduleTimeが未設定・null状態になっていると、コントローラはmetadata.creationTimestampという古い日時まで遡ってミス回数を計算してしまい、実際には正常に動いていたはずのCronJobでも100回超え扱いになるケースがあるとされている。ノード間のクロックスキュー(時刻ズレ)も同様の誤検知を誘発する要因だ。
一度この状態に陥ると、パッチ的な修正では復旧しないことが多い。両ブログとも、CronJobリソースそのものを再作成することを推奨している。
5. 罠4: Exit Code 0 は「仕事をした」ことを保証しない

ここまでの3つの罠は「Jobが起動すらされない」パターンだった。しかしJobが起動しても、まだ油断はできない。
Kubernetes公式ドキュメントのJobリソースの説明にある通り、KubernetesはUnixの慣習に従い、コンテナがExit Code 0で終了すれば、それだけで「成功」と判定する。処理対象が空だったのか、意図した件数を処理できたのかは、Kubernetes自身は関知しない。
dev.toの分析記事では、この性質が「空のキューを処理しただけ」「0バイトのバックアップを作っただけ」でも成功扱いになってしまう問題として指摘されている。加えて、successfulJobsHistoryLimit(既定3)・failedJobsHistoryLimit(既定1)による自動ガベージコレクションで、障害調査に必要なログや実行履歴も早期に消えてしまう。
つまり「Job自体は起動されているのに、実質何もしていない」という4つ目の沈黙が、ここに存在する。
6. 気づける監視に変える — statusフィールドとメトリクス、外形監視

ここまでの4つの罠に共通するのは、Kubernetes内部の仕組みだけでは異常が自己申告されないという点だ。OneUptimeの監視ガイドは、これに対する監視設計として次のポイントを挙げている。
- CronJobの
status.lastScheduleTime・status.lastSuccessfulTime・status.activeを定期的に確認する - kube-state-metricsが公開する
kube_cronjob_status_last_schedule_time・kube_cronjob_status_last_successful_timeをPrometheusで収集する - アラート閾値は想定実行間隔の2倍を目安にする(日次ジョブなら約48時間、毎時ジョブなら約2時間が目安)
- クラスタ内部の状態に依存せずに検知できる外形監視(dead man's switch)を併用する
ZOZO TECH BLOGの事例でも、Kubernetesネイティブな仕組みだけに頼らず、外部のダッシュボードやアラート基盤と組み合わせて閾値ベースの通知を行う構成が紹介されている(同記事)。クラスタ内のコントローラが沈黙する可能性がある以上、クラスタの外側から「最後に成功したのはいつか」を確認する仕組みを持つことが、この4つの罠すべてに対する共通の防御線になる。
このあたりの監視基盤を自前で組むと、Prometheus・Grafanaの構築だけでも相応の工数がかかる。Kubo CloudはK3sベースのマネージドKubernetes基盤で、Prometheus + Grafanaを標準搭載しているため、CronJobのstatusメトリクスを可視化するダッシュボードをゼロから組む必要がない。
まとめ
CronJobが静かに止まる背景には、単一の設定ミスではなく、concurrencyPolicy・startingDeadlineSeconds・100回ミス上限・Exit Code 0という4つの仕様が組み合わさった結果としての沈黙がある。それぞれは公式ドキュメント通りの正しい挙動であり、だからこそ気づきにくい。
対策の本質は「Kubernetesが異常を教えてくれる」という前提を捨て、lastScheduleTimeのようなstatusフィールドとメトリクスを外部から監視する仕組みを最初から組み込んでおくことにある。
K3sクラスタでこうした監視をどこまで自前で運用し続けるか、あるいは標準搭載の監視基盤に任せるかは、チームのリソース次第だ。Kubo CloudならPrometheus + Grafanaが最初から組まれた状態でK3sクラスタを立ち上げられるため、この記事で挙げたようなCronJobの監視設計を、構築フェーズからやり直さずに済む。まずは料金プランで、今のCronJob運用にかかっているコストと比較してみてほしい。
もしオンプレミス環境や閉域網でCronJobを運用していて、外形監視の経路確保自体が課題になっているなら、Kubo On-Premiseという選択肢もある。データを外に出さずに、同様の監視設計を組み込むことができる。