Monitoring Kubernetes CronJobs
A Kubernetes CronJob can stop doing its work without leaving a single failed pod behind. Pod-level alerts can’t see a job that was never started, so the reliable signal is the other way round: the job reports each successful run, and silence means something is wrong.
How CronJobs fail silently
- Too many missed schedules. If the controller couldn’t start the job for a while, for example during a control plane outage or upgrade, and it counts more than 100 missed start times, it stops starting the job and only logs an error.
startingDeadlineSecondslimits how far back it counts. - A hung run blocks the next ones. With
concurrencyPolicy: Forbida new run is skipped while the previous one is still active. One stuck pod stops every future run. suspend: trueleft behind after maintenance.- Pods that never start: an image that can’t be pulled, or not enough resources to schedule the pod.
- Evidence disappears. By default only the last 3 successful jobs and the last failed one are kept, so a failure from last week is gone.
- The wrong time zone. Without
spec.timeZone(stable since Kubernetes 1.27) the schedule follows the controller manager’s time zone, which is usually UTC.
A CronJob that reports
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-report
spec:
schedule: "17 3 * * *"
timeZone: "Europe/Berlin"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600
template:
spec:
restartPolicy: Never
containers:
- name: report
image: registry.example.com/report:1.4.2
env:
- name: PING_URL
valueFrom:
secretKeyRef:
name: heartcron
key: nightly-report
command: ["/bin/sh", "-c"]
args:
- |
wget -q -T 10 -O /dev/null "$PING_URL/start" || true
/app/report
code=$?
wget -q -T 10 -O /dev/null "$PING_URL/$code"
exit $code
What each part does:
activeDeadlineSecondskills a run that takes over an hour, so a hung pod can’t block the schedule forever.- With
startingDeadlineSeconds: 600the controller counts missed starts only within the last 10 minutes, so the limit of 100 can’t be reached; a run that couldn’t start in time is simply skipped. - The script reports the job’s exit code:
0marks the check as up, anything else sends an alert immediately.exit $codekeeps the job’s own status, so Kubernetes still retries failures according tobackoffLimit. /startlets Heartcron spot a run that started but never finished.
Store the ping URL in a secret so it isn’t in the manifest:
kubectl create secret generic heartcron \ --from-literal=nightly-report=https://ping.heartcron.com/<uuid>
Set up the check
Use the same cron expression and time zone as the CronJob. Make the grace period cover the starting deadline plus a normal run: with a 10-minute deadline and a 15-minute job, 30 to 40 minutes works. If a run is never started, finishes with an error or hangs past the grace period, you get an alert in Telegram, Slack, Discord or email.
When the alert comes
kubectl get cronjob nightly-report # SUSPEND and LAST SCHEDULE kubectl get jobs --sort-by=.metadata.creationTimestamp kubectl describe cronjob nightly-report # events, including missed starts kubectl logs job/<job-name>