Back to blog

The Kubernetes CronJob Deadlock That Stops Your Schedule For Good

A Kubernetes CronJob that misses more than 100 start times stops scheduling permanently and pages no one. Here is the mechanism, what trips it, and how to catch it.

CronGuard TeamCron Job Monitoring Experts
6 min read
A person at a desk in a dark room, lit by two monitors filled with lines of code, looking toward the camera, in black and white

The CronJob that quietly went dark

You run kubectl get cronjob and nothing looks wrong. SUSPEND reads False, ACTIVE reads 0. Then LAST SCHEDULE says 4d: no Job in four days. No failed pod, no CrashLoopBackOff, no warning event after the first hour. The controller stopped creating Jobs and never started again.

This is the missed-start deadlock, one of the few Kubernetes failures that is permanent without a human. Once it trips the schedule does not recover on its own, and by default nothing tells you.

How the controller decides whether to run

Counting missed start times

On each sync the CronJob controller looks at every scheduled time between the last Job it created and now, then acts on the most recent one. It walks forward from status.lastScheduleTime in schedule increments and counts the start times it steps over.

With startingDeadlineSeconds unset, that window has no floor: it runs from the last recorded schedule all the way to the current clock. The further behind the controller has fallen, the more start times land inside it. A */5 * * * * job books twelve per hour of downtime.

Where the number 100 comes from

The controller caps that count. Once it finds more than 100 missed start times, it abandons the calculation and logs a warning:

Cannot determine if job needs to be started: too many missed start times. Set or decrease .spec.startingDeadlineSeconds or check clock skew

The threshold lives in pkg/controller/cronjob/utils.go and has been 100 for years. The wording has drifted: older Kubernetes logged too many missed start times (> 100), current versions drop the number, so match on the phrase. The comment in the source is candid: a controller wedged "on Friday at 5:01pm" should let its hourly jobs catch up when someone restarts it on Tuesday, but a bug producing thousands of missed starts "would eat up all the CPU and memory of this controller," so the author "somewhat arbitrarily picked 100." The cap protects the controller, not your schedule.

What pushes a CronJob past 100

Control plane downtime

The obvious trigger is the controller being down: a managed control plane offline for nine hours during an upgrade, or a kube-controller-manager that crash-looped over a weekend. A one-minute CronJob racks up 540 missed starts in that time and comes back past the cap, unable to schedule again.

concurrencyPolicy Forbid plus a slow job

This one catches people out, because the cluster stays healthy. With concurrencyPolicy: Forbid, any scheduled time that arrives while the previous Job still runs is recorded as a missed start. A job that usually finishes in seconds but hangs for hours on a */1 schedule books one every minute it overruns, and enough of those trip the deadlock with no outage and no node pressure. A long suspend: true accrues missed starts the same way.

Why nothing pages you

The failure is silent by construction. The CronJob object still exists and reports SUSPEND: False, so a health check that lists CronJobs sees a healthy one. There is no Job, which means no pod and nothing for a pod-level alert to fire on. kubectl get cronjob shows a growing LAST SCHEDULE age, but only if a human runs it and reads that column.

Most alerts watch for the presence of a failure. This one produces no failing thing. It produces an absence.

Setting startingDeadlineSeconds, and its own trap

A deadline bounds the counting window

Setting startingDeadlineSeconds moves the floor of the window to now - startingDeadlineSeconds. The controller only counts starts missed inside that recent window, so the total can never climb toward 100, and the deadlock cannot form.

spec:
  schedule: "*/5 * * * *"
  startingDeadlineSeconds: 200
  concurrencyPolicy: Forbid

The tradeoff you are accepting

The setting that prevents the deadlock also throws away catch-up runs. With startingDeadlineSeconds: 200 on a five-minute job, a controller down for an hour starts one Job on recovery, not twelve. For idempotent work that is fine; for a job that must run every interval it is not, and either way the missed intervals vanish unless something outside the cluster is counting.

Catching it from outside

Watch lastScheduleTime, not Job status

The cluster-side signal is status.lastScheduleTime. Alert when the gap against the schedule exceeds one interval plus a margin:

kubectl get cronjob nightly-report \
  -o jsonpath='{.status.lastScheduleTime}'

kube-state-metrics exposes the same fact as kube_cronjob_status_last_schedule_time for Prometheus. Both depend on the very control plane that may be wedged, the weakness of every in-cluster check.

A success ping the cluster cannot fake

The check that survives a wedged controller lives outside it. Have the job confirm success to an external monitor, and alert on the ping that never arrives:

#!/bin/sh
set -euo pipefail

python generate_report.py

curl -fsS --retry 3 https://cronguard.app/api/ping/your-monitor-id

If the CronJob stops scheduling, the container never runs, the ping never fires, and the monitor alerts once the window closes. It knows nothing about missed-start counters, which is why it still works when the scheduler has quietly given up.

Frequently asked questions about the deadlock

Why does my Kubernetes CronJob stop creating Jobs with no error? The controller counts how many scheduled start times it missed since the last Job. Once that count passes 100 and startingDeadlineSeconds is not set, it stops scheduling and only logs a warning event that expires after about an hour. The object still reports SUSPEND False, so there is no failing pod and no obvious sign anything broke.

What does the "too many missed start times" message mean? It means the controller found more than 100 scheduled times between the last Job and now, and refused to enumerate them to avoid exhausting its own memory. Until you set or lower startingDeadlineSeconds, the job will not schedule again.

Does setting startingDeadlineSeconds fix the deadlock? Yes. It bounds the counting window to the last startingDeadlineSeconds, so the missed-start total can never reach 100. The tradeoff is that any run older than that deadline is skipped, so you trade a permanent stall for dropped catch-up runs.

Can a healthy cluster trigger this without any downtime? Yes. With concurrencyPolicy Forbid, every scheduled time that arrives while the previous Job is still running counts as a missed start. A frequent job that overruns for long enough can cross 100 while every node stays healthy, then never resumes.

How do I detect the deadlock before it causes damage? Watch status.lastScheduleTime and alert when the gap since the last schedule exceeds one interval plus a margin, using the kube_cronjob_status_last_schedule_time metric. Because that check leans on the same control plane that may be wedged, back it with an external dead man's switch expecting a success ping from each run.

Further reading


Conclusion: The missed-start deadlock is not a bug. It is a deliberate guard that stops one wedged CronJob from taking the controller down with it. The cost is a schedule that stalls forever and pages no one, because the failure is the absence of a Job rather than a broken one. Set startingDeadlineSeconds so the deadlock cannot form, decide whether you want catch-up runs, and put an external monitor on the success signal so the day a schedule goes dark, something is still counting.

Sources: Kubernetes CronJob documentation, CronJob controller source, pkg/controller/cronjob/utils.go.

Share

Related posts

A fiber-optic patch panel in a rack, green connectors and yellow cables looping across rows of numbered ports
ReliabilityWhen a Scheduled Job Runs Long: Overlap, Skip, or Replace
Close-up of a numbered network patch panel with blue and gray Ethernet cables plugged into labelled ports
DevOpsKubernetes Job Suspension and Why It Creates a Monitoring Blind Spot
Colorful syntax-highlighted source code on a black screen viewed at a slight angle, with a database query and functions like parseFloat and forEach visible in orange, blue and pink
ReliabilityThe Python Cron Parser Behind Airflow Is Not the Cron You Know

Set up your first monitor.
It'll take 30 seconds.