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

  1. Qué es exactamente un PersistentVolumeClaim
  2. Anatomía del objeto campo a campo
  3. El algoritmo de emparejamiento
  4. Por qué puedes recibir más espacio del que pediste
  5. El estado Pending y su diagnóstico
  6. Consumir el PVC desde el pod
  7. El manifiesto definitivo de postgres-reservas
  8. La demostración: los datos sobreviven al pod
  9. La relación 1:1 y el peligro de dos pods sobre un RWO
  10. Borrar un PVC: finalizadores y Terminating
  11. Por qué un Deployment con PVC no escala

  1. 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:

  1. 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.
  2. 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.

  1. 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 concreto

Los 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.

  1. 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:

  1. La misma storageClassName, comparada como cadena exacta. "" y ausente no son lo mismo.
  2. capacity.storage del PV ≥ requests.storage del PVC. Mayor o igual, nunca menor.
  3. Los accessModes del PVC deben estar contenidos en los del PV. Si el PVC pide ["ReadWriteMany"] y el PV ofrece ["ReadWriteOnce"], no hay trato.
  4. volumeMode idéntico.
  5. El selector del 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.

  1. 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: 20Gi de 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ó.

  1. El estado Pending y su diagnóstico

Pending 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.

kubectl describe pvc postgres-reservas-datos -n rutas-norte-dev

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-dev

Un 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.

  1. 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: false

Eso 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.

  1. El manifiesto definitivo de postgres-reservas

Ha 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-rapida
kubectl apply -f k8s/base/postgres-reservas-pvc.yaml
kubectl get pvc,pv -n rutas-norte-dev
NAME                                            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   1h

Bound 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-datos

El 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 permitted

securityContext.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.

  1. 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-dev

Creamos 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

  1. 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-dev
NAME              STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS        AGE
otro-reclamante   Pending                                      rutasnorte-rapida   10s

Pending: 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 another

Ese 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: 1
  • strategy: Recreate (termina el pod viejo antes de crear el nuevo)
  • y, si el driver lo soporta, ReadWriteOncePod en PV y PVC.

  1. Borrar un PVC: finalizadores y Terminating

Borrar un PVC en uso es una operación que casi nunca sale como el principiante espera, y por buenas razones.

kubectl delete pvc postgres-reservas-datos -n rutas-norte-dev
persistentvolumeclaim "postgres-reservas-datos" deleted

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}'; echo
NAME                      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 desaparece

Nunca 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;"   # 2

Esa 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.

  1. 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-reservas

En 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:

kubectl scale deploy postgres-reservas -n rutas-norte-dev --replicas=1

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:

  1. 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
    
  2. Averigua quién usa un PVC antes de borrarlo, filtrando kubectl get pods -o json con jq por .spec.volumes[]?.persistentVolumeClaim.claimName.
  3. Nombra los PVC por su función, no por su tamaño: postgres-reservas-datos, no pvc-10gi. El tamaño cambia con la expansión (05-05); la función no.
  4. 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:

  1. Inserta tres reservas ficticias y bórralas del pod con kubectl delete pod.
  2. Borra el Deployment entero y vuelve a aplicarlo.
  3. 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 filas

Resumen 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

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados