I pushed a brand‑new Airflow worker Deployment on a 10‑node GKE cluster at 02:00 am. By 03:15 the scheduler was thrashing, a handful of tasks never finished, and the ops team was staring at “FailedMount” events on three pods. The root cause? Those pods kept losing their PVCs because the Deployment kept recreating them on different nodes. The fix was to switch the workers to a StatefulSet—suddenly each replica kept the same disk and hostname, and the workflow recovered in minutes.
- Use a StatefulSet when pods need stable network names or per‑pod storage.
- Deployments work for truly stateless workers that can be recreated anywhere.
- Hybrid designs keep core services StatefulSet‑based and scale out stateless workers with Deployments.
- Watch out for ordered updates, PVC resizing, and orphaned volumes.
- Leverage headless services, PDBs, and anti‑affinity to keep the cluster healthy.
Before you start: a Kubernetes 1.28+ cluster, kubectl 1.31, a StorageClass that supports volume expansion, familiar‑ity with Helm or Kustomize, and a workflow engine (Airflow, Temporal, Camunda, etc.) you want to run.
Use a StatefulSet when your workflow engine pods require stable, unique network identities, ordered deployment/scaling, or automatic binding of persistent storage per pod. Use a Deployment for stateless, fungible worker instances. For complex engines like Airflow or Temporal, a hybrid approach (StatefulSet for core services, Deployment for workers) is common.
Stateful Workflow Engines: The Core Challenge for Kubernetes
Why Stateless Deployments Can Fail Stateful Apps
A Deployment treats every replica as interchangeable. That’s great for a front‑end service that can answer any request, but a workflow engine is rarely that forgiving. Workers often keep in‑flight state—checkpoint files, local caches, or temporary DB sockets. When a pod dies, the controller spins up a brand‑new pod with a fresh hostname; any local files disappear. In my own production clusters, I’ve seen “no such file” errors cascade into job retries that double the queue size overnight.
The Implicit State Any Workflow Engine Creates
Even the “stateless”‑looking parts of a scheduler hide state. Temporal’s history service writes event logs to a local disk before flushing to Cassandra. Camunda’s job executor writes a lock file to the pod’s filesystem. Airflow’s scheduler stores a SQLite file when you run a single‑node deployment. All of those artefacts assume the pod will keep the same identity across restarts. If the controller can’t guarantee that, the engine can’t guarantee correctness.
Kubernetes Deployment vs StatefulSet: A Side‑by‑Side Architecture Comparison
| Feature | Deployment | StatefulSet |
|---|---|---|
| **Pod identity** | Random name (`my‑app‑ | Stable name (`my‑app‑0`, `my‑app‑1`), ordered |
| **Network hostname** | Assigned from pod IP, may change | Stable DNS entry via headless service |
| **PVC binding** | PVCs are shared or dynamically created per pod, no guarantee of reuse | `volumeClaimTemplates` automatically creates 1‑:‑1 PVCs bound to each replica |
| **Update strategy** | In‑place, parallel rolling update | Ordered rolling update, optional `OnDelete` policy |
| **Scaling** | Fast, any number of replicas | Sequential pod creation, can be slower |
| **Pod termination** | Arbitrary order, may kill leader first | Ordered termination (reverse order) |
The table above shows why StatefulSet shines when you need *stable* DNS and *bound* storage. Let’s dig deeper.
Pod Identity and Ordering Guarantees
Deployments give you a random pod name each time. In a StatefulSet you get predictable names (`my‑engine‑0`, `my‑engine‑1`, …) and a **headless Service** (`ClusterIP: None`) automatically creates DNS A records for each replica. Your code can now do `my‑engine-0.my‑engine` to talk to a specific instance—handy for leader election or sharding.
# k8s 1.30 – StatefulSet with ordered start
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: temporal-worker
spec:
serviceName: "temporal-worker"
replicas: 3
podManagementPolicy: OrderedReady # default, guarantees order
selector:
matchLabels:
app: temporal-worker
template:
metadata:
labels:
app: temporal-worker
spec:
containers:
- name: worker
image: temporalio/worker:1.18.0
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
ports:
- containerPort: 7233
Notice the `podManagementPolicy`. In my experience, the default `OrderedReady` saved us from a race condition where two workers tried to become the leader at the same time.
Persistent Storage and Volume Claims (PVCs)
A StatefulSet’s `volumeClaimTemplates` magically creates a PVC per replica, each bound to the same StorageClass. When a pod restarts, the same PVC is re‑attached, preserving local files. In a Deployment you’d have to manually create PVCs and attach them via `spec.volumeClaimTemplates`, or accept that the pod gets a brand‑new empty volume each time.
**Tip:** For a deep dive on dynamic provisioning, see our tutorial on [Effective Kubernetes StatefulSet Deployment Guide](/kubernetes-statefulset-deployment-guide/).
# k8s 1.28 – PVC template with expand support
volumeClaimTemplates:
- metadata:
name: local-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: fast-ssd
resources:
requests:
storage: 20Gi # can be resized later
volumeMode: Filesystem
When you need to resize a volume during a rolling update, make sure your StorageClass supports `allowVolumeExpansion`. If the resize fails, the pod will hang in `ContainerCreating`. The fix is to pause the rollout (`kubectl rollout pause`) and manually expand the PVC before resuming.
In‑Place Pod Updates vs Ordered Rolling Updates
Deployments replace pods in parallel (by default, `maxUnavailable=1` and `maxSurge=1`). If your workflow engine holds a lock on a DB row, you could lose the lock mid‑upgrade. StatefulSet updates respect the ordinal index: it updates `my‑engine‑0`, waits until it’s Ready, then proceeds to `my‑engine‑1`. This ordering is a double‑edged sword—slower, but safer for stateful workloads.
Choosing for Production: A 2026 Decision Framework
Scenario 1: When a StatefulSet Is Mandatory for Your Workflow Engine
- Your engine writes checkpoints to a local disk.
- You rely on stable hostnames for leader election (e.g., Temporal’s “namespace leader”).
- You need guaranteed 1:1 PVC binding per replica.
In those cases, go full‑stateful:
helm upgrade --install my‑temporal ./temporal-chart \
--set worker.replicaCount=5 \
--set worker.statefulSet.enabled=true
Scenario 2: When a Deployment Can Still Work
- Workers are truly stateless—everything lives in an external DB or object store.
- You can tolerate any worker being killed and restarted elsewhere.
- You want rapid scaling, like a burst of 200+ parallel tasks.
Use a Deployment with a **nodeSelector** or **affinity** to keep pods on nodes that have fast local SSDs, but don’t bother with a headless Service.
Scenario 3: Advanced Hybrid Architectures (e.g., Sidecar + Deployment)
A common pattern is to run a **sidecar** that holds the local cache, while the main container is a stateless worker. The sidecar lives in a **StatefulSet**, the worker in a **Deployment** that mounts the same PVC via `shareProcessNamespace`. This gives you the best of both worlds: fast local cache recovery plus elastic scaling of compute.
# sidecar stateful part
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: cache-sidecar
spec:
serviceName: "cache-sidecar"
replicas: 3
template:
spec:
containers:
- name: cache
image: redis:7.2-alpine
volumeMounts:
- name: cache-data
mountPath: /data
volumes:
- name: cache-data
persistentVolumeClaim:
claimName: cache-pvc
---
# worker deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: worker
spec:
replicas: 10
selector:
matchLabels:
app: worker
template:
metadata:
labels:
app: worker
spec:
containers:
- name: job
image: my‑engine‑worker:1.4
env:
- name: CACHE_HOST
value: cache-sidecar-0.cache-sidecar
volumeMounts:
- name: cache-data
mountPath: /cache
volumes:
- name: cache-data
persistentVolumeClaim:
claimName: cache-pvc
Crucial Implementation Details and Production Gotchas
Headless Services, PodDisruptionBudgets, and Liveness Probes
A headless Service (`ClusterIP: None`) is required for stable DNS resolution. Pair it with a **PodDisruptionBudget** so the controller never evicts the last replica during a voluntary node drain:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: temporal-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: temporal-worker
Don’t forget a **liveness probe** that checks the engine’s health endpoint; otherwise, a pod stuck on a large checkpoint will never be restarted.
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 30
periodSeconds: 15
failureThreshold: 3
Anti‑affinity Rules to Ensure Pod Distribution
If all your replicas land on the same node, a single hardware failure wipes out the whole workflow. Use **pod anti‑affinity** to spread them across zones:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: temporal-worker
topologyKey: "kubernetes.io/hostname"
For a deeper dive on scheduling, see our post on [Custom Operators for Managing Stateful Apps on Kubernetes](/custom-operators-stateful-apps-kubernetes/).
Warning: Setting `podManagementPolicy: Parallel` on a StatefulSet removes ordering guarantees. Only enable this if you’ve verified that your engine can tolerate out‑of‑order starts.
StatefulSet Scaling & Rolling Update Pitfalls in Kubernetes 1.28+
Kubernetes 1.28 introduced a **graceful termination** period for StatefulSets that respects `terminationGracePeriodSeconds`. If you set it too low, the engine may lose in‑flight jobs. I once set it to 5 seconds; the pods vanished while still processing tasks, leading to duplicate executions after the restart.
terminationGracePeriodSeconds: 30 # give the worker a chance to finish
Version 1.30 added `maxUnavailable` to StatefulSet’s rollout strategy, letting you trade speed for safety:
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 0
maxUnavailable: 1
Benchmarking and Real‑World Impact Data
Case Study: Performance and Recovery Metrics
A cloud data platform swapped a 12‑replica Deployment of their custom scheduler for a StatefulSet. After the change:
- “No route to host” errors dropped by **65 %**.
- Mean Time To Recover (MTTR) after a node failure fell from **7 minutes** to **4 minutes**.
- CPU usage stayed flat; the only extra cost was a **~7 %** increase in storage due to orphaned PVCs after scaling down.
**My take:** The storage cost is not a deal‑breaker if you automate PVC cleanup with a simple `kubectl delete pvc -l app=temporal-worker,release=old` job. Forgetting that step is the biggest operational headache.
Cost & Complexity Analysis of Each Approach
| Metric | Deployment | StatefulSet |
|---|---|---|
| **Startup latency** | Low (parallel) | Medium (ordered) |
| **Storage overhead** | Low (shared or none) | Medium (PVC per pod) |
| **Ops burden** | Simple scaling, no PVC cleanup | Need PVC lifecycle scripts |
| **Failure isolation** | Poor (no host identity) | Good (stable hostnames) |
| **Network stability** | IP can change | DNS name stays constant |
The table shows that the “extra” storage cost is balanced by higher reliability. For large‑scale pipelines (≥ 10 pods), the ordered rollout cost becomes noticeable—plan your rollout windows accordingly.
2026 Guidance: Adapting to Kubernetes APIs and Ecosystem Shifts
Changes in Persistent Volume Behavior
Kubernetes 1.29 added **CSI inline volume expansion** support. That means you can now edit a PVC bound to a StatefulSet without recreating the pod, as long as your CSI driver (e.g., `ebs-csi-driver`) advertises `nodeStageSecretRef`. The workflow is:
- `kubectl patch pvc my‑engine‑0‑local-data -p ‘{“