Almacenamiento
Hacer que los datos sobrevivan a unos Pods diseñados para desaparecer.
Almacenamiento persistente
Todo lo que ha aprendido hasta ahora descansa sobre una hipótesis cómoda: los Pods son intercambiables y desechables. Esa hipótesis es exacta para una API sin estado, y se desmorona en cuanto hay datos que conservar. El sistema de archivos de un contenedor muere con él: destruya el Pod de una base de datos PostgreSQL desplegada de forma ingenua y las tablas se pierden. No se corrompen: se pierden.
No basta con «montar un volumen». La dificultad es que, en un clúster, el disco y el cómputo están separados: un Pod puede reprogramarse en otro nodo, y un directorio local del nodo de origen pasa entonces a ser inaccesible. El almacenamiento debe ser, por tanto, un recurso del clúster, no del nodo, y ese recurso debe seguir al Pod.
Kubernetes responde con un desacoplamiento en tres objetos que desconcierta al principio, pero que tiene una lógica clara. El PersistentVolume representa un fragmento de almacenamiento real. El PersistentVolumeClaim es una solicitud formulada por la aplicación: «necesito 10 Gio en lectura-escritura». La StorageClass describe cómo se fabrican esos volúmenes bajo demanda. El desarrollador solo escribe la reclamación; no sabe —ni necesita saber— si obtendrá un disco EBS, un volumen Ceph o un directorio local.
Y llega una pregunta que no se puede eludir: incluso con un volumen que sobrevive, un Deployment sigue siendo inadecuado para una base de datos. Sus Pods son anónimos, se crean en paralelo y se repartirían el mismo volumen. El StatefulSet existe para este caso concreto, y esta lección explica por qué no es un simple Deployment mejorado.
Los volúmenes efímeros y su buen uso
Antes del almacenamiento persistente, unas palabras sobre emptyDir, a menudo mal entendido. Crea un directorio vacío al arrancar el Pod, compartido entre sus contenedores, y se destruye cuando el Pod desaparece.
apiVersion: v1
kind: Pod
metadata:
name: pod-avec-cache
spec:
containers:
- name: app
image: mon-api:1.0.0
volumeMounts:
- name: cache
mountPath: /var/cache/app
- name: collecteur-logs
image: busybox:1.36
command: ["sh", "-c", "tail -F /var/cache/app/app.log"]
volumeMounts:
- name: cache
mountPath: /var/cache/app
volumes:
- name: cache
emptyDir:
sizeLimit: 500Mi
No es un parche: es la herramienta adecuada para una caché, un directorio temporal o un espacio de intercambio entre dos contenedores del mismo Pod. emptyDir solo se convierte en un error cuando se le confían datos que deben sobrevivir. Añada medium: Memory para obtener un tmpfs en RAM: rápido, pero se descuenta del límite de memoria del Pod.
PersistentVolume y PersistentVolumeClaim
El volumen, del lado de la infraestructura
Un PersistentVolume es un objeto del clúster, independiente de cualquier Pod. Tiene su propio ciclo de vida.
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-donnees-01
spec:
capacity:
storage: 5Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: manuel
hostPath:
path: /mnt/donnees/pv-01 # solo para demostración local
El hostPath merece una advertencia franca: apunta a un directorio del nodo, por lo que el volumen solo es utilizable por un Pod situado en ese nodo. Es válido para un clúster local de un solo nodo con el fin de entender el mecanismo, y debe proscribirse en cualquier otro contexto. En producción, el campo de tipo se sustituye por un controlador CSI que se comunica con el almacenamiento real del proveedor.
La reclamación, del lado de la aplicación
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: donnees-app
namespace: atelier
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
storageClassName: manuel # debe coincidir con el del PV buscado
Kubernetes busca un PersistentVolume compatible (capacidad suficiente, modo de acceso compatible, mismo storageClassName) y efectúa el enlace. Un PV enlazado a un PVC queda reservado en exclusiva: ningún otro PVC lo obtendrá.
kubectl get pv
kubectl get pvc -n atelier
# ¿STATUS = Pending? La causa está en los eventos.
kubectl describe pvc donnees-app -n atelier
Un PVC bloqueado en Pending tiene casi siempre una de estas tres causas: ningún PV con capacidad suficiente, un storageClassName que no corresponde a nada, o un modo de acceso que el almacenamiento no admite. Los eventos de describe lo dicen explícitamente: es uno de los pocos lugares donde el mensaje de error de Kubernetes es directamente aprovechable.
Utilizar el PVC en un Pod
apiVersion: apps/v1
kind: Deployment
metadata:
name: app-avec-donnees
spec:
replicas: 1 # véase más abajo: ReadWriteOnce impone 1 réplica
selector:
matchLabels:
app: app-donnees
template:
metadata:
labels:
app: app-donnees
spec:
containers:
- name: app
image: mon-api:1.0.0
volumeMounts:
- name: donnees
mountPath: /var/lib/app/donnees
volumes:
- name: donnees
persistentVolumeClaim:
claimName: donnees-app
El contenedor ve un directorio normal. Toda la mecánica de enlace le resulta invisible, y ese es precisamente el objetivo del desacoplamiento.
Los modos de acceso
Es el campo que más se malinterpreta, porque no depende de Kubernetes sino de la tecnología de almacenamiento subyacente.
| Modo | Abreviatura | Significado |
|---|---|---|
ReadWriteOnce | RWO | montable en lectura-escritura por un único nodo |
ReadOnlyMany | ROX | montable en solo lectura por varios nodos |
ReadWriteMany | RWX | montable en lectura-escritura por varios nodos |
ReadWriteOncePod | RWOP | un único Pod, incluso en el mismo nodo |
Lo que su almacenamiento acepta realmente se lee en los volúmenes existentes, nunca en una documentación genérica:
kubectl get pv -o custom-columns=NOM:.metadata.name,MODES:.spec.accessModes,CLASSE:.spec.storageClassName
Dos precisiones decisivas. En primer lugar, ReadWriteOnce se refiere al nodo, no al Pod: varios Pods de un mismo nodo pueden compartir un volumen RWO. Es ReadWriteOncePod el que garantiza la exclusividad a nivel de Pod, que es lo que quiere una base de datos.
En segundo lugar, y es lo que bloquea a los principiantes: la gran mayoría de los almacenamientos por bloques solo ofrece RWO. Los discos EBS de AWS, los Persistent Disks de Google y los Managed Disks de Azure solo se adjuntan a una máquina a la vez. ReadWriteMany exige un sistema de archivos en red (NFS, AWS EFS, Azure Files, CephFS), a menudo más lento y siempre aprovisionado por separado.
Consecuencia práctica inmediata: un Deployment con replicas: 3 y un PVC en ReadWriteOnce no funcionará de forma fiable. Si los Pods aterrizan en nodos distintos, los que no están en el nodo propietario del volumen se quedan en Pending o en ContainerCreating. No es una rareza de Kubernetes, es la naturaleza del almacenamiento por bloques.
Las StorageClass y el aprovisionamiento dinámico
Crear los PersistentVolumes a mano no escala. Una StorageClass describe un tipo de almacenamiento, y un aprovisionador fabrica los volúmenes bajo demanda, en el momento en que llega un PVC.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: rapide-ssd
provisioner: kubernetes.io/no-provisioner # sustituir por el controlador real
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
allowVolumeExpansion: true
Tres campos que conviene entender en lugar de copiar:
provisionerdesigna el controlador que crea el almacenamiento. Es específico del proveedor:ebs.csi.aws.comen AWS,pd.csi.storage.gke.ioen Google Cloud,disk.csi.azure.comen Azure,rancher.io/local-pathcon el aprovisionador local de kind.volumeBindingMode: WaitForFirstConsumerretrasa la creación del volumen hasta que se planifica un Pod. Casi siempre es el ajuste correcto: el volumen se crea entonces en la misma zona de disponibilidad que el Pod. ConImmediate, un volumen puede nacer en una zona donde el Pod nunca podrá ser ubicado, y el Pod queda bloqueado.allowVolumeExpansion: trueautoriza la ampliación posterior del PVC. Sin este campo, la única vía es copia de seguridad, nuevo volumen y restauración.
Con una StorageClass, el PVC se vuelve más corto y no se escribe ningún PV a mano:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: donnees-postgres
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
storageClassName: rapide-ssd
# Las clases disponibles; (default) marca la que se usa si se omite el campo
kubectl get storageclass
# El PV se ha creado automáticamente en el momento del enlace
kubectl get pv
Hay que decirlo claramente: lo que obtiene depende por completo del proveedor. La misma StorageClass no existe de un clúster a otro, un manifiesto con storageClassName: gp3 falla en cualquier sitio que no sea AWS, y no existe un nombre universal. Es el punto donde se acaba la portabilidad de Kubernetes, y conviene saberlo antes de prometer un despliegue multinube.
Omitir storageClassName hace que se aplique la clase marcada como predeterminada, si el clúster tiene una. Es cómodo en desarrollo, y frágil en cuanto un manifiesto viaja.
La política de recuperación
Decide el destino del almacenamiento real cuando se elimina el PVC:
Delete: el volumen y los datos se eliminan. Es el valor predeterminado del aprovisionamiento dinámico.Retain: el volumen subsiste con sus datos, en estadoReleased. Hay que intervenir a mano para reutilizarlo.
Para cualquier dato que importe, use Retain. Es su única protección contra un kubectl delete pvc desafortunado, y sus copias de seguridad siguen siendo indispensables: Kubernetes no hace copia de seguridad de nada.
# Cambiar un PV existente a Retain
kubectl patch pv pv-donnees-01 \
-p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'
Por qué un Deployment no es adecuado para una base de datos
Reunamos las razones, porque es la pregunta más frecuente y merece una respuesta precisa.
- Los Pods de un Deployment son anónimos. Su nombre contiene un sufijo aleatorio que cambia con cada recreación. Una replicación de PostgreSQL necesita dirigirse a una instancia concreta, de forma estable.
- Comparten el mismo PVC. La plantilla de Pod es única, por lo que
claimNamees el mismo para todos. Tres réplicas escribirían en el mismo volumen, lo que corrompe una base de datos, cuando el almacenamiento lo permite, algo que RWO generalmente no permite. - Se crean y se destruyen en paralelo, en un orden indefinido. Una base de datos con un primario y secundarios exige que el primario esté listo antes que los secundarios.
- La eliminación lo destruye todo. Ninguna noción de identidad estable sobrevive.
El síntoma es inmediato en un Deployment de tres réplicas que comparten un PVC en ReadWriteOnce:
kubectl get pods -l app=postgres
# postgres-6b9f7c4d8-2xk4m 1/1 Running (en el nodo del volumen)
# postgres-6b9f7c4d8-7fd2p 0/1 ContainerCreating (volumen ya adjuntado en otro lugar)
# postgres-6b9f7c4d8-q8v1t 0/1 ContainerCreating
El StatefulSet
El StatefulSet responde punto por punto a esta lista.
apiVersion: v1
kind: Service
metadata:
name: postgres
labels:
app: postgres
spec:
clusterIP: None # Service «headless»: requerido por el StatefulSet
selector:
app: postgres
ports:
- port: 5432
name: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres # debe designar el Service headless anterior
replicas: 3
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_DB
value: "appdb"
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: db-credentials
key: DB_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: DB_PASSWORD
# PostgreSQL exige un subdirectorio cuando el punto de montaje
# contiene un lost+found: sin PGDATA, la inicialización falla.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: donnees
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "app_user", "-d", "appdb"]
initialDelaySeconds: 10
periodSeconds: 5
volumeClaimTemplates:
# Se crea un PVC distinto para CADA réplica, automáticamente.
- metadata:
name: donnees
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
Lo que produce este manifiesto, y que ningún Deployment produce:
kubectl get pods -l app=postgres
# postgres-0, postgres-1, postgres-2 — nombres estables, ordinales, predecibles
kubectl get pvc
# donnees-postgres-0, donnees-postgres-1, donnees-postgres-2
Cuatro garantías concretas:
- Identidad estable.
postgres-1recreado se sigue llamandopostgres-1y recuperadonnees-postgres-1, con los mismos datos. - Volumen dedicado por réplica, gracias a
volumeClaimTemplates. Se acabó compartir, se acabó la corrupción. - Orden garantizado. Creación de 0, luego 1, luego 2, y cada Pod espera a que el anterior esté listo. Eliminación en el orden inverso.
- DNS individual. El Service headless da a cada Pod un nombre alcanzable:
postgres-0.postgres.atelier.svc.cluster.local. Es lo que permite configurar una replicación.
Dos puntos de vigilancia. En primer lugar, los PVC creados por volumeClaimTemplates no se eliminan con el StatefulSet: es una protección deliberada, y significa que una limpieza completa requiere una eliminación explícita.
kubectl delete statefulset postgres
kubectl get pvc # los PVC siguen ahí, con los datos intactos
kubectl delete pvc donnees-postgres-0 # eliminación explícita si se desea
En segundo lugar, una observación honesta sobre la operación: un StatefulSet proporciona una identidad y un volumen estables, nada más. No configura la replicación, no gestiona los cambios de primario, no hace copia de seguridad de nada. Para una base de datos en producción se utiliza un operador dedicado (CloudNativePG, Zalando Postgres Operator) que aporta esa lógica, o un servicio gestionado fuera del clúster. El StatefulSet es el cimiento, no la solución completa.
Ampliar un volumen
Si la StorageClass incluye allowVolumeExpansion: true, basta con aumentar la solicitud:
kubectl patch pvc donnees-postgres-0 \
-p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'
kubectl get pvc donnees-postgres-0
Hay tres limitaciones que conviene conocer: reducir el tamaño es imposible, algunos controladores exigen reiniciar el Pod para que se amplíe el sistema de archivos, y la operación no funciona en absoluto con un PV aprovisionado a mano. Por eso, dimensione con margen desde el principio.
Conclusiones
- El sistema de archivos de un contenedor desaparece con él.
emptyDirestá pensado para cachés e intercambios entre contenedores de un Pod, nunca para datos que deban conservarse. - El tríptico PV / PVC / StorageClass desacopla la infraestructura de la aplicación: el desarrollador solo escribe una solicitud y el aprovisionador fabrica el volumen. Un PVC en
Pendingse explica casi siempre por una clase inexistente o un modo de acceso no compatible. ReadWriteOncese aplica al nodo y es el único modo que ofrecen la mayoría de los almacenamientos en bloque.ReadWriteManyexige un sistema de archivos de red que se aprovisiona por separado: un Deployment con varias réplicas sobre un PVC RWO no funcionará.- La StorageClass depende del proveedor: el aprovisionador, los nombres de las clases y las políticas de recuperación cambian de una nube a otra. Use
Retainen lo que importa y haga copias de seguridad: Kubernetes no respalda nada. - Para una base de datos, hace falta un StatefulSet: nombres ordinales estables, un PVC por réplica mediante
volumeClaimTemplates, arranque ordenado y DNS individual a través de un Service headless. Proporciona la base, no la replicación ni las conmutaciones por error; eso corresponde a un operador o a un servicio gestionado.
Consejo: practique eliminando un Pod de un StatefulSet (
kubectl delete pod postgres-1) y comprobando que vuelve con el mismo nombre y los mismos datos. Es la demostración de lo que el Deployment no puede ofrecer.
Comentarios
Los comentarios funcionan con GitHub Discussions
Inicia sesión con GitHub para participar en la discusión