El sistema de archivos de un contenedor desaparece cuando el pod se elimina o se reprograma en otro nodo. Para bases de datos, colas o archivos subidos por los usuarios, Kubernetes ofrece almacenamiento persistente mediante tres objetos: la StorageClass (el tipo de almacenamiento), el PersistentVolume o PV (el volumen real) y el PersistentVolumeClaim o PVC (la petición de almacenamiento que hace una aplicación). En este tutorial crearás un PVC con aprovisionamiento dinámico, lo montarás en un Deployment, comprobarás que los datos sobreviven al pod y desplegarás una base de datos PostgreSQL con un StatefulSet.

Requisitos previos

  • Un clúster de Kubernetes 1.30 o posterior y kubectl configurado contra él.
  • Permisos para crear recursos en un namespace y, para el paso 1 si tu clúster no tiene almacenamiento, permisos de administrador.
  • Conocer los conceptos básicos de pods y Deployments.

Cómo encajan PV, PVC y StorageClass

ObjetoQuién lo creaQué representa
StorageClassEl administrador del clúster o el proveedorUn tipo de almacenamiento y el aprovisionador (driver CSI) que crea los volúmenes
PersistentVolume (PV)El aprovisionador, automáticamente, o el administrador a manoUn volumen concreto: un disco, un recurso NFS, un directorio de un nodo
PersistentVolumeClaim (PVC)El usuario, junto a su aplicaciónUna petición: "necesito 5 GiB con acceso ReadWriteOnce de la clase X"

En el flujo habitual, el aprovisionamiento dinámico, tú solo creas el PVC. El aprovisionador de la StorageClass crea el PV que lo satisface y los vincula (estado Bound). El pod solo referencia el PVC por su nombre, así que el manifiesto de la aplicación no depende del tipo de almacenamiento que haya detrás.

Paso 1: Comprobar las StorageClass disponibles

Lista las clases de almacenamiento del clúster:

kubectl get storageclass
NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  30d

Fíjate en cuatro columnas:

  • (default): la clase que se usa cuando un PVC no indica ninguna.
  • RECLAIMPOLICY: qué ocurre con el volumen al borrar el PVC. Delete lo elimina junto con sus datos; Retain lo conserva para recuperarlo a mano.
  • VOLUMEBINDINGMODE: con WaitForFirstConsumer el volumen no se crea hasta que un pod lo usa, y se crea en el nodo o zona donde se programa ese pod.
  • ALLOWVOLUMEEXPANSION: si se puede ampliar un PVC existente.

Los clústeres gestionados y k3s traen una StorageClass por defecto. Si la lista está vacía (por ejemplo, en un clúster creado con kubeadm), instala local-path-provisioner, que crea volúmenes como directorios en el disco local de cada nodo:

kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.31/deploy/local-path-storage.yaml
kubectl patch storageclass local-path -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

Paso 2: Crear un PersistentVolumeClaim

Crea un namespace para los ejemplos:

kubectl create namespace storage-demo

Crea el archivo pvc.yaml con una petición de 1 GiB:

nano pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: web-data
  namespace: storage-demo
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

Al no indicar storageClassName, se usa la clase por defecto. Para elegir otra, añade storageClassName: nombre-de-la-clase dentro de spec.

Aplícalo y consulta su estado:

kubectl apply -f pvc.yaml
kubectl get pvc -n storage-demo
NAME       STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
web-data   Pending                                      local-path     5s

Si la clase usa WaitForFirstConsumer, el estado Pending es normal: el volumen se creará cuando un pod lo monte en el paso siguiente. kubectl describe pvc web-data -n storage-demo lo confirma con el evento waiting for first consumer to be created before binding.

Modos de acceso

ModoSignificadoSoportado típicamente por
ReadWriteOnce (RWO)Lectura y escritura desde un solo nodoDiscos de bloque, local-path, la mayoría de drivers CSI
ReadWriteOncePod (RWOP)Lectura y escritura desde un solo podDrivers CSI recientes
ReadOnlyMany (ROX)Solo lectura desde varios nodosNFS, CephFS
ReadWriteMany (RWX)Lectura y escritura desde varios nodosNFS, CephFS, sistemas de archivos distribuidos

Pide solo el modo que tu almacenamiento soporta: un PVC con ReadWriteMany sobre una clase de discos de bloque se quedará en Pending para siempre.

Paso 3: Montar el PVC en un Deployment

Crea un Deployment de Nginx que sirva su contenido desde el volumen:

nano web.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: storage-demo
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
        - name: nginx
          image: nginx:1.28
          volumeMounts:
            - name: data
              mountPath: /usr/share/nginx/html
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: web-data

La estrategia Recreate hace que, al actualizar, el pod antiguo se detenga antes de crear el nuevo. Con un volumen ReadWriteOnce evita que el pod nuevo se quede esperando a un volumen que sigue montado en otro nodo.

Aplícalo y comprueba que el PVC pasa a Bound:

kubectl apply -f web.yaml
kubectl get pvc,pv -n storage-demo
NAME                             STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
persistentvolumeclaim/web-data   Bound    pvc-3f1c2a7e-8b4d-4c1e-9a55-0d2b7c6e1f90   1Gi        RWO            local-path     2m

NAME                                                        CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                   STORAGECLASS   AGE
persistentvolume/pvc-3f1c2a7e-8b4d-4c1e-9a55-0d2b7c6e1f90   1Gi        RWO            Delete           Bound    storage-demo/web-data   local-path     20s

El PV con nombre pvc-... lo ha creado el aprovisionador automáticamente.

Paso 4: Comprobar que los datos persisten

Escribe un archivo en el volumen desde el pod:

kubectl exec -n storage-demo deploy/web -- sh -c 'echo "Datos persistentes" > /usr/share/nginx/html/index.html'

Elimina el pod. El Deployment creará uno nuevo que montará el mismo PVC:

kubectl delete pod -n storage-demo -l app=web
kubectl rollout status deployment/web -n storage-demo

Lee el archivo desde el pod nuevo:

kubectl exec -n storage-demo deploy/web -- cat /usr/share/nginx/html/index.html
Datos persistentes

El contenido sigue ahí aunque el pod es otro. Sin el volumen, el nuevo pod habría servido la página por defecto de Nginx.

Paso 5: Ampliar un PVC

Si la StorageClass tiene ALLOWVOLUMEEXPANSION a true y el driver lo soporta, puedes ampliar un PVC editando su tamaño. Los volúmenes solo pueden crecer, nunca reducirse:

kubectl patch pvc web-data -n storage-demo -p '{"spec":{"resources":{"requests":{"storage":"2Gi"}}}}'
kubectl get pvc web-data -n storage-demo

Cuando termine, la columna CAPACITY mostrará 2Gi. Algunos drivers necesitan que el pod se reinicie para ampliar el sistema de archivos; kubectl describe pvc lo indica con la condición FileSystemResizePending.

Con local-path, que no soporta expansión, el comando falla con un error como este:

Error from server (Forbidden): persistentvolumeclaims "web-data" is forbidden: only dynamically provisioned pvc can be resized and the storageclass that provisions the pvc must support resize

Paso 6: Desplegar PostgreSQL con un StatefulSet

Un Deployment comparte un único PVC entre todas sus réplicas. Las bases de datos necesitan otra cosa: que cada réplica tenga su propio volumen y lo conserve aunque el pod se recree. Para eso existen los StatefulSets y su campo volumeClaimTemplates, que crea un PVC por réplica (data-postgres-0, data-postgres-1...).

Guarda la contraseña de PostgreSQL en un Secret. Sustituye tu_contraseña_segura por una contraseña robusta:

kubectl create secret generic postgres-secret -n storage-demo --from-literal=password='tu_contraseña_segura'

Crea el manifiesto:

nano postgres.yaml
apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: storage-demo
spec:
  clusterIP: None
  selector:
    app: postgres
  ports:
    - port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
  namespace: storage-demo
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:17
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-secret
                  key: password
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          ports:
            - containerPort: 5432
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 5Gi

La variable PGDATA apunta a un subdirectorio del volumen porque muchos sistemas de archivos crean un directorio lost+found en la raíz, y PostgreSQL se niega a inicializarse en un directorio que no está vacío.

Aplícalo y espera a que el pod esté listo:

kubectl apply -f postgres.yaml
kubectl rollout status statefulset/postgres -n storage-demo
kubectl get pvc -n storage-demo
NAME              STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
data-postgres-0   Bound    pvc-9d7e1b3a-2c4f-4e8a-b6d1-5a0f3c8e7b21   5Gi        RWO            local-path     40s
web-data          Bound    pvc-3f1c2a7e-8b4d-4c1e-9a55-0d2b7c6e1f90   1Gi        RWO            local-path     10m

Crea una tabla, borra el pod y comprueba que la tabla sigue existiendo:

kubectl exec -n storage-demo postgres-0 -- psql -U postgres -c "CREATE TABLE prueba (id int); INSERT INTO prueba VALUES (1);"
kubectl delete pod -n storage-demo postgres-0
kubectl wait --for=condition=Ready pod/postgres-0 -n storage-demo --timeout=120s
kubectl exec -n storage-demo postgres-0 -- psql -U postgres -c "SELECT * FROM prueba;"
 id
----
  1
(1 row)

Paso 7: Usar un volumen existente con un PV estático

A veces el almacenamiento ya existe, por ejemplo un recurso NFS de tu red, y quieres usarlo sin aprovisionador. En ese caso creas el PV a mano y un PVC que lo reclama. Este ejemplo usa un servidor NFS en 10.0.0.10 que exporta /srv/nfs/compartido; los nodos necesitan el cliente NFS instalado (nfs-common en Ubuntu):

nano nfs-pv.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-compartido
spec:
  capacity:
    storage: 20Gi
  accessModes:
    - ReadWriteMany
  persistentVolumeReclaimPolicy: Retain
  storageClassName: ""
  mountOptions:
    - nfsvers=4.1
  nfs:
    server: 10.0.0.10
    path: /srv/nfs/compartido
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: compartido
  namespace: storage-demo
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: ""
  volumeName: nfs-compartido
  resources:
    requests:
      storage: 20Gi

storageClassName: "" desactiva el aprovisionamiento dinámico para este PVC y volumeName lo vincula directamente al PV. La política Retain garantiza que borrar el PVC no toca los datos del servidor NFS. Aplícalo y comprueba que ambos quedan Bound:

kubectl apply -f nfs-pv.yaml
kubectl get pv nfs-compartido
kubectl get pvc compartido -n storage-demo

Al ser ReadWriteMany, puedes montar este PVC en un Deployment con varias réplicas repartidas entre nodos.

Paso 8: Limpiar los recursos

Borrar el namespace elimina los pods, los PVC y, con la política Delete, los volúmenes dinámicos y sus datos. El PV estático de NFS, con Retain, queda en estado Released y hay que borrarlo aparte:

kubectl delete namespace storage-demo
kubectl delete pv nfs-compartido

Solución de problemas

  • PVC en Pending indefinidamente: ejecuta kubectl describe pvc nombre y lee los eventos. Las causas habituales son que no hay StorageClass por defecto, que la clase indicada no existe, o que el modo de acceso pedido no lo soporta el driver.
  • Pod en ContainerCreating con errores de montaje: kubectl describe pod nombre muestra eventos FailedMount o FailedAttachVolume. Con volúmenes RWO suele ser que el volumen sigue conectado a otro nodo; espera a que el pod anterior termine o usa la estrategia Recreate.
  • Pod en Pending con volume node affinity conflict: el volumen es local de un nodo (local-path) o de una zona, y el pod no puede programarse ahí. Revisa que ese nodo esté disponible.
  • PVC atascado en Terminating: un pod todavía lo usa. Kubernetes protege el PVC hasta que ningún pod lo monte; localiza el pod con kubectl describe pvc nombre (campo Used By) y elimínalo. No quites el finalizador a mano salvo que sepas que el volumen ya no está en uso.

Conclusión

Has visto cómo se relacionan StorageClass, PV y PVC, has aprovisionado un volumen de forma dinámica, has comprobado que los datos sobreviven a los pods y has desplegado PostgreSQL con un volumen propio por réplica mediante un StatefulSet. Como siguientes pasos, configura copias de seguridad de tus volúmenes con VolumeSnapshots (si tu driver CSI los soporta) o con Velero, y define ResourceQuotas por namespace para limitar cuánto almacenamiento puede pedir cada equipo.