Skip to main content
RunBook Academy

KubernetesXIX · Jobs and CronJobsJobs and CronJobs

CronJobs — schedules, concurrency policy, and missed-run handling

Advanced⏱ ~17 minkubectlkubeadm

What you'll learn

  • Configure CronJob schedule syntax (cron format)
  • Distinguish `concurrencyPolicy: Allow`, `Forbid`, and `Replace`
  • Reason about `startingDeadlineSeconds` and missed-run handling
  • Avoid the common production mistake: silent schedule failures

Prerequisites

Verified against Kubernetes 1.34.x · kubeadm 1.34.x · kubectl 1.34.x · etcd 3.6.x · CoreDNS 1.11.x · containerd 1.7.x / 2.x · 2026-08-16

Not yet marked complete on this device.

A CronJob is the controller for scheduled batch work. It creates a Job at the schedule’s next firing time and manages the Job’s lifecycle. The CronJob controller handles three concerns plain Jobs do not: the schedule itself, the behaviour when the previous Job is still running, and the behaviour when a schedule is missed (controller down, clock drift, paused cluster). This lesson covers each.

What a CronJob is

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-cleanup
spec:
  schedule: "0 2 * * *"      # 02:00 every day
  timeZone: "Etc/UTC"
  startingDeadlineSeconds: 300
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  suspend: false
  jobTemplate:
    spec:
      backoffLimit: 4
      activeDeadlineSeconds: 7200
      ttlSecondsAfterFinished: 86400
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - name: cleanup
            image: cleanup:v1

The CronJob controller evaluates the schedule. At each firing time, the controller creates a Job from jobTemplate. The Job’s Pods run to completion; the Job is deleted after ttlSecondsAfterFinished (or kept in history).

flowchart LR
    A["CronJob<br/>schedule: 0 2 * * *"] --> B{Firing time?}
    B -->|yes| C{Concurrency<br/>policy}
    C -->|Allow| D[Create Job]
    C -->|Forbid| E{Previous Job<br/>still running?}
    C -->|Replace| F["Delete previous Job<br/>create new"]
    E -->|yes| G[Skip this run]
    E -->|no| D
    D --> H[Job runs]
    H --> I[Complete]
    F --> H

Schedule syntax

The schedule is standard cron:

minute hour day-of-month month day-of-week
"0 2 * * *"     # 02:00 every day
"*/15 * * * *"  # every 15 minutes
"0 0 * * 0"     # midnight on Sunday
"0 9-17 * * 1-5"  # 09:00 to 17:00 on weekdays

The schedule is evaluated in the controller’s timezone. The timeZone field (Kubernetes 1.27+) specifies the zone for the schedule; default is the controller’s local time, which can be ambiguous in a distributed cluster.

flowchart LR
    A["Schedule: 0 2 * * *"] --> B{timeZone}
    B -->|Etc/UTC| C["02:00 UTC"]
    B -->|America/New_York| D["02:00 EST/EDT"]
    B -->|not set| E[Controller local time]

Concurrency policy

When a CronJob’s previous Job is still running and the schedule fires again, what happens?

PolicyBehaviour
AllowCreate the new Job; multiple Jobs run concurrently
ForbidSkip the new run; previous Job continues
ReplaceDelete the previous Job; create the new one

The default is Allow. For most batch workloads, this is wrong:

  • A nightly backup that overlaps with itself — two backups running at once, contending for the same database. The Allow default produces duplicate backups; the second Job may fail.
  • A periodic log shipper that overlaps with itself — log files are processed twice.
  • A nightly data sync that overlaps — duplicate sync, wasted bandwidth.

The right policy depends on the workload:

  • Idempotent and side-effect-free: Allow is fine (most reporting jobs).
  • Mutually exclusive: Forbid (most backups, syncs).
  • Always want the latest run: Replace (a health check that needs to run “now,” not at the scheduled time).
flowchart TB
    A["02:00 daily<br/>backup Job"] --> B{Runs for 4 hours}
    B --> C["Next day 02:00<br/>previous still running"]
    C --> D{concurrencyPolicy}
    D -->|Allow| E["Create new Job<br/>two running"]
    D -->|Forbid| F[Skip this run]
    D -->|Replace| G["Kill old Job<br/>create new"]

startingDeadlineSeconds and missed runs

If the controller is down at the firing time (control-plane restart, network blip), the schedule is missed. The startingDeadlineSeconds field bounds how late a Job can start and still count as “on schedule”:

spec:
  startingDeadlineSeconds: 300

If the controller is back up within 300 seconds of the firing time, the Job is created. If the delay exceeds 300 seconds, the run is skipped (no Job is created).

Without startingDeadlineSeconds, missed runs accumulate silently. The operator notices only when a downstream system (e.g., a daily report) is missing.

History limits

spec:
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3

The number of completed (succeeded / failed) Jobs kept for inspection. Old Jobs are deleted when the limit is exceeded.

LimitDefaultProduction
successfulJobsHistoryLimit33 (default is fine)
failedJobsHistoryLimit13 (more visibility into failures)

The default for failed jobs is 1; production typically wants 3 so the operator can correlate multiple failures across the history.

Suspending a CronJob

spec:
  suspend: true

Setting suspend: true stops new Job creation. Existing Jobs continue. Setting suspend: false resumes. This is useful during maintenance windows or upstream outages.

Real production example

A nightly database backup with full retry and history:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: db-backup
  namespace: data
spec:
  schedule: "0 2 * * *"
  timeZone: "Etc/UTC"
  startingDeadlineSeconds: 600
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 7
  failedJobsHistoryLimit: 7
  suspend: false
  jobTemplate:
    spec:
      backoffLimit: 3
      activeDeadlineSeconds: 7200
      ttlSecondsAfterFinished: 604800   # keep 7 days
      template:
        spec:
          restartPolicy: OnFailure
          serviceAccountName: backup-runner
          containers:
          - name: backup
            image: backup:v1.4.2
            env:
            - name: BACKUP_TARGET
              value: postgres.prod
            - name: BACKUP_BUCKET
              value: s3://prod-backups
            resources:
              requests:
                cpu: 1
                memory: 2Gi
              limits:
                cpu: 2
                memory: 4Gi

This CronJob:

  • Runs at 02:00 UTC every day.
  • Skips if the previous backup is still running.
  • Retries up to 3 times on failure.
  • Bounds to 2 hours of total duration.
  • Keeps 7 days of history for inspection.
  • Tolerates 10 minutes of controller downtime before considering a run missed.

Inspecting a CronJob

kubectl get cronjob -A
# NAMESPACE   NAME             SCHEDULE       SUSPEND   ACTIVE   LAST SCHEDULE   AGE
# data        db-backup        0 2 * * *      False     0        2h              30d
# reporting   weekly-report    0 9 * * 1      False     0        4d              60d

kubectl describe cronjob db-backup -n data
# Name:                  db-backup
# Namespace:             data
# Schedule:              0 2 * * *
# TimeZone:              Etc/UTC
# Concurrency Policy:    Forbid
# Suspend:               False
# Successful Job History Limit: 7
# Failed Job History Limit:     7
# Starting Deadline Seconds:    600
# ...

The LAST SCHEDULE column shows the last firing time. The operator can compare it against the schedule interval to detect missed runs.

Quiz

Knowledge check · 4 questions

  1. Q1. What does concurrencyPolicy Forbid mean for a CronJob?

  2. Q2. A CronJob with startingDeadlineSeconds 300 will always run every scheduled time even if the controller was down.

  3. Q3. Your nightly backup CronJob is set with concurrencyPolicy Allow and startingDeadlineSeconds undefined. Two nights ago, the controller was down for an hour at 02:00. Diagnose what happened.

    CronJob db-backup runs at 02:00 daily. The kube-controller-manager was down from 02:00 to 03:00 on day N-1. The schedule fires during the downtime.

  4. Q4. Why is timeZone important for a CronJob, and what is the default?

Passing score: 75%. Answers are checked in this browser.

Production discipline

  • Set timeZone explicitly. A CronJob without timeZone drifts in distributed clusters.
  • Set concurrencyPolicy deliberately. The default Allow is rarely correct; Forbid is the safe choice for most mutually exclusive work.
  • Set startingDeadlineSeconds. Without it, missed runs accumulate silently.
  • Set history limits. Production needs visibility into recent runs.
  • Alert on LAST SCHEDULE age. A missed run is a silent failure unless the dashboard surfaces it.
  • Document the run’s recovery path. A CronJob that produces a side effect (database backup, S3 upload) needs a documented restore procedure.

CronJobs are the simplest scheduler in the Kubernetes ecosystem and the most most commonly misconfigured. The defaults work for development; production requires deliberate concurrency policy, timeZone, and deadline configuration.