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
- Qué garantiza un StatefulSet que un Deployment no puede dar
- Identidad estable: nombres ordinales y DNS por pod
serviceNamey el Service headlessvolumeClaimTemplates: un disco por réplica- Orden de arranque y parada:
podManagementPolicy - Estrategias de actualización:
RollingUpdate,partitionyOnDelete - Migración práctica de
postgres-reservasa StatefulSet - Escalado y qué ocurre con los PVC
- El límite honesto: identidad no es replicación
- Segundo ejemplo:
redis-cache
- 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-0siempre monta el volumen depostgres-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.
- 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 6mEl 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 -- hostnameEsto 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"
fiEse 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.
serviceName y el Service headless
serviceName y el Service headlessEl 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: 5432Con este Service, cada pod del StatefulSet obtiene un FQDN propio con la forma:
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.localCada 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.localServer: 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.37Ademá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.localName: postgres-reservas-nodos.rutas-norte-dev.svc.cluster.local
Address: 10.244.1.37
Address: 10.244.2.19
Address: 10.244.0.28En 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)]
volumeClaimTemplates: un disco por réplica
volumeClaimTemplates: un disco por réplicaAquí 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: 20GiEl nombre del PVC resultante sigue una regla fija:
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 7mDentro 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: pgdataY 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
volumeClaimTemplatesNO 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.
- Orden de arranque y parada:
podManagementPolicy
podManagementPolicyPor 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.
| 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.
- Estrategias de actualización:
RollingUpdate, partition y OnDelete
RollingUpdate, partition y OnDeleteCuando 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:
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
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 |
- Migración práctica de
postgres-reservas a StatefulSet
postgres-reservas a StatefulSetEste 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=600sLa 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
# 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=300sPaso 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-1f2a6c0e7b91NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM
pvc-4c81... 20Gi RWO Retain Released rutas-norte-pre/postgres-reservas-datosEl PV queda en Released: conserva los datos pero no admite un nuevo PVC hasta que se limpie la referencia al anterior.
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: 20GivolumeName 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: 20GiPuntos que merece la pena señalar de este manifiesto:
serviceNameapunta al Service headless, no al ClusterIP normal. Es un error frecuente confundirlos.- Los recursos mantienen la clase
Guaranteedque fijamos en 03-05:requestsiguales alimits, 1 CPU y 2 GiB. - Se conservan la ServiceAccount dedicada y
automountServiceAccountToken: falsede 03-06. PGDATAapunta a un subdirectorio del punto de montaje. PostgreSQL se niega a inicializar sobre un directorio que contengalost+found, algo habitual en volúmenes de bloque formateados.- El selector solo usa
appyentorno, según la convención del proyecto. Y recuerda:spec.selectores 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-prekubectl exec -n rutas-norte-pre postgres-reservas-0 -- \
psql -U rutasnorte -d reservas -c 'SELECT count(*) FROM reservas;'Los datos siguen ahí. Ahora se puede borrar el Deployment antiguo:
- Escalado y qué ocurre con los PVC
Escalar un StatefulSet es idéntico en sintaxis a escalar un Deployment:
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-reservasNAME 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-rapidaLos 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:
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.
- 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.
- Segundo ejemplo:
redis-cache
redis-cacheredis-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: 2GiDiferencias 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:
- Comprueba los nombres de los pods y de los PVC generados.
- Escribe un contenido adicional en el pod
notas-demo-1. - 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:
- Redúcelo a 1 réplica y comprueba cuántos PVC quedan.
- Vuelve a subir a 3 y verifica que
notas-demo-2recupera su fichero, no uno nuevo. - Configura
persistentVolumeClaimRetentionPolicy.whenScaled: Delete, reduce otra vez a 1 y comprueba la diferencia. - 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: 100Mikubectl 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-demoNAME 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-estandarLos 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.txtEl 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# 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].imageSolo 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-devEl 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-demoNAME 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... 100MiUn 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.txtDos 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-demoNAME STATUS VOLUME CAPACITY STORAGECLASS
datos-notas-demo-0 Bound pvc-a1b2... 100Mi rutasnorte-estandarAhora 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-demoConclusió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
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
