Dejamos el PersistentVolume pv-postgres-reservas-10gi en fase Available, con la columna CLAIM vacía y 10 GiB esperando a alguien. Un PV no se monta solo: hace falta que la aplicación lo reclame, y esa reclamación es un objeto con namespace llamado PersistentVolumeClaim (PVC). Esta es la lección en la que la deuda del módulo se salda de verdad: escribirás el PVC, entenderás con precisión el algoritmo por el que el controlador empareja demanda y oferta, montarás el volumen en el pod de postgres-reservas y harás la prueba que llevamos prometiendo desde el módulo 2 —crear una reserva, borrar el pod y encontrarla intacta—. Verás además qué ocurre al borrar un PVC, por qué a veces se queda en Terminating, y por qué un Deployment con un PVC ReadWriteOnce no puede escalar.
Contenido
- Qué es exactamente un PersistentVolumeClaim
- Anatomía del objeto campo a campo
- El algoritmo de emparejamiento
- Por qué puedes recibir más espacio del que pediste
- El estado
Pendingy su diagnóstico - Consumir el PVC desde el pod
- El manifiesto definitivo de
postgres-reservas - La demostración: los datos sobreviven al pod
- La relación 1:1 y el peligro de dos pods sobre un RWO
- Borrar un PVC: finalizadores y
Terminating - Por qué un Deployment con PVC no escala
- Qué es exactamente un PersistentVolumeClaim
Un PersistentVolumeClaim es la petición de almacenamiento que hace una aplicación. La analogía que mejor funciona es la de los recursos de cómputo que ya conoces de 03-04:
| Cómputo | Almacenamiento |
|---|---|
El pod pide requests.cpu: 500m |
El PVC pide requests.storage: 10Gi |
| El planificador busca un nodo con capacidad | El controlador busca un PV compatible |
Si no hay nodo, el pod queda Pending |
Si no hay PV, el PVC queda Pending |
| El pod no sabe qué máquina le tocará | La app no sabe qué disco le tocará |
Sus dos propiedades definitorias:
- Tiene namespace. Vive junto a la aplicación que lo usa, se ve afectado por las ResourceQuota del namespace (03-04) y desaparece si se borra el namespace. Un pod solo puede montar PVCs de su propio namespace: no existe forma de referenciar un PVC de otro.
- Es la única pieza de almacenamiento que escribe el equipo de aplicación. En un clúster con aprovisionamiento dinámico bien configurado, un desarrollador nunca escribe un PersistentVolume; escribe PVCs.
- Anatomía del objeto campo a campo
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-reservas-datos
namespace: rutas-norte-dev # SI tiene namespace
labels:
app: postgres-reservas
app.kubernetes.io/name: postgres-reservas
app.kubernetes.io/component: base-de-datos
app.kubernetes.io/part-of: rutas-norte
entorno: dev
spec:
accessModes: ["ReadWriteOnce"] # como quiero montarlo
volumeMode: Filesystem # Filesystem (defecto) o Block
resources:
requests:
storage: 10Gi # cuanto pido, COMO MINIMO
storageClassName: rutasnorte-rapida # de que clase
selector: # (opcional) filtrar PVs por etiquetas
matchLabels: { tipo-disco: ssd }
# volumeName: pv-postgres-reservas-10gi # (opcional) atarlo a UNO concretoLos campos, con su significado exacto:
| Campo | Qué significa | Detalle que importa |
|---|---|---|
accessModes |
Modos en que se pretende montar | Debe ser un subconjunto de los del PV. Se aplican por nodo (05-02) |
resources.requests.storage |
El mínimo de capacidad aceptable | Puedes recibir más. En dinámico se crea de ese tamaño exacto |
resources.limits.storage |
Tope superior | Rara vez se usa; lo aplican algunos controladores de admisión |
volumeMode |
Filesystem o Block |
Debe coincidir con el del PV, no basta con ser compatible |
storageClassName |
La clase pedida | "" significa "ninguna clase"; omitirlo significa "la clase por defecto" |
selector |
Filtro por etiquetas del PV | Solo aplica al aprovisionamiento estático; el dinámico lo ignora |
volumeName |
Nombre de un PV concreto | Vinculación manual. Salta el emparejamiento normal |
Dos campos merecen un comentario aparte.
selector
Funciona igual que los selectores de 02-07, con matchLabels y matchExpressions, y se aplica sobre las etiquetas del PV. Sirve para expresar requisitos que la capacidad no captura: matchExpressions: [{ key: tipo-disco, operator: In, values: ["ssd", "nvme"] }] significa "quiero un disco rápido, me da igual cuál".
Cuidado: si el PVC lleva selector, el aprovisionamiento dinámico no puede satisfacerlo (un volumen recién creado no tendrá esas etiquetas), así que el PVC quedará Pending para siempre en un clúster que solo use StorageClasses. Es un campo del mundo estático.
volumeName
Ata el PVC a un PV concreto por su nombre, saltándose la búsqueda. Es útil en una recuperación —"quiero exactamente ese volumen, el que tiene los datos"— y es el complemento del claimRef que viste en 05-02: si ambos objetos se nombran mutuamente, la vinculación es determinista y nadie más puede colarse.
- El algoritmo de emparejamiento
Cuando creas un PVC sin volumeName, el controlador de PersistentVolume (parte de kube-controller-manager) busca un PV candidato. El proceso, en orden:
flowchart TB
A["Se crea el PVC<br/>status: Pending"] --> B{"Tiene volumeName?"}
B -->|"Si"| C["Intenta vincular ESE PV<br/>si es compatible"]
B -->|"No"| D["Lista los PV en fase Available"]
D --> E{"Misma clase? capacity suficiente?<br/>accessModes contenidos?<br/>volumeMode identico? selector casa?"}
E -->|"No"| X["Descartado"]
E -->|"Si"| J["CANDIDATO"]
J --> K["De todos los candidatos,<br/>el de MENOR capacidad"]
K --> L["Bound: se escribe claimRef en el PV<br/>y volumeName en el PVC"]
D --> M{"Sin candidatos"}
M -->|"Hay StorageClass"| N["Aprovisionamiento dinamico (05-04)"]
M -->|"Sin clase"| O["Sigue Pending"]
Los cinco criterios, sin excepciones:
- La misma
storageClassName, comparada como cadena exacta.""y ausente no son lo mismo. capacity.storagedel PV ≥requests.storagedel PVC. Mayor o igual, nunca menor.- Los
accessModesdel PVC deben estar contenidos en los del PV. Si el PVC pide["ReadWriteMany"]y el PV ofrece["ReadWriteOnce"], no hay trato. volumeModeidéntico.- El
selectordel PVC, si existe, debe casar con las etiquetas del PV.
Entre todos los candidatos, el controlador elige el de menor capacidad, para desperdiciar lo menos posible. Y una vez decidido, la vinculación es bidireccional y exclusiva: se escribe spec.claimRef en el PV y spec.volumeName en el PVC. A partir de ahí ambos objetos están casados y ningún otro PVC puede usar ese PV, aunque sobre espacio.
- Por qué puedes recibir más espacio del que pediste
De la regla 2 se deriva un comportamiento que sorprende: requests.storage es un mínimo, no una talla exacta. Si pides 8 GiB y el único PV disponible tiene 10 GiB, te vinculas a él y kubectl get pvc informa de CAPACITY: 10Gi. Consecuencias prácticas:
- No hay devolución. Los 2 GiB de diferencia se pierden para el resto del clúster: nadie más puede usar ese PV.
- La ResourceQuota cuenta lo pedido, no lo obtenido. Si el namespace tiene
requests.storage: 20Gide cuota (03-04), tu PVC de 8 GiB consume 8 GiB de cuota aunque disfrutes de 10. - En aprovisionamiento dinámico esto no ocurre: el volumen se crea exactamente del tamaño pedido (redondeado al mínimo del proveedor). Es una razón más para preferirlo.
Lo que nunca pasa es lo contrario: un PVC jamás se vincula a un PV más pequeño del que pidió.
- El estado
Pending y su diagnóstico
Pending y su diagnósticoPending es el estado que más vas a ver y a diagnosticar. Significa una sola cosa: el PVC no ha encontrado volumen. Las causas son pocas y el método para distinguirlas es siempre el mismo.
Los eventos del final son la clave:
| Evento / mensaje | Causa | Solución |
|---|---|---|
no persistent volumes available for this claim and no storage class is set |
No hay PV compatible y el PVC no tiene clase que aprovisione | Crea el PV o asigna una StorageClass |
storageclass.storage.k8s.io "rutasnorte-rapida" not found |
La clase nombrada no existe | Corrige el nombre o crea la clase (05-04) |
waiting for first consumer to be created before binding |
La clase usa volumeBindingMode: WaitForFirstConsumer |
No es un error: crea el pod y se vinculará (05-04) |
failed to provision volume with StorageClass ... |
El aprovisionador falló (cuota del proveedor, permisos) | Mira los registros del provisionador CSI |
exceeded quota: requests.storage |
La ResourceQuota del namespace lo impide | Ajusta la cuota o pide menos |
Y el procedimiento de diagnóstico, en orden, cuando el evento no basta:
# 1. Hay PVs disponibles? De que clase, tamanyo y modos?
kubectl get pv -o custom-columns=\
NOMBRE:.metadata.name,CAP:.spec.capacity.storage,\
MODOS:.spec.accessModes,CLASE:.spec.storageClassName,FASE:.status.phase
# 2. Existe la clase que pide el PVC?
kubectl get storageclass
# 3. Los eventos, que casi siempre lo dicen todo
kubectl describe pvc postgres-reservas-datos -n rutas-norte-dev | tail -15
# 4. Hay cuota que lo bloquee?
kubectl describe resourcequota -n rutas-norte-devUn Pending en el PVC arrastra al pod: se queda en Pending también, con el evento del planificador pod has unbound immediate PersistentVolumeClaims. Diagnostica siempre el PVC primero; el pod es solo el síntoma.
- Consumir el PVC desde el pod
En el pod, el PVC se usa a través de un volumen del tipo persistentVolumeClaim. La mecánica volumes/volumeMounts de 05-01 no cambia en absoluto:
containers:
- name: postgres
volumeMounts:
- name: datos # (2) donde
mountPath: /var/lib/postgresql/data
volumes:
- name: datos # (1) que
persistentVolumeClaim:
claimName: postgres-reservas-datos # el PVC, del MISMO namespace
readOnly: falseEso es todo. El pod no menciona el PersistentVolume por ninguna parte, ni el proveedor, ni el disco: solo el nombre del PVC. Esa indirección es la que hace que el mismo manifiesto funcione en minikube y en producción.
- El manifiesto definitivo de
postgres-reservas
postgres-reservasHa llegado el momento de borrar el comentario "PROVISIONAL" que arrastramos desde 02-05. Primero el PVC:
# k8s/base/postgres-reservas-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-reservas-datos
namespace: rutas-norte-dev
labels:
app: postgres-reservas
app.kubernetes.io/name: postgres-reservas
app.kubernetes.io/component: base-de-datos
app.kubernetes.io/part-of: rutas-norte
entorno: dev
spec:
accessModes: ["ReadWriteOnce"]
volumeMode: Filesystem
resources: { requests: { storage: 10Gi } }
storageClassName: rutasnorte-rapidaNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
persistentvolumeclaim/postgres-reservas-datos Bound pv-postgres-reservas-10gi 10Gi RWO rutasnorte-rapida 4s
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS AGE
persistentvolume/pv-postgres-reservas-10gi 10Gi RWO Retain Bound rutas-norte-dev/postgres-reservas-datos rutasnorte-rapida 1hBound por ambos lados. La columna CLAIM del PV, que en la lección anterior estaba vacía, ahora nombra al PVC con su namespace. Y el Deployment completo:
# k8s/base/postgres-reservas-deployment.yaml (YA NO ES PROVISIONAL)
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres-reservas
namespace: rutas-norte-dev
labels:
app: postgres-reservas
app.kubernetes.io/name: postgres-reservas
app.kubernetes.io/component: base-de-datos
app.kubernetes.io/part-of: rutas-norte
entorno: dev
spec:
replicas: 1 # UNA. Ver apartado 11
strategy:
type: Recreate # NUNCA RollingUpdate sobre un RWO
selector:
matchLabels: { app: postgres-reservas, entorno: dev }
template:
metadata:
labels:
app: postgres-reservas
app.kubernetes.io/name: postgres-reservas
app.kubernetes.io/component: base-de-datos
app.kubernetes.io/part-of: rutas-norte
entorno: dev
spec:
securityContext:
fsGroup: 999 # grupo 'postgres' de la imagen oficial:
# da propiedad del volumen al proceso (08-02)
containers:
- name: postgres
image: postgres:16.4
ports: [{ name: postgres, containerPort: 5432 }]
env:
# username, password y database desde el Secret del modulo 3
- name: POSTGRES_USER
valueFrom:
secretKeyRef: { name: postgres-reservas-credenciales, key: username }
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef: { name: postgres-reservas-credenciales, key: password }
- name: POSTGRES_DB
valueFrom:
secretKeyRef: { name: postgres-reservas-credenciales, key: database }
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: datos
mountPath: /var/lib/postgresql/data
subPath: pgdata # ver explicacion sobre lost+found
resources: # Guaranteed, decidido en 03-05
requests: { cpu: "1", memory: 2Gi }
limits: { cpu: "1", memory: 2Gi }
volumes:
- name: datos
persistentVolumeClaim:
claimName: postgres-reservas-datosEl asunto de lost+found y por qué hay subPath
Cuando un volumen de bloques se formatea con ext4, su raíz contiene un directorio lost+found. Y el initdb de PostgreSQL se niega a inicializar un directorio de datos que no esté vacío:
initdb: error: directory "/var/lib/postgresql/data" exists but is not empty
It contains a lost+found directory, perhaps due to it being a mount point.Hay dos formas de resolverlo, y en el manifiesto están las dos, lo que no es redundancia sino cinturón y tirantes:
| Técnica | Qué hace | Ruta efectiva de los datos |
|---|---|---|
subPath: pgdata en el volumeMount |
Monta el subdirectorio pgdata del volumen, no su raíz |
<volumen>/pgdata montado en /var/lib/postgresql/data |
PGDATA=/var/lib/postgresql/data/pgdata |
Dice a PostgreSQL que use un subdirectorio del punto de montaje | Un nivel más abajo dentro del montaje |
En Rutas Norte adoptamos PGDATA como mecanismo principal (ya venía del módulo 3) porque el subPath tiene una desventaja seria: un volumen montado con subPath no se redimensiona automáticamente en algunos controladores, lo que complica la expansión de 05-05. Si eliges solo uno, elige PGDATA para PostgreSQL y reserva subPath para el caso general de otras aplicaciones.
fsGroup: el otro fallo clásico
El proceso de PostgreSQL no corre como root, sino como el usuario postgres (UID 999 en la imagen oficial). Un volumen recién creado suele pertenecer a root:root, así que el proceso no puede escribir:
initdb: error: could not change permissions of directory
"/var/lib/postgresql/data": Operation not permittedsecurityContext.fsGroup: 999 hace que el kubelet cambie el grupo propietario del volumen a 999 y le añada permiso de escritura al montarlo. Es la solución estándar y se trata en profundidad en 08-02.
- La demostración: los datos sobreviven al pod
Este es el momento del módulo. Aplicamos y repetimos exactamente el experimento de 05-01, que entonces perdió todos los datos.
kubectl apply -f k8s/base/postgres-reservas-pvc.yaml
kubectl apply -f k8s/base/postgres-reservas-deployment.yaml
kubectl rollout status deploy/postgres-reservas -n rutas-norte-devCreamos una reserva ficticia:
POD=$(kubectl get pod -n rutas-norte-dev -l app=postgres-reservas \
-o jsonpath='{.items[0].metadata.name}')
kubectl exec -n rutas-norte-dev "$POD" -- psql -U rutasnorte -d reservas -c \
"CREATE TABLE IF NOT EXISTS reservas (
id serial PRIMARY KEY, cliente text, trayecto text, fecha date);
INSERT INTO reservas (cliente, trayecto, fecha) VALUES
('Marta Iglesias', 'Bilbao-Santander', '2026-08-15'),
('Ignacio Sabater', 'Oviedo-Gijon', '2026-08-16');"Y ahora la prueba. Borramos el pod, igual que haría un rollout, un desalojo o la caída del nodo:
kubectl delete pod -n rutas-norte-dev "$POD"
kubectl wait --for=condition=ready pod -n rutas-norte-dev \
-l app=postgres-reservas --timeout=180s
POD=$(kubectl get pod -n rutas-norte-dev -l app=postgres-reservas \
-o jsonpath='{.items[0].metadata.name}')
echo "Pod NUEVO: $POD"
kubectl exec -n rutas-norte-dev "$POD" -- psql -U rutasnorte -d reservas -c \
"SELECT * FROM reservas;"Pod NUEVO: postgres-reservas-8f7c4b2d9-q7wnl
id | cliente | trayecto | fecha
----+-----------------+-------------------+------------
1 | Marta Iglesias | Bilbao-Santander | 2026-08-15
2 | Ignacio Sabater | Oviedo-Gijon | 2026-08-16
(2 filas)Las reservas siguen ahí, en un pod distinto, con otro nombre y otra IP. La deuda del módulo 2 está saldada. Y puedes verificar dónde viven realmente los bytes con minikube ssh -p rutas-norte -- "sudo ls -l /data/postgres-reservas/pgdata": los ficheros son del UID 999 gracias a fsGroup, y están en el directorio del nodo, fuera del contenedor.
Sube la apuesta si quieres: borra el Deployment entero y vuelve a aplicarlo. Los datos siguen, porque el PVC y el PV no dependen del Deployment.
kubectl delete deploy postgres-reservas -n rutas-norte-dev
kubectl get pvc -n rutas-norte-dev # sigue Bound
kubectl apply -f k8s/base/postgres-reservas-deployment.yaml
kubectl rollout status deploy/postgres-reservas -n rutas-norte-dev
kubectl exec -n rutas-norte-dev deploy/postgres-reservas -- \
psql -U rutasnorte -d reservas -c "SELECT count(*) FROM reservas;" # 2
- La relación 1:1 y el peligro de dos pods sobre un RWO
La relación entre PVC y PV es estrictamente 1:1. Un PV vinculado no admite un segundo PVC ni aunque le sobren 9 GiB de 10. Compruébalo:
kubectl create -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: otro-reclamante, namespace: rutas-norte-dev }
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-rapida
resources: { requests: { storage: 1Gi } }
EOF
kubectl get pvc otro-reclamante -n rutas-norte-devNAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
otro-reclamante Pending rutasnorte-rapida 10sPending: el único PV de esa clase está Bound. Bórralo y sigamos.
Ahora la parte peligrosa. Varios pods sí pueden montar el mismo PVC, y ahí es donde reaparece la trampa de 05-02:
| Escenario | Con ReadWriteOnce |
Con ReadWriteOncePod |
|---|---|---|
| Dos pods en el mismo nodo | Funciona. Ambos escriben a la vez → riesgo de corrupción | El segundo queda Pending |
| Dos pods en nodos distintos | El segundo no arranca: Multi-Attach error |
El segundo queda Pending |
El error del segundo caso, que verás en cuanto trabajes con un clúster de varios nodos:
Warning FailedAttachVolume pod/postgres-reservas-8f7c4b2d9-k3mp2
Multi-Attach error for volume "pvc-3a9f..." Volume is already exclusively
attached to one node and can't be attached to anotherEse mensaje es una protección, no una avería: el controlador de conexión impide que un disco de bloques se conecte a dos máquinas. Lo grave es el primer caso, donde nadie te protege. Por eso, para cualquier carga con estado sobre ReadWriteOnce, la combinación obligatoria en Rutas Norte es:
replicas: 1strategy: Recreate(termina el pod viejo antes de crear el nuevo)- y, si el driver lo soporta,
ReadWriteOncePoden PV y PVC.
- Borrar un PVC: finalizadores y
Terminating
TerminatingBorrar un PVC en uso es una operación que casi nunca sale como el principiante espera, y por buenas razones.
El comando se queda colgado. Y en otra terminal, la explicación:
kubectl get pvc postgres-reservas-datos -n rutas-norte-dev
kubectl get pvc postgres-reservas-datos -n rutas-norte-dev \
-o jsonpath='{.metadata.finalizers}'; echoNAME STATUS VOLUME CAPACITY ACCESS MODES AGE
postgres-reservas-datos Terminating pv-postgres-reservas-10gi 10Gi RWO 2h
["kubernetes.io/pvc-protection"]kubernetes.io/pvc-protection es la protección de objeto de almacenamiento en uso. Mientras un pod activo esté usando el PVC, el finalizador no se retira y el objeto no se borra. Es el mecanismo de finalizadores de 01-06 aplicado a evitar que alguien vuele el almacenamiento de una aplicación en marcha.
La solución es eliminar primero al consumidor, nunca forzar el finalizador:
kubectl delete deploy postgres-reservas -n rutas-norte-dev
kubectl get pvc -n rutas-norte-dev # ahora si desapareceNunca hagas
kubectl patch pvc ... -p '{"metadata":{"finalizers":null}}'. Es la receta que circula por internet para "desatascar" un PVC y lo que consigue es dejar el volumen real conectado a un nodo sin ningún objeto que lo represente: un huérfano invisible que hay que limpiar a mano en el proveedor.
Y qué le pasa al PV
Cuando el PVC desaparece, el destino del PV lo decide su política de reclamación (05-02):
| Política del PV | Al borrarse el PVC | ¿Datos? |
|---|---|---|
Retain |
El PV pasa a Released, con su claimRef caduco |
Intactos. Rescatable con kubectl patch |
Delete |
Se borra el PV y el volumen real | Destruidos |
En nuestro caso el PV queda Released, con la columna CLAIM mostrando todavía rutas-norte-dev/postgres-reservas-datos, y las reservas dentro. Para volver a la situación operativa, aplicas el rescate que ya conoces y vuelves a crear el PVC y el Deployment:
kubectl patch pv pv-postgres-reservas-10gi \
--type=json -p='[{"op": "remove", "path": "/spec/claimRef"}]'
kubectl apply -f k8s/base/postgres-reservas-pvc.yaml
kubectl apply -f k8s/base/postgres-reservas-deployment.yaml
kubectl exec -n rutas-norte-dev deploy/postgres-reservas -- \
psql -U rutasnorte -d reservas -c "SELECT count(*) FROM reservas;" # 2Esa es la razón de ser de Retain en una base de datos con datos personales. Con Delete, este ejercicio habría terminado con la pérdida de todas las reservas de Rutas Norte.
- Por qué un Deployment con PVC no escala
Última pieza, y es una limitación de fondo, no un detalle. Prueba a escalar:
kubectl scale deploy postgres-reservas -n rutas-norte-dev --replicas=3
kubectl get pods -n rutas-norte-dev -l app=postgres-reservasEn un clúster de varios nodos verías dos pods atascados con Multi-Attach error. En minikube, que tiene un solo nodo, verías algo peor: los tres pods arrancan, montan el mismo directorio de datos y PostgreSQL empieza a quejarse de que ya hay una instancia usando ese directorio (o, con otro motor menos protegido, corrompe los ficheros en silencio).
La razón es estructural: todos los pods de un Deployment comparten exactamente la misma plantilla, incluida la lista de volumes. Si la plantilla dice claimName: postgres-reservas-datos, las tres réplicas piden ese mismo PVC. Un Deployment no sabe dar a cada réplica su propio volumen.
flowchart TB
subgraph DEP["Deployment: una plantilla para todos"]
P1["pod-abc"] --> PVC1["PVC postgres-reservas-datos"]
P2["pod-def"] --> PVC1
P3["pod-ghi"] --> PVC1
PVC1 --> PV1["UN solo PV"]
end
subgraph STS["StatefulSet: volumeClaimTemplate (06-01)"]
S0["postgres-reservas-0"] --> C0["PVC datos-...-0"] --> V0["PV propio"]
S1["postgres-reservas-1"] --> C1["PVC datos-...-1"] --> V1["PV propio"]
end
La solución correcta es el StatefulSet, que incorpora un volumeClaimTemplate: crea un PVC por réplica, con nombre estable y ligado a la identidad del pod, de modo que postgres-reservas-0 siempre reencuentra su volumen. Es el objeto que abre el módulo 6 en 06-01, y ahora sabes exactamente qué problema viene a resolver.
Mientras tanto, la regla operativa para Rutas Norte es clara y suficiente: postgres-reservas es un Deployment de una sola réplica con strategy: Recreate y un PVC ReadWriteOnce. Devuélvelo a su sitio:
Errores Comunes y Consejos
| Error | Síntoma | Solución |
|---|---|---|
PVC Pending sin mirar los eventos |
Horas perdidas | kubectl describe pvc primero; el evento casi siempre lo dice |
Confundir storageClassName: "" con omitirlo |
El PVC no ve el PV estático, o usa una clase inesperada | "" = sin clase; ausente = clase por defecto (05-04) |
Poner selector con aprovisionamiento dinámico |
Pending eterno |
El selector solo sirve con PV estáticos |
Montar el volumen en /var/lib/postgresql |
El contenedor no arranca | Es un nivel de más: usa /var/lib/postgresql/data |
Olvidar PGDATA o subPath |
directory exists but is not empty ... lost+found |
Usa un subdirectorio del punto de montaje |
Olvidar fsGroup |
could not change permissions ... Operation not permitted |
securityContext.fsGroup con el GID del proceso |
RollingUpdate con un PVC RWO |
Dos pods escribiendo, o el nuevo atascado | strategy: Recreate |
| Escalar un Deployment con PVC | Multi-Attach error o corrupción silenciosa |
Una réplica, o StatefulSet (06-01) |
| Forzar el borrado quitando el finalizador | Volumen huérfano conectado a un nodo | Borra el pod que lo usa y deja que el finalizador se retire solo |
| Suponer que borrar el PVC conserva los datos | Con Delete, se pierden |
Comprueba la política del PV antes de borrar nada |
Consejos:
- Comando de cabecera para ver oferta y demanda a la vez:
kubectl get pvc -A -o custom-columns=\ NS:.metadata.namespace,PVC:.metadata.name,ESTADO:.status.phase,\ PV:.spec.volumeName,PEDIDO:.spec.resources.requests.storage,CLASE:.spec.storageClassName - Averigua quién usa un PVC antes de borrarlo, filtrando
kubectl get pods -o jsonconjqpor.spec.volumes[]?.persistentVolumeClaim.claimName. - Nombra los PVC por su función, no por su tamaño:
postgres-reservas-datos, nopvc-10gi. El tamaño cambia con la expansión (05-05); la función no. - Pide con holgura pero sin exagerar. En estático te vinculas al menor PV que te sirva; en dinámico creas exactamente lo pedido y podrás expandirlo después, pero nunca reducirlo.
Ejercicios
Ejercicio 1: la prueba de persistencia completa
Aplica el PVC y el Deployment definitivo de postgres-reservas del apartado 7 en rutas-norte-dev y demuestra la persistencia en tres niveles crecientes:
- Inserta tres reservas ficticias y bórralas del pod con
kubectl delete pod. - Borra el Deployment entero y vuelve a aplicarlo.
- Borra el PVC (habiendo borrado antes el Deployment) y recupera los datos rescatando el PV.
Documenta en cada paso el estado del PVC y del PV.
Ejercicio 2: diagnosticar cuatro PVC atascados
Crea estos cuatro PVC en rutas-norte-dev sabiendo que el único PV disponible es pv-postgres-reservas-10gi (10Gi, RWO, clase rutasnorte-rapida, etiqueta tipo-disco: ssd) y que ya está Bound. Para cada uno, predice si quedará Bound o Pending y por qué; después compruébalo.
| PVC | accessModes |
storageClassName |
requests.storage |
|---|---|---|---|
| A | ["ReadWriteOnce"] |
rutasnorte-rapida |
5Gi |
| B | ["ReadWriteMany"] |
rutasnorte-rapida |
5Gi |
| C | ["ReadWriteOnce"] |
"" |
5Gi |
| D | ["ReadWriteOnce"] |
rutasnorte-rapida |
50Gi |
Ejercicio 3: el PVC de redis-cache
El equipo quiere que redis-cache conserve su volcado RDB entre reinicios para no perder la caché de disponibilidad de plazas en cada despliegue. Escribe el PVC (redis-cache-datos, 2 GiB) y el fragmento del Deployment que lo monta en /data, con las etiquetas de Rutas Norte.
Después responde, razonando: ¿es una buena decisión? Ten en cuenta lo que se decidió en 03-05 sobre la naturaleza de redis-cache.
Soluciones
Ejercicio 1
kubectl apply -f k8s/base/postgres-reservas-pvc.yaml
kubectl apply -f k8s/base/postgres-reservas-deployment.yaml
kubectl rollout status deploy/postgres-reservas -n rutas-norte-dev
kubectl exec -n rutas-norte-dev deploy/postgres-reservas -- psql -U rutasnorte -d reservas -c \
"CREATE TABLE IF NOT EXISTS reservas (id serial PRIMARY KEY, cliente text, trayecto text, fecha date);
INSERT INTO reservas (cliente, trayecto, fecha) VALUES
('Marta Iglesias','Bilbao-Santander','2026-08-15'),
('Ignacio Sabater','Oviedo-Gijon','2026-08-16'),
('Lucia Berenguer','Leon-Ponferrada','2026-08-17');"
# --- Nivel 1: borrar el pod ---
kubectl delete pod -n rutas-norte-dev -l app=postgres-reservas
kubectl rollout status deploy/postgres-reservas -n rutas-norte-dev
kubectl exec -n rutas-norte-dev deploy/postgres-reservas -- \
psql -U rutasnorte -d reservas -c "SELECT count(*) FROM reservas;" # 3
kubectl get pvc,pv -n rutas-norte-dev # PVC Bound, PV Bound
# --- Nivel 2: borrar el Deployment ---
kubectl delete deploy postgres-reservas -n rutas-norte-dev
kubectl get pvc -n rutas-norte-dev # Bound: el PVC sobrevive al Deployment
kubectl apply -f k8s/base/postgres-reservas-deployment.yaml
kubectl rollout status deploy/postgres-reservas -n rutas-norte-dev # 3 reservas
# --- Nivel 3: borrar el PVC ---
kubectl delete deploy postgres-reservas -n rutas-norte-dev
kubectl delete pvc postgres-reservas-datos -n rutas-norte-dev
kubectl get pv # Released (gracias a Retain)
kubectl patch pv pv-postgres-reservas-10gi \
--type=json -p='[{"op":"remove","path":"/spec/claimRef"}]'
kubectl get pv # Available
kubectl apply -f k8s/base/postgres-reservas-pvc.yaml
kubectl apply -f k8s/base/postgres-reservas-deployment.yaml
kubectl rollout status deploy/postgres-reservas -n rutas-norte-dev
kubectl exec -n rutas-norte-dev deploy/postgres-reservas -- \
psql -U rutasnorte -d reservas -c "SELECT * FROM reservas;" # 3 filasResumen de estados:
| Paso | PVC | PV | Datos |
|---|---|---|---|
| Inicial | Bound |
Bound |
3 reservas |
| Tras borrar el pod | Bound |
Bound |
3 reservas |
| Tras borrar el Deployment | Bound |
Bound |
3 reservas |
| Tras borrar el PVC | no existe | Released |
3 reservas, inaccesibles |
| Tras el rescate | Bound |
Bound |
3 reservas |
Si la política hubiera sido Delete, el cuarto paso habría destruido los datos definitivamente.
Ejercicio 2
| PVC | Predicción | Motivo |
|---|---|---|
| A | Pending |
El único PV de esa clase está Bound. La relación es 1:1: los 5 GiB que sobran no son reutilizables |
| B | Pending |
Aunque el PV estuviera libre, pide ReadWriteMany y el PV solo ofrece ReadWriteOnce: los modos del PVC deben estar contenidos en los del PV |
| C | Pending |
storageClassName: "" solo casa con PV sin clase; el nuestro tiene rutasnorte-rapida |
| D | Pending |
Pide 50 GiB y el PV tiene 10: la capacidad del PV debe ser mayor o igual |
Los cuatro quedan Pending, cada uno por una razón distinta, y for x in a b c d; do kubectl describe pvc prueba-$x -n rutas-norte-dev | tail -3; done lo confirma con el mismo evento genérico FailedBinding: no persistent volumes available for this claim and no storage class is set — un recordatorio de que el evento dice que no hay candidato, pero no por qué: eso lo tienes que razonar tú con los cinco criterios.
Para que A se vinculara habría que liberar antes el PV (borrar el PVC de postgres-reservas y limpiar el claimRef), lo que ilustra la limitación central del aprovisionamiento estático que resuelve 05-04.
Ejercicio 3
# k8s/base/redis-cache-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-cache-datos
namespace: rutas-norte-dev
labels:
app: redis-cache
app.kubernetes.io/name: redis-cache
app.kubernetes.io/component: cache
app.kubernetes.io/part-of: rutas-norte
entorno: dev
spec:
accessModes: ["ReadWriteOnce"]
volumeMode: Filesystem
resources: { requests: { storage: 2Gi } }
storageClassName: rutasnorte-estandar
---
# fragmento del Deployment de redis-cache
spec:
securityContext:
fsGroup: 999 # usuario redis de la imagen oficial
containers:
- name: redis
image: redis:7.2-alpine
args: ["--save", "60", "1", "--appendonly", "no"]
ports: [{ name: redis, containerPort: 6379 }]
volumeMounts:
- name: datos
mountPath: /data # ruta del RDB en la imagen oficial
volumes:
- name: datos
persistentVolumeClaim: { claimName: redis-cache-datos }¿Es una buena decisión? Es aceptable, pero no es prioritaria y tiene contrapartidas. En 03-05 clasificamos redis-cache como Burstable precisamente porque es prescindible: lo que guarda —la disponibilidad de plazas cacheada— se recalcula consultando postgres-reservas. Lo que se pierde al reiniciar no es información, es un rato de latencia mayor.
A favor: tras un despliegue, la caché arranca templada y postgres-reservas no recibe una avalancha de consultas; en un puente, ese pico de recálculo puede ser notable.
En contra, tres cosas: el PVC lo convierte en una carga con estado, con replicas: 1 y Recreate obligatorios; una caché persistida puede servir datos caducos si el RDB es de hace horas y la disponibilidad de plazas ha cambiado, lo que es peor que no tener caché; y si Redis guarda cualquier dato personal de clientes, ese volumen entra en el ámbito de protección de datos y de las copias de seguridad de 05-06.
Decisión de Rutas Norte: no persistir redis-cache. Si el problema real es el pico de consultas tras un despliegue, la solución correcta es un precalentamiento controlado de la caché al arrancar, no conservar datos de disponibilidad potencialmente obsoletos.
Conclusión
La deuda que arrastraba Rutas Norte desde el módulo 2 está saldada. Has escrito el PersistentVolumeClaim —el objeto con namespace que expresa la demanda de la aplicación y la única pieza de almacenamiento que escribe un equipo de aplicación—, lo has vinculado al PV de la lección anterior y has visto la columna CLAIM llenarse por ambos lados con Bound. Y sobre todo has hecho la prueba: tres reservas insertadas, el pod borrado, el Deployment borrado, y las reservas intactas en un pod nuevo con otro nombre y otra IP. Los bytes, verificables en el nodo, viven fuera del contenedor.
Sabes cómo se produce esa vinculación. El algoritmo de emparejamiento exige cinco condiciones —misma clase exacta, capacidad suficiente, modos de acceso del PVC contenidos en los del PV, volumeMode idéntico y selector que case— y, entre los candidatos, elige el menor, de donde sale el comportamiento que sorprende: requests.storage es un mínimo y puedes recibir más espacio del que pediste, sin devolución posible y sin que la cuota lo refleje. Cuando no hay candidato, el PVC queda Pending, y ya no lo diagnosticas a ciegas: kubectl describe pvc y sus eventos distinguen en segundos entre "no hay PV compatible", "la clase no existe", "esperando al primer consumidor" —que no es un error— y "cuota excedida".
Dominas el manifiesto definitivo de la base de datos y las tres trampas que lo rodean: el mountPath correcto (/var/lib/postgresql/data, no un nivel más arriba), el subdirectorio vía PGDATA o subPath que evita el fallo de lost+found, y el fsGroup sin el cual el proceso no root no puede escribir en su propio volumen. Conoces la relación 1:1 entre PVC y PV —un PV vinculado no admite un segundo reclamante aunque le sobre espacio— y la diferencia entre las dos formas de romperse con ReadWriteOnce: en nodos distintos el Multi-Attach error te protege; en el mismo nodo nadie te protege, y por eso replicas: 1 con strategy: Recreate no es una preferencia sino un requisito. Y sabes borrar sin destruir: el finalizador kubernetes.io/pvc-protection deja el PVC en Terminating mientras un pod lo use, se resuelve eliminando al consumidor y jamás forzando el finalizador; y lo que le ocurre después al PV lo decide su política de reclamación, donde Retain te ha permitido recuperar las reservas de un borrado que con Delete habría sido definitivo.
Queda apuntado el techo de esta arquitectura: un Deployment tiene una sola plantilla, así que todas sus réplicas piden el mismo PVC y por tanto no escala; la respuesta es el volumeClaimTemplate de los StatefulSets (06-01). Y queda el otro techo, el que abría esta lección: seguimos creando los PersistentVolumes a mano, uno por uno, con el desperdicio, el trabajo manual y los huérfanos que eso implica. La solución es que el clúster cree el volumen exacto en el momento en que alguien lo pide, y el objeto que lo hace posible es el que llevamos tres lecciones nombrando en storageClassName sin explicar: Clases de Almacenamiento.
Curso de Kubernetes
Módulo 1: Introducción a Kubernetes
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
