How-to
Automating Reporting WorkflowsDeep dive

Run an Excel Report as a Kubernetes CronJob

Schedule a containerised Python Excel report on a Kubernetes cluster: a CronJob manifest with a time zone, no overlapping runs, a deadline for late starts, retries and a hard time limit, credentials from a Secret, output to object storage, clean-up of finished jobs, a strict schema check before applying, and manual test runs.

Teams that already run their applications on Kubernetes often want their reports there too, rather than on a forgotten VM with a crontab. A CronJob is Kubernetes' scheduler: on each tick it creates a Job, which runs a pod with the report container to completion, retries it if it fails, and keeps a history of recent runs. The manifest has a handful of fields that decide whether it behaves well — whether a slow run can overlap the next one, what happens if Kubernetes was down at the scheduled time, how long a stuck run may live. This page, part of Scheduling Python Excel Scripts with Cron, writes that manifest, checks it strictly against the Kubernetes schema before it goes near a cluster, and covers testing and troubleshooting.

Prerequisites

Bash
pip install kubernetes-validate croniter
# kubectl and access to a cluster to apply it

The report is packaged as a container image as described in Run a Python Excel Report in Docker and pushed to a registry the cluster can pull from. It reads its database password from the environment and uploads the finished workbook to object storage. The manifest below was checked with kubernetes-validate, which validates against the official schemas for a given Kubernetes version without needing a cluster.

CronJob, Job, Pod Three layers. The CronJob holds the schedule and creates a Job on each tick; the Job handles retries and the time limit and creates Pods; each Pod runs the report container once. CronJob schedule, timeZone, concurrencyPolicy, history creates on each tick Job backoffLimit, activeDeadlineSeconds, ttlSecondsAfterFinished creates Pod the report container, restartPolicy Never

Step 1 — The manifest

Every field below is there for a reason explained after it:

Yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: weekly-sales-report
  namespace: reporting
spec:
  schedule: "0 7 * * 1"
  timeZone: "Europe/London"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 3600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 5
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 1800
      ttlSecondsAfterFinished: 604800
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: report
              image: registry.example.com/reports/weekly-sales:1.4.0
              args: ["--report-date", "auto"]
              env:
                - name: DB_PASSWORD
                  valueFrom:
                    secretKeyRef:
                      name: reporting-db
                      key: password
                - name: REPORT_BUCKET
                  value: s3://acme-reports/sales
              resources:
                requests:
                  cpu: 250m
                  memory: 512Mi
                limits:
                  memory: 1Gi
  • schedule and timeZone — standard five-field cron, evaluated in the named zone, so 07:00 means London time through the clock changes. Without timeZone it is the controller's zone, usually UTC.
  • concurrencyPolicy: Forbid — if last week's run is somehow still going, this week's is skipped rather than started alongside it. Two copies writing the same workbook is how reports get corrupted.
  • startingDeadlineSeconds — if the cluster was down or overloaded at 07:00, the job may still start up to an hour late; after that the run is skipped and counted as missed.
  • backoffLimit — the Job retries a failed pod twice, with increasing delays, before giving up.
  • activeDeadlineSeconds — a hard limit of 30 minutes for the whole Job, retries included; a hung database query cannot keep a pod alive for days.
  • ttlSecondsAfterFinished — finished Jobs and their pods are deleted after a week, so logs are available for review but do not pile up.
  • restartPolicy: Never — a failed container is not restarted in place; the Job creates a new pod, and each attempt keeps its own logs.

The image has an explicit version tag rather than latest, so a new build does not change what Monday's run executes without a deliberate update.

Step 2 — Validate strictly before applying

A misspelled field is the most common CronJob bug, and a cluster does not necessarily refuse it: by default, recent versions of kubectl print a warning about an unknown field and apply the rest, so concurencyPolicy with one r is dropped and the job runs with the default policy that allows overlaps. A warning in the middle of a deployment log is easy to miss. Validate the manifest against the schema in strict mode, which rejects unknown fields:

Bash
kubernetes-validate --strict -k 1.33.0 cronjob.yaml
sed 's/concurrencyPolicy/concurencyPolicy/' cronjob.yaml > cronjob_typo.yaml
kubernetes-validate --strict -k 1.33.0 cronjob_typo.yaml || echo "rejected, as it should be"

The real manifest passes; the one with the typo is rejected with the offending field named. Running this in CI, next to the report's tests, catches mistakes before kubectl apply does — or, worse, before the first silent misbehaviour does. On a cluster, kubectl apply --dry-run=server -f cronjob.yaml adds a check by the API server itself, including admission policies.

What strict validation catches Two panels. Without strict validation a misspelled field such as concurencyPolicy is accepted and silently ignored, so overlapping runs are allowed. With strict validation the unknown field is rejected before the manifest is applied. Applied as is unknown field ignored default policy: overlaps allowed found only when runs collide Validated strictly unknown field rejected field name in the error fails in CI, not in production

Step 3 — Check the schedule in its time zone

Preview the next runs in the configured zone, including across the October clock change, to confirm the expression and the zone say what you mean:

Python
from datetime import datetime
from zoneinfo import ZoneInfo
from croniter import croniter

tz = ZoneInfo("Europe/London")
it = croniter("0 7 * * 1", datetime(2026, 10, 12, 12, 0, tzinfo=tz))
for _ in range(3):
    run = it.get_next(datetime)
    print(run.strftime("%a %d %b %H:%M %Z"), "=", run.astimezone(ZoneInfo("UTC")).strftime("%H:%M UTC"))
The same schedule across the October clock change A timeline of three Monday runs. On 19 October the 07:00 London run is 06:00 UTC; after the clocks go back on 25 October, the 26 October and 2 November runs are 07:00 UTC, still 07:00 in London. Mon 19 Oct 07:00 BST = 06:00 UTC Sun 25 Oct clocks go back Mon 26 Oct 07:00 GMT = 07:00 UTC Mon 02 Nov 07:00 GMT = 07:00 UTC

The run stays at 07:00 local time while its UTC time moves from 06:00 to 07:00 after the clocks go back on 25 October — which is exactly what timeZone buys. A schedule written in UTC without it would drift by an hour relative to the office twice a year.

Step 4 — Make the script a good pod citizen

Kubernetes judges each attempt by the container's exit code and stops pods with SIGTERM when the deadline passes or the node is drained. The script should exit non-zero on failure and stop cleanly when asked:

Python
import signal
import sys
import time

stopping = False

def handle_sigterm(signum, frame):
    global stopping
    stopping = True
    print("SIGTERM received, stopping after the current step", flush=True)

signal.signal(signal.SIGTERM, handle_sigterm)

def main() -> int:
    for step in ("extract", "build", "upload"):
        if stopping:
            return 1
        print(f"{step} …", flush=True)
        time.sleep(0.2)
    return 0

if __name__ == "__main__":
    sys.exit(main())
Bash
python report.py; echo "exit $?"
python report.py & sleep 0.3; kill -TERM $!; wait $!; echo "exit after SIGTERM: $?"

Run normally, it completes all three steps and exits 0. Sent SIGTERM part-way through, it finishes the current step, skips the rest and exits 1, so the Job records a failure instead of a success for a report that was never uploaded. Kubernetes waits terminationGracePeriodSeconds — 30 seconds by default — after SIGTERM before killing the container.

Step 5 — Apply, test and troubleshoot

Bash
kubectl apply -f cronjob.yaml
kubectl -n reporting create job weekly-sales-manual --from=cronjob/weekly-sales-report
kubectl -n reporting get jobs,pods -l job-name=weekly-sales-manual
kubectl -n reporting logs job/weekly-sales-manual
kubectl -n reporting get cronjob weekly-sales-report      # LAST SCHEDULE and ACTIVE columns

create job --from=cronjob/... runs the CronJob's template once, immediately — the way to test a new image or a changed secret without waiting for Monday. get cronjob shows when it last ran and whether a run is active. A job that never starts is usually an image pull error or a missing secret, visible in kubectl describe pod; one that starts and fails shows its reason in the logs. Detect a Scheduled Report That Did Not Run covers alerting when a run is missed altogether.

Performance notes

The resource requests decide where the pod can be scheduled and how much of a node it reserves; set them from a measured run — kubectl top pod during a manual run shows real usage — rather than guessing. The memory limit is a hard ceiling: a pandas job that exceeds it is killed with OOMKilled, which shows in kubectl describe pod and counts as a failed attempt. Workbook-building is usually single-threaded, so more than one CPU rarely helps; more memory often does.

Common pitfalls

  • Overlapping runs. Set concurrencyPolicy: Forbid; the default allows two copies at once.
  • No time zone. Without timeZone the schedule is in the controller's zone, usually UTC.
  • Unlimited runtime. Set activeDeadlineSeconds; a hung pod otherwise lives until someone notices.
  • The latest tag. Pin image versions so runs are reproducible.
  • Typos in field names. Unknown fields are dropped with at most a warning; validate strictly in CI.

Conclusion

Write the CronJob with a five-field schedule and an explicit timeZone, forbid concurrent runs, allow a bounded late start, and give the Job retries, a hard time limit and automatic clean-up. Feed credentials from a Secret, pin the image version, validate the manifest strictly with kubernetes-validate before applying, make the script exit non-zero on failure and stop on SIGTERM, and test it with kubectl create job --from=cronjob/....

Frequently asked questions

How do I schedule a Python script in Kubernetes? Package it in a container image and create a CronJob with a cron schedule whose jobTemplate runs that image. Kubernetes creates a Job, and a pod, at each scheduled time.

How do I stop Kubernetes CronJob runs from overlapping? Set concurrencyPolicy: Forbid on the CronJob. A run that is due while the previous one is still active is then skipped.

How do I run a CronJob immediately to test it? Use kubectl create job with --from=cronjob/NAME and a new job name. It runs the CronJob's job template once, right away.

Does a Kubernetes CronJob support time zones? Yes. Set spec.timeZone to an IANA zone name such as Europe/London; the schedule is then evaluated in that zone, including daylight-saving changes.

More on scheduling & orchestration

More on scheduling & orchestration →