Intro
Kubernetes CronJobs are great for recurring tasks: data syncs, report generation, cache warming, and cleanup. This guide shows how to automate CronJobs with CI/CD using practical YAML, Python, Bash, and GitLab CI examples. You will learn to:
- Build and test a job image
- Validate manifests before deploy
- Deploy safely with suspend-first and on-demand test runs
- Add smoke tests and guardrails
- Roll back fast when something fails
- Diagnose common pipeline failures
Why this works: example-led guidance helps teams get from intent to working automation, while a clear separation of stages reduces rework and speeds iteration.
Workflow Overview
The following end-to-end workflow is simple, safe, and production-friendly.
- Author the job code
Example Python job with explicit exit codes and logs:
# app/main.py
import logging, os, sys, time
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
def validate_env():
required = ["EXAMPLE_PARAM"]
missing = [k for k in required if not os.getenv(k)]
if missing:
logging.error("Missing required env: %s", ",".join(missing))
return False
return True
def run_job():
logging.info("job start")
# Simulate work
time.sleep(2)
# Add real logic here
logging.info("job done")
return 0
if __name__ == "__main__":
if not validate_env():
sys.exit(2)
code = run_job()
sys.exit(code)
Entrypoint with strict error handling:
# app/entrypoint.sh
#!/usr/bin/env bash
set -Eeuo pipefail
trap 'echo "[ERROR] line $LINENO status $?" >&2' ERR
python /app/main.py
Minimal Dockerfile:
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY app/requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt || true
COPY app/ /app/
RUN useradd -u 10001 appuser && chown -R appuser /app
USER 10001
ENTRYPOINT ["bash","/app/entrypoint.sh"]
- Define the CronJob manifest
Safe defaults limit blast radius and make failures easier to debug.
# k8s/cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: report-cron
namespace: staging
spec:
schedule: "*/15 * * * *" # every 15 minutes
# timeZone: "UTC" # uncomment if your cluster supports it
concurrencyPolicy: Forbid # avoid overlapping runs
startingDeadlineSeconds: 300 # do not start if too late
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 2
suspend: true # start suspended; smoke test first
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 600 # hard cap on run length
template:
spec:
restartPolicy: Never
containers:
- name: report
image: registry.example.com/report-cron:${IMAGE_TAG}
imagePullPolicy: IfNotPresent
env:
- name: EXAMPLE_PARAM
value: nightly
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
imagePullSecrets:
- name: regcred
Notes:
- Use immutable image tags (e.g., commit SHA). Avoid latest.
- Use suspend: true for first deploys; enable after a successful smoke run.
- For secrets, mount from a Secret or use your standard secrets approach.
- Add CI/CD stages
A practical GitLab CI example with build, validate, deploy, smoke test, promote, and rollback.
# .gitlab-ci.yml
stages: [test, build, validate, deploy_staging, smoke_test, deploy_prod, rollback]
variables:
APP_NAME: "report-cron"
IMAGE_REGISTRY: "registry.example.com"
IMAGE_TAG: "$CI_COMMIT_SHA"
KUBE_NAMESPACE_STAGING: "staging"
KUBE_NAMESPACE_PROD: "prod"
# Provide DOCKER_AUTH, KUBECONFIG, and kubectl in your runners
unit-test:
stage: test
image: python:3.11-slim
script:
- pip install -r app/requirements.txt || true
- python -m py_compile app/main.py
build-image:
stage: build
image: docker:24
services: ["docker:24-dind"]
script:
- echo "$DOCKER_AUTH" | docker login -u "$DOCKER_USER" --password-stdin $IMAGE_REGISTRY
- docker build -t $IMAGE_REGISTRY/$APP_NAME:$IMAGE_TAG .
- docker push $IMAGE_REGISTRY/$APP_NAME:$IMAGE_TAG
needs: ["unit-test"]
validate-manifest:
stage: validate
image: bitnami/kubectl:1.30
script:
- sed "s/\${IMAGE_TAG}/$IMAGE_TAG/g" k8s/cronjob.yaml > k8s/rendered.yaml
- kubectl apply --dry-run=client -f k8s/rendered.yaml
needs: ["build-image"]
deploy-staging:
stage: deploy_staging
image: bitnami/kubectl:1.30
script:
- kubectl config use-context "$KUBE_CONTEXT_STAGING"
- kubectl -n $KUBE_NAMESPACE_STAGING apply -f k8s/rendered.yaml
- kubectl -n $KUBE_NAMESPACE_STAGING annotate cronjob/$APP_NAME ci-commit=$CI_COMMIT_SHA --overwrite
needs: ["validate-manifest"]
environment:
name: staging
smoke-test-staging:
stage: smoke_test
image: bitnami/kubectl:1.30
script:
- kubectl config use-context "$KUBE_CONTEXT_STAGING"
- export SMOKE_JOB=${APP_NAME}-smoke-$(date +%s)
- kubectl -n $KUBE_NAMESPACE_STAGING create job $SMOKE_JOB --from=cronjob/$APP_NAME
- kubectl -n $KUBE_NAMESPACE_STAGING wait --for=condition=complete job/$SMOKE_JOB --timeout=10m || (kubectl -n $KUBE_NAMESPACE_STAGING logs job/$SMOKE_JOB; exit 1)
- kubectl -n $KUBE_NAMESPACE_STAGING logs job/$SMOKE_JOB
- kubectl -n $KUBE_NAMESPACE_STAGING delete job/$SMOKE_JOB --ignore-not-found
- echo "Smoke test passed"
needs: ["deploy-staging"]
promote-prod:
stage: deploy_prod
image: bitnami/kubectl:1.30
script:
- kubectl config use-context "$KUBE_CONTEXT_PROD"
- sed "s/namespace: staging/namespace: $KUBE_NAMESPACE_PROD/g" k8s/rendered.yaml > k8s/prod.yaml
- kubectl -n $KUBE_NAMESPACE_PROD apply -f k8s/prod.yaml
- kubectl -n $KUBE_NAMESPACE_PROD annotate cronjob/$APP_NAME ci-commit=$CI_COMMIT_SHA --overwrite
- # Optionally unsuspend after smoke test in prod
needs: ["smoke-test-staging"]
environment:
name: production
rollback:
stage: rollback
image: bitnami/kubectl:1.30
when: manual
variables:
ROLLBACK_TAG: "" # set this when triggering
script:
- test -n "$ROLLBACK_TAG" || (echo "Set ROLLBACK_TAG" && exit 1)
- kubectl config use-context "$KUBE_CONTEXT_PROD"
- kubectl -n $KUBE_NAMESPACE_PROD patch cronjob $APP_NAME --type='json' \
-p='[{"op":"replace","path":"/spec/jobTemplate/spec/template/spec/containers/0/image","value":"'$IMAGE_REGISTRY'/'$APP_NAME':'$ROLLBACK_TAG'"}]'
- # Verify via one-off job before unsuspending schedule
- export SMOKE_JOB=${APP_NAME}-rb-$(date +%s)
- kubectl -n $KUBE_NAMESPACE_PROD create job $SMOKE_JOB --from=cronjob/$APP_NAME
- kubectl -n $KUBE_NAMESPACE_PROD wait --for=condition=complete job/$SMOKE_JOB --timeout=10m || (kubectl -n $KUBE_NAMESPACE_PROD logs job/$SMOKE_JOB; exit 1)
- kubectl -n $KUBE_NAMESPACE_PROD logs job/$SMOKE_JOB
- kubectl -n $KUBE_NAMESPACE_PROD delete job/$SMOKE_JOB --ignore-not-found
- Safe deployment checks
- Dry run manifests before apply.
- Deploy CronJobs suspended in new environments.
- Smoke test with a one-off Job created from the CronJob template.
- Keep runs short with activeDeadlineSeconds and enforce no overlap with concurrencyPolicy: Forbid.
- Use immutable tags and annotate resources with the commit SHA for traceability.
- Enable schedules after verification
Once smoke tests pass, unsuspend the schedule:
kubectl -n staging patch cronjob report-cron -p '{"spec": {"suspend": false}}'
Repeat in production after promotion and validation.
Local Pilot Plan
Start with one CronJob and keep the scope tight.
Pilot goals
- One CronJob in a sandbox namespace
- Build, validate, deploy suspended, and pass a smoke test
- Measured by exit code 0 and expected logs
Steps
- Choose a low-risk job (e.g., report generation without external side effects).
- Containerize and add strict exit codes and logs (as above).
- Write a CronJob manifest with safe defaults and suspend: true.
- Run local checks:
- Lint Python, run quick tests
- Render manifest with the commit SHA
- kubectl apply --dry-run=client -f k8s/rendered.yaml
- Deploy to a sandbox namespace.
- Create a one-off job from the CronJob and wait for completion.
- Inspect logs and exit status; fix issues.
- Document success criteria and only then unsuspend the schedule.
Why this plan: a narrow, measurable, locally inspectable pilot is the fastest way to reach value and learn without risking production.
Practical rollback patterns
CronJobs do not support kubectl rollout undo. Keep rollback simple:
- Immutable tags: build images tagged with the commit SHA; keep a record of last known good.
- Patch back to the last known good tag and verify with a one-off job.
- Alternatively, revert the Git change that updated the image tag and redeploy.
Example patch (already in the CI job):
kubectl -n prod patch cronjob report-cron --type='json' \
-p='[{"op":"replace","path":"/spec/jobTemplate/spec/template/spec/containers/0/image","value":"registry.example.com/report-cron: abc123"}]'
Common pipeline failures and fixes
- ImagePullBackOff: registry credentials missing or wrong image tag.
- Fix: ensure imagePullSecrets and immutable tags; verify push succeeded.
- CrashLoopBackOff or nonzero exit: code or env misconfig.
- Fix: add required env vars; strengthen entrypoint error handling; inspect logs.
- Job exceeds time limit:
- Fix: set activeDeadlineSeconds and review workload; break into smaller tasks.
- Overlapping runs cause conflicts:
- Fix: concurrencyPolicy: Forbid and reasonable schedule spacing.
- Missed start times when the cluster is busy:
- Fix: set startingDeadlineSeconds and consider increasing resources.
- Suspended CronJob never runs:
- Fix: unsuspend after successful smoke test.
- RBAC or context errors in CI:
- Fix: confirm kubectl context, namespace, and permissions for apply, create job, and patch.
- Cron timezone confusion:
- Fix: schedule in UTC or validate cluster timezone behavior; set expectations in documentation.
Conclusion
Automating Kubernetes CronJobs with CI/CD is straightforward when you separate concerns and build in safety:
- Build and test the image with explicit exit codes
- Validate manifests with dry runs
- Deploy suspended, then smoke test via a one-off Job
- Promote with immutable tags and clear annotations
- Keep a simple, fast rollback by patching the image tag
Start with one CronJob in a sandbox, make it measurable, and only expand once your pilot is green and observable.