Kubernetes Workloads: Choosing the Right Controller¶
Fundamentals covered Deployment — the right controller when Pods are interchangeable: any replica can serve any request, none of them has an identity that matters, and losing one and getting a fresh replacement is completely fine. Three other controllers exist specifically because that assumption doesn't always hold.
StatefulSet: when identity and storage must survive Pod replacement¶
A Deployment's replicas are anonymous — Pod names are random suffixes
(web-7d9f8c9-x2kpl), and any replica can be deleted and replaced by an
interchangeable twin with a different name and, if it used a volume at
all, potentially different data. That's wrong for a clustered database,
where replica 0 might be the primary and needs to be found again by
that specific identity after a restart, and each replica needs its own
persistent volume, not a randomly-assigned one.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: db
spec:
serviceName: db
replicas: 3
selector:
matchLabels:
app: db
template:
metadata:
labels:
app: db
spec:
containers:
- name: db
image: postgres:16
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
Two things a Deployment doesn't do, both visible in this spec:
volumeClaimTemplates creates a separate PersistentVolumeClaim per
replica (data-db-0, data-db-1, data-db-2) — not one shared
volume, three independent ones, each permanently associated with its
ordinal. And Pods get stable, predictable names (db-0, db-1, db-2)
and — combined with the serviceName field, which points at a "headless"
Service (clusterIP: None) — stable DNS names
(db-0.db.default.svc.cluster.local) that resolve to that specific
Pod, not a load-balanced VIP across all of them. If db-1 is deleted,
its replacement comes back named db-1 and reattaches to the same
PVC — the data survives the Pod's death because the identity-to-storage
mapping is preserved, unlike a Deployment where a fresh replica has no
particular claim to any specific prior volume.
DaemonSet: exactly one Pod per node, automatically¶
Some workloads aren't "run N copies somewhere in the cluster" — they're
"run exactly one copy on every node, including nodes added later."
Log collectors, node-level monitoring agents, and — notably —
kube-proxy itself is commonly deployed this way.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: log-agent
spec:
selector:
matchLabels:
app: log-agent
template:
metadata:
labels:
app: log-agent
spec:
containers:
- name: log-agent
image: log-agent:1.0
There's no replicas: field — a DaemonSet doesn't have a target count,
it has a target coverage: one Pod per node matching its (optional)
node selector, always. A Deployment with replicas: 3 on a 5-node
cluster leaves 2 nodes with nothing; a DaemonSet has no equivalent
concept of "enough" short of "every eligible node."
Job: run to completion, not forever¶
A Deployment's reconciliation loop actively fights a container that exits — it restarts it, forever, because "0 running replicas" never matches a desired count above 0. That's the wrong model for a database migration script, a batch report generator, or any task that's supposed to finish and stay finished.
apiVersion: batch/v1
kind: Job
metadata:
name: db-migration
spec:
backoffLimit: 3
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: my-app:1.0
command: ["python", "migrate.py"]
A Job creates Pod(s), waits for them to exit successfully (exit code
0), and then considers itself done — it does not recreate a Pod that
exited 0. If a Pod fails (non-zero exit), the Job retries, up to
backoffLimit attempts, then gives up and marks itself Failed rather
than retrying forever the way a Deployment's loop effectively would.
CronJob: a Job, on a schedule¶
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-backup
spec:
schedule: "0 2 * * *"
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: backup
image: backup-tool:1.0
A CronJob is exactly a template for creating a new Job object on the
cron schedule given — each scheduled run is its own independent Job,
with its own retry behavior via backoffLimit, not one long-lived
process being told to loop internally.
Choosing between them¶
| Need | Controller |
|---|---|
| Interchangeable replicas, no per-replica identity or storage | Deployment |
| Each replica needs stable identity and/or its own persistent volume | StatefulSet |
| Exactly one Pod on every node | DaemonSet |
| Run once to completion, don't restart on success | Job |
| Run to completion, on a recurring schedule | CronJob |
Common pitfall¶
Running a database or any workload that needs a stable identity
matched to specific storage as a Deployment instead of a StatefulSet
works fine right up until a Pod is rescheduled to a different node or
replaced — at which point the replacement Pod has no particular claim to
the previous Pod's volume (Deployments don't template per-replica PVCs
the way volumeClaimTemplates does), and depending on the volume type,
either fails to mount anything or, worse, silently starts empty. If a
workload's correctness depends on "this specific replica always comes
back with this specific data," that's the direct signal it needs a
StatefulSet, not a Deployment with a single shared volume bolted on.