--- title: "Volúmenes persistentes en Kubernetes: PV, PVC y StorageClass" description: "Aprende a dar almacenamiento persistente a tus pods con PersistentVolumeClaims, StorageClass y StatefulSets, y comprueba que los datos sobreviven al pod." category: "Docker Kubernetes" language: es source: https://cubepath.com/es/docs/docker-kubernetes/volumenes-persistentes-en-kubernetes updated: 2026-09-25 --- # Volúmenes persistentes en Kubernetes: PV, PVC y StorageClass 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 | Objeto | Quién lo crea | Qué representa | |---|---|---| | StorageClass | El administrador del clúster o el proveedor | Un tipo de almacenamiento y el aprovisionador (driver CSI) que crea los volúmenes | | PersistentVolume (PV) | El aprovisionador, automáticamente, o el administrador a mano | Un volumen concreto: un disco, un recurso NFS, un directorio de un nodo | | PersistentVolumeClaim (PVC) | El usuario, junto a su aplicación | Una 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: ```bash kubectl get storageclass ``` ```output 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: ```bash 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"}}}' ``` > Importante: local-path es adecuado para pruebas y cargas de un solo nodo. Los datos viven en el disco de un nodo concreto: si ese nodo cae, el pod no puede arrancar en otro. En producción usa el driver CSI de tu proveedor o un almacenamiento distribuido como Longhorn o Ceph. ## Paso 2: Crear un PersistentVolumeClaim Crea un namespace para los ejemplos: ```bash kubectl create namespace storage-demo ``` Crea el archivo `pvc.yaml` con una petición de 1 GiB: ```bash nano pvc.yaml ``` ```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: ```bash kubectl apply -f pvc.yaml kubectl get pvc -n storage-demo ``` ```output 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 | Modo | Significado | Soportado típicamente por | |---|---|---| | `ReadWriteOnce` (RWO) | Lectura y escritura desde un solo nodo | Discos de bloque, local-path, la mayoría de drivers CSI | | `ReadWriteOncePod` (RWOP) | Lectura y escritura desde un solo pod | Drivers CSI recientes | | `ReadOnlyMany` (ROX) | Solo lectura desde varios nodos | NFS, CephFS | | `ReadWriteMany` (RWX) | Lectura y escritura desde varios nodos | NFS, 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: ```bash nano web.yaml ``` ```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`: ```bash kubectl apply -f web.yaml kubectl get pvc,pv -n storage-demo ``` ```output 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: ```bash 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: ```bash kubectl delete pod -n storage-demo -l app=web kubectl rollout status deployment/web -n storage-demo ``` Lee el archivo desde el pod nuevo: ```bash kubectl exec -n storage-demo deploy/web -- cat /usr/share/nginx/html/index.html ``` ```output 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: ```bash 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: ```output 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: ```bash kubectl create secret generic postgres-secret -n storage-demo --from-literal=password='tu_contraseña_segura' ``` Crea el manifiesto: ```bash nano postgres.yaml ``` ```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: ```bash kubectl apply -f postgres.yaml kubectl rollout status statefulset/postgres -n storage-demo kubectl get pvc -n storage-demo ``` ```output 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: ```bash 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;" ``` ```output id ---- 1 (1 row) ``` > Nota: al borrar o reducir un StatefulSet, Kubernetes no elimina por defecto los PVC creados por `volumeClaimTemplates`, precisamente para no perder datos. Tendrás que borrarlos a mano cuando ya no los necesites. ## 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): ```bash nano nfs-pv.yaml ``` ```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`: ```bash 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: ```bash 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.