We close the storage module with a promise still outstanding. The Rutas Norte platform already has verified backups and bookings that survive the deletion of a pod, but bookings-postgres is still a single-replica Deployment with strategy: Recreate. That configuration is not an oversight: it is the only one that works, because a Deployment applies the same pod template — and therefore the same PersistentVolumeClaim — to all of its replicas. If we go up to two, both try to mount the same ReadWriteOnce volume and the second one waits forever.
The underlying problem is that a Deployment treats its pods as interchangeable cattle: any of them will do, the name does not matter, the order does not matter, it does not matter which disk it gets. For a database that is exactly the opposite of what we need. A PostgreSQL replica needs to know who it is, it needs its own disk and it needs to start in a particular order relative to the others.
The StatefulSet is the controller that provides those three guarantees. In this lesson we will study it in depth, migrate bookings-postgres from Deployment to StatefulSet without losing the existing volume, and finish with an honest warning that will shape the rest of the module: a StatefulSet gives you identity and a disk, but it does not replicate data for you.
Contents
- What a StatefulSet guarantees that a Deployment cannot
- Stable identity: ordinal names and per-pod DNS
serviceNameand the headless ServicevolumeClaimTemplates: one disk per replica- Start-up and shutdown order:
podManagementPolicy - Update strategies:
RollingUpdate,partitionandOnDelete - Migrating
bookings-postgresto a StatefulSet in practice - Scaling and what happens to the PVCs
- The honest limit: identity is not replication
- A second example:
redis-cache
- What a StatefulSet guarantees that a Deployment cannot
A StatefulSet is, like the Deployment, a controller that maintains a desired number of pods from a template. The difference lies in what it promises about those pods.
| Aspect | Deployment | StatefulSet |
|---|---|---|
| Pod names | Random: bookings-api-7d9f8-x2k4p |
Ordinal and fixed: bookings-postgres-0 |
| Identity after a restart | Lost (a new name) | Preserved (same name and same disk) |
| Storage | One shared by the whole template | One per replica, created automatically |
| Individual DNS name | No | Yes, with a headless Service |
| Creation order | All at once | -0, then -1, then -2 (by default) |
| Deletion order | Arbitrary | Descending ordinal: -2, -1, -0 |
| Scaling to zero and back | Completely new pods | Same names, same disks |
| Typical use case | Stateless processes | Databases, queues, consensus systems |
The three guarantees, stated precisely:
- Stable network identity. Each pod is given an ordinal name derived from the name of the StatefulSet, and a hostname that matches it. That name does not change even if the pod restarts, is recreated or ends up on another node.
- Stable per-replica storage. Each pod gets its own PVC generated from a template. The pod
bookings-postgres-0always mounts the volume belonging tobookings-postgres-0, never the one belonging to-1. - Ordered deployment and scaling. Pods are created and removed in sequence, waiting for each one to be ready before continuing (if configured that way).
A warning up front that saves a lot of grief: using a StatefulSet does not make an application distributed. If your application does not know what to do with three copies of itself, three replicas in a StatefulSet only give you three isolated instances stepping on each other. We will come back to this in section 9.
- Stable identity: ordinal names and per-pod DNS
A StatefulSet called bookings-postgres with replicas: 3 produces exactly these pods:
NAME READY STATUS RESTARTS AGE
bookings-postgres-0 1/1 Running 0 8m
bookings-postgres-1 1/1 Running 0 7m
bookings-postgres-2 1/1 Running 0 6mThe suffix is an ordinal that starts at zero and is contiguous. There are no gaps: if -2 exists, then -0 and -1 exist. If you delete bookings-postgres-1 by hand, the controller recreates it with exactly the same name and mounts exactly the same PVC on it again. From the application's point of view, the pod "has restarted", not "has been replaced by another one".
That ordinal shows up in three places the application can consult:
- The pod name (
metadata.name), reachable through the Downward API as we saw in module 3. - The hostname inside the container, which matches the pod name.
- The subdomain that forms the DNS record, if there is a headless Service.
# Check the hostname from inside the pod
kubectl exec -n rutas-norte-dev bookings-postgres-1 -- hostnameThis is what lets a start-up script work out its own role:
# Typical fragment of a replicated database entrypoint
ORDINAL="${HOSTNAME##*-}" # from "bookings-postgres-1" it extracts "1"
if [ "$ORDINAL" = "0" ]; then
echo "Starting as primary"
else
echo "Starting as a replica of bookings-postgres-0"
fiThat pattern — deriving the role from the ordinal — is the basis of many Kubernetes-ready database images. It is also, as we shall see, fragile: if pod -0 dies and another one has to take over, the ordinal no longer tells the truth.
serviceName and the headless Service
serviceName and the headless ServiceThe serviceName field is mandatory in a StatefulSet and points to the name of a headless Service, the kind we defined in lesson 04-02 with clusterIP: None and whose real use we said would arrive here.
Let us recall why. A normal Service has a virtual IP and spreads traffic across all the pods matching its selector. That is precisely what we do not want for a replicated database: if I write to "the service IP", I do not know whether I am writing to the primary or to a read-only replica.
A headless Service creates no virtual IP and no load-balancing rules. Instead, CoreDNS publishes one record per pod:
# k8s/base/bookings-postgres-headless-service.yaml
apiVersion: v1
kind: Service
metadata:
name: bookings-postgres-nodes
namespace: rutas-norte-dev
labels:
app: bookings-postgres
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
clusterIP: None # <- this is what makes it headless
selector:
app: bookings-postgres # only app and environment go into selectors
environment: dev
ports:
- name: postgresql
port: 5432
targetPort: 5432With this Service, each pod of the StatefulSet gets its own FQDN of the form:
That is:
bookings-postgres-0.bookings-postgres-nodes.rutas-norte-dev.svc.cluster.local
bookings-postgres-1.bookings-postgres-nodes.rutas-norte-dev.svc.cluster.local
bookings-postgres-2.bookings-postgres-nodes.rutas-norte-dev.svc.cluster.localEach one resolves to that pod's actual IP, not to a virtual IP. We can check it:
kubectl run -n rutas-norte-dev debugger --rm -it --restart=Never \
--image=busybox:1.36 -- \
nslookup bookings-postgres-1.bookings-postgres-nodes.rutas-norte-dev.svc.cluster.localServer: 10.96.0.10
Address: 10.96.0.10:53
Name: bookings-postgres-1.bookings-postgres-nodes.rutas-norte-dev.svc.cluster.local
Address: 10.244.1.37In addition, querying the name of the headless Service returns all the IPs of the ready pods, which is how you discover the membership list:
kubectl run -n rutas-norte-dev debugger --rm -it --restart=Never \
--image=busybox:1.36 -- \
nslookup bookings-postgres-nodes.rutas-norte-dev.svc.cluster.localName: bookings-postgres-nodes.rutas-norte-dev.svc.cluster.local
Address: 10.244.1.37
Address: 10.244.2.19
Address: 10.244.0.28In practice you use two Services over the same StatefulSet:
| Service | Type | What for |
|---|---|---|
bookings-postgres-nodes |
Headless (clusterIP: None) |
Per-pod DNS identity; mandatory for the StatefulSet |
bookings-postgres |
Normal ClusterIP | Stable entry point for bookings-api and notifications-worker |
The normal ClusterIP is the one the other components have known since module 4; there is no need to touch the configuration of bookings-api.
graph LR A[bookings-api] -->|bookings-postgres:5432| S[ClusterIP Service] S --> P0[bookings-postgres-0] H[Headless Service<br/>bookings-postgres-nodes] -.per-pod DNS.-> P0 H -.per-pod DNS.-> P1[bookings-postgres-1] H -.per-pod DNS.-> P2[bookings-postgres-2] P0 --- V0[(PVC data-...-0)] P1 --- V1[(PVC data-...-1)] P2 --- V2[(PVC data-...-2)]
volumeClaimTemplates: one disk per replica
volumeClaimTemplates: one disk per replicaHere is the piece that breaks through the ceiling we hit in 05-03. Instead of referencing an existing PVC, the StatefulSet declares a PVC template, and the controller creates one per replica.
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-fast
resources:
requests:
storage: 20GiThe name of the resulting PVC follows a fixed rule:
With the template called data and the StatefulSet bookings-postgres:
NAME STATUS VOLUME CAPACITY STORAGECLASS AGE
data-bookings-postgres-0 Bound pvc-8a1c... 20Gi rutasnorte-fast 9m
data-bookings-postgres-1 Bound pvc-3f77... 20Gi rutasnorte-fast 8m
data-bookings-postgres-2 Bound pvc-b204... 20Gi rutasnorte-fast 7mInside the pod template, the volume is mounted by referring to the template name, not to the name of the actual PVC:
volumeMounts:
- name: data # matches volumeClaimTemplates[].metadata.name
mountPath: /var/lib/postgresql/data
subPath: pgdataAnd now the crucial fact, the one that prevents the most grief and causes the most grief depending on how you look at it:
The PVCs created by
volumeClaimTemplatesare NOT deleted automatically. Neither when you scale down, nor when you delete the whole StatefulSet.
It is a deliberate design decision: data is the most valuable thing there is and Kubernetes does not throw it away for you. If you delete the bookings-postgres StatefulSet and create it again with the same name and the same template, the pods reattach to the volumes that already existed. That makes deleting and recreating a StatefulSet a safe operation, something we will take advantage of in the migration.
Since Kubernetes 1.27 there is a stable field for tuning that behaviour:
spec:
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain # Retain (default) | Delete
whenScaled: Retain # Retain (default) | Delete| Field | Value | Effect |
|---|---|---|
whenDeleted |
Retain |
When the StatefulSet is deleted, the PVCs survive (default) |
whenDeleted |
Delete |
When the StatefulSet is deleted, all of its PVCs are deleted |
whenScaled |
Retain |
When scaling down, the PVC of the removed pod survives (default) |
whenScaled |
Delete |
When scaling down, the PVC of the removed pod is deleted |
For bookings-postgres we will leave both on Retain, which is also consistent with the rutasnorte-fast class, whose reclaim policy is Retain. For a cache such as redis-cache, Delete is defensible.
- Start-up and shutdown order:
podManagementPolicy
podManagementPolicyBy default (podManagementPolicy: OrderedReady), the StatefulSet creates pod -0, waits for it to be Running and Ready, and only then creates -1. When deleting or scaling down it goes the other way: highest ordinal first.
This is exactly what a database needs when its replicas connect to the primary: there is no point in starting the replica before the node it is going to copy from.
But it has a cost. If pod -0 never reaches Ready — badly written image, misconfigured probe, unavailable disk — the others are never created. A StatefulSet stuck on ordinal 0 is one of the most common incidents.
| Policy | Creation | Deletion | When to use it |
|---|---|---|---|
OrderedReady (default) |
Sequential, waiting for Ready |
Sequential, descending | Databases with primary/replica, consensus systems with an ordered start-up |
Parallel |
All at once | All at once | Symmetric members that do not depend on each other and only need identity and a disk |
An important note: podManagementPolicy is immutable. It cannot be changed on an existing StatefulSet; you have to delete it (with --cascade=orphan if you do not want to touch the pods) and recreate it.
- Update strategies:
RollingUpdate, partition and OnDelete
RollingUpdate, partition and OnDeleteWhen you change the pod template — for example, moving from postgres:16.4 to postgres:16.6 — the StatefulSet applies its updateStrategy.
RollingUpdate (default)
It updates pods in reverse ordinal order: -2 first, then -1, and -0 last. It waits for each one to be Ready before moving on to the next.
The reverse order is intentional: if -0 is the primary, it is updated last, minimising the time the cluster spends without a primary. And since each pod keeps its disk, the updated pod comes back with its data intact.
The partition field
Inside RollingUpdate there is a field that turns the update into a phased rollout:
The rule is simple: only pods whose ordinal is greater than or equal to partition are updated. With partition: 2 and three replicas, only bookings-postgres-2 is updated. Pods -0 and -1 stay on the old version even though you changed the template.
This allows a manual, controlled canary release:
# 1. Get ready: nobody is updated yet
kubectl patch statefulset bookings-postgres -n rutas-norte-pre \
-p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":3}}}}'
# 2. Change the image (nothing happens yet, partition == replicas)
kubectl set image statefulset/bookings-postgres -n rutas-norte-pre \
postgres=postgres:16.6
# 3. Release only ordinal 2 and watch it for a while
kubectl patch statefulset bookings-postgres -n rutas-norte-pre \
-p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":2}}}}'
# 4. If all goes well, lower the partition to zero
kubectl patch statefulset bookings-postgres -n rutas-norte-pre \
-p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":0}}}}'If pod -2 fails at step 3, it is enough to raise partition again and revert the image: the other two were never touched.
OnDelete
The controller updates nothing automatically. Pods are recreated with the new template only when you delete them by hand. It is the maximum possible control and it is used when the update requires external steps (migrating the schema, manually promoting a primary, verifying data integrity between one pod and the next).
| Strategy | Who decides when | Order | Risk |
|---|---|---|---|
RollingUpdate (partition: 0) |
Kubernetes | Descending ordinal | Touches every pod unsupervised |
RollingUpdate with partition: N |
You, by lowering N | Only ordinals ≥ N | Low; allows canary and rollback |
OnDelete |
You, by deleting pods | Whichever you choose | Forgetting pods left un-updated for months |
- Migrating
bookings-postgres to a StatefulSet in practice
bookings-postgres to a StatefulSet in practiceThis is the central exercise of the lesson: moving from a single-replica Deployment with a PVC called bookings-postgres-data to a StatefulSet, without losing the data.
The obstacle is naming. The StatefulSet will demand a PVC called data-bookings-postgres-0, whereas the existing one is called bookings-postgres-data. PVCs cannot be renamed. But we can reuse the underlying PersistentVolume, which is where the data really lives.
Step 0: backup and downtime window
First of all, and with what we learned in 05-06:
kubectl create job -n rutas-norte-pre --from=cronjob/backup-bookings-postgres backup-pre-migration
kubectl wait --for=condition=complete job/backup-pre-migration -n rutas-norte-pre --timeout=600sThe migration involves downtime. bookings-postgres is single-node: there is no way to do it live.
Step 1: identify the PV and protect it
# Make sure the PV is not deleted when the PVC disappears
kubectl patch pv pvc-4c81ae30-9b7f-4a02-8d55-1f2a6c0e7b91 \
-p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'With the rutasnorte-fast class it is already Retain, but checking is free and getting it wrong is irreversible.
Step 2: stop the Deployment
kubectl scale deployment bookings-postgres -n rutas-norte-pre --replicas=0
kubectl wait --for=delete pod -l app=bookings-postgres -n rutas-norte-pre --timeout=300sStep 3: release the PV and delete the old PVC
kubectl delete pvc bookings-postgres-data -n rutas-norte-pre
kubectl get pv pvc-4c81ae30-9b7f-4a02-8d55-1f2a6c0e7b91NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM
pvc-4c81... 20Gi RWO Retain Released rutas-norte-pre/bookings-postgres-dataThe PV is left in Released: it keeps the data but will not accept a new PVC until the reference to the previous one is cleared.
The PV is now back to Available.
Step 4: create the PVC with the name the StatefulSet expects
# k8s/environments/pre/bookings-postgres-pvc-migrated.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-bookings-postgres-0 # the exact name the StatefulSet will generate
namespace: rutas-norte-pre
labels:
app: bookings-postgres
app.kubernetes.io/part-of: rutas-norte
environment: pre
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-fast
volumeName: pvc-4c81ae30-9b7f-4a02-8d55-1f2a6c0e7b91 # binds to the existing PV
resources:
requests:
storage: 20GivolumeName forces the match with that specific PV, instead of provisioning a new, empty one.
Step 5: the StatefulSet
# k8s/base/bookings-postgres-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: bookings-postgres
namespace: rutas-norte-pre
labels:
app: bookings-postgres
app.kubernetes.io/part-of: rutas-norte
environment: pre
spec:
serviceName: bookings-postgres-nodes # mandatory: the headless Service
replicas: 1
podManagementPolicy: OrderedReady
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 0
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
selector:
matchLabels:
app: bookings-postgres
environment: pre
template:
metadata:
labels:
app: bookings-postgres
app.kubernetes.io/part-of: rutas-norte
environment: pre
spec:
serviceAccountName: bookings-postgres
automountServiceAccountToken: false
terminationGracePeriodSeconds: 60
securityContext:
fsGroup: 999
containers:
- name: postgres
image: postgres:16.4
ports:
- name: postgresql
containerPort: 5432
env:
- name: POSTGRES_DB
value: bookings
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: username
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: bookings-postgres-credentials
key: password
resources:
requests:
cpu: "1"
memory: 2Gi
limits:
cpu: "1"
memory: 2Gi
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
labels:
app: bookings-postgres
app.kubernetes.io/part-of: rutas-norte
environment: pre
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-fast
resources:
requests:
storage: 20GiPoints worth highlighting in this manifest:
serviceNamepoints to the headless Service, not to the normal ClusterIP. Confusing the two is a frequent mistake.- The resources keep the
Guaranteedclass we settled on in 03-05:requestsequal tolimits, 1 CPU and 2 GiB. - The dedicated ServiceAccount and the
automountServiceAccountToken: falsefrom 03-06 are preserved. PGDATApoints to a subdirectory of the mount point. PostgreSQL refuses to initialise over a directory containinglost+found, which is common on formatted block volumes.- The selector uses only
appandenvironment, following the project convention. And remember:spec.selectoris immutable here too.
Step 6: apply and verify
kubectl apply -f k8s/environments/pre/bookings-postgres-pvc-migrated.yaml
kubectl apply -f k8s/base/bookings-postgres-headless-service.yaml
kubectl apply -f k8s/base/bookings-postgres-statefulset.yaml
kubectl rollout status statefulset/bookings-postgres -n rutas-norte-prekubectl exec -n rutas-norte-pre bookings-postgres-0 -- \
psql -U rutasnorte -d bookings -c 'SELECT count(*) FROM bookings;'The data is still there. The old Deployment can now be deleted:
- Scaling and what happens to the PVCs
Scaling a StatefulSet is syntactically identical to scaling a Deployment:
When scaling up, the controller creates -1, waits for it to be Ready, then creates -2. Each one triggers the creation of a new PVC from the template, and with rutasnorte-fast and volumeBindingMode: WaitForFirstConsumer the volume is provisioned in the zone where the scheduler placed the pod.
When scaling down to one replica:
kubectl scale statefulset bookings-postgres -n rutas-norte-pre --replicas=1
kubectl get pvc -n rutas-norte-pre -l app=bookings-postgresNAME STATUS VOLUME CAPACITY STORAGECLASS
data-bookings-postgres-0 Bound pvc-4c81... 20Gi rutasnorte-fast
data-bookings-postgres-1 Bound pvc-9d02... 20Gi rutasnorte-fast
data-bookings-postgres-2 Bound pvc-71bb... 20Gi rutasnorte-fastPods -1 and -2 are gone, but their PVCs are still there (with whenScaled: Retain). Practical consequences:
- Advantage: if you scale back up to 3, the pods get their disks back with their data. Scaling is reversible.
- Cost: those volumes keep being billed. With a cloud provider, three 20 GiB SSDs cost the same whether they are mounted or not.
Cleaning up, when you are sure, is manual and explicit:
With the rutasnorte-fast class on Retain, the PV will move to Released and will keep the data until an administrator removes it. That friction is deliberate.
- The honest limit: identity is not replication
We reach the most important point of the lesson, and the one most often misunderstood.
We have scaled bookings-postgres to three replicas. That is not a PostgreSQL cluster. They are three PostgreSQL processes, each with its own empty, independent disk, with no relationship between them. If bookings-api wrote against the ClusterIP Service, bookings would land randomly in one of the three databases and the data would end up scattered and inconsistent.
What a StatefulSet gives you and what it does not:
| The StatefulSet gives you | The StatefulSet does NOT give you |
|---|---|
| A stable name per replica | The choice of which one is the primary |
| Its own disk per replica | Copying the contents of one disk to another |
| Individual DNS per pod | Failover when the primary goes down |
| Start-up and shutdown order | Redirecting writes to the new primary |
| Ordered, reverse updates | Data consistency between replicas |
| Reattaching to the same disk after a restart | Backups or point-in-time recovery |
Replication has to be implemented by the application. In PostgreSQL that means configuring streaming replication, creating the replication user, running pg_basebackup on the replicas, keeping a correct primary_conninfo and, above all, having something that decides on and carries out the promotion when the primary dies.
That "something" can be written by hand: init containers that detect the ordinal, probes that check the role, a script that rewrites the Services. It is exactly the work that operators do, and that is why lesson 06-07 will close the module by replacing this hand-rolled StatefulSet with a PostgreSQL operator that does know how to do all of that.
A practical rule for your day-to-day work:
If the application already knows how to run in a cluster (Kafka, Cassandra, etcd, Elasticsearch, ZooKeeper), a well-configured StatefulSet is enough. If it does not (PostgreSQL, MySQL, MongoDB in simple mode), a StatefulSet gives you the infrastructure but you need an operator or a managed service to supply the logic.
In the meantime, bookings-postgres in production stays on a single replica. That is honest: one well-backed-up, restorable replica is better than three replicas pretending to be a cluster.
- A second example:
redis-cache
redis-cacheredis-cache is a different and instructive case. It holds seat availability, a piece of data that is rebuildable from bookings-postgres, and losing it only causes a temporary spike in queries.
Does it deserve a StatefulSet? It depends what you want:
- Redis as a pure cache, with no persistence: a Deployment is perfectly valid. There is no state to preserve.
- Redis with AOF/RDB persistence so it does not start cold after a restart: a StatefulSet, because we want the pod to find its file again.
Rutas Norte chooses the second option for production, because after a restart on a bank-holiday weekend an empty cache pushes the load onto the database at exactly the worst moment.
# k8s/base/redis-cache-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis-cache
namespace: rutas-norte-pro
labels:
app: redis-cache
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
serviceName: redis-cache-nodes
replicas: 3
podManagementPolicy: Parallel # all three are symmetric, there is no order to respect
persistentVolumeClaimRetentionPolicy:
whenDeleted: Delete # it is a cache: orphan disks are not worth keeping
whenScaled: Delete
selector:
matchLabels:
app: redis-cache
environment: pro
template:
metadata:
labels:
app: redis-cache
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
automountServiceAccountToken: false
terminationGracePeriodSeconds: 30
containers:
- name: redis
image: redis:7.4.1-alpine
args:
- "--appendonly"
- "yes"
- "--maxmemory"
- "700mb"
- "--maxmemory-policy"
- "allkeys-lru"
ports:
- name: redis
containerPort: 6379
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: 500m
memory: 1Gi
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-standard
resources:
requests:
storage: 2GiDeliberate differences from bookings-postgres, and the reasoning behind each one:
| Decision | bookings-postgres |
redis-cache |
Reason |
|---|---|---|---|
podManagementPolicy |
OrderedReady |
Parallel |
Cache instances do not depend on each other; starting all three at once is faster |
whenScaled / whenDeleted |
Retain |
Delete |
Cache data is rebuildable; booking data is not |
| StorageClass | rutasnorte-fast |
rutasnorte-standard |
The cache lives in memory; the disk only serves the warm start |
| QoS | Guaranteed |
Burstable |
The cache tolerates CPU contention; the database does not |
Here we really do have three useful replicas, because each instance serves its share of the traffic independently and bookings-api can spread queries across them through the ClusterIP. There is no consistency to guarantee: if an instance does not have the key, the database is queried and the key is populated.
Common Mistakes and Tips
Confusing serviceName with the normal Service. serviceName must point to the headless Service. If it points to a normal ClusterIP, the StatefulSet starts without complaining but the per-pod DNS names do not work, and replicas trying to find each other fail in bewildering ways.
Forgetting to create the headless Service. Kubernetes does not validate that the Service named in serviceName exists. The pods start and everything looks fine until something tries to resolve bookings-postgres-1.bookings-postgres-nodes... and gets NXDOMAIN.
Expecting deleting the StatefulSet to delete the disks. With the default policy (Retain) it does not. After several rounds of testing you end up with dozens of orphan PVCs eating into your quota. Review them periodically: kubectl get pvc -A | grep -v Bound is not enough, because they stay Bound. Search by label and compare against the existing StatefulSets.
Scaling a database and believing it is now replicated. The most expensive conceptual mistake in this lesson. Three PostgreSQL pods with three empty disks are three different databases. Reread section 9.
A StatefulSet stuck on ordinal 0. With OrderedReady, if -0 never reaches Ready, nothing progresses. Diagnosis: kubectl describe pod bookings-postgres-0 and kubectl get events -n <ns> --sort-by=.lastTimestamp. Typical causes: a PVC in Pending because no node offers the required storage class, or a badly configured readiness probe.
Changing volumeClaimTemplates on an existing StatefulSet. It is a practically immutable field: only resources.requests.storage may be modified (and since 1.31, with the corresponding feature gate). Any other change causes a validation error. If you need to change the StorageClass, you have to migrate as in section 7.
Resizing the disk. Do not edit volumeClaimTemplates expecting it to propagate to the PVCs that already exist. You have to expand each PVC individually (kubectl patch pvc data-bookings-postgres-0 ...) as we saw in 05-05, and update the template so that future replicas are born with the new size.
Tip: always use subPath with PostgreSQL. Block volumes formatted as ext4 come with a lost+found directory at the root, and initdb refuses to work on a non-empty directory. Mounting at /var/lib/postgresql/data with PGDATA=/var/lib/postgresql/data/pgdata solves the problem cleanly.
Tip: kubectl delete statefulset X --cascade=orphan. It deletes the StatefulSet leaving the pods alive. This is the manoeuvre for changing immutable fields (selector, podManagementPolicy, serviceName) without interrupting the service: you delete the object, apply the corrected manifest and the controller adopts the existing pods, just as we saw with ReplicaSets in 02-02.
Exercises
Exercise 1: identity and ordinal persistence
In the rutas-norte-dev namespace of your minikube (profile rutas-norte), deploy a StatefulSet called notes-demo with 3 replicas of busybox:1.36, a headless Service called notes-demo-nodes and a 100 MiB volumeClaimTemplates with the rutasnorte-standard class, mounted at /data. Each pod must write its name to /data/who-am-i.txt on start-up and then sleep.
Then:
- Check the names of the pods and of the generated PVCs.
- Write some extra content in pod
notes-demo-1. - Delete that pod and verify that the new pod recovers the file.
Exercise 2: phased rollout with partition
Using the StatefulSet from exercise 1, change the image to busybox:1.37 with partition so that only the pod with ordinal 2 is updated. Verify that notes-demo-0 and notes-demo-1 still run the old image. Then complete the update.
Exercise 3: scaling and the fate of the PVCs
With the StatefulSet from exercise 1 at 3 replicas:
- Scale it down to 1 replica and check how many PVCs remain.
- Scale back up to 3 and verify that
notes-demo-2recovers its file, not a new one. - Set
persistentVolumeClaimRetentionPolicy.whenScaled: Delete, scale down to 1 again and check the difference. - Clean everything up.
Solutions
Solution 1
# /tmp/notes-demo.yaml
apiVersion: v1
kind: Service
metadata:
name: notes-demo-nodes
namespace: rutas-norte-dev
labels:
app: notes-demo
environment: dev
spec:
clusterIP: None
selector:
app: notes-demo
environment: dev
ports:
- name: dummy
port: 9999
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: notes-demo
namespace: rutas-norte-dev
labels:
app: notes-demo
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
serviceName: notes-demo-nodes
replicas: 3
selector:
matchLabels:
app: notes-demo
environment: dev
template:
metadata:
labels:
app: notes-demo
app.kubernetes.io/part-of: rutas-norte
environment: dev
spec:
automountServiceAccountToken: false
containers:
- name: writer
image: busybox:1.36
command:
- sh
- -c
- 'echo "I am $(hostname)" >> /data/who-am-i.txt; sleep 86400'
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 50m
memory: 32Mi
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-standard
resources:
requests:
storage: 100Mikubectl apply -f /tmp/notes-demo.yaml
kubectl rollout status statefulset/notes-demo -n rutas-norte-dev
kubectl get pods -n rutas-norte-dev -l app=notes-demo
kubectl get pvc -n rutas-norte-dev -l app=notes-demoNAME READY STATUS RESTARTS AGE
notes-demo-0 1/1 Running 0 40s
notes-demo-1 1/1 Running 0 32s
notes-demo-2 1/1 Running 0 24s
NAME STATUS VOLUME CAPACITY STORAGECLASS
data-notes-demo-0 Bound pvc-a1b2... 100Mi rutasnorte-standard
data-notes-demo-1 Bound pvc-c3d4... 100Mi rutasnorte-standard
data-notes-demo-2 Bound pvc-e5f6... 100Mi rutasnorte-standardThe PVCs follow the pattern <template>-<statefulset>-<ordinal>, exactly as anticipated.
# 2. Write some extra content
kubectl exec -n rutas-norte-dev notes-demo-1 -- \
sh -c 'echo "dummy booking 4471" >> /data/who-am-i.txt'
# 3. Delete the pod and wait for its replacement
kubectl delete pod notes-demo-1 -n rutas-norte-dev
kubectl wait --for=condition=ready pod/notes-demo-1 -n rutas-norte-dev --timeout=120s
kubectl exec -n rutas-norte-dev notes-demo-1 -- cat /data/who-am-i.txtThe file keeps the two previous lines and adds a third one from the new start-up. The pod has the same name and has found its disk again: stable identity and stable storage, exactly what a Deployment cannot promise.
Solution 2
# Set the partition above the highest ordinal before touching anything
kubectl patch statefulset notes-demo -n rutas-norte-dev \
-p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":3}}}}'
kubectl set image statefulset/notes-demo -n rutas-norte-dev writer=busybox:1.37
# Nothing has moved: partition (3) > highest ordinal (2)
kubectl get pods -n rutas-norte-dev -l app=notes-demo \
-o custom-columns=POD:.metadata.name,IMAGE:.spec.containers[0].image# Release only ordinal 2
kubectl patch statefulset notes-demo -n rutas-norte-dev \
-p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":2}}}}'
kubectl rollout status statefulset/notes-demo -n rutas-norte-dev
kubectl get pods -n rutas-norte-dev -l app=notes-demo \
-o custom-columns=POD:.metadata.name,IMAGE:.spec.containers[0].imageOnly the highest ordinal has been updated. If this were a database with the primary on -0, we would have tested the new version on the least critical replica.
# Complete the update
kubectl patch statefulset notes-demo -n rutas-norte-dev \
-p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":0}}}}'
kubectl rollout status statefulset/notes-demo -n rutas-norte-devThe order observed in the events will be -1 first and -0 after: always descending.
Solution 3
# 1. Scale down to 1
kubectl scale statefulset notes-demo -n rutas-norte-dev --replicas=1
kubectl get pods,pvc -n rutas-norte-dev -l app=notes-demoNAME READY STATUS RESTARTS AGE
pod/notes-demo-0 1/1 Running 0 14m
NAME STATUS VOLUME CAPACITY
persistentvolumeclaim/data-notes-demo-0 Bound pvc-a1b2... 100Mi
persistentvolumeclaim/data-notes-demo-1 Bound pvc-c3d4... 100Mi
persistentvolumeclaim/data-notes-demo-2 Bound pvc-e5f6... 100MiOne pod, three PVCs. It is the default behaviour (whenScaled: Retain) and the usual cause of unexplained storage bills.
# 2. Scale back up and check that -2 recovers its file
kubectl scale statefulset notes-demo -n rutas-norte-dev --replicas=3
kubectl wait --for=condition=ready pod/notes-demo-2 -n rutas-norte-dev --timeout=120s
kubectl exec -n rutas-norte-dev notes-demo-2 -- cat /data/who-am-i.txtTwo lines: the one from the original start-up and the one from the new one. The disk is the same as before.
# 3. Change the policy and scale down again
kubectl patch statefulset notes-demo -n rutas-norte-dev \
-p '{"spec":{"persistentVolumeClaimRetentionPolicy":{"whenScaled":"Delete","whenDeleted":"Retain"}}}'
kubectl scale statefulset notes-demo -n rutas-norte-dev --replicas=1
kubectl get pvc -n rutas-norte-dev -l app=notes-demoNAME STATUS VOLUME CAPACITY STORAGECLASS
data-notes-demo-0 Bound pvc-a1b2... 100Mi rutasnorte-standardThe PVCs of -1 and -2 have now been deleted along with their pods. For a cache that is what you want; for bookings-postgres it would be a quick way to lose a year of bookings.
# 4. Clean-up
kubectl delete -f /tmp/notes-demo.yaml
kubectl delete pvc -n rutas-norte-dev -l app=notes-demoConclusion
The StatefulSet solves the ceiling we had been dragging since lesson 05-03. With it, each replica has a stable ordinal name, its own PVC generated from volumeClaimTemplates and an individual DNS name thanks to the headless Service that serviceName demands. Start-up and shutdown are ordered (OrderedReady) or simultaneous (Parallel) as suits you, and updates work down in reverse order with the option of holding them back in phases through partition.
We have migrated bookings-postgres from Deployment to StatefulSet by reusing the existing PersistentVolume — the trick is to clear the claimRef and create a PVC with the exact name the controller expects — and we have turned redis-cache into a three-replica StatefulSet with opposite, well-reasoned decisions in every field.
And we have been honest about the limit: the StatefulSet provides infrastructure, not logic. PostgreSQL does not replicate itself just by living inside one. That gap is what will motivate the operators of lesson 06-07, which will close this module by replacing our hand-rolled StatefulSet with software that knows how to promote a primary, create read replicas and recover to a specific point in time.
Before that there are other ways of running workloads we do not know yet. So far all of our workloads are spread wherever the scheduler wants and in whatever number of copies we ask for. But there is a category of process — infrastructure agents: log collectors, metrics exporters, network plugins — for which "how many replicas" is the wrong question: the right answer is always one per node, no more and no fewer, and automatically on the nodes added tomorrow. That is the subject of the next lesson: DaemonSets.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
