Intro
Kubernetes CronJobs run containers on a schedule. A local lab lets you practice safely, reproduce issues, and build confidence before touching shared environments. In this guide you will set up a disposable kind cluster, create a dedicated namespace with a non-root service account, and run two CronJob examples: a minutely echo job and a Python ETL mock. You will validate schedules, concurrency behavior, logs, history limits, and cleanup routines so your team can test changes quickly and repeatably.
Workflow Overview
Follow this workflow to keep your experiments clean and fast:
- Create an isolated local cluster.
- Create a namespace and service account for CronJobs.
- Apply a tiny pilot CronJob with a fast schedule.
- Observe Job and Pod creation, logs, and history limits.
- Exercise controls: suspend, run-once, replace concurrency, backoff.
- Add a realistic second example (Python) with env vars and resources.
- Troubleshoot failures by inspecting events, Jobs, and Pods.
- Add safety guards: resource limits, non-root, cleanup scripts.
- Automate common checks into one-liners or scripts.
- Reset or delete the lab quickly when done.
Local Pilot Plan
Pilot goal: a minutely CronJob that prints the timestamp and a message, keeps a short Job history, and forbids overlapping runs.
Success criteria:
- Within 2 minutes you can see at least one successful Job.
- You can read logs from the latest Job Pod.
- No overlapping runs occur.
- You can suspend it, then resume.
- You can trigger a one-off run on demand.
This narrow scope is fast to measure and easy to inspect locally before any deployment.
Set Up a Local Cluster
Use kind for a lightweight, disposable cluster.
Create the cluster:
kind create cluster --name cronlab
Point kubectl at it:
kubectl cluster-info --context kind-cronlab
Verify nodes:
kubectl get nodes
Expected output shows one control-plane node with Ready status. Alternative: minikube start if you prefer minikube.
Namespace and ServiceAccount
Scope your lab to a namespace and run pods as non-root when possible.
Create a namespace and service account (save as ns-sa.yaml):
apiVersion: v1
kind: Namespace
metadata:
name: cronlab
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: cronjob-sa
namespace: cronlab
Apply it:
kubectl apply -f ns-sa.yaml
Example 1: Minutely Echo Job
A tiny CronJob that runs every minute, forbids overlaps, and keeps short history. Save as cron-echo.yaml:
apiVersion: batch/v1
kind: CronJob
metadata:
name: echo-minutely
namespace: cronlab
spec:
schedule: '* * * * *'
concurrencyPolicy: Forbid
startingDeadlineSeconds: 60
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
template:
metadata:
labels:
app: echo-minutely
spec:
serviceAccountName: cronjob-sa
restartPolicy: OnFailure
securityContext:
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: echo
image: busybox:1.36
imagePullPolicy: IfNotPresent
command: ['sh','-c','date; echo Hello from CronJob; sleep 5']
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 100m
memory: 64Mi
Apply and observe:
kubectl apply -f cron-echo.yaml
kubectl get cronjob -n cronlab
kubectl get jobs -n cronlab --watch
Get logs from the most recent Job Pod:
kubectl get pods -n cronlab -l app=echo-minutely
kubectl logs -n cronlab <pod-name>
Typical log output:
Tue Jan 14 10:30:00 UTC 2025
Hello from CronJob
Suspend and resume using a patch file (safer across shells). Save suspend-true.yaml:
spec:
suspend: true
Then:
kubectl patch cronjob echo-minutely -n cronlab --type merge --patch-file suspend-true.yaml
Set spec.suspend: false to resume.
Run once on demand without waiting for the schedule:
kubectl create job echo-now-1 --from=cronjob/echo-minutely -n cronlab
Use a new name each time (echo-now-2, echo-now-3, ...).
Example 2: Python ETL Mock
This example simulates a small data task every 5 minutes, uses env vars, scratch storage, and modest resources. Save as cron-python.yaml:
apiVersion: batch/v1
kind: CronJob
metadata:
name: python-etl-mock
namespace: cronlab
spec:
schedule: '*/5 * * * *'
concurrencyPolicy: Replace
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 2
jobTemplate:
spec:
backoffLimit: 2
template:
metadata:
labels:
app: python-etl-mock
spec:
serviceAccountName: cronjob-sa
restartPolicy: OnFailure
containers:
- name: worker
image: python:3.11-slim
command:
- python
- -c
- |
import os, time, random, sys
print("starting job" )
time.sleep(2)
n = random.randint(0,9)
print(f"processed {n} records from {os.getenv('SOURCE_URL')}" )
sys.exit(0)
env:
- name: SOURCE_URL
value: 'file:///data/input.csv'
resources:
requests:
cpu: 20m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: scratch
mountPath: /data
volumes:
- name: scratch
emptyDir: {}
Apply and check:
kubectl apply -f cron-python.yaml
kubectl get cronjob -n cronlab
kubectl get jobs -n cronlab -l app=python-etl-mock
kubectl logs -n cronlab -l job-name -c worker --tail=50
Expected log snippet:
starting job
processed 7 records from file:///data/input.csv
Observability and Troubleshooting
Common checks:
- List CronJobs:
kubectl get cronjob -n cronlab
- Describe a CronJob:
kubectl describe cronjob echo-minutely -n cronlab
- Watch Jobs:
kubectl get jobs -n cronlab --watch
- Show Pods for a Job:
kubectl get pods -n cronlab -l job-name=<job-name>
- Pod logs:
kubectl logs -n cronlab <pod-name>
- Events on failures:
kubectl describe job/<job-name> -n cronlab
Run a CronJob immediately without waiting:
kubectl create job echo-now-1 --from=cronjob/echo-minutely -n cronlab
Repeat with a new name each time (echo-now-2, echo-now-3, ...).
Simulate failure for testing backoff by changing the command to exit 1, then observe retries and events. A failed Job description shows BackoffLimitExceeded after the configured retries.
Safety and Resource Controls
Use these guardrails in your lab and in real workloads:
concurrencyPolicy: ForbidorReplaceto prevent overlaps.startingDeadlineSeconds: 60to avoid missing runs after short outages.backoffLimit: 1-2to cap retries for failing Jobs.successfulJobsHistoryLimitandfailedJobsHistoryLimitto control list sizes.- Resource requests/limits: prevent noisy neighbors locally.
- Security context:
runAsNonRoot,seccompProfile=RuntimeDefault. - Namespace scoping: keep lab artifacts contained.
Cleanup quickly:
kubectl delete ns cronlab
kind delete cluster --name cronlab
Automate and Iterate Locally
Package your lab steps into a simple script so new teammates can spin up, test, and tear down with a few commands. Example lab.sh:
#!/usr/bin/env bash
set -euo pipefail
kind create cluster --name cronlab
kubectl apply -f ns-sa.yaml
kubectl apply -f cron-echo.yaml
kubectl apply -f cron-python.yaml
# smoke test: trigger one run from each and tail logs
kubectl create job echo-smoke --from=cronjob/echo-minutely -n cronlab
kubectl create job python-smoke --from=cronjob/python-etl-mock -n cronlab
kubectl wait --for=condition=complete job/echo-smoke -n cronlab --timeout=60s
kubectl wait --for=condition=complete job/python-smoke -n cronlab --timeout=60s
kubectl logs -n cronlab -l job-name=echo-smoke
kubectl logs -n cronlab -l job-name=python-smoke -c worker
Keep manifests in version control, add a smoke test that creates a Job from each CronJob, tails logs, and asserts a zero exit code. This separation of setup, execution, and review helps you add new examples without rework.
Conclusion
You now have a repeatable local CronJobs lab: an isolated kind cluster, a scoped namespace, a fast pilot CronJob, and a more realistic Python example. You can observe schedules, limit history, prevent overlaps, run on demand, and clean up safely. Next steps: add alerts for failed Jobs, parameterize environments via ConfigMaps and Secrets, and codify your checks so every change is quick to validate locally.