Jobs and CronJobs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A Deployment keeps a Pod running forever and restarts it whenever it exits, which is exactly wrong for work that is supposed to finish: a database migration, a report export, a one-off batch import. A Job (batch/v1) is the controller for that shape of work instead — it creates one or more Pods, tracks how many completed successfully, and stops once enough of them have, retrying failures according to its own policy rather than forever. A CronJob wraps a Job template with a schedule, creating a fresh Job at each tick. This page covers both controllers as they stand in Kubernetes 1.37, and rebuilds, one by one, the three Job patterns that Hightower, Burns & Beda’s Kubernetes: Up and Running (1st ed., 2017 sampler), ch. 3 (book ch. 10), "Jobs", walked through against the Bookshelf notifier — a small worker that sends catalog-update notifications — correcting the two kubectl invocations that no longer work as printed.

The Job spec

apiVersion: batch/v1
kind: Job
metadata:
  name: notifier-once
  namespace: bookshelf
  labels:
    app.kubernetes.io/name: notifier
    app.kubernetes.io/part-of: bookshelf
spec:
  completions: 1
  parallelism: 1
  backoffLimit: 3
  activeDeadlineSeconds: 600
  ttlSecondsAfterFinished: 3600
  template:
    metadata:
      labels:
        app.kubernetes.io/name: notifier
    spec:
      restartPolicy: OnFailure
      containers:
        - name: notifier
          image: ghcr.io/example/bookshelf-notifier:1.0.0
          args: ["--mode=once"]
Field Meaning

completions

How many Pods must complete successfully before the Job itself is considered Complete. Defaults to 1 (a plain one-shot Job); with parallelism above 1 and no completionMode, several Pods run at once but the Job still stops once this many total successes are reached (NonIndexed mode, below).

parallelism

How many Pods the Job controller tries to keep running concurrently while completions remain outstanding. Never exceeds completions in NonIndexed mode; the controller throttles it down after repeated failures.

backoffLimit

How many Pod failures the Job tolerates, in total, before giving up and marking itself Failed. Defaults to 6. Each retry backs off exponentially (10s, 20s, 40s, capped at 6 minutes), the same backoff Pods describes for CrashLoopBackOff, so a Job stuck retrying does not hammer the API server or the node.

activeDeadlineSeconds

A wall-clock ceiling on the whole Job, independent of backoffLimit: once this many seconds have passed since the Job started, every running Pod is terminated and the Job is marked Failed with reason DeadlineExceeded, even if retries were still within budget. Guards against a Job that is technically still retrying but has long since stopped being useful to wait for.

ttlSecondsAfterFinished

How long a Complete or Failed Job (and its Pods) is left around before the TTL controller deletes it automatically, GA since Kubernetes 1.23. Without it, finished Jobs accumulate until something — a person, or the GitOps controller that created them — cleans them up by hand.

podFailurePolicy

GA since Kubernetes 1.31; requires the Pod template’s restartPolicy: Never. A list of rules matching a failed Pod’s container exit code or Pod condition, each mapped to an action: FailJob (stop retrying immediately, ignoring remaining backoffLimit), Ignore (do not count this failure against backoffLimit at all, e.g. a Pod evicted for a node drain), or Count (the default behaviour, count it normally). Lets a Job distinguish "the application returned exit code 42, meaning a non-retryable business error" from "the node it was scheduled on was drained", instead of backoffLimit treating both the same way.

completionMode: Indexed

GA since Kubernetes 1.24 (default remains NonIndexed). Gives each of the completions Pods a stable index 0 .. completions-1, exposed to the Pod as the JOB_COMPLETION_INDEX environment variable and the batch.kubernetes.io/job-completion-index annotation/label, and as a suffix on the Pod’s hostname. Used below for the fixed-completion parallel pattern.

backoffLimitPerIndex

GA since Kubernetes 1.33, only meaningful with completionMode: Indexed. Moves backoffLimit from a single Job-wide counter to a per-index one, so one persistently failing index does not consume retry budget that other, healthy indexes still need. The Job still ends Failed if any index failed; maxFailedIndexes caps how many indexes may exhaust their own budget before the Job is failed early, without waiting for the rest.

successPolicy

GA since Kubernetes 1.33, only meaningful with completionMode: Indexed. Declares the Job Complete once a named subset of indexes succeeds (rules[].succeededIndexes plus an optional succeededCount), instead of waiting for every index — useful for a fan-out where only a leader index’s result matters, or where any N of completions succeeding is enough.

suspend

GA since Kubernetes 1.24. true stops the Job controller from creating any Pods at all (existing ones are deleted) without deleting the Job object itself; flipping it back to false resumes from a clean slate. Used by a CronJob’s spec.jobTemplate indirectly through concurrencyPolicy, below, and directly by anything that needs to stage a Job’s manifest ahead of time and release it later (a CI pipeline, a maintenance window).

A Job whose process hangs instead of exiting never finishes, so the Job controller cannot react. Give the Job’s container a liveness probe so the kubelet restarts a hung process, and bound the whole run with activeDeadlineSeconds.

restartPolicy: OnFailure vs Never

A Job’s Pod template may only set restartPolicy to OnFailure or Never — Always, the Deployment default, is rejected by the API server, because a Pod that always restarts can never reach a terminal state for the Job controller to count. Both retry a failure, but at a different layer, and Book 2’s ch. 3 (book ch. 10) treats the choice as a small but consequential one:

restartPolicy Behaviour

OnFailure

The kubelet restarts the failed container in place, inside the same Pod, the same way it would under Pods' ordinary CrashLoopBackOff handling. The Pod stays Running and its restart count climbs, and the Job controller counts those container restarts toward backoffLimit just as it counts failed Pods under Never — once the restarts reach the limit, the Job is marked Failed and its running Pod is terminated. What is lost is the failed attempt itself: the restarted container replaces it in place, so only the previous attempt’s logs (kubectl logs --previous) remain.

Never

The container is left Terminated, the Pod is marked Failed, and it is the Job controller — not the kubelet — that creates a brand-new Pod to retry, counting the failed Pod against backoffLimit immediately. Failed Pods are kept around (subject to ttlSecondsAfterFinished on the whole Job) rather than deleted, so kubectl logs and kubectl describe on the specific failed attempt stay available for debugging.

Book 2’s sampler default was OnFailure for the in-place-retry case, reserving Never for a Job whose failed attempts are worth keeping around individually — both patterns below use OnFailure, since neither needs to inspect an old failed attempt once a new Pod has already retried it.

Auto-generated labels and manualSelector

As kubectl Essentials already introduced, the Job controller stamps every Pod it creates with two labels of its own: job-name (the owning Job’s name) and controller-uid (a value unique to this particular Job object, so a second Job later created with the same name never accidentally adopts Pods left over from the first one). spec.selector is filled in automatically from these at creation time and, unlike a ReplicaSet's selector, is immutable once set. manualSelector: true opts out of the auto-generated selector in favour of a hand-written one, which is possible but discouraged — it is easy to write a selector that a second Job also matches, and nothing in Bookshelf needs it.

The three Book 2 patterns, rebuilt for the notifier

Book 2’s ch. 3 (book ch. 10) worked through three shapes of Job in turn: a one-shot Job, a fixed-completion parallel Job, and a work-queue Job consuming from a shared queue. All three are rebuilt below against the Bookshelf notifier, which sends a notification per catalog update.

One-shot

The simplest shape: completions: 1, parallelism: 1, exactly the manifest shown at the top of this page. One Pod runs, and the Job is Complete the moment it exits 0.

kubectl apply -f notifier-once.yaml
kubectl wait --for=condition=complete --timeout=120s job/notifier-once
kubectl logs job/notifier-once
job.batch/notifier-once condition met
sent 42 notifications in 3.1s

Book 2’s own way of creating this same one-shot Job was kubectl run notifier --image=ghcr.io/example/bookshelf-notifier:1.0.0 --restart=OnFailure — --mode=once — as kubectl Essentials covers in full, kubectl run’s Job/Deployment generators were removed in Kubernetes 1.18, and `kubectl run today always creates a bare Pod regardless of --restart. The direct replacement is an imperative kubectl create job, or the declarative manifest above:

kubectl create job notifier-once --image=ghcr.io/example/bookshelf-notifier:1.0.0 -- --mode=once

Fixed-completion parallel (Indexed)

Book 2’s second pattern split a fixed amount of work into completions equal-sized pieces, run up to parallelism at a time. On a 1.37 cluster the same pattern is written with completionMode: Indexed, so each Pod can read its own index and process only its slice instead of every Pod polling a shared queue for work:

apiVersion: batch/v1
kind: Job
metadata:
  name: notifier-batch
  namespace: bookshelf
  labels:
    app.kubernetes.io/name: notifier
    app.kubernetes.io/part-of: bookshelf
spec:
  completions: 5
  parallelism: 3
  completionMode: Indexed
  backoffLimitPerIndex: 2
  maxFailedIndexes: 1
  template:
    metadata:
      labels:
        app.kubernetes.io/name: notifier
    spec:
      restartPolicy: OnFailure
      containers:
        - name: notifier
          image: ghcr.io/example/bookshelf-notifier:1.0.0
          args: ["--mode=batch", "--shard-count=5"]
          env:
            - name: SHARD_INDEX
              valueFrom:
                fieldRef:
                  fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']

Five completions, three Pods running at once: each Pod’s SHARD_INDEX (0-4) picks a disjoint slice of the catalog to notify about, with no coordination needed between the Pods — the split is entirely static, decided by completions and the shard count baked into the image’s arguments, unlike the work-queue pattern below where the split happens at runtime against shared state.

kubectl create -f notifier-batch.yaml
kubectl get pods -l job-name=notifier-batch \
  -o custom-columns=NAME:.metadata.name,INDEX:.metadata.annotations.batch\\.kubernetes\\.io/job-completion-index,STATUS:.status.phase
NAME                  INDEX   STATUS
notifier-batch-0-x7f2  0      Succeeded
notifier-batch-1-k9m4  1      Succeeded
notifier-batch-2-q2p8  2      Running
notifier-batch-3-z4n1  3      Succeeded
notifier-batch-4-b8t6  4      Succeeded

backoffLimitPerIndex: 2 and maxFailedIndexes: 1 together mean any single shard may retry up to twice on its own before that shard is given up on, and a persistently broken shard 2 does not consume retry budget that shards 0, 1, 3 and 4 never needed — they keep running to completion. Once every index has finished, the Job is still marked Failed if any index failed; maxFailedIndexes only ends it early, terminating the remaining Pods as soon as more than one shard has exhausted its own retries.

Work queue (Redis list plus a consumer Job)

Book 2’s third pattern had producers push work items onto a queue Service and a pool of consumer Pods — created as a single Job with parallelism set but no fixed completions — pop items off it until the queue is empty, each Pod deciding for itself when there is no more work left rather than being handed a pre-assigned share. Rebuilt here with Redis as the queue, matching the Bookshelf app’s existing Redis instance (Persistent Volumes and Storage Classes covers how it is deployed):

apiVersion: batch/v1
kind: Job
metadata:
  name: notifier-queue-consumer
  namespace: bookshelf
  labels:
    app.kubernetes.io/name: notifier
    app.kubernetes.io/part-of: bookshelf
spec:
  parallelism: 4
  backoffLimit: 6
  template:
    metadata:
      labels:
        app.kubernetes.io/name: notifier
    spec:
      restartPolicy: OnFailure
      containers:
        - name: notifier
          image: ghcr.io/example/bookshelf-notifier:1.0.0
          args: ["--mode=queue-worker"]
          env:
            - name: REDIS_ADDR
              value: "redis.bookshelf.svc.cluster.local:6379"
            - name: REDIS_QUEUE_KEY
              value: "notifier:pending"
            - name: REDIS_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: bookshelf-redis
                  key: password

Each of the four consumer Pods loops on a blocking BRPOP notifier:pending 5 against Redis: whichever Pod happens to pop an item processes it, and a Pod that gets an empty result after its own timeout budget simply exits 0, counting as a success. Because completions is left unset, no new Pods are created once any consumer has exited successfully, and the Job is Complete once at least one Pod has succeeded and all Pods have terminated — there is no fixed total to reach, only "every consumer eventually found the queue empty and stopped". Producers (the catalog-api and reviews-api Deployments, on a catalog update) push with LPUSH notifier:pending <payload>, entirely decoupled from how many consumer Pods happen to be running at that moment.

kubectl exec deploy/catalog-api -- redis-cli -h redis.bookshelf.svc.cluster.local LPUSH notifier:pending '{"book_id":"42"}'
kubectl create -f notifier-queue-consumer.yaml
kubectl logs -l job-name=notifier-queue-consumer -f --prefix
[pod/notifier-queue-consumer-4kxqp/notifier] popped {"book_id":"42"}, notified 3 subscribers
[pod/notifier-queue-consumer-9mzr2/notifier] queue empty after 5s, exiting
[pod/notifier-queue-consumer-p7wfl/notifier] queue empty after 5s, exiting
[pod/notifier-queue-consumer-t2blq/notifier] queue empty after 5s, exiting

Book 2’s own queue was a small HTTP work-queue Deployment reached through kubectl port-forward from the consumer Job’s Pods; the shape above is functionally identical — a shared, external store that decouples producers from however many consumers currently exist — swapped for Redis, since Bookshelf already runs one, and using a cluster-internal Service address instead of port-forward, which is a debugging tool (kubectl Essentials) rather than how Pods reach each other in production. The REDIS_PASSWORD above is never a literal in the manifest: it comes from a Secret created out of band, e.g. kubectl create secret generic bookshelf-redis --from-literal=password="$(openssl rand -base64 24)", exactly as ConfigMaps describes for Secret values in general.

Finally, once the consumer Job has run, Book 2’s way of checking on it was kubectl get jobs -a (--show-all) to see completed Jobs alongside active ones — as kubectl Essentials already covers, that flag was removed once kubectl get stopped hiding completed and failed objects by default, so a plain kubectl get jobs on 1.37 already shows notifier-queue-consumer once it finishes.

📊 The work-queue pattern

flowchart LR subgraph Producers CA[catalog-api Deployment] RA[reviews-api Deployment] end Q[(Redis List\nnotifier:pending)] subgraph "Job/notifier-queue-consumer (parallelism 4)" W1[Pod 1] W2[Pod 2] W3[Pod 3] W4[Pod 4] end CA -- LPUSH --> Q RA -- LPUSH --> Q Q -- BRPOP --> W1 Q -- BRPOP --> W2 Q -- BRPOP --> W3 Q -- BRPOP --> W4 W1 -- empty, exit 0 --> C1[Succeeded] W2 -- empty, exit 0 --> C2[Succeeded] W3 -- empty, exit 0 --> C3[Succeeded] W4 -- empty, exit 0 --> C4[Succeeded]

CronJobs

A CronJob (batch/v1, GA since Kubernetes 1.21, promoted from the batch/v1beta1 API Book 2’s era predates) does not itself run anything — it holds a jobTemplate and a schedule, and at each tick the CronJob controller creates a brand-new Job object from that template, which then runs exactly like any Job above. For in-process scheduling inside a Spring Boot service, and how its cron syntax differs from a CronJob’s, see Standard Scheduling vs Quartz.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: notifier-nightly-digest
  namespace: bookshelf
  labels:
    app.kubernetes.io/name: notifier
    app.kubernetes.io/part-of: bookshelf
spec:
  schedule: "0 2 * * *"
  timeZone: "Europe/Madrid"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 900
      template:
        metadata:
          labels:
            app.kubernetes.io/name: notifier
        spec:
          restartPolicy: OnFailure
          containers:
            - name: notifier
              image: ghcr.io/example/bookshelf-notifier:1.0.0
              args: ["--mode=digest"]
Field Meaning

schedule

A standard five-field crontab expression (minute, hour, day-of-month, month, day-of-week), plus the non-standard @hourly / @daily / @weekly / @monthly / @yearly / @annually shorthands. 0 2 * * * above means "every day at 02:00".

timeZone

GA since Kubernetes 1.27. An IANA time zone name (Europe/Madrid) that schedule is interpreted against, including that zone’s daylight-saving transitions. Before this field existed, every CronJob’s schedule was interpreted in the kube-controller-manager process’s own UTC clock, which is why older manifests sometimes hard-code a UTC-offset schedule instead.

concurrencyPolicy

Allow (default, runs may overlap), Forbid (skip a new run if the previous Job from this CronJob is still running — used above, since two overlapping digests would double-send notifications), or Replace (cancel the still-running Job and start the new one).

startingDeadlineSeconds

How late a missed tick may still be started. If the CronJob controller was down, or the cluster was overloaded, for longer than this many seconds past a scheduled time, that tick is skipped entirely rather than started late; left unset, a missed tick may still be started arbitrarily late (subject to a 100-missed-tick safety cap).

successfulJobsHistoryLimit / failedJobsHistoryLimit

How many completed Job objects (and their Pods) are kept around after a run, split by outcome, purely for kubectl logs job/… and kubectl describe on a past run — mirroring a Deployment’s revisionHistoryLimit. Default 3 and 1 respectively.

A CronJob’s jobTemplate.spec accepts every field the Job spec above does, including completionMode: Indexed or a work-queue-style parallelism, so the nightly digest above could equally well be the fixed-completion or work-queue pattern instead of the one-shot shown — CronJob only adds the schedule on top, it does not change what kind of Job runs at each tick.

Running a Job from a CronJob’s template on demand, without waiting for the next scheduled tick, is a common enough debugging need that kubectl has a dedicated verb for it rather than requiring the whole manifest to be copied by hand:

kubectl create job notifier-digest-manual --from=cronjob/notifier-nightly-digest
kubectl get jobs -l app.kubernetes.io/name=notifier
job.batch/notifier-digest-manual created
NAME                       COMPLETIONS   DURATION   AGE
notifier-digest-manual     1/1           4s         12s