Kubernetes Workload Resources: When to Use Deployments, StatefulSets, DaemonSets, and Jobs

2 min readCloud Infrastructure

Kubernetes has six workload resource types. Each represents a different scheduling and lifecycle contract. Choosing the wrong one — a Deployment for a database, a StatefulSet for a stateless API — creates subtle operational problems that don't surface until scale or failure.

awsekskubernetes

The six workload types and what they promise

Every Kubernetes workload wraps a pod template. The workload type controls how many instances run, how they're replaced, and what guarantees are made about identity and storage.

| Resource | Pods | Identity | Storage | Use case | |---|---|---|---|---| | Deployment | N interchangeable | Random names | Shared or none | Stateless services | | StatefulSet | N ordered | Stable names | Per-pod PVCs | Databases, brokers | | DaemonSet | 1 per node | Per-node | Host path or none | Log agents, monitoring | | Job | 1+ to completion | Temporary | Temporary | Batch processing | | CronJob | Job on schedule | Temporary | Temporary | Scheduled tasks | | ReplicaSet | N (rarely direct) | Random | Shared or none | Managed by Deployment |

Deployment: stateless services with rolling updates

ConceptKubernetes

A Deployment manages a ReplicaSet that keeps N identical pod replicas running. Pods are fungible — no stable identity, no persistent storage tied to individual pods. Rolling updates replace pods one by one. Scale up or down by changing replicas count.

Prerequisites

  • Kubernetes pods
  • ReplicaSets
  • rolling updates

Key Points

  • Pod names are randomly generated: my-api-7d9f4b6c5-x2q8p. No stable identity between restarts.
  • Rolling update: maxUnavailable controls minimum available during update, maxSurge controls temporary overcapacity.
  • All pods share the same configuration. Horizontal scaling adds more identical pods.
  • Rollback to previous revision: kubectl rollout undo deployment/my-api.

Deployment: the right choice for stateless services

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1     # at least 2 pods available during update
      maxSurge: 1           # at most 4 pods total during update
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
      - name: api
        image: myapp:1.2.3
        resources:
          requests:
            cpu: "500m"
            memory: "512Mi"
          limits:
            cpu: "1000m"
            memory: "1Gi"
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10

Deployments are the right choice when: all pods are equivalent, storage is external (RDS, Redis, S3), and the service can be load-balanced across any instance.

StatefulSet: stable identity for stateful applications

StatefulSets make three guarantees Deployments don't:

  1. Stable, predictable pod names: postgres-0, postgres-1, postgres-2
  2. Ordered startup (0 before 1 before 2) and ordered shutdown (reverse)
  3. Per-pod persistent volume claims that survive pod replacement

The stable DNS names are the critical feature for distributed systems. A PostgreSQL replica can be configured to replicate from postgres-0.postgres-headless.default.svc.cluster.local — this DNS name resolves to the same pod regardless of restarts and rescheduling.

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres-headless  # must exist as a headless Service
  replicas: 3
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
      - name: postgres
        image: postgres:15
        volumeMounts:
        - name: data
          mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:           # each pod gets its own PVC
  - metadata:
      name: data
    spec:
      accessModes: ["ReadWriteOnce"]
      storageClassName: gp3
      resources:
        requests:
          storage: 100Gi

DaemonSet: one pod per node

DaemonSets ensure exactly one pod runs on every node (or every node matching a node selector). When new nodes join the cluster, the DaemonSet controller automatically schedules a pod on them.

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: log-collector
spec:
  selector:
    matchLabels:
      app: log-collector
  template:
    metadata:
      labels:
        app: log-collector
    spec:
      tolerations:
      - key: node-role.kubernetes.io/control-plane
        effect: NoSchedule    # run on control plane nodes too if needed
      containers:
      - name: fluentd
        image: fluentd:v1.16
        volumeMounts:
        - name: varlog
          mountPath: /var/log
        - name: dockercontainers
          mountPath: /var/lib/docker/containers
          readOnly: true
      volumes:
      - name: varlog
        hostPath:
          path: /var/log
      - name: dockercontainers
        hostPath:
          path: /var/lib/docker/containers

DaemonSets use hostPath volumes to access node-local paths. This is why they need DaemonSet — a Deployment doesn't guarantee one pod per node, and wouldn't have access to per-node paths in a predictable way.

📝Jobs and CronJobs: run-to-completion semantics

Jobs run a pod until it completes successfully. Unlike Deployments, a finished Job doesn't restart. This makes them appropriate for database migrations, batch data processing, and one-time initialization tasks.

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migration
spec:
  completions: 1            # run 1 successful completion
  parallelism: 1            # run 1 pod at a time
  backoffLimit: 3           # retry up to 3 times on failure
  template:
    spec:
      restartPolicy: OnFailure   # required for Jobs (Never or OnFailure only)
      containers:
      - name: migrate
        image: myapp:1.2.3
        command: ["python", "manage.py", "migrate"]
        envFrom:
        - secretRef:
            name: db-credentials

CronJobs create Jobs on a schedule:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: cleanup
spec:
  schedule: "0 2 * * *"          # 2 AM daily
  concurrencyPolicy: Forbid       # don't start new Job if previous is still running
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - name: cleanup
            image: myapp:1.2.3
            command: ["python", "cleanup.py"]

Common CronJob problems:

  • concurrencyPolicy: Allow (default) lets multiple Jobs run simultaneously if the previous one hasn't finished. For database cleanup jobs, this causes conflicts.
  • successfulJobsHistoryLimit: 3 — keep this low. Without it, finished pod objects accumulate.
  • Missing resource requests means CronJob pods may not schedule when the cluster is under resource pressure.

HorizontalPodAutoscaler: scaling based on metrics

HPAs automatically adjust Deployment (or StatefulSet) replica counts based on observed metrics:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api
  minReplicas: 2
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70    # scale up when average CPU > 70%
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30    # react quickly to traffic spikes
    scaleDown:
      stabilizationWindowSeconds: 300   # wait 5 min before scaling down

HPAs require resource requests to be set on containers — without them, CPU utilization percentage is undefined and HPA won't work.

A team runs a Redis cluster in EKS using a Deployment with 3 replicas. During a node failure, two Redis pods restart on different nodes. After the incident, Redis reports cluster state errors and data inconsistency. Why is Deployment the wrong choice here, and what should be used instead?

medium

The Redis cluster uses cluster mode. Each Redis instance is configured to communicate with the others using their pod names. The Deployment uses a shared PVC (ReadWriteMany EFS volume) for persistence.

  • ADeployments don't support Redis — use a Helm chart instead
    Incorrect.Helm charts still use underlying Kubernetes resources. The issue is the resource type chosen, not the deployment method.
  • BDeployment pods get new random names on restart. Redis cluster peers are configured by pod name — the new names break cluster membership. StatefulSet provides stable pod names that survive restart
    Correct!Redis cluster mode members discover each other by their addresses. A Deployment's pod redis-7f9d-abc becomes redis-4b2e-xyz after restart — the old address is gone. Other cluster members still reference the old name, causing cluster split-brain. StatefulSet gives redis-0, redis-1, redis-2 — stable DNS names that survive restarts. Each pod also needs its own dedicated PVC via volumeClaimTemplates, not a shared EFS volume (Redis doesn't support multiple writers to the same data directory).
  • CDeployments can't use persistent volumes — that's why data was lost
    Incorrect.Deployments can use persistent volumes. The issue is pod identity stability and per-pod storage, not whether PVs can be used.
  • DRedis should run as a DaemonSet for cluster mode
    Incorrect.DaemonSet places one pod per node — the cluster would scale with node count rather than independent replica count, and couldn't be scaled independently.

Hint:Think about what changes when a Deployment pod restarts vs a StatefulSet pod restarts.