La lección anterior terminó con un diagnóstico preciso: todos los volúmenes que conocíamos —emptyDir, configMap, secret, projectedmueren con el pod, y hostPath sobrevive pero ata los datos a un nodo concreto y abre un agujero de seguridad inaceptable. Para que postgres-reservas conserve las reservas hace falta algo distinto: un objeto de almacenamiento con vida propia en el clúster, independiente de cualquier pod. Ese objeto es el PersistentVolume (PV). En esta lección verás por qué Kubernetes lo separó del consumo, desmontarás su especificación campo a campo —incluida la trampa de los modos de acceso, que se aplican por nodo y no por pod—, recorrerás sus fases con un diagrama de estados, y crearás un PersistentVolume real de 10 GiB para la base de datos de Rutas Norte en tu clúster de prácticas.

Contenido

  1. El problema real: quién administra y quién consume
  2. Qué es un PersistentVolume
  3. Anatomía del objeto: capacity y volumeMode
  4. Los modos de acceso y la trampa del nodo
  5. La política de reclamación: Retain, Delete y el obsoleto Recycle
  6. storageClassName, mountOptions y nodeAffinity
  7. Los backends posibles y qué modos admite cada familia
  8. Ciclo de vida: las fases del PV
  9. Qué hacer con un PV en Released
  10. Práctica: un PV de 10 GiB para postgres-reservas
  11. Por qué el aprovisionamiento estático no escala

  1. El problema real: quién administra y quién consume

Antes de mirar un solo campo YAML, hay que entender qué problema organizativo resuelve esta abstracción, porque si no, la pareja PV/PVC parece burocracia innecesaria.

En Rutas Norte hay dos papeles distintos y no se solapan:

Equipo de plataforma Equipo de aplicación
Sabe Qué cabinas de disco hay, qué clases de rendimiento, en qué zonas, cuánto cuesta cada GiB, cómo se hacen los snapshots Que postgres-reservas necesita 10 GiB rápidos y que no puede perderlos
No sabe Qué necesita cada aplicación concreta Si debajo hay un EBS gp3, una LUN de iSCSI o un directorio del nodo
Escribe PersistentVolume y StorageClass PersistentVolumeClaim
Recurso Sin namespace: es del clúster Con namespace: vive junto a la aplicación

Si el manifiesto de postgres-reservas tuviera que nombrar directamente un disco de AWS con su identificador, ocurrirían tres cosas malas: el manifiesto dejaría de ser portable entre minikube, preproducción y producción; el desarrollador necesitaría credenciales y conocimiento de la infraestructura; y cualquier cambio de proveedor obligaría a reescribir todos los manifiestos de la plataforma.

La solución de Kubernetes es un contrato con dos caras:

flowchart TB
    subgraph PLATAFORMA["Equipo de plataforma (recursos de clúster)"]
        SC["StorageClass<br/>rutasnorte-rapida<br/>(05-04)"]
        PV["PersistentVolume<br/>capacity: 10Gi<br/>accessModes: RWO<br/>reclaimPolicy: Retain"]
    end
    subgraph APP["Equipo de aplicacion (namespace rutas-norte-pro)"]
        PVC["PersistentVolumeClaim<br/>'quiero 10Gi RWO<br/>de clase rutasnorte-rapida'"]
        POD["Pod postgres-reservas<br/>volumes:<br/>persistentVolumeClaim"]
    end
    CTRL["Controlador PersistentVolume<br/>(kube-controller-manager)"]

    PVC -->|"1. lo solicita"| CTRL
    PV -->|"2. candidatos disponibles"| CTRL
    CTRL -->|"3. VINCULA (Bound)"| PVC
    SC -.->|"aprovisiona bajo demanda"| PV
    POD -->|"4. monta"| PVC

La frase que resume la lección entera: el PVC es la demanda, el PV es la oferta, y el controlador los empareja. Quien despliega la aplicación escribe únicamente la demanda.

  1. Qué es un PersistentVolume

Un PersistentVolume es un objeto de la API que representa una porción concreta de almacenamiento ya existente en el clúster, aprovisionada por un administrador o creada dinámicamente por una StorageClass.

Tres propiedades definen su naturaleza y conviene fijarlas de entrada:

  1. No tiene namespace. Es un recurso de ámbito de clúster, como los nodos o las StorageClasses. Lo compruebas con lo que aprendiste en 02-06:
    kubectl api-resources | grep -E "^NAME|persistentvolume"
    
    NAME                    SHORTNAMES   APIVERSION   NAMESPACED   KIND
    persistentvolumeclaims  pvc          v1           true         PersistentVolumeClaim
    persistentvolumes       pv           v1           false        PersistentVolume
    
    El PVC tiene namespace: pertenece a la aplicación. El PV no.
  2. Su ciclo de vida es independiente del pod. Puedes borrar el Deployment entero, el pod, incluso el PVC, y el PV puede seguir existiendo con los datos dentro. Esa independencia es justamente lo que faltaba en 05-01.
  3. Es un objeto declarativo como cualquier otro. Tiene apiVersion: v1, kind: PersistentVolume, metadata, spec y status, exactamente el modelo de 01-06. Explóralo:
    kubectl explain pv.spec
    kubectl explain pv.spec.accessModes
    

  1. Anatomía del objeto: capacity y volumeMode

Un PV completo, con todos los campos que importan, comentado:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-postgres-reservas-10gi      # sin namespace: es del cluster
  labels:
    app: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
    tipo-disco: ssd                    # util para los selectores del PVC (05-03)
spec:
  capacity:
    storage: 10Gi                      # tamanyo declarado del volumen
  volumeMode: Filesystem               # Filesystem (por defecto) o Block
  accessModes:
    - ReadWriteOnce                    # un solo NODO en lectura-escritura
  persistentVolumeReclaimPolicy: Retain  # que hacer cuando se libere
  storageClassName: rutasnorte-rapida  # la clase a la que pertenece
  mountOptions:                        # opciones pasadas al montaje
    - noatime
  hostPath:                            # el backend: SOLO para pruebas
    path: /data/postgres-reservas
    type: DirectoryOrCreate

capacity.storage

El tamaño del volumen, en unidades de Kubernetes (Gi = 2^30 bytes, G = 10^9 bytes; usa siempre Gi). Es un dato declarado, no medido: Kubernetes no comprueba que el disco tenga realmente ese tamaño. Con un backend hostPath de pruebas puedes escribir capacity: 10Gi sobre un directorio de un disco de 4 GiB y nadie protestará hasta que se llene. Su función real es servir de criterio en el emparejamiento con el PVC.

Hoy storage es el único recurso admitido en capacity; atributos como IOPS se expresan mediante los parameters de la StorageClass (05-04).

volumeMode: Filesystem frente a Block

Filesystem (por defecto) Block
Qué recibe el pod Un directorio montado en mountPath Un dispositivo de bloques crudo en devicePath
Formateo Lo hace Kubernetes/CSI (ext4, xfs) No hay sistema de ficheros
Campo en el contenedor volumeMounts volumeDevices
Uso típico El 99 % de los casos, incluido PostgreSQL Bases de datos que gestionan su propio E/S, sistemas de almacenamiento distribuido

En modo bloque el contenedor no usa volumeMounts sino volumeDevices, con devicePath: /dev/xvda en lugar de mountPath: el dispositivo aparece crudo, sin sistema de ficheros.

En Rutas Norte usamos Filesystem en todo. Block solo tiene sentido si la aplicación sabe escribir directamente sobre el dispositivo, y PostgreSQL no lo hace.

  1. Los modos de acceso y la trampa del nodo

Los accessModes declaran de qué formas puede montarse el volumen. Son cuatro:

Modo Abreviatura Significado exacto
ReadWriteOnce RWO Lectura y escritura desde un solo nodo
ReadOnlyMany ROX Solo lectura desde muchos nodos
ReadWriteMany RWX Lectura y escritura desde muchos nodos
ReadWriteOncePod RWOP Lectura y escritura desde un solo pod en todo el clúster

La trampa está en ReadWriteOnce, y casi todo el mundo cae en ella al menos una vez. El "Once" se refiere a un nodo, no a un pod. Esto significa que dos, tres o veinte pods pueden montar simultáneamente el mismo volumen RWO en lectura y escritura si el planificador los coloca en el mismo nodo. Ninguna capa de Kubernetes lo impedirá.

La consecuencia para una base de datos es directa y grave: si un rollout con estrategia RollingUpdate levanta un pod nuevo de postgres-reservas en el mismo nodo antes de terminar el viejo, tendrás dos procesos de PostgreSQL sobre el mismo directorio de datos. Por eso postgres-reservas lleva strategy: Recreate desde el módulo 2, y por eso existe accessModes: ["ReadWriteOncePod"].

ReadWriteOncePod es la única garantía de que un solo pod puede usar el volumen; el segundo se queda en Pending con un evento explícito. Es un modo estable desde Kubernetes 1.29 y requiere que el driver CSI lo soporte. Cuando el driver lo permite, es la elección correcta para una base de datos con una única réplica.

Dos advertencias más sobre los modos:

  • La lista es una declaración de capacidades, no una restricción activa. Un PV puede declarar ["ReadWriteOnce", "ReadOnlyMany"] para decir que admite ambos usos; el PVC elegirá uno.
  • Lo que un backend admite de verdad no depende de lo que escribas. Puedes poner ReadWriteMany en un PV respaldado por un disco de bloques de AWS, y Kubernetes lo aceptará sin rechistar; el fallo aparecerá al intentar montarlo desde el segundo nodo. La tabla del apartado 7 recoge qué admite realmente cada familia.

  1. La política de reclamación: Retain, Delete y el obsoleto Recycle

persistentVolumeReclaimPolicy decide qué pasa con el volumen y con los datos cuando el PVC que lo usaba se borra. Es el campo con más consecuencias del objeto.

Política Al borrar el PVC Estado del PV ¿Se pierden los datos?
Retain No se hace nada con el volumen Pasa a Released No. Quedan intactos, esperando intervención manual
Delete Se borra el PV y el volumen real subyacente Desaparece el objeto PV Sí, de forma irreversible
Recycle Un rm -rf /volumen/* y vuelve a Available Available

Recycle está obsoleto y no debe usarse: solo funcionaba con hostPath y NFS, borraba con un pod auxiliar sin ninguna garantía, y no encaja con el modelo CSI. Su sustituto es el aprovisionamiento dinámico (05-04): en lugar de reciclar un volumen usado, se crea uno nuevo y limpio.

La decisión práctica en Rutas Norte:

  • postgres-reservasRetain. El escenario que hay que evitar es que alguien borre por error el namespace rutas-norte-pro —recuerda de 02-06 que kubectl delete ns arrastra los PVC— y que eso destruya los datos de todos los clientes. Con Retain, el PVC desaparece pero el volumen sigue ahí y se puede recuperar.
  • Volúmenes reconstruibles (caché, espacios de trabajo) → Delete. Recrear es más barato que limpiar a mano, y Delete evita facturar discos huérfanos.

Un detalle importante que confunde mucho: en el aprovisionamiento dinámico el PV hereda la política de la StorageClass, y la mayoría de las clases por defecto de las nubes usan Delete. Es decir, si no haces nada, borrar un PVC de producción borra el disco. Se comprueba con kubectl get storageclass -o custom-columns=NOMBRE:.metadata.name,POLITICA:.reclaimPolicy.

Y en un PV ya existente se puede cambiar en caliente, que es la maniobra defensiva estándar antes de tocar nada:

kubectl patch pv pv-postgres-reservas-10gi \
  -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}'

  1. storageClassName, mountOptions y nodeAffinity

storageClassName

Es una etiqueta de emparejamiento: un PVC solo se vincula a un PV cuya clase coincida exactamente. Tres situaciones que hay que distinguir con cuidado (y que volveremos a ver en 05-04):

En el PV En el PVC Resultado
storageClassName: rapida storageClassName: rapida Pueden emparejarse
Campo ausente o "" storageClassName: "" Pueden emparejarse (PV estático "sin clase")
Campo ausente Campo ausente El PVC usará la clase por defecto y no mirará ese PV

Un PV estático puede llevar un storageClassName que no corresponde a ninguna StorageClass existente (por ejemplo manual). Es una práctica habitual y perfectamente válida: la cadena solo se usa para casar oferta y demanda.

mountOptions

Opciones que se pasan tal cual al montaje del sistema de ficheros —noatime y nodiratime para reducir escrituras, o hard y nfsvers=4.1 en NFS—. Kubernetes no las valida: si el backend no las admite, el montaje falla y el pod se queda en ContainerCreating con un evento de FailedMount.

nodeAffinity

Restringe desde qué nodos puede montarse el volumen. Es obligatorio en los volúmenes de tipo local, porque el disco está físicamente en una máquina y el planificador debe saberlo para no colocar el pod donde el volumen no existe.

spec:
  local:
    path: /mnt/discos/ssd1
  nodeAffinity:
    required:
      nodeSelectorTerms:
        - matchExpressions:
            - key: kubernetes.io/hostname
              operator: In
              values: ["rutas-norte"]      # el nodo de minikube

Este es un caso de el almacenamiento condicionando la planificación, un tema que se profundiza en 06-05. En la nube el mecanismo es idéntico pero la clave suele ser topology.kubernetes.io/zone: un disco creado en eu-west-1a solo puede montarse desde nodos de esa zona, y de ahí nace el problema del volumeBindingMode que resolverá 05-04.

  1. Los backends posibles y qué modos admite cada familia

El spec del PV contiene exactamente una clave de backend, que define de dónde sale el almacenamiento de verdad.

hostPath y local: para pruebas y para discos locales

hostPath como backend de un PV tiene los mismos peligros descritos en 05-01 y solo debe usarse en clústeres de un solo nodo como tu minikube. local es su primo serio: también usa un disco de la máquina, pero es un tipo pensado para producción, respeta la planificación gracias a la nodeAffinity obligatoria y no permite rutas arbitrarias del sistema. Aun así, un volumen local no sobrevive a la pérdida del nodo: la redundancia es responsabilidad de la aplicación (replicación de PostgreSQL, por ejemplo).

NFS: el clásico compartido

spec:
  capacity: { storage: 100Gi }
  accessModes: ["ReadWriteMany"]     # varios nodos a la vez: esta es su gracia
  nfs:
    server: nas.rutasnorte.example
    path: /exports/adjuntos
  mountOptions: ["hard", "nfsvers=4.1"]

NFS es la vía más sencilla para conseguir ReadWriteMany, útil por ejemplo para que las tres réplicas de tienda-web compartan un directorio de imágenes de trayectos. No es adecuado para el fichero de datos de PostgreSQL: el bloqueo de ficheros sobre NFS es una fuente clásica de corrupción.

Controladores CSI: lo que se usa en producción

En un clúster gestionado no escribirás casi nunca un PV a mano; los creará el driver CSI del proveedor. Cuando los inspecciones, verás una clave csi con driver: ebs.csi.aws.com, el volumeHandle (el identificador del disco real en el proveedor), el fsType y unos volumeAttributes.

La arquitectura de CSI se estudia a fondo en 05-05.

Qué modos admite realmente cada familia

Tabla orientativa; la verdad última la marca el driver y su versión.

Familia RWO ROX RWX RWOP Nota
hostPath (un nodo) Todo "funciona" porque solo hay un nodo. No es producción
local No Atado al nodo por nodeAffinity
NFS La opción clásica para compartir
Discos de bloques en la nube (EBS, PD, Azure Disk) No No Un disco solo se conecta a una máquina
Ficheros compartidos en la nube (EFS, Filestore, Azure Files) Más caro y con más latencia
Ceph RBD No Bloques
CephFS Sistema de ficheros distribuido

La regla que se deriva: si necesitas ReadWriteMany, necesitas un sistema de ficheros compartido, y eso es una decisión de arquitectura y de coste, no un campo YAML. Para postgres-reservas no lo necesitamos: una base de datos con una réplica quiere ReadWriteOnce (o mejor ReadWriteOncePod) sobre un disco de bloques rápido.

  1. Ciclo de vida: las fases del PV

El PV tiene un campo status.phase con cuatro valores posibles:

stateDiagram-v2
    [*] --> Available: se crea el PV (estatico)<br/>o lo aprovisiona la StorageClass
    Available --> Bound: el controlador lo empareja<br/>con un PVC compatible
    Bound --> Released: se borra el PVC<br/>(los datos SIGUEN ahi)
    Released --> [*]: reclaimPolicy Delete<br/>-> se borra PV y volumen
    Released --> Available: intervencion manual<br/>(reclaimPolicy Retain)
    Available --> Failed: fallo en el aprovisionamiento<br/>o en la recuperacion
    Bound --> Failed: fallo del backend
    Failed --> [*]: correccion manual
Fase Significado Qué hacer
Available Libre y disponible para ser vinculado Nada; espera a un PVC
Bound Vinculado a un PVC concreto Estado normal de operación
Released El PVC se borró; el PV conserva los datos pero no puede reutilizarse tal cual Recuperar los datos o liberarlo a mano (apartado 9)
Failed La recuperación automática falló Investigar con kubectl describe pv

Comprobación en el clúster:

kubectl get pv
NAME                          CAPACITY  ACCESS MODES  RECLAIM POLICY  STATUS      CLAIM                                     STORAGECLASS        AGE
pv-postgres-reservas-10gi     10Gi      RWO           Retain          Bound       rutas-norte-dev/postgres-reservas-datos   rutasnorte-rapida   4m
pv-adjuntos-50gi              50Gi      RWX           Delete          Available                                             rutasnorte-estandar 4m

La columna CLAIM te dice, para cada PV, qué PVC de qué namespace lo tiene tomado. Es la vista de un vistazo que más se usa en el día a día.

  1. Qué hacer con un PV en Released

Esta es una situación operativa que aparece siempre y desconcierta la primera vez. Alguien borra el PVC de postgres-reservas; como la política es Retain, el PV pasa a Released. Creas de nuevo el mismo PVC, con el mismo nombre y la misma petición… y se queda en Pending para siempre, aunque el PV que quiere está ahí con 10Gi libres.

La causa es que el PV conserva en spec.claimRef la referencia al PVC anterior, incluido su uid:

kubectl get pv pv-postgres-reservas-10gi -o jsonpath='{.spec.claimRef}'; echo
{"kind":"PersistentVolumeClaim","namespace":"rutas-norte-dev",
 "name":"postgres-reservas-datos","uid":"1c5b7f2a-3d9e-4c11-9f88-0a2b6d4e7c31"}

Aunque el PVC nuevo se llame igual, su uid es distinto, así que no casa. Y ese bloqueo es deliberado: es la protección que impide que los datos de un inquilino acaben montados por error en el pod de otro.

El procedimiento correcto tiene tres caminos, en orden de preferencia:

a) Reutilizar el volumen para el mismo propósito (el caso de una recuperación tras un borrado accidental). Se elimina la referencia caduca y el PV vuelve a Available:

kubectl patch pv pv-postgres-reservas-10gi \
  --type=json -p='[{"op": "remove", "path": "/spec/claimRef"}]'

kubectl get pv pv-postgres-reservas-10gi
NAME                        CAPACITY  ACCESS MODES  RECLAIM POLICY  STATUS      CLAIM   STORAGECLASS        AGE
pv-postgres-reservas-10gi   10Gi      RWO           Retain          Available           rutasnorte-rapida   1h

Ahora cualquier PVC compatible puede tomarlo. Los datos siguen dentro.

b) Preasignarlo a un PVC concreto, que es más seguro que dejarlo suelto. Se edita el claimRef dejando namespace y name pero sin uid: el PV solo aceptará ese PVC exacto.

spec:
  claimRef:
    apiVersion: v1
    kind: PersistentVolumeClaim
    namespace: rutas-norte-dev
    name: postgres-reservas-datos
    # sin uid: se rellenara al vincularse

c) Descartarlo. Si los datos ya no valen, se borra el PV y se limpia el volumen real a mano (con Retain, borrar el objeto PV no borra los datos del backend: en la nube el disco sigue facturándose).

Consejo operativo: antes de reutilizar un PV Released que contenga datos, haz una copia. El apartado 10 crea el volumen; la copia de seguridad se estudia en 05-06, pero la regla se aplica desde hoy.

  1. Práctica: un PV de 10 GiB para postgres-reservas

Vamos a crear el volumen que salda la deuda del módulo. En minikube el respaldo será un directorio del nodo, y aquí hostPath es aceptable por ser un clúster de un único nodo y de pruebas; el manifiesto lleva un comentario que lo deja explícito para que nadie lo copie a producción.

Primero preparamos el directorio en el nodo:

minikube ssh -p rutas-norte -- "sudo mkdir -p /data/postgres-reservas && \
  sudo chmod 777 /data/postgres-reservas && ls -ld /data/postgres-reservas"

En minikube, /data es un directorio que persiste a los reinicios de la máquina virtual, a diferencia de la mayoría del sistema de ficheros. Por eso es el sitio correcto para esta práctica.

Ahora el PersistentVolume:

# k8s/base/pv-postgres-reservas.yaml
#
# AVISO: el backend hostPath es valido UNICAMENTE en el minikube de practicas,
# que es un cluster de un solo nodo. En pre y pro este PV lo crea el driver CSI
# de forma dinamica a partir de la StorageClass rutasnorte-rapida (ver 05-04).
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-postgres-reservas-10gi
  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
    tipo-disco: ssd
spec:
  capacity:
    storage: 10Gi
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  # Retain: si alguien borra el PVC (o el namespace entero), los datos
  # de reservas y clientes NO se destruyen. Decision deliberada.
  persistentVolumeReclaimPolicy: Retain
  storageClassName: rutasnorte-rapida
  mountOptions:
    - noatime
  hostPath:
    path: /data/postgres-reservas
    type: DirectoryOrCreate
kubectl apply -f k8s/base/pv-postgres-reservas.yaml
kubectl get pv pv-postgres-reservas-10gi
persistentvolume/pv-postgres-reservas-10gi created

NAME                        CAPACITY  ACCESS MODES  RECLAIM POLICY  STATUS      CLAIM  STORAGECLASS        AGE
pv-postgres-reservas-10gi   10Gi      RWO           Retain          Available          rutasnorte-rapida   3s

Available: existe, está libre y espera un PVC. Fíjate en que no hay ningún namespace en la salida: es un recurso del clúster.

Vista detallada:

kubectl describe pv pv-postgres-reservas-10gi
Name:              pv-postgres-reservas-10gi
Finalizers:        [kubernetes.io/pv-protection]
StorageClass:      rutasnorte-rapida
Status:            Available
Claim:
Reclaim Policy:    Retain
Access Modes:      RWO
VolumeMode:        Filesystem
Capacity:          10Gi
Node Affinity:     <none>
Source:
    Type:          HostPath (bare host directory volume)
    Path:          /data/postgres-reservas
    HostPathType:  DirectoryOrCreate
Events:            <none>

Dos detalles que merece la pena leer:

  • Finalizers: [kubernetes.io/pv-protection]. Es el mecanismo de 01-06: impide que el PV se borre mientras esté vinculado a un PVC. Si borras un PV en uso, el objeto queda en Terminating hasta que deja de estarlo.
  • Claim: vacío. Nadie lo ha reclamado todavía. Ese hueco lo llenará el PVC de la lección siguiente.

Ahora comprueba lo que todavía no ocurre: el pod de postgres-reservas sigue sin usarlo. Un PV no se monta solo; hace falta un PVC que lo reclame. Ese es exactamente el contenido de 05-03.

  1. Por qué el aprovisionamiento estático no escala

Lo que acabas de hacer se llama aprovisionamiento estático: un humano crea el PV a mano, por adelantado, y espera que alguien lo reclame. Funciona, y para un directorio en minikube o para una cabina de almacenamiento fija en un centro de datos propio es perfectamente razonable. Pero como modelo general falla por cuatro sitios:

Problema Por qué duele
Trabajo manual en el camino crítico Cada equipo que despliega algo con estado tiene que esperar a que un administrador cree su PV. La plataforma se convierte en un cuello de botella
Ajuste de tallas imposible Si preparas PV de 10, 20 y 100 GiB, un PVC de 12 GiB se vinculará al de 20 y desperdiciarás 8 GiB; y si pide 150 GiB no habrá candidato aunque haya terabytes libres
Volúmenes huérfanos Los PV Released con Retain se acumulan; en la nube siguen facturando aunque nadie los use
Topología a ciegas El administrador crea el PV en una zona sin saber dónde acabará el pod; el emparejamiento puede dejar el pod imposible de planificar

La solución es invertir el orden: en lugar de crear volúmenes por si acaso, crear el volumen exacto en el momento en que alguien lo pide. Eso es el aprovisionamiento dinámico, y el objeto que lo hace posible es la StorageClass, que ya hemos nombrado en storageClassName sin explicarla. La verás en 05-04 y la maquinaria que hay detrás, CSI, en 05-05.

La buena noticia es que lo que estás aprendiendo no cambia: en aprovisionamiento dinámico siguen existiendo PV con exactamente estos campos —capacidad, modos de acceso, política de reclamación, afinidad de nodo—; la única diferencia es quién los escribe.

Errores Comunes y Consejos

Error Síntoma Solución
Creer que ReadWriteOnce significa "un pod" Dos pods escriben a la vez y corrompen los datos Significa un nodo. Usa ReadWriteOncePod y strategy: Recreate
Declarar ReadWriteMany sobre un disco de bloques El pod del segundo nodo no arranca: Multi-Attach error Kubernetes no valida los modos; consulta la tabla del backend
Dejar la política Delete en un volumen de datos Se borra un PVC y desaparecen los datos Retain en todo lo que guarde datos de negocio
Intentar reutilizar un PV Released El PVC nuevo se queda en Pending sin explicación clara Elimina spec.claimRef con kubectl patch --type=json
Crear un PV local sin nodeAffinity El pod se planifica en un nodo sin el disco: FailedMount La nodeAffinity es obligatoria en local
Suponer que capacity mide algo El volumen se llena mucho antes de lo declarado capacity es un dato declarado, no medido
hostPath en un clúster de varios nodos Los datos "cambian" según dónde caiga el pod Solo en clústeres de un nodo; en producción, CSI
Poner mountOptions que el backend no admite Pod atascado en ContainerCreating, evento FailedMount Comprueba las opciones del driver antes
Buscar el PV con -n <namespace> "No resources found" pese a que existe El PV no tiene namespace

Consejos operativos:

  1. Etiqueta los PV. Sin etiquetas no puedes usarlos con selector desde un PVC ni filtrarlos en una auditoría. Usa las mismas convenciones que el resto de Rutas Norte, incluida app.kubernetes.io/part-of.
  2. Auditoría rápida de riesgo — qué volúmenes se destruirían al borrar su PVC:
    kubectl get pv -o custom-columns=\
    NOMBRE:.metadata.name,POLITICA:.spec.persistentVolumeReclaimPolicy,\
    ESTADO:.status.phase,CLAIM:.spec.claimRef.name | grep Delete
    
  3. Antes de cualquier operación delicada, cambia la política a Retain con kubectl patch. Es reversible y ha salvado muchas bases de datos.
  4. kubectl get pv,pvc -A en una sola orden te da la foto completa de oferta y demanda del clúster.

Ejercicios

Ejercicio 1: el PV de la base de datos y sus fases

Crea el PersistentVolume pv-postgres-reservas-10gi del apartado 10 en tu minikube. Después:

  1. Comprueba que es un recurso sin namespace y que su fase es Available.
  2. Escribe un fichero dentro del directorio del nodo y verifica que está ahí.
  3. Cambia su política de reclamación a Delete con kubectl patch y vuelve a dejarla en Retain, comprobando el cambio en cada paso.
  4. Intenta borrar el PV y explica qué finalizador aparece en kubectl describe.

Ejercicio 2: diseñar tres PV para Rutas Norte

El equipo de plataforma debe preparar el almacenamiento de tres necesidades. Para cada una, decide capacity, accessModes, persistentVolumeReclaimPolicy, volumeMode y el tipo de backend, y justifica cada elección:

  • A. Los datos de postgres-reservas en producción: 200 GiB, una sola instancia, contienen datos personales de clientes, rendimiento crítico.
  • B. Un directorio de imágenes de trayectos que las tres réplicas de tienda-web deben leer simultáneamente, y que un proceso de administración actualiza una vez al día: 20 GiB.
  • C. Un espacio de trabajo de 500 GiB para que informes-ocupacion genere el informe nocturno y lo borre al terminar.

Escribe el manifiesto del caso B.

Ejercicio 3: rescatar un PV bloqueado

Reproduce y resuelve el incidente clásico:

  1. Crea un PV pv-practica-rescate de 1 GiB con Retain, clase manual y respaldo hostPath en /data/rescate.
  2. Crea un PVC llamado datos-practica en rutas-norte-dev que lo vincule y escribe un fichero testigo desde un pod.
  3. Borra el PVC y observa la fase del PV.
  4. Vuelve a crear el mismo PVC. ¿Qué ocurre y por qué?
  5. Arréglalo sin perder el fichero testigo.

Soluciones

Ejercicio 1

# 1
kubectl apply -f k8s/base/pv-postgres-reservas.yaml
kubectl get pv -n rutas-norte-dev pv-postgres-reservas-10gi   # el -n se IGNORA
kubectl api-resources --namespaced=false | grep persistentvolumes
kubectl get pv pv-postgres-reservas-10gi -o jsonpath='{.status.phase}'; echo
persistentvolumes    v1    false    PersistentVolume
Available
# 2
minikube ssh -p rutas-norte -- "echo 'testigo del PV' | sudo tee /data/postgres-reservas/PRUEBA.txt"

# 3
for P in Delete Retain; do
  kubectl patch pv pv-postgres-reservas-10gi \
    -p "{\"spec\":{\"persistentVolumeReclaimPolicy\":\"$P\"}}"
  kubectl get pv pv-postgres-reservas-10gi \
    -o jsonpath='{.spec.persistentVolumeReclaimPolicy}'; echo
done

# 4
kubectl describe pv pv-postgres-reservas-10gi | grep -i finalizers
Delete
Retain
Finalizers:  [kubernetes.io/pv-protection]

Mientras el PV esté Available el borrado es inmediato: el finalizador se retira solo. Si estuviera Bound a un PVC, el objeto quedaría en Terminating hasta que el PVC desapareciera. Es la protección de volumen en uso, hermana de la pvc-protection que verás en 05-03.

Ejercicio 2

Caso capacity accessModes reclaimPolicy volumeMode Backend
A. postgres-reservas pro 200Gi ReadWriteOncePod Retain Filesystem Disco de bloques SSD por CSI
B. Imágenes de trayectos 20Gi ReadWriteMany Retain Filesystem NFS o fichero compartido en la nube
C. Espacio de informes-ocupacion 500Gi ReadWriteOnce Delete Filesystem Disco de bloques estándar

Justificaciones:

  • A. Una sola instancia de PostgreSQL: ReadWriteOncePod garantiza que ningún segundo pod pueda montarlo, ni siquiera en el mismo nodo, lo que elimina el riesgo de corrupción por dos procesos. Retain porque contiene datos personales de clientes y un borrado accidental del PVC no puede destruirlos. Disco de bloques por rendimiento: los ficheros compartidos añaden latencia inaceptable para una base de datos.
  • B. Varios lectores en nodos distintos exigen ReadWriteMany, y eso obliga a un sistema de ficheros compartido: un disco de bloques no sirve aquí por mucho que se escriba RWX en el YAML. Retain porque las imágenes son contenido de negocio recuperable pero costoso de regenerar.
  • C. Datos completamente reconstruibles: Delete evita pagar 500 GiB huérfanos cada noche. En realidad, el caso C es el candidato perfecto para un volumen efímero genérico (05-01), que ni siquiera requiere crear el PV.
# k8s/base/pv-imagenes-trayectos.yaml
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-imagenes-trayectos-20gi
  labels:
    app: tienda-web
    app.kubernetes.io/name: imagenes-trayectos
    app.kubernetes.io/part-of: rutas-norte
    entorno: pro
spec:
  capacity:
    storage: 20Gi
  volumeMode: Filesystem
  accessModes:
    - ReadWriteMany          # las 3 replicas de tienda-web leen a la vez
  persistentVolumeReclaimPolicy: Retain
  storageClassName: rutasnorte-compartida
  mountOptions:
    - hard
    - nfsvers=4.1
  nfs:
    server: nas.rutasnorte.example
    path: /exports/imagenes-trayectos

Ejercicio 3

# 1
minikube ssh -p rutas-norte -- "sudo mkdir -p /data/rescate && sudo chmod 777 /data/rescate"

kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-practica-rescate
  labels: { app: practica, entorno: dev }
spec:
  capacity: { storage: 1Gi }
  accessModes: ["ReadWriteOnce"]
  persistentVolumeReclaimPolicy: Retain
  storageClassName: manual
  hostPath: { path: /data/rescate, type: DirectoryOrCreate }
EOF

# 2
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: datos-practica
  namespace: rutas-norte-dev
  labels: { app: practica, entorno: dev }
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: manual
  resources: { requests: { storage: 1Gi } }
EOF

kubectl get pv pv-practica-rescate
minikube ssh -p rutas-norte -- "echo 'RESERVA-2026-0815-MARTA' | sudo tee /data/rescate/testigo.txt"

# 3
kubectl delete pvc datos-practica -n rutas-norte-dev
kubectl get pv pv-practica-rescate
NAME                  CAPACITY  ACCESS MODES  RECLAIM POLICY  STATUS     CLAIM                            STORAGECLASS  AGE
pv-practica-rescate   1Gi       RWO           Retain          Released   rutas-norte-dev/datos-practica   manual        2m
# 4
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: datos-practica
  namespace: rutas-norte-dev
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: manual
  resources: { requests: { storage: 1Gi } }
EOF

kubectl get pvc datos-practica -n rutas-norte-dev
kubectl describe pvc datos-practica -n rutas-norte-dev | tail -5
NAME             STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
datos-practica   Pending                                      manual         20s

Se queda en Pending. Por qué: el PV está en Released, no en Available, porque conserva un spec.claimRef que apunta al PVC anterior con su uid. El PVC nuevo se llama igual pero tiene otro uid, así que el controlador no lo considera candidato. Además, un PV en fase Released nunca se ofrece para vincular.

# 5
kubectl get pv pv-practica-rescate -o jsonpath='{.spec.claimRef.uid}'; echo
kubectl patch pv pv-practica-rescate \
  --type=json -p='[{"op": "remove", "path": "/spec/claimRef"}]'
kubectl get pvc datos-practica -n rutas-norte-dev
minikube ssh -p rutas-norte -- "cat /data/rescate/testigo.txt"
NAME             STATUS   VOLUME                CAPACITY   ACCESS MODES   STORAGECLASS   AGE
datos-practica   Bound    pv-practica-rescate   1Gi        RWO            manual         2m

RESERVA-2026-0815-MARTA

El fichero testigo sigue intacto: Retain cumplió su función. Para limpiar, borra el PVC, repite el patch del claimRef y borra el PV.

Conclusión

Has incorporado la abstracción que sostiene todo el almacenamiento de Kubernetes, y lo primero que has entendido es por qué existe: separa a quien administra el almacenamiento —el equipo de plataforma, que escribe PersistentVolumes y StorageClasses, recursos del clúster— de quien lo consume —el equipo de aplicación, que escribe un PersistentVolumeClaim en su namespace sin saber qué hay debajo—. Gracias a esa separación, el manifiesto de postgres-reservas es idéntico en minikube y en producción aunque debajo haya un directorio del nodo o un SSD de una nube.

Conoces la especificación completa del PV. La capacity, que es declarada y no medida. El volumeMode, con Filesystem para el 99 % de los casos y Block para quien gestione su propio E/S. Los cuatro modos de acceso, y sobre todo la trampa que más caro se paga: ReadWriteOnce significa un nodo, no un pod, de modo que dos pods en la misma máquina pueden escribir a la vez sobre los ficheros de PostgreSQL; la garantía real es ReadWriteOncePod, estable desde 1.29. La política de reclamación, con Retain para todo lo que guarde datos de negocio, Delete para lo reconstruible —y el aviso de que las clases por defecto de las nubes vienen en Delete, así que borrar un PVC borra el disco— y Recycle obsoleto y sustituido por el aprovisionamiento dinámico. Y storageClassName como etiqueta de emparejamiento, mountOptions sin validación previa y nodeAffinity obligatoria en los volúmenes local, primer ejemplo de almacenamiento que condiciona la planificación.

Sabes qué backends existen y qué admite cada familia de verdad —hostPath y local para pruebas y discos locales, NFS como vía sencilla a ReadWriteMany, y los controladores CSI en producción— y tienes claro que escribir ReadWriteMany sobre un disco de bloques no lo convierte en compartido. Dominas las fases (Available, Bound, Released, Failed) y, muy en particular, la operación que aparece siempre en un incidente real: un PV atrapado en Released por su claimRef caduco, que se rescata eliminando ese campo con kubectl patch --type=json sin perder un solo byte.

Y has creado el volumen: pv-postgres-reservas-10gi, Retain, ReadWriteOnce, clase rutasnorte-rapida, esperando en fase Available con la columna CLAIM vacía. Ese hueco vacío es el argumento de la lección siguiente. Un PersistentVolume no se monta solo: alguien tiene que reclamarlo desde el namespace de la aplicación, y ese alguien es el objeto que convierte por fin en persistente a la base de datos de Rutas Norte. Lo construimos en Reclamaciones de Volúmenes Persistentes, donde además haremos la prueba que llevamos todo el módulo prometiendo: crear una reserva, borrar el pod y encontrarla intacta.

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