60.7 CronJobs: schedule, concurrencyPolicy, history limits and time zones
Why: the problem
Suppose HDFC Bank must send a daily statement report at 6 AM India time, clear expired OTP records every 15 minutes, and back up a database every night at 2 AM. On a normal server you would write a crontab line. But in Kubernetes, pods move between nodes, and a crontab on one node dies with that node. A CronJob is cron for the whole cluster. At each scheduled time it creates a Job, and the Job creates pods. If a node dies, the next run simply lands on another node. The hard parts are not the schedule. They are: what if the last run is still going when the next one starts? What if the cluster was down at 6 AM? Which time zone is 6 AM? How many old Jobs do we keep? In this lesson we write CronJobs with every important field, run one by hand, stop overlaps with concurrencyPolicy, set the Asia/Kolkata time zone, and suspend a CronJob during maintenance. Then we break it with a bad schedule, overlapping runs and missed schedules.
What: every term in plain English
- CronJob — A batch/v1 controller that creates a Job on a cron schedule.
- schedule — Five fields: minute, hour, day of month, month, day of week. */15 * * * * means every 15 minutes.
- timeZone — An IANA zone like Asia/Kolkata. Without it, the schedule uses the controller's zone, usually UTC.
- concurrencyPolicy — Allow (default), Forbid (skip the new run if the old one is running), or Replace (kill the old one).
- startingDeadlineSeconds — If a run is missed by more than this, skip it.
- successfulJobsHistoryLimit — How many finished Jobs to keep. Default 3.
- failedJobsHistoryLimit — How many failed Jobs to keep. Default 1.
- suspend — true stops new runs; running Jobs continue.
- jobTemplate — The full Job spec that each run copies.
- lastScheduleTime — Status field showing when the CronJob last created a Job.
How: step by step
- Write the schedule. Test it with a cron helper; remember the five fields.
- Set timeZone. Asia/Kolkata for Indian business times.
- Pick a concurrencyPolicy. Forbid for backups and reports, Replace for 'latest wins' syncs.
- Add limits. backoffLimit, activeDeadlineSeconds and history limits.
- Apply and trigger once by hand. kubectl create job --from=cronjob/NAME.
- Watch runs. kubectl get cronjob, jobs and pods.
- Suspend for maintenance. Patch suspend: true, then false.
Example 1: Daily report at 6 AM India time
Goal. Daily report at 6 AM India time.
daily-report-cron.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: daily-report
namespace: shop
spec:
schedule: "0 6 * * *"
timeZone: "Asia/Kolkata"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 2
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 900
template:
metadata:
labels:
app: daily-report
spec:
restartPolicy: Never
containers:
- name: report
image: busybox:1.36
command: ["sh", "-c"]
args:
- echo "statement report for $(date +%F) started";
sleep 5;
echo "report sent"
resources:
requests: {cpu: 50m, memory: 32Mi}
limits: {memory: 64Mi}
What each line does
schedule 0 6 * * *— Minute 0, hour 6, every day.timeZone Asia/Kolkata— 6 AM IST, not 6 AM UTC (11:30 AM IST).concurrencyPolicy: Forbid— If yesterday's run is somehow still running, skip today's.startingDeadlineSeconds: 600— If the controller was down at 6:00 and comes back by 6:10, it still runs; later, it skips.History limits— Keep 3 good and 2 failed Jobs for logs.jobTemplate— Everything from 60.6 applies inside.
Green output
$ kubectl apply -f daily-report-cron.yaml
cronjob.batch/daily-report created
$ kubectl get cronjob daily-report -n shop
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
daily-report 0 6 * * * Asia/Kolkata False 0 <none> 5s
$ kubectl create job manual-report -n shop --from=cronjob/daily-report
job.batch/manual-report created
$ kubectl logs -n shop job/manual-report
statement report for 2026-10-25 started
report sent
Expected result. The CronJob waits for 6 AM IST. We did not wait: --from=cronjob ran the same template now. Always test this way before trusting a schedule.
Example 2: Every minute, with Forbid vs Allow overlaps
Goal. Every minute, with Forbid vs Allow overlaps.
otp-cleanup-cron.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: otp-cleanup
namespace: shop
spec:
schedule: "* * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: cleanup
image: busybox:1.36
command: ["sh", "-c", "echo cleanup start $(date +%T); sleep 90; echo cleanup end $(date +%T)"]
What each line does
schedule * * * * *— Every minute, the fastest cron allows.sleep 90— Each run takes longer than the gap, on purpose.Forbid— The run at minute 2 is skipped because minute 1 is still active.Allow (default)— Would start a second Job, and two cleanups would fight over the same rows.History limit 2— Only the last 2 finished Jobs stay.
Green output
$ kubectl apply -f otp-cleanup-cron.yaml
$ kubectl get jobs -n shop -l batch.kubernetes.io/cronjob-name=otp-cleanup -w
NAME STATUS COMPLETIONS DURATION AGE
otp-cleanup-29358421 Running 0/1 30s 30s
otp-cleanup-29358421 Complete 1/1 93s 93s
otp-cleanup-29358423 Running 0/1 2s 2s
$ kubectl get cronjob otp-cleanup -n shop
NAME SCHEDULE SUSPEND ACTIVE LAST SCHEDULE
otp-cleanup * * * * * False 1 2s
Expected result. There is no Job for minute 22. Forbid skipped it. The number in the Job name is the scheduled time in minutes since 1970.
Example 3: Nightly backup with Replace, suspend and a real-looking command
Goal. Nightly backup with Replace, suspend and a real-looking command.
backup-cron.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: db-backup
namespace: shop
spec:
schedule: "0 2 * * *"
timeZone: "Asia/Kolkata"
concurrencyPolicy: Replace
suspend: false
jobTemplate:
spec:
backoffLimit: 1
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
containers:
- name: backup
image: mysql:8.4
envFrom:
- secretRef:
name: mysql-auth
command: ["sh", "-c"]
args:
- mysqldump -h mysql-0.mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --all-databases > /backup/all-$(date +%F).sql &&
ls -lh /backup
volumeMounts:
- name: backup
mountPath: /backup
volumes:
- name: backup
emptyDir: {}
What each line does
0 2 * * *— 2 AM IST every night.Replace— If last night's backup is stuck, kill it and start fresh.secretRef mysql-auth— Same Secret as the StatefulSet lab in 60.4; apply mysql-secret.yaml, mysql-headless.yaml and mysql-sts.yaml from there first.mysql-0.mysql— Stable DNS name from the headless Service.emptyDir /backup— Lab only: the file vanishes with the pod. In production write to a PVC or upload to S3 (chapter 61).ttlSecondsAfterFinished: 86400— Each Job is removed a day later.
Green output
$ kubectl apply -f backup-cron.yaml
$ kubectl create job backup-now -n shop --from=cronjob/db-backup
$ kubectl logs -n shop job/backup-now
-rw-r--r-- 1 root root 3.1M Oct 25 06:41 all-2026-10-25.sql
$ kubectl patch cronjob db-backup -n shop -p '{"spec":{"suspend":true}}'
cronjob.batch/db-backup patched
$ kubectl get cronjob db-backup -n shop
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE
db-backup 0 2 * * * Asia/Kolkata True 0
Expected result. The dump worked. During a planned database upgrade we suspend the backup so it does not run against a half-upgraded server.
Every command, step by step (kind lab with kubectl)
-
Explain the CronJob spec
kubectl explain cronjob.spec | grep -E 'schedule|timeZone|concurrency|suspend|History'What it does: All the fields we use.
concurrencyPolicy <string> failedJobsHistoryLimit <integer> schedule <string> -required- successfulJobsHistoryLimit <integer> suspend <boolean> timeZone <string> -
Create one from the CLI
kubectl create cronjob ping -n shop --image=busybox:1.36 --schedule='*/5 * * * *' -- echo pingWhat it does: Quick CronJob without YAML.
cronjob.batch/ping created -
Apply the daily report
kubectl apply -f daily-report-cron.yaml kubectl get cronjob -n shopWhat it does: Shows SCHEDULE and TIMEZONE.
daily-report 0 6 * * * Asia/Kolkata False 0 <none> -
Run it now
kubectl create job manual-report -n shop --from=cronjob/daily-report kubectl wait -n shop --for=condition=complete job/manual-report --timeout=60sWhat it does: The standard way to test a CronJob.
job.batch/manual-report condition met -
Watch overlap handling
kubectl apply -f otp-cleanup-cron.yaml kubectl get jobs -n shop -wWhat it does: Leave it for 3 minutes and count Jobs.
otp-cleanup-29358421 Running 0/1 -
Read events for skips
kubectl describe cronjob otp-cleanup -n shop | tail -5What it does: Shows SuccessfulCreate and, with Forbid, no Job for busy minutes.
Normal SuccessfulCreate cronjob-controller Created job otp-cleanup-29358421 Normal SawCompletedJob cronjob-controller Saw completed job: otp-cleanup-29358421, status: Complete -
Change the schedule
kubectl patch cronjob otp-cleanup -n shop -p '{"spec":{"schedule":"*/15 * * * *"}}'What it does: Unlike Jobs, CronJob fields can be edited; the next Job uses them.
cronjob.batch/otp-cleanup patched -
Suspend everything in a namespace
for c in $(kubectl get cronjob -n shop -o name); do kubectl patch $c -n shop -p '{"spec":{"suspend":true}}'; doneWhat it does: Handy before maintenance.
cronjob.batch/daily-report patched cronjob.batch/otp-cleanup patched -
Clean up
kubectl delete cronjob -n shop daily-report otp-cleanup db-backup ping kubectl delete job -n shop manual-report backup-now --ignore-not-foundWhat it does: Deleting a CronJob deletes its Jobs too.
cronjob.batch "daily-report" deleted
When it is red 1
The CronJob "daily-report" is invalid: spec.schedule: Invalid value: "0 6 * *": expected exactly 5 fields, found 4
Cause. The schedule has the wrong number of fields.
Fix, step by step
- Use 5 fields: minute hour day month weekday.
- Check with kubectl apply --dry-run=server.
When it is red 2
spec.timeZone: Invalid value: "IST": unknown time zone IST
Cause. Short names like IST are not valid IANA zones.
Fix, step by step
- Use Asia/Kolkata.
When it is red 3
Warning TooManyMissedTimes Cannot determine if job needs to be started: too many missed start times (> 100). Set or decrease .spec.startingDeadlineSeconds or check clock skew
Cause. The CronJob was suspended or the controller was down for a long time with a frequent schedule.
Fix, step by step
- Set startingDeadlineSeconds, for example 300.
- Re-apply; future runs work normally.
When it is red 4
two report emails arrived at 6:00
Cause. concurrencyPolicy Allow and a slow previous run, or two CronJobs with the same work.
Fix, step by step
- Set concurrencyPolicy: Forbid.
- Make the job idempotent: check if today's report is already sent.
- kubectl get cronjob -A | grep report to find duplicates.
Practice tasks
Easy Task 1: Create
Why: First CronJob.
What to do: Create ping with kubectl create cronjob.
- --schedule.
Solution
kubectl create cronjob ping -n shop --image=busybox:1.36 --schedule='*/5 * * * *' -- echo pingEasy Task 2: Run now
Why: Testing.
What to do: Trigger daily-report by hand.
- --from=cronjob.
Solution
kubectl create job manual-report -n shop --from=cronjob/daily-reportEasy Task 3: Read
Why: Status.
What to do: Print lastScheduleTime of ping after 5 minutes.
- jsonpath status.
Solution
kubectl get cronjob ping -n shop -o jsonpath='{.status.lastScheduleTime}'Medium Task 4: Time zone
Why: IST.
What to do: Change ping to timeZone Asia/Kolkata and a 9:30 schedule.
- 30 9 * * *.
Solution
kubectl patch cronjob ping -n shop -p '{"spec":{"timeZone":"Asia/Kolkata","schedule":"30 9 * * *"}}'Medium Task 5: Forbid
Why: Overlap.
What to do: Run otp-cleanup for 3 minutes and count Jobs.
- List jobs.
Solution
kubectl get jobs -n shop -l batch.kubernetes.io/cronjob-name=otp-cleanupMedium Task 6: Replace
Why: Overlap 2.
What to do: Change otp-cleanup to Replace and watch the old Job get killed.
- Events.
Solution
kubectl patch cronjob otp-cleanup -n shop -p '{"spec":{"concurrencyPolicy":"Replace"}}'Medium Task 7: History
Why: Limits.
What to do: Set successful limit 1 and see older Jobs go.
- patch.
Solution
kubectl patch cronjob otp-cleanup -n shop -p '{"spec":{"successfulJobsHistoryLimit":1}}'Medium Task 8: Suspend
Why: Maintenance.
What to do: Suspend db-backup and confirm SUSPEND True.
- patch suspend.
Solution
kubectl patch cronjob db-backup -n shop -p '{"spec":{"suspend":true}}'Hard Task 9: Weekdays
Why: Real schedule.
What to do: Do the main task schedule.
- */10 9-17 * * 1-5.
Solution
schedule: "*/10 9-17 * * 1-5"Hard Task 10: Backup
Why: Real work.
What to do: Run backup-now against the 60.4 MySQL and print the file size.
- Needs mysql-0 running.
Solution
kubectl logs -n shop job/backup-nowHomework: try at home
Write a CronJob that runs every 10 minutes from 9 AM to 6 PM IST on weekdays only, uses Forbid, keeps 5 successful Jobs and deletes each Job after 1 hour. Prove it with kubectl create job --from.
Recap
- A CronJob creates a Job on a cron schedule, cluster-wide.
- Set timeZone, for example Asia/Kolkata, or schedules run in UTC.
- concurrencyPolicy Forbid or Replace prevents overlapping runs.
- startingDeadlineSeconds and history limits keep it healthy and tidy.
- Test with kubectl create job --from=cronjob; suspend during maintenance.
- Next, 60.8 compares every controller in one lab.
FAQ
Q. Is a CronJob exact to the second?
No. It starts within a few seconds of the minute.
Q. Can a schedule run every 30 seconds?
No. One minute is the smallest step. Use a Deployment with a loop for faster work.
Q. What happens to running Jobs if I delete the CronJob?
They are deleted too, by cascade, unless you use --cascade=orphan.
Q. Does daylight saving matter in India?
Not for Asia/Kolkata, but it does for zones like Europe/London.
Q. Can I use @daily or @hourly?
Yes, those macros are supported.
Q. Where does the CronJob loop run?
In the cronjob controller in kube-controller-manager.
Interview questions
-
How does a CronJob work?
Model answer: The cronjob controller checks schedules and, at each due time, creates a Job from jobTemplate; the Job creates pods.
-
Explain concurrencyPolicy.
Model answer: Allow runs Jobs in parallel, Forbid skips a new run while one is active, Replace cancels the running Job and starts the new one.
-
How do you make a CronJob use IST?
Model answer: Set spec.timeZone: Asia/Kolkata (stable since Kubernetes 1.27).
-
How do you test a CronJob without waiting?
Model answer: kubectl create job test --from=cronjob/NAME.
-
A CronJob stopped running. What do you check?
Model answer: suspend, lastScheduleTime, events like TooManyMissedTimes, startingDeadlineSeconds, failed Jobs and their pod logs.
Quick quiz
Q1. What does */15 * * * * mean?
Every 15 minutes.
Q2. Which time zone is used without timeZone?
The controller manager's, usually UTC.
Q3. Which policy skips a run if the last is still active?
Forbid.
Q4. How do you run a CronJob right now?
kubectl create job NAME --from=cronjob/CRON.
Q5. Default successful history limit?
3.
Samajla ka?