Run Django Scheduled Jobs as Kubernetes CronJobs
Schedule Django management commands on Kubernetes with CronJobs: concurrencyPolicy Forbid, time zones, startingDeadlineSeconds, history limits, resource requests, secrets, and monitoring missed or failed runs, compared with Celery Beat.
Problem statement
Django apps have periodic work: clearing expired sessions, sending digest emails, syncing data, generating reports. On a VM you would use cron or systemd timers. On Kubernetes, running cron inside the web container is fragile (every replica runs it, logs vanish, and it dies with the pod). Kubernetes CronJobs create a fresh pod from your image on schedule, run one management command, and record success or failure. They need a few settings to behave well: no overlapping runs, a defined time zone, sensible deadlines, and monitoring for runs that silently never happen.
- Create a
CronJobper command, using the same image tag and secrets as the web Deployment. - Set
concurrencyPolicy: Forbidso a slow run is never overlapped by the next one. - Set
timeZone(Kubernetes 1.27+) explicitly, for exampleEurope/BerlinorEtc/UTC. - Set
startingDeadlineSeconds,backoffLimit,activeDeadlineSeconds, and history limits. - Make commands idempotent; a run can be retried or, rarely, start twice.
- Monitor with a heartbeat service or alerts on failed Jobs and missed schedules.
Step-by-step solution
1. A CronJob for a management command
apiVersion: batch/v1
kind: CronJob
metadata: { name: clearsessions, namespace: app }
spec:
schedule: "15 3 * * *"
timeZone: "Etc/UTC"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 1800
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
containers:
- name: task
image: ghcr.io/you/myapp:SHA
command: ["python", "manage.py", "clearsessions"]
envFrom: [{ secretRef: { name: django-env } }]
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { memory: 512Mi }
Keep the image tag in step with the Deployment in your release tooling (Helm values or Kustomize), so scheduled jobs run the same code as the web pods.
2. Understand the scheduling fields
concurrencyPolicy: Forbidskips a run if the previous one is still active.Replacekills the old run;Allow(the default) runs both, which is rarely safe for Django tasks.startingDeadlineSecondslets a run start late (for example after a controller restart) within the window; outside it, the run is counted as missed.activeDeadlineSecondskills runs that hang.backoffLimitretries failed pods; set it low for non-idempotent commands.
3. Idempotent commands
Even with Forbid, Kubernetes documents that a CronJob can occasionally create two Jobs or none for one schedule. Write commands so a second run does no harm: process items with a "done" flag, use database locks (select_for_update(skip_locked=True)), or take an advisory lock at the start:
from django.db import connection
def handle(self, *args, **opts):
with connection.cursor() as cur:
cur.execute("SELECT pg_try_advisory_lock(%s)", [hash("send_digest") & 0x7FFFFFFF])
if not cur.fetchone()[0]:
self.stdout.write("another run in progress, exiting")
return
send_digests()
4. CronJobs or Celery Beat?
CronJobs suit a handful of independent, mostly daily or hourly commands, with logs and status per run in Kubernetes. Celery Beat suits many schedules, frequent intervals (every minute), schedules edited by staff in the admin, and tasks that fan out to workers. Mixing both is fine; just do not schedule the same work in both.
5. Monitor missed and failed runs
A CronJob that never runs produces no error. Add a heartbeat ping at the end of each successful command (Healthchecks.io, Cronitor, or your monitoring), and alert on kube_job_status_failed and on kube_cronjob_status_last_schedule_time falling behind with kube-state-metrics.
Verification check: kubectl create job --from=cronjob/clearsessions test-clearsessions -n app runs the command on demand and completes; kubectl get cronjob clearsessions shows LAST SCHEDULE after the next scheduled time; the heartbeat dashboard shows the ping.
Explanation
The CronJob controller creates a Job object at each scheduled time, and the Job controller creates pods until one succeeds or the retry limit is reached. Each run is an isolated pod from your image with its own logs and exit status, which is cleaner than cron inside a long-running container. The controller's guarantees are deliberately loose (at most once per schedule is not strictly guaranteed), which is why idempotent commands and explicit concurrency settings matter.
Edge cases / notes
- Clock and DST. Schedules in time zones with daylight saving can skip or repeat an hour; use UTC for critical jobs.
- Database migrations should not run as CronJobs; use release Jobs.
- Long jobs that exceed the schedule interval are skipped under
Forbid; size them or split the work. - Suspend a CronJob during maintenance with
kubectl patch cronjob x -p '{"spec":{"suspend":true}}'.
Next steps
- Without Kubernetes, use systemd Timers for Django Management Commands.
- Get alerts for silent failures with Monitor Django Scheduled Jobs With Heartbeat Checks.
FAQ
How do I run a Django management command on a schedule in Kubernetes?
Create a CronJob whose pod template runs python manage.py <command> with the app image and secrets.
How do I stop CronJob runs from overlapping?
Set concurrencyPolicy: Forbid.
Should I use CronJobs or Celery Beat?
CronJobs for a few daily or hourly commands; Beat for many or frequent schedules.
How do I test a CronJob immediately?
kubectl create job --from=cronjob/<name> <test-name>.