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
- Del aprovisionamiento estático al dinámico
- Anatomía de la StorageClass
provisioner: quién crea el volumenparameters: los detalles del discoreclaimPolicyyallowVolumeExpansionvolumeBindingMode: el campo que evita un desastre- La clase por defecto y sus trampas
storageClassName: ""frente a omitir el campo- Las clases que traen minikube y las nubes gestionadas
- Las clases de almacenamiento de Rutas Norte
- Práctica: un PVC que crea su propio volumen
- Migrar un PVC de una clase a otra
- 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."
- 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:
- Es un recurso de clúster (sin namespace), como el PersistentVolume. Lo gestiona el equipo de plataforma.
- 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.
provisioner: quién crea el volumen
provisioner: quién crea el volumenEl 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.
parameters: los detalles del disco
parameters: los detalles del discoparameters 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: ext4En 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.
reclaimPolicy y allowVolumeExpansion
reclaimPolicy y allowVolumeExpansionreclaimPolicy
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:.volumeBindingModeComo 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 sí 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.
volumeBindingMode: el campo que evita un desastre
volumeBindingMode: el campo que evita un desastreEs 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.
- 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 hoySi 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.
storageClassName: "" frente a omitir el campo
storageClassName: "" frente a omitir el campoEsta 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 } }
- 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:
Name: standard IsDefaultClass: Yes
Provisioner: k8s.io/minikube-hostpath
Parameters: <none> AllowVolumeExpansion: <unset>
ReclaimPolicy: Delete VolumeBindingMode: Immediatek8s.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 | Sí | 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 | Sí | RWO | Delete |
| AKS (Azure) | managed-csi, managed-csi-premium |
disk.csi.azure.com |
Managed Disk | Sí | RWO | Delete |
| AKS con ficheros | azurefile-csi |
file.csi.azure.com |
Azure Files | Sí | 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.
- 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-reservascontiene 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 namespaceequivocado (02-06), unkubectl 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. ConRetain, deja un PV enReleasedque se rescata en dos minutos con elkubectl patchde 05-02. - El coste de
Retaines 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 enReleased.
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: WaitForFirstConsumerEs 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 | Sí | Sí |
| Por defecto | No | Sí |
| Cifrado | Sí | Sí |
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.
- 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 scNAME 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 12dAhora, 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)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-adjuntosFíjate en los cuatro detalles que resumen la lección:
- Ha aparecido un PersistentVolume que tú no escribiste. El aprovisionador lo creó al ver el PVC.
- Su nombre es
pvc-<uid-del-pvc>: si un nombre de PV empieza porpvc-, es dinámico. CAPACITY: 3Gi, exactamente lo pedido, yRECLAIM 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.nameNOMBRE CLASE POLITICA CLAIM
pvc-7f3e1a2b-9c4d-... rutasnorte-estandar Delete informes-adjuntos
pvc-2b8c9d0e-1f2a-... rutasnorte-rapida Retain postgres-reservas-datosDos volúmenes, dos políticas distintas, ninguno escrito a mano. Ese es el catálogo funcionando.
- 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 claimsEl mensaje además te adelanta lo único que sí 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:
- 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. - Audita el catálogo el primer día en cualquier clúster con un
kubectl get sc -o custom-columns=...que muestre a la vezprovisioner,reclaimPolicy,allowVolumeExpansion,volumeBindingModey la anotación de clase por defecto. - Pocas clases y con nombres que digan la intención.
rutasnorte-rapidayrutasnorte-estandarse entienden;sc-gp3-6000iops-encryptedobliga a saber de AWS y ata el nombre al proveedor. - 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:
- Crea
rutasnorte-rapidayrutasnorte-estandarcon el provisioner local, dejandorutasnorte-estandarcomo clase por defecto y quitando la marca astandard. - Verifica con una sola orden que solo hay una clase por defecto.
- Crea un PVC de 1 GiB sin
storageClassNamey comprueba en qué clase acaba y con qué política. - Crea otro PVC de 1 GiB con
storageClassName: rutasnorte-rapiday compara la política de reclamación de ambos PV. - 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: ImmediateAl día siguiente, el pod de postgres-reservas lleva 40 minutos en Pending con este evento:
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.nameNOMBRE CLASE POLITICA CLAIM
pvc-a1b2c3d4-... rutasnorte-estandar Delete prueba-defecto
pvc-e5f6a7b8-... rutasnorte-rapida Retain prueba-rapidaEl 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 incidentevolumeBindingMode: WaitForFirstConsumer, que invierte el orden: primero planifica el pod, después crea el disco en su zona. Elimina el conflicto por construcción.reclaimPolicy: Retain, porque la clase que faltaba poníaDeletepor defecto y esto es la base de datos de producción.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=1El 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
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
