How to Use StatefulSets in Kubernetes

How to Use StatefulSets in Kubernetes

Deployments are great until you need something Kubernetes’ default “cattle, not pets” philosophy actively fights against: stable network identities, ordered startup, and storage that follows a specific pod instance across restarts. That’s the exact gap StatefulSets fill, and if you’ve ever tried running a database or a Kafka cluster on a plain Deployment, you already know why they exist. Let’s go through StatefulSets thoroughly, from the mechanics to a full production example on AWS EKS.

Why Deployments Aren’t Enough for Stateful Workloads

A Deployment’s pods are interchangeable — they get random name suffixes, no guaranteed identity, and if backed by a shared PVC, multiple replicas would fight over the same volume (which is disallowed for RWO storage anyway). For applications like PostgreSQL replicas, Kafka brokers, Elasticsearch nodes, or ZooKeeper ensembles, each instance needs:

  • A stable, predictable network identity (e.g., kafka-0, kafka-1, kafka-2 — not random hashes)
  • Its own dedicated, persistent storage that follows it across rescheduling
  • Ordered, sequential startup and shutdown (so a database primary starts before replicas try to connect to it)

StatefulSet provides exactly these three guarantees.

How StatefulSets Work Internally

A StatefulSet requires a headless Service (clusterIP: None) to provide network identity. Instead of load-balancing across pods like a normal Service, a headless Service creates DNS records for each individual pod: <pod-name>.<service-name>.<namespace>.svc.cluster.local.

Pods are named deterministically: <statefulset-name>-0, <statefulset-name>-1, <statefulset-name>-2, and so on. By default, pods are created and terminated in strict ordinal order — pod 1 won’t be created until pod 0 is Running and Ready, and on scale-down, the highest ordinal is removed first.

Each pod gets its own PVC generated from a volumeClaimTemplate, named <volume-name>-<pod-name>. Critically, if a pod is deleted and recreated (e.g., after a node failure), it reattaches to the same PVC — the data isn’t lost or reshuffled between replicas.

A Complete PostgreSQL StatefulSet Example

Let’s build a realistic 3-node example — for illustration I’ll model it loosely on a primary/replica setup, though in practice you’d typically use a purpose-built operator like Zalando’s postgres-operator or CloudNativePG for real Postgres HA. Understanding the raw StatefulSet mechanics first makes those operators much less mysterious.

Headless Service for stable network identity:

apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: database
  labels:
    app: postgres
spec:
  clusterIP: None
  selector:
    app: postgres
  ports:
    - port: 5432
      name: postgres

The StatefulSet itself:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: database
spec:
  serviceName: postgres
  replicas: 3
  podManagementPolicy: OrderedReady
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      partition: 0
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: postgres
          image: postgres:16
          ports:
            - containerPort: 5432
              name: postgres
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: password
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
          resources:
            requests:
              cpu: 500m
              memory: 1Gi
            limits:
              cpu: 1
              memory: 2Gi
          readinessProbe:
            exec:
              command: ["pg_isready", "-U", "postgres"]
            initialDelaySeconds: 10
            periodSeconds: 10
          livenessProbe:
            exec:
              command: ["pg_isready", "-U", "postgres"]
            initialDelaySeconds: 30
            periodSeconds: 20
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: gp3
        resources:
          requests:
            storage: 50Gi
kubectl apply -f postgres-headless-service.yaml -f postgres-statefulset.yaml
kubectl get statefulset postgres -n database
NAME       READY   AGE
postgres   3/3     4m
kubectl get pods -n database -l app=postgres -o wide
NAME         READY   STATUS    RESTARTS   AGE   NODE
postgres-0   1/1     Running   0          4m    ip-10-0-1-15
postgres-1   1/1     Running   0          3m    ip-10-0-2-22
postgres-2   1/1     Running   0          2m    ip-10-0-3-31

Notice they came up sequentially — postgres-1 didn’t start until postgres-0 was ready, exactly the ordering guarantee we need if postgres-0 acts as primary and the others need it available first.

Check the PVCs — one per pod, following the naming pattern:

kubectl get pvc -n database
NAME               STATUS   VOLUME       CAPACITY   ACCESS MODES   STORAGECLASS
data-postgres-0    Bound    pvc-a1b2...  50Gi       RWO            gp3
data-postgres-1    Bound    pvc-c3d4...  50Gi       RWO            gp3
data-postgres-2    Bound    pvc-e5f6...  50Gi       RWO            gp3

DNS and Stable Network Identity

Each pod is individually addressable:

kubectl run -it --rm debug --image=busybox --restart=Never -n database -- \
  nslookup postgres-0.postgres.database.svc.cluster.local
Name:      postgres-0.postgres.database.svc.cluster.local
Address 1: 10.0.1.45 postgres-0.postgres.database.svc.cluster.local

This is what makes StatefulSets viable for distributed systems that need to reference specific peers by name — Kafka’s advertised.listeners, Elasticsearch’s discovery.seed_hosts, or a Postgres replica’s primary_conninfo can all point at a stable, predictable hostname rather than a rotating IP.

Pod Management Policies

  • OrderedReady (default) — pods are created/deleted one at a time, in order, waiting for Ready between each. Safer, slower.
  • Parallel — all pods are created/deleted simultaneously. Faster, but only appropriate when your application doesn’t depend on startup ordering (e.g., a Cassandra ring, which handles concurrent node joins natively).
spec:
  podManagementPolicy: Parallel

Rolling Updates with Partitions

StatefulSet rolling updates go in reverse ordinal order (highest number first) by default — the opposite of pod creation order. This matters for databases: you typically want to update replicas before the primary.

The partition field lets you stage canary-style updates across a StatefulSet:

spec:
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      partition: 2

With partition: 2 on a 3-replica StatefulSet, only pods with ordinal >= 2 (i.e., just postgres-2) get updated when you change the pod template. Lower-ordinal pods are left untouched. This lets you validate an update on one replica before lowering the partition to roll it out further:

kubectl patch statefulset postgres -n database --type='json' \
  -p='[{"op": "replace", "path": "/spec/updateStrategy/rollingUpdate/partition", "value": 0}]'

Scaling

kubectl scale statefulset postgres -n database --replicas=5

New pods (postgres-3, postgres-4) get new PVCs automatically provisioned. Scaling down removes the highest-ordinal pods first, but PVCs are not deleted automatically — this is intentional, protecting you from accidental data loss, but it means you need to clean up orphaned PVCs manually if you’re sure you no longer need that data:

kubectl get pvc -n database
kubectl delete pvc data-postgres-4 -n database

Production Pattern: Use an Operator for Real Databases

While hand-rolling a StatefulSet teaches you the mechanics, running real production databases on Kubernetes almost always benefits from a purpose-built Operator that understands the application’s specific failover, backup, and replication logic — a raw StatefulSet has no idea what “promote a replica to primary” means for Postgres.

# Example: CloudNativePG operator
kubectl apply --server-side -f \
  https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/main/releases/cnpg-1.23.1.yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgres-cluster
  namespace: database
spec:
  instances: 3
  storage:
    size: 50Gi
    storageClass: gp3
  bootstrap:
    initdb:
      database: appdb
      owner: appuser

Under the hood, CloudNativePG still uses a StatefulSet-like pattern (technically it manages individual Pods with PVCs directly for finer control), but exposes proper failover automation, backup scheduling to S3, and connection pooling on top — all things you’d otherwise have to build yourself.

Monitoring StatefulSets

kubectl get statefulset postgres -n database -o wide
kubectl describe statefulset postgres -n database
kubectl rollout status statefulset/postgres -n database

For Prometheus-based monitoring (see the companion Prometheus article), useful metrics from kube-state-metrics include:

kube_statefulset_status_replicas_ready{statefulset="postgres"}
kube_statefulset_status_current_revision != kube_statefulset_status_update_revision

That second query flags StatefulSets mid-rollout or stuck on a partial update.

Troubleshooting

Pod stuck waiting on a prior ordinal:

kubectl describe pod postgres-1 -n database

If postgres-0 never became Ready, postgres-1 will never be created under OrderedReady — check postgres-0‘s readiness probe and logs first.

PVC stuck Pending after scale-up:

Check the same AZ-mismatch and StorageClass issues covered in the Persistent Volume Claims article — WaitForFirstConsumer binding mode applies here too.

Stale data after pod rescheduling to a different node:

This shouldn’t happen with EBS-backed PVCs (the volume follows the pod), but if you’re using hostPath or local storage for a StatefulSet, verify node affinity is correctly pinning pods to nodes with local data — this is a common misconfiguration when moving from cloud block storage to local NVMe setups.

Common Mistakes

  • Deleting a StatefulSet’s PVCs thinking a plain kubectl delete -f on the StatefulSet cleans them up — it doesn’t, by design, but this surprises people in both directions (unexpected retention, and unexpected assumption of retention).
  • Assuming Parallel pod management is always faster and safer — for genuinely ordering-sensitive applications it can cause split-brain or bootstrap race conditions.
  • Forgetting the headless Service — without clusterIP: None, you don’t get the stable per-pod DNS entries that make StatefulSets useful in the first place.
  • Hand-rolling primary/replica failover logic instead of using a mature operator for the specific database engine in question.

Best Practices

  • Use a dedicated operator (CloudNativePG, Strimzi for Kafka, ECK for Elasticsearch) for production stateful workloads rather than raw StatefulSets whenever one exists for your technology.
  • Set resource requests/limits generously and realistically for stateful workloads — OOMKills on a database are far more disruptive than on a stateless API pod.
  • Use partition for staged, validated rollouts on anything storing critical data.
  • Monitor PVC usage proactively — full disks on a stateful workload are a much worse failure mode than a stateless pod running out of memory.

Summary

StatefulSets give Kubernetes workloads the three things Deployments deliberately don’t provide: stable network identity via headless Services, ordered startup/shutdown, and per-replica persistent storage that survives rescheduling. They’re the right primitive for databases, message queues, and any distributed system where individual instance identity matters. For real production database workloads, layer a purpose-built operator on top rather than reinventing failover and backup logic yourself — but understanding the raw StatefulSet mechanics makes those operators far less of a black box.

References

Total
1
Shares

Leave a Reply

Previous Post
How to Implement PodDisruptionBudgets in Kubernetes

How to Implement PodDisruptionBudgets in Kubernetes

Next Post
How to Deploy a Node.js Application on Kubernetes

How to Deploy a Node.js Application on Kubernetes

Related Posts