Lesson 4 - CronJobs

Welcome to CronJobs

Lesson 3’s Job ran once, on command, the moment you applied it. A lot of real batch work — a nightly aggregation, an hourly ingest check, a periodic cleanup — needs to run on a schedule instead, with nobody around to type kubectl apply at 2 a.m. A CronJob is exactly that: a template for creating Jobs, on a real cron schedule, that Kubernetes itself keeps firing indefinitely.

By the end of this lesson, you will be able to:

  • Write a CronJob with a real cron schedule and explain what each field controls.
  • Watch a CronJob create several genuinely separate Job runs over real time, with no command from you after the first apply.
  • Explain what successfulJobsHistoryLimit does, and watch it actually prune an old run.
  • Delete a CronJob and confirm it cascades to every Job it created.

A CronJob, scheduled every minute

Real production schedules are usually hourly or daily — this lesson uses a schedule fast enough to actually watch fire more than once while reading it, rather than take on faith:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: cityflow-heartbeat
  namespace: default
spec:
  schedule: "*/1 * * * *"
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      backoffLimit: 0
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: heartbeat
              image: nginx:alpine
              command: ["sh", "-c", "date -u +'%Y-%m-%dT%H:%M:%SZ CityFlow heartbeat OK'"]

schedule: "*/1 * * * *" is standard five-field cron syntax — minute, hour, day-of-month, month, day-of-week — meaning “every minute, every hour, every day.” Everything under jobTemplate.spec is a real Job spec, the exact same shape Lesson 3 used directly; a CronJob is, quite literally, a Job factory with a clock attached.

kubectl apply -f cronjob.yaml
kubectl get cronjob cityflow-heartbeat -n default
cronjob.batch/cityflow-heartbeat created
NAME                 SCHEDULE      TIMEZONE   SUSPEND   ACTIVE   LAST SCHEDULE   AGE
cityflow-heartbeat   */1 * * * *   <none>     False     0        <none>          0s

LAST SCHEDULE: <none> — nothing has fired yet. This is a real, live schedule; the first run happens at the next minute boundary the cluster’s clock crosses, not the instant you apply the manifest.


Watch it actually fire, more than once

Polling this cluster for several minutes shows exactly what a CronJob does on its own:

kubectl get cronjob cityflow-heartbeat -n default
kubectl get jobs -n default

At t+8s after the first minute boundary:

NAME                 SCHEDULE      TIMEZONE   SUSPEND   ACTIVE   LAST SCHEDULE   AGE
cityflow-heartbeat   */1 * * * *   <none>     False     0        8s              41s
NAME                          STATUS     COMPLETIONS   DURATION   AGE
cityflow-heartbeat-29744495   Complete   1/1           3s         8s

A real Job, cityflow-heartbeat-29744495, already Complete — created, run, and finished with no command typed after the original apply. A full minute later, a second, completely separate Job appears:

NAME                 SCHEDULE      TIMEZONE   SUSPEND   ACTIVE   LAST SCHEDULE   AGE
cityflow-heartbeat   */1 * * * *   <none>     False     0        9s              102s
NAME                          STATUS     COMPLETIONS   DURATION   AGE
cityflow-heartbeat-29744495   Complete   1/1           3s         69s
cityflow-heartbeat-29744496   Complete   1/1           3s         9s

...29744495 and ...29744496 — the trailing number is a Unix-minute index the CronJob controller uses to name each run uniquely, which is exactly why it climbs by one every time the schedule fires. A third run appears a minute after that:

NAME                          STATUS     COMPLETIONS   DURATION   AGE
cityflow-heartbeat-29744495   Complete   1/1           3s         2m1s
cityflow-heartbeat-29744496   Complete   1/1           3s         61s
cityflow-heartbeat-29744497   Running    0/1           1s         1s

Caught mid-flight this time — STATUS: Running, 0/1 completions, age 1 second — the actual moment a scheduled run is executing, a few seconds before it too reports Complete. Three real, independent Job objects, each with its own pod, its own logs, its own completion time, created by nothing but the passage of real time against this CronJob’s schedule.

kubectl logs cityflow-heartbeat-29744497-d6lpq -n default
kubectl logs cityflow-heartbeat-29744498-8lwmm -n default
kubectl logs cityflow-heartbeat-29744499-2mhlk -n default
2026-07-21T21:37:00Z CityFlow heartbeat OK
2026-07-21T21:38:00Z CityFlow heartbeat OK
2026-07-21T21:39:00Z CityFlow heartbeat OK

Real, distinct UTC timestamps, each landing right on a minute boundary — the schedule genuinely firing once per minute, not an artifact of how fast the pods happened to run.


successfulJobsHistoryLimit in action

This CronJob’s successfulJobsHistoryLimit: 3 caps how many completed Jobs it keeps around at once. Watched across five real runs, that limit visibly kicks in:

kubectl get jobs -n default

Once a fourth run (...29744498) completes, the oldest one is already gone:

NAME                          STATUS     COMPLETIONS   DURATION   AGE
cityflow-heartbeat-29744496   Complete   1/1           3s         2m13s
cityflow-heartbeat-29744497   Complete   1/1           3s         73s
cityflow-heartbeat-29744498   Complete   1/1           3s         13s

...29744495 — the very first run this lesson watched fire — has been deleted automatically, because keeping it would have meant 4 completed Jobs for a limit of 3. One run later, the same thing happens again to ...29744496:

NAME                          STATUS     COMPLETIONS   DURATION   AGE
cityflow-heartbeat-29744497   Complete   1/1           3s         2m4s
cityflow-heartbeat-29744498   Complete   1/1           3s         64s
cityflow-heartbeat-29744499   Complete   1/1           3s         4s

At every point in this sequence there are exactly 3 completed Jobs, never more — old runs quietly pruned as new ones land, so a schedule left running for weeks doesn’t accumulate an unbounded pile of finished Job objects. failedJobsHistoryLimit does the identical thing for failed runs, kept separately (and usually smaller, since a failing schedule is something you want to notice and fix rather than let scroll by).

Timeline diagram showing CronJob cityflow-heartbeat with schedule */1 * * * * firing five real successive times: Job 29744495 at t+8s, 29744496 at t+9s one minute later, 29744497 caught mid-run as Running 0/1 at age 1s then Complete, 29744498, and 29744499, each printing a real UTC heartbeat timestamp exactly one minute apart. A second panel shows successfulJobsHistoryLimit 3 in effect: once a 4th Job (29744498) completes, the oldest (29744495) is automatically deleted, and once a 5th (29744499) completes, the next oldest (29744496) is deleted too, keeping exactly 3 completed Jobs at any point. A caption notes the trailing number in each Job's name is a Unix-minute index, which is why it climbs by exactly one every time the schedule fires.
Five real, successive scheduled runs, with the oldest completed Job pruned automatically each time a new one would push the count past the history limit.

Clean up

kubectl delete cronjob cityflow-heartbeat -n default
kubectl get jobs -n default
cronjob.batch "cityflow-heartbeat" deleted
No resources found in default namespace.

One command removed the CronJob and every Job it had ever created, including the ones still within the history limit — the same cascading-delete behavior a Deployment’s ReplicaSet and a Job’s pods already showed you, one level further up the ownership chain: CronJob owns Jobs, which own pods, and deleting the top of that chain deletes all of it.


Practice Exercises

Exercise 1: Watch it fire yourself

Apply this lesson’s CronJob yourself and run kubectl get jobs -n default every 20-30 seconds for about 3 minutes. Confirm you see at least two separate Job names, each ending in a climbing Unix-minute index.

Hint

The first run won’t appear instantly — it fires at the next minute boundary the cluster’s clock crosses, which could be anywhere from a few seconds to just under a minute after you apply it.

Exercise 2: Lower the history limit and watch it prune faster

Change successfulJobsHistoryLimit to 1 and re-apply. After two or three more runs, confirm kubectl get jobs -n default never shows more than one completed Job at a time.

Hint

A change to successfulJobsHistoryLimit only affects pruning going forward — it won’t retroactively delete Jobs already kept under the old, higher limit until enough new runs happen to trigger cleanup.

Exercise 3: Suspend a CronJob without deleting it

Patch this lesson’s CronJob with kubectl patch cronjob cityflow-heartbeat -n default -p '{"spec":{"suspend":true}}', wait a couple of minutes, and confirm no new Jobs appear even though the CronJob object itself still exists.

Hint

suspend: true is the correct way to pause a schedule temporarily without losing its configuration or completed-Job history — kubectl get cronjob will still show it, just with new runs no longer firing until you set suspend back to false.


Summary

A CronJob is a Job factory with a real schedule attached — cityflow-heartbeat, on */1 * * * *, created five genuinely separate Job objects over five real minutes with no command from you after the initial apply, each with its own pod, its own real UTC-timestamped log line, and its own completion. Watching it across those five runs also showed successfulJobsHistoryLimit: 3 actually working: the oldest completed Job was deleted automatically each time a new one would have pushed the count past 3, keeping the object list bounded no matter how long the schedule keeps running. Deleting the CronJob cascaded to remove every Job it had created in one command.

Key Concepts

  • CronJob — a template for creating Jobs on a real cron schedule, rather than once on demand.
  • schedule — standard five-field cron syntax (minute, hour, day-of-month, month, day-of-week).
  • Job naming — each scheduled run gets a unique name ending in a Unix-minute index, which climbs by one every time the schedule fires.
  • successfulJobsHistoryLimit / failedJobsHistoryLimit — cap how many completed/failed Jobs a CronJob keeps around, pruning the oldest automatically.
  • Cascading delete — deleting a CronJob removes every Job (and every pod) it ever created.

Why This Matters

Almost every real data pipeline has at least one thing that has to happen on a schedule — an hourly ingest, a nightly rollup, a periodic cleanup — and running it by hand or from an external cron daemon outside the cluster means one more system to keep alive and one more place a schedule can silently stop firing without anyone noticing. A CronJob puts that scheduling logic inside Kubernetes itself, next to everything else this course has built, observable with the same kubectl get/describe/logs commands you already know. Lesson 5’s guided project uses exactly this mechanism — a real, original CityFlow batch task — to tie every piece of this module together on one real example.


Continue Building Your Skills

You’ve now seen a CronJob create real, successive Job runs on its own and prune its own history automatically. The guided project brings ConfigMaps, Secrets, storage, and Jobs together on one real CityFlow batch task: a job whose parameters come from a ConfigMap, that processes the real NYC taxi data this course has used since Course 1, and whose output survives on a PersistentVolumeClaim long after its own pod is gone.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com