Tanquem el mòdul d'emmagatzematge amb una promesa pendent. La plataforma Rutas Norte ja té còpies de seguretat verificades i les reserves sobreviuen a l'esborrat d'un pod, però postgres-reserves continua sent un Deployment d'una sola rèplica amb strategy: Recreate. Aquesta configuració no és un descuit: és l'única que funciona, perquè un Deployment aplica la mateixa plantilla de pod —i per tant el mateix PersistentVolumeClaim— a totes les seves rèpliques. Si pugem a dues, totes dues intenten muntar el mateix volum ReadWriteOnce i la segona es queda esperant eternament.

El problema de fons és que un Deployment tracta els seus pods com a bestiar intercanviable: qualsevol serveix, tant se val el nom, tant se val l'ordre, tant se val quin disc toqui. Per a una base de dades això és exactament el contrari del que necessitem. Una rèplica de PostgreSQL necessita saber qui és, necessita el seu propi disc i necessita arrencar en un ordre concret respecte de les altres.

El StatefulSet és el controlador que aporta aquestes tres garanties. En aquesta lliçó l'estudiarem a fons, migrarem postgres-reserves de Deployment a StatefulSet sense perdre el volum existent, i acabarem amb un advertiment honest que marcarà la resta del mòdul: un StatefulSet dona identitat i disc, però no replica dades per tu.

Contingut

  1. Què garanteix un StatefulSet que un Deployment no pot donar
  2. Identitat estable: noms ordinals i DNS per pod
  3. serviceName i el Service headless
  4. volumeClaimTemplates: un disc per rèplica
  5. Ordre d'arrencada i aturada: podManagementPolicy
  6. Estratègies d'actualització: RollingUpdate, partition i OnDelete
  7. Migració pràctica de postgres-reserves a StatefulSet
  8. Escalat i què passa amb els PVC
  9. El límit honest: identitat no és replicació
  10. Segon exemple: redis-cache

  1. Què garanteix un StatefulSet que un Deployment no pot donar

Un StatefulSet és, com el Deployment, un controlador que manté un nombre desitjat de pods a partir d'una plantilla. La diferència és què promet sobre aquests pods.

Aspecte Deployment StatefulSet
Nom dels pods Aleatori: api-reserves-7d9f8-x2k4p Ordinal i fix: postgres-reserves-0
Identitat després d'un reinici Es perd (nom nou) Es conserva (mateix nom i mateix disc)
Emmagatzematge Un de compartit per tota la plantilla Un de propi per rèplica, creat automàticament
Nom DNS individual No Sí, amb Service headless
Ordre de creació Tots alhora -0, després -1, després -2 (per defecte)
Ordre d'esborrat Arbitrari Ordinal descendent: -2, -1, -0
Escalat a zero i tornada Pods completament nous Mateixos noms, mateixos discos
Cas d'ús típic Processos sense estat Bases de dades, cues, sistemes de consens

Les tres garanties, dites amb precisió:

  • Identitat de xarxa estable. Cada pod rep un nom ordinal derivat del nom del StatefulSet i un hostname que hi coincideix. Aquest nom no canvia encara que el pod es reiniciï, es recreï o acabi en un altre node.
  • Emmagatzematge estable per rèplica. Cada pod obté el seu propi PVC generat a partir d'una plantilla. El pod postgres-reserves-0 sempre munta el volum de postgres-reserves-0, mai el de -1.
  • Desplegament i escalat ordenats. Els pods es creen i s'eliminen en seqüència, esperant que cadascun estigui llest abans de continuar (si així es configura).

Un advertiment previ que estalvia disgustos: fer servir un StatefulSet no fa que una aplicació sigui distribuïda. Si la teva aplicació no sap què fer amb tres còpies d'ella mateixa, tres rèpliques en un StatefulSet només et donen tres instàncies aïllades que es trepitgen. Hi tornarem a l'apartat 9.

  1. Identitat estable: noms ordinals i DNS per pod

Un StatefulSet anomenat postgres-reserves amb replicas: 3 produeix exactament aquests pods:

NAME                   READY   STATUS    RESTARTS   AGE
postgres-reserves-0    1/1     Running   0          8m
postgres-reserves-1    1/1     Running   0          7m
postgres-reserves-2    1/1     Running   0          6m

El sufix és un ordinal que comença a zero i és contigu. No hi ha salts: si existeix -2, existeixen -0 i -1. Si esborres a mà postgres-reserves-1, el controlador el recrea amb exactament el mateix nom i li torna a muntar exactament el mateix PVC. Des del punt de vista de l'aplicació, el pod "s'ha reiniciat", no "ha estat substituït per un altre".

Aquest ordinal es reflecteix en tres llocs que l'aplicació pot consultar:

  • El nom del pod (metadata.name), accessible per Downward API tal com vam veure al mòdul 3.
  • El hostname dins del contenidor, que coincideix amb el nom del pod.
  • El subdomini que forma el registre DNS, si hi ha Service headless.
# Comprovar el hostname des de dins del pod
kubectl exec -n rutas-norte-dev postgres-reserves-1 -- hostname
postgres-reserves-1

Això és el que permet que un script d'arrencada decideixi el seu propi paper:

# Fragment típic d'un entrypoint de base de dades replicada
ORDINAL="${HOSTNAME##*-}"      # de "postgres-reserves-1" extreu "1"
if [ "$ORDINAL" = "0" ]; then
  echo "Arrenco com a primari"
else
  echo "Arrenco com a rèplica de postgres-reserves-0"
fi

Aquest patró —deduir el rol de l'ordinal— és la base de moltes imatges de bases de dades preparades per a Kubernetes. També és, com veurem, fràgil: si el pod -0 mor i un altre ha de prendre el relleu, l'ordinal ja no diu la veritat.

  1. serviceName i el Service headless

El camp serviceName és obligatori en un StatefulSet i apunta al nom d'un Service headless, aquell que a la lliçó 04-02 vam definir amb clusterIP: None i del qual vam dir que el seu ús real arribaria aquí.

Recordem per què. Un Service normal té una IP virtual i reparteix el trànsit entre tots els pods que casen amb el seu selector. Això és justament el que no volem per a una base de dades replicada: si escric a "la IP del servei", no sé si estic escrivint al primari o a una rèplica de només lectura.

Un Service headless no crea IP virtual ni regles de balanceig. En el seu lloc, CoreDNS publica un registre per a cada pod:

# k8s/base/postgres-reserves-headless-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: postgres-reserves-nodes
  namespace: rutas-norte-dev
  labels:
    app: postgres-reserves
    app.kubernetes.io/part-of: rutas-norte
    entorn: dev
spec:
  clusterIP: None          # <- això el converteix en headless
  selector:
    app: postgres-reserves # només app i entorn entren als selectors
    entorn: dev
  ports:
    - name: postgresql
      port: 5432
      targetPort: 5432

Amb aquest Service, cada pod del StatefulSet obté un FQDN propi amb la forma:

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

És a dir:

postgres-reserves-0.postgres-reserves-nodes.rutas-norte-dev.svc.cluster.local
postgres-reserves-1.postgres-reserves-nodes.rutas-norte-dev.svc.cluster.local
postgres-reserves-2.postgres-reserves-nodes.rutas-norte-dev.svc.cluster.local

Cadascun resol a la IP concreta d'aquest pod, no a una IP virtual. Ho podem comprovar:

kubectl run -n rutas-norte-dev depurador --rm -it --restart=Never \
  --image=busybox:1.36 -- \
  nslookup postgres-reserves-1.postgres-reserves-nodes.rutas-norte-dev.svc.cluster.local
Server:    10.96.0.10
Address:   10.96.0.10:53

Name:      postgres-reserves-1.postgres-reserves-nodes.rutas-norte-dev.svc.cluster.local
Address:   10.244.1.37

A més, consultar el nom del Service headless retorna totes les IP dels pods llestos, cosa que serveix per descobrir la llista de membres:

kubectl run -n rutas-norte-dev depurador --rm -it --restart=Never \
  --image=busybox:1.36 -- \
  nslookup postgres-reserves-nodes.rutas-norte-dev.svc.cluster.local
Name:      postgres-reserves-nodes.rutas-norte-dev.svc.cluster.local
Address:   10.244.1.37
Address:   10.244.2.19
Address:   10.244.0.28

A la pràctica es fan servir dos Services sobre el mateix StatefulSet:

Service Tipus Per a què
postgres-reserves-nodes Headless (clusterIP: None) Identitat DNS per pod; obligatori per al StatefulSet
postgres-reserves ClusterIP normal Punt d'entrada estable per a api-reserves i worker-notificacions

El ClusterIP normal és el que ja coneixen els altres components des del mòdul 4; no cal tocar la configuració d'api-reserves.

graph LR
  A[api-reserves] -->|postgres-reserves:5432| S[Service ClusterIP]
  S --> P0[postgres-reserves-0]
  H[Service headless<br/>postgres-reserves-nodes] -.DNS per pod.-> P0
  H -.DNS per pod.-> P1[postgres-reserves-1]
  H -.DNS per pod.-> P2[postgres-reserves-2]
  P0 --- V0[(PVC dades-...-0)]
  P1 --- V1[(PVC dades-...-1)]
  P2 --- V2[(PVC dades-...-2)]

  1. volumeClaimTemplates: un disc per rèplica

Aquí hi ha la peça que trenca el sostre que vam detectar a 05-03. En lloc de referenciar un PVC ja existent, el StatefulSet declara una plantilla de PVC, i el controlador en crea un per a cada rèplica.

  volumeClaimTemplates:
    - metadata:
        name: dades
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: rutasnorte-rapida
        resources:
          requests:
            storage: 20Gi

El nom del PVC resultant segueix una regla fixa:

<nom-de-la-plantilla>-<nom-del-statefulset>-<ordinal>

Amb la plantilla anomenada dades i el StatefulSet postgres-reserves:

NAME                            STATUS   VOLUME        CAPACITY   STORAGECLASS         AGE
dades-postgres-reserves-0       Bound    pvc-8a1c...   20Gi       rutasnorte-rapida    9m
dades-postgres-reserves-1       Bound    pvc-3f77...   20Gi       rutasnorte-rapida    8m
dades-postgres-reserves-2       Bound    pvc-b204...   20Gi       rutasnorte-rapida    7m

Dins de la plantilla de pod, el volum es munta referint-se al nom de la plantilla, no al del PVC concret:

        volumeMounts:
          - name: dades          # coincideix amb volumeClaimTemplates[].metadata.name
            mountPath: /var/lib/postgresql/data
            subPath: pgdata

I ara el fet crucial, el que més disgustos evita i el que més disgustos causa segons com es miri:

Els PVC creats per volumeClaimTemplates NO s'esborren automàticament. Ni en reduir rèpliques, ni en esborrar el StatefulSet sencer.

És una decisió de disseny deliberada: les dades són el més valuós i Kubernetes no les llença per tu. Si esborres el StatefulSet postgres-reserves i el tornes a crear amb el mateix nom i la mateixa plantilla, els pods es reconnecten als volums que ja existien. Això converteix l'esborrat i la recreació del StatefulSet en una operació segura, cosa que aprofitarem a la migració.

Des de Kubernetes 1.27 hi ha un camp estable per modular aquest comportament:

spec:
  persistentVolumeClaimRetentionPolicy:
    whenDeleted: Retain    # Retain (per defecte) | Delete
    whenScaled: Retain     # Retain (per defecte) | Delete
Camp Valor Efecte
whenDeleted Retain En esborrar el StatefulSet, els PVC sobreviuen (per defecte)
whenDeleted Delete En esborrar el StatefulSet, s'esborren tots els seus PVC
whenScaled Retain En reduir rèpliques, el PVC del pod eliminat sobreviu (per defecte)
whenScaled Delete En reduir rèpliques, s'esborra el PVC del pod eliminat

Per a postgres-reserves deixarem tots dos en Retain, cosa que a més és coherent amb la classe rutasnorte-rapida, la política de reclamació de la qual és Retain. Per a una memòria cau com redis-cache, Delete és defensable.

  1. Ordre d'arrencada i aturada: podManagementPolicy

Per defecte (podManagementPolicy: OrderedReady), el StatefulSet crea el pod -0, espera que estigui Running i Ready, i només llavors crea el -1. En esborrar o reduir, va a l'inrevés: primer l'ordinal més alt.

Això és exactament el que necessita una base de dades on les rèpliques es connecten al primari: no té sentit arrencar la rèplica abans que el node del qual ha de copiar.

Però té un cost. Si el pod -0 no arriba mai a Ready —imatge mal escrita, sonda mal configurada, disc no disponible—, els altres no es creen mai. Un StatefulSet bloquejat a l'ordinal 0 és una de les incidències més habituals.

spec:
  podManagementPolicy: OrderedReady    # o Parallel
Política Creació Esborrat Quan fer-la servir
OrderedReady (per defecte) Seqüencial, esperant Ready Seqüencial descendent Bases de dades amb primari/rèplica, sistemes de consens amb arrencada ordenada
Parallel Tots alhora Tots alhora Membres simètrics que no depenen els uns dels altres i que només necessiten identitat i disc

Nota important: podManagementPolicy és immutable. No es pot canviar en un StatefulSet existent; cal esborrar-lo (amb --cascade=orphan si no vols tocar els pods) i recrear-lo.

  1. Estratègies d'actualització: RollingUpdate, partition i OnDelete

Quan canvies la plantilla de pod —per exemple, puges de postgres:16.4 a postgres:16.6—, el StatefulSet aplica la seva updateStrategy.

RollingUpdate (per defecte)

Actualitza els pods en ordre ordinal invers: primer -2, després -1, per últim -0. Espera que cadascun estigui Ready abans de passar al següent.

L'ordre invers és intencionat: si -0 és el primari, s'actualitza l'últim, minimitzant el temps en què el clúster està sense primari. I com que cada pod conserva el seu disc, el pod actualitzat torna amb les seves dades intactes.

El camp partition

Dins de RollingUpdate hi ha un camp que converteix l'actualització en un desplegament per fases:

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

La regla és simple: només s'actualitzen els pods l'ordinal dels quals és més gran o igual que partition. Amb partition: 2 i tres rèpliques, només s'actualitza postgres-reserves-2. Els pods -0 i -1 es queden amb la versió antiga encara que hagis canviat la plantilla.

Això permet un alliberament canari manual i controlat:

# 1. Preparar: encara no s'actualitza ningú
kubectl patch statefulset postgres-reserves -n rutas-norte-pre \
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":3}}}}'

# 2. Canviar la imatge (encara no passa res, partition == replicas)
kubectl set image statefulset/postgres-reserves -n rutas-norte-pre \
  postgres=postgres:16.6

# 3. Alliberar només l'ordinal 2 i observar-lo una estona
kubectl patch statefulset postgres-reserves -n rutas-norte-pre \
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":2}}}}'

# 4. Si tot va bé, baixar la partició fins a zero
kubectl patch statefulset postgres-reserves -n rutas-norte-pre \
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":0}}}}'

Si al pas 3 el pod -2 falla, n'hi ha prou amb tornar a pujar partition i revertir la imatge: els altres dos no s'han tocat mai.

OnDelete

spec:
  updateStrategy:
    type: OnDelete

El controlador no actualitza res automàticament. Els pods es recreen amb la plantilla nova només quan tu els esborres a mà. És el màxim control possible i es fa servir quan l'actualització requereix passos externs (migrar l'esquema, promoure manualment un primari, verificar la integritat de les dades entre pod i pod).

Estratègia Qui decideix quan Ordre Risc
RollingUpdate (partition: 0) Kubernetes Ordinal descendent Toca tots els pods sense supervisió
RollingUpdate amb partition: N Tu, baixant N Només ordinals ≥ N Baix; permet canari i marxa enrere
OnDelete Tu, esborrant pods El que triïs Oblidar pods sense actualitzar durant mesos

  1. Migració pràctica de postgres-reserves a StatefulSet

Aquest és l'exercici central de la lliçó: passar d'un Deployment d'una rèplica amb un PVC anomenat postgres-reserves-dades a un StatefulSet, sense perdre les dades.

L'obstacle és de nomenclatura. El StatefulSet exigirà un PVC anomenat dades-postgres-reserves-0, mentre que el que existeix es diu postgres-reserves-dades. Els PVC no es poden reanomenar. Però sí que podem reutilitzar el PersistentVolume subjacent, que és on hi ha les dades de veritat.

Pas 0: còpia de seguretat i finestra d'aturada

Abans de res, i amb el que hem après a 05-06:

kubectl create job -n rutas-norte-pre --from=cronjob/copia-reserves copia-premigracio
kubectl wait --for=condition=complete job/copia-premigracio -n rutas-norte-pre --timeout=600s

La migració implica aturada de servei. postgres-reserves és d'un sol node: no hi ha manera de fer-la en calent.

Pas 1: identificar el PV i protegir-lo

kubectl get pvc postgres-reserves-dades -n rutas-norte-pre \
  -o jsonpath='{.spec.volumeName}'
pvc-4c81ae30-9b7f-4a02-8d55-1f2a6c0e7b91
# Assegurar que el PV no s'esborra en desaparèixer el PVC
kubectl patch pv pvc-4c81ae30-9b7f-4a02-8d55-1f2a6c0e7b91 \
  -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'

Amb la classe rutasnorte-rapida ja és Retain, però comprovar-ho és gratis i equivocar-se és irreversible.

Pas 2: aturar el Deployment

kubectl scale deployment postgres-reserves -n rutas-norte-pre --replicas=0
kubectl wait --for=delete pod -l app=postgres-reserves -n rutas-norte-pre --timeout=300s

Pas 3: alliberar el PV i esborrar el PVC antic

kubectl delete pvc postgres-reserves-dades -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/postgres-reserves-dades

El PV queda en Released: conserva les dades però no admet un PVC nou fins que es netegi la referència a l'anterior.

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

Ara el PV torna a Available.

Pas 4: crear el PVC amb el nom que el StatefulSet espera

# k8s/entorns/pre/postgres-reserves-pvc-migrat.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: dades-postgres-reserves-0    # nom exacte que generarà el StatefulSet
  namespace: rutas-norte-pre
  labels:
    app: postgres-reserves
    app.kubernetes.io/part-of: rutas-norte
    entorn: pre
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: rutasnorte-rapida
  volumeName: pvc-4c81ae30-9b7f-4a02-8d55-1f2a6c0e7b91   # enllaça amb el PV existent
  resources:
    requests:
      storage: 20Gi

volumeName força l'aparellament amb aquell PV concret, en lloc d'aprovisionar-ne un de nou i buit.

Pas 5: el StatefulSet

# k8s/base/postgres-reserves-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres-reserves
  namespace: rutas-norte-pre
  labels:
    app: postgres-reserves
    app.kubernetes.io/part-of: rutas-norte
    entorn: pre
spec:
  serviceName: postgres-reserves-nodes   # obligatori: el Service headless
  replicas: 1
  podManagementPolicy: OrderedReady
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      partition: 0
  persistentVolumeClaimRetentionPolicy:
    whenDeleted: Retain
    whenScaled: Retain
  selector:
    matchLabels:
      app: postgres-reserves
      entorn: pre
  template:
    metadata:
      labels:
        app: postgres-reserves
        app.kubernetes.io/part-of: rutas-norte
        entorn: pre
    spec:
      serviceAccountName: postgres-reserves
      automountServiceAccountToken: false
      terminationGracePeriodSeconds: 60
      securityContext:
        fsGroup: 999
      containers:
        - name: postgres
          image: postgres:16.4
          ports:
            - name: postgresql
              containerPort: 5432
          env:
            - name: POSTGRES_DB
              value: reserves
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
            - name: POSTGRES_USER
              valueFrom:
                secretKeyRef:
                  name: postgres-reserves-credencials
                  key: usuari
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-reserves-credencials
                  key: password
          resources:
            requests:
              cpu: "1"
              memory: 2Gi
            limits:
              cpu: "1"
              memory: 2Gi
          volumeMounts:
            - name: dades
              mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
    - metadata:
        name: dades
        labels:
          app: postgres-reserves
          app.kubernetes.io/part-of: rutas-norte
          entorn: pre
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: rutasnorte-rapida
        resources:
          requests:
            storage: 20Gi

Punts que val la pena assenyalar d'aquest manifest:

  • serviceName apunta al Service headless, no al ClusterIP normal. És un error freqüent confondre'ls.
  • Els recursos mantenen la classe Guaranteed que vam fixar a 03-05: requests iguals a limits, 1 CPU i 2 GiB.
  • Es conserven la ServiceAccount dedicada i automountServiceAccountToken: false de 03-06.
  • PGDATA apunta a un subdirectori del punt de muntatge. PostgreSQL es nega a inicialitzar sobre un directori que contingui lost+found, cosa habitual en volums de blocs formatats.
  • El selector només fa servir app i entorn, segons la convenció del projecte. I recorda: spec.selector és immutable també aquí.

Pas 6: aplicar i verificar

kubectl apply -f k8s/entorns/pre/postgres-reserves-pvc-migrat.yaml
kubectl apply -f k8s/base/postgres-reserves-headless-service.yaml
kubectl apply -f k8s/base/postgres-reserves-statefulset.yaml

kubectl rollout status statefulset/postgres-reserves -n rutas-norte-pre
partitioned roll out complete: 1 new pods have been updated...
kubectl exec -n rutas-norte-pre postgres-reserves-0 -- \
  psql -U rutasnorte -d reserves -c 'SELECT count(*) FROM reserves;'
 count
-------
 18342
(1 row)

Les dades continuen allà. Ara ja es pot esborrar el Deployment antic:

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

  1. Escalat i què passa amb els PVC

Escalar un StatefulSet és idèntic en sintaxi a escalar un Deployment:

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

En pujar, el controlador crea -1, espera que estigui Ready, crea -2. Cadascun provoca la creació d'un PVC nou a partir de la plantilla, i amb rutasnorte-rapida i volumeBindingMode: WaitForFirstConsumer el volum s'aprovisiona a la zona on el scheduler hagi col·locat el pod.

En baixar a una rèplica:

kubectl scale statefulset postgres-reserves -n rutas-norte-pre --replicas=1
kubectl get pvc -n rutas-norte-pre -l app=postgres-reserves
NAME                          STATUS   VOLUME        CAPACITY   STORAGECLASS
dades-postgres-reserves-0     Bound    pvc-4c81...   20Gi       rutasnorte-rapida
dades-postgres-reserves-1     Bound    pvc-9d02...   20Gi       rutasnorte-rapida
dades-postgres-reserves-2     Bound    pvc-71bb...   20Gi       rutasnorte-rapida

Els pods -1 i -2 han desaparegut, però els seus PVC continuen allà (amb whenScaled: Retain). Conseqüències pràctiques:

  • Avantatge: si tornes a pujar a 3, els pods recuperen els seus discos amb les seves dades. L'escalat és reversible.
  • Cost: aquests volums continuen facturant. En un proveïdor cloud, tres discos SSD de 20 GiB costen el mateix estiguin muntats o no.

La neteja, quan n'estiguis segur, és manual i explícita:

kubectl delete pvc dades-postgres-reserves-1 dades-postgres-reserves-2 -n rutas-norte-pre

Amb la classe rutasnorte-rapida en Retain, el PV passarà a Released i continuarà conservant les dades fins que un administrador l'elimini. Aquesta fricció és deliberada.

  1. El límit honest: identitat no és replicació

Arribem al punt més important de la lliçó, i el que més es malinterpreta.

Hem escalat postgres-reserves a tres rèpliques. Això no és un clúster de PostgreSQL. Són tres processos de PostgreSQL, cadascun amb el seu disc buit i independent, sense cap relació entre ells. Si api-reserves escrivís contra el Service ClusterIP, les reserves caurien aleatòriament en una de les tres bases de dades i les dades quedarien escampades i incoherents.

El que un StatefulSet aporta i el que no:

El StatefulSet et dona El StatefulSet NO et dona
Nom estable per rèplica Elecció de qui és el primari
Disc propi per rèplica Còpia del contingut d'un disc a un altre
DNS individual per pod Commutació per error quan el primari cau
Ordre d'arrencada i aturada Redirecció d'escriptures al primari nou
Actualització ordenada i inversa Coherència de dades entre rèpliques
Reconnexió al mateix disc després d'un reinici Còpies de seguretat ni recuperació a un instant

La replicació l'ha d'implementar l'aplicació. En PostgreSQL això significa configurar la replicació per streaming, crear l'usuari de replicació, executar pg_basebackup a les rèpliques, mantenir un primary_conninfo correcte i, sobretot, tenir alguna cosa que decideixi i executi la promoció quan el primari mor.

Aquest "alguna cosa" es pot escriure a mà: initContainers que detecten l'ordinal, sondes que comproven el rol, un script que reescriu els Services. És exactament la feina que fan els operadors, i per això la lliçó 06-07 tancarà el mòdul substituint aquest StatefulSet artesanal per un operador de PostgreSQL que sí que sap fer tot això.

Regla pràctica per al teu dia a dia:

Si l'aplicació ja sap funcionar en clúster (Kafka, Cassandra, etcd, Elasticsearch, ZooKeeper), un StatefulSet ben configurat és suficient. Si no ho sap (PostgreSQL, MySQL, MongoDB en mode simple), un StatefulSet et dona la infraestructura però necessites un operador o un servei gestionat que aporti la lògica.

Mentrestant, postgres-reserves en producció continua amb una sola rèplica. Això és honest: una sola rèplica ben protegida i restaurable és millor que tres rèpliques que fingeixen ser un clúster.

  1. Segon exemple: redis-cache

redis-cache és un cas diferent i il·lustratiu. Guarda la disponibilitat de places, és una dada reconstruïble des de postgres-reserves, i perdre-la només provoca un pic temporal de consultes.

Mereix un StatefulSet? Depèn de què vulguis:

  • Redis com a memòria cau pura, sense persistència: un Deployment és perfectament vàlid. No hi ha estat a preservar.
  • Redis amb persistència AOF/RDB per no arrencar en fred després d'un reinici: StatefulSet, perquè volem que el pod retrobi el seu fitxer.

Rutas Norte tria la segona opció per a producció, perquè després d'un reinici en un pont festiu una memòria cau buida dispara la càrrega sobre la base de dades justament en el pitjor 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
    entorn: pro
spec:
  serviceName: redis-cache-nodes
  replicas: 3
  podManagementPolicy: Parallel     # els tres són simètrics, no hi ha ordre a respectar
  persistentVolumeClaimRetentionPolicy:
    whenDeleted: Delete             # és memòria cau: no val la pena conservar discos orfes
    whenScaled: Delete
  selector:
    matchLabels:
      app: redis-cache
      entorn: pro
  template:
    metadata:
      labels:
        app: redis-cache
        app.kubernetes.io/part-of: rutas-norte
        entorn: 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: dades
              mountPath: /data
  volumeClaimTemplates:
    - metadata:
        name: dades
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: rutasnorte-estandar
        resources:
          requests:
            storage: 2Gi

Diferències deliberades respecte de postgres-reserves, i el raonament que hi ha darrere de cadascuna:

Decisió postgres-reserves redis-cache Motiu
podManagementPolicy OrderedReady Parallel Les instàncies de memòria cau no depenen les unes de les altres; arrencar-les totes tres alhora és més ràpid
whenScaled / whenDeleted Retain Delete Les dades de memòria cau són reconstruïbles; les de reserves no
StorageClass rutasnorte-rapida rutasnorte-estandar La memòria cau viu en memòria; el disc només serveix per a l'arrencada en calent
QoS Guaranteed Burstable La memòria cau tolera contenció de CPU; la base de dades no

Aquí sí que tenim tres rèpliques de veritat útils, perquè cada instància atén la seva porció de trànsit de manera independent i api-reserves pot repartir consultes entre elles mitjançant el ClusterIP. No hi ha coherència a garantir: si una instància no té la clau, es consulta la base de dades i s'omple.

Errors Comuns i Consells

Confondre serviceName amb el Service normal. serviceName ha d'apuntar al Service headless. Si apunta a un ClusterIP normal, el StatefulSet arrenca sense queixar-se però els noms DNS per pod no funcionen, i les rèpliques que intentin localitzar-se entre si fallaran de manera desconcertant.

Oblidar de crear el Service headless. Kubernetes no valida que el Service de serviceName existeixi. Els pods arrenquen i tot sembla bé fins que alguna cosa intenta resoldre postgres-reserves-1.postgres-reserves-nodes... i obté NXDOMAIN.

Esperar que esborrar el StatefulSet esborri els discos. Amb la política per defecte (Retain) no ho fa. Després de diverses iteracions de proves acabes amb desenes de PVC orfes consumint quota. Revisa-ho periòdicament: kubectl get pvc -A | grep -v Bound no n'hi ha prou, perquè continuen Bound. Busca per etiqueta i compara amb els StatefulSets existents.

Escalar una base de dades i creure que ja està replicada. L'error conceptual més car d'aquesta lliçó. Tres pods de PostgreSQL amb tres discos buits són tres bases de dades diferents. Rellegeix l'apartat 9.

StatefulSet encallat a l'ordinal 0. Amb OrderedReady, si -0 no arriba a Ready, res no avança. Diagnòstic: kubectl describe pod postgres-reserves-0 i kubectl get events -n <ns> --sort-by=.lastTimestamp. Causes típiques: PVC en Pending perquè no hi ha node amb la classe d'emmagatzematge requerida, o sonda de disponibilitat mal configurada.

Canviar volumeClaimTemplates en un StatefulSet existent. És un camp pràcticament immutable: només es permet modificar resources.requests.storage (i des de 1.31 amb la porta de característiques corresponent). Qualsevol altre canvi provoca un error de validació. Si necessites canviar la StorageClass, cal migrar com a l'apartat 7.

Redimensionar el disc. No editis el volumeClaimTemplates esperant que es propagui als PVC ja existents. Cal ampliar cada PVC individualment (kubectl patch pvc dades-postgres-reserves-0 ...) tal com vam veure a 05-05, i actualitzar la plantilla perquè les rèpliques futures neixin amb la mida nova.

Consell: fes servir sempre subPath amb PostgreSQL. Els volums de blocs formatats en ext4 porten un directori lost+found a l'arrel, i initdb es nega a treballar sobre un directori no buit. Muntar a /var/lib/postgresql/data amb PGDATA=/var/lib/postgresql/data/pgdata resol el problema netament.

Consell: kubectl delete statefulset X --cascade=orphan. Esborra el StatefulSet deixant els pods vius. És la maniobra per canviar camps immutables (selector, podManagementPolicy, serviceName) sense interrompre el servei: esborres l'objecte, apliques el manifest corregit i el controlador adopta els pods existents, igual que vam veure amb els ReplicaSets a 02-02.

Exercicis

Exercici 1: identitat i persistència de l'ordinal

Al namespace rutas-norte-dev del teu minikube (perfil rutas-norte), desplega un StatefulSet anomenat notes-demo amb 3 rèpliques de busybox:1.36, un Service headless anomenat notes-demo-nodes i un volumeClaimTemplates de 100 MiB amb la classe rutasnorte-estandar, muntat a /dades. Cada pod ha d'escriure el seu nom a /dades/qui-soc.txt en arrencar i després dormir.

Després:

  1. Comprova els noms dels pods i dels PVC generats.
  2. Escriu un contingut addicional al pod notes-demo-1.
  3. Esborra aquest pod i verifica que el pod nou recupera el fitxer.

Exercici 2: desplegament per fases amb partition

Sobre el StatefulSet de l'exercici 1, canvia la imatge a busybox:1.37 fent servir partition de manera que només s'actualitzi el pod d'ordinal 2. Verifica que notes-demo-0 i notes-demo-1 continuen amb la imatge antiga. Després completa l'actualització.

Exercici 3: escalat i destí dels PVC

Amb el StatefulSet de l'exercici 1 en 3 rèpliques:

  1. Redueix-lo a 1 rèplica i comprova quants PVC queden.
  2. Torna a pujar a 3 i verifica que notes-demo-2 recupera el seu fitxer, no un de nou.
  3. Configura persistentVolumeClaimRetentionPolicy.whenScaled: Delete, redueix un altre cop a 1 i comprova la diferència.
  4. Neteja-ho tot.

Solucions

Solució 1

# /tmp/notes-demo.yaml
apiVersion: v1
kind: Service
metadata:
  name: notes-demo-nodes
  namespace: rutas-norte-dev
  labels:
    app: notes-demo
    entorn: dev
spec:
  clusterIP: None
  selector:
    app: notes-demo
    entorn: dev
  ports:
    - name: fictici
      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
    entorn: dev
spec:
  serviceName: notes-demo-nodes
  replicas: 3
  selector:
    matchLabels:
      app: notes-demo
      entorn: dev
  template:
    metadata:
      labels:
        app: notes-demo
        app.kubernetes.io/part-of: rutas-norte
        entorn: dev
    spec:
      automountServiceAccountToken: false
      containers:
        - name: escriptor
          image: busybox:1.36
          command:
            - sh
            - -c
            - 'echo "soc $(hostname)" >> /dades/qui-soc.txt; sleep 86400'
          resources:
            requests:
              cpu: 10m
              memory: 16Mi
            limits:
              cpu: 50m
              memory: 32Mi
          volumeMounts:
            - name: dades
              mountPath: /dades
  volumeClaimTemplates:
    - metadata:
        name: dades
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: rutasnorte-estandar
        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
dades-notes-demo-0    Bound    pvc-a1b2...   100Mi      rutasnorte-estandar
dades-notes-demo-1    Bound    pvc-c3d4...   100Mi      rutasnorte-estandar
dades-notes-demo-2    Bound    pvc-e5f6...   100Mi      rutasnorte-estandar

Els PVC segueixen el patró <plantilla>-<statefulset>-<ordinal>, tal com havíem anticipat.

# 2. Escriure contingut addicional
kubectl exec -n rutas-norte-dev notes-demo-1 -- \
  sh -c 'echo "reserva fictícia 4471" >> /dades/qui-soc.txt'

# 3. Esborrar el pod i esperar el substitut
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 /dades/qui-soc.txt
soc notes-demo-1
reserva fictícia 4471
soc notes-demo-1

El fitxer conserva les dues línies anteriors i n'afegeix una tercera de la nova arrencada. El pod es diu igual i ha retrobat el seu disc: identitat estable i emmagatzematge estable, exactament el que un Deployment no pot prometre.

Solució 2

# Fixar la partició per sobre de l'ordinal màxim abans de tocar res
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 escriptor=busybox:1.37

# Res no s'ha mogut: partition (3) > ordinal màxim (2)
kubectl get pods -n rutas-norte-dev -l app=notes-demo \
  -o custom-columns=POD:.metadata.name,IMATGE:.spec.containers[0].image
POD            IMATGE
notes-demo-0   busybox:1.36
notes-demo-1   busybox:1.36
notes-demo-2   busybox:1.36
# Alliberar només l'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,IMATGE:.spec.containers[0].image
POD            IMATGE
notes-demo-0   busybox:1.36
notes-demo-1   busybox:1.36
notes-demo-2   busybox:1.37

Només l'ordinal més alt s'ha actualitzat. Si això fos una base de dades amb el primari a -0, hauríem provat la versió nova a la rèplica menys crítica.

# Completar l'actualització
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

L'ordre observat als esdeveniments serà -1 primer i -0 després: sempre descendent.

Solució 3

# 1. Reduir a 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/dades-notes-demo-0 Bound    pvc-a1b2...   100Mi
persistentvolumeclaim/dades-notes-demo-1 Bound    pvc-c3d4...   100Mi
persistentvolumeclaim/dades-notes-demo-2 Bound    pvc-e5f6...   100Mi

Un pod, tres PVC. És el comportament per defecte (whenScaled: Retain) i la causa habitual de factures d'emmagatzematge inexplicables.

# 2. Tornar a pujar i comprovar que -2 recupera el seu fitxer
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 /dades/qui-soc.txt
soc notes-demo-2
soc notes-demo-2

Dues línies: la de l'arrencada original i la de la nova. El disc és el mateix d'abans.

# 3. Canviar la política i reduir de nou
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
dades-notes-demo-0    Bound    pvc-a1b2...   100Mi      rutasnorte-estandar

Ara els PVC de -1 i -2 s'han esborrat en desaparèixer els seus pods. Per a una memòria cau és el que es desitja; per a postgres-reserves seria una manera ràpida de perdre les reserves d'un any.

# 4. Neteja
kubectl delete -f /tmp/notes-demo.yaml
kubectl delete pvc -n rutas-norte-dev -l app=notes-demo

Conclusió

El StatefulSet resol el sostre que arrossegàvem des de la lliçó 05-03. Amb ell, cada rèplica té nom ordinal estable, el seu propi PVC generat des de volumeClaimTemplates i un nom DNS individual gràcies al Service headless que serviceName exigeix. L'arrencada i l'aturada són ordenades (OrderedReady) o simultànies (Parallel) segons convingui, i les actualitzacions baixen en ordre invers amb la possibilitat de frenar-les per fases mitjançant partition.

Hem migrat postgres-reserves de Deployment a StatefulSet reutilitzant el PersistentVolume existent —el truc és alliberar el claimRef i crear un PVC amb el nom exacte que el controlador espera— i hem convertit redis-cache en un StatefulSet de tres rèpliques amb decisions oposades i ben raonades a cada camp.

I hem estat honestos amb el límit: el StatefulSet aporta infraestructura, no lògica. PostgreSQL no es replica pel fet d'estar dins d'un. Aquesta mancança és la que motivarà els operadors de la lliçó 06-07, que tancaran aquest mòdul substituint el nostre StatefulSet artesanal per programari que sap promoure un primari, crear rèpliques de lectura i recuperar a un instant concret.

Abans d'això hi ha altres maneres d'executar càrregues de treball que encara no coneixem. Fins ara totes les nostres càrregues es distribueixen on el scheduler vulgui i en el nombre de còpies que demanem. Però hi ha una categoria de procés —els agents d'infraestructura: recol·lectors de registres, exportadors de mètriques, connectors de xarxa— per als quals "quantes rèpliques" és la pregunta equivocada: la resposta correcta sempre és una per node, ni una més ni una menys, i automàticament als nodes que s'afegeixin demà. Aquest és l'objecte de la lliçó següent: els DaemonSets.

Curs de Kubernetes

Mòdul 1: Introducció a Kubernetes

Mòdul 2: Components Principals de Kubernetes

Mòdul 3: Gestió de Configuració i Secrets

Mòdul 4: Xarxes a Kubernetes

Mòdul 5: Emmagatzematge a Kubernetes

Mòdul 6: Conceptes Avançats de Kubernetes

Mòdul 7: Monitoratge i Registre

Mòdul 8: Seguretat a Kubernetes

Mòdul 9: Escalat i Rendiment

Mòdul 10: Ecosistema i Eines de Kubernetes

Mòdul 11: Estudis de Cas i Aplicacions del Món Real

Mòdul 12: Preparació per a la Certificació de Kubernetes

© Copyright 2026. Tots els drets reservats