Cerramos el módulo de almacenamiento con una promesa pendiente. La plataforma Rutas Norte ya tiene copias de seguridad verificadas y las reservas sobreviven al borrado de un pod, pero postgres-reservas sigue siendo un Deployment de una sola réplica con strategy: Recreate. Esa configuración no es un descuido: es la única que funciona, porque un Deployment aplica la misma plantilla de pod —y por tanto el mismo PersistentVolumeClaim— a todas sus réplicas. Si subimos a dos, ambas intentan montar el mismo volumen ReadWriteOnce y la segunda se queda esperando eternamente.

El problema de fondo es que un Deployment trata a sus pods como ganado intercambiable: cualquiera vale, no importa el nombre, no importa el orden, no importa qué disco toque. Para una base de datos eso es exactamente lo contrario de lo que necesitamos. Una réplica de PostgreSQL necesita saber quién es, necesita su propio disco y necesita arrancar en un orden concreto respecto a las demás.

El StatefulSet es el controlador que aporta esas tres garantías. En esta lección lo estudiaremos a fondo, migraremos postgres-reservas de Deployment a StatefulSet sin perder el volumen existente, y terminaremos con una advertencia honesta que marcará el resto del módulo: un StatefulSet da identidad y disco, pero no replica datos por ti.

Contenido

  1. Qué garantiza un StatefulSet que un Deployment no puede dar
  2. Identidad estable: nombres ordinales y DNS por pod
  3. serviceName y el Service headless
  4. volumeClaimTemplates: un disco por réplica
  5. Orden de arranque y parada: podManagementPolicy
  6. Estrategias de actualización: RollingUpdate, partition y OnDelete
  7. Migración práctica de postgres-reservas a StatefulSet
  8. Escalado y qué ocurre con los PVC
  9. El límite honesto: identidad no es replicación
  10. Segundo ejemplo: redis-cache

  1. Qué garantiza un StatefulSet que un Deployment no puede dar

Un StatefulSet es, como el Deployment, un controlador que mantiene un número deseado de pods a partir de una plantilla. La diferencia está en qué promete sobre esos pods.

Aspecto Deployment StatefulSet
Nombre de los pods Aleatorio: api-reservas-7d9f8-x2k4p Ordinal y fijo: postgres-reservas-0
Identidad tras reinicio Se pierde (nombre nuevo) Se conserva (mismo nombre y mismo disco)
Almacenamiento Uno compartido por toda la plantilla Uno propio por réplica, creado automáticamente
Nombre DNS individual No Sí, con Service headless
Orden de creación Todos a la vez -0, luego -1, luego -2 (por defecto)
Orden de borrado Arbitrario Ordinal descendente: -2, -1, -0
Escalado a cero y vuelta Pods completamente nuevos Mismos nombres, mismos discos
Caso de uso típico Procesos sin estado Bases de datos, colas, sistemas de consenso

Las tres garantías, dichas con precisión:

  • Identidad de red estable. Cada pod recibe un nombre ordinal derivado del nombre del StatefulSet y un hostname que coincide con él. Ese nombre no cambia aunque el pod se reinicie, se recree o acabe en otro nodo.
  • Almacenamiento estable por réplica. Cada pod obtiene su propio PVC generado a partir de una plantilla. El pod postgres-reservas-0 siempre monta el volumen de postgres-reservas-0, nunca el de -1.
  • Despliegue y escalado ordenados. Los pods se crean y se eliminan en secuencia, esperando a que cada uno esté listo antes de continuar (si así se configura).

Una advertencia previa que ahorra disgustos: usar un StatefulSet no hace que una aplicación sea distribuida. Si tu aplicación no sabe qué hacer con tres copias de sí misma, tres réplicas en un StatefulSet solo te dan tres instancias aisladas que se pisan. Volveremos a esto en el apartado 9.

  1. Identidad estable: nombres ordinales y DNS por pod

Un StatefulSet llamado postgres-reservas con replicas: 3 produce exactamente estos pods:

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

El sufijo es un ordinal que empieza en cero y es contiguo. No hay saltos: si existe -2, existen -0 y -1. Si borras a mano postgres-reservas-1, el controlador lo recrea con exactamente el mismo nombre y le vuelve a montar exactamente el mismo PVC. Desde el punto de vista de la aplicación, el pod "se ha reiniciado", no "ha sido sustituido por otro".

Ese ordinal se refleja en tres sitios que la aplicación puede consultar:

  • El nombre del pod (metadata.name), accesible por Downward API como vimos en el módulo 3.
  • El hostname dentro del contenedor, que coincide con el nombre del pod.
  • El subdominio que forma el registro DNS, si hay Service headless.
# Comprobar el hostname desde dentro del pod
kubectl exec -n rutas-norte-dev postgres-reservas-1 -- hostname
postgres-reservas-1

Esto es lo que permite que un script de arranque decida su propio papel:

# Fragmento típico de un entrypoint de base de datos replicada
ORDINAL="${HOSTNAME##*-}"      # de "postgres-reservas-1" extrae "1"
if [ "$ORDINAL" = "0" ]; then
  echo "Arranco como primario"
else
  echo "Arranco como réplica de postgres-reservas-0"
fi

Ese patrón —deducir el rol del ordinal— es la base de muchas imágenes de bases de datos preparadas para Kubernetes. Es también, como veremos, frágil: si el pod -0 muere y otro debe tomar el relevo, el ordinal ya no dice la verdad.

  1. serviceName y el Service headless

El campo serviceName es obligatorio en un StatefulSet y apunta al nombre de un Service headless, aquel que en la lección 04-02 definimos con clusterIP: None y del que dijimos que su uso real llegaría aquí.

Recordemos por qué. Un Service normal tiene una IP virtual y reparte el tráfico entre todos los pods que casan con su selector. Eso es justo lo que no queremos para una base de datos replicada: si escribo en "la IP del servicio", no sé si estoy escribiendo en el primario o en una réplica de solo lectura.

Un Service headless no crea IP virtual ni reglas de balanceo. En su lugar, CoreDNS publica un registro por cada pod:

# k8s/base/postgres-reservas-headless-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: postgres-reservas-nodos
  namespace: rutas-norte-dev
  labels:
    app: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  clusterIP: None          # <- esto lo convierte en headless
  selector:
    app: postgres-reservas # solo app y entorno entran en selectores
    entorno: dev
  ports:
    - name: postgresql
      port: 5432
      targetPort: 5432

Con este Service, cada pod del StatefulSet obtiene un FQDN propio con la forma:

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

Es decir:

postgres-reservas-0.postgres-reservas-nodos.rutas-norte-dev.svc.cluster.local
postgres-reservas-1.postgres-reservas-nodos.rutas-norte-dev.svc.cluster.local
postgres-reservas-2.postgres-reservas-nodos.rutas-norte-dev.svc.cluster.local

Cada uno resuelve a la IP concreta de ese pod, no a una IP virtual. Podemos comprobarlo:

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

Name:      postgres-reservas-1.postgres-reservas-nodos.rutas-norte-dev.svc.cluster.local
Address:   10.244.1.37

Además, consultar el nombre del Service headless devuelve todas las IP de los pods listos, lo que sirve para descubrir la lista de miembros:

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

En la práctica se usan dos Services sobre el mismo StatefulSet:

Service Tipo Para qué
postgres-reservas-nodos Headless (clusterIP: None) Identidad DNS por pod; obligatorio para el StatefulSet
postgres-reservas ClusterIP normal Punto de entrada estable para api-reservas y worker-notificaciones

El ClusterIP normal es el que ya conocen los demás componentes desde el módulo 4; no hace falta tocar la configuración de api-reservas.

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

  1. volumeClaimTemplates: un disco por réplica

Aquí está la pieza que rompe el techo que detectamos en 05-03. En lugar de referenciar un PVC ya existente, el StatefulSet declara una plantilla de PVC, y el controlador crea uno por cada réplica.

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

El nombre del PVC resultante sigue una regla fija:

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

Con la plantilla llamada datos y el StatefulSet postgres-reservas:

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

Dentro de la plantilla de pod, el volumen se monta refiriéndose al nombre de la plantilla, no al del PVC concreto:

        volumeMounts:
          - name: datos          # coincide con volumeClaimTemplates[].metadata.name
            mountPath: /var/lib/postgresql/data
            subPath: pgdata

Y ahora el hecho crucial, el que más disgustos evita y el que más disgustos causa según se mire:

Los PVC creados por volumeClaimTemplates NO se borran automáticamente. Ni al reducir réplicas, ni al borrar el StatefulSet entero.

Es una decisión de diseño deliberada: los datos son lo más valioso y Kubernetes no los tira por ti. Si borras el StatefulSet postgres-reservas y lo vuelves a crear con el mismo nombre y la misma plantilla, los pods se reconectan a los volúmenes que ya existían. Eso convierte al borrado y recreación del StatefulSet en una operación segura, algo que aprovecharemos en la migración.

Desde Kubernetes 1.27 existe un campo estable para modular ese comportamiento:

spec:
  persistentVolumeClaimRetentionPolicy:
    whenDeleted: Retain    # Retain (por defecto) | Delete
    whenScaled: Retain     # Retain (por defecto) | Delete
Campo Valor Efecto
whenDeleted Retain Al borrar el StatefulSet, los PVC sobreviven (por defecto)
whenDeleted Delete Al borrar el StatefulSet, se borran todos sus PVC
whenScaled Retain Al reducir réplicas, el PVC del pod eliminado sobrevive (por defecto)
whenScaled Delete Al reducir réplicas, se borra el PVC del pod eliminado

Para postgres-reservas dejaremos ambos en Retain, que además es coherente con la clase rutasnorte-rapida, cuya política de reclamación es Retain. Para una caché como redis-cache, Delete es defendible.

  1. Orden de arranque y parada: podManagementPolicy

Por defecto (podManagementPolicy: OrderedReady), el StatefulSet crea el pod -0, espera a que esté Running y Ready, y solo entonces crea el -1. Al borrar o reducir, va al revés: primero el ordinal más alto.

Esto es exactamente lo que necesita una base de datos donde las réplicas se conectan al primario: no tiene sentido arrancar la réplica antes que el nodo del que va a copiar.

Pero tiene un coste. Si el pod -0 no llega nunca a Ready —imagen mal escrita, sonda mal configurada, disco no disponible—, los demás no se crean nunca. Un StatefulSet bloqueado en el ordinal 0 es una de las incidencias más habituales.

spec:
  podManagementPolicy: OrderedReady    # o Parallel
Política Creación Borrado Cuándo usarla
OrderedReady (por defecto) Secuencial, esperando Ready Secuencial descendente Bases de datos con primario/réplica, sistemas de consenso con arranque ordenado
Parallel Todos a la vez Todos a la vez Miembros simétricos que no dependen unos de otros y que solo necesitan identidad y disco

Nota importante: podManagementPolicy es inmutable. No se puede cambiar en un StatefulSet existente; hay que borrarlo (con --cascade=orphan si no quieres tocar los pods) y recrearlo.

  1. Estrategias de actualización: RollingUpdate, partition y OnDelete

Cuando cambias la plantilla de pod —por ejemplo, subes de postgres:16.4 a postgres:16.6—, el StatefulSet aplica su updateStrategy.

RollingUpdate (por defecto)

Actualiza los pods en orden ordinal inverso: primero -2, luego -1, por último -0. Espera a que cada uno esté Ready antes de pasar al siguiente.

El orden inverso es intencionado: si -0 es el primario, se actualiza el último, minimizando el tiempo en que el clúster está sin primario. Y como cada pod conserva su disco, el pod actualizado vuelve con sus datos intactos.

El campo partition

Dentro de RollingUpdate hay un campo que convierte la actualización en un despliegue por fases:

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

La regla es simple: solo se actualizan los pods cuyo ordinal es mayor o igual que partition. Con partition: 2 y tres réplicas, solo se actualiza postgres-reservas-2. Los pods -0 y -1 se quedan con la versión antigua aunque hayas cambiado la plantilla.

Esto permite una liberación canaria manual y controlada:

# 1. Preparar: nadie se actualiza todavía
kubectl patch statefulset postgres-reservas -n rutas-norte-pre \
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":3}}}}'

# 2. Cambiar la imagen (no pasa nada aún, partition == replicas)
kubectl set image statefulset/postgres-reservas -n rutas-norte-pre \
  postgres=postgres:16.6

# 3. Liberar solo el ordinal 2 y observarlo un rato
kubectl patch statefulset postgres-reservas -n rutas-norte-pre \
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":2}}}}'

# 4. Si todo va bien, bajar la partición hasta cero
kubectl patch statefulset postgres-reservas -n rutas-norte-pre \
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":0}}}}'

Si en el paso 3 el pod -2 falla, basta con volver a subir partition y revertir la imagen: los otros dos nunca se tocaron.

OnDelete

spec:
  updateStrategy:
    type: OnDelete

El controlador no actualiza nada automáticamente. Los pods se recrean con la nueva plantilla solo cuando tú los borras a mano. Es el máximo control posible y se usa cuando la actualización requiere pasos externos (migrar el esquema, promover manualmente un primario, verificar la integridad de los datos entre pod y pod).

Estrategia Quién decide cuándo Orden Riesgo
RollingUpdate (partition: 0) Kubernetes Ordinal descendente Toca todos los pods sin supervisión
RollingUpdate con partition: N Tú, bajando N Solo ordinales ≥ N Bajo; permite canario y vuelta atrás
OnDelete Tú, borrando pods El que elijas Olvidar pods sin actualizar durante meses

  1. Migración práctica de postgres-reservas a StatefulSet

Este es el ejercicio central de la lección: pasar de un Deployment de una réplica con un PVC llamado postgres-reservas-datos a un StatefulSet, sin perder los datos.

El obstáculo es de nomenclatura. El StatefulSet exigirá un PVC llamado datos-postgres-reservas-0, mientras que el que existe se llama postgres-reservas-datos. Los PVC no se pueden renombrar. Pero sí podemos reutilizar el PersistentVolume subyacente, que es donde están los datos de verdad.

Paso 0: copia de seguridad y ventana de parada

Antes de nada, y con lo aprendido en 05-06:

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

La migración implica parada de servicio. postgres-reservas es de un solo nodo: no hay forma de hacerla en caliente.

Paso 1: identificar el PV y protegerlo

kubectl get pvc postgres-reservas-datos -n rutas-norte-pre \
  -o jsonpath='{.spec.volumeName}'
pvc-4c81ae30-9b7f-4a02-8d55-1f2a6c0e7b91
# Asegurar que el PV no se borra al desaparecer el PVC
kubectl patch pv pvc-4c81ae30-9b7f-4a02-8d55-1f2a6c0e7b91 \
  -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'

Con la clase rutasnorte-rapida ya es Retain, pero comprobarlo es gratis y equivocarse es irreversible.

Paso 2: parar el Deployment

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

Paso 3: liberar el PV y borrar el PVC antiguo

kubectl delete pvc postgres-reservas-datos -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-reservas-datos

El PV queda en Released: conserva los datos pero no admite un nuevo PVC hasta que se limpie la referencia al anterior.

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

Ahora el PV vuelve a Available.

Paso 4: crear el PVC con el nombre que el StatefulSet espera

# k8s/entornos/pre/postgres-reservas-pvc-migrado.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: datos-postgres-reservas-0    # nombre exacto que generará el StatefulSet
  namespace: rutas-norte-pre
  labels:
    app: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: pre
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: rutasnorte-rapida
  volumeName: pvc-4c81ae30-9b7f-4a02-8d55-1f2a6c0e7b91   # enlaza con el PV existente
  resources:
    requests:
      storage: 20Gi

volumeName fuerza el emparejamiento con ese PV concreto, en lugar de aprovisionar uno nuevo y vacío.

Paso 5: el StatefulSet

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

Puntos que merece la pena señalar de este manifiesto:

  • serviceName apunta al Service headless, no al ClusterIP normal. Es un error frecuente confundirlos.
  • Los recursos mantienen la clase Guaranteed que fijamos en 03-05: requests iguales a limits, 1 CPU y 2 GiB.
  • Se conservan la ServiceAccount dedicada y automountServiceAccountToken: false de 03-06.
  • PGDATA apunta a un subdirectorio del punto de montaje. PostgreSQL se niega a inicializar sobre un directorio que contenga lost+found, algo habitual en volúmenes de bloque formateados.
  • El selector solo usa app y entorno, según la convención del proyecto. Y recuerda: spec.selector es inmutable también aquí.

Paso 6: aplicar y verificar

kubectl apply -f k8s/entornos/pre/postgres-reservas-pvc-migrado.yaml
kubectl apply -f k8s/base/postgres-reservas-headless-service.yaml
kubectl apply -f k8s/base/postgres-reservas-statefulset.yaml

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

Los datos siguen ahí. Ahora se puede borrar el Deployment antiguo:

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

  1. Escalado y qué ocurre con los PVC

Escalar un StatefulSet es idéntico en sintaxis a escalar un Deployment:

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

Al subir, el controlador crea -1, espera a que esté Ready, crea -2. Cada uno provoca la creación de un PVC nuevo a partir de la plantilla, y con rutasnorte-rapida y volumeBindingMode: WaitForFirstConsumer el volumen se aprovisiona en la zona donde el scheduler haya colocado el pod.

Al bajar a una réplica:

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

Los pods -1 y -2 han desaparecido, pero sus PVC siguen ahí (con whenScaled: Retain). Consecuencias prácticas:

  • Ventaja: si vuelves a subir a 3, los pods recuperan sus discos con sus datos. El escalado es reversible.
  • Coste: esos volúmenes siguen facturando. En un proveedor cloud, tres discos SSD de 20 GiB cuestan lo mismo estén montados o no.

La limpieza, cuando estés seguro, es manual y explícita:

kubectl delete pvc datos-postgres-reservas-1 datos-postgres-reservas-2 -n rutas-norte-pre

Con la clase rutasnorte-rapida en Retain, el PV pasará a Released y seguirá conservando los datos hasta que un administrador lo elimine. Esa fricción es deliberada.

  1. El límite honesto: identidad no es replicación

Llegamos al punto más importante de la lección, y el que más se malinterpreta.

Hemos escalado postgres-reservas a tres réplicas. Eso no es un clúster de PostgreSQL. Son tres procesos de PostgreSQL, cada uno con su disco vacío e independiente, sin ninguna relación entre ellos. Si api-reservas escribiera contra el Service ClusterIP, las reservas caerían aleatoriamente en una de las tres bases de datos y los datos quedarían desperdigados e incoherentes.

Lo que un StatefulSet aporta y lo que no:

El StatefulSet te da El StatefulSet NO te da
Nombre estable por réplica Elección de quién es el primario
Disco propio por réplica Copia del contenido de un disco a otro
DNS individual por pod Conmutación por error cuando el primario cae
Orden de arranque y parada Redirección de escrituras al primario nuevo
Actualización ordenada e inversa Coherencia de datos entre réplicas
Reconexión al mismo disco tras reinicio Copias de seguridad ni recuperación a un instante

La replicación la tiene que implementar la aplicación. En PostgreSQL eso significa configurar la replicación por streaming, crear el usuario de replicación, ejecutar pg_basebackup en las réplicas, mantener un primary_conninfo correcto y, sobre todo, tener algo que decida y ejecute la promoción cuando el primario muere.

Ese "algo" se puede escribir a mano: initContainers que detectan el ordinal, sondas que comprueban el rol, un script que reescribe los Services. Es exactamente el trabajo que hacen los operadores, y por eso la lección 06-07 cerrará el módulo sustituyendo este StatefulSet artesanal por un operador de PostgreSQL que sí sabe hacer todo eso.

Regla práctica para tu día a día:

Si la aplicación ya sabe funcionar en clúster (Kafka, Cassandra, etcd, Elasticsearch, ZooKeeper), un StatefulSet bien configurado es suficiente. Si no lo sabe (PostgreSQL, MySQL, MongoDB en modo simple), un StatefulSet te da la infraestructura pero necesitas un operador o un servicio gestionado que aporte la lógica.

Mientras tanto, postgres-reservas en producción sigue con una sola réplica. Eso es honesto: una sola réplica bien respaldada y restaurable es mejor que tres réplicas que fingen ser un clúster.

  1. Segundo ejemplo: redis-cache

redis-cache es un caso distinto e ilustrativo. Guarda la disponibilidad de plazas, es un dato reconstruible desde postgres-reservas, y perderlo solo provoca un pico temporal de consultas.

¿Merece un StatefulSet? Depende de qué quieras:

  • Redis como caché pura, sin persistencia: un Deployment es perfectamente válido. No hay estado que preservar.
  • Redis con persistencia AOF/RDB para no arrancar en frío tras un reinicio: StatefulSet, porque queremos que el pod reencuentre su fichero.

Rutas Norte elige la segunda opción para producción, porque tras un reinicio en un puente festivo una caché vacía dispara la carga sobre la base de datos justo en el peor momento.

# 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
    entorno: pro
spec:
  serviceName: redis-cache-nodos
  replicas: 3
  podManagementPolicy: Parallel     # los tres son simétricos, no hay orden que respetar
  persistentVolumeClaimRetentionPolicy:
    whenDeleted: Delete             # es caché: no vale la pena conservar discos huérfanos
    whenScaled: Delete
  selector:
    matchLabels:
      app: redis-cache
      entorno: pro
  template:
    metadata:
      labels:
        app: redis-cache
        app.kubernetes.io/part-of: rutas-norte
        entorno: 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: datos
              mountPath: /data
  volumeClaimTemplates:
    - metadata:
        name: datos
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: rutasnorte-estandar
        resources:
          requests:
            storage: 2Gi

Diferencias deliberadas respecto a postgres-reservas, y el razonamiento detrás de cada una:

Decisión postgres-reservas redis-cache Motivo
podManagementPolicy OrderedReady Parallel Las instancias de caché no dependen unas de otras; arrancar las tres a la vez es más rápido
whenScaled / whenDeleted Retain Delete Los datos de caché son reconstruibles; los de reservas no
StorageClass rutasnorte-rapida rutasnorte-estandar La caché vive en memoria; el disco solo sirve para el arranque en caliente
QoS Guaranteed Burstable La caché tolera contención de CPU; la base de datos no

Aquí sí tenemos tres réplicas de verdad útiles, porque cada instancia atiende su porción de tráfico de forma independiente y api-reservas puede repartir consultas entre ellas mediante el ClusterIP. No hay coherencia que garantizar: si una instancia no tiene la clave, se consulta la base de datos y se rellena.

Errores Comunes y Consejos

Confundir serviceName con el Service normal. serviceName debe apuntar al Service headless. Si apunta a un ClusterIP normal, el StatefulSet arranca sin quejarse pero los nombres DNS por pod no funcionan, y las réplicas que intenten localizarse entre sí fallarán de forma desconcertante.

Olvidar crear el Service headless. Kubernetes no valida que el Service de serviceName exista. Los pods arrancan y todo parece bien hasta que algo intenta resolver postgres-reservas-1.postgres-reservas-nodos... y obtiene NXDOMAIN.

Esperar que borrar el StatefulSet borre los discos. Con la política por defecto (Retain) no lo hace. Después de varias iteraciones de pruebas acabas con decenas de PVC huérfanos consumiendo cuota. Revisa periódicamente: kubectl get pvc -A | grep -v Bound no basta, porque siguen Bound. Busca por etiqueta y compara con los StatefulSets existentes.

Escalar una base de datos y creer que ya está replicada. El error conceptual más caro de esta lección. Tres pods de PostgreSQL con tres discos vacíos son tres bases de datos distintas. Relee el apartado 9.

StatefulSet atascado en el ordinal 0. Con OrderedReady, si -0 no llega a Ready, nada avanza. Diagnóstico: kubectl describe pod postgres-reservas-0 y kubectl get events -n <ns> --sort-by=.lastTimestamp. Causas típicas: PVC en Pending porque no hay nodo con la clase de almacenamiento requerida, o sonda de disponibilidad mal configurada.

Cambiar volumeClaimTemplates en un StatefulSet existente. Es un campo prácticamente inmutable: solo se permite modificar resources.requests.storage (y desde 1.31 con la puerta de características correspondiente). Cualquier otro cambio provoca un error de validación. Si necesitas cambiar la StorageClass, hay que migrar como en el apartado 7.

Redimensionar el disco. No edites el volumeClaimTemplates esperando que se propague a los PVC ya existentes. Hay que ampliar cada PVC individualmente (kubectl patch pvc datos-postgres-reservas-0 ...) tal como vimos en 05-05, y actualizar la plantilla para que las réplicas futuras nazcan con el tamaño nuevo.

Consejo: usa siempre subPath con PostgreSQL. Los volúmenes de bloque formateados en ext4 traen un directorio lost+found en la raíz, y initdb se niega a trabajar sobre un directorio no vacío. Montar en /var/lib/postgresql/data con PGDATA=/var/lib/postgresql/data/pgdata resuelve el problema limpiamente.

Consejo: kubectl delete statefulset X --cascade=orphan. Borra el StatefulSet dejando los pods vivos. Es la maniobra para cambiar campos inmutables (selector, podManagementPolicy, serviceName) sin interrumpir el servicio: borras el objeto, aplicas el manifiesto corregido y el controlador adopta los pods existentes, igual que vimos con los ReplicaSets en 02-02.

Ejercicios

Ejercicio 1: identidad y persistencia del ordinal

En el namespace rutas-norte-dev de tu minikube (perfil rutas-norte), despliega un StatefulSet llamado notas-demo con 3 réplicas de busybox:1.36, un Service headless llamado notas-demo-nodos y un volumeClaimTemplates de 100 MiB con la clase rutasnorte-estandar, montado en /datos. Cada pod debe escribir su nombre en /datos/quien-soy.txt al arrancar y luego dormir.

Después:

  1. Comprueba los nombres de los pods y de los PVC generados.
  2. Escribe un contenido adicional en el pod notas-demo-1.
  3. Borra ese pod y verifica que el nuevo pod recupera el fichero.

Ejercicio 2: despliegue por fases con partition

Sobre el StatefulSet del ejercicio 1, cambia la imagen a busybox:1.37 usando partition de forma que solo se actualice el pod de ordinal 2. Verifica que notas-demo-0 y notas-demo-1 siguen con la imagen antigua. Después completa la actualización.

Ejercicio 3: escalado y destino de los PVC

Con el StatefulSet del ejercicio 1 en 3 réplicas:

  1. Redúcelo a 1 réplica y comprueba cuántos PVC quedan.
  2. Vuelve a subir a 3 y verifica que notas-demo-2 recupera su fichero, no uno nuevo.
  3. Configura persistentVolumeClaimRetentionPolicy.whenScaled: Delete, reduce otra vez a 1 y comprueba la diferencia.
  4. Limpia todo.

Soluciones

Solución 1

# /tmp/notas-demo.yaml
apiVersion: v1
kind: Service
metadata:
  name: notas-demo-nodos
  namespace: rutas-norte-dev
  labels:
    app: notas-demo
    entorno: dev
spec:
  clusterIP: None
  selector:
    app: notas-demo
    entorno: dev
  ports:
    - name: ficticio
      port: 9999
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: notas-demo
  namespace: rutas-norte-dev
  labels:
    app: notas-demo
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  serviceName: notas-demo-nodos
  replicas: 3
  selector:
    matchLabels:
      app: notas-demo
      entorno: dev
  template:
    metadata:
      labels:
        app: notas-demo
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      automountServiceAccountToken: false
      containers:
        - name: escritor
          image: busybox:1.36
          command:
            - sh
            - -c
            - 'echo "soy $(hostname)" >> /datos/quien-soy.txt; sleep 86400'
          resources:
            requests:
              cpu: 10m
              memory: 16Mi
            limits:
              cpu: 50m
              memory: 32Mi
          volumeMounts:
            - name: datos
              mountPath: /datos
  volumeClaimTemplates:
    - metadata:
        name: datos
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: rutasnorte-estandar
        resources:
          requests:
            storage: 100Mi
kubectl apply -f /tmp/notas-demo.yaml
kubectl rollout status statefulset/notas-demo -n rutas-norte-dev

kubectl get pods -n rutas-norte-dev -l app=notas-demo
kubectl get pvc -n rutas-norte-dev -l app=notas-demo
NAME           READY   STATUS    RESTARTS   AGE
notas-demo-0   1/1     Running   0          40s
notas-demo-1   1/1     Running   0          32s
notas-demo-2   1/1     Running   0          24s

NAME                  STATUS   VOLUME        CAPACITY   STORAGECLASS
datos-notas-demo-0    Bound    pvc-a1b2...   100Mi      rutasnorte-estandar
datos-notas-demo-1    Bound    pvc-c3d4...   100Mi      rutasnorte-estandar
datos-notas-demo-2    Bound    pvc-e5f6...   100Mi      rutasnorte-estandar

Los PVC siguen el patrón <plantilla>-<statefulset>-<ordinal>, tal como anticipamos.

# 2. Escribir contenido adicional
kubectl exec -n rutas-norte-dev notas-demo-1 -- \
  sh -c 'echo "reserva ficticia 4471" >> /datos/quien-soy.txt'

# 3. Borrar el pod y esperar al sustituto
kubectl delete pod notas-demo-1 -n rutas-norte-dev
kubectl wait --for=condition=ready pod/notas-demo-1 -n rutas-norte-dev --timeout=120s
kubectl exec -n rutas-norte-dev notas-demo-1 -- cat /datos/quien-soy.txt
soy notas-demo-1
reserva ficticia 4471
soy notas-demo-1

El fichero conserva las dos líneas anteriores y añade una tercera del nuevo arranque. El pod se llama igual y ha reencontrado su disco: identidad estable y almacenamiento estable, exactamente lo que un Deployment no puede prometer.

Solución 2

# Fijar la partición por encima del ordinal máximo antes de tocar nada
kubectl patch statefulset notas-demo -n rutas-norte-dev \
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":3}}}}'

kubectl set image statefulset/notas-demo -n rutas-norte-dev escritor=busybox:1.37

# Nada se ha movido: partition (3) > ordinal máximo (2)
kubectl get pods -n rutas-norte-dev -l app=notas-demo \
  -o custom-columns=POD:.metadata.name,IMAGEN:.spec.containers[0].image
POD            IMAGEN
notas-demo-0   busybox:1.36
notas-demo-1   busybox:1.36
notas-demo-2   busybox:1.36
# Liberar solo el ordinal 2
kubectl patch statefulset notas-demo -n rutas-norte-dev \
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":2}}}}'

kubectl rollout status statefulset/notas-demo -n rutas-norte-dev
kubectl get pods -n rutas-norte-dev -l app=notas-demo \
  -o custom-columns=POD:.metadata.name,IMAGEN:.spec.containers[0].image
POD            IMAGEN
notas-demo-0   busybox:1.36
notas-demo-1   busybox:1.36
notas-demo-2   busybox:1.37

Solo el ordinal más alto se ha actualizado. Si esto fuera una base de datos con el primario en -0, habríamos probado la versión nueva en la réplica menos crítica.

# Completar la actualización
kubectl patch statefulset notas-demo -n rutas-norte-dev \
  -p '{"spec":{"updateStrategy":{"rollingUpdate":{"partition":0}}}}'
kubectl rollout status statefulset/notas-demo -n rutas-norte-dev

El orden observado en los eventos será -1 primero y -0 después: siempre descendente.

Solución 3

# 1. Reducir a 1
kubectl scale statefulset notas-demo -n rutas-norte-dev --replicas=1
kubectl get pods,pvc -n rutas-norte-dev -l app=notas-demo
NAME               READY   STATUS    RESTARTS   AGE
pod/notas-demo-0   1/1     Running   0          14m

NAME                                     STATUS   VOLUME        CAPACITY
persistentvolumeclaim/datos-notas-demo-0 Bound    pvc-a1b2...   100Mi
persistentvolumeclaim/datos-notas-demo-1 Bound    pvc-c3d4...   100Mi
persistentvolumeclaim/datos-notas-demo-2 Bound    pvc-e5f6...   100Mi

Un pod, tres PVC. Es el comportamiento por defecto (whenScaled: Retain) y la causa habitual de facturas de almacenamiento inexplicables.

# 2. Volver a subir y comprobar que -2 recupera su fichero
kubectl scale statefulset notas-demo -n rutas-norte-dev --replicas=3
kubectl wait --for=condition=ready pod/notas-demo-2 -n rutas-norte-dev --timeout=120s
kubectl exec -n rutas-norte-dev notas-demo-2 -- cat /datos/quien-soy.txt
soy notas-demo-2
soy notas-demo-2

Dos líneas: la del arranque original y la del nuevo. El disco es el mismo de antes.

# 3. Cambiar la política y reducir de nuevo
kubectl patch statefulset notas-demo -n rutas-norte-dev \
  -p '{"spec":{"persistentVolumeClaimRetentionPolicy":{"whenScaled":"Delete","whenDeleted":"Retain"}}}'

kubectl scale statefulset notas-demo -n rutas-norte-dev --replicas=1
kubectl get pvc -n rutas-norte-dev -l app=notas-demo
NAME                  STATUS   VOLUME        CAPACITY   STORAGECLASS
datos-notas-demo-0    Bound    pvc-a1b2...   100Mi      rutasnorte-estandar

Ahora los PVC de -1 y -2 se han borrado al desaparecer sus pods. Para una caché es lo deseable; para postgres-reservas sería una forma rápida de perder las reservas de un año.

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

Conclusión

El StatefulSet resuelve el techo que arrastrábamos desde la lección 05-03. Con él, cada réplica tiene nombre ordinal estable, su propio PVC generado desde volumeClaimTemplates y un nombre DNS individual gracias al Service headless que serviceName exige. El arranque y la parada son ordenados (OrderedReady) o simultáneos (Parallel) según convenga, y las actualizaciones bajan en orden inverso con la posibilidad de frenarlas por fases mediante partition.

Hemos migrado postgres-reservas de Deployment a StatefulSet reutilizando el PersistentVolume existente —el truco está en liberar el claimRef y crear un PVC con el nombre exacto que el controlador espera— y hemos convertido redis-cache en un StatefulSet de tres réplicas con decisiones opuestas y bien razonadas en cada campo.

Y hemos sido honestos con el límite: el StatefulSet aporta infraestructura, no lógica. PostgreSQL no se replica por estar dentro de uno. Esa carencia es la que motivará los operadores de la lección 06-07, que cerrarán este módulo sustituyendo nuestro StatefulSet artesanal por software que sabe promover un primario, crear réplicas de lectura y recuperar a un instante concreto.

Antes de eso hay otras formas de ejecutar cargas de trabajo que aún no conocemos. Hasta ahora todas nuestras cargas se distribuyen donde el scheduler quiera y en el número de copias que pidamos. Pero hay una categoría de proceso —los agentes de infraestructura: recolectores de logs, exportadores de métricas, plugins de red— para los que "cuántas réplicas" es la pregunta equivocada: la respuesta correcta siempre es una por nodo, ni una más ni una menos, y automáticamente en los nodos que se añadan mañana. Ese es el objeto de la siguiente lección: los DaemonSets.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados