Índice del curso
Lección 8 — Datos y tareas
Por Dorian Chávez · fundador y CEO de Hábil ·
Lunes, 7:12 de la mañana. A las 9:00 hay reunión con el proveedor del servicio de pagos, y tu jefa te escribe un mensaje de una línea: «Necesito el reporte del viernes a las 6 de la tarde, el que mostró a prueba caída cuarenta minutos. Es la prueba del reclamo». Abres el clúster con la tranquilidad de quien sabe que el revisor guarda su reporte en un archivo, /datos/reporte.json. Pides los Pods y ves algo que no esperabas: las tres réplicas tienen menos de dos días. El sábado, en el mantenimiento de la madrugada, alguien vació los nodos uno por uno para actualizarlos (con kubectl drain, que desaloja los Pods de un nodo) y el clúster hizo lo que sabe hacer: borró los Pods viejos y el Deployment creó Pods nuevos en otros nodos. Pides el archivo. No está. Buscas en las otras dos réplicas. Tampoco. Y aunque hubiera estado, te das cuenta de algo peor mientras lo piensas: el revisor guarda el último reporte y escribe encima del anterior. El del viernes se habría perdido de todos modos con la primera consulta del sábado.
Son las 7:20. Quedan cien minutos para encontrar una forma de decir «no lo tengo» que no suene a «no lo guardé».
No es un caso raro de mala suerte. Es lo que pasa cuando se confunde un contenedor con una caja fuerte. En 2017, el equipo de GitLab publicó el relato de las horas en que perdió datos de producción y descubrió que sus mecanismos de respaldo no estaban sirviendo cuando hicieron falta: las copias diarias fallaban en silencio y el cubo donde debían estar estaba vacío, las instantáneas de disco no estaban activadas en los servidores de la base de datos y la réplica estaba rota. Su informe (las fuentes lo citan, con la liga) es un buen recordatorio de que la pregunta no es «¿tengo respaldo?», sino «¿lo he probado?». Esta lección es más modesta: no construye respaldos. Te enseña la mitad anterior, la que casi todos descubren tarde: qué parte de lo que escribe un Pod sobrevive al propio Pod, y qué parte no. Con esa respuesta en la mano, el reporte del viernes deja de depender de la suerte: una tarea programada le pide el reporte al revisor y lo guarda, con la fecha en el nombre, en un volumen que sobrevive a los Pods (mientras exista la solicitud y el nodo donde vive el disco). Ya no es un renglón que se pisa: es un archivo con nombre y fecha.
La lección tiene dos mitades que se necesitan una a la otra. La primera es el dónde: volúmenes que viven más que los Pods. La segunda es el cuándo: tareas que corren una vez, o cada cinco minutos, sin que nadie las lance. Juntas resuelven la escena: una tarea programada le pide al revisor su reporte cada cinco minutos y lo guarda con la fecha en el nombre, en un volumen que sobrevive a los Pods.
Tiempo: 2 × 45 min
Qué construyes: el reporte guardado y el revisor como tarea programada: un volumen persistente que se llama reportes, una tarea (Job) que le pide al revisor su reporte con el token y lo escribe con la fecha en el nombre, y un CronJob que la repite cada cinco minutos, con una cuenta de servicio sin permisos ni credenciales montadas
Qué aprendes: qué sobrevive y qué no cuando se cae un contenedor y cuando se borra un Pod (emptyDir); PersistentVolume, PersistentVolumeClaim y StorageClass, y la clase de almacenamiento de kind; por qué la solicitud espera a su primer Pod; Job y CronJob con sus reintentos, su horario y su historial; el dueño de los archivos con fsGroup; qué se pierde al borrar la solicitud; y un tamaño pedido que el clúster no hace respetar
De dónde vienes. De la Lección 7: el revisor y el servicio de prueba viven en un espacio de nombres propio, revisor, que exige el nivel restricted de Pod Security Admission; cada carga tiene su cuenta de servicio sin credenciales montadas; y la red está cerrada por defecto, con aperturas nombradas: al revisor solo entran los Pods con la etiqueta acceso-revisor: permitido y los Envoy de la puerta. De la Lección 4, el token como Secret montado como archivo y los nombres con hash que pone Kustomize. De la Lección 5, requests y limits en todo contenedor; hoy los traen también las tareas. Y de la Lección 6, la plataforma (MetalLB y Envoy Gateway) que esta lección vuelve a instalar al crear su clúster, porque el punto de partida incluye el Gateway.
Qué no hace esta lección. No pone el reporte del revisor dentro del volumen persistente: el Ejercicio 2 te deja intentarlo y ver por qué el curso no lo hace (ata todas las réplicas a un solo nodo). No instala un sistema de almacenamiento de red, ni enseña StatefulSet, ni toma instantáneas o respaldos de un volumen: los nombra al final de «El hábito de seguridad», con una frase sobre el problema que resuelve cada uno. No escala nada (eso es la Lección 9, que parte de lo que dejas aquí) ni evalúa el proyecto completo contra un clúster real (la Lección 10).
Sobre las corridas. Todas las salidas de esta lección se obtuvieron el 21 de octubre de 2026 con kind v0.33.0, kubectl v1.37.1 y la imagen de nodo kindest/node:v1.37.0 fijada por su huella, en el clúster de tres nodos de la Lección 1, al que aquí se le llama curso-k8s-l8. La máquina fue una Mac con procesador ARM y Docker Engine dentro de una máquina virtual Linux (Colima), no Linux Mint, que es el sistema de referencia del curso. La corrida usó una red de Docker propia, curso-k8s-red-l8, con la subred 172.28.0.0/16, porque la máquina era compartida y en ese momento corrían otras lecciones con MetalLB: dos clústeres con el mismo rango de IP en la misma red se pisan. Por eso las direcciones del Gateway salen aquí en 172.28.255.x. En tu máquina, con la red kind normal que Docker crea al levantar el primer clúster, las IP salen en 172.18.255.x (el rango de infra/metallb-pool.yaml es 172.18.255.200-172.18.255.220), y no necesitas crear ninguna red. Eso supone que tu red kind tiene la subred 172.18.0.0/16; compruébalo antes con docker network inspect kind -f '{{range .IPAM.Config}}{{.Subnet}} {{end}}' y, si es otra, ajusta el rango del archivo. Lo demás que cambia en tu Mint: la arquitectura (amd64 en lugar de arm64), los sufijos al azar de los nombres de Pods y de Jobs, las edades, en qué nodo cae cada réplica, el uid de cada Pod, la hora y los nombres de los volúmenes (pvc- y un identificador). Los comandos son los mismos. El recorrido completo está en programas/08-datos-y-tareas/: correr.sh crea el clúster, le instala la plataforma, deja todo como lo dejó la Lección 7, recorre la lección en orden, corre los Ejercicios 1 a 3 y borra el clúster; su salida es correr.salida.txt. La corrida completa tardó 19 minutos y 17 segundos (los tres ensayos de concurrencyPolicy del Ejercicio 3 suman unos ocho minutos de esperas): crear el clúster y esperar a que estuviera listo tomó 42 segundos, instalar la plataforma tomó 175 (el guion no cronometra cada paso, así que no sé cuál de las esperas fue la larga) y borrar el clúster, 4. Estos tiempos varían mucho con la carga de la máquina: en otra corrida del mismo guion, con la máquina ocupada, instalar la plataforma tomó 678 segundos y el kubectl wait de Envoy Gateway llegó a agotar su plazo de 300 segundos, aunque el Gateway terminó funcionando. Las esperas largas de la lección (ver pasar el horario de un CronJob, ver cómo un Job agota sus reintentos) están escritas a la vista: el guion imprime cada cuántos segundos mira y qué ve, no las esconde. No usa ningún puerto de tu máquina.
Al terminar vas a poder
- Demostrar con la corrida qué sobrevive y qué no en un
emptyDir: el reinicio de un contenedor sí, el borrado del Pod no. - Explicar con tus palabras qué son un
PersistentVolume, unaPersistentVolumeClaimy unaStorageClass, y por qué la solicitud del curso se queda enPendinghasta que aparece el primer Pod que la usa. - Leer el estado de una solicitud de volumen, su volumen y el directorio real que lo respalda en el nodo.
- Escribir un
Job, decir cuántas veces reintenta y con qué esperas, y leer unBackoffLimitExceeded. - Escribir un
CronJob, leer su horario de cinco campos, y explicarconcurrencyPolicy, el historial de tareas y por qué una tarea programada debe poder correr dos veces sin dañar nada. - Provocar y arreglar un
Permission denieden un volumen confsGroup, y decir en cuál de los volúmenes de la lecciónfsGroupcambió el grupo y en cuál no, y por qué no puedes darlo por hecho. - Decir qué pasa con los datos al borrar una solicitud de volumen según la política de reclamación, y por qué el borrado puede quedarse en
Terminating.
El porqué antes del cómo
Hasta hoy, todo lo que el revisor escribe vive en un lugar que el clúster trata como desechable. Lo viste de pasada en la Lección 2, cuando el Deployment montó un emptyDir en /datos para que el sistema de archivos de solo lectura no impidiera guardar el reporte, y lo has usado desde entonces sin preguntarte cuánto dura. La primera pregunta de la lección es esa: ¿cuánto dura lo que escribe un Pod? La respuesta tiene dos escalones, porque un Pod no es lo mismo que un contenedor: el contenedor puede caerse y volver (el kubelet lo reinicia), y el Pod puede borrarse y ser reemplazado por otro con otro nombre en otro nodo. Lo que sobrevive a lo primero no tiene por qué sobrevivir a lo segundo, y lo medimos.
La segunda pregunta es ¿cómo se guarda algo de verdad? Kubernetes separa dos papeles que en Docker venían juntos. Quien administra el clúster decide qué almacenamiento existe (discos, carpetas, servicios de red); quien despliega una aplicación decide cuánto necesita y de qué tipo. El puente entre los dos son tres objetos con nombres parecidos y trabajos distintos, que esta lección presenta uno por uno antes de usarlos.
La tercera es ¿cómo se hace algo que no es un servicio? Un Deployment mantiene procesos que no deben terminar. Pedirle el reporte al revisor cada cinco minutos es lo contrario: un proceso que arranca, hace una cosa y termina, y que el clúster debe repetir en una hora fija sin que nadie se acuerde. Para eso existen el Job (hacer algo hasta que salga bien o hasta rendirse) y el CronJob (crear Jobs con un horario).
Y hay una cuarta pregunta, menos vistosa, que decide si lo de arriba funciona: ¿quién es el dueño de lo que se escribe? Un volumen que nace con un dueño y un permiso, y un proceso que corre como otro usuario, son una receta para un Permission denied. fsGroup, la línea de la Lección 4 que dejó pendiente su historia completa, vuelve aquí.
La lección tiene ocho partes de conceptos y después las secciones de siempre:
- El clúster de la lección y su punto de partida (8.1).
- Qué se pierde: el
emptyDirde hoy (8.2). - Volúmenes, solicitudes y clases (8.3).
- La solicitud de volumen y su espera (8.4).
Job: la tarea que termina (8.5).CronJob: la tarea con horario (8.6).- El dueño de los archivos:
fsGroup(8.7). - Borrar la solicitud: ahí sí se pierde (8.8).
Este es el destino. Al final de 8.5, una tarea corrida a mano deja un archivo con fecha en el nombre dentro de un volumen que sobrevive a su Pod, y una segunda tarea ve el archivo de la primera:
$ kubectl logs -n revisor job/reporte-manual-2
total 8
-rw-r--r-- 1 100 101 92 Oct 21 23:24 reporte-20261021T232446Z.json
-rw-r--r-- 1 100 101 92 Oct 21 23:24 reporte-20261021T232451Z.json
Si no traes las imágenes del curso de Docker
Esta lección usa las mismas dos imágenes que las anteriores: curso-docker-revisor:1.6.0 y curso-docker-prueba:1.0.0. Comprueba que las tienes:
$ docker image ls curso-docker-revisor:1.6.0 --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.Size}}'
curso-docker-revisor:1.6.0 bd1b2e7d91b8 16.2MB
Para la otra, el mismo comando con curso-docker-prueba:1.0.0. Si te falta alguna, la Lección 3 dice cómo construir cada una desde el repositorio del curso de Docker. Las tareas de esta lección usan además la imagen pública de curl que ya conoces de depurar, fijada por su huella sha256; cada nodo la descarga de internet la primera vez.
Los conceptos
8.1 El clúster de la lección y su punto de partida
8.1.1 Crear el clúster
Esta lección usa su propio clúster, curso-k8s-l8, con la configuración de tres nodos de la Lección 1. Las rutas programas/08-datos-y-tareas/… del bloque siguiente suponen que estás en la raíz del repositorio del curso. Antes de crearlo, dos precauciones que ya son costumbre: un kubeconfig temporal y propio, para que tu ~/.kube/config ni se lea ni se toque, y una función que comprueba a qué clúster le estás hablando. La llamaremos ctx y va delante de cada comando que cambie algo: si el contexto no es el de la lección, devuelve un error y el comando que sigue no corre, porque está unido a ella con &&.
# Un kubeconfig temporal y propio. La carpeta queda guardada en una variable:
# la necesitas para abrir una segunda terminal y para borrarla al final.
L8_TMP="$(mktemp -d /tmp/curso-k8s-l8.XXXX)"
export KUBECONFIG="$L8_TMP/kubeconfig"
echo "$KUBECONFIG" # anota esta ruta
# La guarda: el contexto debe ser el de la lección, o el comando que sigue no corre.
ctx() { [ "$(kubectl config current-context)" = kind-curso-k8s-l8 ] || { echo "contexto equivocado: no sigo"; return 1; }; }
# curso-k8s-l8 NO debe aparecer aquí. Si aparece y no es tuyo, detente.
kind get clusters
kind create cluster --name curso-k8s-l8 --config programas/08-datos-y-tareas/kind-curso.yaml
ctx && kind load docker-image curso-docker-revisor:1.6.0 curso-docker-prueba:1.0.0 --name curso-k8s-l8
# El resto de la lección se trabaja desde la carpeta de sus programas.
cd programas/08-datos-y-tareas
(La corrida de esta lección, por lo que se dijo en «Sobre las corridas», antepuso KIND_EXPERIMENTAL_DOCKER_NETWORK=curso-k8s-red-l8 a kind create cluster para usar su red propia. Tú no lo necesitas.)
El export y la función ctx solo viven en esa terminal. Si abres otra, repites las dos líneas (export KUBECONFIG=… con la ruta que imprimió el echo, y la definición de ctx) o kubectl le hablará a otro clúster. Desde aquí, todo bloque de código que cambie algo empieza con ctx &&. En las salidas pegadas más abajo, el renglón que empieza con $ es el comando tal como lo imprimió el guion, sin ctx &&: el guion comprueba el contexto antes de cada kubectl por su cuenta, y tú lo haces con la función.
Las salidas de esta sección vienen de la corrida de correr.sh, que hace exactamente eso, con la red propia que se explicó en «Sobre las corridas»:
$ kubectl config current-context
kind-curso-k8s-l8
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
curso-k8s-l8-control-plane Ready control-plane 27s v1.37.0
curso-k8s-l8-worker Ready <none> 16s v1.37.0
curso-k8s-l8-worker2 Ready <none> 17s v1.37.0
kind load imprime seis renglones not yet present on node …, loading..., dos imágenes por tres nodos, como en las lecciones anteriores.
8.1.2 La plataforma de la Lección 6, otra vez
El punto de partida de esta lección incluye el Gateway de la Lección 6, y un Gateway necesita los tipos nuevos (CRD) de Gateway API y el controlador de Envoy Gateway. Como el clúster es nuevo, hay que instalarlos otra vez, en el mismo orden de la Lección 6: primero la plataforma, luego una espera, luego su configuración. El orden importa porque los CRD y los webhooks deben existir antes de los recursos que los usan.
ctx && kubectl apply -k infra/metallb
ctx && kubectl apply --server-side -k infra/envoy-gateway
# La espera: los tipos nuevos establecidos y los controladores disponibles.
ctx && kubectl wait --for=condition=Established crd/ipaddresspools.metallb.io crd/l2advertisements.metallb.io crd/gatewayclasses.gateway.networking.k8s.io crd/gateways.gateway.networking.k8s.io crd/httproutes.gateway.networking.k8s.io --timeout=120s
ctx && kubectl wait --for=condition=Available deployment/controller -n metallb-system --timeout=240s
ctx && kubectl wait --for=condition=Available deployment/envoy-gateway -n envoy-gateway-system --timeout=300s
# La configuración: el rango de IP de MetalLB y la clase de puerta «eg».
ctx && kubectl apply -f infra/metallb-pool.yaml -f infra/gatewayclass.yaml
ctx && kubectl rollout status daemonset/speaker -n metallb-system --timeout=240s
La salida, con el rango de la red propia de la corrida:
$ kubectl apply -k infra/metallb | tail -2
daemonset.apps/speaker created
validatingwebhookconfiguration.admissionregistration.k8s.io/metallb-webhook-configuration created
$ kubectl apply --server-side -k infra/envoy-gateway | tail -1
mutatingwebhookconfiguration.admissionregistration.k8s.io/envoy-gateway-topology-injector.envoy-gateway-system serverside-applied
$ kubectl wait --for=condition=Established crd/ipaddresspools.metallb.io crd/l2advertisements.metallb.io crd/gatewayclasses.gateway.networking.k8s.io crd/gateways.gateway.networking.k8s.io crd/httproutes.gateway.networking.k8s.io --timeout=120s
customresourcedefinition.apiextensions.k8s.io/ipaddresspools.metallb.io condition met
customresourcedefinition.apiextensions.k8s.io/l2advertisements.metallb.io condition met
customresourcedefinition.apiextensions.k8s.io/gatewayclasses.gateway.networking.k8s.io condition met
customresourcedefinition.apiextensions.k8s.io/gateways.gateway.networking.k8s.io condition met
customresourcedefinition.apiextensions.k8s.io/httproutes.gateway.networking.k8s.io condition met
$ kubectl wait --for=condition=Available deployment/controller -n metallb-system --timeout=240s
deployment.apps/controller condition met
$ kubectl wait --for=condition=Available deployment/envoy-gateway -n envoy-gateway-system --timeout=300s
deployment.apps/envoy-gateway condition met
$ sed 's#172.18.255.200-172.18.255.220#172.28.255.200-172.28.255.250#' infra/metallb-pool.yaml | kubectl apply -f - && kubectl apply -f infra/gatewayclass.yaml
ipaddresspool.metallb.io/curso created
l2advertisement.metallb.io/curso created
gatewayclass.gateway.networking.k8s.io/eg created
$ kubectl rollout status daemonset/speaker -n metallb-system --timeout=240s | tail -1
daemon set "speaker" successfully rolled out
(la plataforma tardó 175 s)
Dos notas sobre esta salida. La del sed es solo de la corrida: cambia el rango del archivo (172.18.255.200-220) por el de la red propia (172.28.255.200-250) antes de aplicarlo. En tu máquina, con la red kind normal, aplicas el archivo tal cual: kubectl apply -f infra/metallb-pool.yaml -f infra/gatewayclass.yaml. Y los 175 segundos son mucho para esto (con la máquina más cargada llegaron a 678), y el guion no cronometra cada paso: la máquina estaba ocupada con otros clústeres. En un ensayo previo de esta misma lección, el último en declararse listo fue el speaker de MetalLB (un Pod por nodo que anuncia las IP en la red). Si en tu máquina el rollout status se tarda, kubectl get pods -n metallb-system te dice cuántos de los tres speakers están listos.
8.1.3 El punto de partida: la Lección 7
La carpeta programas/08-datos-y-tareas/punto-de-partida/ trae, sin cambiar una coma, los manifiestos con los que terminó la Lección 7: el espacio de nombres revisor con el nivel restricted, las cuentas de servicio, las seis políticas de red, el revisor y el servicio de prueba con sus Services, el Gateway y la kustomization.yaml. Como en las lecciones anteriores, el token no se versiona: se genera en cada corrida, con permiso 0600, y se copia a las dos carpetas (la del punto de partida y la de la lección) para que las dos generen el mismo Secret.
ctx && (umask 077; openssl rand -hex 16 > punto-de-partida/token.txt)
ctx && cp punto-de-partida/token.txt token.txt # hereda el 0600 del original
ls -l token.txt punto-de-partida/token.txt | awk '{print $1, $NF}'
TOKEN="$(cat token.txt)"
ctx && kubectl apply -k punto-de-partida
ctx && kubectl rollout status deployment/revisor -n revisor --timeout=180s
ctx && kubectl rollout status deployment/prueba -n revisor --timeout=180s
ctx && kubectl wait --for=condition=Programmed gateway/revisor -n revisor --timeout=240s
ctx && kubectl apply -n revisor -f depurar.yaml
$ ls -l token.txt punto-de-partida/token.txt | awk '{print $1, $NF}'
-rw------- punto-de-partida/token.txt
-rw------- token.txt
$ kubectl apply -k punto-de-partida
namespace/revisor created
serviceaccount/observador created
serviceaccount/prueba created
serviceaccount/revisor created
role.rbac.authorization.k8s.io/observador created
rolebinding.rbac.authorization.k8s.io/observador created
configmap/revisor-config-t6cm6872fk created
secret/revisor-token-hbc69k5m5c created
service/prueba created
service/revisor created
deployment.apps/prueba created
deployment.apps/revisor created
gateway.gateway.networking.k8s.io/revisor created
httproute.gateway.networking.k8s.io/revisor created
networkpolicy.networking.k8s.io/acceso-revisor-salida created
networkpolicy.networking.k8s.io/cerrado created
networkpolicy.networking.k8s.io/dns created
networkpolicy.networking.k8s.io/prueba-entrada created
networkpolicy.networking.k8s.io/revisor-entrada created
networkpolicy.networking.k8s.io/revisor-salida created
$ kubectl wait --for=condition=Programmed gateway/revisor -n revisor --timeout=240s
gateway.gateway.networking.k8s.io/revisor condition met
$ kubectl apply -n revisor -f depurar.yaml
pod/depurar created
El nombre del ConfigMap es el de la Lección 4, revisor-config-t6cm6872fk, porque la lista de servicios no cambió; el del Secret cambia en cada corrida, porque el token es nuevo. Los dos rollout status del bloque anterior no imprimen nada aquí porque el guion los corre en silencio; en tu terminal verás deployment "revisor" successfully rolled out y el equivalente de prueba. El Pod depurar es el de siempre, con una diferencia que ya conoces de la Lección 7: lleva la etiqueta acceso-revisor: permitido, y por eso la red cerrada lo deja hablar con el revisor.
$ kubectl get pods -n revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
depurar 1/1 Running 0 8s 10.244.2.5 curso-k8s-l8-worker <none> <none>
prueba-6899c74865-9dckl 1/1 Running 0 52s 10.244.1.4 curso-k8s-l8-worker2 <none> <none>
revisor-6b7f4d564d-g6wc5 1/1 Running 0 52s 10.244.2.4 curso-k8s-l8-worker <none> <none>
revisor-6b7f4d564d-rxlw6 1/1 Running 0 52s 10.244.2.3 curso-k8s-l8-worker <none> <none>
revisor-6b7f4d564d-wk56w 1/1 Running 0 52s 10.244.1.5 curso-k8s-l8-worker2 <none> <none>
$ kubectl get pvc,cronjob,job -n revisor
No resources found in revisor namespace.
$ curl -s -m 5 http://172.28.255.200/salud # desde fuera del clúster
{"estado":"vivo","version":"1.6.0"}
Tres réplicas del revisor, el servicio de prueba y depurar. Y la última línea de kubectl get: no hay volúmenes, ni tareas, ni horarios. Esa es la pregunta abierta de la lección. La última salida es una petición que viene desde un contenedor de Docker fuera del clúster, en la misma red que los nodos, y llega por el Gateway: la puerta de la Lección 6 funciona en el espacio de nombres de la Lección 7 con la red cerrada. Si en tu máquina no contesta, kubectl get gateway -n revisor dice qué IP recibió; en la corrida fue 172.28.255.200, y en tu red kind sería 172.18.255.200.
8.2 Qué se pierde: el emptyDir de hoy
8.2.1 Dónde está el archivo
Primero, que el revisor escriba su reporte. Cada petición a /api/estados arma el reporte y, antes de contestar, lo guarda en el archivo que dice la variable REVISOR_REPORTE de la imagen, /datos/reporte.json. La imagen no tiene shell, así que no puedes entrar a ver el archivo; pero el archivo existe en algún disco, y los nodos de kind son contenedores de Docker a los que sí puedes entrar. Se pide un reporte y se busca:
$ kubectl exec -n revisor depurar -- curl -s -H 'Authorization: Bearer …' 'http://revisor/api/estados?formato=tabla'
SERVICIO ESTADO TIEMPO DETALLE
prueba OK 6ms 200
$ kubectl get pods -n revisor -l app=revisor -o custom-columns=POD:.metadata.name,NODO:.spec.nodeName,UID:.metadata.uid
POD NODO UID
revisor-6b7f4d564d-g6wc5 curso-k8s-l8-worker 961791a4-d756-4382-a1de-ae62d7bc11b1
revisor-6b7f4d564d-rxlw6 curso-k8s-l8-worker 29162810-cd29-4693-89bf-ed789dd8f347
revisor-6b7f4d564d-wk56w curso-k8s-l8-worker2 510c7d49-6be3-4fac-947d-18783838b9e5
(En el primer comando, el token se ve como …: el guion lo oculta en lo que imprime, y tú lo pasas con $TOKEN, como en las lecciones anteriores.) El uid es el identificador único que el clúster le da a cada Pod, y es el nombre de la carpeta donde el kubelet del nodo guarda lo suyo. Los volúmenes de un Pod viven en /var/lib/kubelet/pods/<uid>/volumes/, y los de tipo emptyDir, en la subcarpeta kubernetes.io~empty-dir. Se mira la de cada una de las tres réplicas, en el nodo donde corre:
$ docker exec curso-k8s-l8-worker ls -lan /var/lib/kubelet/pods/961791a4-d756-4382-a1de-ae62d7bc11b1/volumes/kubernetes.io~empty-dir/datos
total 8
drwxrwsrwx 2 0 65532 4096 Oct 21 23:23 .
drwxr-xr-x 3 0 0 4096 Oct 21 23:23 ..
$ docker exec curso-k8s-l8-worker ls -lan /var/lib/kubelet/pods/29162810-cd29-4693-89bf-ed789dd8f347/volumes/kubernetes.io~empty-dir/datos
total 8
drwxrwsrwx 2 0 65532 4096 Oct 21 23:23 .
drwxr-xr-x 3 0 0 4096 Oct 21 23:23 ..
$ docker exec curso-k8s-l8-worker2 ls -lan /var/lib/kubelet/pods/510c7d49-6be3-4fac-947d-18783838b9e5/volumes/kubernetes.io~empty-dir/datos
total 12
drwxrwsrwx 2 0 65532 4096 Oct 21 23:24 .
drwxr-xr-x 3 0 0 4096 Oct 21 23:23 ..
-rw------- 1 65532 65532 92 Oct 21 23:24 reporte.json
Hay tres hallazgos en estas tres salidas, y los tres importan para lo que sigue.
El primero: solo una de las tres réplicas tiene el archivo. La petición a http://revisor/ la repartió el Service a una de las tres, y esa fue la que guardó el reporte. Las otras dos tienen su /datos vacío. Cada réplica tiene su propio emptyDir: nada se comparte entre Pods. Si le preguntaras a «el revisor» cuál fue su último reporte, la respuesta dependería de cuál réplica te contesta. Eso, por sí solo, ya descalifica al emptyDir como lugar para una historia.
El segundo: el dueño del directorio. La primera columna de ls -l es drwxrwsrwx: el directorio tiene permiso de lectura, escritura y entrada para todos, y una s en lugar de una x en el lugar del grupo, que es el setgid. El dueño es el usuario 0 (root) y el grupo, el 65532. Ese grupo no es casual: es el fsGroup: 65532 que la Lección 4 puso en el Deployment. Para no atribuirle a fsGroup más de lo que hace: el permiso abierto 777 es el valor por omisión de un emptyDir según la documentación de volúmenes; lo que pone fsGroup es el grupo 65532 y el bit setgid. 8.7 cuenta qué hizo fsGroup aquí y qué no hace en otros volúmenes.
El tercero: el archivo es del usuario del revisor. -rw------- 1 65532 65532 92: el dueño y el grupo son el 65532, el usuario con el que corre el revisor, y el permiso 0600 (solo el dueño) viene de cómo el revisor lo crea: escribe primero un archivo temporal en la misma carpeta y luego lo renombra, para que quien lo lea vea el reporte viejo o el nuevo, nunca uno a medias. Y 92 bytes: un reporte de un solo servicio.
Con root en el nodo se puede leer lo que el revisor guardó:
$ docker exec curso-k8s-l8-worker2 stat -c '%a %U:%g %n' /var/lib/kubelet/pods/510c7d49-6be3-4fac-947d-18783838b9e5/volumes/kubernetes.io~empty-dir/datos
2777 root:65532 /var/lib/kubelet/pods/510c7d49-6be3-4fac-947d-18783838b9e5/volumes/kubernetes.io~empty-dir/datos
$ docker exec curso-k8s-l8-worker2 cat /var/lib/kubelet/pods/510c7d49-6be3-4fac-947d-18783838b9e5/volumes/kubernetes.io~empty-dir/datos/reporte.json
[
{
"servicio": "prueba",
"ok": true,
"codigo": 200,
"tiempo_ms": 6
}
]
El stat traduce el permiso a octal: 2777 es el 777 de lectura y escritura para todos más el 2 del setgid. Es la misma información que la s, en otra forma. El contenido es el reporte en JSON: el nombre del servicio, si está bien, el código HTTP y el tiempo en milisegundos.
(Las salidas de esta sección asumen que sabes entrar a un nodo con docker exec; es lo mismo que hiciste en la Lección 4 con el Secret y en la Lección 5 con crictl. Funciona porque en kind los nodos son contenedores. En un clúster de verdad, la carpeta existe igual, pero el nodo es una máquina a la que entras con otro mecanismo y con otros permisos.)
8.2.2 Un contenedor que se cae: el emptyDir sobrevive
La documentación de volúmenes de Kubernetes (enlace en las fuentes) dice de emptyDir: el volumen se crea «cuando el Pod se asigna a un nodo»; empieza vacío; y «cuando un Pod se quita de un nodo por cualquier razón, los datos del emptyDir se borran permanentemente». Y agrega una nota que parece pequeña y es la mitad de la lección: «un contenedor que se cae no quita al Pod del nodo. Los datos de un volumen emptyDir están a salvo ante las caídas de contenedores».
Se comprueba. Con crictl, la herramienta de la Lección 5, se detiene el contenedor del revisor que tiene el archivo. El identificador del contenedor se busca por el uid del Pod, para no detener uno ajeno:
CID="$(docker exec curso-k8s-l8-worker2 crictl ps --label "io.kubernetes.pod.uid=510c7d49-6be3-4fac-947d-18783838b9e5" --name revisor -q)"
docker exec curso-k8s-l8-worker2 crictl stop "$CID"
$ docker exec curso-k8s-l8-worker2 crictl stop a208caab7a28e3a47c90a21f11ccd62ffd1a723a411b85e63427d8d5744f8090
a208caab7a28e3a47c90a21f11ccd62ffd1a723a411b85e63427d8d5744f8090
(esperando 10 s a que el kubelet reinicie el contenedor)
$ kubectl get pod -n revisor revisor-6b7f4d564d-wk56w
NAME READY STATUS RESTARTS AGE
revisor-6b7f4d564d-wk56w 1/1 Running 1 (10s ago) 65s
$ kubectl get pod -n revisor revisor-6b7f4d564d-wk56w -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason} {.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
Error 137
$ docker exec curso-k8s-l8-worker2 ls -lan /var/lib/kubelet/pods/510c7d49-6be3-4fac-947d-18783838b9e5/volumes/kubernetes.io~empty-dir/datos
total 12
drwxrwsrwx 2 0 65532 4096 Oct 21 23:24 .
drwxr-xr-x 3 0 0 4096 Oct 21 23:23 ..
-rw------- 1 65532 65532 92 Oct 21 23:24 reporte.json
Se lee en orden. RESTARTS 1 (10s ago): el kubelet vio morir al contenedor y lo reinició hace 10 segundos; el Pod sigue siendo el mismo (mismo nombre, mismo uid, AGE de 65 segundos). La última terminación fue un Error con el código 137, el mismo de la Lección 5: 128 más la señal 9, la que no se puede ignorar. Y la última salida es la que importa: el archivo está intacto, con la misma hora (23:24) y los mismos 92 bytes. El contenedor nuevo montó el mismo volumen y encontró el reporte donde lo dejó el viejo.
Esto tiene una consecuencia práctica que conviene decir clara: si tu aplicación se cae y se reinicia sola, lo que guardó en un emptyDir lo encuentra el contenedor nuevo. Ese es el uso que la documentación propone: espacio de trabajo, puntos de control de un cálculo largo para retomarlo tras una caída. No es un lugar para lo que debe sobrevivir al Pod.
8.2.3 Un Pod que se borra: el emptyDir se va con él
El segundo escalón. Se borra el Pod entero, no solo su contenedor, y se mira la carpeta del volumen:
ctx && kubectl delete pod -n revisor revisor-6b7f4d564d-wk56w
$ kubectl delete pod -n revisor revisor-6b7f4d564d-wk56w
pod "revisor-6b7f4d564d-wk56w" deleted from revisor namespace
$ docker exec curso-k8s-l8-worker2 ls /var/lib/kubelet/pods/510c7d49-6be3-4fac-947d-18783838b9e5/volumes/kubernetes.io~empty-dir/datos
ls: cannot access '/var/lib/kubelet/pods/510c7d49-6be3-4fac-947d-18783838b9e5/volumes/kubernetes.io~empty-dir/datos': No such file or directory
[código de salida: 2]
$ docker exec curso-k8s-l8-worker2 ls /var/lib/kubelet/pods/510c7d49-6be3-4fac-947d-18783838b9e5
containers
etc-hosts
plugins
volumes
$ kubectl get pods -n revisor -l app=revisor -o custom-columns=POD:.metadata.name,NODO:.spec.nodeName,UID:.metadata.uid,LISTO:.status.containerStatuses[0].ready
POD NODO UID LISTO
revisor-6b7f4d564d-4mnvh curso-k8s-l8-worker2 d61911b6-d777-4c4c-a159-e2a782fc69d8 true
revisor-6b7f4d564d-g6wc5 curso-k8s-l8-worker 961791a4-d756-4382-a1de-ae62d7bc11b1 true
revisor-6b7f4d564d-rxlw6 curso-k8s-l8-worker 29162810-cd29-4693-89bf-ed789dd8f347 true
La carpeta del volumen ya no existe: No such file or directory, con código de salida 2. El kubelet borró el volumen al retirar el Pod. La segunda salida es un matiz que conviene leer con cuidado: la carpeta del Pod todavía muestra cuatro entradas (containers, etc-hosts, plugins y volumes) unos segundos después del borrado. No investigué por qué (probablemente sea limpieza del kubelet que aún no terminaba); lo que importa para la lección ya pasó: la carpeta del volumen de datos no existe. Y el ReplicaSet repuso la réplica: 4mnvh es nueva (otro nombre, otro uid) y esta vez cayó en el mismo nodo, curso-k8s-l8-worker2; el planificador reparte, pero no promete un nodo distinto. El revisor nuevo arrancó con su /datos vacío.
Con esto, el viernes por la tarde de la escena queda explicado en dos mediciones. Un reinicio del contenedor se salva. El mantenimiento del sábado no reinició contenedores: desalojó Pods, y cada Pod nuevo nació con un emptyDir nuevo, en un nodo que quizá no era el mismo. (Un simple reinicio del nodo es otra cosa: la documentación del ciclo de vida del Pod distingue ese caso, en que el Pod no se desaloja y el kubelet vuelve a levantar sus contenedores, del desalojo o la pérdida del nodo. Los Pods se reemplazan cuando alguien los borra, el nodo se vacía o se declara perdido.) Para que un dato sobreviva al Pod necesitas un volumen que no dependa del Pod: eso es lo que sigue.
8.3 Volúmenes, solicitudes y clases
8.3.1 Tres objetos, tres papeles
Antes de usarlos, los tres objetos con un ejemplo de la vida diaria. Imagina un edificio de oficinas. Los espacios de oficina existen: alguien los construyó, tienen un tamaño y una ubicación. Una solicitud de renta dice «necesito una oficina de tantos metros, para trabajar una sola empresa»; no dice cuál. Y el tipo de oficina (con vista, sin vista, en el sótano) describe cómo se construyen y cuánto cuestan. En Kubernetes:
| En el edificio | En Kubernetes | Quién lo crea | Qué dice |
|---|---|---|---|
| el espacio de oficina | PersistentVolume (PV) |
el administrador del clúster, o el sistema, bajo demanda | «existe un almacenamiento de este tamaño, en este lugar, con estas reglas» |
| la solicitud de renta | PersistentVolumeClaim (PVC) |
quien despliega la aplicación | «necesito este tamaño, con este modo de acceso» |
| el tipo de oficina | StorageClass |
el administrador del clúster | «así se crea un volumen de esta clase» |
La documentación de volúmenes persistentes define los dos primeros con estas palabras: un PersistentVolume es «una pieza de almacenamiento en el clúster que ha sido aprovisionada por un administrador o aprovisionada dinámicamente usando clases de almacenamiento. Es un recurso del clúster, igual que un nodo». Y una PersistentVolumeClaim «es una solicitud de almacenamiento por parte de un usuario. Es similar a un Pod». La analogía con el Pod va más lejos de lo que parece: así como un Pod consume recursos de un nodo, una solicitud consume recursos de un volumen; y así como un Pod puede pedir CPU y memoria, una solicitud pide un tamaño y un modo de acceso.
Un detalle de nombres que confunde a quien llega: el PV no tiene espacio de nombres (es del clúster, como un nodo; en la salida de kubectl api-resources --namespaced=false aparece junto a nodes y namespaces), y la PVC sí (vive en revisor, junto a los Pods que la usan).
$ kubectl api-resources --api-group='' --namespaced=false -o name | grep -E '^(persistentvolumes|nodes|namespaces)$'
namespaces
nodes
persistentvolumes
8.3.2 Los dos caminos para tener un volumen
La misma página describe dos formas de que una solicitud encuentre su volumen:
- Estática. «Un administrador del clúster crea cierto número de PV.» Los PV ya existen, y una solicitud que coincide con uno (en tamaño, en modo de acceso, en clase) se une a él. Es lo que pasa con discos que alguien preparó a mano.
- Dinámica. «Cuando ninguno de los PV estáticos que creó el administrador coincide con la solicitud de un usuario, el clúster puede intentar aprovisionar dinámicamente un volumen para ella.» Nadie crea el PV: lo crea un programa del clúster, el aprovisionador, a partir de la clase que pidió la solicitud.
Y el trámite, que la documentación llama el ciclo de vida: primero el aprovisionamiento; luego la unión (binding): «un ciclo de control del plano de control vigila las PVC nuevas, encuentra un PV que coincida (si puede) y las une»; después el uso: «una vez que el usuario tiene una solicitud y está unida, el PV unido le pertenece mientras la necesite»; y al final la reclamación, cuando el usuario ya no la necesita. Anota una frase de esa página, porque la vas a ver cumplida: «las solicitudes se quedan sin unir indefinidamente si no existe un volumen que coincida». También la unión es de uno a uno: un PV con una solicitud, y una solicitud con un PV.
El curso usa los dos caminos, cada uno donde enseña algo. El dinámico es el camino normal y es el que usa el reporte de la lección. El estático lo usarás en 8.7 para fabricar un disco con las reglas de uno de verdad.
8.3.3 La clase de kind
Este clúster ya trae una clase. Se le pregunta:
$ kubectl get storageclass
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ALLOWVOLUMEEXPANSION AGE
standard (default) rancher.io/local-path Delete WaitForFirstConsumer false 4m31s
$ kubectl get storageclass standard -o jsonpath='{.provisioner} {.reclaimPolicy} {.volumeBindingMode} {.allowVolumeExpansion}{"\n"}'
rancher.io/local-path Delete WaitForFirstConsumer
$ kubectl get pods -n local-path-storage
NAME READY STATUS RESTARTS AGE
local-path-provisioner-75f7fc7dc5-xl4bd 1/1 Running 0 4m26s
$ kubectl get pv
No resources found
Una clase con cuatro datos que importan:
standard (default)es su nombre, y el paréntesis dice que es la clase por omisión: la documentación de clases dice que, cuando una solicitud no ponestorageClassName, «se usa la clase por omisión». El curso la nombra de todos modos en su manifiesto, porque un manifiesto que dice lo que usa se lee sin depender de una configuración del clúster.rancher.io/local-pathes el aprovisionador. En el espacio de nombreslocal-path-storagehay un Pod,local-path-provisioner, que vigila las solicitudes de esta clase y les crea el volumen. La documentación del proyecto lo describe así: «ofrece una forma de que los usuarios de Kubernetes utilicen el almacenamiento local de cada nodo», y el volumen que crea es, por omisión, una carpeta del nodo, de tipohostPath. Dos frases de ese mismo README, que usaremos más adelante, anótalas: no soporta límite de capacidad («la capacidad se ignora por ahora»), y la carpeta se crea con permiso0777.Deletees la política de reclamación de los volúmenes de esta clase: qué se hace con el volumen cuando se borra la solicitud que lo usaba. La documentación de volúmenes persistentes lo dice así: conDelete, el borrado quita tanto el objeto PV de Kubernetes como el almacenamiento asociado. La alternativa esRetain, que conserva el volumen para que un administrador lo recupere a mano. 8.8 lo mide con las dos.WaitForFirstConsumeres el modo de unión, y es el dato más importante para entender lo que sigue. La documentación de clases lo explica por contraste. Con el modoImmediate, que es el que se usa si no pones nada, «la unión de volúmenes y el aprovisionamiento dinámico ocurren en cuanto se crea la solicitud». Eso puede salir mal cuando el almacenamiento está atado a un lugar (un nodo, una zona), porque se crearía el volumen sin saber dónde va a correr el Pod. El remedio: el modoWaitForFirstConsumer«retrasará la unión y el aprovisionamiento de un PersistentVolume hasta que se cree un Pod que use la PersistentVolumeClaim». Primero se decide dónde va a correr el Pod; luego se crea el volumen en ese lugar.ALLOWVOLUMEEXPANSION false(y vacío en eljsonpath, porque el campo no está escrito en la clase): la clase no permite agrandar un volumen después de creado. No lo vas a necesitar aquí; si algún día un volumen se llena, esta columna decide si la salida es agrandarlo o crear otro más grande y mudar los datos.
Y el cierre de la salida: No resources found en kubectl get pv. No hay ningún volumen todavía. La clase existe, el aprovisionador espera, y los volúmenes se crean cuando alguien los pida y haya un Pod que los use.
8.4 La solicitud de volumen y su espera
8.4.1 El manifiesto
La lección agrega un archivo al proyecto, reporte.yaml, con tres objetos: la cuenta de servicio reporte, la solicitud reportes y el CronJob reporte. Los dos primeros se leen ahora; el tercero tiene su propia sección, la 8.6, porque necesita que antes sepas qué es un Job. El archivo completo es un programa: programas/08-datos-y-tareas/reporte.yaml, y la kustomization.yaml de la lección lo suma a su lista de recursos.
# reporte.yaml — Lección 8: el reporte guardado y el revisor como tarea programada.
# Cada 5 minutos, un Job le pide al revisor su reporte y lo guarda en un volumen
# persistente. El binario del revisor solo sirve HTTP (no tiene modo «una vez»),
# por eso la tarea es un curl contra su API; la lección (8.6) explica por qué.
apiVersion: v1
kind: ServiceAccount
metadata:
name: reporte
automountServiceAccountToken: false
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: reportes
spec:
accessModes: [ReadWriteOnce]
storageClassName: standard
resources:
requests:
storage: 100Mi
(La tercera pieza, el CronJob, está completa en 8.6; aquí se corta para leer primero la solicitud.) La cuenta de servicio es la costumbre de la Lección 7: una cuenta propia para la tarea, sin permisos y con automountServiceAccountToken: false, porque la tarea le habla al revisor, no a la API del clúster. La solicitud tiene cuatro líneas que importan:
accessModes: [ReadWriteOnce]es el modo de acceso. Se abrevia RWO y la documentación lo define así: «el volumen puede montarse como lectura y escritura por un solo nodo». Esa última palabra es la que se malinterpreta: es un nodo, no un Pod. La misma página aclara que RWO «todavía puede permitir que varios Pods accedan (lean o escriban) al volumen cuando los Pods corren en el mismo nodo». Retenlo, porque el Ejercicio 2 lo usa. Existen otros modos (ReadOnlyMany,ReadWriteMany,ReadWriteOncePod); con la clase de kind, el que aplica es éste: la tabla de la misma página marca que un volumenHostPathsoporta soloReadWriteOnce, y la carpeta vive en un nodo.storageClassName: standardnombra la clase. Escribir el nombre, aunque sea la clase por omisión, deja el manifiesto sin sorpresas en un clúster donde la clase por omisión sea otra.storage: 100Mies el tamaño pedido: 100 mebibytes, la misma unidad de memoria de la Lección 5. En aritmética pura, 100 Mi alcanzan para más de un millón de reportes de 92 bytes. En un sistema de archivos real cada archivo ocupa al menos un bloque (comprobé en la máquina de Docker de la corrida que un archivo de 92 bytes ocupa 8 bloques de 512, es decir 4096 bytes, con bloques de sistema de archivos de 4096), así que 100 Mi caben unos 25,600 archivos, y con 288 reportes al día (uno cada cinco minutos) eso son unos 89 días de historial antes de tener que purgar. Habrá algo que decir más adelante sobre qué tan en serio se toma un clúster ese número.- No hay
volumeName: la solicitud no nombra un volumen. Pide una clase y un tamaño, y el sistema resolverá el resto.
Antes de aplicar, la pregunta que la Lección 5 enseñó a hacer: qué cambiaría.
kubectl diff -k . | grep -E '^\+ ' | grep -E 'kind: (PersistentVolumeClaim|CronJob|ServiceAccount)|schedule|concurrencyPolicy|storageClassName|storage:'
$ kubectl diff -k . | grep -E '^\+ ' | grep -E 'kind: (PersistentVolumeClaim|CronJob|ServiceAccount)|schedule|concurrencyPolicy|storageClassName|storage:'
+ concurrencyPolicy: Forbid
+ schedulerName: default-scheduler
+ schedule: '*/5 * * * *'
+ storage: 100Mi
+ storageClassName: standard
[código de salida: 1]
El diff filtra solo lo que se agrega (líneas que empiezan con + ) y, de esas, las que nombran lo nuevo de la lección: la política de concurrencia, el horario, el tamaño y la clase. El guion imprime [código de salida: 1] porque corre con pipefail y conserva el 1 de kubectl diff (hay diferencias, como en la Lección 5); en tu terminal, la tubería devuelve el código de grep y verás 0. (El renglón de schedulerName lo agrega el clúster como valor por omisión; el grep lo atrapó por contener la palabra schedule.)
8.4.2 Aplicar, y leer el Pending
ctx && kubectl apply -k .
$ kubectl apply -k .
namespace/revisor unchanged
serviceaccount/observador unchanged
serviceaccount/prueba unchanged
serviceaccount/reporte created
serviceaccount/revisor unchanged
role.rbac.authorization.k8s.io/observador unchanged
rolebinding.rbac.authorization.k8s.io/observador unchanged
configmap/revisor-config-t6cm6872fk unchanged
secret/revisor-token-hbc69k5m5c unchanged
service/prueba unchanged
service/revisor unchanged
persistentvolumeclaim/reportes created
deployment.apps/prueba unchanged
deployment.apps/revisor unchanged
cronjob.batch/reporte created
gateway.gateway.networking.k8s.io/revisor configured
httproute.gateway.networking.k8s.io/revisor configured
networkpolicy.networking.k8s.io/acceso-revisor-salida unchanged
networkpolicy.networking.k8s.io/cerrado unchanged
networkpolicy.networking.k8s.io/dns unchanged
networkpolicy.networking.k8s.io/prueba-entrada unchanged
networkpolicy.networking.k8s.io/revisor-entrada unchanged
networkpolicy.networking.k8s.io/revisor-salida unchanged
Los Deployments no cambiaron (unchanged): ninguna réplica se reinició. Lo nuevo son tres objetos, serviceaccount/reporte, persistentvolumeclaim/reportes y cronjob.batch/reporte. (Que gateway y httproute digan configured aparece en este apply y no cambia nada que se vea en el servicio; la lección no lo investiga.) Y ahora, lo que importa:
$ kubectl get pvc,cronjob -n revisor
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
persistentvolumeclaim/reportes Pending standard <unset> 1s
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
cronjob.batch/reporte */5 * * * * <none> False 0 <none> 0s
$ kubectl describe pvc reportes -n revisor | sed -n '/^Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal WaitForFirstConsumer 1s persistentvolume-controller waiting for first consumer to be created before binding
$ kubectl get pv
No resources found
STATUS Pending, VOLUME vacío, CAPACITY vacía. A quien viene de las lecciones anteriores, Pending le suena a problema (el Pod de la Lección 5 que no cabía en ningún nodo). Aquí no lo es, y el evento lo dice con todas sus letras: waiting for first consumer to be created before binding, «esperando a que se cree el primer consumidor antes de unirse». Es el modo WaitForFirstConsumer de 8.3.3 en acción. Todavía no hay ningún Pod que use la solicitud, así que el clúster no ha decidido en qué nodo va a correr nada, y por tanto no puede decidir en qué nodo crear la carpeta. Y kubectl get pv lo confirma: sin volumen.
La diferencia con el Pending de la Lección 5 es la que separa un diagnóstico de otro. Aquí el evento es de nivel Normal, lo emite persistentvolume-controller y dice que espera; allá era un Warning del planificador que decía que no había dónde. La regla para leer cualquier Pending es la misma de siempre: los eventos dicen quién espera y a qué. Un Pending con WaitForFirstConsumer y ningún Pod usando la solicitud es un sistema funcionando.
El CronJob, mientras tanto, existe y no ha corrido: LAST SCHEDULE <none>, ACTIVE 0. Su primera ejecución llegará en el siguiente múltiplo de cinco minutos del reloj. Para no esperar, 8.5 enseña a pedir una a mano.
8.5 Job: la tarea que termina
8.5.1 El más pequeño que sirve
Un Job es, en las palabras de su documentación, una tarea que corre «hasta completarse y luego se detiene». Crea uno o más Pods y reintenta «hasta que un número especificado de ellos termina con éxito». Cuando se alcanza ese número, el Job está completo. Lo importante frente al Deployment es qué se considera éxito: un Pod de un Job que termina con código 0 es el resultado esperado; en un Deployment, ese mismo Pod sería un fallo, y el clúster lo reemplazaría.
Antes del Job de verdad, uno de práctica: fig08_01.job-salud.yaml, que una sola vez le pregunta a /salud del revisor y termina. Es un programa.
# fig08_01.job-salud.yaml — Lección 8: el Job más pequeño que sirve. Una vez, le pregunta
# a /salud del revisor y termina. Lleva la etiqueta acceso-revisor: permitido, la que la red
# cerrada de la Lección 7 exige para que un Pod hable con el revisor.
apiVersion: batch/v1
kind: Job
metadata:
name: salud
namespace: revisor
spec:
backoffLimit: 2
activeDeadlineSeconds: 120
template:
metadata:
labels:
app: salud
acceso-revisor: permitido
spec:
automountServiceAccountToken: false
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: salud
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command: ["curl", "-fsS", "-m", "5", "http://revisor/salud"]
resources:
requests: {cpu: 10m, memory: 16Mi}
limits: {cpu: 100m, memory: 32Mi}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
Léelo de arriba abajo; casi todo lo reconoces. apiVersion: batch/v1 y kind: Job: el grupo batch agrupa los Jobs y los CronJobs. El namespace: revisor va escrito en el archivo para que el Job caiga ahí sin que tengas que acordarte de -n. spec.template es la plantilla del Pod, igual que en un Deployment, pero con dos diferencias. La primera: no tiene selector, porque el Job lo asigna solo (la documentación advierte que «en casi todos los casos no deberías especificarlo»). La segunda: restartPolicy es obligatorio y solo admite dos valores, Never y OnFailure. Con Never, si el contenedor falla, el Pod queda como Failed y el Job crea otro; con OnFailure, el kubelet reinicia el contenedor dentro del mismo Pod. El curso usa Never: cada intento es un Pod aparte, con su propia bitácora y su propio código de salida, y puedes leer qué pasó en cada uno. Con OnFailure, los intentos se encimarían en un solo Pod.
Los dos campos del Job que no son del Pod:
backoffLimit: 2: cuántas veces reintentar antes de rendirse. La documentación dice que el valor por omisión es 6, y que los Pods fallidos se recrean «con una espera exponencial (10 s, 20 s, 40 s…) con tope de diez minutos». Aquí 2 significa: el intento original más dos reintentos, tres Pods como máximo.activeDeadlineSeconds: 120: un tope de tiempo para el Job completo, sin importar cuántos Pods cree. Cuando se cumple, el clúster termina los Pods que corren y el Job pasa aFailedcon la razónDeadlineExceeded. Y la documentación precisa que este plazo tiene precedencia sobrebackoffLimit: un Job que está reintentando no crea más Pods cuando llega el plazo, aunque no haya agotado sus intentos. Los dos topes cubren fallas distintas: uno es para «falla rápido muchas veces», el otro para «se queda colgado».
La tarea misma, curl -fsS -m 5 http://revisor/salud, se lee por letras. -f hace que curl salga con error cuando el servidor contesta 400 o más (sin esa letra, un 503 saldría con código 0, y el Job lo contaría como éxito). -sS es silencioso pero muestra los errores. -m 5 es el tiempo máximo de toda la operación: 5 segundos. Y el comando se escribe como lista (["curl", ...]) y no como un texto para un shell, porque la imagen de curl no necesita uno para esto.
ctx && kubectl apply -f fig08_01.job-salud.yaml
ctx && kubectl wait -n revisor --for=condition=Complete job/salud --timeout=180s
$ kubectl apply -f fig08_01.job-salud.yaml
job.batch/salud created
$ kubectl get job,pods -n revisor -l app=salud
NAME STATUS COMPLETIONS DURATION AGE
job.batch/salud Complete 1/1 4s 4s
NAME READY STATUS RESTARTS AGE
pod/salud-svx9s 0/1 Completed 0 4s
$ kubectl logs -n revisor job/salud
{"estado":"vivo","version":"1.6.0"}
$ kubectl get job -n revisor salud -o jsonpath='{range .status.conditions[*]}{.type} {.status}{"\n"}{end}'
SuccessCriteriaMet True
Complete True
El Job tardó 4 segundos y terminó con COMPLETIONS 1/1. El Pod quedó como Completed con READY 0/1: no es un error. El contenedor terminó bien y ya no corre, y el Pod se conserva para que puedas leer su bitácora. kubectl logs job/salud encuentra el Pod del Job y muestra lo que imprimió el curl: el JSON de /salud. Las condiciones del Job son dos: SuccessCriteriaMet (se cumplió el criterio de éxito) y Complete (terminó). Que haya dos y no una tiene que ver con algo que la documentación dice: desde la versión 1.31, el controlador de Jobs retrasa la condición terminal (Complete o Failed) hasta que todos los Pods del Job hayan terminado. SuccessCriteriaMet (y su gemela FailureTarget, que verás en «El error que vas a ver») son las condiciones que aparecen antes: dicen que el desenlace ya se decidió.
Se borra para dejar el espacio como estaba:
$ kubectl delete -f fig08_01.job-salud.yaml
job.batch "salud" deleted from revisor namespace
La documentación lo dice: borrar un Job limpia los Pods que creó. Eso importa en 8.8, donde el borrado de los Jobs se vuelve parte de la maniobra.
8.5.2 El reporte guardado: la primera tarea
Ahora el Job de la lección. No se escribe aparte: el CronJob reporte ya lo trae adentro, como plantilla, y kubectl sabe crear un Job a partir de él en cualquier momento, sin esperar el horario. Es la forma de probar una tarea programada sin quedarse mirando el reloj:
ctx && kubectl create job -n revisor --from=cronjob/reporte reporte-manual-1
$ kubectl create job -n revisor --from=cronjob/reporte reporte-manual-1
job.batch/reporte-manual-1 created
$ kubectl get pods -n revisor -l app=reporte -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
reporte-manual-1-t7d8l 0/1 Pending 0 3s <none> <none> curso-k8s-l8-worker <none>
$ kubectl describe pvc reportes -n revisor | sed -n '/^Used By:/,$p'
Used By: reporte-manual-1-t7d8l
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal WaitForFirstConsumer 7s (x2 over 9s) persistentvolume-controller waiting for first consumer to be created before binding
Normal ExternalProvisioning 3s (x2 over 3s) persistentvolume-controller Waiting for a volume to be created either by the external provisioner 'rancher.io/local-path' or manually by the system administrator. If volume creation is delayed, please verify that the provisioner is running and correctly registered.
Normal Provisioning 3s rancher.io/local-path_local-path-provisioner-75f7fc7dc5-xl4bd_3605f90f-a3ba-46c3-a75e-a794883e9732 External provisioner is provisioning volume for claim "revisor/reportes"
Aquí está, en tres segundos, la historia que 8.3.3 anunció. El Pod de la tarea existe y está Pending, pero fíjate en la columna NOMINATED NODE: el planificador ya nominó un nodo, curso-k8s-l8-worker, aunque el Pod todavía no está asignado (la columna NODE sigue en <none>). Con el modo WaitForFirstConsumer, el planificador decide primero dónde correrá el Pod; la documentación de clases lo dice así: el volumen se selecciona o aprovisiona «conforme a la topología que especifican las restricciones de planificación del Pod». Con esa decisión tomada, el aprovisionador de la clase puede actuar. Los eventos de la solicitud cuentan lo que pasó, de arriba hacia abajo (la columna Age baja: 7 s, 3 s, 3 s): la solicitud esperaba un consumidor (WaitForFirstConsumer); apareció el Pod y el controlador se lo dejó claro al aprovisionador (ExternalProvisioning, que quiere decir «un aprovisionador externo debe crear el volumen»); el aprovisionador rancher.io/local-path empezó (Provisioning). La captura se hizo a los 3 segundos y por eso termina ahí; el evento siguiente, ProvisioningSucceeded, con el nombre del volumen creado (aquí pvc-5fcf36b8-…), llega un instante después (en otra corrida del mismo guion alcanzó a salir en la captura). Por eso lo de «externo»: el aprovisionador es un programa aparte, un Pod del clúster, no parte del plano de control.
El renglón de arriba, Used By: reporte-manual-1-t7d8l, es una novedad de esta versión: dice qué Pod usa la solicitud. Junto a él, kubectl describe puede mostrar una tabla Conditions con la condición Unused, y aquí hay que ser exactos sobre lo que se midió: en esta captura la tabla no salió: el controlador de protección todavía no había escrito la condición (la captura se hizo a los 3 segundos). Fíjate que Used By: ya lista al Pod aunque esté Pending: para esta condición, un Pod cuenta como usuario mientras no haya terminado, incluso antes de que lo asignen a un nodo; en otra corrida del mismo guion, a los 3 segundos, sí salió una condición Unused False con la razón PodUsingPVC («un Pod está usando la solicitud»). Es decir, la condición aparece según el instante en que mires, y no puedes contar con verla. Cuidado con su alcance: la documentación define Unused solo con Pods que no han terminado (un Pod en fase Succeeded o Failed no cuenta), mientras que la protección de borrado de 8.8 cuenta cualquier objeto Pod que use la solicitud. Un Pod Completed no mantiene Unused en False, pero sí puede mantener la solicitud en Terminating. El registro de cambios de Kubernetes v1.37 lo explica: la función PersistentVolumeClaimUnusedSinceTime pasó a beta y quedó activada por omisión, y las solicitudes «reportan la condición Unused, que indica cuánto tiempo lleva una solicitud sin usarse, para ayudar a identificar candidatas a limpieza». Es una pista útil en un clúster viejo: las solicitudes con Unused True desde hace meses son candidatas a revisar (puede que aún tengan Pods terminados que las mantengan protegidas).
$ kubectl logs -n revisor job/reporte-manual-1
total 4
-rw-r--r-- 1 100 101 92 Oct 21 23:24 reporte-20261021T232446Z.json
$ kubectl get pvc -n revisor
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
reportes Bound pvc-5fcf36b8-f096-44c7-abdb-a86d1e815804 100Mi RWO standard <unset> 13s
$ kubectl get pv -o custom-columns=NOMBRE:.metadata.name,CLASE:.spec.storageClassName,NODO:.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0],RUTA:.spec.hostPath.path
NOMBRE CLASE NODO RUTA
pvc-5fcf36b8-f096-44c7-abdb-a86d1e815804 standard curso-k8s-l8-worker /var/local-path-provisioner/pvc-5fcf36b8-f096-44c7-abdb-a86d1e815804_revisor_reportes
La bitácora de la tarea es el ls -ln que ella misma imprime después de guardar: un archivo, reporte-20261021T232446Z.json, con la fecha en el nombre (año, mes, día, T, hora, minuto, segundo, Z de UTC). Esa es la respuesta a la mitad de la escena: no se pisa nada. Dueño 100, grupo 101: los del usuario de la tarea, el de la imagen de curl.
La solicitud ya es Bound (unida), con el volumen pvc-5fcf36b8-…, 100Mi, RWO, clase standard. Y el volumen, mirado con las columnas que importan, tiene dos datos que merecen explicación:
NODO curso-k8s-l8-worker: el volumen tiene una afinidad de nodo. Está atado a un solo nodo, el que eligió el planificador. Esto es lo que lo hace un volumen local: sus datos están en el disco de esa máquina, y cualquier Pod que lo use tiene que correr ahí. Si ese nodo se pierde, los datos se van con él.RUTA /var/local-path-provisioner/pvc-…_revisor_reportes: la carpeta real, en el nodo. El nombre sigue el patrón<volumen>_<espacio de nombres>_<solicitud>, y es el lugar donde se puede ir a mirar.
$ docker exec curso-k8s-l8-worker stat -c '%a %u:%g %n' /var/local-path-provisioner/pvc-5fcf36b8-f096-44c7-abdb-a86d1e815804_revisor_reportes
777 0:0 /var/local-path-provisioner/pvc-5fcf36b8-f096-44c7-abdb-a86d1e815804_revisor_reportes
$ docker exec curso-k8s-l8-worker ls -lan /var/local-path-provisioner/pvc-5fcf36b8-f096-44c7-abdb-a86d1e815804_revisor_reportes
total 12
drwxrwxrwx 2 0 0 4096 Oct 21 23:24 .
drwxr-xr-x 3 0 0 4096 Oct 21 23:24 ..
-rw-r--r-- 1 100 101 92 Oct 21 23:24 reporte-20261021T232446Z.json
El directorio tiene permiso 777 y dueño 0:0 (root). Compáralo con el del emptyDir de 8.2.1: allá era 2777 y el grupo 65532. Aquí no hay setgid ni grupo de la tarea. Aunque el Pod de la tarea declara fsGroup: 101, el directorio de este volumen no cambió de dueño. Es el contraste con el segundo hallazgo de 8.2.1, y 8.7 lo desarrolla: lo medido aquí es que fsGroup no hizo nada visible en este tipo de volumen. El permiso 777 viene del aprovisionador, que según su README crea la carpeta con mkdir -m 0777. Por eso la tarea pudo escribir: no por su grupo, sino porque la carpeta deja entrar a cualquiera.
8.5.3 El Pod terminó; el archivo sigue ahí
La prueba del volumen es que sobreviva al Pod que lo llenó. El Pod de la primera tarea ya terminó. Se lanza una segunda tarea, un Pod nuevo, con otro nombre, que no tiene nada que ver con el primero salvo que monta la misma solicitud:
ctx && kubectl create job -n revisor --from=cronjob/reporte reporte-manual-2
$ kubectl get pods -n revisor -l app=reporte
NAME READY STATUS RESTARTS AGE
reporte-manual-1-t7d8l 0/1 Completed 0 7s
$ kubectl create job -n revisor --from=cronjob/reporte reporte-manual-2
job.batch/reporte-manual-2 created
$ kubectl logs -n revisor job/reporte-manual-2
total 8
-rw-r--r-- 1 100 101 92 Oct 21 23:24 reporte-20261021T232446Z.json
-rw-r--r-- 1 100 101 92 Oct 21 23:24 reporte-20261021T232451Z.json
$ kubectl get jobs -n revisor
NAME STATUS COMPLETIONS DURATION AGE
reporte-manual-1 Complete 1/1 7s 15s
reporte-manual-2 Complete 1/1 5s 6s
El segundo Pod ve dos archivos: el suyo, de las 23:24:51, y el de la primera tarea, de las 23:24:46, de un Pod que ya no está corriendo. Eso es lo que no podía hacer el emptyDir. La solicitud, y el volumen que la respalda, tienen un ciclo de vida independiente del de los Pods. Mientras la solicitud exista y el disco del nodo que guarda la carpeta siga íntegro, los datos se conservan aunque los Pods que los escribieron hayan terminado.
Se confirma el contenido con el nodo. El primer comando es el reporte en formato tabla que ya conoces (solo confirma que el revisor responde igual que antes); el segundo lee los archivos que dejaron las tareas, que guardan lo que respondió /api/estados en cada momento:
$ kubectl exec -n revisor depurar -- curl -s -H 'Authorization: Bearer …' 'http://revisor/api/estados?formato=tabla'
SERVICIO ESTADO TIEMPO DETALLE
prueba OK 6ms 200
$ docker exec curso-k8s-l8-worker sh -c 'cat /var/local-path-provisioner/pvc-5fcf36b8-f096-44c7-abdb-a86d1e815804_revisor_reportes/reporte-*.json | head -9'
[
{
"servicio": "prueba",
"ok": true,
"codigo": 200,
"tiempo_ms": 5
}
]
[
(El head -9 corta la salida a nueve líneas: el primer archivo completo y el inicio del segundo.) La escena del lunes queda resuelta en su forma: el reporte del viernes a las 6 de la tarde, hora de la Ciudad de México, sería un archivo reporte-20261017T0000…Z.json (las 18:00 de México son las 00:00 UTC del sábado, y los segundos casi nunca son 00 porque el Pod arranca unos segundos después del horario), uno entre decenas, ninguno pisado por el siguiente.
Antes de seguir, dos preguntas que este diseño deja y que la lección contesta en su sitio. Primera: si el CronJob lo hace cada cinco minutos, ¿quién lo hace funcionar? Es la 8.6. Segunda: si la tarea guarda un archivo y el revisor también guarda reporte.json en su emptyDir, ¿por qué no guarda el revisor directamente en el volumen? La decisión del curso es que no, y el Ejercicio 2 te deja comprobar por qué.
8.6 CronJob: la tarea con horario
8.6.1 El objeto, campo por campo
Un CronJob crea Jobs en un horario repetido. La documentación lo compara con algo que quizá ya conoces: «un objeto CronJob es como una línea de un archivo crontab (tabla de cron) de un sistema Unix. Ejecuta un Job periódicamente en un horario dado, escrito en formato Cron». Lo que sigue es la tercera pieza de reporte.yaml, la que se cortó en 8.4.1:
apiVersion: batch/v1
kind: CronJob
metadata:
name: reporte
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 60
template:
metadata:
labels:
app: reporte
# la red (red.yaml) deja a estos Pods hablar con el revisor:8080
acceso-revisor: permitido
spec:
serviceAccountName: reporte
automountServiceAccountToken: false
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 101
fsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: reporte
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command:
- sh
- -c
- >-
curl -fsS -m 20
-H "Authorization: Bearer $(cat /secretos/token)"
-o /datos/reporte-$(date -u +%Y%m%dT%H%M%SZ).json
http://revisor/api/estados
&& ls -ln /datos
resources:
requests: {cpu: 10m, memory: 16Mi}
limits: {cpu: 100m, memory: 32Mi}
volumeMounts:
- name: datos
mountPath: /datos
- name: token
mountPath: /secretos
readOnly: true
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: datos
persistentVolumeClaim:
claimName: reportes
- name: token
secret:
secretName: revisor-token
defaultMode: 0440
Esto es lo nuevo, de arriba abajo:
schedule: "*/5 * * * *" es el horario, en el formato de cinco campos de cron. Según la documentación, de izquierda a derecha son: minuto (0 a 59), hora (0 a 23), día del mes (1 a 31), mes (1 a 12) y día de la semana (0 a 6, de domingo a sábado). Un asterisco quiere decir «cualquiera», y */5 quiere decir «cada cinco»: */5 * * * * es «cada cinco minutos, a cualquier hora, cualquier día». Los ejemplos de la documentación ayudan a leer otros: 0 3 * * 1 es «los lunes a las 3 de la mañana». Y las abreviaturas @hourly, @daily, @weekly, @monthly y @yearly existen como atajos. El Ejercicio 4 te pide escribir dos horarios.
¿En qué zona horaria? La documentación lo dice claro: para un CronJob sin zona horaria, el kube-controller-manager interpreta el horario en su zona local, que en kind es UTC. La columna TIMEZONE de la salida decía <none>, que es justo eso. Se puede fijar con .spec.timeZone (por ejemplo "America/Mexico_City"); poner TZ= o CRON_TZ= dentro del horario no se admite y el clúster lo rechaza con un error de validación, según la documentación. En un clúster real, el curso recomienda escribir la zona; es la causa de más de un «¿por qué corrió a las 4 de la madrugada?».
concurrencyPolicy: Forbid: qué hacer si llega la hora de una ejecución nueva y la anterior sigue corriendo. Los tres valores, según la documentación: Allow (el valor por omisión) deja correr las dos a la vez; Forbid no permite ejecuciones concurrentes y salta la nueva; Replace reemplaza la que corre por la nueva. Para la tarea del reporte, Forbid es la elección prudente: dos tareas escribiendo en el mismo volumen a la vez sería más complicado de lo necesario. El Ejercicio 3 te deja ver trabajar a los tres.
successfulJobsHistoryLimit: 3 y failedJobsHistoryLimit: 3: cuántos Jobs terminados conservar. Los valores por omisión son 3 y 1, y un valor de cero no conserva ninguno. Se conservan porque son la evidencia: sin ellos, un Job fallido desaparecería con su bitácora y nadie sabría por qué. Se acotan porque sin tope se acumularían (un Job cada 5 minutos son 288 al día).
jobTemplate es un Job completo, el que el CronJob crea cada vez. Adentro, backoffLimit: 2 y activeDeadlineSeconds: 60 son los de 8.5.1, con un plazo de un minuto: si la tarea se cuelga, el clúster la corta antes de que se encime con la siguiente.
La plantilla del Pod ya es familiar: la cuenta reporte, sin credenciales montadas; el securityContext de la Lección 7 (sin root, usuario 100 y grupo 101 de la imagen de curl); los recursos de la Lección 5. La etiqueta acceso-revisor: permitido es la que abre la red: sin ella el Pod no llega al revisor, y la sección «El error que vas a ver» lo demuestra. El volumen datos es la solicitud reportes. Y el volumen token monta el Secret de la Lección 4, como archivo, con defaultMode: 0440.
Una nota sobre la ruta /secretos/token: el archivo se llama como la llave del Secret, y la Lección 4 la generó con token=token.txt en Kustomize. Si algún día armas el Secret a mano con kubectl create secret generic revisor-token --from-file=token.txt, la llave será token.txt y el archivo /secretos/token.txt; para que coincida, usa --from-file=token=token.txt.
Una cosa que merece detenerse: el secretName del archivo dice revisor-token, sin el hash, igual que en el Deployment del revisor. Kustomize lo reescribe al armar el resultado, también dentro de un CronJob. Se comprueba:
$ kubectl get cronjob reporte -n revisor -o jsonpath='{.spec.schedule}{" "}{.spec.concurrencyPolicy}{" "}{.spec.successfulJobsHistoryLimit}{" "}{.spec.failedJobsHistoryLimit}{" "}{.spec.timeZone}{"\n"}'
*/5 * * * * Forbid 3 3
$ kubectl get cronjob reporte -n revisor -o jsonpath='{.spec.jobTemplate.spec.template.spec.volumes[1].secret.secretName}{"\n"}'
revisor-token-hbc69k5m5c
$ kubectl get secret -n revisor -o name
secret/revisor-token-hbc69k5m5c
El campo timeZone salió vacío (el horario va en UTC). El nombre del Secret dentro de la plantilla del CronJob es el generado, con su hash: Kustomize encontró la referencia y la cambió, como hizo con el Deployment. Esto importa para los programas de la lección: los ejemplos fig08_NN que no pasan por Kustomize (los que se aplican con kubectl apply -f) no pueden referirse al Secret por su nombre base, y por eso ninguno lo monta.
La tarea misma es un sh -c con dos comandos unidos por &&: el curl (con cuatro opciones y una dirección) y ls -ln /datos. Se lee opción por opción, de izquierda a derecha: curl -fsS -m 20 (como en el Job de práctica, pero con 20 segundos, porque armar un reporte consulta servicios y puede tardar más); -H "Authorization: Bearer $(cat /secretos/token)" (el encabezado con el token, que se lee del archivo montado en el momento de correr y nunca aparece escrito en el manifiesto); -o /datos/reporte-$(date -u +%Y%m%dT%H%M%SZ).json (guarda la respuesta en un archivo cuyo nombre lleva la hora UTC de ese instante, con date -u); y http://revisor/api/estados, la dirección del Service. Si el curl falla, el && impide que siga y la tarea sale con el código de curl: el Job lo cuenta como fallo. Si funciona, ls -ln /datos imprime el listado, que es lo que viste en la bitácora de las tareas manuales.
¿Por qué un curl contra la API y no una tarea que ejecute al propio revisor una vez y termine? Porque el revisor es un servidor HTTP y no trae un modo «revisa una vez». Es una decisión del curso, no un truco: se prefiere aprovechar el programa tal como lo entrega el curso de Docker, sin cambiarlo. La tarea es un cliente del revisor como cualquier otro.
8.6.2 Ver pasar el horario
El horario de verdad es de cinco minutos. Para verlo funcionar sin esperarlo, fig08_03.cron-cada-minuto.yaml es un CronJob que corre cada minuto (* * * * *) y le pregunta a /salud, que no pide token. Es un programa; su plantilla es la del Job de práctica y lo nuevo es el encabezado.
apiVersion: batch/v1
kind: CronJob
metadata:
name: salud-cada-minuto
namespace: revisor
spec:
schedule: "* * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 1
jobTemplate:
spec:
backoffLimit: 0
activeDeadlineSeconds: 30
template:
# … la plantilla del Job de práctica (etiqueta acceso-revisor incluida), con una
# diferencia: el comando imprime primero la hora, para poder leerla en la bitácora:
# command: ["sh", "-c", "date -u +%T; curl -fsS -m 5 http://revisor/salud"]
(El archivo completo, con la plantilla, está en la carpeta de programas.) Lo que cambia respecto a reporte: el horario, un historial más corto y backoffLimit: 0, que quiere decir «sin reintentos»: una tarea que corre cada minuto no necesita reintentarse, porque el minuto siguiente es el reintento. La espera está a la vista: el guion mira cada 10 segundos cuántos Jobs ha terminado el CronJob y se detiene cuando son dos.
ctx && kubectl apply -f fig08_03.cron-cada-minuto.yaml
$ date -u +%H:%M:%S
23:24:56
$ kubectl get cronjob reporte -n revisor
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
reporte */5 * * * * <none> False 0 <none> 20s
$ kubectl apply -f fig08_03.cron-cada-minuto.yaml
cronjob.batch/salud-cada-minuto created
(esperando hasta 200 s a que el CronJob cree y termine 2 Job(s) «salud-cada-minuto…»; se mira cada 10 s)
+ 0 s: 0 Job(s) terminados
+ 10 s: 1 Job(s) terminados
+ 20 s: 1 Job(s) terminados
+ 30 s: 1 Job(s) terminados
+ 40 s: 1 Job(s) terminados
+ 50 s: 1 Job(s) terminados
+ 60 s: 1 Job(s) terminados
+ 70 s: 2 Job(s) terminados
A los 10 segundos de crearlo ya había un Job terminado: el CronJob no esperó un minuto completo, sino el siguiente minuto del reloj (:25:00), que llegó 4 segundos después de las 23:24:56. Un horario cron no cuenta «cada 60 segundos desde que me creaste»: dispara en los instantes que su fórmula marca en el reloj. Y a los 70 segundos, el segundo (el guion mira cada 10 segundos, y el minuto siguiente llegó a los 64).
$ kubectl get cronjob,jobs -n revisor
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
cronjob.batch/reporte */5 * * * * <none> False 0 67s 91s
cronjob.batch/salud-cada-minuto * * * * * <none> False 0 7s 71s
NAME STATUS COMPLETIONS DURATION AGE
job.batch/reporte-29877085 Complete 1/1 4s 67s
job.batch/reporte-manual-1 Complete 1/1 7s 86s
job.batch/reporte-manual-2 Complete 1/1 5s 77s
job.batch/salud-cada-minuto-29877085 Complete 1/1 4s 67s
job.batch/salud-cada-minuto-29877086 Complete 1/1 3s 7s
Hay mucho que leer aquí, y todo es evidencia de que el horario funciona:
cronjob.batch/reportetambién corrió, solo.LAST SCHEDULE 67sy un Job nuevo,reporte-29877085, que nadie creó: lo creó el CronJob a las23:25:00, el múltiplo de cinco minutos más cercano, mientras la lección esperaba otra cosa. La tarea real del proyecto ya está trabajando.- Los nombres de los Jobs.
reporte-manual-1lo nombraste tú.reporte-29877085lo nombró el CronJob: su nombre, un guion y un número. El número son los minutos transcurridos desde el 1 de enero de 1970 (UTC), la época de Unix:29877085 × 60segundos es2026-10-21T23:25:00Z, y29877086, el minuto siguiente. Es un detalle observado, no documentado, que sirve para leer una lista de Jobs de un vistazo: dos números consecutivos son dos minutos seguidos. Y de aquí sale una restricción que la documentación sí dice: el nombre de un CronJob no debe pasar de 52 caracteres, porque, según la documentación, el controlador le agrega 11 caracteres y el nombre de un Job no puede pasar de 63 (en la corrida se agregaron 9: un guion y 8 dígitos; la documentación reserva más por prudencia). - El CronJob de cada minuto tiene dos Jobs,
…085y…086, de minutos consecutivos, yLAST SCHEDULE 7s: el último fue hace 7 segundos.
$ kubectl logs -n revisor -l app=salud --prefix --tail=3
[pod/salud-cada-minuto-29877085-lbt8h/salud] 23:25:01
[pod/salud-cada-minuto-29877085-lbt8h/salud] {"estado":"vivo","version":"1.6.0"}
[pod/salud-cada-minuto-29877086-j7h2l/salud] 23:26:01
[pod/salud-cada-minuto-29877086-j7h2l/salud] {"estado":"vivo","version":"1.6.0"}
$ kubectl get job -n revisor -l app=salud -o custom-columns=NOMBRE:.metadata.name,PROGRAMADA:.metadata.annotations.batch\\.kubernetes\\.io/cronjob-scheduled-timestamp
NOMBRE PROGRAMADA
salud-cada-minuto-29877085 2026-10-21T23:25:00Z
salud-cada-minuto-29877086 2026-10-21T23:26:00Z
Mira las horas de las bitácoras: 23:25:01 y 23:26:01; salen de ese date -u +%T que el comando de fig08_03 imprime antes del curl. El Job estaba programado para las :00, pero el Pod empezó a correr 1 segundo después en los dos casos (en otra corrida del mismo guion fueron 5 y 9: el trecho cambia con la carga). Entre el instante del horario y el instante de la ejecución hay un trecho: el CronJob crea el Job, el Job crea el Pod, el planificador lo asigna y el kubelet arranca el contenedor. La anotación batch.kubernetes.io/cronjob-scheduled-timestamp, que el CronJob escribe en cada Job desde la versión 1.32, guarda la hora original del horario, en formato RFC 3339 (la forma 2026-10-21T23:25:00Z que ves en la columna PROGRAMADA): sirve para saber qué ejecución fue cuál aunque se hayan retrasado. La documentación lo dice con una advertencia que conviene leer entera: un CronJob crea un Job «aproximadamente una vez por cada instante de su horario», y es aproximado porque «hay circunstancias en las que se podrían crear dos Jobs, o ninguno. Kubernetes intenta evitar esas situaciones, pero no las evita por completo. Por lo tanto, los Jobs que definas deben ser idempotentes».
Idempotente, en sentido estricto, quiere decir: correrlo dos veces deja el mundo igual que correrlo una. La tarea del reporte no es estrictamente idempotente: si por un tropiezo corriera dos veces en el mismo minuto, dejaría dos archivos y no uno. Lo que sí es: segura ante repeticiones. Como la hora del nombre lleva segundos, dos ejecuciones escriben dos archivos distintos y ninguno pisa al otro (si dos Pods corrieran en el mismo segundo exacto, el segundo reemplazaría al primero con un reporte equivalente, sin corromper nada). Una tarea que sumara un contador o enviara un correo no sería segura así, y habría que protegerla de otra forma, por ejemplo con una clave única por horario. Esa es la razón de fondo de que los nombres lleven la hora y no un número consecutivo.
Se borra el CronJob de práctica, y con él sus Jobs y Pods:
$ kubectl delete -f fig08_03.cron-cada-minuto.yaml
cronjob.batch "salud-cada-minuto" deleted from revisor namespace
$ kubectl get jobs,pods -n revisor -l app=salud
No resources found in revisor namespace.
Dos cosas más que la documentación del CronJob enseña, aunque esta lección no las corre. La primera: .spec.suspend: true suspende las ejecuciones siguientes sin borrar nada; las que caen mientras está suspendido cuentan como perdidas. La segunda: startingDeadlineSeconds dice cuántos segundos después de su hora se puede todavía crear el Job, y si se pasa, esa ejecución se salta. Sin este campo, un controlador caído un par de horas puede dejar muchas ejecuciones pendientes; la documentación explica que, si hay más de 100 perdidas desde la última, el CronJob se niega a iniciar y registra un error. En producción, con tareas importantes, conviene fijarlo.
8.7 El dueño de los archivos: fsGroup
8.7.1 Lo que fsGroup cambió, y lo que no
Desde la Lección 4, el Deployment del revisor lleva fsGroup: 65532 y el CronJob, fsGroup: 101. Es momento de dar la historia completa. La guía del contexto de seguridad lo explica con un ejemplo. Un Pod con fsGroup: 2000 hace que «todos los procesos del contenedor sean también parte del grupo suplementario 2000», y «el dueño del volumen /data/demo y de cualquier archivo creado en ese volumen será el grupo 2000». Su salida de ejemplo es el directorio con grupo 2000 y permisos drwxrwsrwx, el mismo patrón que ya viste. Y en la sección de discusión: fsGroup aplica a los volúmenes «que soportan administración de dueños», que se modifican para que sean del grupo que indica fsGroup y escribibles por él.
Esas dos palabras, que soportan, son las que se miden aquí. Los tres volúmenes de la lección, uno al lado del otro. El último comando necesita el nombre del nodo y el uid de una réplica de tu clúster, que son otros; se obtienen así:
REV_POD="$(kubectl get pods -n revisor -l app=revisor -o jsonpath='{.items[0].metadata.name}')"
REV_NODE="$(kubectl get pod -n revisor "$REV_POD" -o jsonpath='{.spec.nodeName}')"
REV_UID="$(kubectl get pod -n revisor "$REV_POD" -o jsonpath='{.metadata.uid}')"
docker exec "$REV_NODE" stat -c '%a %u:%g %n' "/var/lib/kubelet/pods/$REV_UID/volumes/kubernetes.io~empty-dir/datos"
La salida de la corrida, con el nodo y el uid de esa réplica:
$ kubectl get pod -n revisor -l app=revisor -o jsonpath='{.items[0].spec.securityContext.fsGroup}{"\n"}'
65532
$ kubectl get cronjob reporte -n revisor -o jsonpath='{.spec.jobTemplate.spec.template.spec.securityContext}{"\n"}'
{"fsGroup":101,"runAsGroup":101,"runAsNonRoot":true,"runAsUser":100,"seccompProfile":{"type":"RuntimeDefault"}}
$ docker exec curso-k8s-l8-worker2 stat -c '%a %u:%g %n' /var/lib/kubelet/pods/d61911b6-d777-4c4c-a159-e2a782fc69d8/volumes/kubernetes.io~empty-dir/datos
2777 0:65532 /var/lib/kubelet/pods/d61911b6-d777-4c4c-a159-e2a782fc69d8/volumes/kubernetes.io~empty-dir/datos
Ahí están los dos fsGroup declarados (65532 en el revisor, 101 en la tarea). Y el stat del emptyDir de un revisor actual: 2777 y grupo 65532: en el emptyDir, fsGroup sí cambió el grupo y puso el setgid. En cambio, el volumen de la solicitud reportes (8.5.2) tenía 777 0:0 aunque su Pod declaraba fsGroup: 101. La razón, como lo contó el README del aprovisionador y lo confirma la medición: el aprovisionador crea una carpeta común del nodo con permiso 0777, y la medición muestra que el kubelet no le cambió el grupo (la documentación solo dice que fsGroup actúa sobre los volúmenes «que soportan administración de dueños» y no da la lista; lo que medimos es que en este volumen no lo hizo, y que en el emptyDir y en el volumen local de 8.7.2 sí. El PV que crea este aprovisionador es de tipo hostPath: la columna RUTA de 8.5.2 sale de spec.hostPath.path). Tampoco hizo falta: con 777, cualquier usuario escribe.
Esto no es un descuido del curso: es una diferencia entre la clase standard de kind y un disco que nace con dueño root y permiso 755, como el que 8.7.2 fabrica a mano. Ahí, un contenedor que corre como usuario 100 recibe Permission denied al intentar escribir, a menos que fsGroup le dé el grupo del directorio. Con la clase standard de kind es poco probable que veas ese error, y ese es justo el peligro: un manifiesto que funciona porque la carpeta es 777 puede fallar en otro clúster por la misma razón por la que fsGroup existe. Un manifiesto que ya lleva fsGroup está más cerca de funcionar con un disco así. Por eso el curso lo escribe aunque aquí no se note.
8.7.2 Un disco como los de verdad
Para ver fsGroup trabajando hace falta un volumen que sí lo soporte. Kubernetes tiene uno a mano: el tipo local, una carpeta de un nodo presentada como un disco (la documentación lo describe como «un dispositivo de almacenamiento local montado: un disco, una partición o un directorio»). A diferencia del aprovisionador de kind, aquí tú eres el administrador: creas la carpeta en el nodo, creas el PV que la describe, y creas la solicitud que lo reclama. Es el camino estático de 8.3.2. Se usa como ejemplo fig08_04.disco-local.yaml, un programa con dos objetos:
# fig08_04.disco-local.yaml — Lección 8: un volumen creado a mano por «el administrador»,
# como los de un clúster real. Un PersistentVolume de tipo local (una carpeta de UN nodo,
# que hay que crear antes: mkdir -p /mnt/disco-reportes dentro del nodo curso-k8s-l8-worker)
# y la solicitud (PersistentVolumeClaim) que lo reclama. Su clase «manual» no existe como
# objeto: sirve solo de etiqueta para que la solicitud encuentre este volumen y no pida uno
# nuevo a la clase por omisión.
apiVersion: v1
kind: PersistentVolume
metadata:
name: disco-reportes
spec:
capacity:
storage: 100Mi
accessModes: [ReadWriteOnce]
persistentVolumeReclaimPolicy: Retain
storageClassName: manual
local:
path: /mnt/disco-reportes
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values: [curso-k8s-l8-worker]
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: disco
namespace: revisor
spec:
accessModes: [ReadWriteOnce]
storageClassName: manual
resources:
requests:
storage: 100Mi
Los campos nuevos del PV: capacity es el tamaño que ofrece; persistentVolumeReclaimPolicy: Retain es la política de reclamación que 8.8 mide; local.path es la carpeta del nodo; y nodeAffinity es obligatoria en un volumen local: la documentación dice «debes poner un nodeAffinity en el PersistentVolume cuando uses volúmenes local. El planificador de Kubernetes usa esa afinidad para programar los Pods en el nodo correcto». Aquí dice que el volumen está en el nodo cuyo nombre es curso-k8s-l8-worker. La clase manual no existe como objeto; es solo una palabra que une los dos objetos entre sí y evita que la solicitud caiga en la clase por omisión. Que la solicitud no falle por una clase inexistente (como pasa en el Ejercicio 1) tiene una explicación: el aprovisionamiento dinámico solo se intenta cuando ningún volumen que ya existe coincide con la solicitud, y aquí el PV existía.
La carpeta del nodo se prepara primero, con permisos de root y modo 755, como la dejaría un disco recién formateado, y después se aplica el archivo:
docker exec curso-k8s-l8-worker mkdir -p /mnt/disco-reportes
docker exec curso-k8s-l8-worker stat -c '%a %u:%g %n' /mnt/disco-reportes
ctx && kubectl apply -f fig08_04.disco-local.yaml
$ docker exec curso-k8s-l8-worker mkdir -p /mnt/disco-reportes
$ docker exec curso-k8s-l8-worker stat -c '%a %u:%g %n' /mnt/disco-reportes
755 0:0 /mnt/disco-reportes
$ kubectl apply -f fig08_04.disco-local.yaml
persistentvolume/disco-reportes created
persistentvolumeclaim/disco created
$ kubectl get pv,pvc -n revisor
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
persistentvolume/disco-reportes 100Mi RWO Retain Bound revisor/disco manual <unset> 0s
persistentvolume/pvc-5fcf36b8-f096-44c7-abdb-a86d1e815804 100Mi RWO Delete Bound revisor/reportes standard <unset> 85s
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
persistentvolumeclaim/disco Bound disco-reportes 100Mi RWO manual <unset> 0s
persistentvolumeclaim/reportes Bound pvc-5fcf36b8-f096-44c7-abdb-a86d1e815804 100Mi RWO standard <unset> 94s
El stat del nodo dice 755 0:0: de root, y solo root puede escribir. El PV disco-reportes nace Bound (unido) en el mismo segundo en que existe, con política Retain, y la solicitud disco también sale Bound en esta captura. Ojo: el instante importa. En otras dos corridas del mismo guion la captura cayó antes de que la unión terminara: una vez con la solicitud todavía Pending y el volumen ya Bound, otra con el volumen todavía Available. La unión tarda una fracción de segundo; si en tu terminal ves Pending, repite el get y se completa. No hubo espera de primer consumidor: la clase manual no es una clase con modo de unión retardado, y el volumen ya existía. Más abajo, el volumen pvc-5fcf36b8-… es el de reportes, con la política Delete de su clase. En un clúster real no dejarías la clase como etiqueta suelta: la documentación pide crear una StorageClass para los volúmenes local con volumeBindingMode: WaitForFirstConsumer, para que la solicitud no se una a un disco de un nodo antes de saber dónde va a correr el Pod. Aquí se omite porque hay un solo PV y su afinidad decide el nodo.
Ahora, dos Pods que hacen lo mismo y solo se diferencian en una línea. Escriben un archivo en /datos, corriendo como el usuario 100 y el grupo 101, sin root. El primero, fig08_05.escritor-sin-fsgroup.yaml, no declara fsGroup:
# fig08_05.escritor-sin-fsgroup.yaml — Lección 8: un Pod que corre sin root (usuario 100,
# grupo 101) e intenta escribir en el disco local de fig08_04, cuyo directorio es de root
# con permiso 0755. Sin fsGroup, nadie le cambia el dueño al directorio: Permission denied.
apiVersion: v1
kind: Pod
metadata:
name: sin-fsgroup
namespace: revisor
labels:
app: escritor
spec:
restartPolicy: Never
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 100
runAsGroup: 101
seccompProfile:
type: RuntimeDefault
containers:
- name: escritor
image: curlimages/curl:8.22.0@sha256:58adaa4e8dca9c988bae2aba4ab3434a0bb2da16bbe3f92dec39ec7785166777
command: ["sh", "-c", "id; ls -lnd /datos; echo hola > /datos/a.txt && ls -ln /datos"]
resources:
requests: {cpu: 10m, memory: 16Mi}
limits: {cpu: 100m, memory: 32Mi}
volumeMounts:
- name: datos
mountPath: /datos
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: datos
persistentVolumeClaim:
claimName: disco
El segundo, fig08_06.escritor-con-fsgroup.yaml, es el mismo archivo con name: con-fsgroup y una línea más en el securityContext del Pod: fsGroup: 101. El comando de ambos es corto: id imprime quién es el proceso; ls -lnd /datos imprime el dueño y permiso del directorio montado; y echo hola > /datos/a.txt && ls -ln /datos intenta escribir y, si pudo, lista.
ctx && kubectl apply -f fig08_05.escritor-sin-fsgroup.yaml
$ kubectl apply -f fig08_05.escritor-sin-fsgroup.yaml
pod/sin-fsgroup created
$ kubectl get pod -n revisor sin-fsgroup
NAME READY STATUS RESTARTS AGE
sin-fsgroup 0/1 Error 0 4s
$ kubectl logs -n revisor sin-fsgroup
uid=100(curl_user) gid=101(curl_group) groups=101(curl_group)
drwxr-xr-x 2 0 0 4096 Oct 21 23:26 /datos
sh: can't create /datos/a.txt: Permission denied
Ése es el error que fsGroup evita, medido. Se lee en tres renglones: el proceso es el usuario 100, con el grupo 101 y ningún otro; el directorio es de 0:0 con drwxr-xr-x (solo root escribe: el usuario 100 cae en la categoría «otros», que tiene lectura y entrada, pero no escritura); y el sh lo dice sin rodeos, can't create /datos/a.txt: Permission denied. El Pod quedó en Error: el comando falló, y con restartPolicy: Never no se reinicia. Ahora el mismo Pod con la línea más:
ctx && kubectl delete -f fig08_05.escritor-sin-fsgroup.yaml
ctx && kubectl apply -f fig08_06.escritor-con-fsgroup.yaml
$ kubectl delete -f fig08_05.escritor-sin-fsgroup.yaml
pod "sin-fsgroup" deleted from revisor namespace
$ kubectl apply -f fig08_06.escritor-con-fsgroup.yaml
pod/con-fsgroup created
$ kubectl get pod -n revisor con-fsgroup
NAME READY STATUS RESTARTS AGE
con-fsgroup 0/1 Completed 0 3s
$ kubectl logs -n revisor con-fsgroup
uid=100(curl_user) gid=101(curl_group) groups=101(curl_group)
drwxrwsr-x 2 0 101 4096 Oct 21 23:26 /datos
total 4
-rw-r--r-- 1 100 101 5 Oct 21 23:26 a.txt
$ docker exec curso-k8s-l8-worker stat -c '%a %u:%g %n' /mnt/disco-reportes
2775 0:101 /mnt/disco-reportes
Completed, y el archivo a.txt de 5 bytes (hola y un salto de línea) escrito. Mira el directorio en la bitácora: drwxrwsr-x, grupo 101. Y en el nodo, stat dice 2775 0:101: el kubelet cambió el grupo de la carpeta al 101 y le dio permiso de escritura a ese grupo, con setgid, antes de arrancar el contenedor. El proceso es del grupo 101, y por eso puede escribir. El dueño sigue siendo root; lo que cambió es el grupo. Es lo que dice la documentación: los volúmenes que soportan administración de dueños «se modifican para que sean del GID que indica fsGroup y escribibles por él».
El setgid (la s) tiene un efecto práctico que hace que fsGroup valga la pena: los archivos que se creen dentro del directorio heredan su grupo, el 101, y no el grupo primario del proceso que los crea. Aquí el efecto no se nota, porque el proceso ya es del grupo 101 (runAsGroup: 101); sí se notaría en un proceso con otro grupo primario. Y para que otro Pod del grupo 101 pueda modificar esos archivos, cada archivo necesitaría permiso de grupo (umask 002), cosa que esta tarea no pide: sus archivos salen -rw-r--r--.
Una nota de costo, que la documentación del contexto de seguridad explica: por omisión, Kubernetes cambia el dueño y los permisos de todo el contenido del volumen cada vez que se monta. Para un volumen con millones de archivos, eso tarda y retrasa el arranque del Pod. El campo fsGroupChangePolicy: OnRootMismatch pide que el cambio se haga solo si el dueño o el permiso de la raíz del volumen no coinciden con lo esperado, y evita recorrerlo cuando ya está en orden. Con volúmenes de 100 Mi no se nota; con discos grandes, sí.
8.7.3 Retain: el volumen sobrevive a su solicitud
Los dos Pods ya no hacen falta. Y el disco a mano sirve, además, para ver lo que no se puede ver con Delete: la política Retain. Se borra la solicitud:
ctx && kubectl delete -f fig08_06.escritor-con-fsgroup.yaml
ctx && kubectl delete pvc -n revisor disco
$ kubectl delete -f fig08_06.escritor-con-fsgroup.yaml
pod "con-fsgroup" deleted from revisor namespace
$ kubectl delete pvc -n revisor disco
persistentvolumeclaim "disco" deleted from revisor namespace
$ kubectl get pv
NAME CAPACITY ACCESS MODES RECLAIM POLICY STATUS CLAIM STORAGECLASS VOLUMEATTRIBUTESCLASS REASON AGE
disco-reportes 100Mi RWO Retain Released revisor/disco manual <unset> 11s
pvc-5fcf36b8-f096-44c7-abdb-a86d1e815804 100Mi RWO Delete Bound revisor/reportes standard <unset> 96s
La solicitud se fue; el volumen no. Su estado es Released («liberado»), que la documentación define así: «la solicitud ha sido borrada, pero el recurso de almacenamiento asociado todavía no ha sido reclamado por el clúster». Con Retain, el volumen se queda con su CLAIM revisor/disco anotado y no está disponible para otra solicitud, porque «los datos del reclamante anterior siguen en el volumen». Recuperarlo es un trabajo manual del administrador, en tres pasos que dice la documentación: borrar el PV, limpiar los datos del almacenamiento y borrar el almacenamiento asociado (o reutilizarlo con un PV nuevo). Se borra el PV y se mira el disco:
$ kubectl delete pv disco-reportes
persistentvolume "disco-reportes" deleted
$ docker exec curso-k8s-l8-worker ls -ln /mnt/disco-reportes
total 4
-rw-r--r-- 1 100 101 5 Oct 21 23:26 a.txt
El objeto PV desapareció de Kubernetes; la carpeta del nodo, y el archivo a.txt que escribió el Pod, siguen ahí. Eso es Retain: el clúster suelta el objeto y no toca los datos. Es la política que se escoge para datos que no deben desaparecer por un kubectl delete equivocado, a cambio de un trabajo de limpieza manual. (La carpeta vive dentro del contenedor del nodo, así que kind delete cluster se la lleva. Si quisieras que unos datos sobrevivan al clúster entero, la configuración de kind permite montar una carpeta de tu máquina dentro de un nodo con extraMounts; el curso no lo hace.)
8.8 Borrar la solicitud: ahí sí se pierde
La política de la clase standard es Delete. Se mide qué pasa con la solicitud reportes, la que guarda los reportes de la lección. Primero se mira la política, luego se pide el borrado sin esperar (--wait=false), porque lo que sigue puede quedarse esperando:
ctx && kubectl get storageclass standard -o jsonpath='{.reclaimPolicy}{"\n"}'
ctx && kubectl delete pvc -n revisor reportes --wait=false
$ kubectl get storageclass standard -o jsonpath='{.reclaimPolicy}{"\n"}'
Delete
$ kubectl delete pvc -n revisor reportes --wait=false
persistentvolumeclaim "reportes" deleted from revisor namespace
$ kubectl get pvc -n revisor
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
reportes Terminating pvc-5fcf36b8-f096-44c7-abdb-a86d1e815804 100Mi RWO standard <unset> 111s
$ kubectl get pvc -n revisor reportes -o jsonpath='{.metadata.finalizers} {.metadata.deletionTimestamp}{"\n"}'
["kubernetes.io/pvc-protection"] 2026-10-21T23:26:21Z
$ kubectl get pods -n revisor -l app=reporte
NAME READY STATUS RESTARTS AGE
reporte-29877085-6q87g 0/1 Completed 0 86s
reporte-manual-1-t7d8l 0/1 Completed 0 105s
reporte-manual-2-4hmmf 0/1 Completed 0 96s
Terminating: pidió que se borre y no se borra. El comando devolvió el control porque tenía --wait=false; sin esa opción, kubectl delete se habría quedado esperando. La explicación está en finalizers: ["kubernetes.io/pvc-protection"], que es una marca que impide que un objeto desaparezca hasta que algo la quite. La documentación llama a esta función protección de objetos de almacenamiento en uso, y su propósito es «asegurar que las solicitudes en uso activo por un Pod, y los volúmenes unidos a solicitudes, no se quiten del sistema, ya que esto podría resultar en pérdida de datos». Y define qué es «en uso activo»: una solicitud está en uso cuando existe un objeto Pod que la usa. Si la borras en ese caso, «el borrado se pospone hasta que ninguno la use».
Mira la última salida: tres Pods Completed. Terminaron hace tiempo y ya no corren, pero existen como objetos (se conservaron para leer su bitácora), y cada uno declara en sus volúmenes la solicitud reportes. Para la protección, siguen usándola. La solicitud espera a que esos Pods dejen de existir. La salida no es forzar el borrado, sino quitar a quienes la sostienen: borrar los Jobs, que se llevan sus Pods.
ctx && kubectl delete jobs -n revisor --all
$ kubectl delete jobs -n revisor --all
job.batch "reporte-29877085" deleted from revisor namespace
job.batch "reporte-manual-1" deleted from revisor namespace
job.batch "reporte-manual-2" deleted from revisor namespace
$ kubectl get pvc,pv
No resources found
$ docker exec curso-k8s-l8-worker ls -la /var/local-path-provisioner/
total 8
drwxr-xr-x 2 root root 4096 Oct 21 23:26 .
drwxr-xr-x 12 root root 4096 Oct 21 23:24 ..
En cuanto los Pods se fueron, el borrado de la solicitud avanzó: ya no hay solicitud ni volumen (No resources found), y la carpeta del nodo, pvc-5fcf36b8-…_revisor_reportes, desapareció con todos sus reportes. El directorio /var/local-path-provisioner/ quedó vacío. Es la política Delete en acción: se borra la solicitud, se borra el volumen y se borra el almacenamiento. En el borrado de la corrida no se alcanzó a ver el estado intermedio Released del volumen dinámico (el guion miró 8 segundos después de borrar los Jobs y para entonces el volumen ya no existía; no sé en cuánto tiempo exacto desapareció); el de 8.7.3, con Retain, sí lo mostró.
Recrear la solicitud y pedir una tarea nueva muestra lo que queda: nada del pasado.
ctx && kubectl apply -k . | grep persistentvolumeclaim
ctx && kubectl create job -n revisor --from=cronjob/reporte reporte-manual-3
$ kubectl apply -k . | grep persistentvolumeclaim
persistentvolumeclaim/reportes created
$ kubectl create job -n revisor --from=cronjob/reporte reporte-manual-3
job.batch/reporte-manual-3 created
$ kubectl logs -n revisor job/reporte-manual-3
total 4
-rw-r--r-- 1 100 101 92 Oct 21 23:26 reporte-20261021T232640Z.json
Un solo archivo: el de ahora. Los reportes del pasado no están. Con esto, la regla de la sección queda en una frase para cuando algún día lo hagas sin querer: con la política Delete, los datos viven lo que viva la solicitud y el borrado de la solicitud se lleva los datos; con Retain, sobreviven a ella y alguien tiene que limpiarlos (8.7.3). La política es de la clase, no de quien borra: por eso conviene saber cuál es la clase antes de borrar nada que se llame pvc.
El error que vas a ver
Esta lección tiene dos errores a propósito. El segundo ya lo viste en 8.7.2: el Permission denied del Pod que escribe en un volumen de root sin fsGroup. El primero es el de una tarea que nunca llega al revisor.
BackoffLimitExceeded: un Job que no puede salir
fig08_02.job-sin-etiqueta.yaml es el Job de práctica de 8.5.1 con una diferencia: le falta la etiqueta acceso-revisor: permitido. Es el error que se comete al copiar una plantilla y olvidar una línea que no se ve.
diff fig08_01.job-salud.yaml fig08_02.job-sin-etiqueta.yaml
ctx && kubectl apply -f fig08_02.job-sin-etiqueta.yaml
El diff no lo corre el guion (no toca el clúster); esta es su salida, corrida en la carpeta de programas. Además de los comentarios del encabezado y del nombre, la única diferencia de contenido es la línea de la etiqueta:
$ diff fig08_01.job-salud.yaml fig08_02.job-sin-etiqueta.yaml
1,3c1,4
< # fig08_01.job-salud.yaml — Lección 8: el Job más pequeño que sirve. Una vez, le pregunta
< # a /salud del revisor y termina. Lleva la etiqueta acceso-revisor: permitido, la que la red
< # cerrada de la Lección 7 exige para que un Pod hable con el revisor.
---
> # fig08_02.job-sin-etiqueta.yaml — Lección 8: el MISMO Job de fig08_01, sin la etiqueta
> # acceso-revisor. La red cerrada de la Lección 7 no lo deja salir hacia el revisor: curl se
> # rinde a los 5 s (código 28), el Job reintenta dos veces y termina Failed con
> # BackoffLimitExceeded.
7c8
< name: salud
---
> name: salud-sin-etiqueta
16d16
< acceso-revisor: permitido
Un Job así no falla de inmediato. Reintenta, con las esperas que 8.5.1 contó, y la lección lo mira a la vista, cada 15 segundos:
$ kubectl apply -f fig08_02.job-sin-etiqueta.yaml
job.batch/salud-sin-etiqueta created
(esperando hasta 150 s a que el Job agote sus reintentos; se mira cada 15 s)
+ 15 s: 1 fallidos, activos, condiciones:
+ 30 s: 2 fallidos, activos, condiciones:
+ 45 s: 2 fallidos, 1 activos, condiciones:
+ 60 s: 3 fallidos, activos, condiciones: FailureTarget=True Failed=True
$ kubectl get job,pods -n revisor -l app=salud
NAME STATUS COMPLETIONS DURATION AGE
job.batch/salud-sin-etiqueta Failed 0/1 61s 61s
NAME READY STATUS RESTARTS AGE
pod/salud-sin-etiqueta-9bnv9 0/1 Error 0 61s
pod/salud-sin-etiqueta-svs9r 0/1 Error 0 44s
pod/salud-sin-etiqueta-tr95q 0/1 Error 0 19s
Primero, el estado. A los 61 segundos, el Job está Failed y tiene tres Pods, los tres Error. Son exactamente los que dijo backoffLimit: 2: el original y dos reintentos. Las edades (61s, 44s, 19s desde que se crearon, leídas al revés) cuentan la espera creciente de la documentación: el segundo Pod nació 17 segundos después del primero (5 de curl más 10 de espera) y el tercero 25 después del segundo (5 más 20). No son segundos redondos porque el clúster agrega unos de más al crear Pods.
Segundo, la bitácora, porque el contenedor sí corrió.
$ kubectl logs -n revisor job/salud-sin-etiqueta
Found 3 pods, using pod/salud-sin-etiqueta-9bnv9
curl: (28) Connection timed out after 5000 milliseconds
$ kubectl get pods -n revisor -l app=salud -o custom-columns=POD:.metadata.name,CODIGO:.status.containerStatuses[0].state.terminated.exitCode,RAZON:.status.containerStatuses[0].state.terminated.reason
POD CODIGO RAZON
salud-sin-etiqueta-9bnv9 28 Error
salud-sin-etiqueta-svs9r 28 Error
salud-sin-etiqueta-tr95q 28 Error
kubectl logs job/… avisa que hay tres Pods y que muestra uno. El mensaje de curl es el que conoces de la Lección 7: código 28, «la conexión expiró». La red cerrada no deja salir a un Pod sin la etiqueta de acceso hacia el revisor, y no contesta con un rechazo: simplemente no contesta, y curl espera sus 5 segundos (-m 5) y se rinde. Los tres Pods terminaron con código 28. Es la misma falla de «la entrada abierta que no entra» de la Lección 7, vista desde una tarea.
Tercero, el Job: por qué se rindió.
$ kubectl get job -n revisor salud-sin-etiqueta -o jsonpath='{range .status.conditions[*]}{.type} {.status} {.reason}: {.message}{"\n"}{end}'
FailureTarget True BackoffLimitExceeded: Job has reached the specified backoff limit
Failed True BackoffLimitExceeded: Job has reached the specified backoff limit
$ kubectl describe job -n revisor salud-sin-etiqueta | sed -n '/^Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulCreate 62s job-controller Created pod: salud-sin-etiqueta-9bnv9
Normal SuccessfulCreate 45s job-controller Created pod: salud-sin-etiqueta-svs9r
Normal SuccessfulCreate 20s job-controller Created pod: salud-sin-etiqueta-tr95q
Warning BackoffLimitExceeded 10s job-controller Job has reached the specified backoff limit
BackoffLimitExceeded («se excedió el límite de reintentos») es la razón del fallo, tal como la documentación lista las causas por las que un Job falla: «el número de fallas de Pods excedió el .spec.backoffLimit». Los eventos, de arriba abajo, son la historia en cuatro renglones: tres creaciones y la rendición.
Cuarto, la causa, que el Job no sabe decir. Nada de lo anterior dice por qué el curl expira. Eso lo dice la comparación con lo que sí funciona. depurar, que en 8.1 hablaba bien con el revisor, lleva una etiqueta que este Job no tiene:
$ kubectl get pod depurar -n revisor --show-labels
NAME READY STATUS RESTARTS AGE LABELS
depurar 1/1 Running 0 3m36s acceso-revisor=permitido,app=depurar
acceso-revisor=permitido es la etiqueta que las políticas de red de la Lección 7 piden. El Job de la Lección, el CronJob real, la lleva en su plantilla, con un comentario que lo explica; este no. La corrección es la de siempre: el archivo es la verdad. Se agrega la línea al manifiesto y se vuelve a aplicar; aquí, simplemente, se borra el experimento:
$ kubectl delete -f fig08_02.job-sin-etiqueta.yaml
job.batch "salud-sin-etiqueta" deleted from revisor namespace
Dos lecciones de este error. La primera: un Job que falla dice que falló, no por qué; la causa está en la bitácora de sus Pods (curl: (28)) y en lo que sabes de la red. La segunda: una tarea programada que falla a las 3 de la madrugada no despierta a nadie. El CronJob conserva los últimos tres Jobs fallidos (failedJobsHistoryLimit: 3) justo para que alguien, al día siguiente, pueda ir a leer qué les pasó. Sin vigilancia, una tarea que falla en silencio durante semanas produce lo contrario de lo que prometía: la sensación de que hay un respaldo donde no lo hay. Es la lección del relato de GitLab que abrió la lección, en pequeño.
El orden para una tarea que no funcionó: kubectl get jobs (¿Failed?); los Pods del Job (kubectl get pods -l …) y el código de salida de cada uno; la bitácora (kubectl logs job/…); y, si el Job no creó ni un Pod, los eventos del propio Job (kubectl describe job). Antes de cambiar nada, saber si falló una vez, siempre, o a veces.
El hábito de seguridad de la lección: los datos con su dueño
La tabla de hábitos del curso le asigna a esta lección dos cosas: el ServiceAccount sin credenciales montadas si no las necesita, y los datos con su dueño. Las dos se comprueban en el clúster.
La cuenta de servicio de la tarea. La tarea le habla al revisor por HTTP; no le habla a la API del clúster. No necesita una credencial para eso, y por tanto no se le monta:
$ kubectl get serviceaccount reporte -n revisor -o jsonpath='{.automountServiceAccountToken}{"\n"}'
false
$ kubectl get cronjob reporte -n revisor -o jsonpath='{.spec.jobTemplate.spec.template.spec.automountServiceAccountToken}{" "}{.spec.jobTemplate.spec.template.spec.containers[0].env}{"\n"}'
false
$ kubectl get pods -n revisor -l app=reporte -o custom-columns=POD:.metadata.name,CUENTA:.spec.serviceAccountName,AUTOMOUNT:.spec.automountServiceAccountToken,VOLUMENES:.spec.volumes[*].name
POD CUENTA AUTOMOUNT VOLUMENES
reporte-manual-3-2657n reporte false datos,token
$ kubectl exec -n revisor depurar -- curl -s -m 3 -o /dev/null -w '%{http_code}\n' http://revisor/api/estados
401
false en la cuenta, false en la plantilla del CronJob, y env vacío (el segundo valor de la segunda salida es nada: la tarea no recibe variables con secretos). Los volúmenes del Pod de la tarea son dos y se llaman datos y token: ninguno es la credencial de la API. Si alguien lograra ejecutar código dentro de la tarea, no encontraría un token del clúster que robar. Y el token del revisor va como archivo de solo lectura con 0440, no como variable de entorno: es lo que la Lección 4 enseñó. La última salida es la prueba de que el revisor sigue pidiéndolo: sin token, 401.
Los datos con su dueño. Las tres piezas que se midieron:
- El grupo del volumen lo pone
fsGroup, no un permiso abierto. En un disco real, la solución a unPermission deniedes dar el grupo, nochmod 777. En 8.7.2 el volumen era derootcon755, y con una línea quedó en2775 0:101: deroot, grupo 101, escribible solo por ese grupo (y legible por todos). - El proceso corre sin
rooty el archivo que crea es suyo:-rw-r--r-- 1 100 101en la tarea;-rw------- 1 65532 65532en elrevisor. Cada escritor deja su marca, y se puede saber quién escribió qué. - El token no llega al volumen de datos. La tarea lee el token de
/secretos, un volumen de solo lectura, y escribe solo en/datos. Mantener separadas la entrada y la salida impide que un error de la tarea escriba el secreto en un lugar donde se conserve.
El tamaño que el clúster no hace respetar. Falta una sorpresa, la de la sección 8.4.1, donde se comentó que habría algo que decir sobre qué tan en serio se toma un clúster el número 100Mi. El volumen pidió 100 mebibytes y el clúster dijo CAPACITY 100Mi. Se prueba con un Job, fig08_07.llenar-el-volumen.yaml, que escribe 150 millones de bytes (unos 143 MiB) en ese mismo volumen:
ctx && kubectl get pvc reportes -n revisor -o jsonpath='{.spec.resources.requests.storage} {.status.capacity.storage}{"\n"}'
ctx && kubectl apply -f fig08_07.llenar-el-volumen.yaml
ctx && kubectl wait -n revisor --for=condition=Complete job/llenar --timeout=180s
ctx && kubectl logs -n revisor job/llenar
ctx && kubectl delete -f fig08_07.llenar-el-volumen.yaml
(El wait evita pedir la bitácora antes de que el Pod termine de escribir; el guion lo corre sin imprimirlo.)
$ kubectl get pvc reportes -n revisor -o jsonpath='{.spec.resources.requests.storage} {.status.capacity.storage}{"\n"}'
100Mi 100Mi
$ kubectl apply -f fig08_07.llenar-el-volumen.yaml
job.batch/llenar created
$ kubectl logs -n revisor job/llenar
total 146496
-rw-r--r-- 1 100 101 150000000 Oct 21 23:27 grande.bin
-rw-r--r-- 1 100 101 92 Oct 21 23:26 reporte-20261021T232640Z.json
/dev/root 57.1G 48.4G 8.6G 85% /datos
$ kubectl delete -f fig08_07.llenar-el-volumen.yaml
job.batch "llenar" deleted from revisor namespace
El Job terminó sin error y ls muestra grande.bin de 150000000 bytes en un volumen que pidió y recibió 100Mi. Y el df de adentro del Pod enseña el motivo: el «volumen» /datos aparece como /dev/root, de 57.1 GB, con el 85 % usado: es el disco del nodo, que en kind es el de la máquina de Docker, no un trozo de 100 Mi (el nombre del dispositivo y el tamaño cambian en tu máquina). Es exactamente lo que el README del aprovisionador anunció («la capacidad se ignora por ahora»): el número de storage decide con qué PV se une la solicitud, pero la clase de kind no limita lo que se escribe.
Este es un hallazgo de laboratorio que conviene no olvidar: con la clase de kind, lo que pides no es lo que obtienes; en otros almacenamientos depende del sistema, y conviene medirlo. En un clúster real, el almacenamiento de verdad sí tiene tamaño y se llena (con un error No space left on device que tumba a la aplicación a media escritura), y un espacio de nombres sin cuota puede crear solicitudes de cualquier tamaño. La cuota de almacenamiento (ResourceQuota, con requests.storage) pone un máximo por espacio de nombres, como se comentó en la Lección 5 para la CPU y la memoria; el curso la nombra y no la instala.
Lo que defiende y lo que no. Estas costumbres protegen que la tarea no tenga más poder del que necesita, que el token no se filtre al disco, y que los archivos tengan un dueño identificable. No protegen los datos: un Delete los borra (8.8), un nodo caído los vuelve inaccesibles (el volumen está en un solo disco), y nadie los respalda. Lo que cuida los datos de verdad es la fila que la lección 10 pone en la lista de lo que falta para producción: almacenamiento replicado fuera del nodo, instantáneas de volumen y respaldos que se prueben restaurando. Una frase sobre cada uno, para que sepas qué problema resuelve: un StatefulSet es el objeto para aplicaciones con estado, cuyos Pods tienen identidad estable y un volumen propio cada uno (el curso no lo usa); una instantánea de volumen es una copia del volumen en un instante, que sirve para volver atrás; y un respaldo probado es la copia que alguien ya restauró en otro lugar y comprobó, que es justo lo que le faltó al relato de GitLab del principio.
Lo que se hace mal
Guardar en un emptyDir lo que debe sobrevivir al Pod. Lo viste en 8.2.3: el contenedor se salva de una caída, el Pod no se salva de un borrado, y vaciar un nodo (drain), una evicción o un nodo declarado perdido borran Pods; un reinicio del nodo no necesariamente, pero tampoco conviene apostar a ello. Un emptyDir es para espacio de trabajo, no para historia.
Creer que Pending en una solicitud es un error. Con WaitForFirstConsumer, una solicitud sin Pod se queda Pending correctamente. Se lee el evento antes de actuar.
Poner storage: 100Mi y creer que ahí termina. En kind, el tamaño no se hace cumplir (lo mide «El hábito de seguridad»). En producción, el disco se llena. Hay que vigilar el uso, con alertas, antes de que llegue al tope.
Borrar una solicitud (pvc) sin saber la política de la clase. Con Delete, se borran los datos. Con Retain, se conservan y alguien debe limpiarlos. Se mira RECLAIMPOLICY antes.
Borrar un PV a mano para «liberar» una solicitud atorada. La solicitud en Terminating espera a los Pods que la usan; el PV tiene su propia protección. Forzar finalizadores (kubectl patch … finalizers) saltándose la protección es la forma de dañar datos en uso. Se encuentra y se borra lo que la sostiene (en esta lección, los Jobs).
Usar chmod 777 o un volumen abierto para que «ya escriba». Es lo que hace el aprovisionador de kind, y por eso rara vez se nota el problema. En un disco de verdad el remedio es fsGroup y un usuario sin root, no abrir el directorio a todos.
Escribir una tarea que no soporta correr dos veces. La documentación lo avisa: el CronJob puede crear dos Jobs o ninguno. Una tarea que suma, envía o cobra sin protección es un duplicado esperando.
Dejar concurrencyPolicy: Allow por omisión en una tarea que escribe en un volumen compartido. Dos tareas a la vez en la misma carpeta, y una tarea lenta que se encima con la siguiente, pueden acumular Pods sin que nadie lo note. Forbid o Replace dicen explícitamente qué quieres.
Un Job sin backoffLimit ni activeDeadlineSeconds. El valor por omisión de reintentos es 6 y el tiempo no tiene tope: una tarea colgada se queda colgada. Se ponen los dos en las tareas de verdad, con números que se puedan justificar (los Jobs de práctica de la lección, que duran segundos, no los llevan todos).
Un CronJob sin historial o con un historial enorme. Sin historial (0), no hay evidencia de lo que falló. Con un número muy grande, se acumulan Jobs y Pods por miles. Se escoge un número pequeño y se revisa.
Dar a la tarea una cuenta de servicio con permisos «por si acaso». La tarea que solo habla HTTP no necesita credencial de la API. automountServiceAccountToken: false y una cuenta propia sin permisos.
Poner el token en el comando o en una variable de entorno de la tarea. Se lee del archivo montado en el momento de correr, como hace la tarea de la lección.
Olvidar la zona horaria de un CronJob. Sin timeZone, el horario es el del controlador (UTC en kind). «Todos los días a las 3» no es lo que crees si estás a seis horas de UTC.
Guardar el reporte de todas las réplicas en un volumen ReadWriteOnce local. El Ejercicio 2 lo prueba: ata todas las réplicas a un solo nodo.
Ejercicios
Los tres primeros se corren en el clúster de la lección, en orden y desde el estado en que quedó después del «hábito de seguridad». Antes de cada uno, la función ctx debe darte éxito (ctx && echo ok). Antes de correr cada uno, escribe en la bitácora qué crees que va a pasar. El cuarto se escribe con tus palabras.
Ejercicio 1 — Una clase de almacenamiento que no existe
fig08_08.pvc-clase-inexistente.yaml crea una solicitud de volumen que pide la clase rapido (que este clúster no tiene) y un Pod, lector, que la usa. Aplícalo. Antes de mirar, predice: ¿en qué estado queda la solicitud? ¿En cuál, el Pod? ¿Qué evento crees que da cada uno, y quién lo emite? Compruébalo con kubectl get pvc rapido -n revisor, kubectl get pod lector -n revisor y los describe de los dos. Y la pregunta difícil: ¿cuánto tiempo esperarías antes de concluir que no se va a resolver solo? Al terminar, bórralo con kubectl delete -f fig08_08.pvc-clase-inexistente.yaml.
Ejercicio 2 — El reporte del revisor, en el volumen
La decisión del curso es que el revisor guarde su reporte.json en el emptyDir y que la tarea guarde los suyos en el volumen. Compruébalo con un cambio de una línea. Con kubectl patch, reemplaza el volumen datos del Deployment del revisor (el primero de la lista, /spec/template/spec/volumes/0) por uno que use la solicitud reportes:
ctx && kubectl patch deployment revisor -n revisor --type=json -p '[{"op":"replace","path":"/spec/template/spec/volumes/0","value":{"name":"datos","persistentVolumeClaim":{"claimName":"reportes"}}}]'
Predice: ¿arrancan las tres réplicas? ¿En qué nodo cae cada una? ¿Qué dice kubectl get pv del volumen? Pide un reporte con depurar y busca el archivo en el nodo. Después, deshaz el cambio con kubectl apply -k . (el archivo es la verdad) y comprueba dónde quedan las réplicas. Explica en cinco frases por qué esto no es lo que quieres para el proyecto, y qué se pierde de lo que la Lección 9 va a pedir.
Ejercicio 3 — Una tarea que tarda más que su horario
fig08_09.cron-lento.yaml es un CronJob que corre cada minuto (* * * * *) con concurrencyPolicy: Forbid, y cuya tarea tarda 100 segundos (sleep 100). Predice, antes de aplicarlo: a los 2 minutos y medio, ¿cuántos Jobs habrá corriendo a la vez? ¿Se crearán los de los minutos intermedios? ¿Qué evento deja el CronJob cuando salta uno? Aplícalo, mira cada 35 segundos durante unos 210 segundos (la primera tarea tarda cerca de dos minutos y hay que ver cómo termina) con kubectl get cronjob lento -n revisor y kubectl get jobs -n revisor y lee kubectl describe cronjob lento -n revisor. Después, borra el CronJob (ctx && kubectl delete -f fig08_09.cron-lento.yaml), espera al inicio de un minuto y vuelve a crearlo con la política cambiada sin tocar el archivo: sed 's/concurrencyPolicy: Forbid/concurrencyPolicy: Allow/' fig08_09.cron-lento.yaml | kubectl apply -f -; repite con Replace. Así el archivo queda intacto para la corrida de correr.sh. Compara: ¿qué ves con cada valor? Al terminar, bórralo con ctx && kubectl delete -f fig08_09.cron-lento.yaml: si lo dejas, sigue creando un Job por minuto, y kubectl diff -k . no lo detecta porque no es parte de los archivos de la lección.
Ejercicio 4 — Dos horarios
En cinco frases como máximo, escribe tu respuesta a esto. (a) El horario cron de una tarea que corre los días de entre semana a las 7:30 de la mañana, hora de la Ciudad de México, y qué campo de la especificación del CronJob necesitas además del horario. (b) El horario de una tarea que corre cada dos horas en punto. (c) Qué ocurre con una ejecución programada para las 3:00 si el controlador de CronJobs estuvo caído de las 2:50 a las 3:20 y el CronJob tiene startingDeadlineSeconds: 600; y qué cambia si no lo tiene. (d) Por qué la tarea del reporte puede correr dos veces en el mismo minuto sin dañar nada.
Soluciones
Solución 1
Medido en correr.salida.txt:
$ kubectl apply -f fig08_08.pvc-clase-inexistente.yaml
persistentvolumeclaim/rapido created
pod/lector created
$ kubectl get pvc rapido -n revisor
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
rapido Pending rapido <unset> 12s
$ kubectl get pod lector -n revisor
NAME READY STATUS RESTARTS AGE
lector 0/1 Pending 0 12s
$ kubectl describe pvc rapido -n revisor | sed -n '/^Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning ProvisioningFailed 6s (x2 over 12s) persistentvolume-controller storageclass.storage.k8s.io "rapido" not found
$ kubectl describe pod lector -n revisor | sed -n '/^Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning FailedScheduling 13s default-scheduler 0/3 nodes are available: pod has unbound immediate PersistentVolumeClaims. not found
Los dos están Pending, pero por razones de distinto dueño. La solicitud lo está porque el persistentvolume-controller busca la clase rapido, no la encuentra, y emite un Warning con la causa exacta: storageclass.storage.k8s.io "rapido" not found. A diferencia del Pending de 8.4, éste es de nivel Warning y no dice «esperando»: dice que algo no existe. El Pod lo está porque el planificador, que lo ve con una solicitud sin unir, responde 0/3 nodes are available: pod has unbound immediate PersistentVolumeClaims: ningún nodo sirve mientras la solicitud no esté unida. (El mensaje termina con un not found suelto, tal como lo imprimió el clúster de la corrida; el texto de los eventos cambia entre versiones, y lo que debes leer es la causa en el evento de la solicitud.)
La lectura es la de siempre: el Pod Pending no es el problema, es el síntoma. La causa está un objeto más abajo, en la solicitud, y su evento dice qué clase falta. La corrección es escribir una clase que exista (kubectl get storageclass da la lista) o crear la que falta. Sobre cuánto esperar: nada de esto se resuelve solo. La solicitud sin clase se queda Pending «indefinidamente», como dice la documentación de volúmenes persistentes para una solicitud sin volumen que coincida, hasta que alguien cree la clase; no hay tiempo de espera ni error final. Un Pending con ProvisioningFailed no es una espera, es un aviso.
Solución 2
Medido en correr.salida.txt:
$ kubectl patch deployment revisor -n revisor --type=json -p '[{"op":"replace","path":"/spec/template/spec/volumes/0","value":{"name":"datos","persistentVolumeClaim":{"claimName":"reportes"}}}]'
deployment.apps/revisor patched
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-6d9bd59b8b-5wnrq 1/1 Running 0 7s 10.244.2.24 curso-k8s-l8-worker <none> <none>
revisor-6d9bd59b8b-sjjkr 1/1 Running 0 10s 10.244.2.23 curso-k8s-l8-worker <none> <none>
revisor-6d9bd59b8b-z7sc9 1/1 Running 0 12s 10.244.2.22 curso-k8s-l8-worker <none> <none>
$ kubectl get pv -o custom-columns=NOMBRE:.metadata.name,NODO:.spec.nodeAffinity.required.nodeSelectorTerms[0].matchExpressions[0].values[0]
NOMBRE NODO
pvc-54c37c25-a11a-4a37-b2da-ae67d36aea40 curso-k8s-l8-worker
$ docker exec curso-k8s-l8-worker ls -lan /var/local-path-provisioner/pvc-54c37c25-a11a-4a37-b2da-ae67d36aea40_revisor_reportes
total 146508
drwxrwxrwx 2 0 0 4096 Oct 21 23:28 .
drwxr-xr-x 3 0 0 4096 Oct 21 23:26 ..
-rw-r--r-- 1 100 101 150000000 Oct 21 23:27 grande.bin
-rw-r--r-- 1 100 101 92 Oct 21 23:26 reporte-20261021T232640Z.json
-rw------- 1 65532 65532 93 Oct 21 23:28 reporte.json
$ kubectl apply -k . | grep -E 'deployment|persistentvolumeclaim'
persistentvolumeclaim/reportes unchanged
deployment.apps/prueba unchanged
deployment.apps/revisor configured
$ kubectl get pods -n revisor -l app=revisor -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
revisor-6b7f4d564d-mgzwr 1/1 Running 0 2s 10.244.1.9 curso-k8s-l8-worker2 <none> <none>
revisor-6b7f4d564d-nxzdj 1/1 Running 0 7s 10.244.1.8 curso-k8s-l8-worker2 <none> <none>
revisor-6b7f4d564d-tk4tr 1/1 Running 0 5s 10.244.2.25 curso-k8s-l8-worker <none> <none>
revisor-6d9bd59b8b-5wnrq 1/1 Terminating 0 15s 10.244.2.24 curso-k8s-l8-worker <none> <none>
Las tres réplicas arrancan, y las tres caen en el mismo nodo: curso-k8s-l8-worker, el que tiene el volumen. El planificador no pudo repartirlas porque el PV tiene una afinidad de nodo (8.5.2): cualquier Pod que use la solicitud debe correr donde está el disco. Y fueron las tres porque ese modo, ReadWriteOnce, es «un nodo», no «un Pod»: varios Pods en el mismo nodo pueden montar el volumen a la vez. La documentación lo dice, y aquí se vio. El reporte.json del revisor aparece en la misma carpeta de los reportes de la tarea, junto a grande.bin (el 150 MB de «el hábito de seguridad», todavía ahí porque el volumen es el mismo) y el archivo con fecha de la tarea 3.
Se deshace con kubectl apply -k .: las réplicas vuelven al emptyDir y se reparten otra vez en los dos nodos de trabajo (worker y worker2). Lo que se vería mal en el proyecto, en cinco frases: (1) todas las réplicas quedan en un solo nodo, porque el volumen está en un solo disco; (2) si ese nodo cae, el revisor completo queda sin sitio donde correr, porque los Pods no pueden irse a otro nodo con su volumen; (3) las tres réplicas escribirían el mismo reporte.json, pisándose entre sí, que es peor que antes; (4) el reparto de réplicas por nodos que la Lección 9 necesita para escalar y para actualizar midiendo cada corte desaparece; (5) lo que el reporte necesita es historia con nombre y fecha, y eso ya lo hace la tarea.
Solución 3
Medido en correr.salida.txt. Primero Forbid (el guion espera al inicio de un minuto del reloj antes de aplicar cada variante, para que la primera tarea caiga en el siguiente :00):
$ kubectl apply -f fig08_09.cron-lento.yaml
cronjob.batch/lento created
$ date -u +%T; kubectl get cronjob lento -n revisor; kubectl get jobs -n revisor | grep -E 'NAME|lento'
23:30:45
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
lento * * * * * <none> False 1 45s 105s
NAME STATUS COMPLETIONS DURATION AGE
lento-29877090 Running 0/1 45s 45s
$ date -u +%T; kubectl get cronjob lento -n revisor; kubectl get jobs -n revisor | grep -E 'NAME|lento'
23:31:56
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
lento * * * * * <none> False 1 56s 2m56s
NAME STATUS COMPLETIONS DURATION AGE
lento-29877090 Complete 1/1 103s 117s
lento-29877091 Running 0/1 14s 14s
$ kubectl describe cronjob lento -n revisor | sed -n '/^Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulCreate 2m32s cronjob-controller Created job lento-29877090
Normal SawCompletedJob 49s cronjob-controller Saw completed job: lento-29877090, condition: Complete
Normal SuccessfulCreate 49s cronjob-controller Created job lento-29877091
Normal JobAlreadyActive 32s (x4 over 92s) cronjob-controller Not starting job because prior execution is running and concurrency policy is Forbid
Con Forbid, un solo Job a la vez, y los minutos intermedios no se crean. El primer Job, lento-29877090, nació a las 23:30:00 y tardó 103 segundos (100 de sleep más lo que tarda arrancar y terminar). Mientras corría, llegaron los horarios de 23:31:00 y no se creó nada: el CronJob dejó ACTIVE 1 y siguió con el mismo. Cuando el Job terminó, a las 23:31:43 más o menos, el CronJob lo vio (SawCompletedJob) y creó otro de inmediato, lento-29877091. Fíjate en el nombre del segundo: …091 es el de las 23:31:00, no el de las 23:32:00. La razón está en la documentación: cuando termina la ejecución anterior, startingDeadlineSeconds (que aquí no se fijó, y por tanto no tiene tope) todavía cuenta, y la ejecución de las 23:31:00 que se había saltado se crea al fin. (En correr.salida.txt hay una captura más, a los 35 segundos de aplicar, que todavía no tiene ningún Job: el CronJob se creó a las 23:29:00 y su primer horario fue el de las 23:30:00; por eso lento-29877090 es del minuto siguiente.)
El evento que dejan los horarios saltados es JobAlreadyActive, con el mensaje Not starting job because prior execution is running and concurrency policy is Forbid («no se inicia el Job porque la ejecución anterior sigue corriendo y la política de concurrencia es Forbid»), y el contador x4 over 92s dice cuántas veces ocurrió en el rato que miraste. La documentación agrega una consecuencia: una ejecución saltada por Forbid cuenta como perdida.
Ahora la misma tarea con Allow, el valor por omisión, y después con Replace. Los dos son mediciones, no aritmética:
$ sed 's/concurrencyPolicy: Forbid/concurrencyPolicy: Allow/' fig08_09.cron-lento.yaml | kubectl apply -f -
cronjob.batch/lento created
$ kubectl get cronjob lento -n revisor -o jsonpath='{.spec.concurrencyPolicy}{"\n"}'
Allow
$ date -u +%T; kubectl get cronjob lento -n revisor; kubectl get jobs -n revisor | grep -E 'NAME|lento'; kubectl get pods -n revisor -l app=lento
23:35:21
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
lento * * * * * <none> False 2 21s 2m21s
NAME STATUS COMPLETIONS DURATION AGE
lento-29877094 Running 0/1 81s 81s
lento-29877095 Running 0/1 21s 21s
NAME READY STATUS RESTARTS AGE
lento-29877094-mt8vs 1/1 Running 0 81s
lento-29877095-hqn7d 1/1 Running 0 21s
$ kubectl describe cronjob lento -n revisor | sed -n '/^Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulCreate 81s cronjob-controller Created job lento-29877094
Normal SuccessfulCreate 21s cronjob-controller Created job lento-29877095
Con Allow, dos Jobs corren a la vez. El primero (lento-29877094) empezó a las 23:34:00; a las 23:35:00 llegó el segundo (lento-29877095) sin esperar a que el primero terminara, y la captura muestra ACTIVE 2, dos Jobs Running y dos Pods Running al mismo tiempo, con 81 y 21 segundos de vida. Los eventos son solo SuccessfulCreate, uno por horario: no hay JobAlreadyActive, porque nada se salta. Como la tarea dura 100 segundos y el horario es de 60, en régimen siempre hay entre uno y dos activos (esto último sale de la aritmética; lo medido es el primer solape). Con una tarea que escribe en un volumen compartido, esos dos Pods escribirían a la vez: por eso 8.6 elige Forbid para el reporte.
$ sed 's/concurrencyPolicy: Forbid/concurrencyPolicy: Replace/' fig08_09.cron-lento.yaml | kubectl apply -f -
cronjob.batch/lento created
$ kubectl get cronjob lento -n revisor -o jsonpath='{.spec.concurrencyPolicy}{"\n"}'
Replace
$ date -u +%T; kubectl get cronjob lento -n revisor; kubectl get jobs -n revisor | grep -E 'NAME|lento'; kubectl get pods -n revisor -l app=lento
23:37:46
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
lento * * * * * <none> False 1 46s 106s
NAME STATUS COMPLETIONS DURATION AGE
lento-29877097 Running 0/1 46s 46s
NAME READY STATUS RESTARTS AGE
lento-29877097-n8cpx 1/1 Running 0 46s
$ date -u +%T; kubectl get cronjob lento -n revisor; kubectl get jobs -n revisor | grep -E 'NAME|lento'; kubectl get pods -n revisor -l app=lento
23:38:21
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
lento * * * * * <none> False 1 21s 2m21s
NAME STATUS COMPLETIONS DURATION AGE
lento-29877098 Running 0/1 21s 21s
NAME READY STATUS RESTARTS AGE
lento-29877097-n8cpx 1/1 Terminating 0 81s
lento-29877098-g6cvv 1/1 Running 0 21s
$ kubectl describe cronjob lento -n revisor | sed -n '/^Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulCreate 81s cronjob-controller Created job lento-29877097
Normal SuccessfulDelete 21s cronjob-controller Deleted job lento-29877097
Normal SuccessfulCreate 21s cronjob-controller Created job lento-29877098
Con Replace, el Job en curso se borra y se empieza otro, y ninguno llega a terminar. El primero (lento-29877097) empezó a las 23:37:00; a las 23:38:00 el CronJob lo borró (SuccessfulDelete) y creó lento-29877098 en su lugar. En la última captura el Pod viejo está Terminating y el nuevo Running con 21 segundos. El primer Job vivió 60 segundos, menos de los 100 que necesita, y no llegó a terminar. Eso se midió una vez (el primer reemplazo); que los siguientes repitan lo mismo sale de que el horario sigue siendo de 60 segundos y la tarea de 100. Replace es el valor para tareas cuyo último resultado es el único que importa y que se pueden reiniciar sin daño, y el incorrecto para una que necesita sus 100 segundos completos.
Las tres políticas son una respuesta a «qué pasa cuando la tarea es más lenta que su horario», y la respuesta correcta es la que describe lo que tu tarea necesita, no el valor por omisión.
Solución 4
Una respuesta posible: «(a) 30 7 * * 1-5, que es minuto 30, hora 7, cualquier día del mes, cualquier mes, de lunes (1) a viernes (5); y además timeZone: "America/Mexico_City" en la especificación, porque sin él el horario se interpreta en la zona del controlador, UTC en kind, y la tarea correría a la 1:30 de la madrugada. (b) 0 */2 * * *: minuto 0 de cada segunda hora. (c) Con startingDeadlineSeconds: 600, a las 3:20 el CronJob ve que la ejecución de las 3:00 se retrasó 1200 segundos, más del plazo de 600, y la salta, porque ya es demasiado tarde para ella; con el campo sin fijar, no hay plazo y la crea en cuanto el controlador vuelve, a las 3:20, con 20 minutos de retraso, salvo que se hayan acumulado más de 100 ejecuciones perdidas, en cuyo caso se niega a empezar. (d) Porque es segura ante repeticiones: el nombre del archivo lleva la fecha con segundos y cada ejecución escribe un archivo distinto, así que dos ejecuciones dejan dos archivos y no se corrompe ni se pierde nada (no es idempotente en sentido estricto: el resultado no es el mismo que con una sola).»
Borrar el clúster
Antes de borrar, el estado final. Primero se retira el Pod depurar, que no es parte de los archivos de la lección (ctx && kubectl delete pod depurar -n revisor --wait=false); por eso, en la salida de abajo, aparece Terminating en la lista de Pods. Si la cadena de la lección se siguió en orden y los experimentos se deshicieron, el clúster debe ser exactamente lo que dicen los archivos. Es el kubectl diff de la Lección 5, ahora al revés: sin diferencias, sale con 0.
$ kubectl diff -k . >/dev/null && echo 'sin diferencias con los archivos'
sin diferencias con los archivos
$ kubectl get pvc,cronjob -n revisor
NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS VOLUMEATTRIBUTESCLASS AGE
persistentvolumeclaim/reportes Bound pvc-54c37c25-a11a-4a37-b2da-ae67d36aea40 100Mi RWO standard <unset> 12m
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
cronjob.batch/reporte */5 * * * * <none> False 0 3m53s 14m
$ kubectl get jobs -n revisor
NAME STATUS COMPLETIONS DURATION AGE
reporte-29877090 Complete 1/1 4s 8m53s
reporte-29877095 Complete 1/1 4s 3m53s
reporte-manual-3 Complete 1/1 8s 12m
$ kubectl get pods -n revisor
NAME READY STATUS RESTARTS AGE
depurar 1/1 Terminating 0 14m
prueba-6899c74865-9dckl 1/1 Running 0 15m
reporte-29877090-8ms5h 0/1 Completed 0 8m53s
reporte-29877095-wbx85 0/1 Completed 0 3m53s
reporte-manual-3-2657n 0/1 Completed 0 12m
revisor-6b7f4d564d-mgzwr 1/1 Running 0 10m
revisor-6b7f4d564d-nxzdj 1/1 Running 0 10m
revisor-6b7f4d564d-tk4tr 1/1 Running 0 10m
$ curl -s -m 5 http://172.28.255.200/salud # desde fuera del clúster
{"estado":"vivo","version":"1.6.0"}
La solicitud reportes está unida, el CronJob reporte está programado y ya corrió solo (los Jobs reporte-29877090 y reporte-29877095, de las 23:30 y las 23:35, los creó él), y la puerta del Gateway sigue contestando desde fuera. Ese es el estado con el que empieza la Lección 9. (El único Pod Terminating es depurar, que se borró justo antes de esta comprobación; los Pods lento-… del Ejercicio 3 ya no están porque el guion esperó a que desaparecieran antes de medir.)
Cuando termines, borra el clúster de la lección. No dejes nada vivo en la máquina:
$ kind delete cluster --name curso-k8s-l8
Deleting cluster "curso-k8s-l8" ...
Deleted nodes: ["curso-k8s-l8-worker2" "curso-k8s-l8-control-plane" "curso-k8s-l8-worker"]
(borrar tardó 4 s)
$ kind get clusters | grep -c curso-k8s-l8
0
(La línea entre paréntesis la escribe el guion, que mide el borrado.) El --name es obligatorio: sin él, kind borraría el clúster llamado kind, que puede ser de otra persona o de otro proyecto. Con el clúster se va la carpeta /mnt/disco-reportes del nodo y los volúmenes que quedaran: todo vivía dentro de los contenedores de los nodos.
Quedan en tu máquina la carpeta temporal del kubeconfig y los dos token.txt. Se borran así, desde programas/08-datos-y-tareas:
# Los dos tokens de la lección: son de mentira, pero un token suelto no se deja.
rm -f -- token.txt punto-de-partida/token.txt
# La carpeta temporal, y SOLO si es la que creó mktemp en 8.1.1.
case "$L8_TMP" in
/tmp/curso-k8s-l8.?*) rm -rf -- "$L8_TMP" ;;
*) echo "No borro «$L8_TMP»: no es la carpeta de esta lección" ;;
esac
unset KUBECONFIG L8_TMP
unset -f ctx
El case solo deja pasar una ruta que empiece con /tmp/curso-k8s-l8. y tenga algo después, la forma exacta de la que creaste en 8.1.1. Si L8_TMP está vacía (por ejemplo, porque estás en otra terminal, donde nunca la definiste) o vale otra cosa, no borra nada y te dice por qué. Es la misma protección que usa correr.sh al limpiar: un rm -rf que se puede copiar y pegar tiene que protegerse solo. Si tu correr.sh usó la red propia de las notas de la corrida, esa red (docker network rm curso-k8s-red-l8) también la borras tú, y solo esa.
Cómo sé que lo logré
- Explicas, con la medición de 8.2, qué sobrevive a la caída de un contenedor y qué no sobrevive al borrado de un Pod, y por qué.
- Distingues
PersistentVolume,PersistentVolumeClaimyStorageClass, y dices quién crea cada uno. - Lees
Pendingen una solicitud conWaitForFirstConsumery sabes que no es un error, y leesProvisioningFailedcon una clase inexistente y sabes que sí lo es. - Escribes un
JobconbackoffLimityactiveDeadlineSecondsy dices cuántos Pods como máximo crea y con qué esperas. - Lees un horario cron de cinco campos y escribes el de «cada cinco minutos» y el de «lunes a viernes a las 7:30».
- Dices qué hace
Forbidfrente aAllowyReplace, y por qué la tarea del reporte debe poder correr dos veces. - Provocas y arreglas un
Permission denieden un volumen confsGroup, y dices en qué volumen de la lecciónfsGroupcambió el grupo y en cuál no. - Dices qué se pierde al borrar una solicitud con política
Deletey qué conRetain, y por qué un borrado se queda enTerminating. - Corres
programas/08-datos-y-tareas/correr.sh > /tmp/mi-corrida-l8.txt 2>&1y comparas/tmp/mi-corrida-l8.txtcontraprogramas/08-datos-y-tareas/correr.salida.txt(no redirijas a ese archivo: es la referencia) en lo que no cambia entre corridas: tres nodosReadyenv1.37.0; seis renglonesnot yet present on node … loading...;-rw-------en los dostoken.txt; en elemptyDir, el directorio2777con grupo65532y un soloreporte.jsonde unos 92 bytes en una de las tres réplicas (93 si el tiempo medido tiene dos dígitos);RESTARTS 1yError 137tras detener el contenedor, con el archivo intacto; la carpeta del volumen que ya no existe tras borrar el Pod; la clasestandard (default)conrancher.io/local-path,DeleteyWaitForFirstConsumer;No resources foundenkubectl get pv; la solicitudPendingcon el eventowaiting for first consumer to be created before binding; el JobsaludconCompletey{"estado":"vivo","version":"1.6.0"}; el Pod de la tareaPendingcon un nodo nominado y el eventoProvisioning(ProvisioningSucceededllega un instante después y puede o no caer en la captura); la solicitudBound,100Mi,RWO,standard; el volumen atado a un nodo, con la ruta/var/local-path-provisioner/pvc-…_revisor_reportesy permiso777 0:0; un archivoreporte-…Z.jsonen la primera tarea y dos en la segunda; Jobs de CronJob con nombre<cronjob>-<número>de ocho dígitos y la anotacióncronjob-scheduled-timestampen:00;Permission deniedy755 0:0sinfsGroup,Completedy2775 0:101con él;ReleasedconRetain;Terminatingcon el finalizadorkubernetes.io/pvc-protectionhasta borrar los Jobs;BackoffLimitExceededcon tres Pods enError, código28;150000000bytes escritos en un volumen de100Mi;ProvisioningFailed … "rapido" not found; las tres réplicas delrevisoren un solo nodo;JobAlreadyActive … concurrency policy is Forbid; ysin diferencias con los archivos. Las IP, los sufijos de los nombres, losuid, las edades, el nodo que recibe el volumen, los segundos exactos, el identificador de cadapvc-…y la hora de los archivos cambian en cada corrida: se observan, no se comparan. - Al terminar,
kind get clustersya no muestracurso-k8s-l8.
Resumen
Responde sin mirar la lección:
- ¿Qué sobrevive a la caída de un contenedor y qué no sobrevive al borrado de un Pod, en un
emptyDir? - ¿Qué papel tiene cada uno de estos tres objetos:
PersistentVolume,PersistentVolumeClaim,StorageClass? - ¿Por qué la solicitud
reportesse queda enPendingal aplicarla, y cuándo deja de estarlo? - ¿Qué significa
ReadWriteOnce, y qué consecuencia tiene sobre dónde pueden correr los Pods que usan el volumen? - ¿En qué se parece y en qué se diferencia un
Jobde un Deployment? - ¿Cuántos Pods crea como máximo un Job con
backoffLimit: 2, y con qué esperas entre ellos? - ¿Qué dicen los cinco campos de
*/5 * * * *? ¿En qué zona horaria corren si no ponestimeZone? - ¿Qué hace
concurrencyPolicy: Forbidy qué evento deja? - ¿Qué quiere decir que una tarea sea idempotente, y por qué importa en un CronJob?
- ¿Qué hace
fsGroup, en qué volumen de la lección cambió algo y en cuál no? - ¿Por qué una solicitud puede quedarse
Terminating? ¿Cómo se resuelve sin forzar nada? - ¿Qué diferencia hay entre
DeleteyRetain?
Fuentes
Todas consultadas el 21 de octubre de 2026.
- Kubernetes, «Persistent Volumes»: las definiciones de
PersistentVolumeyPersistentVolumeClaim; el aprovisionamiento estático y el dinámico; la unión de uno a uno y que una solicitud sin volumen que coincida espera indefinidamente; la protección de objetos de almacenamiento en uso y su definición de «en uso»; las políticas de reclamaciónRetainyDelete; las fasesBoundyReleased; los modos de acceso y la definición deReadWriteOnce(un nodo, varios Pods). - Kubernetes, «Storage Classes»: qué es una clase; la clase por omisión; la política de reclamación por omisión (
Delete); el modo de uniónImmediatefrente aWaitForFirstConsumer. - Kubernetes, «Volumes»:
emptyDir(se crea al asignar el Pod a un nodo, se borra al quitar el Pod, sobrevive a la caída de un contenedor, el permiso0777por omisión) y el volumenlocal(solo estático,nodeAffinityobligatoria). - Kubernetes, «Jobs»: la definición de Job;
restartPolicyNeveruOnFailure;backoffLimit(6 por omisión) y la espera exponencial;activeDeadlineSecondsy su precedencia; las causas de fallo (BackoffLimitExceeded,DeadlineExceeded); las condiciones terminales retrasadas desde la 1.31; que el mismo programa puede arrancar dos veces. - Kubernetes, «CronJob»: el formato cron de cinco campos; la zona horaria y
.spec.timeZone;concurrencyPolicy; los límites de historial (3 y 1 por omisión);startingDeadlineSecondsy el tope de 100 ejecuciones perdidas;suspend; el nombre de 52 caracteres como máximo; la anotacióncronjob-scheduled-timestampdesde la 1.32; el aviso de que los Jobs deben ser idempotentes. - Kubernetes, «Configure a Security Context for a Pod or Container»:
fsGroupy el grupo del volumen; los volúmenes «que soportan administración de dueños»;fsGroupChangePolicy. - Kubernetes v1.37, registro de cambios: la condición
Unusedde las solicitudes de volumen (PersistentVolumeClaimUnusedSinceTime, beta y activada desde la 1.37). - Local Path Provisioner (Rancher), README: el aprovisionador que usa la clase
standardde kind; el volumen como carpetahostPathdel nodo; que la capacidad se ignora; el permiso0777de la carpeta; el borrado del contenido al borrar el volumen. - kind, «Configuration»: los montajes extra (
extraMounts), la forma de pasar almacenamiento de tu máquina a un nodo de kind para que los datos persistan más allá del contenedor del nodo. - Kubernetes, «StatefulSets»: identidad de red estable y almacenamiento estable por Pod (
volumeClaimTemplates). - Kubernetes, «Volume Snapshots»: las instantáneas de volumen.
- Kubernetes, «Resource Quotas»:
requests.storage, la suma máxima de almacenamiento que pueden pedir las solicitudes de un espacio de nombres. - GitLab, «Postmortem of database outage of January 31»: el relato público, de febrero de 2017, de una pérdida de datos de producción y de los mecanismos de respaldo que no estaban sirviendo (una copia diaria que fallaba en silencio, instantáneas no activadas en los servidores de la base, una réplica rota): la razón para probar los respaldos en vez de suponerlos.
¿Prefiere correo? Escríbanos a hola@habil.mx