Skip to content

Jobs and CronJobs

A container that does a piece of work and exits does not belong in a Deployment. A Deployment restarts it forever, which is where most CrashLoopBackOff reports on a perfectly healthy container come from.

A one-off task

yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: migrate
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: OnFailure
      containers:
        - name: migrate
          image: registry.itsh.dev/<your-slug>/myapp:1.0.0
          command: ["./migrate"]
          securityContext:
            allowPrivilegeEscalation: false
            runAsNonRoot: true
            seccompProfile:
              type: RuntimeDefault
            capabilities:
              drop: ["ALL"]
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 256Mi

restartPolicy must be OnFailure or Never; Always is not valid for a Job. backoffLimit caps the retries before the Job is marked failed.

The securityContext is the same block as on any other pod, and it is there for the same reason: without it Kubernetes prints four warnings on apply. See Versions and defaults.

Scheduled work

yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 3 * * *"
  timeZone: "Europe/Berlin"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: report
              image: registry.itsh.dev/<your-slug>/myapp:1.0.0
              command: ["./report"]
              securityContext:
                allowPrivilegeEscalation: false
                runAsNonRoot: true
                seccompProfile:
                  type: RuntimeDefault
                capabilities:
                  drop: ["ALL"]

Standard five-field cron, unlike the six-field form the database operator uses for scheduled backups.

Three fields worth setting deliberately:

  • timeZone. Without it the schedule runs in UTC, which is not what you meant if you wrote 0 3 * * * thinking of local time.
  • concurrencyPolicy: Forbid skips a run if the previous one is still going. The default lets them overlap, which is rarely what a report or a cleanup wants.
  • The history limits. Completed pods are kept so you can read their logs. Keep a few; keeping many clutters the namespace.

What they cost

A Job is billed for the requests of its pod, for as long as that pod is actually running, measured by the minute. A Job that runs for five minutes costs five minutes, not an hour.

Completed pods kept as history are not billed. They are not running, so they cost nothing, and keeping a few for their logs is free.

This makes scheduled work the cheapest thing on the platform, and it is why a CronJob is a better answer than a service that sleeps: a CronJob costs nothing between runs without any of the scale-to-zero caveats. See Scale-to-zero.

When a Job fails

kubectl get jobs shows completions. The pods stay around, so:

bash
kubectl get pods --selector=job-name=migrate
kubectl logs job/migrate

A CronJob that never seems to run is usually a schedule in the wrong timezone, or concurrencyPolicy: Forbid skipping runs because the previous one never finished.

What's next