Cerrábamos el módulo 4 con una plataforma accesible, cifrada y segmentada, y con una deuda escrita en un comentario del manifiesto desde el módulo 2: postgres-reservas no tiene almacenamiento persistente. Este módulo la salda, y empieza por el principio, que no es el PersistentVolume sino algo más básico: entender por qué el sistema de ficheros de un contenedor desaparece y qué es exactamente un volumen en Kubernetes. Verás que el volumen es una propiedad del pod, no del contenedor, y que esa distinción resuelve el reinicio de un contenedor pero no el reemplazo del pod —justo el problema que arrastra la base de datos—. En esta lección demostrarás la pérdida de datos con tus manos, dominarás la pareja volumes/volumeMounts que rige todos los tipos de volumen del resto del módulo, y trabajarás a fondo los dos tipos que no dependen de ninguna infraestructura externa: emptyDir y hostPath.
Contenido
- Por qué el sistema de ficheros de un contenedor es efímero
- Demostración: perder datos en
postgres-reservas - Qué es un volumen en Kubernetes
- La pareja
volumesyvolumeMounts mountPath,readOnly,subPathysubPathExpremptyDiren detalle- Compartir un
emptyDirentre contenedores del mismo pod hostPath: qué es y por qué es peligroso- Los volúmenes de configuración:
configMap,secret,downwardAPI - El volumen
projected: todos en un directorio - Tabla resumen de tipos de volumen
- Volúmenes efímeros genéricos: el puente al resto del módulo
- Por qué el sistema de ficheros de un contenedor es efímero
Una imagen de contenedor está formada por capas de solo lectura apiladas. Cuando el runtime (containerd en tu minikube) arranca un contenedor a partir de postgres:16.4, no copia esas capas: las monta apiladas mediante un sistema de ficheros de unión (overlayfs) y añade encima una única capa de escritura, vacía y exclusiva de ese contenedor.
flowchart TB
subgraph CONT["Contenedor en ejecucion"]
RW["Capa de escritura (rw)<br/>vacia al arrancar<br/>SE DESTRUYE al recrear el contenedor"]
end
subgraph IMG["Imagen postgres:16.4 (solo lectura, compartida)"]
L3["Capa 3: configuracion de PostgreSQL"]
L2["Capa 2: binarios de PostgreSQL"]
L1["Capa 1: sistema base Debian"]
end
RW --> L3 --> L2 --> L1
Todo lo que el proceso escribe —un fichero nuevo, la modificación de uno existente, los ficheros de datos de PostgreSQL en /var/lib/postgresql/data— va a esa capa de escritura. Y esa capa tiene una propiedad demoledora: su ciclo de vida es exactamente el del contenedor. Cuando el contenedor se destruye, la capa se borra.
Conviene distinguir con precisión dos sucesos que suenan parecidos y no lo son:
| Suceso | Qué ocurre con el contenedor | Qué ocurre con la capa de escritura |
|---|---|---|
El proceso principal falla y restartPolicy: Always lo reinicia |
Se crea un contenedor nuevo en el mismo pod | Se pierde (capa nueva y vacía) |
kubectl delete pod o un rollout restart |
Se crea un pod nuevo, con contenedores nuevos | Se pierde |
| El nodo se cae y el pod se reprograma en otro | Pod nuevo en otro nodo | Se pierde |
kubectl exec entra y sale |
Nada, el contenedor sigue vivo | Se conserva |
Es decir: cualquier reinicio, de la clase que sea, borra lo escrito. Y esto no es un defecto de Kubernetes; es la definición misma de contenedor y la razón de que sean ligeros y reemplazables. El precio es que el estado hay que sacarlo fuera del contenedor de forma explícita, y ese "fuera" es el volumen.
Recordarás de 03-04 que esa capa de escritura consume ephemeral-storage del nodo y que un contenedor que la llena provoca el desalojo del pod. Los volúmenes también son parte de esa contabilidad, como veremos con emptyDir.
- Demostración: perder datos en
postgres-reservas
postgres-reservasHazlo. Es la práctica que da sentido al módulo entero. Partimos del estado actual: postgres-reservas corriendo como Deployment en rutas-norte-dev, sin volumen alguno. Creamos una reserva real dentro de la base de datos y, además, un fichero testigo cualquiera:
POD=$(kubectl get pod -n rutas-norte-dev -l app=postgres-reservas \
-o jsonpath='{.items[0].metadata.name}')
# 1. Una tabla y una reserva ficticia
kubectl exec -n rutas-norte-dev "$POD" -- psql -U rutasnorte -d reservas -c \
"CREATE TABLE IF NOT EXISTS reservas (
id serial PRIMARY KEY, cliente text, trayecto text, fecha date);
INSERT INTO reservas (cliente, trayecto, fecha)
VALUES ('Marta Iglesias', 'Bilbao-Santander', '2026-08-15');"
# 2. Un fichero testigo en la capa de escritura
kubectl exec -n rutas-norte-dev "$POD" -- \
sh -c 'echo "prueba de persistencia" > /var/lib/postgresql/data/TESTIGO.txt'Ahora provocamos lo que en producción provocaría un despliegue, un desalojo o la caída de un nodo:
kubectl delete pod -n rutas-norte-dev "$POD"
kubectl wait --for=condition=ready pod -n rutas-norte-dev \
-l app=postgres-reservas --timeout=120s
POD=$(kubectl get pod -n rutas-norte-dev -l app=postgres-reservas \
-o jsonpath='{.items[0].metadata.name}')
kubectl exec -n rutas-norte-dev "$POD" -- psql -U rutasnorte -d reservas -c \
"SELECT * FROM reservas;"
kubectl exec -n rutas-norte-dev "$POD" -- cat /var/lib/postgresql/data/TESTIGO.txtERROR: no existe la relacion «reservas»
cat: /var/lib/postgresql/data/TESTIGO.txt: No such file or directory
command terminated with exit code 1Se ha perdido todo. El ReplicaSet ha hecho su trabajo a la perfección —hay un pod Running, el Service enruta, la aplicación arranca— y sin embargo el negocio ha perdido todas las reservas. Este es el punto exacto que separa una carga de trabajo sin estado de una con estado: para tienda-web recrear el pod es gratis; para postgres-reservas es una catástrofe.
- Qué es un volumen en Kubernetes
Un volumen es un directorio, con contenido y respaldo variables según su tipo, que Kubernetes pone a disposición de los contenedores de un pod.
La definición formal importa menos que estas dos afirmaciones, que son las que hay que interiorizar:
- El volumen se declara en el pod, no en el contenedor. Vive en
spec.volumesdel pod. Los contenedores solo lo montan en una ruta, mediantespec.containers[].volumeMounts. Por eso dos contenedores del mismo pod pueden ver el mismo volumen en rutas distintas. - El ciclo de vida del volumen está ligado al pod, no al contenedor. Un volumen sobrevive al reinicio de un contenedor. Lo que ocurra cuando el pod desaparece depende del tipo de volumen: unos mueren con él (
emptyDir) y otros no (los respaldados por un PersistentVolumeClaim).
flowchart TB
subgraph POD["Pod"]
M1["Contenedor A<br/>volumeMounts: datos -> /var/lib/datos"]
M2["Contenedor B<br/>volumeMounts: datos -> /entrada (readOnly)"]
V["volumes:<br/>- name: datos<br/> emptyDir: {}"]
end
M1 --> V
M2 --> V
Esa segunda afirmación explica exactamente hasta dónde llega esta lección. Un emptyDir arregla el caso "el contenedor de PostgreSQL se cayó y el kubelet lo reinició": el nuevo contenedor vuelve a montar el mismo directorio y encuentra sus datos. No arregla el caso "el pod fue reemplazado", que es el que acabas de demostrar. Para eso hacen falta los PersistentVolumes de 05-02.
- La pareja
volumes y volumeMounts
volumes y volumeMountsTodo volumen requiere dos declaraciones que se enlazan por el nombre. Es el patrón más repetido de Kubernetes y no cambia sea cual sea el tipo:
apiVersion: v1
kind: Pod
metadata:
name: demo-volumen
namespace: rutas-norte-dev
labels: { app: demo-volumen, app.kubernetes.io/part-of: rutas-norte, entorno: dev }
spec:
containers:
- name: escritor
image: busybox:1.36
command: ["sh", "-c", "echo hola > /datos/saludo.txt && sleep 3600"]
volumeMounts: # (2) DONDE lo monta ESTE contenedor
- name: espacio-temporal # debe coincidir con volumes[].name
mountPath: /datos
volumes: # (1) QUE volumen tiene el POD
- name: espacio-temporal
emptyDir: {}Los dos bloques, con precisión:
spec.volumes(nivel de pod): una lista de volúmenes. Cada elemento tiene unnameúnico dentro del pod y exactamente una clave de tipo (emptyDir,hostPath,configMap,secret,persistentVolumeClaim,projected,ephemeral...). Declarar un volumen que ningún contenedor monta es legal pero inútil; para algunos tipos (comoconfigMap) implica de todos modos que el pod no arrancará si el objeto referenciado no existe.spec.containers[].volumeMounts(nivel de contenedor): dónde se ve ese volumen dentro de ese contenedor concreto. Elnamees la referencia cruzada.
Si el name no casa, el error es inmediato y explícito al crear el pod:
The Pod "demo-volumen" is invalid: spec.containers[0].volumeMounts[0].name:
Not found: "espacio-temporl"Los initContainers también pueden montar volúmenes, y es precisamente el mecanismo que hace útil el patrón de inicialización que se estudia en 06-04: un init container prepara ficheros en un volumen y el contenedor principal los encuentra ya listos.
mountPath, readOnly, subPath y subPathExpr
mountPath, readOnly, subPath y subPathExprLos campos de volumeMounts merecen detalle porque cada uno resuelve un problema real.
| Campo | Qué hace | Cuidado |
|---|---|---|
mountPath |
Ruta absoluta dentro del contenedor donde aparece el volumen | Oculta el contenido previo de esa ruta en la imagen |
readOnly |
Monta el volumen en solo lectura | Por defecto false; ponlo a true siempre que puedas |
subPath |
Monta un subdirectorio o un fichero del volumen, no su raíz | No recibe actualizaciones de ConfigMap/Secret |
subPathExpr |
Igual que subPath pero con variables de entorno expandidas |
Excluyente con subPath |
mountPropagation |
Propagación de submontajes al host o desde él | Solo para casos muy especiales |
mountPath oculta lo que había
Este es el efecto que más sorprende al principio. Si montas un volumen en /etc/nginx/conf.d de la imagen de tienda-web, todo lo que la imagen traía en ese directorio deja de verse, igual que al montar un disco sobre un directorio en Linux. No se borra: queda tapado. Por eso montar sobre /etc o /usr rompe el contenedor.
subPath: montar un solo fichero
Ya lo usaste en 03-01 para inyectar un único fichero de configuración sin tapar el directorio entero. La misma técnica sirve para los volúmenes de datos:
volumeMounts:
- name: datos-postgres
mountPath: /var/lib/postgresql/data
subPath: pgdata # se monta <volumen>/pgdata, no la raizEn el caso de PostgreSQL hay una razón operativa concreta para hacerlo, que se explica en detalle en 05-03: muchos volúmenes de bloque formateados traen un directorio lost+found en su raíz, y PostgreSQL se niega a inicializar un directorio de datos que no esté vacío. En Rutas Norte lo resolvimos ya en el módulo 3 por la vía equivalente de la variable PGDATA, y en 05-03 unificaremos ambos enfoques.
subPathExpr: una ruta por pod
subPathExpr expande variables de entorno del contenedor, incluidas las de la Downward API de 03-03. El caso canónico es que cada réplica escriba en su propio subdirectorio de un volumen compartido:
- name: worker
image: registry.rutasnorte.example/worker-notificaciones:1.8.0
env:
- name: NOMBRE_POD
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: registros
mountPath: /var/log/notificaciones
subPathExpr: $(NOMBRE_POD) # /registros/worker-notificaciones-abc123Cada pod de worker-notificaciones escribe sus registros en un subdirectorio con su propio nombre, y no se pisan entre sí.
emptyDir en detalle
emptyDir en detalleemptyDir es el tipo de volumen más simple y el más usado con diferencia.
Cuándo se crea: en el momento en que el pod se asigna a un nodo. Empieza siempre vacío, de ahí el nombre.
Cuándo se borra: cuando el pod se elimina del nodo, de forma definitiva e irrecuperable. Que quede muy claro qué lo sobrevive y qué no:
| Suceso | ¿Sobrevive el emptyDir? |
|---|---|
| Un contenedor del pod falla y se reinicia | Sí |
kubectl exec mata un proceso secundario |
Sí |
El pod se borra (kubectl delete pod, rollout, desalojo) |
No |
| El nodo se reinicia | No (el pod se recrea) |
Dónde vive: por defecto, en el disco del nodo, bajo /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~empty-dir/. Puedes verlo en minikube:
sizeLimit
Sin límite, un emptyDir puede llenar el disco del nodo y provocar DiskPressure, con el desalojo de pods ajenos que estudiaste en 03-04. Se acota con sizeLimit:
Cuando se supera el límite, el kubelet desaloja el pod (no devuelve un error de escritura al proceso). El evento lo dice sin rodeos:
Warning Evicted pod/worker-notificaciones-7c9d5-k4m2n
Usage of EmptyDir volume "adjuntos" exceeds the limit "500Mi".medium: Memory
Con medium: Memory, el volumen se respalda en un tmpfs: RAM, no disco. Es rapidísimo y el contenido nunca toca un disco, lo que lo hace idóneo para material sensible —es exactamente lo que Kubernetes hace por debajo con los volúmenes de secret, como viste en 03-02—. Se declara con emptyDir: { medium: Memory, sizeLimit: 64Mi }.
El aviso importante: lo que escribas ahí cuenta contra el limit de memoria del contenedor que lo usa. Si tienda-web tiene limits.memory: 256Mi y llenas 200 MiB de tmpfs, al proceso le quedan 56 MiB y acabará OOMKilled sin que ningún gráfico de "memoria del proceso" lo explique. Regla: si usas medium: Memory, súmalo al limit de memoria y pon siempre sizeLimit. Sin sizeLimit, el tmpfs puede crecer hasta la mitad de la RAM del nodo.
- Compartir un
emptyDir entre contenedores del mismo pod
emptyDir entre contenedores del mismo podEste es el caso de uso que justifica por sí solo la existencia de emptyDir, y encaja con lo que aprendiste en 02-01: los contenedores de un pod comparten red y pueden compartir volúmenes, pero no su sistema de ficheros.
En Rutas Norte, tienda-web renderiza plantillas HTML de horarios y trayectos. Añadimos un contenedor secundario que las genera cada cinco minutos a partir de la API y las deja en un directorio; nginx simplemente las sirve. Ambos ven el mismo emptyDir, y nginx lo monta en solo lectura.
# k8s/base/tienda-web-deployment.yaml (fragmento: el podTemplate)
spec:
containers:
# --- Contenedor principal: sirve el HTML ---
- name: nginx
image: nginx:1.27.1
ports: [{ name: http, containerPort: 8080 }]
volumeMounts:
- name: cache-plantillas
mountPath: /usr/share/nginx/html/horarios
readOnly: true # nginx NO debe escribir aqui
resources:
requests: { cpu: 50m, memory: 128Mi }
limits: { cpu: 200m, memory: 192Mi } # 128Mi proceso + 64Mi tmpfs
# --- Contenedor secundario: regenera las plantillas ---
- name: generador-plantillas
image: registry.rutasnorte.example/generador-plantillas:1.2.0
env:
- name: API_URL
value: "http://api-reservas:8080" # el Service del modulo 2
- name: INTERVALO_SEGUNDOS
value: "300"
volumeMounts:
- name: cache-plantillas
mountPath: /salida # OTRA ruta, MISMO volumen
resources:
requests: { cpu: 25m, memory: 64Mi }
limits: { cpu: 100m, memory: 128Mi }
volumes:
- name: cache-plantillas
# RAM: se regenera cada 5 min, no hace falta disco
emptyDir: { medium: Memory, sizeLimit: 64Mi }Los puntos de diseño que hay que saber leer en ese manifiesto:
- Un solo volumen, dos
mountPathdistintos. El generador escribe en/salida; nginx lee en/usr/share/nginx/html/horarios. Es el mismo directorio del nodo. readOnly: trueen el consumidor. Si mañana una vulnerabilidad permite escribir a través de nginx, el atacante no puede alterar las plantillas que sirve. Cuesta una línea.medium: MemoryconsizeLimit: 64Mi, y ellimits.memoryde nginx elevado a 192Mi para absorberlo. Sin ese ajuste, un pico de plantillas mataría el contenedor conOOMKilled.- No es persistencia. Si el pod muere, la caché se pierde y el generador la rehace en el arranque siguiente. Ese es exactamente el criterio para elegir
emptyDir: datos reconstruibles.
Verificación, incluida la prueba de que readOnly funciona:
kubectl exec -n rutas-norte-dev deploy/tienda-web -c generador-plantillas -- \
sh -c 'echo "<h1>Bilbao-Santander</h1>" > /salida/prueba.html'
kubectl exec -n rutas-norte-dev deploy/tienda-web -c nginx -- \
cat /usr/share/nginx/html/horarios/prueba.html
kubectl exec -n rutas-norte-dev deploy/tienda-web -c nginx -- \
sh -c 'echo intruso > /usr/share/nginx/html/horarios/intruso.html'<h1>Bilbao-Santander</h1>
sh: can't create /usr/share/nginx/html/horarios/intruso.html: Read-only file system
command terminated with exit code 1
hostPath: qué es y por qué es peligroso
hostPath: qué es y por qué es peligrosohostPath monta un fichero o directorio del sistema de ficheros del nodo dentro del pod.
El campo type valida qué se espera encontrar en esa ruta, y es la diferencia entre un fallo claro y un comportamiento silencioso e inesperado:
type |
Comportamiento |
|---|---|
| (vacío) | Sin comprobación alguna. Compatibilidad hacia atrás; evítalo |
Directory |
Debe existir un directorio; si no, el pod no arranca |
DirectoryOrCreate |
Si no existe, el kubelet lo crea (permisos 0755, propiedad del kubelet) |
File |
Debe existir un fichero |
FileOrCreate |
Si no existe, se crea vacío |
Socket |
Debe existir un socket UNIX (caso típico: /var/run/docker.sock) |
CharDevice / BlockDevice |
Dispositivo de caracteres o de bloques |
Usos legítimos, todos ellos de componentes de infraestructura, no de aplicaciones:
Por qué es un riesgo grave en producción
hostPath rompe el aislamiento entre el pod y el nodo. Las consecuencias son de gravedad máxima:
- Escalada a root en el nodo. Un pod que monta
/o/etccon escritura puede añadir una clave SSH en/root/.ssh/authorized_keys, o dejar un manifiesto en/etc/kubernetes/manifests/que el kubelet arrancará como pod estático con todos los privilegios. Eso es control total del nodo. - Acceso a los secretos de los demás pods. Bajo
/var/lib/kubelet/pods/están montados los volúmenes de todos los pods de ese nodo, incluidos los Secrets con las credenciales depostgres-reservas. - Fuga del runtime. Montar
/var/run/containerd/containerd.sock(o el socket de Docker) permite crear contenedores privilegiados sin pasar por la API de Kubernetes ni por RBAC. - Datos que no viajan. Aunque no hubiera riesgo de seguridad, un
hostPathestá atado a un nodo concreto: si el pod se reprograma en otro, encuentra un directorio distinto (o vacío) y los datos "desaparecen" sin ningún error.
Por eso hostPath no es una solución de persistencia para postgres-reservas, aunque a primera vista lo parezca. Y por eso los Pod Security Standards en su nivel baseline prohíben hostPath por completo; lo verás aplicado en 08-03, y el endurecimiento del contenedor que lo acompaña en 08-02.
Regla de Rutas Norte: ningún manifiesto de aplicación usa hostPath. Si aparece uno en una revisión de código, se rechaza sin discusión. La necesidad legítima —persistencia real— se cubre con PVC.
- Los volúmenes de configuración:
configMap, secret, downwardAPI
configMap, secret, downwardAPIYa has usado los tres en el módulo 3; los recolocamos aquí porque son volúmenes, con la misma mecánica volumes/volumeMounts, y conviene verlos en la misma familia.
| Tipo | Origen del contenido | Respaldo | Se actualiza solo |
|---|---|---|---|
configMap |
Claves de un ConfigMap | Disco del nodo | Sí (salvo subPath o immutable: true) |
secret |
Claves de un Secret | tmpfs (RAM) | Sí (salvo subPath o immutable: true) |
downwardAPI |
Campos y recursos del propio pod | Disco del nodo | Solo labels y annotations |
volumes:
- name: configuracion
configMap:
name: api-reservas-config
defaultMode: 0444
items: # solo estas claves, con nombre elegido
- key: application.yaml
path: app.yaml
- name: credenciales
secret:
secretName: postgres-reservas-credenciales
defaultMode: 0400 # solo lectura para el propietarioDos recordatorios que se olvidan a menudo:
- La actualización automática del contenido no reinicia el proceso. Por eso en 03-03 añadimos una anotación con el hash de la configuración al pod, para forzar un
rolloutcuando cambia. - Un montaje con
subPathqueda congelado: recibe el contenido del momento del montaje y nunca se actualiza. Es la contrapartida de poder montar un único fichero.
- El volumen
projected: todos en un directorio
projected: todos en un directorioprojected combina varias fuentes —configMap, secret, downwardAPI y serviceAccountToken— en un único directorio. Sirve para no llenar el contenedor de puntos de montaje y, sobre todo, para pedir tokens de ServiceAccount con audiencia y caducidad controladas, como viste en 03-06.
volumes:
- name: identidad-y-config
projected:
defaultMode: 0400
sources:
- secret:
name: postgres-reservas-credenciales
items:
- key: password
path: db/password # -> /etc/rutasnorte/db/password
- configMap:
name: api-reservas-config
items:
- key: application.yaml
path: app/application.yaml
- serviceAccountToken:
path: token
expirationSeconds: 3600
audience: api-reservasMontado en /etc/rutasnorte, el contenedor ve un solo árbol: app/application.yaml, db/password y token.
Restricciones a recordar: dentro de un projected no caben persistentVolumeClaim ni emptyDir (solo las cuatro fuentes citadas), y no se puede repetir el mismo path en dos fuentes: el pod se rechaza por conflicto.
- Tabla resumen de tipos de volumen
Los tipos que se usan hoy en un clúster 1.30+, con su ciclo de vida y su caso de uso. Los tipos "in-tree" de proveedores concretos (awsElasticBlockStore, gcePersistentDisk, azureDisk...) están eliminados o migrados a CSI; no los escribas en manifiestos nuevos.
| Tipo | Ciclo de vida | Respaldo | Caso de uso típico en Rutas Norte |
|---|---|---|---|
emptyDir |
Muere con el pod | Disco del nodo | Caché de plantillas de tienda-web, ficheros temporales |
emptyDir + medium: Memory |
Muere con el pod | RAM (tmpfs) | Datos sensibles temporales, caché rápida pequeña |
configMap |
Muere con el pod | Disco del nodo | application.yaml de api-reservas |
secret |
Muere con el pod | RAM (tmpfs) | postgres-reservas-credenciales |
downwardAPI |
Muere con el pod | Disco del nodo | Nombre del pod y entorno para los registros |
projected |
Muere con el pod | Mixto | Token de ServiceAccount con audiencia |
hostPath |
Sobrevive al pod, atado al nodo | Disco del nodo | Solo infraestructura (agentes, DaemonSets) |
local |
Sobrevive, atado al nodo, vía PV | Disco del nodo | Almacenamiento local de alto rendimiento (05-02) |
persistentVolumeClaim |
Sobrevive al pod | Lo que provea el PV | Los datos de postgres-reservas (05-03) |
ephemeral (genérico) |
Muere con el pod | Lo que provea la StorageClass | Espacio de trabajo grande y desechable |
csi (en línea) |
Según el driver | Driver CSI | Inyección de secretos externos (Vault, gestores de la nube) |
nfs |
Sobrevive al pod | Servidor NFS | Compartir ficheros entre varios pods |
- Volúmenes efímeros genéricos: el puente al resto del módulo
Queda un tipo que hace de bisagra entre esta lección y las siguientes: el volumen efímero genérico. Es un volumen que se aprovisiona como un PVC —con toda la potencia del almacenamiento real: la clase que quieras, el tamaño que quieras, expansión, snapshots— pero cuyo ciclo de vida es el del pod: al borrarse el pod, el PVC creado automáticamente se borra con él.
volumes:
- name: espacio-informes
ephemeral:
volumeClaimTemplate:
metadata:
labels: { app: informes-ocupacion, entorno: dev }
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: standard
resources: { requests: { storage: 20Gi } }Es lo que hay que usar cuando necesitas mucho espacio temporal —más del que cabe razonablemente en el disco del nodo— sin querer conservarlo: la generación de un informe voluminoso, un procesamiento por lotes, una descarga grande. En Rutas Norte encajará con informes-ocupacion cuando llegue en 06-03.
La comparación cierra el mapa mental de la lección:
emptyDir |
ephemeral genérico |
persistentVolumeClaim |
|
|---|---|---|---|
| Ciclo de vida | Pod | Pod | Independiente del pod |
| Tamaño garantizado | No (solo tope) | Sí | Sí |
| Clase de almacenamiento | No aplica | Sí | Sí |
| Snapshots y expansión | No | Sí | Sí |
Sobrevive a delete pod |
No | No | Sí |
Y con eso queda planteado lo que falta: para postgres-reservas necesitamos la tercera columna. Esa columna la construyen las cinco lecciones restantes del módulo.
Errores Comunes y Consejos
| Error | Síntoma | Solución |
|---|---|---|
Creer que emptyDir da persistencia |
Datos perdidos tras un rollout |
emptyDir muere con el pod; usa un PVC (05-03) |
name distinto en volumes y volumeMounts |
spec.containers[0].volumeMounts[0].name: Not found |
Revisa la referencia cruzada; son dos bloques enlazados por nombre |
| Montar sobre un directorio de la imagen | El contenedor arranca pero "faltan ficheros" | mountPath oculta lo que hubiera; usa subPath para un solo fichero |
medium: Memory sin ajustar limits.memory |
OOMKilled inexplicable |
El tmpfs cuenta contra el límite del contenedor (03-04) |
emptyDir sin sizeLimit |
Nodo en DiskPressure, pods ajenos desalojados |
Pon siempre sizeLimit |
Esperar que un subPath de ConfigMap se actualice |
La configuración cambia y el fichero no | subPath congela el contenido; monta el directorio o fuerza un rollout |
Usar hostPath para datos de aplicación |
Datos "desaparecidos" al reprogramar el pod, y agujero de seguridad | Prohibido en aplicaciones; usa PVC |
hostPath con type vacío |
Se crea un directorio inesperado o se monta lo que no era | Especifica siempre type |
| Montar en escritura lo que solo se lee | Superficie de ataque innecesaria | readOnly: true por defecto en los consumidores |
Cuatro consejos que ahorran horas:
- Antes de escribir un volumen, responde: ¿qué pasa si esto se pierde? Si la respuesta es "se regenera",
emptyDir. Si es "perdemos datos del negocio", PVC. No hay término medio. - Inspecciona los volúmenes efectivos de un pod en marcha, que no siempre son los que creías, con
kubectl exec ... -- df -hykubectl exec ... -- mount | grep -E "tmpfs|overlay". kubectl describe podmuestra la secciónVolumes:yMounts:de cada contenedor: es el sitio más rápido para verificar que el montaje es el que pretendías y si esroorw.kubectl explain pod.spec.volumeslista todos los tipos disponibles en tu versión del clúster, que es la fuente de verdad frente a cualquier documentación desactualizada.
Ejercicios
Ejercicio 1: demostrar y acotar la efimeridad
En rutas-norte-dev, crea un pod prueba-efimero con la imagen busybox:1.36 y dos volúmenes: uno emptyDir normal montado en /persiste-reinicio y otro emptyDir con medium: Memory y sizeLimit: 32Mi montado en /en-ram. Escribe un fichero en cada uno. Después:
- Mata el proceso principal del contenedor para forzar un reinicio y comprueba qué sobrevive.
- Borra el pod, recréalo y comprueba qué sobrevive.
- Verifica con
mountcuál de los dos está entmpfs. - Intenta escribir 50 MiB en
/en-ramy describe qué ocurre.
Ejercicio 2: caché compartida para tienda-web
Escribe k8s/base/tienda-web-cache.yaml: un Deployment de tienda-web en rutas-norte-dev con dos contenedores que compartan un emptyDir llamado cache-plantillas:
nginx(imagennginx:1.27.1) que lo monta en/usr/share/nginx/html/horariosen solo lectura.generador-plantillas(usabusybox:1.36con un bucle que escriba la hora en un fichero HTML cada 30 segundos) que lo monta en/salidaen escritura.
Etiquetas y resources según las convenciones del curso. Demuestra que nginx ve lo que escribe el generador y que no puede escribir él.
Ejercicio 3: revisión de código
Un compañero envía este manifiesto para dar persistencia a postgres-reservas en el clúster de preproducción. Encuentra cuatro problemas distintos y explica la consecuencia de cada uno.
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres-reservas
namespace: rutas-norte-pre
spec:
replicas: 2
selector:
matchLabels: { app: postgres-reservas, entorno: pre }
template:
metadata:
labels: { app: postgres-reservas, entorno: pre }
spec:
containers:
- name: postgres
image: postgres:16.4
volumeMounts:
- name: datos
mountPath: /var/lib/postgresql
volumes:
- name: datos
hostPath:
path: /datos/postgresSoluciones
Ejercicio 1
# prueba-efimero.yaml
apiVersion: v1
kind: Pod
metadata:
name: prueba-efimero
namespace: rutas-norte-dev
labels:
app: prueba-efimero
app.kubernetes.io/part-of: rutas-norte
entorno: dev
spec:
containers:
- name: caja
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
volumeMounts:
- name: en-disco
mountPath: /persiste-reinicio
- name: en-ram
mountPath: /en-ram
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { cpu: 50m, memory: 96Mi } # 64Mi proceso + 32Mi de tmpfs
volumes:
- name: en-disco
emptyDir:
sizeLimit: 64Mi
- name: en-ram
emptyDir:
medium: Memory
sizeLimit: 32Mikubectl apply -f prueba-efimero.yaml
kubectl wait --for=condition=ready pod/prueba-efimero -n rutas-norte-dev --timeout=60s
kubectl exec -n rutas-norte-dev prueba-efimero -- sh -c \
'echo disco > /persiste-reinicio/f.txt; echo ram > /en-ram/f.txt'
# 3. Que hay en tmpfs
kubectl exec -n rutas-norte-dev prueba-efimero -- mount | grep -E "/en-ram|/persiste"
# 1. Reinicio del CONTENEDOR: el volumen sobrevive
kubectl exec -n rutas-norte-dev prueba-efimero -- kill 1
kubectl wait --for=condition=ready pod/prueba-efimero -n rutas-norte-dev --timeout=60s
kubectl exec -n rutas-norte-dev prueba-efimero -- cat /persiste-reinicio/f.txt /en-ram/f.txttmpfs on /en-ram type tmpfs (rw,relatime,size=32768k)
/dev/vda1 on /persiste-reinicio type ext4 (rw,relatime)
disco
ramAmbos sobreviven: el volumen está ligado al pod, no al contenedor. Incluso el de RAM, porque el tmpfs pertenece al pod.
# 2. Borrado del POD: se pierde todo
kubectl delete pod prueba-efimero -n rutas-norte-dev
kubectl apply -f prueba-efimero.yaml
kubectl wait --for=condition=ready pod/prueba-efimero -n rutas-norte-dev --timeout=60s
kubectl exec -n rutas-norte-dev prueba-efimero -- ls /persiste-reinicio /en-ram
# 4. Superar el sizeLimit del tmpfs
kubectl exec -n rutas-norte-dev prueba-efimero -- \
dd if=/dev/zero of=/en-ram/grande bs=1M count=50Los dos directorios quedan vacíos: es exactamente lo que le pasa hoy a postgres-reservas. Y con medium: Memory el sizeLimit es el tamaño real del tmpfs, así que el fallo es inmediato y dentro del contenedor. En un emptyDir de disco el comportamiento es distinto: la escritura tiene éxito y es el kubelet quien, al detectar el exceso, desaloja el pod con el evento Usage of EmptyDir volume ... exceeds the limit. Compruébalo con kubectl get events -n rutas-norte-dev --sort-by=.lastTimestamp.
Ejercicio 2
# k8s/base/tienda-web-cache.yaml (fragmento: el podTemplate)
spec:
containers:
- name: nginx
image: nginx:1.27.1
ports: [{ name: http, containerPort: 80 }]
volumeMounts:
- name: cache-plantillas
mountPath: /usr/share/nginx/html/horarios
readOnly: true # el consumidor NO escribe
resources:
requests: { cpu: 50m, memory: 96Mi }
limits: { cpu: 200m, memory: 160Mi } # 128Mi + 32Mi de tmpfs
- name: generador-plantillas
image: busybox:1.36
command: ["sh", "-c", "while true; do
echo \"<h1>Horarios</h1><p>$(date)</p>\" > /salida/horarios.html;
sleep 30; done"]
volumeMounts:
- name: cache-plantillas
mountPath: /salida # OTRA ruta, MISMO volumen
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { cpu: 50m, memory: 64Mi }
volumes:
- name: cache-plantillas
emptyDir: { medium: Memory, sizeLimit: 32Mi }kubectl apply -f k8s/base/tienda-web-cache.yaml
kubectl rollout status deploy/tienda-web -n rutas-norte-dev
# nginx ve lo que escribe el generador, y lo sirve por HTTP
kubectl exec -n rutas-norte-dev deploy/tienda-web -c nginx -- \
curl -s localhost/horarios/horarios.html
# pero no puede escribir
kubectl exec -n rutas-norte-dev deploy/tienda-web -c nginx -- \
sh -c 'touch /usr/share/nginx/html/horarios/x' || echo "DENEGADO (correcto)"<h1>Horarios Rutas Norte</h1><p>Wed Aug 5 09:14:22 UTC 2026</p>
touch: cannot touch '...': Read-only file system
DENEGADO (correcto)Ejercicio 3
Los cuatro problemas:
| # | Problema | Consecuencia |
|---|---|---|
| 1 | hostPath para datos de aplicación |
Los datos quedan atados a un nodo: si el pod se reprograma, la base de datos aparece vacía sin error alguno. Además es un agujero de seguridad y lo prohíbe el nivel baseline de los Pod Security Standards (08-03) |
| 2 | replicas: 2 |
Dos procesos de PostgreSQL escribiendo sobre el mismo directorio: corrupción del cluster de datos. PostgreSQL protege con un fichero de bloqueo, pero solo dentro del mismo nodo; en nodos distintos con un almacenamiento compartido, la corrupción es real. Una base de datos así va a replicas: 1 y a strategy: Recreate (o a un StatefulSet, 06-01) |
| 3 | mountPath: /var/lib/postgresql en lugar de /var/lib/postgresql/data |
Se monta un nivel de más y se oculta el contenido que la imagen trae en ese directorio (el home del usuario postgres, con su configuración). El síntoma es un arranque que falla o un initdb que se rehace |
| 4 | Faltan resources, strategy: Recreate, la etiqueta app.kubernetes.io/part-of y el type del hostPath |
Sin resources el pod es Burstable o BestEffort y puede ser desalojado, cuando la decisión de 03-05 es que sea Guaranteed; con strategy por defecto (RollingUpdate) el pod nuevo convive con el viejo sobre los mismos datos; y sin type el hostPath no valida nada |
La corrección no es retocar este manifiesto sino cambiar de mecanismo: un PersistentVolumeClaim, que es lo que construyen las siguientes lecciones.
Conclusión
Has demostrado con tus manos el problema que da nombre al módulo: creaste una reserva en postgres-reservas, borraste el pod y la reserva desapareció. La causa no es un fallo: es la capa de escritura de overlayfs, que nace vacía con cada contenedor y muere con él. De ahí sale la regla que gobierna todo lo demás: en Kubernetes el estado hay que sacarlo del contenedor de forma explícita.
Ese "fuera" es el volumen, y ahora sabes leerlo con precisión. Se declara en el pod (spec.volumes), no en el contenedor; los contenedores solo lo montan (volumeMounts), y ambos bloques se enlazan por el name. Dominas los campos del montaje: mountPath, que oculta lo que hubiera en esa ruta; readOnly, que deberías poner por defecto en todo consumidor; subPath, para montar un único fichero al precio de congelar su contenido; y subPathExpr, para dar a cada réplica su propio subdirectorio.
Conoces a fondo los dos tipos que no dependen de infraestructura externa. emptyDir nace vacío al asignarse el pod a un nodo y muere con el pod: sobrevive al reinicio de un contenedor pero no a su reemplazo; se acota con sizeLimit para no provocar DiskPressure, y con medium: Memory vive en RAM —rápido y sin tocar disco, pero contando contra el limit de memoria del contenedor—. Lo has puesto a trabajar en el caso que le corresponde: la caché de plantillas que comparten nginx y el generador dentro del pod de tienda-web, con el consumidor en solo lectura y datos reconstruibles. Y hostPath, con sus type y con su veredicto claro: legítimo solo para infraestructura, prohibido en aplicaciones, porque rompe el aislamiento con el nodo —escalada a root, acceso a los secretos de los demás pods, fuga por el socket del runtime— y porque ata los datos a una máquina concreta. Has recolocado además los volúmenes de configuración que ya usabas —configMap, secret, downwardAPI— y el projected que los reúne en un solo directorio, y tienes la tabla de todos los tipos con su ciclo de vida.
Lo que no has resuelto es precisamente lo que abría la lección: ningún volumen de los vistos sobrevive al reemplazo del pod. La bisagra la dejamos apuntada con el volumen efímero genérico (ephemeral.volumeClaimTemplate), que ya se aprovisiona como almacenamiento de verdad aunque siga muriendo con el pod. Falta cortar el último hilo: desacoplar la vida del disco de la vida del pod. Eso exige un objeto nuevo, con existencia propia en el clúster, administrado por quien gestiona la infraestructura y consumido por quien despliega la aplicación. Es el PersistentVolume, y es la siguiente lección: Volúmenes Persistentes.
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
