Kubernetes Jobs and CronJobs

Lesson 9 of 39 20 min read Updated

So far you have run long-lived workloads: web APIs, databases and background services that should stay up until you replace them. Deployments, ReplicaSets and DaemonSets manage these and keep their Pods running forever.

Many tasks are different. Applying a database migration, generating a monthly report, compressing old logs or sending a batch of emails should run once, finish, and stop. If you ran them in a normal Pod, Kubernetes would restart them every time they exit, and your migration or report would run in an endless loop.

A Job is the Kubernetes object for this kind of work. Think of it as a to-do item: Kubernetes keeps working on it until it is ticked off, then leaves it alone. A CronJob is the recurring reminder in your calendar: it creates a new Job on a schedule, like the Unix cron daemon.

What you will learn

  1. Why run-to-completion work needs a Job instead of a Deployment
  2. The three Job patterns (one shot, parallel fixed completions and work queue) and how completions and parallelism control them
  3. How restart policies, backoffLimit and deadlines handle failures
  4. How to schedule recurring work with a CronJob and keep old Jobs from piling up
  5. How to run, watch, debug and clean up each pattern with kubectl

Jobs versus regular Pods

Pods managed by a Deployment use restartPolicy: Always. The kubelet restarts the container every time it exits, whatever the exit code. That is perfect for a web server, and wrong for a migration.

SituationDeployment Pod (restartPolicy: Always)Job Pod (OnFailure or Never)
Container exits with code 0 (success)Restarted immediatelyPod is marked Completed; no restart
Container exits with a non-zero code (failure)Restarted immediatelyRestarted in the same Pod (OnFailure) or replaced by a new Pod (Never)
Node fails during the runPod is recreated on another nodeJob controller creates a new Pod on another node
A Job stops when the work is done. A Deployment never stops on purpose; it always tries to keep Pods running. A Job counts successful exits and stops creating Pods once it has enough.

How a Job works

A Job is a controller. It creates Pods from its spec.template, counts how many finish successfully, and keeps going until it reaches the number you asked for.

  1. You submit a Job manifest to the API server.
  2. The Job controller reads the Pod template and creates one or more Pods.
  3. Each Pod is scheduled onto a node and runs its containers.
  4. When a Pod exits with code 0, the controller records a completion. When it fails, the container is restarted or the Pod is replaced, depending on restartPolicy.
  5. When the successful completions reach spec.completions, the Job is marked Complete.
  6. The finished Job and its Pods stay in the cluster so you can read their logs. They are not deleted unless you ask for it.

The controller also labels every Pod it creates with batch.kubernetes.io/job-name and batch.kubernetes.io/controller-uid (plus the older short forms job-name and controller-uid). The unique ID keeps a Job's Pods separate from any other Pods, and the name label lets you list them with -l job-name=<name>.

Job patterns

Two fields shape a Job: completions (how much work: the number of Pods that must succeed) and parallelism (how many workers: the number of Pods that may run at once). Together they give three patterns:

PatternTypical useBehaviourcompletionsparallelism
One shotDatabase migration, one-time setupOne Pod runs until it succeeds11
Parallel, fixed completionsBatch reports, data processingSeveral Pods run at once until a fixed number have succeeded1 or more1 or more
Work queueDraining a message queueSeveral Pods share a queue; once one finishes successfully, no new Pods start and the Job completes when the rest finishunset2 or more

Picture a restaurant kitchen. A one-shot Job is one chef cooking one special dish; when it is done, the kitchen closes. A fixed completions Job is four chefs who must turn out eight dishes between them. A work queue Job is four chefs sharing one stack of orders; they keep cooking until the stack is empty.

completions and parallelism in detail

  1. spec.completions: the number of Pods that must exit successfully. Defaults to 1. With completions: 10 the Job keeps creating Pods until 10 have exited with code 0.
  2. spec.parallelism: the maximum number of Pods running at the same time. Defaults to 1. With parallelism: 5 and completions: 10, up to 5 Pods run at once, and a new one starts each time one finishes, until 10 have succeeded.

A worked example: you need to generate 800 invoices, and each Pod generates 100. That is completions: 8. Your cluster has room for 4 Pods at once, so parallelism: 4. Kubernetes starts 4 Pods, replaces each one as it finishes, and stops when 8 have succeeded.

Need each Pod to know its share? Add completionMode: Indexed to the Job. Each Pod then gets a unique index from 0 to completions - 1 in the JOB_COMPLETION_INDEX environment variable, so Pod 3 can process invoices 300 to 399.

Restart policies and failure handling

A Job's Pod template must use restartPolicy: OnFailure or restartPolicy: Never. Always is rejected, because it would contradict run-to-completion.

restartPolicyWhen the container failsResultWhen to use it
OnFailureThe kubelet restarts the container inside the same PodOne Pod with a growing restart count; tidyMost Jobs
NeverThe Pod is marked failed and the Job controller creates a new PodFailed Pods pile up, each with its own logsWhen you want to inspect every failed attempt

Retries are not immediate. With OnFailure, the kubelet waits 10 s, 20 s, 40 s and so on (capped at five minutes) between container restarts; the Pod shows CrashLoopBackOff while it waits. With Never, the Job controller applies a similar growing delay, capped at six minutes, before it creates each new Pod. Either way, a crashing task cannot hammer your nodes in a tight loop.

Three fields put limits on a Job:

  1. spec.backoffLimit (default 6): how many retries are allowed before the Job is marked Failed. A migration that either works or does not might use 2 to 4; a task that calls a flaky network service might use more.
  2. spec.activeDeadlineSeconds: a hard time limit for the whole Job. When it passes, all running Pods are stopped and the Job fails, whatever backoffLimit says.
  3. Liveness probes: they work in Job Pods just as in Deployment Pods. If a worker hangs (a deadlock, or a loop that makes no progress), the probe fails and the container is restarted.
OnFailure can hide the evidence. When a Job with restartPolicy: OnFailure reaches its backoffLimit, Kubernetes terminates its Pod, and the logs of the failed attempts may go with it. While you are still debugging a task, use restartPolicy: Never so every failed Pod and its logs stay around.

CronJobs: scheduling recurring Jobs

A CronJob creates a new Job on a schedule. The schedule field uses standard five-field cron syntax:

# ┌───────────── minute (0 - 59)
# │ ┌───────────── hour (0 - 23)
# │ │ ┌───────────── day of the month (1 - 31)
# │ │ │ ┌───────────── month (1 - 12)
# │ │ │ │ ┌───────────── day of the week (0 - 6, Sunday = 0)
# │ │ │ │ │
# * * * * *

Common schedules:

ScheduleMeaning
0 2 * * *Every day at 02:00
*/15 * * * *Every 15 minutes
0 0 1 * *Midnight on the first day of every month
0 */6 * * *Every 6 hours
30 8 * * 1-5Weekdays at 08:30

By default the schedule follows the time zone of the cluster's controller manager, which is often UTC. Set spec.timeZone (for example "Europe/Istanbul" or "Etc/UTC") to make it explicit.

Two fields control how many old Jobs a CronJob keeps:

  1. successfulJobsHistoryLimit (default 3): how many successful Jobs to keep. Older ones are deleted automatically.
  2. failedJobsHistoryLimit (default 1): how many failed Jobs to keep for debugging.

concurrencyPolicy decides what happens when a new run is due but the previous one is still running:

  1. Allow (default): the runs overlap.
  2. Forbid: the new run is skipped.
  3. Replace: the running Job is cancelled and a new one starts.

Finally, suspend: true pauses a CronJob: it stops creating new Jobs until you set it back to false. This is handy during maintenance windows.

Cleaning up finished Jobs

Finished Jobs and their Pods stay in the cluster. That is useful for reading logs, but over months it can leave thousands of old objects behind. You have three tools:

  1. TTL after finished: set spec.ttlSecondsAfterFinished on the Job. With ttlSecondsAfterFinished: 3600, the Job and its Pods are deleted one hour after it completes or fails. This is the recommended way for standalone Jobs.
  2. History limits: for CronJobs, successfulJobsHistoryLimit and failedJobsHistoryLimit do the cleanup for you.
  3. Manual cleanup: kubectl delete job <name> removes a Job and all its Pods.

Command reference

You will use these commands throughout the hands-on section:

I want to…Command
Create a Job or CronJobkubectl apply -f <file>.yaml
List Jobs, including finished oneskubectl get jobs
See a Job's status, completions and eventskubectl describe job <name>
List the Pods of a Jobkubectl get pods -l job-name=<name>
Watch the Pods of a Job livekubectl get pods -l job-name=<name> --watch
Read a Job's logs (one of its Pods)kubectl logs job/<name>
Delete a Job and its Podskubectl delete job <name>
Delete a Job but keep its Podskubectl delete job <name> --cascade=orphan
List CronJobskubectl get cronjobs
See a CronJob's schedule and last runkubectl describe cronjob <name>
Run a CronJob right nowkubectl create job <job-name> --from=cronjob/<name>
Pause a CronJobkubectl patch cronjob <name> -p '{"spec":{"suspend":true}}'
Resume a CronJobkubectl patch cronjob <name> -p '{"spec":{"suspend":false}}'

Hands-on: run every Job pattern

You will run a one-shot database migration, a parallel invoice batch, a work queue backed by Redis, and a scheduled cleanup CronJob. Every object carries the label chapter: jobs, so you can remove everything with one command at the end.

Step 1 — Run a one-shot migration Job

The simplest pattern: one Pod, one task, run until it succeeds. This Job simulates Entity Framework Core migrations for a .NET ShippingApi. Save it as schema-migrate-job.yaml:

apiVersion: batch/v1
kind: Job
metadata:
name: schema-migrate
labels:
app: shipping-api
component: migration
chapter: jobs
spec:
backoffLimit: 4
template:
metadata:
labels:
app: shipping-api
component: migration
chapter: jobs
spec:
containers:
- name: migrate
image: mcr.microsoft.com/dotnet/sdk:10.0
command:
- sh
- -c
- |
echo "Starting database migration for ShippingApi...";
echo "Applying migration: 001_CreateShipmentsTable";
sleep 2;
echo "Applying migration: 002_AddTrackingIndex";
sleep 2;
echo "Applying migration: 003_SeedCarrierData";
sleep 2;
echo "All migrations applied successfully.";
exit 0
env:
- name: DOTNET_ENVIRONMENT
value: "Production"
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
restartPolicy: OnFailure

Apply it:

kubectl apply -f schema-migrate-job.yaml

Key points in this manifest:

  1. restartPolicy: OnFailure restarts the container inside the same Pod, on the same node, if it exits with a non-zero code.
  2. backoffLimit: 4 allows up to 4 retries before the Job is marked Failed.
  3. Neither completions nor parallelism is set, so both default to 1: the classic one-shot pattern.

The SDK image is large, so the first run may spend a minute pulling it.

Step 2 — Inspect the Job and its Pod

List Jobs to see the completion count:

kubectl get jobs

Expected output once it has finished:

NAME STATUS COMPLETIONS DURATION AGE
schema-migrate Complete 1/1 9s 45s

For more detail, describe it:

kubectl describe job schema-migrate

The output includes lines like these:

Name: schema-migrate
Namespace: default
Parallelism: 1
Completions: 1
Pods Statuses: 0 Active (0 Ready) / 1 Succeeded / 0 Failed
Events:
Type Reason Message
---- ------ -------
Normal SuccessfulCreate Created pod: schema-migrate-7x2kp
Normal Completed Job completed

Read the migration output from the Pod's logs:

kubectl logs job/schema-migrate

Expected output:

Starting database migration for ShippingApi...
Applying migration: 001_CreateShipmentsTable
Applying migration: 002_AddTrackingIndex
Applying migration: 003_SeedCarrierData
All migrations applied successfully.

The Pod ran once and stopped, and Kubernetes will not restart it. It stays in the Completed state:

kubectl get pods -l job-name=schema-migrate

Expected output:

NAME READY STATUS RESTARTS AGE
schema-migrate-7x2kp 0/1 Completed 0 45s

Step 3 — See how failures are handled

To try this yourself, change exit 0 to exit 1 in the manifest. A Job's Pod template cannot be changed after creation, so delete the Job and apply it again:

kubectl delete job schema-migrate
kubectl apply -f schema-migrate-job.yaml

With restartPolicy: OnFailure, the same Pod is restarted. The RESTARTS count grows and the status shows CrashLoopBackOff between attempts:

NAME READY STATUS RESTARTS AGE
schema-migrate-7x2kp 0/1 CrashLoopBackOff 3 3m

This keeps the cluster tidy: one Pod, retrying in place. When the retry count reaches the backoffLimit of 4, the Job is marked Failed and the Pod is terminated.

Now change restartPolicy to Never, delete and apply again. The kubelet no longer restarts the container; each failure marks the Pod as failed and the Job controller creates a new one:

NAME READY STATUS RESTARTS AGE
schema-migrate-abc12 0/1 Error 0 2m
schema-migrate-def34 0/1 Error 0 90s
schema-migrate-ghi56 0/1 Error 0 60s
schema-migrate-jkl78 1/1 Running 0 10s

The extra Pods are clutter, but each one keeps the logs of its own attempt (kubectl logs schema-migrate-abc12). When you are done, set the manifest back to exit 0 and OnFailure, then delete and re-apply it so the Job completes.

Step 4 — Run a parallel batch Job

Now scale up. The billing system needs 8 invoice batches, with at most 4 running at once: the parallel fixed completions pattern. Save this as invoice-batch-job.yaml:

apiVersion: batch/v1
kind: Job
metadata:
name: invoice-batch
labels:
app: billing-api
component: batch
chapter: jobs
spec:
completions: 8
parallelism: 4
backoffLimit: 6
template:
metadata:
labels:
app: billing-api
component: batch
chapter: jobs
spec:
containers:
- name: invoice-generator
image: mcr.microsoft.com/dotnet/runtime:10.0
command:
- sh
- -c
- |
echo "Invoice batch worker started on $(hostname)";
for i in $(seq 1 5); do
echo "Generating invoice $i of 5...";
sleep 1;
done;
echo "Batch complete.";
exit 0
env:
- name: DOTNET_ENVIRONMENT
value: "Production"
resources:
requests:
cpu: "200m"
memory: "128Mi"
limits:
cpu: "400m"
memory: "256Mi"
restartPolicy: OnFailure

Apply it:

kubectl apply -f invoice-batch-job.yaml

The Job immediately starts 4 Pods, the parallelism limit. Each time one completes, a new one starts, until 8 have succeeded.

Step 5 — Watch the parallel Job progress

Use --watch to see Pods come and go in real time (press Ctrl+C to stop):

kubectl get pods -l job-name=invoice-batch --watch

You will see something like:

NAME READY STATUS RESTARTS AGE
invoice-batch-2nk7f 1/1 Running 0 3s
invoice-batch-8xp4m 1/1 Running 0 3s
invoice-batch-kw9vt 1/1 Running 0 3s
invoice-batch-r5hbn 1/1 Running 0 3s
invoice-batch-2nk7f 0/1 Completed 0 8s
invoice-batch-zt3qx 0/1 Pending 0 0s
invoice-batch-zt3qx 1/1 Running 0 1s
...

No more than 4 Pods run at any moment. As one completes, the next is scheduled, and once 8 have succeeded no new Pods are created. Check the final status:

kubectl describe job invoice-batch

The relevant lines:

Parallelism: 4
Completions: 8
Pods Statuses: 0 Active (0 Ready) / 8 Succeeded / 0 Failed

Clean up before the next step:

kubectl delete job invoice-batch

Step 6 — Start a Redis work queue

In the work queue pattern, Pods do not get a fixed share of work. They all pull items from one shared queue until it is empty. You will use Redis as the queue.

Run Redis under a ReplicaSet rather than as a bare Pod, so it is recreated if its node fails. Save this as task-queue-rs.yaml:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: task-queue
labels:
app: task-queue
component: server
chapter: jobs
spec:
replicas: 1
selector:
matchLabels:
app: task-queue
component: server
chapter: jobs
template:
metadata:
labels:
app: task-queue
component: server
chapter: jobs
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
protocol: TCP
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"

Then give it a Service so workers can reach it by the DNS name task-queue. Save this as task-queue-service.yaml:

apiVersion: v1
kind: Service
metadata:
name: task-queue
labels:
app: task-queue
component: server
chapter: jobs
spec:
ports:
- port: 6379
protocol: TCP
targetPort: 6379
selector:
app: task-queue
component: server

Apply both and check that Redis is running:

kubectl apply -f task-queue-rs.yaml
kubectl apply -f task-queue-service.yaml
kubectl get pods -l app=task-queue

Expected output:

NAME READY STATUS RESTARTS AGE
task-queue-9m4xz 1/1 Running 0 30s

Step 7 — Fill the queue

Push ten work items onto a Redis list called work-items. kubectl exec rs/task-queue runs the command in the ReplicaSet's Pod, so you do not need its generated name:

kubectl exec rs/task-queue -- redis-cli RPUSH work-items order-1 order-2 order-3 order-4 order-5 order-6 order-7 order-8 order-9 order-10

Expected output (the new length of the list):

10

Step 8 — Run the queue workers

This Job starts 5 workers in parallel. Each one pops items from the list with LPOP and stops when the list is empty. Save it as queue-processor-job.yaml:

apiVersion: batch/v1
kind: Job
metadata:
name: queue-processor
labels:
app: task-queue
component: worker
chapter: jobs
spec:
parallelism: 5
template:
metadata:
labels:
app: task-queue
component: worker
chapter: jobs
spec:
containers:
- name: worker
image: redis:7-alpine
command:
- sh
- -c
- |
echo "Queue worker started on $(hostname)";
while true; do
ITEM=$(redis-cli -h task-queue LPOP work-items);
if [ -z "$ITEM" ]; then
echo "No more items in queue. Worker exiting.";
exit 0;
fi;
echo "Processing $ITEM...";
sleep 2;
done
resources:
requests:
cpu: "100m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
restartPolicy: OnFailure

The worker reuses the redis:7-alpine image only because it already contains redis-cli. A real .NET worker would use a Redis client library in the same loop.

Leaving completions unset turns on work queue mode. With parallelism above 1 and no completions, the Job does not count a fixed number of successes. As soon as any Pod exits with code 0, no new Pods are started, and the Job completes when the running Pods have finished. That is why each worker must only exit 0 when the queue is truly empty.

Apply it and watch the workers:

kubectl apply -f queue-processor-job.yaml
kubectl get pods -l job-name=queue-processor --watch

You will see something like:

NAME READY STATUS RESTARTS AGE
queue-processor-4kf7n 1/1 Running 0 5s
queue-processor-8m2xp 1/1 Running 0 5s
queue-processor-bn3rv 1/1 Running 0 5s
queue-processor-ht6wz 1/1 Running 0 5s
queue-processor-ws9jk 1/1 Running 0 5s
queue-processor-bn3rv 0/1 Completed 0 9s
queue-processor-4kf7n 0/1 Completed 0 9s
...

Read the logs of one worker. The items it got depend on timing, so yours will differ:

kubectl logs job/queue-processor

Example output:

Queue worker started on queue-processor-4kf7n
Processing order-1...
Processing order-6...
No more items in queue. Worker exiting.

Confirm the queue is empty:

kubectl exec rs/task-queue -- redis-cli LLEN work-items

Expected output:

0

Step 9 — Schedule a CronJob

Finally, a cleanup task that runs every day at 02:00 UTC, for example to purge old reports or compact database indexes. Save it as report-cleanup-cronjob.yaml:

apiVersion: batch/v1
kind: CronJob
metadata:
name: report-cleanup
labels:
app: billing-api
component: cleanup
chapter: jobs
spec:
schedule: "0 2 * * *"
timeZone: "Etc/UTC"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
jobTemplate:
metadata:
labels:
chapter: jobs
spec:
template:
metadata:
labels:
app: billing-api
component: cleanup
chapter: jobs
spec:
containers:
- name: cleanup
image: mcr.microsoft.com/dotnet/runtime:10.0
command:
- sh
- -c
- |
echo "Report cleanup started at $(date)";
echo "Scanning for expired reports older than 30 days...";
sleep 3;
echo "Removed 47 expired reports.";
echo "Compacting database indexes...";
sleep 2;
echo "Cleanup finished at $(date)";
exit 0
env:
- name: DOTNET_ENVIRONMENT
value: "Production"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "250m"
memory: "256Mi"
restartPolicy: OnFailure

A few things to notice:

  1. There are two templates. jobTemplate describes each Job the CronJob creates, and the template inside it describes that Job's Pods.
  2. concurrencyPolicy: Forbid skips a run if yesterday's cleanup is somehow still going.
  3. successfulJobsHistoryLimit: 3 keeps the last three successful runs; failedJobsHistoryLimit: 1 keeps the latest failed run for debugging.
  4. The chapter: jobs label on jobTemplate.metadata is copied to every Job it creates, so the final cleanup finds them too.

Apply it and check it:

kubectl apply -f report-cleanup-cronjob.yaml
kubectl get cronjobs

Expected output:

NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
report-cleanup 0 2 * * * Etc/UTC False 0 <none> 10s

Step 10 — Trigger, pause and resume the CronJob

You do not want to wait until 02:00 to test it. Create a one-off Job from the CronJob's template:

kubectl create job manual-cleanup --from=cronjob/report-cleanup

Read its logs once it has run:

kubectl logs job/manual-cleanup

Expected output (with the current date):

Report cleanup started at Wed Mar 11 10:15:00 UTC 2026
Scanning for expired reports older than 30 days...
Removed 47 expired reports.
Compacting database indexes...
Cleanup finished at Wed Mar 11 10:15:05 UTC 2026

Pause the schedule, for example during a maintenance window, and resume it afterwards:

kubectl patch cronjob report-cleanup -p '{"spec":{"suspend":true}}'
kubectl patch cronjob report-cleanup -p '{"spec":{"suspend":false}}'

While it is suspended, kubectl get cronjobs shows True in the SUSPEND column.

Test CronJobs without waiting. kubectl create job --from=cronjob/... runs the exact template the schedule will use. Make it part of your routine whenever you change a CronJob.

Step 11 — Clean up

Every object in this walkthrough carries chapter: jobs, including the manually triggered Job, so one command removes them all:

kubectl delete job,cronjob,rs,svc -l chapter=jobs

Common mistakes

  1. Using restartPolicy: Always. The API server rejects the Job. Use OnFailure or Never.
  2. Editing a Job and re-applying it. A Job's Pod template is immutable, so kubectl apply fails. Delete the Job and create it again.
  3. A task that is not safe to run twice. A Pod can be retried after a node failure or a timeout, so the same work may run more than once. Make migrations and batch steps idempotent.
  4. A work queue worker that exits 0 too early. The first successful exit stops new Pods from starting. Only exit 0 when the queue is really empty; exit non-zero on errors.
  5. Old Jobs piling up. Set ttlSecondsAfterFinished on standalone Jobs and sensible history limits on CronJobs.
  6. A CronJob that fires at the wrong hour. Without timeZone the schedule uses the controller manager's time zone. Set it explicitly.

Summary

A Job runs Pods until a task has finished successfully, then stops; a CronJob creates such Jobs on a schedule. Two fields, completions and parallelism, decide which pattern a Job follows.

  1. One shot (both 1, the defaults): one-time tasks such as database migrations.
  2. Parallel fixed completions: several Pods at once until a set number have succeeded, for example invoice batches.
  3. Work queue (completions unset, parallelism above 1): workers drain a shared queue, and the Job completes once a worker has exited successfully and the rest have finished.
  4. Use restartPolicy: OnFailure for most Jobs and Never while debugging; backoffLimit caps retries and activeDeadlineSeconds caps run time.
  5. CronJobs take a five-field cron schedule, an optional timeZone, a concurrencyPolicy and history limits; suspend pauses them.
  6. Clean up with ttlSecondsAfterFinished, CronJob history limits, or kubectl delete job.

With Jobs and CronJobs you can run migrations, batch work and scheduled maintenance with the same tools you already use for long-running services.