0%
Almacenamiento persistente

Almacenamiento

Hacer que los datos sobrevivan a unos Pods diseñados para desaparecer.

15-20 min

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.

ModoAbreviaturaSignificado
ReadWriteOnceRWOmontable en lectura-escritura por un único nodo
ReadOnlyManyROXmontable en solo lectura por varios nodos
ReadWriteManyRWXmontable en lectura-escritura por varios nodos
ReadWriteOncePodRWOPun ú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:

  • provisioner designa el controlador que crea el almacenamiento. Es específico del proveedor: ebs.csi.aws.com en AWS, pd.csi.storage.gke.io en Google Cloud, disk.csi.azure.com en Azure, rancher.io/local-path con el aprovisionador local de kind.
  • volumeBindingMode: WaitForFirstConsumer retrasa 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. Con Immediate, un volumen puede nacer en una zona donde el Pod nunca podrá ser ubicado, y el Pod queda bloqueado.
  • allowVolumeExpansion: true autoriza 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 estado Released. 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.

  1. 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.
  2. Comparten el mismo PVC. La plantilla de Pod es única, por lo que claimName es 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.
  3. 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.
  4. 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-1 recreado se sigue llamando postgres-1 y recupera donnees-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. emptyDir está 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 Pending se explica casi siempre por una clase inexistente o un modo de acceso no compatible.
  • ReadWriteOnce se aplica al nodo y es el único modo que ofrecen la mayoría de los almacenamientos en bloque. ReadWriteMany exige 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 Retain en 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

¡Enlace copiado!