60.4 StatefulSet: stable names, headless Service, volumeClaimTemplates and a MySQL lab
Why: the problem
Suppose Paytm runs MySQL for its wallet service inside Kubernetes with a Deployment. A pod dies and the new one gets a random name like mysql-7f9c-x2k. Its disk is gone because it used the pod's own storage, or two pods try to write to the same disk at once. Replicas cannot find "the primary" because names change on every restart. Databases, Kafka, ZooKeeper, Elasticsearch and Redis clusters need three things a Deployment does not give: a stable name, a stable disk per pod, and a fixed start and stop order. A StatefulSet gives exactly that. Pods are called mysql-0, mysql-1, mysql-2, always. Each one gets its own PersistentVolumeClaim, data-mysql-0 and so on, that stays even if the pod is deleted. A headless Service gives each pod its own DNS name, like mysql-0.mysql.shop.svc.cluster.local. In this lesson we run a StatefulSet on kind, write data, kill the pod, and see the data survive. We scale it, update it with partitions, and break it with a missing headless Service and a stuck PVC. Chapter 61 covers storage in full; here we focus on the controller.
What: every term in plain English
- StatefulSet — An apps/v1 controller for pods that need stable identity and stable storage.
- Ordinal — The number in the pod name: mysql-0, mysql-1. It never changes for that slot.
- Headless Service — A Service with clusterIP: None. DNS returns pod IPs and gives each pod its own name.
- serviceName — The headless Service the StatefulSet uses for pod DNS names.
- volumeClaimTemplates — A PVC blueprint. The StatefulSet makes one PVC per pod, named TEMPLATE-STS-ORDINAL.
- podManagementPolicy — OrderedReady (default: one by one, in order) or Parallel.
- updateStrategy — RollingUpdate (default, highest ordinal first) or OnDelete.
- partition — Only pods with ordinal >= partition get the new version. Used for canary updates.
- persistentVolumeClaimRetentionPolicy — Whether PVCs are deleted when the StatefulSet is deleted or scaled down. Default: Retain.
- StorageClass standard — kind's built-in local-path provisioner that creates volumes automatically.
How: step by step
- Create the headless Service first. clusterIP: None and the same labels as the pods.
- Write the StatefulSet. serviceName, replicas, selector, template.
- Add volumeClaimTemplates. Size, access mode and storageClassName.
- Put the password in a Secret. Never hard-code it in the YAML.
- Apply and watch the order. mysql-0 must be Ready before mysql-1 starts.
- Test identity. Delete a pod; the same name and the same PVC come back.
- Update safely. Use partition to update the last pod first.
Example 1: Run MySQL as a StatefulSet with its own disk
Goal. Run MySQL as a StatefulSet with its own disk.
mysql-secret.yaml
apiVersion: v1
kind: Secret
metadata:
name: mysql-auth
namespace: shop
type: Opaque
stringData:
MYSQL_ROOT_PASSWORD: change-me-lab-only
mysql-headless.yaml
apiVersion: v1
kind: Service
metadata:
name: mysql
namespace: shop
spec:
clusterIP: None
selector:
app: mysql
ports:
- name: mysql
port: 3306
mysql-sts.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
namespace: shop
spec:
serviceName: mysql
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
terminationGracePeriodSeconds: 30
containers:
- name: mysql
image: mysql:8.4
envFrom:
- secretRef:
name: mysql-auth
ports:
- name: mysql
containerPort: 3306
readinessProbe:
exec:
command: ["sh", "-c", "mysqladmin ping -h 127.0.0.1 -uroot -p$MYSQL_ROOT_PASSWORD"]
initialDelaySeconds: 15
periodSeconds: 5
resources:
requests: {cpu: 100m, memory: 512Mi}
limits: {memory: 1Gi}
volumeMounts:
- name: data
mountPath: /var/lib/mysql
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard
resources:
requests:
storage: 1Gi
What each line does
stringData— Plain text in the file; Kubernetes stores it base64. Lab password only; use a real secret store in production.clusterIP: None— Makes the Service headless, so each pod gets a DNS name.serviceName: mysql— Must equal the headless Service name.envFrom secretRef— Loads MYSQL_ROOT_PASSWORD from the Secret.readinessProbe mysqladmin ping— The pod is Ready only when MySQL answers.volumeClaimTemplates name: data— Creates PVC data-mysql-0, mounted at /var/lib/mysql.storageClassName: standard— kind's default class makes the volume for you.
Green output
$ kubectl apply -f mysql-secret.yaml -f mysql-headless.yaml -f mysql-sts.yaml
secret/mysql-auth created
service/mysql created
statefulset.apps/mysql created
$ kubectl get pods,pvc -n shop -l app=mysql
NAME READY STATUS RESTARTS AGE
pod/mysql-0 1/1 Running 0 45s
$ kubectl get pvc -n shop
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
data-mysql-0 Bound pvc-5d0e2c1a-8f3b-4a7e-9c6d-1b2a3f4e5d6c 1Gi RWO standard
$ kubectl exec -n shop mysql-0 -- sh -c 'mysql -uroot -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE wallet; CREATE TABLE wallet.tx(id INT, amt INT); INSERT INTO wallet.tx VALUES (1,500);"'
$ kubectl delete pod mysql-0 -n shop
pod "mysql-0" deleted
$ kubectl exec -n shop mysql-0 -- sh -c 'mysql -uroot -p$MYSQL_ROOT_PASSWORD -N -e "SELECT * FROM wallet.tx"'
1 500
Expected result. Same name mysql-0, same PVC data-mysql-0, same data. A Deployment pod with emptyDir would have lost the row.
Example 2: Ordered start, stable DNS names and scaling
Goal. Ordered start, stable DNS names and scaling.
web-sts.yaml
apiVersion: v1
kind: Service
metadata:
name: web
namespace: shop
spec:
clusterIP: None
selector:
app: web-sts
ports:
- port: 80
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
namespace: shop
spec:
serviceName: web
replicas: 3
podManagementPolicy: OrderedReady
selector:
matchLabels:
app: web-sts
template:
metadata:
labels:
app: web-sts
spec:
containers:
- name: nginx
image: nginx:1.26
command: ["sh", "-c", "echo $(hostname) > /usr/share/nginx/html/index.html; exec nginx -g 'daemon off;'"]
readinessProbe:
httpGet: {path: /, port: 80}
volumeMounts:
- name: www
mountPath: /usr/share/nginx/html
volumeClaimTemplates:
- metadata:
name: www
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 100Mi
What each line does
Two objects in one file— The headless Service and the StatefulSet, separated by ---.OrderedReady— web-0 must be Ready before web-1 is created.echo $(hostname)— Each pod writes its own name, so we can see which pod answers.exec nginx— nginx becomes PID 1 and gets SIGTERM directly.No storageClassName— Uses the cluster default class, standard on kind.
Green output
$ kubectl apply -f web-sts.yaml
$ kubectl get pods -n shop -l app=web-sts -w
web-0 0/1 ContainerCreating 0 0s
web-0 1/1 Running 0 4s
web-1 0/1 Pending 0 0s
web-1 1/1 Running 0 4s
web-2 0/1 Pending 0 0s
web-2 1/1 Running 0 4s
$ kubectl run dns -n shop --rm -it --image=busybox:1.36 --restart=Never -- sh -c 'nslookup web-1.web.shop.svc.cluster.local; wget -qO- web-1.web'
Name: web-1.web.shop.svc.cluster.local
Address: 10.244.2.17
web-1
$ kubectl scale sts web -n shop --replicas=1
$ kubectl get pvc -n shop -l app=web-sts
NAME STATUS VOLUME CAPACITY
www-web-0 Bound pvc-... 100Mi
www-web-1 Bound pvc-... 100Mi
www-web-2 Bound pvc-... 100Mi
Expected result. Pods start in order and each has a DNS name. Scaling down removes web-2 then web-1, but their PVCs stay, so scaling up again finds the old data.
Example 3: Canary update with partition, then full update
Goal. Canary update with partition, then full update.
web-partition-patch.yaml
spec:
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 2
template:
spec:
containers:
- name: nginx
image: nginx:1.27
What each line does
partition: 2— Only pods with ordinal 2 or more get nginx:1.27.web-0 and web-1— Stay on 1.26 even if deleted, until partition is lowered.Order— RollingUpdate goes from the highest ordinal down: web-2, then web-1, then web-0.Full rollout— Set partition to 0.
Green output
$ kubectl scale sts web -n shop --replicas=3
$ kubectl patch sts web -n shop --type=strategic --patch-file web-partition-patch.yaml
statefulset.apps/web patched
$ kubectl get pods -n shop -l app=web-sts -o custom-columns=NAME:.metadata.name,IMAGE:.spec.containers[0].image
NAME IMAGE
web-0 nginx:1.26
web-1 nginx:1.26
web-2 nginx:1.27
$ kubectl patch sts web -n shop -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":0}}}}'
$ kubectl rollout status sts/web -n shop
partitioned roll out complete: 3 new pods have been updated...
Expected result. web-2 was the canary. After checking it, partition 0 updated web-1 then web-0. Databases often use this to update replicas before the primary.
Every command, step by step (kind lab with kubectl)
-
Check the default StorageClass
kubectl get storageclassWhat it does: kind ships standard with the local-path provisioner.
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE standard (default) rancher.io/local-path Delete WaitForFirstConsumer -
Apply MySQL
kubectl apply -f mysql-secret.yaml -f mysql-headless.yaml -f mysql-sts.yaml kubectl rollout status sts/mysql -n shopWhat it does: Waits until mysql-0 is Ready.
partitioned roll out complete: 1 new pods have been updated... -
See the PVC name rule
kubectl get pvc -n shopWhat it does: Template name, StatefulSet name, ordinal.
data-mysql-0 Bound 1Gi RWO standard -
Find the node and volume
kubectl get pod mysql-0 -n shop -o wide kubectl get pvWhat it does: local-path volumes live on one node; the pod always returns there.
mysql-0 1/1 Running 0 2m 10.244.1.20 ch59-worker -
Connect by DNS
kubectl run cli -n shop --rm -it --image=mysql:8.4 --restart=Never --env=P=change-me-lab-only -- sh -c 'mysql -h mysql-0.mysql -uroot -p$P -e "SELECT 1"'What it does: Clients reach a specific pod by name.
1 1 -
Kill and check identity
kubectl delete pod mysql-0 -n shop kubectl get pod mysql-0 -n shop -o jsonpath='{.spec.volumes[0].persistentVolumeClaim.claimName}{"\n"}'What it does: Same PVC is mounted again.
data-mysql-0 -
Read the StatefulSet status
kubectl get sts -n shop kubectl describe sts web -n shop | grep -i -A2 'update strategy'What it does: READY shows ready/desired.
NAME READY AGE mysql 1/1 5m web 3/3 3m -
Delete and keep data
kubectl delete sts web -n shop kubectl get pvc -n shop -l app=web-stsWhat it does: Pods go; PVCs stay by default.
www-web-0 Bound 100Mi -
Clean up fully
kubectl delete sts mysql -n shop kubectl delete svc mysql web -n shop kubectl delete secret mysql-auth -n shop kubectl delete pvc -n shop data-mysql-0 www-web-0 www-web-1 www-web-2What it does: PVCs must be deleted by hand. Their volumes go too, because the reclaim policy is Delete.
persistentvolumeclaim "data-mysql-0" deleted
When it is red 1
nslookup: can't resolve 'mysql-0.mysql'
Cause. The headless Service is missing, has a different name from serviceName, or is not headless.
Fix, step by step
- kubectl get svc mysql -n shop -o jsonpath='{.spec.clusterIP}' must print None.
- Make serviceName equal the Service name.
- Re-create the Service with clusterIP: None (clusterIP cannot be changed in place).
When it is red 2
mysql-0 0/1 Pending
Warning FailedScheduling 0/3 nodes are available: pod has unbound immediate PersistentVolumeClaims
Cause. No StorageClass can create the volume, or the class name is wrong.
Fix, step by step
- kubectl get sc and check the name.
- kubectl describe pvc data-mysql-0 -n shop for the provisioner error.
- Fix storageClassName; delete the stuck PVC and pod so they are made again.
When it is red 3
web-1 never starts; web-0 is 0/1 Running
Cause. OrderedReady waits for web-0 to be Ready. Its readiness probe fails.
Fix, step by step
- kubectl describe pod web-0 for probe errors.
- Fix the probe, or use podManagementPolicy: Parallel if order does not matter.
When it is red 4
The StatefulSet "mysql" is invalid: spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden
Cause. You changed volumeClaimTemplates or serviceName on a live StatefulSet.
Fix, step by step
- Delete the StatefulSet with --cascade=orphan, keeping pods and PVCs.
- Apply the new spec; it adopts the pods.
- To grow disks, edit each PVC (61.7).
Practice tasks
Easy Task 1: Apply
Why: First StatefulSet.
What to do: Apply the three MySQL files and wait Ready.
- rollout status sts.
Solution
kubectl rollout status sts/mysql -n shopEasy Task 2: Write data
Why: Persistence.
What to do: Insert a row into wallet.tx.
- kubectl exec with mysql -e.
Solution
kubectl exec -n shop mysql-0 -- sh -c 'mysql -uroot -p$MYSQL_ROOT_PASSWORD -e "SELECT * FROM wallet.tx"'Easy Task 3: Kill
Why: Identity.
What to do: Delete mysql-0 and read the row again.
- Same name comes back.
Solution
kubectl delete pod mysql-0 -n shopMedium Task 4: DNS
Why: Headless.
What to do: Resolve web-2.web from a busybox pod.
- nslookup.
Solution
kubectl run dns -n shop --rm -it --image=busybox:1.36 --restart=Never -- nslookup web-2.webMedium Task 5: Order
Why: OrderedReady.
What to do: Watch web pods start one by one.
- -w.
Solution
kubectl get pods -n shop -l app=web-sts -wMedium Task 6: Parallel
Why: Policy.
What to do: Change to Parallel on a new StatefulSet and compare start times.
- podManagementPolicy is set at create.
Solution
podManagementPolicy: ParallelMedium Task 7: Scale down
Why: PVC retain.
What to do: Scale web to 1 and list PVCs.
- PVCs stay.
Solution
kubectl get pvc -n shopMedium Task 8: Partition
Why: Canary.
What to do: Apply the partition patch and list images.
- custom-columns.
Solution
kubectl patch sts web -n shop --type=strategic --patch-file web-partition-patch.yamlHard Task 9: Retention policy
Why: Auto clean.
What to do: Set persistentVolumeClaimRetentionPolicy whenScaled: Delete and scale down.
- Needs Kubernetes 1.27+.
Solution
kubectl patch sts web -n shop -p '{"spec":{"persistentVolumeClaimRetentionPolicy":{"whenScaled":"Delete"}}}'Hard Task 10: OnDelete
Why: Manual updates.
What to do: Switch updateStrategy to OnDelete, change the image and update one pod by deleting it.
- Pods change only when deleted.
Solution
kubectl patch sts web -n shop -p '{"spec":{"updateStrategy":{"type":"OnDelete","rollingUpdate":null}}}'Homework: try at home
Run a 3-replica Redis StatefulSet, write a different key to each pod, delete all pods at once, and prove each key came back on the same ordinal.
Recap
- StatefulSets give each pod a stable name, DNS name and disk.
- A headless Service is required and must match serviceName.
- volumeClaimTemplates make one PVC per pod; PVCs survive pod and StatefulSet deletion.
- Pods start in order and update from the highest ordinal; partition gives canaries.
- Most fields are immutable; orphan-delete and re-apply to change them.
- On AWS, managed RDS is often simpler than MySQL in a StatefulSet; choose deliberately.
FAQ
Q. Does a StatefulSet make MySQL replication work?
No. It gives identity and disks. Replication is configured by the database or an operator.
Q. Should I run production databases in Kubernetes?
It works with good operators and storage, but many teams on AWS prefer RDS for backups and failover.
Q. Why does mysql-0 always go to the same node on kind?
local-path volumes are tied to one node. EBS volumes are tied to one availability zone.
Q. Can I use a normal ClusterIP Service too?
Yes. Many setups have a headless one for pod names and a normal one for read traffic.
Q. What is an operator?
A controller for one app, like the MySQL or Strimzi Kafka operator, that adds backups and failover on top of StatefulSets.
Q. How do I grow the disk?
Edit the PVC size if the StorageClass allows expansion. See 61.7.
Interview questions
-
Deployment vs StatefulSet?
Model answer: Deployments have random, interchangeable pods and shared or no storage; StatefulSets have stable ordinal names, per-pod PVCs, stable DNS through a headless Service, and ordered start, stop and update.
-
Why is a headless Service needed?
Model answer: It creates DNS records for each pod (pod.service.ns.svc.cluster.local) so peers and clients can reach a specific member.
-
What happens to PVCs when a StatefulSet scales down?
Model answer: By default they are retained, so data returns on scale-up; persistentVolumeClaimRetentionPolicy can change this.
-
How do you do a canary update of a StatefulSet?
Model answer: Set rollingUpdate.partition so only higher ordinals update, verify, then lower the partition to 0.
-
A StatefulSet pod is stuck Pending. What do you check?
Model answer: PVC status and events, StorageClass name, provisioner, node capacity and zone affinity of the volume.
Quick quiz
Q1. What is the name of the first StatefulSet pod named db?
db-0.
Q2. What Service type gives pods DNS names?
Headless, clusterIP: None.
Q3. PVC name for template data on pod mysql-2?
data-mysql-2.
Q4. Which pod updates first in a RollingUpdate?
The highest ordinal.
Q5. Does deleting a StatefulSet delete PVCs by default?
No, they are retained.
Samajla ka?