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

  1. What a StatefulSet guarantees that a Deployment cannot
  2. Stable identity: ordinal names and per-pod DNS
  3. serviceName and the headless Service
  4. volumeClaimTemplates: one disk per replica
  5. Start-up and shutdown order: podManagementPolicy
  6. Update strategies: RollingUpdate, partition and OnDelete
  7. Migrating bookings-postgres to a StatefulSet in practice
  8. Scaling and what happens to the PVCs
  9. The honest limit: identity is not replication
  10. A second example: redis-cache

  1. 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-0 always mounts the volume belonging to bookings-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.

  1. 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          6m

The 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 -- hostname
bookings-postgres-1

This 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"
fi

That 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.

  1. serviceName and the headless Service

The 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: 5432

With this Service, each pod of the StatefulSet gets its own FQDN of the form:

<pod>.<serviceName>.<namespace>.svc.cluster.local

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.local

Each 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.local
Server:    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.37

In 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.local
Name:      bookings-postgres-nodes.rutas-norte-dev.svc.cluster.local
Address:   10.244.1.37
Address:   10.244.2.19
Address:   10.244.0.28

In 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)]

  1. volumeClaimTemplates: one disk per replica

Here 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: 20Gi

The name of the resulting PVC follows a fixed rule:

<template-name>-<statefulset-name>-<ordinal>

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      7m

Inside 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: pgdata

And 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 volumeClaimTemplates are 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.

  1. Start-up and shutdown order: podManagementPolicy

By 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.

spec:
  podManagementPolicy: OrderedReady    # or Parallel
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.

  1. Update strategies: RollingUpdate, partition and OnDelete

When 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:

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

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

spec:
  updateStrategy:
    type: 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

  1. Migrating bookings-postgres to a StatefulSet in practice

This 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=600s

The migration involves downtime. bookings-postgres is single-node: there is no way to do it live.

Step 1: identify the PV and protect it

kubectl get pvc bookings-postgres-data -n rutas-norte-pre \
  -o jsonpath='{.spec.volumeName}'
pvc-4c81ae30-9b7f-4a02-8d55-1f2a6c0e7b91
# 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=300s

Step 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-1f2a6c0e7b91
NAME          CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS     CLAIM
pvc-4c81...   20Gi       RWO            Retain           Released   rutas-norte-pre/bookings-postgres-data

The 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.

kubectl patch pv pvc-4c81ae30-9b7f-4a02-8d55-1f2a6c0e7b91 \
  -p '{"spec":{"claimRef":null}}'

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: 20Gi

volumeName 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: 20Gi

Points worth highlighting in this manifest:

  • serviceName points to the headless Service, not to the normal ClusterIP. Confusing the two is a frequent mistake.
  • The resources keep the Guaranteed class we settled on in 03-05: requests equal to limits, 1 CPU and 2 GiB.
  • The dedicated ServiceAccount and the automountServiceAccountToken: false from 03-06 are preserved.
  • PGDATA points to a subdirectory of the mount point. PostgreSQL refuses to initialise over a directory containing lost+found, which is common on formatted block volumes.
  • The selector uses only app and environment, following the project convention. And remember: spec.selector is 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-pre
partitioned roll out complete: 1 new pods have been updated...
kubectl exec -n rutas-norte-pre bookings-postgres-0 -- \
  psql -U rutasnorte -d bookings -c 'SELECT count(*) FROM bookings;'
 count
-------
 18342
(1 row)

The data is still there. The old Deployment can now be deleted:

kubectl delete deployment bookings-postgres -n rutas-norte-pre

  1. Scaling and what happens to the PVCs

Scaling a StatefulSet is syntactically identical to scaling a Deployment:

kubectl scale statefulset bookings-postgres -n rutas-norte-pre --replicas=3

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-postgres
NAME                          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-fast

Pods -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:

kubectl delete pvc data-bookings-postgres-1 data-bookings-postgres-2 -n rutas-norte-pre

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.

  1. 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.

  1. A second example: redis-cache

redis-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: 2Gi

Deliberate 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:

  1. Check the names of the pods and of the generated PVCs.
  2. Write some extra content in pod notes-demo-1.
  3. 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:

  1. Scale it down to 1 replica and check how many PVCs remain.
  2. Scale back up to 3 and verify that notes-demo-2 recovers its file, not a new one.
  3. Set persistentVolumeClaimRetentionPolicy.whenScaled: Delete, scale down to 1 again and check the difference.
  4. 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: 100Mi
kubectl 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-demo
NAME           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-standard

The 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.txt
I am notes-demo-1
dummy booking 4471
I am notes-demo-1

The 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
POD            IMAGE
notes-demo-0   busybox:1.36
notes-demo-1   busybox:1.36
notes-demo-2   busybox:1.36
# 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].image
POD            IMAGE
notes-demo-0   busybox:1.36
notes-demo-1   busybox:1.36
notes-demo-2   busybox:1.37

Only 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-dev

The 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-demo
NAME               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...   100Mi

One 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.txt
I am notes-demo-2
I am notes-demo-2

Two 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-demo
NAME                  STATUS   VOLUME        CAPACITY   STORAGECLASS
data-notes-demo-0     Bound    pvc-a1b2...   100Mi      rutasnorte-standard

The 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-demo

Conclusion

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

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved