Skip to content
OpsAtlas

Kubernetes CronJobs: Time Zones, Missed Runs, Overlaps

By OpsAtlas · · 3 min read

Table of contents

A crontab line on a server is simple: the job runs at the time you wrote. A Kubernetes CronJob uses the same five-field syntax, but it runs in a distributed system with controller restarts, node failures and a time zone you may not have chosen. These are the surprises that come up most often, and the fields that fix them.

“It runs at the wrong hour”

By default, the CronJob controller reads schedule in the time zone of kube-controller-manager. On most managed clusters that is UTC. A job written as 0 2 * * * for 2 a.m. in Berlin actually runs at 3 or 4 a.m. local time, depending on daylight saving.

Set the zone explicitly. spec.timeZone has been stable since Kubernetes 1.27:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 2 * * *"
  timeZone: "Europe/Berlin"

Don’t put TZ= or CRON_TZ= inside the schedule string. Kubernetes doesn’t support it, and newer versions reject it. Use timeZone.

Daylight saving still applies. In zones that change their clocks, the hour between about 02:00 and 03:00 is skipped once a year and repeated once a year. A job scheduled in that window can be skipped or run twice. Schedule important jobs outside it, or use UTC and accept that the local time shifts.

“It ran twice” or “it ran while the last one was still going”

concurrencyPolicy decides what happens when a run is due and the previous one hasn’t finished:

ValueBehaviour
Allow (default)starts another Job. Runs can pile up.
Forbidskips the new run while the old one is still active
Replacecancels the running Job and starts the new one

A report that takes 70 minutes on an @hourly schedule with Allow gradually builds up more and more parallel copies. For most maintenance jobs, Forbid is the safe default.

Separately, the Kubernetes documentation says a CronJob creates a Job about once per scheduled time. In rare cases there can be two, or none. Write jobs to be idempotent: running the same report twice should produce one report, not two invoices.

“It stopped running”

Two settings interact here.

startingDeadlineSeconds is how late a run may start before it counts as missed. Without it, the controller counts how many scheduled times it missed since the last run. If that number goes over 100, for example after a long controller outage, a suspended CronJob or a very frequent schedule, it doesn’t start the job and logs an error instead. Setting a deadline limits how far back the controller looks:

spec:
  startingDeadlineSeconds: 300   # skip a run that is more than 5 minutes late

suspend: true stops new runs. People set it during an incident and forget to turn it off, so check it first when a CronJob goes quiet:

kubectl get cronjob -A -o custom-columns=NAME:.metadata.name,SUSPEND:.spec.suspend,LAST:.status.lastScheduleTime

“I can’t see why it failed”

By default a CronJob keeps 3 successful and 1 failed Job (successfulJobsHistoryLimit, failedJobsHistoryLimit). The pod of an older failure is gone, along with its logs. Keep a few more failures and give each Job a time limit and a retry limit:

spec:
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 3600

activeDeadlineSeconds stops a stuck Job, so with Forbid it can’t block every later run.

Read the schedule before you ship it

Many CronJob bugs come from the expression itself. 0 0 1-7 * 1 means “the first seven days of the month or any Monday”, not “the first Monday”. */45 runs at minutes 0 and 45, so the gap is 45 minutes and then 15. And 0 0 31 * * skips seven months of the year.

Paste the expression into the cron expression parser before you commit it. It describes the schedule in plain English and lists the next run times, so you can check when it really runs. It also warns about the day-of-month OR day-of-week rule and about uneven steps. The same check works for GitHub Actions schedule triggers and ordinary crontabs.

A CronJob template to start from

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "15 1 * * *"
  timeZone: "Europe/Berlin"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 3600
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: report
              image: ghcr.io/acme/report:1.8.2

01:15 is outside the daylight-saving window. Forbid prevents overlapping runs. The deadline stops the controller from replaying old runs after an outage, and the failure history keeps the logs of the last five failed runs.

Try it yourself

  • Cron expression parser

    Translate a crontab schedule into plain English and list its next run times in UTC or your time zone, with warnings for common cron traps.

    Linux