Skip to main content

How to Run Cron in Kubernetes So Jobs Never Overlap or Vanish

5 min read
Fix my Kubernetes CronJobs

We set concurrency, missed schedule and history fields so nightly jobs run once and leave logs.

Scheduled work in Kubernetes fails in two quiet ways, a slow job that starts a second copy of itself and a schedule that silently stops firing. Both are configuration, not luck. This covers the CronJob fields that control concurrency, missed schedules, history retention and failure handling, with the actual defaults, so the nightly billing run is still there in the morning and there is a log to read when it is not.

Scheduled work fails quietly. A billing run that takes longer than usual starts a second copy of itself and double charges. A schedule stops firing after a control plane hiccup and nobody notices until the weekly report is missing. Neither is bad luck, both are default behaviour you can change.

Here is the manifest that gets it right, followed by why each field is there.

# k8s/cronjob-nightly-invoices.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-invoices
spec:
  schedule: "15 2 * * *"
  timeZone: "Etc/UTC"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
  successfulJobsHistoryLimit: 5
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 3
      activeDeadlineSeconds: 3600
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: invoices
              image: registry.example.com/billing:1.4.2
              command: ["/app/bin/invoices", "--run-date=yesterday"]

Overlap

concurrencyPolicy defaults to Allow, which lets concurrent Jobs run. That default is right for a stateless cache warmer and wrong for anything that writes.

Forbid skips the new run if the previous one has not finished. Replace kills the running Job and starts the new one. For financial or reporting work Forbid is almost always the answer, because a skipped run is visible and recoverable while two concurrent runs are neither.

Even with Forbid, keep the job itself idempotent. The documentation is direct about this, a CronJob creates a Job approximately once per execution time, circumstances exist where two Jobs or no Job get created, and the Jobs you define should be idempotent. Guard the work with a database transaction or a run key rather than trusting the scheduler.

Vanishing schedules

This is the failure that costs an afternoon of confusion. For every CronJob, the controller checks how many schedules it missed between its last scheduled time and now. If there are more than 100 missed schedules it does not start the Job at all, and logs an error reading "too many missed start times. Set or decrease .spec.startingDeadlineSeconds or check clock skew".

A CronJob suspended for a week, or a control plane that was down long enough, lands in exactly that state and then stays stuck. The fix is startingDeadlineSeconds, which changes what the controller counts. With a deadline set, it measures the time between when a Job was expected and now, and skips the execution if the difference is larger than the limit, so old misses stop accumulating into the lockout.

Do not set it below ten seconds. The CronJob controller checks things every ten seconds, so a deadline shorter than that can mean the Job is never scheduled at all. Five minutes is a reasonable value for a nightly job.

Time zones

.spec.timeZone takes an IANA name such as Etc/UTC or Europe/Berlin and has been stable since v1.27. Putting CRON_TZ or TZ variables inside .spec.schedule is not officially supported and produces a validation error.

Daylight saving is the reason to care. A job scheduled at 02:30 in a zone that observes DST will run twice on one night of the year and not at all on another. Schedule anything financial in UTC and do the timezone conversion inside the job.

History, so there is something to read

successfulJobsHistoryLimit defaults to 3 and failedJobsHistoryLimit defaults to 1. One failed Job is not much of an audit trail when a job has been failing intermittently for a fortnight, so raise both for anything important. Setting either to 0 keeps none.

Job history is the primary debugging surface, since Pod logs disappear with the Pod.

kubectl get jobs -l batch.kubernetes.io/cronjob-name=nightly-invoices --sort-by=.metadata.creationTimestamp
kubectl logs job/nightly-invoices-29001234

Since Kubernetes v1.32 the CronJob controller also stamps created Jobs with a batch.kubernetes.io/cronjob-scheduled-timestamp annotation, which tells you the schedule slot a Job belongs to rather than only when it happened to start.

If you prefer time based cleanup, ttlSecondsAfterFinished on the Job spec deletes finished Jobs after a fixed delay. Both mechanisms delete Jobs, so if you set both, the one that fires first wins. That feature is also sensitive to clock skew in the cluster, which can cause cleanup at the wrong time.

Failure handling

backoffLimit defaults to 6 retries. Failed Pods are recreated with an exponential back-off delay of 10s, 20s, 40s and so on, capped at six minutes, so six retries can stretch across a long window. Lower it for a job that either works immediately or needs a human.

activeDeadlineSeconds is the wall clock stop. It takes precedence over backoffLimit, so a Job that hits the time limit stops deploying new Pods even if retries remain. Every scheduled job should have one, chosen a comfortable margin above the longest healthy run, otherwise a hung job holds a Forbid lock forever and every subsequent schedule is skipped.

restartPolicy in a Job's Pod template only accepts Never or OnFailure. OnFailure restarts the container in place, which is faster; Never creates a fresh Pod per attempt, which leaves clearer evidence behind.

Running one by hand

Testing a schedule by waiting for it is a bad use of an evening. Trigger a run from the CronJob definition instead.

kubectl create job invoices-manual-001 --from=cronjob/nightly-invoices

And when a job needs to stop without being deleted, suspend it rather than commenting out the schedule.

kubectl patch cronjob nightly-invoices -p '{"spec":{"suspend":true}}'

Remember the missed schedule counter when you unsuspend a job that has been paused for a long time, since that is precisely the situation startingDeadlineSeconds exists to survive.

Alert on absence

Nothing above tells you a job stopped running, and Kubernetes will not either. A job that never starts produces no failed Pod and no alert. Have each run touch a heartbeat, then alert when the heartbeat is older than the interval plus a margin. Absence is the failure mode that scheduled work is worst at reporting on its own.

Getting this right across a fleet of scheduled work is standard practice in our SRE services and our ongoing Kubernetes management.

Frequently asked questions

How do I stop a Kubernetes CronJob from running two copies at the same time?

Set concurrencyPolicy to Forbid, which skips a new run while the previous one is still going. The default, Allow, lets concurrent Jobs run, and Replace kills the running Job to start the new one. Keep the job idempotent anyway. Kubernetes creates a Job only approximately once per execution time and in some circumstances creates two or none, so guard the work with a database transaction or a run key.

Why did my Kubernetes CronJob stop firing after it was suspended?

When the CronJob controller counts more than 100 missed schedules since the last scheduled time, it does not start the Job at all and logs an error about too many missed start times. A long suspension or control plane outage leaves it stuck there. Setting startingDeadlineSeconds fixes the count, because the controller then measures from when each Job was expected and skips executions older than the deadline. Never set it below ten seconds, since the controller checks every ten seconds. Five minutes suits a nightly job.

How do I set a time zone for a Kubernetes CronJob?

Use .spec.timeZone with an IANA name such as Etc/UTC or Europe/Berlin, a field that has been stable since Kubernetes v1.27. Putting CRON_TZ or TZ variables inside .spec.schedule is not officially supported and produces a validation error. For financial work, schedule in UTC and convert inside the job, since a job set for 02:30 in a zone that observes daylight saving runs twice on one night of the year and not at all on another.

How many finished Jobs does a Kubernetes CronJob keep by default?

A CronJob keeps 3 successful Jobs and 1 failed Job by default, set by successfulJobsHistoryLimit and failedJobsHistoryLimit, and a value of 0 keeps none. One failed Job is a thin audit trail for a job that has failed on and off for a fortnight, so raise both for important work, since Pod logs disappear with the Pod. The time based alternative is ttlSecondsAfterFinished on the Job spec, and when both are set, whichever fires first wins.

What is the default backoffLimit for a Kubernetes Job?

The default backoffLimit is 6 retries. Failed Pods are recreated with an exponential back-off of 10s, 20s, 40s and so on, capped at six minutes, so six retries can stretch over a long window. Lower it for a job that either works straight away or needs a human. In the Pod template, restartPolicy accepts only Never or OnFailure. OnFailure restarts the container in place, which is faster, and Never creates a fresh Pod per attempt, which leaves clearer evidence behind.

Why does a Kubernetes CronJob with concurrencyPolicy Forbid skip every run?

A hung Job holds the Forbid lock indefinitely, so every later schedule is skipped while it hangs. Give every scheduled job an activeDeadlineSeconds value as a wall clock stop, set a comfortable margin above its longest healthy run. The deadline takes precedence over backoffLimit, so a Job that reaches it stops starting new Pods even if retries remain.

How do I trigger a Kubernetes CronJob manually for testing?

Create a one-off Job from the CronJob definition, for example kubectl create job invoices-manual-001 --from=cronjob/nightly-invoices, instead of waiting for the schedule. To pause a CronJob without deleting it, suspend it with kubectl patch cronjob nightly-invoices -p '{"spec":{"suspend":true}}' and leave the schedule alone. Before unsuspending a job that was paused for a long time, make sure startingDeadlineSeconds is set, since the missed schedule counter is exactly what it guards against.

How do I get alerted when a Kubernetes CronJob silently stops running?

Kubernetes will not report a job that never starts, because a run that never begins produces no failed Pod and no alert. Have each run touch a heartbeat, then alert when the heartbeat is older than the schedule interval plus a margin. Absence is the failure mode scheduled work is worst at reporting on its own, so it needs its own check.

Read how we handle it in DevOps as a Service.

Related Articles

Cloud

How to Run Multi AZ So It Actually Survives an AZ Failure

Most AWS accounts are multi AZ on paper already, and then a zone has a bad day and the site goes down anyway. Spreading a deployment across zones and keeping it serving when one disappears are two different properties. This covers what the managed services really do during a zone failure, including which failovers reset every open connection and how long each one takes, the single points that quietly survive a multi AZ design, and the commands to rehearse all of it on purpose.

Cloud

How to Run Postgres on Kubernetes With Point in Time Recovery

A nightly dump cannot answer the question that actually gets asked after an incident, which is to put the database back the way it was at 10.42, just before the migration ran. Continuous archiving can. This is an operator based setup on Kubernetes with base backups and write ahead log shipped to object storage, plus recovery to a named timestamp. It also flags the configuration change that makes most copied CloudNativePG YAML out of date.

Magento

How to Run Magento 2 on Kubernetes Without Overpaying

Most Magento clusters cost more than they need to because every tier gets replicated as if every tier were the bottleneck, when only PHP-FPM ever is. This is the shape that keeps the bill honest, with sessions and cache moved to Valkey or Redis, media moved to object storage instead of a shared filesystem, and cron running on exactly one scheduler because Adobe documents that it can only run on one node. It also covers the case for not doing this at all, since a single well-sized server with a warm standby reaches the same uptime for a single steady store.