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: true is 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 startingDeadlineSeconds is 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 logs Cannot determine if job needs to be started: too many missed start times. Setting startingDeadlineSeconds bounds the look-back window and avoids the trap.
  • Pods that never start. ImagePullBackOff after 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 activeDeadlineSeconds or 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. successfulJobsHistoryLimit defaults to 3, failedJobsHistoryLimit to 1, and ttlSecondsAfterFinished deletes finished Jobs outright. By the time you investigate, kubectl get jobs may 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 pipefail discipline 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 */5 schedule 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, monitoring scheduled database backups, monitoring GitHub Actions scheduled workflows, monitoring Vercel cron jobs, monitoring Celery Beat, and monitoring Heroku Scheduler jobs.