Run Kubernetes Jobs and CronJobs

KubernetesBeginner
Practice Now

Introduction

Deployments keep services running continuously. Batch work has a different success condition: run to completion, record the result, and stop. Kubernetes models one-time work with Jobs and repeated schedules with CronJobs.

You will run a Job, inspect its Pod and logs, add controlled parallelism, schedule a CronJob, and learn how suspension and history limits make recurring work operationally manageable.

Think of this lab as a comparison of success conditions. A Deployment succeeds by keeping replicas available. A Job succeeds when a required number of Pods finish with exit code 0. A CronJob succeeds by creating Jobs at the intended times. Keeping those three layers separate will make the YAML much easier to read.

Run a One-Time Job

In this step, you will create a Job whose Pod prints a report and exits successfully. Create the manifest with a here-document; the quoted EOF keeps the shell from expanding characters inside the YAML.

Before entering the YAML, notice its shape: kind: Job selects the controller, spec.template describes the Pod it will create, and the Pod contains one short-lived container. Unlike a Deployment template, a Job Pod must use restartPolicy: Never or OnFailure because successful work is expected to stop.

cd /home/labex/project/batch-workloads
cat <<'EOF' > report-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: report-job
  namespace: practice-batch
spec:
  backoffLimit: 2
  template:
    metadata:
      labels:
        app: report-job
    spec:
      restartPolicy: Never
      containers:
        - name: reporter
          image: busybox:1.36
          imagePullPolicy: IfNotPresent
          command: ["sh", "-c", "echo report-complete"]
EOF

First ask Kubernetes to validate the file locally. --dry-run=client checks the object shape without creating anything, which is a useful habit whenever you write a new manifest:

kubectl apply --dry-run=client -f report-job.yaml

If the result is job.batch/report-job created (dry run), the file is structurally valid. Apply it for real, then wait for the Job's Complete condition rather than guessing how long it needs:

kubectl apply -f report-job.yaml
kubectl wait --for=condition=Complete job/report-job -n practice-batch --timeout=60s

restartPolicy: Never lets the Job controller decide whether another Pod is needed. backoffLimit: 2 caps failed retries. Read the completed Pod's output through the controller:

kubectl logs job/report-job -n practice-batch

You should see report-complete. Addressing job/report-job lets kubectl locate the Job's Pod for you, so you do not need to copy its generated name.

The three commands produce a short, readable lifecycle:

job.batch/report-job created
job.batch/report-job condition met
report-complete

condition met means the Job controller has recorded completion; the following log line is the application's output. On a freshly started local cluster, even this tiny Job can take several seconds while networking settles. Waiting on a condition makes the workflow reliable without guessing a sleep duration.

Trace Job Ownership and Completion

In this step, you will compare a completed Job with a continuously running Deployment. A completed Job keeps its Pod so logs and termination status remain inspectable.

Start with the ordinary table view. The Job should show one successful completion, while its Pod should have phase Completed or Succeeded depending on the column being viewed:

kubectl get jobs,pods -n practice-batch
kubectl get pods -n practice-batch -l job-name=report-job -o custom-columns='POD:.metadata.name,PHASE:.status.phase,OWNER:.metadata.ownerReferences[0].name,EXIT_CODE:.status.containerStatuses[0].state.terminated.exitCode'
kubectl describe job report-job -n practice-batch

The Pod phase should be Succeeded, its owner should be report-job, and the exit code should be 0. A Job is complete when the required number of Pods succeeds; it does not restart a successful container merely to keep it running.

The custom-column query turns four nested fields into a small evidence table. This is more useful than reading the entire Pod YAML when your question is specifically “who owns this Pod, and did its process succeed?”

The tested result looked like this:

POD                PHASE       OWNER        EXIT_CODE
report-job-cd54c   Succeeded   report-job   0

The generated suffix will differ. Read across the row: OWNER=report-job connects the Pod to its controller, and PHASE=Succeeded plus EXIT_CODE=0 proves the process completed successfully.

Run Several Completions in Parallel

In this step, you will create a Job that requires four successful Pods while allowing two to run at the same time.

There are two independent numbers to understand before writing the manifest:

  • completions: 4 means four successful units of work are required in total.
  • parallelism: 2 means Kubernetes may run at most two of those units simultaneously.

completionMode: Indexed assigns the work units indexes 0 through 3. Each Pod receives its own index through the JOB_COMPLETION_INDEX environment variable, which lets real batch programs divide a dataset without every Pod processing the same partition.

cat <<'EOF' > indexed-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: indexed-job
  namespace: practice-batch
spec:
  completionMode: Indexed
  completions: 4
  parallelism: 2
  backoffLimitPerIndex: 1
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: worker
          image: busybox:1.36
          imagePullPolicy: IfNotPresent
          command: ["sh", "-c", "echo worker-index=${JOB_COMPLETION_INDEX}; sleep 2"]
EOF

Apply the manifest and wait for all four indexes to finish:

The final command uses -L to add the value of the batch.kubernetes.io/job-completion-index label as an extra table column. This lets you see which work index belongs to each generated Pod without opening its full YAML.

kubectl apply -f indexed-job.yaml
kubectl wait --for=condition=Complete job/indexed-job -n practice-batch --timeout=90s
kubectl get pods -n practice-batch -l job-name=indexed-job -L batch.kubernetes.io/job-completion-index

completions is the total successful work count; parallelism is the maximum concurrent Pod count. Indexed completion gives each Pod a stable index through JOB_COMPLETION_INDEX.

The final table should contain four completed Pods with different index labels. They may not be displayed in numeric order; completion timing is concurrent and therefore not deterministic.

For example:

NAME                  STATUS      JOB-COMPLETION-INDEX
indexed-job-0-gw5wq   Completed   0
indexed-job-1-vw4qk   Completed   1
indexed-job-2-mpvnr   Completed   2
indexed-job-3-8z5j8   Completed   3

Seeing four rows does not mean four Pods ran simultaneously. The four indexes prove the total work, while parallelism: 2 limited how many Kubernetes could run at once.

Schedule a CronJob

In this step, you will create a CronJob that starts a Job every minute. Cron syntax has five fields: minute, hour, day of month, month, and day of week.

The schedule */1 * * * * means “every minute.” It is intentionally frequent so you can observe it in a short lab. Production schedules are usually less frequent. The manifest also contains a nested jobTemplate, because a CronJob creates Jobs and each Job then creates Pods.

cat <<'EOF' > heartbeat-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: heartbeat
  namespace: practice-batch
spec:
  schedule: "*/1 * * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 2
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: heartbeat
              image: busybox:1.36
              imagePullPolicy: IfNotPresent
              command: ["sh", "-c", "date -u; echo heartbeat-complete"]
EOF

Apply the schedule and inspect its summary:

kubectl apply -f heartbeat-cronjob.yaml
kubectl get cronjob heartbeat -n practice-batch

concurrencyPolicy: Forbid prevents a new run from overlapping an unfinished earlier run. Create one Job immediately from the CronJob template so you can inspect it without waiting for the next minute:

kubectl create job --from=cronjob/heartbeat heartbeat-manual -n practice-batch
kubectl wait --for=condition=Complete job/heartbeat-manual -n practice-batch --timeout=60s
kubectl logs job/heartbeat-manual -n practice-batch

The log contains a UTC timestamp followed by heartbeat-complete. The manually created Job uses the same template but does not change the CronJob's future schedule.

Typical output is:

job.batch/heartbeat-manual condition met
Thu Aug 13 07:48:29 UTC 2026
heartbeat-complete

Your timestamp will be different. Notice that the container uses UTC because its command is date -u; this avoids confusing cluster scheduling with the terminal's local time zone.

Suspend and Resume Scheduled Work

In this step, you will pause the CronJob without deleting its definition. Suspension is useful during maintenance or incident response.

kubectl patch changes only the field named in the JSON payload. Here it sets spec.suspend to true; the CronJob object, its schedule, and any Jobs that already exist remain intact.

kubectl patch cronjob heartbeat -n practice-batch --type=merge -p '{"spec":{"suspend":true}}'
kubectl get cronjob heartbeat -n practice-batch -o custom-columns='NAME:.metadata.name,SCHEDULE:.spec.schedule,SUSPEND:.spec.suspend,LAST_SCHEDULE:.status.lastScheduleTime'

Existing Jobs continue; suspension only prevents new scheduled Jobs. Resume it after inspection:

kubectl patch cronjob heartbeat -n practice-batch --type=merge -p '{"spec":{"suspend":false}}'
kubectl get jobs -n practice-batch --sort-by=.metadata.creationTimestamp

You can now distinguish three layers: CronJob defines a schedule, each scheduled run creates a Job, and each Job creates one or more Pods.

Because the whole exercise may finish before the next minute boundary, LAST_SCHEDULE can still be <none>. That is not an error: heartbeat-manual was created on demand with --from, not by the CronJob scheduler. The important final value is SUSPEND=false, which allows future scheduled runs again.

Summary

You used Jobs for finite work, indexed completions for parallel tasks, and CronJobs for recurring schedules. You inspected controller ownership, logs, completion state, concurrency policy, history limits, and suspension. These lifecycle differences are central to choosing the correct Kubernetes workload controller.