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.

graph TD subgraph deploy["Deployment: interchangeable"] d1["web-7d9f8c9-x2kpl"] d2["web-7d9f8c9-m4qrt"] note1["Random names. Delete one,<br/>get a different-named replacement.<br/>No per-Pod storage identity."] end subgraph ss["StatefulSet: stable identity"] s0["db-0<br/>db-0.db.default.svc.cluster.local"] --- pvc0["PVC: db-0 (its own volume)"] s1["db-1<br/>db-1.db.default.svc.cluster.local"] --- pvc1["PVC: db-1 (its own volume)"] s2["db-2<br/>db-2.db.default.svc.cluster.local"] --- pvc2["PVC: db-2 (its own volume)"] note2["Ordinal names, always reused.<br/>db-1 deleted comes back as db-1,<br/>reattached to the SAME PVC."] end
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.

graph TD subgraph n1["Node 1"] a1["Agent Pod"] end subgraph n2["Node 2"] a2["Agent Pod"] end subgraph n3["Node 3 (added later)"] a3["Agent Pod<br/>(scheduled automatically the moment this node joins)"] end
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.