Kubernetes CronJobs are where scheduled work goes to fail silently. A plain crontab has a short list of failure modes; a CronJob adds a controller that can skip runs, a scheduler that can refuse pods, an image registry that can be unreachable at 2 AM, eviction, quotas, and a handful of spec fields — suspend, concurrencyPolicy, startingDeadlineSeconds — each capable of stopping your job without an error landing anywhere you look. The job history self-prunes, so by morning even the evidence may be gone.
Our cron job heartbeat guide covers the general pattern: the job pings a unique URL on success, and silence triggers the alert. This post applies it to Kubernetes specifically — the failure modes unique to CronJobs, the YAML to wire a heartbeat in, how to size grace periods around backoffLimit and activeDeadlineSeconds, and a canary pattern that watches the cluster's ability to schedule anything at all.
The ways CronJobs fail silently
- Suspended and forgotten.
suspend: trueis the right move during a migration — and a one-line change nobody remembers to revert. The CronJob sits there looking configured; it just never runs. - Skipped by
concurrencyPolicy: Forbid. Forbid is usually correct for jobs that must not overlap, but it means one stuck run silently cancels every subsequent one. A backup hung on a network filesystem at 2 AM means no backup Tuesday, Wednesday, or any night until someone notices. - The 100-missed-schedules trap. If
startingDeadlineSecondsis unset and the controller misses more than 100 consecutive start times — a controller outage, or a long suspension on a frequent schedule — the CronJob stops scheduling permanently and logsCannot determine if job needs to be started: too many missed start times. SettingstartingDeadlineSecondsbounds the look-back window and avoids the trap. - Pods that never start.
ImagePullBackOffafter a registry credential rotation, unschedulable pods after a resource-request bump, quota exhaustion, taints. The Job exists; the work never happens. - Failed past
backoffLimit. After the retry budget (default 6) is spent, the Job is marked Failed — an event and a status field, not an alert. - Killed by
activeDeadlineSecondsor eviction. Deadline exceeded, node pressure, spot instance reclaimed — the pod is terminated mid-run, and unless something re-runs it, that window's work is simply missing. - Vanished evidence.
successfulJobsHistoryLimitdefaults to 3,failedJobsHistoryLimitto 1, andttlSecondsAfterFinisheddeletes finished Jobs outright. By the time you investigate,kubectl get jobsmay show nothing at all. - Exit 0 lies. The Kubernetes-independent classic: a shell pipeline where the last command succeeds masks the failed dump before it. Kubernetes faithfully reports the Job as Succeeded. Our backup monitoring guide covers the
set -euo pipefaildiscipline this requires.
What these share: none of them produce a failed HTTP request, and most don't even produce a Failed Job you'll see. Checking kubectl is an action someone must remember to take. Monitoring has to be structural.
The heartbeat pattern, in YAML
Create a heartbeat monitor in CronAlert for each critical CronJob, set the expected interval to the job's schedule, and make the final act of a successful run a ping to its URL. The && is the load-bearing operator — the curl runs only if the real work exited 0:
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-backup
spec:
schedule: "0 2 * * *"
timeZone: "Etc/UTC"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600 # bound the missed-schedule window
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 1800 # kill runs stuck past 30 min
template:
spec:
restartPolicy: Never
containers:
- name: backup
image: registry.example.com/backup:1.4
command: ["/bin/sh", "-c"]
args:
- |
set -euo pipefail
/app/run-backup.sh
curl -fsS --retry 3 \
https://cronalert.com/api/heartbeat/TOKEN Details that matter. set -euo pipefail makes the script exit on the first failure, so the curl is unreachable unless everything before it succeeded — with plain && chains, a mid-pipeline failure can otherwise slip through. curl -fsS --retry 3 treats HTTP errors as failures and rides out a transient network blip without masking real trouble. And startingDeadlineSeconds plus activeDeadlineSeconds aren't just monitoring hygiene — they cap the two unbounded failure modes (never-starts and never-finishes) so your grace period has something firm to lean on. Store the heartbeat token in a Secret and inject it as an env var if your manifests are public.
Sizing the grace period
A heartbeat monitor needs to know how much silence is normal. Build the number bottom-up: expected schedule interval, plus the job's worst-case runtime (bounded by activeDeadlineSeconds), plus retry headroom (backoffLimit × runtime for short jobs), plus a few minutes of scheduling and image-pull slack. For the nightly backup above — 30-minute deadline, 2 retries — pinging by 02:35 is normal and 04:00 is not, so a grace period of about 90 minutes past the scheduled time alerts the same night without paging on a slow run. For a */10 job, a grace period of one to two missed intervals is the equivalent rule of thumb. Expect to tune once after a week of real timings; our heartbeat guide covers variable-duration jobs.
The canary CronJob: monitoring the scheduler itself
Per-job heartbeats tell you a specific workload succeeded. One more cheap monitor tells you the platform can run workloads at all:
apiVersion: batch/v1
kind: CronJob
metadata:
name: heartbeat-canary
spec:
schedule: "*/5 * * * *"
startingDeadlineSeconds: 120
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: ping
image: curlimages/curl:latest
args: ["-fsS", "https://cronalert.com/api/heartbeat/CANARY_TOKEN"] If the canary goes quiet, the problem is upstream of any individual job: the CronJob controller, node capacity, registry access, or cluster egress. That's your signal that every scheduled workload is at risk — and because the alert comes from outside the cluster, it still fires when the cluster's own monitoring stack is down with it. This is the same monitor-outside-the-failure-domain argument from our Kubernetes uptime guide, applied to batch workloads.
What about Prometheus and kube-state-metrics?
If you already run the Prometheus stack, alerts on kube_job_status_failed and a stale kube_cronjob_status_last_schedule_time are worth having — they carry cluster-side detail a heartbeat can't, like why a pod is Pending. The limits are operational: you're maintaining Prometheus, Alertmanager, and rule hygiene, the metrics only cover what kube-state-metrics sees (a Succeeded job that did nothing still looks fine), and the whole pipeline lives inside the failure domain it's watching. Heartbeats invert all three: one curl per job, success defined by your script rather than pod phase, alerting from outside the cluster. Small teams can skip the stack entirely; larger ones run both and let them cover each other. See batch job monitoring for the broader decision framework.
Setting it up in CronAlert
- Create a heartbeat monitor per critical CronJob — heartbeats are available on every paid plan, from $5/mo. Set the expected interval to the job's schedule and the grace period per the sizing rule above.
- Add the ping as the final step of the container command, behind
set -euo pipefail, with the token injected from a Secret. - Deploy the canary CronJob on a
*/5schedule with its own heartbeat. - Wire alerts where your team lives — email, Slack, Discord, Teams, Telegram, PagerDuty, Opsgenie, Splunk On-Call, webhooks, or PWA push.
- Cover the cluster's HTTP surface too: CronAlert's free plan (25 monitors, 3-minute interval) handles your ingress, API, and SSL certificates alongside the heartbeats.
The REST API (read-only on free, read-write from Pro) and MCP server let you create the monitor fleet from a script or an AI assistant — useful when you have thirty CronJobs to instrument, not three.
Frequently asked questions
How do I know if a Kubernetes CronJob actually ran?
kubectl get cronjob shows the last schedule time, not whether the work succeeded — and history limits plus ttlSecondsAfterFinished erase the evidence quickly. A success-gated heartbeat ping converts every failure mode into one signal: the ping stops, you get alerted.
Why did my Kubernetes CronJob stop being scheduled?
Check for suspend: true left over from maintenance, a stuck run blocking everything under concurrencyPolicy: Forbid, or the 100-missed-start-times trap when startingDeadlineSeconds is unset. Set startingDeadlineSeconds to bound the window, and let a heartbeat surface the silence either way.
Do I need Prometheus to monitor CronJobs?
No — kube-state-metrics alerts are valuable if you already run the stack, but they live inside the failure domain and require real upkeep. A heartbeat is one curl per job and alerts from outside the cluster. Many teams run both.
What heartbeat grace period should I use?
Schedule interval + worst-case runtime (cap it with activeDeadlineSeconds) + retry headroom + a few minutes of scheduling slack. For a nightly job with a 30-minute deadline, roughly 90 minutes past the scheduled start; for frequent jobs, one to two missed intervals.
How do I monitor whether the cluster can run CronJobs at all?
A canary CronJob on a */5 schedule that only curls a heartbeat URL. When it goes quiet, the platform — controller, capacity, registry, egress — is the problem, and every scheduled workload is suspect.
Make silent skips impossible
Every CronJob failure mode on this page — suspended, skipped, unscheduled, unschedulable, killed, or "succeeded" without doing the work — ends the same way: the heartbeat ping doesn't arrive, and you get an alert instead of a surprise. Instrumenting a job is one curl behind set -euo pipefail; the canary is ten lines of YAML. Create a free CronAlert account, add heartbeats from the $5/mo Pro plan, and find out about the stuck backup at 2:35 AM — not at restore time.
Related reading: cron job heartbeat monitoring, Kubernetes uptime monitoring, batch job monitoring, and monitoring scheduled database backups.