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
kubectlconfigurado 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:
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.
Deletelo elimina junto con sus datos;Retainlo conserva para recuperarlo a mano. - VOLUMEBINDINGMODE: con
WaitForFirstConsumerel 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"}}}'
Importantelocal-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:
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
| 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:
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)
Notaal 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):
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
Pendingindefinidamente: ejecutakubectl describe pvc nombrey 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
ContainerCreatingcon errores de montaje:kubectl describe pod nombremuestra eventosFailedMountoFailedAttachVolume. Con volúmenes RWO suele ser que el volumen sigue conectado a otro nodo; espera a que el pod anterior termine o usa la estrategiaRecreate. - Pod en
Pendingconvolume 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 conkubectl describe pvc nombre(campoUsed 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.
