Llevamos tres lecciones escribiendo storageClassName: rutasnorte-rapida sin haber creado nunca esa clase, y funcionaba porque el emparejamiento estático solo comparaba cadenas de texto. Se acabó el truco. La StorageClass es el objeto que convierte esa cadena en algo con consecuencias reales: el clúster crea el volumen exacto en el momento en que alguien lo pide, con el tipo de disco, la política de reclamación y las capacidades que la clase define. Es el salto del aprovisionamiento estático al dinámico, y cambia por completo la operación diaria: nadie vuelve a crear un PersistentVolume a mano. En esta lección desmontarás la StorageClass campo a campo —con especial atención a volumeBindingMode, responsable de uno de los fallos más caros y difíciles de diagnosticar en la nube—, entenderás la clase por defecto y la diferencia entre storageClassName: "" y omitir el campo, diseñarás las dos clases de Rutas Norte y verás aparecer un PersistentVolume solo, sin escribirlo.

Contenido

  1. Del aprovisionamiento estático al dinámico
  2. Anatomía de la StorageClass
  3. provisioner: quién crea el volumen
  4. parameters: los detalles del disco
  5. reclaimPolicy y allowVolumeExpansion
  6. volumeBindingMode: el campo que evita un desastre
  7. La clase por defecto y sus trampas
  8. storageClassName: "" frente a omitir el campo
  9. Las clases que traen minikube y las nubes gestionadas
  10. Las clases de almacenamiento de Rutas Norte
  11. Práctica: un PVC que crea su propio volumen
  12. Migrar un PVC de una clase a otra

  1. Del aprovisionamiento estático al dinámico

Repasemos el flujo que has practicado hasta ahora y comparémoslo con el que vas a construir:

flowchart LR
    subgraph EST["ESTATICO (05-02, 05-03)"]
        direction TB
        A1["Un humano crea el PV<br/>por adelantado"] --> A4{"Hay PV compatible<br/>cuando llega el PVC?"}
        A4 -->|"Si"| A5["Bound"]
        A4 -->|"No"| A6["Pending<br/>hasta que alguien actue"]
    end
    subgraph DIN["DINAMICO (esta leccion)"]
        direction TB
        B1["El equipo crea el PVC<br/>con storageClassName"] --> B2["El aprovisionador<br/>crea el volumen REAL"]
        B2 --> B3["Se crea el PV<br/>automaticamente"] --> B4["Bound, en segundos"]
    end

Los cuatro problemas del estático que enumeramos en 05-02 desaparecen de golpe:

Problema del estático Cómo lo resuelve el dinámico
Trabajo manual en el camino crítico El equipo de aplicación se autoservicio: crea el PVC y el volumen aparece
Desperdicio por ajuste de tallas El volumen se crea del tamaño exacto pedido
Volúmenes huérfanos Con reclaimPolicy: Delete, el volumen se destruye al borrar el PVC
Topología a ciegas volumeBindingMode: WaitForFirstConsumer crea el volumen donde el pod cabe

El cambio de mentalidad es este: la plataforma deja de ofrecer volúmenes concretos y pasa a ofrecer catálogo de servicios. "Tenemos disco rápido con retención y disco estándar barato; pide el que necesites y el clúster te lo crea."

  1. Anatomía de la StorageClass

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: rutasnorte-rapida            # el nombre es lo que se escribe en el PVC
  labels: { app.kubernetes.io/part-of: rutas-norte }
  annotations: { storageclass.kubernetes.io/is-default-class: "false" }
provisioner: ebs.csi.aws.com         # QUIEN crea el volumen
parameters:                          # COMO lo crea (especifico del provisioner)
  type: gp3
  iops: "6000"
  throughput: "250"
  encrypted: "true"
  fsType: ext4
reclaimPolicy: Retain                # que heredan los PV creados
allowVolumeExpansion: true           # se puede agrandar despues?
volumeBindingMode: WaitForFirstConsumer
mountOptions: ["noatime"]
allowedTopologies:                   # (opcional) restringir zonas
  - matchLabelExpressions:
      - { key: topology.kubernetes.io/zone, values: ["eu-west-1a", "eu-west-1b"] }

Dos características estructurales antes de entrar en los campos:

  1. Es un recurso de clúster (sin namespace), como el PersistentVolume. Lo gestiona el equipo de plataforma.
  2. Es prácticamente inmutable. Salvo un puñado de campos (allowVolumeExpansion, las anotaciones), una StorageClass no se puede modificar una vez creada: hay que borrarla y crearla de nuevo. Y ojo, borrar una StorageClass no afecta a los PV ya creados, que siguen funcionando con la configuración que tenían.

  1. provisioner: quién crea el volumen

El provisioner identifica al componente que atenderá las peticiones de esa clase. Es la única parte verdaderamente obligatoria, junto al nombre.

Provisioner Quién lo implementa Dónde se usa
k8s.io/minikube-hostpath minikube Tu clúster de prácticas
rancher.io/local-path local-path-provisioner kind, k3s, clústeres de desarrollo
ebs.csi.aws.com Driver CSI de AWS EBS EKS
disk.csi.azure.com Driver CSI de Azure Disk AKS
pd.csi.storage.gke.io Driver CSI de Google PD GKE
efs.csi.aws.com Driver CSI de AWS EFS EKS, cuando hace falta ReadWriteMany
kubernetes.io/no-provisioner Nadie Clases para volúmenes local, que se crean a mano

Dos casos especiales que conviene reconocer. kubernetes.io/no-provisioner se usa para clases que no aprovisionan nada, lo que suena absurdo hasta que ves para qué sirve: agrupar volúmenes local creados a mano y aprovechar el volumeBindingMode: WaitForFirstConsumer para que la planificación respete la ubicación del disco. Y los provisioners con prefijo kubernetes.io/ (como kubernetes.io/aws-ebs) son los antiguos controladores in-tree, eliminados del código de Kubernetes: en un clúster 1.30+ no funcionan, y si heredas manifiestos con esos nombres hay que migrarlos al driver CSI equivalente.

Para saber qué drivers CSI hay realmente instalados en tu clúster: kubectl get csidrivers.

  1. parameters: los detalles del disco

parameters es un mapa opaco para Kubernetes: se pasa tal cual al provisioner, que es quien lo interpreta. Por eso las claves válidas dependen por completo del driver, y una clave mal escrita no da error al crear la clase, sino al crear el primer PVC.

Ejemplos reales por proveedor:

# AWS EBS: SSD de proposito general con IOPS y rendimiento a medida
parameters:
  type: gp3               # gp2, gp3, io1, io2, st1, sc1
  iops: "6000"
  throughput: "250"       # MiB/s
  encrypted: "true"
  kmsKeyId: "arn:aws:kms:eu-west-1:111122223333:key/abcd-1234"
  fsType: ext4

En Google Cloud las claves equivalentes son type (de pd-standard a pd-extreme) y replication-type: regional-pd, que replica el disco entre dos zonas; en Azure son skuName (Standard_LRS, StandardSSD_LRS, Premium_LRS, UltraSSD_LRS) y cachingmode.

Un parámetro que no debe faltar en ninguna clase de Rutas Norte es el de cifrado en reposo. postgres-reservas guarda nombre, DNI, teléfono y correo de los clientes; ese disco debe estar cifrado, igual que ya lo están los Secrets en etcd desde 03-02. En AWS es encrypted: "true"; en Azure y Google el cifrado en reposo viene activado por defecto y lo que se configura es la clave.

  1. reclaimPolicy y allowVolumeExpansion

reclaimPolicy

Los PV creados por esta clase heredan esta política. Los valores son los de 05-02: Retain o Delete. Y el valor por defecto, si no lo pones, es Delete.

Esto merece un aviso en mayúsculas: las clases por defecto de todas las nubes gestionadas usan Delete. Es decir, en un clúster recién creado, si tu equipo borra el PVC de una base de datos —o el namespace que lo contiene—, el disco se destruye. Compruébalo siempre lo primero al llegar a un clúster nuevo:

kubectl get sc -o custom-columns=\
NOMBRE:.metadata.name,POLITICA:.reclaimPolicy,EXPANSION:.allowVolumeExpansion,\
MODO:.volumeBindingMode
NOMBRE     POLITICA   EXPANSION   MODO
standard   Delete     true        Immediate
gp2        Delete     true        WaitForFirstConsumer

Como la clase es casi inmutable, no puedes cambiarle la política; lo que se hace es crear una clase propia con Retain para los datos que importan. Eso es exactamente lo que haremos con rutasnorte-rapida.

allowVolumeExpansion

Declara si los PVC de esta clase pueden agrandarse después de creados. Es de los pocos campos que se pueden modificar en caliente, con kubectl patch sc rutasnorte-rapida -p '{"allowVolumeExpansion": true}'.

Ponlo a true en toda clase destinada a datos que crecen. El coste es cero y la alternativa —migrar los datos a un volumen mayor con la base de datos parada— es una operación de madrugada. La mecánica de la expansión se detalla en 05-05, incluido el hecho de que no se puede reducir.

  1. volumeBindingMode: el campo que evita un desastre

Es el campo menos comprendido de la StorageClass y el que más incidentes causa en la nube. Tiene dos valores:

Modo Cuándo se crea y vincula el volumen Consecuencia
Immediate En cuanto se crea el PVC, sin saber dónde irá el pod El volumen puede nacer donde el pod no cabe
WaitForFirstConsumer Cuando aparece el primer pod que usa el PVC El volumen nace donde el planificador ha decidido poner el pod

El caso concreto que lo explica

Rutas Norte tiene su clúster de producción repartido en tres zonas de disponibilidad: eu-west-1a, eu-west-1b y eu-west-1c. Con volumeBindingMode: Immediate:

sequenceDiagram
    participant Dev as Equipo
    participant API as apiserver
    participant Prov as Aprovisionador CSI
    participant Sched as Planificador
    Dev->>API: crea PVC de 200Gi
    API->>Prov: hay un PVC pendiente
    Prov->>Prov: crea el disco en eu-west-1c<br/>(zona elegida A CIEGAS)
    Prov->>API: PV con nodeAffinity zone=eu-west-1c
    Note over API: PVC Bound. Todo parece bien.
    Dev->>API: crea el pod de postgres-reservas
    API->>Sched: planifica este pod
    Sched-->>API: Pod Pending: el volumen exige eu-west-1c<br/>y alli no hay CPU/memoria libre

El pod se queda Pending indefinidamente con un evento del tipo:

0/6 nodes are available: 4 node(s) had volume node affinity conflict,
2 Insufficient cpu. preemption: 0/6 nodes are available.

Y no hay arreglo cómodo: el disco ya existe en la zona equivocada y no se mueve. Hay que borrar el PVC, borrar el disco y volver a empezar, confiando en la suerte.

Con WaitForFirstConsumer el orden se invierte: el planificador decide primero, considerando CPU, memoria, afinidades, taints y tolerations (06-05), y después el aprovisionador crea el disco en la zona de ese nodo. El conflicto es imposible por construcción.

El efecto secundario que hay que reconocer para no asustarse: con WaitForFirstConsumer, un PVC recién creado se queda en Pending a propósito hasta que exista un pod que lo use, y kubectl describe pvc lo dice con el evento WaitForFirstConsumer: waiting for first consumer to be created before binding.

Eso no es un error. Es el modo funcionando. En cuanto crees el Deployment, el PVC pasa a Bound en segundos.

Regla de Rutas Norte: WaitForFirstConsumer en todas las clases, sin excepción. El único escenario donde Immediate es preferible es un clúster de una sola zona con almacenamiento de red uniforme, y ni siquiera ahí aporta nada.

  1. La clase por defecto y sus trampas

Un clúster puede tener una StorageClass marcada como por defecto. Cuando un PVC omite el campo storageClassName, un controlador de admisión le inyecta el nombre de esa clase.

Se marca con la anotación storageclass.kubernetes.io/is-default-class: "true" en el metadata de la clase, y en la salida de kubectl get sc aparece señalada como standard (default). Cambiarla es un par de kubectl patch:

# quitar la marca a la actual
kubectl patch sc standard -p \
  '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'

# ponersela a la nueva
kubectl patch sc rutasnorte-estandar -p \
  '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'

Si hay dos clases por defecto

Nada impide marcar dos, y no es un error de configuración detectable a simple vista. El comportamiento es el siguiente: el controlador de admisión elige la más recientemente creada e ignora las demás. En versiones anteriores llegaba a rechazar el PVC. En cualquier caso es una configuración incorrecta, y su síntoma es de los peores: los PVC funcionan, pero unos acaban en discos rápidos y caros y otros en discos lentos, sin patrón aparente.

kubectl get sc -o json | jq -r '.items[] |
  select(.metadata.annotations["storageclass.kubernetes.io/is-default-class"]=="true") |
  .metadata.name'          # si devuelve mas de una linea, arreglalo hoy

Si no hay ninguna clase por defecto

Entonces un PVC que omita storageClassName no recibe ninguna clase y se queda Pending para siempre, buscando un PV estático que probablemente no exista, con el evento ProvisioningFailed: no persistent volumes available for this claim and no storage class is set.

  1. storageClassName: "" frente a omitir el campo

Esta distinción vale una sección propia porque es sutil, se olvida constantemente y produce fallos desconcertantes.

En el PVC Qué hace el controlador de admisión Con qué PV puede casar
Campo omitido Le inyecta la clase por defecto Con PV de esa clase, o se aprovisiona uno
storageClassName: "" No toca nada: el PVC se queda sin clase Solo con PV sin clase (estáticos)
storageClassName: mi-clase No toca nada Con PV de mi-clase, o se aprovisiona uno

La cadena vacía es la forma explícita de decir: "desactiva el aprovisionamiento dinámico para este PVC; quiero un PV estático concreto". Es lo que hay que escribir cuando restauras un volumen preexistente y no quieres que el clúster te cree uno nuevo y vacío al lado. El error clásico y su síntoma: en un clúster con clase por defecto, alguien crea un PV estático sin clase para restaurar unos datos, escribe un PVC omitiendo storageClassName, y en lugar de vincularse al PV con los datos, el clúster aprovisiona un volumen nuevo y vacío. El PVC queda Bound, la aplicación arranca sin errores y la base de datos está vacía. Es un fallo silencioso, y la protección es doble: storageClassName: "" y volumeName apuntando al PV concreto.

# Restauracion: quiero ESE volumen, no uno nuevo
spec:
  storageClassName: ""                       # sin clase: nada de dinamico
  volumeName: pv-postgres-reservas-10gi      # y ademas, exactamente este
  accessModes: ["ReadWriteOnce"]
  resources: { requests: { storage: 10Gi } }

  1. Las clases que traen minikube y las nubes gestionadas

Tu clúster de prácticas ya tiene una clase, provista por el addon storage-provisioner que activaste en 01-04:

kubectl describe sc standard
Name:                  standard          IsDefaultClass:  Yes
Provisioner:           k8s.io/minikube-hostpath
Parameters:            <none>            AllowVolumeExpansion: <unset>
ReclaimPolicy:         Delete            VolumeBindingMode:    Immediate

k8s.io/minikube-hostpath crea directorios bajo /tmp/hostpath-provisioner/<namespace>/<nombre-pvc> dentro de la VM. Sus limitaciones son las esperables de un clúster de un nodo: no admite expansión ni snapshots, y su política es Delete. Sirve perfectamente para aprender el flujo, pero no para practicar lo de 05-05; allí instalaremos el driver CSI de hostpath, que sí las soporta.

Tabla comparativa orientativa de las clases típicas que encontrarás:

Entorno Clase habitual Provisioner Respaldo Expansión Modos Política
minikube standard k8s.io/minikube-hostpath Directorio de la VM No Todos (un nodo) Delete
kind / k3s local-path rancher.io/local-path Directorio del nodo No RWO Delete
EKS (AWS) gp2 / gp3 ebs.csi.aws.com EBS RWO Delete
EKS con ficheros efs-sc efs.csi.aws.com EFS N/A RWX Delete
GKE (Google) standard-rwo, premium-rwo pd.csi.storage.gke.io Persistent Disk RWO Delete
AKS (Azure) managed-csi, managed-csi-premium disk.csi.azure.com Managed Disk RWO Delete
AKS con ficheros azurefile-csi file.csi.azure.com Azure Files RWX Delete

Los precios y rendimientos varían y no tiene sentido memorizarlos; lo que sí conviene retener es el patrón: cada nube ofrece al menos un disco de bloques estándar, uno premium y un sistema de ficheros compartido más caro para ReadWriteMany, y las clases preinstaladas vienen con Delete.

  1. Las clases de almacenamiento de Rutas Norte

Con todo lo anterior, el diseño del catálogo de la plataforma: dos clases, cada una con una intención clara.

rutasnorte-rapida: para los datos que no se pueden perder

# k8s/base/storageclass-rapida.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: rutasnorte-rapida
  labels: { app.kubernetes.io/part-of: rutas-norte }
  annotations:
    storageclass.kubernetes.io/is-default-class: "false"
    rutasnorte.example/descripcion: "SSD cifrado para bases de datos. Retiene el volumen al borrar el PVC."
provisioner: ebs.csi.aws.com          # en pre y pro; en minikube ver mas abajo
parameters:
  type: gp3
  iops: "6000"
  throughput: "250"
  encrypted: "true"                   # datos personales de clientes: obligatorio
  fsType: ext4
reclaimPolicy: Retain                 # <-- la decision central
allowVolumeExpansion: true            # la BD crecera en cada temporada alta
volumeBindingMode: WaitForFirstConsumer
mountOptions: ["noatime"]

Por qué Retain en la clase de la base de datos. Es la decisión más importante del catálogo y conviene poder defenderla:

  • El PVC de postgres-reservas contiene nombre, DNI, teléfono y correo de todos los clientes y todas las reservas vendidas. Su pérdida no es una incidencia técnica: es una parada de negocio y un incidente de datos personales.
  • Los mecanismos que pueden borrar un PVC sin que nadie lo pretenda son muchos y cotidianos: un kubectl delete namespace equivocado (02-06), un kubectl delete -f k8s/ sobre el directorio entero, una herramienta de GitOps que sincroniza y "poda" recursos que ya no están en Git (10-05), un script de limpieza de entornos.
  • Con Delete, cualquiera de esos accidentes destruye el disco de forma irreversible. Con Retain, deja un PV en Released que se rescata en dos minutos con el kubectl patch de 05-02.
  • El coste de Retain es real pero pequeño: volúmenes huérfanos que hay que limpiar a mano y que siguen facturándose. Se compensa con una revisión mensual de PV en Released.

La regla general que se deriva: Retain para datos de negocio, Delete para datos reconstruibles.

rutasnorte-estandar: para todo lo demás

# k8s/base/storageclass-estandar.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: rutasnorte-estandar
  labels: { app.kubernetes.io/part-of: rutas-norte }
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
    rutasnorte.example/descripcion: "Disco estandar para datos reconstruibles. Se borra con el PVC."
provisioner: ebs.csi.aws.com
parameters: { type: gp3, encrypted: "true", fsType: ext4 }
reclaimPolicy: Delete
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer

Es la clase por defecto, y esa elección también es deliberada: si alguien crea un PVC sin pensar, que caiga en la clase barata y borrable, no en la cara y retenida. La clase de la base de datos hay que pedirla explícitamente, lo que obliga a una decisión consciente.

rutasnorte-rapida rutasnorte-estandar
Uso previsto postgres-reservas, volumen de copias (05-06) Espacios de trabajo, adjuntos, informes
Política de reclamación Retain Delete
Rendimiento SSD con IOPS provisionadas SSD estándar
Expansión
Por defecto No
Cifrado

En el clúster de prácticas

En minikube no existe ebs.csi.aws.com. Para practicar, se crean las mismas clases con el provisioner local. Fíjate en que el nombre no cambia: es lo único que ven los manifiestos de la aplicación, y por eso el PVC de postgres-reservas es idéntico en minikube y en producción.

# k8s/entornos/dev/storageclasses-minikube.yaml
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: rutasnorte-rapida
  labels: { app.kubernetes.io/part-of: rutas-norte }
provisioner: k8s.io/minikube-hostpath
reclaimPolicy: Retain                # el contrato: se conserva
allowVolumeExpansion: false          # el provisioner de minikube no lo soporta
volumeBindingMode: Immediate         # minikube tiene un solo nodo
# --- y una segunda clase rutasnorte-estandar identica salvo por:
#     reclaimPolicy: Delete y la anotacion is-default-class: "true"

Que la configuración de dev difiera de la de producción en parameters y provisioner es normal y correcto: lo que debe permanecer idéntico es el contrato, es decir, el nombre de la clase y su política de reclamación.

  1. Práctica: un PVC que crea su propio volumen

Aplicamos las clases y comprobamos el efecto. Primero, la clase por defecto en su sitio:

kubectl apply -f k8s/entornos/dev/storageclasses-minikube.yaml
kubectl patch sc standard -p \
  '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'
kubectl get sc
NAME                            PROVISIONER                RECLAIMPOLICY   VOLUMEBINDINGMODE   AGE
rutasnorte-estandar (default)   k8s.io/minikube-hostpath   Delete          Immediate           5s
rutasnorte-rapida               k8s.io/minikube-hostpath   Retain          Immediate           5s
standard                        k8s.io/minikube-hostpath   Delete          Immediate           12d

Ahora, y esto es lo importante: borramos el PersistentVolume estático que creamos a mano en 05-02, comprobamos con kubectl get pv que no queda ninguno (No resources found) y creamos un PVC nuevo sin ningún PV esperando.

# k8s/base/informes-adjuntos-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: informes-adjuntos
  namespace: rutas-norte-dev
  labels: { app: informes-ocupacion, app.kubernetes.io/part-of: rutas-norte, entorno: dev }
spec:
  accessModes: ["ReadWriteOnce"]
  resources: { requests: { storage: 3Gi } }
  # sin storageClassName: usara la clase por defecto (rutasnorte-estandar)
kubectl apply -f k8s/base/informes-adjuntos-pvc.yaml
kubectl get pvc,pv -n rutas-norte-dev
NAME                                      STATUS  VOLUME                 CAPACITY  STORAGECLASS
persistentvolumeclaim/informes-adjuntos   Bound   pvc-7f3e1a2b-9c4d-...  3Gi       rutasnorte-estandar
NAME                                      RECLAIM POLICY  STATUS  CLAIM
persistentvolume/pvc-7f3e1a2b-9c4d-...    Delete          Bound   rutas-norte-dev/informes-adjuntos

Fíjate en los cuatro detalles que resumen la lección:

  1. Ha aparecido un PersistentVolume que tú no escribiste. El aprovisionador lo creó al ver el PVC.
  2. Su nombre es pvc-<uid-del-pvc>: si un nombre de PV empieza por pvc-, es dinámico.
  3. CAPACITY: 3Gi, exactamente lo pedido, y RECLAIM POLICY: Delete, heredada de la clase por defecto.

Y la comprobación de que el PVC de la base de datos, al pedir la clase rutasnorte-rapida, obtiene un volumen con Retain:

kubectl apply -f k8s/base/postgres-reservas-pvc.yaml
kubectl apply -f k8s/base/postgres-reservas-deployment.yaml
kubectl get pv -o custom-columns=\
NOMBRE:.metadata.name,CLASE:.spec.storageClassName,\
POLITICA:.spec.persistentVolumeReclaimPolicy,CLAIM:.spec.claimRef.name
NOMBRE                CLASE                 POLITICA   CLAIM
pvc-7f3e1a2b-9c4d-... rutasnorte-estandar   Delete     informes-adjuntos
pvc-2b8c9d0e-1f2a-... rutasnorte-rapida     Retain     postgres-reservas-datos

Dos volúmenes, dos políticas distintas, ninguno escrito a mano. Ese es el catálogo funcionando.

  1. Migrar un PVC de una clase a otra

Última pieza operativa, y la respuesta corta es contundente: no se puede cambiar la clase de un PVC existente. El campo storageClassName es inmutable una vez vinculado, y con razón: la clase determina el disco físico, y un disco no cambia de tipo por editar un YAML. Un kubectl patch pvc ... -p '{"spec":{"storageClassName":"rutasnorte-rapida"}}' se estrella contra:

The PersistentVolumeClaim "postgres-reservas-datos" is invalid:
spec: Forbidden: spec is immutable after creation except resources.requests
and volumeAttributesClassName for bound claims

El mensaje además te adelanta lo único que se puede cambiar: resources.requests, es decir, la expansión de 05-05. El procedimiento real de migración implica copiar los datos, y hay tres variantes según lo que puedas permitirte:

a) Con parada (el estándar para una base de datos)

# 1. Copia de seguridad ANTES de nada (05-06)
kubectl exec -n rutas-norte-dev deploy/postgres-reservas -- \
  pg_dump -U rutasnorte -d reservas -Fc -f /tmp/previo-migracion.dump
# 2. Parar la escritura: sin pods, no hay cambios en el volumen
kubectl scale deploy postgres-reservas -n rutas-norte-dev --replicas=0
# 3. Crear el PVC destino postgres-reservas-datos-v2 con la clase nueva
kubectl apply -f k8s/base/postgres-reservas-pvc-v2.yaml
# 4. Un Job que monta AMBOS volumenes y copia
apiVersion: batch/v1
kind: Job
metadata:
  name: migrar-volumen-postgres
  namespace: rutas-norte-dev
  labels: { app: postgres-reservas, app.kubernetes.io/part-of: rutas-norte, entorno: dev }
spec:
  backoffLimit: 2
  template:
    metadata:
      labels: { app: postgres-reservas, entorno: dev }
    spec:
      restartPolicy: Never
      containers:
        - name: copiar
          image: busybox:1.36
          # -a preserva permisos, propietarios y marcas de tiempo: imprescindible
          command: ["sh", "-c", "cp -a /origen/. /destino/ && ls -la /destino"]
          volumeMounts:
            - { name: origen,  mountPath: /origen, readOnly: true }
            - { name: destino, mountPath: /destino }
          resources:
            requests: { cpu: 200m, memory: 256Mi }
            limits:   { cpu: "1",  memory: 512Mi }
      volumes:                                       # los DOS PVC a la vez
        - name: origen
          persistentVolumeClaim: { claimName: postgres-reservas-datos }
        - name: destino
          persistentVolumeClaim: { claimName: postgres-reservas-datos-v2 }
kubectl apply -f migrar-volumen-postgres.yaml
kubectl wait --for=condition=complete job/migrar-volumen-postgres -n rutas-norte-dev --timeout=600s
# 5. Apuntar el Deployment al PVC nuevo y arrancar
kubectl patch deploy postgres-reservas -n rutas-norte-dev --type=json -p='[
  {"op":"replace",
   "path":"/spec/template/spec/volumes/0/persistentVolumeClaim/claimName",
   "value":"postgres-reservas-datos-v2"}]'
kubectl scale deploy postgres-reservas -n rutas-norte-dev --replicas=1
# 6. VERIFICAR antes de borrar nada
kubectl exec -n rutas-norte-dev deploy/postgres-reservas -- \
  psql -U rutasnorte -d reservas -c "SELECT count(*) FROM reservas;"

El paso 6 no es opcional. No borres el PVC de origen hasta haber verificado los datos en destino, y aun entonces déjalo unos días. Recuerda que si su clase es Retain, borrarlo deja el PV en Released recuperable.

b) Con la copia lógica como intermediario

Para una base de datos suele ser más limpio: pg_dump del origen, crear el PVC nuevo vacío, arrancar PostgreSQL sobre él y restaurar con pg_restore. Es más lento pero valida los datos por el camino (una copia binaria arrastraría una corrupción; un volcado lógico no se restaura si está corrupto). Se detalla en 05-06.

c) Sin parada

Requiere replicación a nivel de aplicación: levantar una réplica de PostgreSQL sobre el volumen nuevo, sincronizarla y promoverla. Es un procedimiento de base de datos, no de Kubernetes, y se delega en un operador (06-07).

Errores Comunes y Consejos

Error Síntoma Solución
Dejar la clase por defecto de la nube en un volumen de datos Se borra el PVC y desaparece el disco Clase propia con reclaimPolicy: Retain
Usar volumeBindingMode: Immediate en varias zonas volume node affinity conflict, pod Pending para siempre WaitForFirstConsumer siempre
Asustarse del WaitForFirstConsumer Se cree que el PVC está roto Es lo esperado: se vincula al crear el pod
Dos clases marcadas por defecto Volúmenes en clases impredecibles Deja una sola; comprueba con jq
Ninguna clase por defecto PVC sin clase Pending para siempre Marca una, o nombra la clase en cada PVC
Confundir "" con omitir el campo Se aprovisiona un volumen vacío en vez de usar el PV con los datos "" y volumeName en toda restauración
Intentar cambiar la clase de un PVC spec is immutable after creation Copiar los datos a un PVC nuevo
Olvidar allowVolumeExpansion No se puede agrandar el disco de la BD sin parada Ponlo a true; es modificable en caliente
Usar provisioners kubernetes.io/* El PVC nunca se aprovisiona Los in-tree están eliminados; usa CSI
No cifrar el disco de la base de datos Datos personales en claro en el almacenamiento encrypted: "true" o su equivalente

Consejos:

  1. Documenta cada clase con una anotación (rutasnorte.example/descripcion). Quien elige la clase suele ser un desarrollador que no sabe qué hay debajo, y esa línea le evita preguntar.
  2. Audita el catálogo el primer día en cualquier clúster con un kubectl get sc -o custom-columns=... que muestre a la vez provisioner, reclaimPolicy, allowVolumeExpansion, volumeBindingMode y la anotación de clase por defecto.
  3. Pocas clases y con nombres que digan la intención. rutasnorte-rapida y rutasnorte-estandar se entienden; sc-gp3-6000iops-encrypted obliga a saber de AWS y ata el nombre al proveedor.
  4. Los nombres de clase son el contrato entre entornos. Mantén los mismos nombres en dev, pre y pro aunque debajo haya provisioners distintos: así los manifiestos de la aplicación son idénticos.

Ejercicios

Ejercicio 1: montar el catálogo de Rutas Norte

En tu minikube:

  1. Crea rutasnorte-rapida y rutasnorte-estandar con el provisioner local, dejando rutasnorte-estandar como clase por defecto y quitando la marca a standard.
  2. Verifica con una sola orden que solo hay una clase por defecto.
  3. Crea un PVC de 1 GiB sin storageClassName y comprueba en qué clase acaba y con qué política.
  4. Crea otro PVC de 1 GiB con storageClassName: rutasnorte-rapida y compara la política de reclamación de ambos PV.
  5. Borra los dos PVC y explica por qué uno de los PV desaparece y el otro no.

Ejercicio 2: el incidente de la zona

Un compañero del equipo de plataforma ha creado esta clase para el clúster de producción, que tiene nodos en eu-west-1a, eu-west-1b y eu-west-1c:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata: { name: rutasnorte-rapida }
provisioner: ebs.csi.aws.com
parameters: { type: gp3 }
volumeBindingMode: Immediate

Al día siguiente, el pod de postgres-reservas lleva 40 minutos en Pending con este evento:

0/9 nodes are available: 6 node(s) had volume node affinity conflict,
3 Insufficient memory.

Explica qué ha pasado exactamente, por qué el PVC sí está Bound mientras el pod no arranca, cómo se resuelve el incidente hoy y qué tres cambios hay que hacer en la clase para que no vuelva a ocurrir.

Ejercicio 3: el PVC que se vinculó al volumen equivocado

El equipo debe restaurar los datos de postgres-reservas de preproducción. Un administrador ha creado a mano un PV llamado pv-restauracion-pre (20 GiB, Retain, sin storageClassName) que apunta al volumen con los datos recuperados. Un desarrollador aplica este PVC:

apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: postgres-reservas-datos, namespace: rutas-norte-pre }
spec:
  accessModes: ["ReadWriteOnce"]
  resources: { requests: { storage: 20Gi } }

El PVC queda Bound en segundos, PostgreSQL arranca sin errores… y la base de datos está vacía. Explica qué ha ocurrido y escribe el PVC correcto.

Soluciones

Ejercicio 1

# 1
kubectl apply -f k8s/entornos/dev/storageclasses-minikube.yaml
kubectl patch sc standard -p \
  '{"metadata":{"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'

# 2
kubectl get sc -o json | jq -r \
  '.items[] | select(.metadata.annotations["storageclass.kubernetes.io/is-default-class"]=="true") | .metadata.name'
# -> rutasnorte-estandar (una sola linea: correcto)

# 3 y 4: dos PVC de 1Gi, uno SIN storageClassName (prueba-defecto)
#        y otro con storageClassName: rutasnorte-rapida (prueba-rapida)
kubectl apply -f pvc-pruebas-clases.yaml

kubectl get pv -o custom-columns=\
NOMBRE:.metadata.name,CLASE:.spec.storageClassName,\
POLITICA:.spec.persistentVolumeReclaimPolicy,CLAIM:.spec.claimRef.name
NOMBRE             CLASE                 POLITICA   CLAIM
pvc-a1b2c3d4-...   rutasnorte-estandar   Delete     prueba-defecto
pvc-e5f6a7b8-...   rutasnorte-rapida     Retain     prueba-rapida

El PVC sin clase recibió rutasnorte-estandar por inyección del controlador de admisión (compruébalo con kubectl get pvc prueba-defecto -o yaml: el campo aparece escrito en el objeto, aunque tú no lo pusieras). Y al borrar ambos PVC (paso 5), kubectl get pv solo devuelve pvc-e5f6a7b8-... 1Gi Retain Released rutas-norte-dev/prueba-rapida.

Solo queda uno. El de rutasnorte-estandar tenía Delete y se destruyó con su PVC; el de rutasnorte-rapida heredó Retain de su clase y quedó en Released con los datos dentro. Es exactamente la protección que buscábamos para la base de datos. Limpieza: kubectl delete pv pvc-e5f6a7b8-....

Ejercicio 2

Qué ha pasado. La clase usa volumeBindingMode: Immediate (y además es el valor por defecto, así que basta con omitirlo para caer en la trampa). Al crearse el PVC, el aprovisionador de EBS creó el disco inmediatamente, eligiendo una zona sin ninguna información sobre dónde acabaría el pod —digamos eu-west-1c—. El PV resultante lleva una nodeAffinity que exige topology.kubernetes.io/zone=eu-west-1c.

Por qué el PVC está Bound y el pod no arranca. Son dos decisiones independientes: la vinculación PVC-PV la hace el controlador de PersistentVolume y solo mira capacidad, modos y clase; la colocación del pod la hace el planificador, que además de la afinidad del volumen evalúa CPU, memoria y taints. El PVC está perfectamente vinculado; lo que no existe es un nodo que satisfaga a la vez la zona del disco y los recursos del pod. Los 6 node(s) had volume node affinity conflict son los nodos de las otras dos zonas, y los 3 Insufficient memory son los de eu-west-1c, que sí valen por zona pero están llenos —postgres-reservas pide 2 GiB con QoS Guaranteed (03-05)—.

Cómo se resuelve hoy. Hay que decidir entre dos caminos. La opción A es hacer sitio en eu-west-1c —localiza los nodos con kubectl get nodes -L topology.kubernetes.io/zone, mira su Allocated resources con kubectl describe node, y libera carga o añade un nodo allí—. La opción B es rehacer el volumen: borrar el Deployment y el PVC de rutas-norte-pro, corregir la clase y volver a aplicar.

La opción B solo es aceptable porque el volumen aún no tiene datos. Si los tuviera, la única salida sería una restauración desde copia de seguridad (05-06), porque un disco EBS no cambia de zona.

Los tres cambios en la clase:

provisioner: ebs.csi.aws.com
parameters: { type: gp3, encrypted: "true" }   # 4o: datos personales (recomendable)
reclaimPolicy: Retain                  # 1: no destruir los datos al borrar el PVC
allowVolumeExpansion: true             # 2: poder crecer sin parada
volumeBindingMode: WaitForFirstConsumer  # 3: EL arreglo del incidente
  1. volumeBindingMode: WaitForFirstConsumer, que invierte el orden: primero planifica el pod, después crea el disco en su zona. Elimina el conflicto por construcción.
  2. reclaimPolicy: Retain, porque la clase que faltaba ponía Delete por defecto y esto es la base de datos de producción.
  3. allowVolumeExpansion: true, para no tener que repetir esta maniobra el día que los 200 GiB se queden cortos.

Recuerda que la StorageClass no se puede editar: hay que borrarla y recrearla. Los PV ya existentes conservan la configuración con la que nacieron.

Ejercicio 3

Qué ha ocurrido. El PVC omite storageClassName. El controlador de admisión le inyectó entonces la clase por defecto del clúster (rutasnorte-estandar). A partir de ahí, el PV pv-restauracion-pre quedó descartado como candidato —está sin clase, y la clase debe coincidir exactamente— y en su lugar el aprovisionador creó un volumen nuevo, vacío y de 20 GiB. El PVC se vinculó a ese volumen nuevo en segundos, PostgreSQL encontró un directorio vacío, ejecutó initdb y arrancó tan contento: una base de datos nueva y vacía, sin un solo error en los registros.

Es el fallo más peligroso de la lección precisamente porque no falla nada. Y hay un daño colateral: el PV con los datos sigue Available, pero si nadie se da cuenta y la aplicación empieza a operar sobre la base vacía, se pierde la ventana de restauración.

El PVC correcto lleva doble protección:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-reservas-datos
  namespace: rutas-norte-pre
  labels: { app: postgres-reservas, app.kubernetes.io/part-of: rutas-norte, entorno: pre }
spec:
  accessModes: ["ReadWriteOnce"]
  volumeMode: Filesystem
  # 1: cadena VACIA -> sin clase -> nada de aprovisionamiento dinamico
  storageClassName: ""
  # 2: ademas, exactamente ESTE volumen y ningun otro
  volumeName: pv-restauracion-pre
  resources: { requests: { storage: 20Gi } }

Y el procedimiento seguro de restauración, para no repetir el incidente:

# 1. Preasignar el PV al PVC (claimRef sin uid): nadie mas puede tomarlo
kubectl patch pv pv-restauracion-pre -p '{"spec":{"claimRef":{
  "apiVersion":"v1","kind":"PersistentVolumeClaim",
  "namespace":"rutas-norte-pre","name":"postgres-reservas-datos"}}}'
# 2. Crear el PVC y VERIFICAR a que volumen se ha vinculado
kubectl apply -f pvc-restauracion.yaml
kubectl get pvc postgres-reservas-datos -n rutas-norte-pre \
  -o jsonpath='{.spec.volumeName}'; echo      # debe decir pv-restauracion-pre
# 3. Solo entonces, arrancar la aplicacion y contar las reservas
kubectl scale deploy postgres-reservas -n rutas-norte-pre --replicas=1

El paso 2 —comprobar volumeName antes de arrancar la aplicación— es la verificación que habría evitado el incidente.

Conclusión

Has dado el salto que cambia la operación diaria de un clúster: del aprovisionamiento estático, donde un humano crea cada PersistentVolume por adelantado con su desperdicio, sus esperas y sus huérfanos, al dinámico, donde el equipo de aplicación crea un PVC y el volumen exacto aparece en segundos. Has visto nacer un PV que no escribiste, con el nombre pvc-<uid> que delata su origen, del tamaño justo pedido y con la política heredada de su clase.

Conoces la StorageClass campo a campo. El provisioner, que identifica al driver —CSI en producción, k8s.io/minikube-hostpath en prácticas, y nunca los kubernetes.io/* in-tree, ya eliminados—. Los parameters, opacos para Kubernetes y específicos de cada driver, donde va el tipo de disco, las IOPS y, obligatoriamente en Rutas Norte, el cifrado en reposo. La reclaimPolicy que heredan los PV, con el aviso que hay que comprobar el primer día en cualquier clúster nuevo: las clases por defecto de las nubes vienen en Delete, así que borrar un PVC borra el disco. El allowVolumeExpansion, que cuesta nada y evita una madrugada de migración. Y, sobre todo, el volumeBindingMode: Immediate crea el disco a ciegas y puede dejarlo en una zona donde el pod no cabe, produciendo un volume node affinity conflict irreparable sin restaurar de copia; WaitForFirstConsumer invierte el orden —primero planifica el pod, después crea el disco donde ese pod está— y elimina el problema por construcción, al precio de un Pending deliberado que ya no te asusta.

Dominas la clase por defecto: su anotación storageclass.kubernetes.io/is-default-class, el desorden que produce tener dos y el Pending eterno de no tener ninguna. Y distingues con precisión los dos silencios: omitir storageClassName deja que te inyecten la clase por defecto, mientras que storageClassName: "" significa "sin clase, nada de dinámico, quiero un PV estático". Esa diferencia de dos comillas es lo que separa una restauración correcta de una base de datos vacía que arranca sin un solo error, como viste en el tercer ejercicio.

Y has diseñado el catálogo de la plataforma: rutasnorte-rapida con Retain para postgres-reservas, porque el disco guarda datos personales de clientes y hay demasiadas formas cotidianas de borrar un PVC sin querer; y rutasnorte-estandar con Delete como clase por defecto, para que un descuido caiga siempre en lo barato y borrable y la clase protegida haya que pedirla a conciencia. Los nombres se mantienen idénticos en dev, pre y pro aunque debajo cambien el provisioner y los parámetros: el nombre de la clase es el contrato entre entornos. Y sabes que la clase de un PVC es inmutable, de modo que migrar significa copiar los datos, con parada y con verificación antes de borrar nada.

Queda por abrir la caja negra. Hemos dicho "el aprovisionador crea el volumen" sin explicar quién es, cómo se entera de que hay un PVC pendiente, cómo conecta el disco al nodo y cómo lo monta en el pod. Y hay dos capacidades que la StorageClass anuncia pero que aún no hemos usado: la expansión que habilita allowVolumeExpansion y los snapshots, imprescindibles antes de tocar el esquema de una base de datos en producción. Todo eso es la maquinaria de la Container Storage Interface, y es la siguiente lección: Aprovisionamiento Dinámico, Expansión y Snapshots.

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