StatefulSets
CKA prep • Stable identity, ordered rollout, headless Service, volumeClaimTemplates
Key terms
| Term | Meaning |
|---|---|
| StatefulSet | Workload for pods that need stable identity + storage |
| Stable network ID | Each pod gets a fixed ordinal name and DNS record |
| Headless Service | clusterIP: None Service that gives per-pod DNS |
| volumeClaimTemplates | Per-pod PVCs created automatically and kept on rescheduling |
| Ordinal index | The -0, -1, -2 suffix that orders pods |
| OrderedReady | Default: create/scale/update pods one at a time, in order |
| Partition | rollingUpdate field to stage an update by ordinal |
Problem & solution
Deployments treat pods as interchangeable cattle — random names, shared or no persistent storage, parallel rollout. That breaks stateful systems (databases, queues, clustered stores) which need a stable name, their own durable disk, and a predictable start order (e.g. bring up the primary before replicas).
Solution: A StatefulSet gives each pod a stable ordinal identity, its own PVC via volumeClaimTemplates, stable DNS through a headless Service, and ordered, one-at-a-time deploy/scale/update.
The analogy
At the port, a line of numbered ships, Ship-0, Ship-1, Ship-2, each keeps a fixed berth and a name posted on the harbor board so anyone can find it directly, and each owns its own named warehouse unit on the quay. When a ship sails and returns, it gets the same number, the same berth listing, and the same warehouse reattached, nothing is shuffled. A StatefulSet is exactly this: pods get stable ordinal names like postgres-0, a headless Service gives each a fixed DNS name, and volumeClaimTemplates give each its own PVC that survives and reattaches by name across restarts.
Graph legend — each Kubernetes node maps a port concept to the real PostgreSQL StatefulSet:
| Graph node | Maps to | What it does |
|---|---|---|
| StatefulSet pod postgres-0 | Pod with ordinal name | The first replica, named deterministically by ordinal index |
| stable DNS via headless Service postgres | Service with clusterIP: None | Gives the pod a fixed DNS record postgres-0.postgres...svc |
| per-pod PVC pgdata-postgres-0 | volumeClaimTemplates entry | The pod's own durable disk that reattaches by name on restart |
StatefulSet vs Deployment
The quickest way to grasp StatefulSets is to compare them with Deployments side by side. The differences all stem from identity and storage:
+---------------------+----------------------+--------------------------+ | | Deployment | StatefulSet | +---------------------+----------------------+--------------------------+ | pod names | random hash | ordinal: postgres-0..N | | pod identity | interchangeable | stable + sticky | | DNS | via Service VIP | per-pod via headless svc | | storage | shared / ephemeral | one PVC per pod | | rollout / scale | parallel | ordered, one at a time | | PVC on pod delete | usually gone | retained + reattached | | use for | stateless web/api | databases, clustered apps| +---------------------+----------------------+--------------------------+
Stable identity and DNS
Pods are named <set>-<ordinal> (postgres-0, postgres-1, ...). With a
headless Service each pod gets a deterministic DNS name, so replicas can find
the primary by name regardless of IP changes.
pod DNS: postgres-0.postgres.default.svc.cluster.local
postgres-1.postgres.default.svc.cluster.local
<pod>.<headless-svc>.<namespace>.svc.cluster.local
The headless Service
A headless Service (clusterIP: None) returns the pod A records directly instead
of a single VIP — that is what powers per-pod DNS, so replicas can stream WAL
from postgres-0 by name.
apiVersion: v1
kind: Service
metadata:
name: postgres # serviceName referenced by the StatefulSet
spec:
clusterIP: None # headless -> per-pod DNS
selector: { app: postgres }
ports:
- port: 5432
name: postgresql
A StatefulSet with volumeClaimTemplates
Each replica gets its own PVC rendered from the template (pgdata-postgres-0,
pgdata-postgres-1, ...). Those PVCs survive pod deletion and reattach by name,
so each PostgreSQL instance keeps its data directory.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres # MUST match the headless Service name
replicas: 3
selector:
matchLabels: { app: postgres }
template:
metadata:
labels: { app: postgres }
spec:
containers:
- name: postgres
image: postgres:16
ports:
- { containerPort: 5432, name: postgresql }
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-secret
key: password
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: pgdata
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
Ordered deploy, scale, and update
StatefulSets change pods in a strict order rather than all at once. This is how create, scale, and update sequence the ordinals:
create / scale up: postgres-0 (Ready) -> postgres-1 -> postgres-2 (in order)
scale down: postgres-2 -> postgres-1 -> postgres-0 (reverse order)
rolling update: highest ordinal first, down to postgres-0, one at a time
kubectl get statefulset postgres
kubectl get pods -l app=postgres -o wide # postgres-0, postgres-1, postgres-2 in order
kubectl scale statefulset postgres --replicas=5 # adds postgres-3 then postgres-4
kubectl get pvc # pgdata-postgres-0 ... persist independently
# stage an update: only ordinals >= partition are updated
kubectl patch statefulset postgres -p \
'{"spec":{"updateStrategy":{"rollingUpdate":{"partition":2}}}}'
Operational details that bite
A few behaviors surprise people in practice, especially around PVCs and ordering. Keep these in mind:
- PVCs are NOT deleted when you delete the StatefulSet (delete them by hand)
- deleting a pod reschedules it with the SAME name and reattaches its PVC
- podManagementPolicy: Parallel skips ordering for faster, order-agnostic apps
- updateStrategy: OnDelete = you delete pods manually to pick up a new template
- a stuck postgres-0 (not Ready) blocks postgres-1 from ever starting (OrderedReady)
End-to-end: ordered bring-up of a 3-node set
Every node below names the actual resource or field so you can trace the bring-up straight into the PostgreSQL StatefulSet manifest.
Graph legend — every node is a real step in the ordered StatefulSet rollout:
| Graph node | Maps to | What it does |
|---|---|---|
| Apply StatefulSet postgres replicas=3 | kubectl apply of Service + StatefulSet | Submits the desired 3-replica PostgreSQL set |
| Create pod postgres-0 | StatefulSet controller | Creates the lowest ordinal first (OrderedReady) |
| Bind PVC pgdata-postgres-0 | volumeClaimTemplates rendered PVC | Provisions and attaches postgres-0's own disk |
| postgres-0 Ready? | the pod's readinessProbe | Gates the next ordinal until postgres-0 is Ready |
| Register DNS postgres-0.postgres...svc | headless Service A record | Publishes the per-pod DNS name for peers |
| Create postgres-1 / postgres-2 | StatefulSet controller | Continues in strict ordinal order, each with its own PVC |
| Replicas stream WAL from postgres-0 | per-pod DNS resolution | Standbys reach the primary by stable name |
End-to-end example: a 3-replica PostgreSQL StatefulSet with stable DNS
A full walkthrough using the real postgres image: deploy a headless Service plus a 3-replica StatefulSet with volumeClaimTemplates, watch ordered creation, resolve a peer by its stable DNS name, write to one pod's PVC, scale, and prove the PVC reattaches by name.
- Create the password Secret, then apply the headless Service and the StatefulSet together:
kubectl create secret generic postgres-secret --from-literal=password='S3cretPg!'
apiVersion: v1
kind: Service
metadata:
name: postgres
spec:
clusterIP: None
selector: { app: postgres }
ports:
- { port: 5432, name: postgresql }
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres
replicas: 3
selector:
matchLabels: { app: postgres }
template:
metadata:
labels: { app: postgres }
spec:
containers:
- name: postgres
image: postgres:16
ports: [{ containerPort: 5432, name: postgresql }]
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef: { name: postgres-secret, key: password }
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- { name: pgdata, mountPath: /var/lib/postgresql/data }
volumeClaimTemplates:
- metadata:
name: pgdata
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
- Watch the pods come up one at a time, in order:
kubectl get pods -l app=postgres -w
# expected order: postgres-0 Running -> postgres-1 Running -> postgres-2 Running
- Confirm each replica got its own PVC, rendered from the template:
kubectl get pvc
# expected: pgdata-postgres-0, pgdata-postgres-1, pgdata-postgres-2 (each Bound, 10Gi)
- Create a table in postgres-0, then resolve postgres-0 by its stable DNS name from a client pod:
kubectl exec postgres-0 -- psql -U postgres -c \
"CREATE TABLE ships(id int); INSERT INTO ships VALUES (0);"
kubectl run pgclient --rm -it --restart=Never --image=postgres:16 --env PGPASSWORD='S3cretPg!' -- \
psql -h postgres-0.postgres.default.svc.cluster.local -U postgres -c "SELECT * FROM ships;"
# expected: one row, id = 0 (served by postgres-0 via its stable DNS name)
- Scale out and confirm the new ordinals append in order:
kubectl scale statefulset postgres --replicas=5
kubectl get pods -l app=postgres
# expected: postgres-3 then postgres-4 created after postgres-0..postgres-2 stay Ready
kubectl get pvc
# expected: pgdata-postgres-3, pgdata-postgres-4 now also Bound
- Prove sticky identity: delete postgres-0, watch it return with the SAME name and its data intact:
kubectl delete pod postgres-0
kubectl wait --for=condition=Ready pod/postgres-0 --timeout=60s
kubectl exec postgres-0 -- psql -U postgres -c "SELECT * FROM ships;"
# expected: id = 0 still present (PVC pgdata-postgres-0 reattached by name)
- Scale down and observe reverse-order termination (PVCs remain):
kubectl scale statefulset postgres --replicas=3
kubectl get pods -l app=postgres
# expected: postgres-4 then postgres-3 terminate first (reverse order)
kubectl get pvc
# expected: pgdata-postgres-3, pgdata-postgres-4 still present (StatefulSet keeps PVCs)
Graph legend — each participant in the sequence is a real actor in the StatefulSet flow:
| Participant | Maps to | What it does |
|---|---|---|
| kubectl apply | client submitting manifests | Declares the desired 3-replica PostgreSQL set |
| StatefulSet controller | kube-controller-manager | Creates pods + PVCs one ordinal at a time |
| Scheduler/kubelet | kube-scheduler + kubelet | Places each pod and reports Ready |
| Headless Service postgres DNS | Service clusterIP: None | Registers per-pod A records as each pod becomes Ready |
| Replica pod | a standby postgres-N | Resolves the primary by stable DNS to stream WAL |
Common pitfalls
These are the failures you are most likely to meet with StatefulSets:
- pods stuck Pending -> no headless Service, or serviceName mismatch
- postgres-1 never starts -> postgres-0 not Ready blocks ordered rollout
- storage shared by mistake -> use volumeClaimTemplates, not one shared PVC
- PVCs linger after delete -> StatefulSet leaves PVCs; clean up manually
- no per-pod DNS -> Service must be clusterIP: None (headless)
- rollout too slow -> set podManagementPolicy: Parallel if order-agnostic
Key takeaways
- StatefulSets give pods stable ordinal names and sticky identity.
- A headless Service (
clusterIP: None) provides per-pod DNS. - volumeClaimTemplates create one durable PVC per pod that reattaches by name.
- Deploy/scale is ordered (up in order, down in reverse); a blocked pod stalls the rest.
- PVCs persist after pod or StatefulSet deletion — clean them up deliberately.
- Use
partitionto stage updates andParallelpolicy when ordering is unneeded.
Checklist
- [ ] Explained StatefulSet vs Deployment (identity, storage, ordering)
- [ ] Created a headless Service and matched
serviceName - [ ] Deployed a PostgreSQL StatefulSet with
volumeClaimTemplatesand saw per-pod PVCs - [ ] Scaled up/down and observed ordered create/reverse delete
- [ ] Resolved a pod by its
postgres-0.postgres...svcDNS name - [ ] Staged a rolling update with a
partition