Sign in
← Kubernetes: workloads
6 min · easy · Lesson 7 of 11

CronJob: scheduled tasks

The schedule runs in UTC, concurrencyPolicy, history limits, and other spots where a CronJob catches you off guard.

Why

Almost every service has recurring tasks: a backup once an hour, cleanup of stale records every night, syncing with an external API every 15 minutes. This used to mean keeping a cron job on a separate VM. In Kubernetes there's an object for this, CronJob: the schedule lives in the cluster next to the application, and the task itself is run by an ordinary Job.

What a CronJob is

CronJob schedule, timeZone on tick Job copy of jobTemplate creates Pod command, exit 0
Two layers: the schedule lives in the CronJob, the work in the Pod.

A CronJob sits one level above a Job. It doesn't create the Pod itself. On schedule, a CronJob creates a new Job, and the Job controller creates the Pod and waits for it to finish. So everything you already know about Jobs (backoffLimit, restartPolicy, logs from a failed Pod) applies here too.

CronJob is batch/v1, stable since Kubernetes 1.21. The old batch/v1beta1 was removed in 1.25; a new cluster won't accept manifests that use it. The schedule is handled by the CronJob controller inside kube-controller-manager: it computes the next run time and checks each CronJob roughly once every 10 seconds.

Minimal YAML

apiVersion: batch/v1
kind: CronJob
metadata:
  name: hello-cron
spec:
  schedule: "* * * * *"
  successfulJobsHistoryLimit: 2
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: hello
              image: busybox:1.36.1
              command: ["sh", "-c", "echo tick"]

Note the nesting: the CronJob's spec → jobTemplate.spec (the Job's spec) → template.spec (the Pod's spec). Job fields like backoffLimit go in jobTemplate.spec, Pod fields like restartPolicy go one level deeper still.

A CronJob's name can be at most 52 characters. The controller appends a suffix to it to get the Job's name, and a Job's limit is 63 characters.

Field breakdown

Without spec.timeZone, the schedule is evaluated using the kube-controller-manager's clock. Almost everywhere, including kind, that's UTC. 0 3 * * * fires at 03:00 UTC, which is 06:00 Moscow time.

How a CronJob creates a Job

A Job created on schedule is named <CronJob name>-<number>. The number is the scheduled run time in minutes since 1970, so all Jobs from the same CronJob share a prefix:

kubectl get jobs
NAME                    STATUS     COMPLETIONS   DURATION   AGE
slow-allow-29859640     Running    0/1           76s        76s
slow-allow-29859641     Running    0/1           16s        16s
tick-29859640           Complete   1/1           6s         76s
tick-29859641           Complete   1/1           4s         16s

Each Job points back to its CronJob through metadata.ownerReferences. So kubectl delete cronjob also deletes its Jobs, and their Pods along with them.

A few things that aren't obvious:

restartPolicy inside jobTemplate

Only OnFailure or Never, same as an ordinary Job. The API rejects Always:

spec.jobTemplate.spec.template.spec.restartPolicy: Required value: valid values: "OnFailure", "Never"

The logic is the same as for a Job: the Pod has to finish at some point, and the Job controller handles retries.

kubectl create cronjob hello --image=busybox:1.36.1 --schedule='*/5 * * * *' -- echo hi
kubectl get cronjob
kubectl get jobs --watch
kubectl create job --from=cronjob/hello hello-manual  # run it manually, without waiting for the schedule
kubectl patch cronjob hello -p '{"spec":{"suspend":true}}'
kubectl describe cronjob hello

kubectl create cronjob without flags sets restartPolicy: OnFailure and the defaults: Allow, history limits 3 and 1. Output of kubectl get cronjob:

NAME        SCHEDULE     TIMEZONE        SUSPEND   ACTIVE   LAST SCHEDULE   AGE
tz-moscow   30 3 * * *   Europe/Moscow   False     0        <none>          0s

ACTIVE shows how many Jobs from this CronJob are running right now, LAST SCHEDULE shows how long it's been since the last scheduled run.

A manual run via create job --from creates a Job right away, from the same jobTemplate. Such a Job carries the annotation cronjob.kubernetes.io/instantiate: manual and the same owner CronJob. This is the main way to test a nightly CronJob during the day. When you do this, the CronJob's events will show Warning UnexpectedJob ... Saw a job that the controller did not create or forgot: the controller is reporting that it didn't create this Job. That's expected for a manual run.

Common mistakes

What you'll need for the tasks

The labs are in the labs/ folder: each one brings up its own kind cluster on your machine, with instructions in its README.md.

Homework

Practice the theory on your own cluster: 3 steps with a check. Set up the cluster with the Kubernetes locally track, the tasks live in the k8s-practice repository.

./run.sh start k8s-cronjob     # set up the starting state
./run.sh verify k8s-cronjob    # check the current step

In short

Done reading?

Mark the lesson as read to move your track progress forward.

Go through it with an AI agent

The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.

Get access for $49

Tell the agent: use MCP izi, give me lesson k8s-workloads-07-cronjob