Todo lo que hemos desplegado hasta ahora comparte una premisa: el proceso no debe terminar nunca. tienda-web sirve páginas indefinidamente, api-reservas atiende peticiones indefinidamente, el recolector de logs de la lección anterior lee ficheros indefinidamente. Si alguno de esos contenedores sale, aunque sea con código 0, Kubernetes lo considera una anomalía y lo reinicia.
Pero una parte importante del trabajo real de una plataforma consiste precisamente en terminar: generar el informe de ocupación de anoche, migrar el esquema de la base de datos antes de un despliegue, reprocesar un lote de billetes con un precio mal calculado. Para esas cargas Kubernetes tiene dos objetos: el Job, que ejecuta algo hasta que se completa con éxito, y el CronJob, que crea Jobs según un calendario.
Con ellos desplegaremos por fin el último componente pendiente de Rutas Norte: informes-ocupacion, la tarea nocturna que consulta postgres-reservas y deja un informe en disco.
Contenido
- Cargas que terminan frente a cargas que no terminan
- El objeto Job: anatomía y campos de control
restartPolicy:Neverfrente aOnFailure- Los tres patrones de Job
completionMode: Indexedy el reparto determinista- El objeto CronJob: calendario y política de concurrencia
- Caso central:
informes-ocupacioncomo CronJob nocturno - Segundo ejemplo: un Job de migración de esquema
- Depuración de trabajos fallidos y limpieza de historial
- Cargas que terminan frente a cargas que no terminan
La diferencia arranca en el propio contenedor. Un proceso de larga duración se queda bloqueado en un bucle de eventos; un proceso por lotes hace su trabajo, escribe un resultado y llama a exit(0).
Kubernetes distingue ambos casos por el controlador que gestiona el pod, no por el contenido de la imagen.
| Aspecto | Deployment / StatefulSet / DaemonSet | Job / CronJob |
|---|---|---|
| Duración esperada | Indefinida | Finita |
| Salida con código 0 | Anomalía; se reinicia | Éxito; el Job se marca completado |
restartPolicy permitida |
Solo Always |
Solo Never u OnFailure |
| Estado terminal | No existe | Complete o Failed |
| Qué mide el éxito | Que siga vivo | Que haya terminado bien N veces |
| Recogida de resultados | Logs continuos | Logs del pod terminado, hasta que se limpian |
Un detalle que suele sorprender: el pod de un Job terminado no desaparece. Queda en estado Completed, sin consumir CPU ni memoria, para que puedas leer sus logs. Ese es también el motivo por el que un clúster mal mantenido acumula miles de pods Completed, algo que resolveremos en el apartado 9.
graph LR
subgraph Indefinidas
D[Deployment] --> P1[Pod Running siempre]
P1 -->|sale| P1
end
subgraph Finitas
C[CronJob] -->|cada noche| J[Job]
J --> P2[Pod]
P2 -->|exit 0| OK[Completed]
P2 -->|exit != 0| RT[Reintento]
RT --> P2
RT -->|backoffLimit agotado| KO[Failed]
end
- El objeto Job: anatomía y campos de control
El Job más simple posible:
apiVersion: batch/v1
kind: Job
metadata:
name: calculo-tarifas
namespace: rutas-norte-dev
spec:
template:
spec:
restartPolicy: Never
containers:
- name: calculo
image: busybox:1.36
command: ["sh", "-c", "echo 'Recalculando tarifas...'; sleep 5; echo 'Hecho'"]Obsérvese que no hay selector: el Job genera el suyo automáticamente con una etiqueta única (batch.kubernetes.io/controller-uid). Es una de las pocas cargas donde no hay que declararlo, y es mejor no intentarlo: un selector escrito a mano que colisione con otro Job provoca que un controlador adopte pods ajenos.
Los campos que gobiernan el comportamiento:
| Campo | Por defecto | Qué hace |
|---|---|---|
completions |
1 | Cuántos pods deben terminar con éxito para dar el Job por completado |
parallelism |
1 | Cuántos pods pueden correr simultáneamente |
backoffLimit |
6 | Cuántos fallos se toleran antes de marcar el Job como Failed |
activeDeadlineSeconds |
sin límite | Segundos máximos desde el inicio; al superarlos el Job se corta aunque no haya fallado |
ttlSecondsAfterFinished |
sin límite | Segundos tras terminar (con éxito o no) antes de que el Job y sus pods se borren solos |
completionMode |
NonIndexed |
NonIndexed o Indexed (apartado 5) |
suspend |
false |
Si es true, el Job no crea pods; sirve para encolar y liberar después |
podFailurePolicy |
ninguna | Reglas finas por código de salida (estable desde 1.31) |
backoffLimit y el retroceso exponencial
Cuando un pod falla, el Job crea otro, pero no inmediatamente: espera 10 s, luego 20 s, 40 s, 80 s… hasta un máximo de 6 minutos. Esa espera creciente evita que un fallo permanente —una contraseña incorrecta, un host inalcanzable— consuma el clúster con miles de intentos por minuto.
Al llegar a backoffLimit fallos, el Job pasa a Failed con la razón BackoffLimitExceeded y deja de crear pods.
Valor práctico para Rutas Norte:
- Tareas idempotentes con dependencias de red (consultar la pasarela de pagos):
backoffLimit: 6, el valor por defecto, tiene sentido; un fallo transitorio se recupera solo. - Migraciones de esquema:
backoffLimit: 0. Si falla, quieres saberlo y mirar, no que se reintente cinco veces sobre una base de datos a medio migrar.
activeDeadlineSeconds
Es un cortafuegos temporal. Se cuenta desde que el Job arranca e incluye los reintentos:
Si se agota, los pods activos se terminan y el Job queda Failed con razón DeadlineExceeded. Es la protección contra el proceso que no falla pero tampoco avanza: una consulta bloqueada por un lock en postgres-reservas, por ejemplo. activeDeadlineSeconds tiene prioridad sobre backoffLimit: manda el que se cumpla primero.
ttlSecondsAfterFinished
Pasado ese tiempo desde que el Job alcanza estado terminal, el controlador de TTL borra el Job y sus pods. Es la forma correcta de evitar la acumulación de objetos. Ponlo siempre en Jobs creados por automatismos; deja tiempo suficiente para que alguien pueda leer los logs de un fallo nocturno (24 horas es un buen valor).
restartPolicy: Never frente a OnFailure
restartPolicy: Never frente a OnFailureLa API solo admite estos dos valores en la plantilla de un Job. Always se rechaza, porque contradice la idea misma de una tarea que termina.
La diferencia entre ambos no es cosmética: cambia qué se reinicia.
restartPolicy: Never |
restartPolicy: OnFailure |
|
|---|---|---|
| Qué ocurre al fallar el contenedor | El pod queda Failed; el Job crea un pod nuevo |
El kubelet reinicia el contenedor dentro del mismo pod |
| Contador que se incrementa | backoffLimit del Job |
restartCount del contenedor (y también backoffLimit) |
| Rastro que queda | Un pod por intento, todos consultables | Un solo pod; los logs de intentos previos se pierden salvo con --previous |
| Nodo del reintento | Puede ser otro | Siempre el mismo |
Volumen emptyDir |
Vacío en cada intento | Se conserva entre reinicios |
Ejemplo de lo que se ve con Never:
NAME READY STATUS RESTARTS AGE
migracion-esquema-4kx2p 0/1 Error 0 3m
migracion-esquema-9dz7w 0/1 Error 0 2m
migracion-esquema-t6m1c 0/1 Error 0 1mTres pods, uno por intento, cada uno con sus logs íntegros. Con OnFailure verías:
Un solo pod con RESTARTS: 3, y para ver el log del intento anterior harías falta kubectl logs <pod> --previous.
Recomendación para Rutas Norte: usa Never salvo motivo concreto. Conservar un pod por intento hace la depuración mucho más honesta, y en una tarea nocturna que ha fallado a las 03:00 querrás poder leer exactamente qué dijo el primer intento. OnFailure es preferible cuando el arranque del contenedor es muy costoso —descargar un modelo, restaurar una caché— y quieres aprovechar el estado de un emptyDir entre reintentos.
Un matiz importante: si el nodo entero falla, el pod se recrea en ambos casos, porque el que actúa es el controlador del Job, no el kubelet.
- Los tres patrones de Job
Con completions y parallelism se construyen tres patrones que cubren casi todo el trabajo por lotes.
| Patrón | completions |
parallelism |
Cuándo |
|---|---|---|---|
| Tarea única | 1 (o ausente) | 1 (o ausente) | Migración, informe, tarea puntual |
| Paralelismo fijo | N | M (≤ N) | N unidades de trabajo conocidas de antemano |
| Cola de trabajo | ausente | M | Los trabajadores consumen de una cola externa hasta vaciarla |
Patrón 1: tarea única
El más habitual. Un pod, una vez, con éxito.
apiVersion: batch/v1
kind: Job
metadata:
name: informe-puntual-julio
namespace: rutas-norte-dev
labels:
app: informes-ocupacion
app.kubernetes.io/part-of: rutas-norte
entorno: dev
spec:
backoffLimit: 2
activeDeadlineSeconds: 900
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app: informes-ocupacion
entorno: dev
spec:
restartPolicy: Never
containers:
- name: generador
image: postgres:16.4
command: ["sh", "-c", "psql -h postgres-reservas -U rutasnorte -d reservas -c 'SELECT count(*) FROM reservas'"]Patrón 2: paralelismo fijo con número de finalizaciones
Rutas Norte tiene 12 líneas de autobús y quiere recalcular la ocupación histórica de todas. Son 12 unidades de trabajo, y no quiere más de 4 consultas simultáneas contra postgres-reservas:
apiVersion: batch/v1
kind: Job
metadata:
name: recalculo-ocupacion-lineas
namespace: rutas-norte-dev
spec:
completions: 12 # 12 pods deben terminar bien
parallelism: 4 # como mucho 4 a la vez
backoffLimit: 6
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
containers:
- name: recalculo
image: busybox:1.36
command: ["sh", "-c", "echo 'Procesando una línea'; sleep 10"]El Job va manteniendo cuatro pods vivos: en cuanto uno termina, arranca el siguiente, hasta acumular 12 éxitos. parallelism es el mecanismo de control de carga: sin él, doce consultas simultáneas podrían saturar la base de datos.
Observando la evolución:
NAME COMPLETIONS DURATION AGE
recalculo-ocupacion-lineas 0/12 3s 3s
recalculo-ocupacion-lineas 4/12 15s 15s
recalculo-ocupacion-lineas 8/12 27s 27s
recalculo-ocupacion-lineas 12/12 39s 39sEl problema de este patrón en su forma básica: todos los pods ejecutan el mismo comando. No hay nada que le diga a cada uno qué línea le toca. Eso lo resuelve el apartado 5.
Patrón 3: cola de trabajo
Si se omite completions pero se fija parallelism, el Job entra en modo cola: arranca M trabajadores y considera el trabajo terminado cuando uno cualquiera de ellos sale con éxito estando el resto ya finalizado. Los trabajadores deben coordinarse por sí mismos a través de una cola externa (Redis, RabbitMQ, una tabla con SELECT ... FOR UPDATE SKIP LOCKED).
apiVersion: batch/v1
kind: Job
metadata:
name: reproceso-billetes-cola
namespace: rutas-norte-dev
spec:
parallelism: 5 # sin completions: modo cola de trabajo
backoffLimit: 10
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
containers:
- name: trabajador
image: registry.rutasnorte.example/worker-billetes:1.4.2
env:
- name: COLA_URL
value: "redis://redis-cache.rutas-norte-dev.svc.cluster.local:6379/3"
command: ["/app/consumir-cola"]Cada trabajador saca un billete de la cola de redis-cache, lo reprocesa y repite hasta que la cola está vacía, momento en el que sale con código 0. Es el patrón más flexible y el que peor tolera errores de diseño: si un trabajador muere a mitad de una unidad, esa unidad debe volver a la cola, y eso lo tiene que garantizar la aplicación.
completionMode: Indexed y el reparto determinista
completionMode: Indexed y el reparto deterministaEl modo indexado resuelve la carencia del patrón 2: dar a cada pod un número que le diga qué porción del trabajo le corresponde.
apiVersion: batch/v1
kind: Job
metadata:
name: recalculo-ocupacion-lineas
namespace: rutas-norte-dev
spec:
completionMode: Indexed # <- la clave
completions: 12
parallelism: 4
backoffLimit: 6
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
containers:
- name: recalculo
image: busybox:1.36
command:
- sh
- -c
- |
LINEA=$(( JOB_COMPLETION_INDEX + 1 ))
echo "Pod con índice $JOB_COMPLETION_INDEX -> recalculando la línea $LINEA de Rutas Norte"
sleep 5
echo "Línea $LINEA recalculada"Con Indexed, Kubernetes asigna a cada pod un índice único de 0 a completions - 1, y lo expone de dos formas:
- La variable de entorno
JOB_COMPLETION_INDEX. - La anotación
batch.kubernetes.io/job-completion-indexdel pod.
Además, los nombres de los pods incorporan el índice, lo que hace la observación trivial:
kubectl get pods -n rutas-norte-dev -l job-name=recalculo-ocupacion-lineas \
--sort-by=.metadata.nameNAME READY STATUS RESTARTS AGE
recalculo-ocupacion-lineas-0-h2k9x 0/1 Completed 0 62s
recalculo-ocupacion-lineas-1-m4p2t 0/1 Completed 0 62s
recalculo-ocupacion-lineas-2-w8j5r 0/1 Completed 0 61s
...
recalculo-ocupacion-lineas-11-z3q7n 0/1 Completed 0 18sPropiedades que hacen a Indexed muy superior al modo por defecto cuando hay que repartir trabajo:
- Determinismo: el índice 7 procesa siempre la línea 8, se ejecute cuando se ejecute.
- Reintentos correctos: si el pod del índice 7 falla, el reintento vuelve a recibir el índice 7, no otro. Ninguna unidad se queda sin procesar ni se procesa dos veces.
- Trazabilidad: el nombre del pod te dice qué unidad de trabajo miró ese pod.
Si necesitas repartir un rango, el índice basta para calcularlo:
TOTAL=12000
POR_POD=$(( TOTAL / COMPLETIONS ))
DESDE=$(( JOB_COMPLETION_INDEX * POR_POD ))
HASTA=$(( DESDE + POR_POD ))
echo "Procesando reservas de la $DESDE a la $HASTA"
- El objeto CronJob: calendario y política de concurrencia
Un CronJob no ejecuta nada por sí mismo: crea Jobs según un calendario. Y cada Job crea sus pods. Son tres niveles, y entenderlos evita mucha confusión al depurar.
graph LR CJ[CronJob<br/>informes-ocupacion] -->|03:15 del día 1| J1[Job informes-ocupacion-28935120] CJ -->|03:15 del día 2| J2[Job informes-ocupacion-28936560] J1 --> P1[Pod ...-28935120-4kx2p] J2 --> P2[Pod ...-28936560-9dz7w]
La sintaxis del schedule
Cinco campos separados por espacios, en el formato cron de siempre:
┌───────────── minuto (0 - 59)
│ ┌───────────── hora (0 - 23)
│ │ ┌───────────── día del mes (1 - 31)
│ │ │ ┌───────────── mes (1 - 12)
│ │ │ │ ┌───────────── día de la semana (0 - 6, domingo = 0)
│ │ │ │ │
* * * * *Operadores admitidos en cada campo:
| Operador | Significado | Ejemplo |
|---|---|---|
* |
Cualquier valor | * * * * * = cada minuto |
, |
Lista | 0 8,14,20 * * * = a las 8, 14 y 20 |
- |
Rango | 0 9-17 * * 1-5 = cada hora de 9 a 17, de lunes a viernes |
/ |
Paso | */15 * * * * = cada 15 minutos |
Ejemplos con significado para Rutas Norte:
| Expresión | Cuándo se ejecuta |
|---|---|
15 3 * * * |
Todos los días a las 03:15 |
0 * * * * |
En punto de cada hora |
*/10 * * * * |
Cada 10 minutos |
0 4 * * 1 |
Los lunes a las 04:00 |
0 5 1 * * |
El día 1 de cada mes a las 05:00 |
30 2 * * 0 |
Los domingos a las 02:30 |
timeZone
Sin este campo, el calendario se interpreta en la zona horaria del kube-controller-manager, que en la mayoría de clústeres es UTC. Para Rutas Norte, que opera en España, eso significa que 15 3 * * * se ejecutaría a las 04:15 en horario de verano y a las 03:15 en invierno: una hora distinta según la época del año, justo lo que no quieres en una ventana nocturna.
El campo timeZone es estable desde Kubernetes 1.27 y acepta cualquier identificador de la base de datos IANA. Úsalo siempre; es una de las mejoras más útiles y menos conocidas de la API de batch.
concurrencyPolicy
¿Qué pasa si llega la hora de la siguiente ejecución y la anterior aún no ha terminado?
| Valor | Comportamiento | Cuándo usarlo |
|---|---|---|
Allow (por defecto) |
Se crea el Job nuevo; conviven | Tareas independientes y ligeras |
Forbid |
Se omite la ejecución nueva; se registra el salto | Tareas que no pueden solaparse |
Replace |
Se cancela la anterior y arranca la nueva | Tareas donde solo importa el resultado más reciente |
El caso real que justifica Forbid en Rutas Norte: informes-ocupacion se ejecuta cada noche y hace consultas agregadas pesadas sobre postgres-reservas. Si una noche de puente el volumen de reservas es tan grande que el informe tarda 26 horas, con Allow a la noche siguiente habría dos informes agregando simultáneamente sobre la misma base de datos: el doble de carga en el peor momento posible, y dos ficheros escribiendo en el mismo PVC. Con Forbid, la segunda ejecución simplemente no se lanza y el evento queda registrado para que alguien investigue por qué el informe tarda tanto.
Replace sería adecuado para, por ejemplo, un recálculo de disponibilidad de plazas cada cinco minutos: si el cálculo de las 10:00 aún corre a las 10:05, su resultado ya está obsoleto y es mejor cancelarlo y empezar con datos frescos.
startingDeadlineSeconds y el clúster caído
Supongamos que el plano de control estuvo caído entre las 03:00 y las 05:00 por un mantenimiento, y el CronJob debía disparar a las 03:15. ¿Qué ocurre al volver?
- Sin
startingDeadlineSeconds: el controlador ve una ejecución perdida y la lanza en cuanto puede, a las 05:00. Un informe "de las 03:15" ejecutándose a las 05:00 puede ser inofensivo o desastroso según lo que haga. - Con
startingDeadlineSeconds: 600: la ejecución solo se lanza si han pasado menos de 600 segundos desde la hora prevista. A las 05:00 han pasado 6.300 s, así que se omite y se registra el salto.
Hay además un mecanismo de protección que conviene conocer: si el controlador detecta más de 100 ejecuciones perdidas dentro de la ventana de la deadline, deja de programar el CronJob por completo y emite este evento:
Warning FailedNeedsStart cronjob-controller Cannot determine if job needs to be started:
too many missed start times (> 100). Set or decrease .spec.startingDeadlineSeconds or check
clock skewEs el motivo por el que un CronJob que no se ha ejecutado en meses puede quedarse permanentemente muerto. Fijar startingDeadlineSeconds a un valor razonable lo evita.
Historial y suspensión
spec:
successfulJobsHistoryLimit: 3 # por defecto 3
failedJobsHistoryLimit: 5 # por defecto 1
suspend: falseEl controlador conserva los N Jobs más recientes de cada clase y borra el resto con sus pods. Consejo práctico: sube failedJobsHistoryLimit. El valor por defecto de 1 significa que si el informe falla tres noches seguidas, solo conservas los logs de la última, justo cuando querrías comparar las tres.
suspend: true detiene la programación sin borrar el objeto. Es la maniobra correcta durante una ventana de mantenimiento de postgres-reservas:
kubectl patch cronjob informes-ocupacion -n rutas-norte-pro -p '{"spec":{"suspend":true}}'
# ... mantenimiento ...
kubectl patch cronjob informes-ocupacion -n rutas-norte-pro -p '{"spec":{"suspend":false}}'Ojo: mientras está suspendido, las ejecuciones perdidas no se recuperan al reanudar; el controlador reanuda a partir de la siguiente hora prevista.
- Caso central:
informes-ocupacion como CronJob nocturno
informes-ocupacion como CronJob nocturnoEs el momento de desplegar el componente que llevamos seis módulos citando y nunca creando. informes-ocupacion consulta cada madrugada las reservas del día anterior en postgres-reservas, calcula la ocupación por línea y deja un fichero CSV en un PVC.
El PVC del informe
Los informes se acumulan y se conservan un tiempo. Usamos la clase estándar, no la rápida: no hay exigencias de latencia.
# k8s/base/informes-ocupacion-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: informes-ocupacion-datos
namespace: rutas-norte-pro
labels:
app: informes-ocupacion
app.kubernetes.io/part-of: rutas-norte
entorno: pro
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: rutasnorte-estandar
resources:
requests:
storage: 5GiLa ServiceAccount
Siguiendo la práctica de 03-06: cuenta dedicada y sin token montado, porque el trabajo no habla con la API de Kubernetes.
# k8s/base/informes-ocupacion-sa.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: informes-ocupacion
namespace: rutas-norte-pro
labels:
app: informes-ocupacion
app.kubernetes.io/part-of: rutas-norte
entorno: pro
automountServiceAccountToken: falseLa NetworkPolicy
En 04-06 dejamos rutas-norte-pro con un deny-all por defecto y fuimos autorizando conversación a conversación. informes-ocupacion es una conversación nueva y sin esta política no atravesará el muro: los pods arrancarán, la consulta se quedará colgada y el Job fallará por activeDeadlineSeconds sin ningún mensaje que explique la causa. Es uno de los fallos más difíciles de diagnosticar de todo el curso.
Hacen falta dos reglas: salida desde el informe y entrada en la base de datos.
# k8s/entornos/pro/informes-ocupacion-networkpolicy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: informes-ocupacion-salida-postgres
namespace: rutas-norte-pro
labels:
app: informes-ocupacion
app.kubernetes.io/part-of: rutas-norte
entorno: pro
spec:
podSelector:
matchLabels:
app: informes-ocupacion
entorno: pro
policyTypes: ["Egress"]
egress:
# Resolución DNS: sin esto no se resuelve el nombre del servicio
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
# Acceso a la base de datos
- to:
- podSelector:
matchLabels:
app: postgres-reservas
entorno: pro
ports:
- protocol: TCP
port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: postgres-reservas-entrada-informes
namespace: rutas-norte-pro
labels:
app: postgres-reservas
app.kubernetes.io/part-of: rutas-norte
entorno: pro
spec:
podSelector:
matchLabels:
app: postgres-reservas
entorno: pro
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector:
matchLabels:
app: informes-ocupacion
entorno: pro
ports:
- protocol: TCP
port: 5432La regla de DNS es la que más se olvida. Con deny-all, un pod sin permiso de salida al puerto 53 de CoreDNS no resuelve postgres-reservas y falla con un error de host desconocido que parece un problema de configuración de la aplicación.
El CronJob
# k8s/base/informes-ocupacion-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: informes-ocupacion
namespace: rutas-norte-pro
labels:
app: informes-ocupacion
app.kubernetes.io/part-of: rutas-norte
entorno: pro
spec:
schedule: "15 3 * * *"
timeZone: "Europe/Madrid"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
suspend: false
jobTemplate:
metadata:
labels:
app: informes-ocupacion
app.kubernetes.io/part-of: rutas-norte
entorno: pro
spec:
backoffLimit: 2
activeDeadlineSeconds: 5400 # 90 minutos como techo absoluto
ttlSecondsAfterFinished: 172800 # se autolimpia a las 48 h
template:
metadata:
labels:
app: informes-ocupacion
app.kubernetes.io/part-of: rutas-norte
entorno: pro
spec:
restartPolicy: Never
serviceAccountName: informes-ocupacion
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10001
fsGroup: 10001
containers:
- name: generador
image: postgres:16.4
command:
- /bin/bash
- -c
- |
set -euo pipefail
AYER=$(date -d 'yesterday' +%Y-%m-%d)
SALIDA="/informes/ocupacion-${AYER}.csv"
echo "Generando informe de ocupación de ${AYER}"
psql -h postgres-reservas -U "${PGUSER}" -d reservas \
--csv --no-psqlrc -o "${SALIDA}" <<SQL
SELECT l.codigo AS linea,
COUNT(r.id) AS billetes,
SUM(r.plazas) AS plazas_ocupadas,
ROUND(100.0 * SUM(r.plazas) / NULLIF(SUM(s.capacidad), 0), 2) AS pct_ocupacion
FROM reservas r
JOIN salidas s ON s.id = r.salida_id
JOIN lineas l ON l.id = s.linea_id
WHERE s.fecha = DATE '${AYER}'
GROUP BY l.codigo
ORDER BY pct_ocupacion DESC;
SQL
echo "Informe escrito en ${SALIDA} ($(wc -l < "${SALIDA}") líneas)"
find /informes -name 'ocupacion-*.csv' -mtime +90 -delete
echo "Informes de más de 90 días eliminados"
env:
- name: PGUSER
valueFrom:
secretKeyRef:
name: postgres-reservas-credenciales
key: usuario
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: postgres-reservas-credenciales
key: password
- name: PGCONNECT_TIMEOUT
value: "10"
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
volumeMounts:
- name: informes
mountPath: /informes
volumes:
- name: informes
persistentVolumeClaim:
claimName: informes-ocupacion-datosRepaso de las decisiones, que resumen media docena de lecciones anteriores:
schedulea las 03:15 contimeZone: Europe/Madrid: fuera de las horas de venta y con hora estable todo el año.concurrencyPolicy: Forbid: dos informes simultáneos sobre la misma base de datos y el mismo PVC serían nocivos.activeDeadlineSeconds: 5400: si la consulta se queda bloqueada por un lock, el Job muere a los 90 minutos en vez de arrastrarse hasta la mañana.backoffLimit: 2: un reintento es útil ante un corte de red; cinco solo prolongan la carga.ttlSecondsAfterFinished: 172800más los límites de historial: dos capas de limpieza automática.- Credenciales por Secret (03-02), nunca en el manifiesto.
PGPASSWORDyPGUSERson variables quepsqlreconoce directamente. - Recursos declarados: sin ellos el pod sería
BestEffort(03-05) y el primer nodo con presión de memoria lo desalojaría. securityContextcon usuario no root yfsGrouppara poder escribir en el PVC.- Retención de 90 días aplicada en el propio script: el PVC es finito y los informes contienen datos agregados, no personales, pero acumular sin límite es una fuga de espacio garantizada.
Desplegar y probar sin esperar a las tres de la mañana
kubectl apply -f k8s/base/informes-ocupacion-pvc.yaml
kubectl apply -f k8s/base/informes-ocupacion-sa.yaml
kubectl apply -f k8s/entornos/pro/informes-ocupacion-networkpolicy.yaml
kubectl apply -f k8s/base/informes-ocupacion-cronjob.yaml
kubectl get cronjob informes-ocupacion -n rutas-norte-proNAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
informes-ocupacion 15 3 * * * Europe/Madrid False 0 <none> 20sEl comando imprescindible: disparar una ejecución manual a partir del CronJob.
kubectl wait --for=condition=complete job/informes-prueba-manual \
-n rutas-norte-pro --timeout=600s
kubectl logs -n rutas-norte-pro job/informes-prueba-manualGenerando informe de ocupación de 2026-08-04
Informe escrito en /informes/ocupacion-2026-08-04.csv (13 líneas)
Informes de más de 90 días eliminadoskubectl create job --from=cronjob/... copia el jobTemplate tal cual, así que prueba exactamente lo que se ejecutará de madrugada, incluidas la ServiceAccount, la NetworkPolicy y los volúmenes. Es la forma correcta de validar un CronJob nuevo.
- Segundo ejemplo: un Job de migración de esquema
Antes de desplegar la versión 2.5.0 de api-reservas hay que añadir una columna a la tabla de reservas. Es un caso de Job puntual con exigencias muy distintas al informe.
# k8s/entornos/pre/migracion-esquema-2-5-0.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: migracion-esquema-2-5-0
namespace: rutas-norte-pre
labels:
app: api-reservas
app.kubernetes.io/part-of: rutas-norte
entorno: pre
spec:
backoffLimit: 0 # una migración NO se reintenta a ciegas
activeDeadlineSeconds: 600
ttlSecondsAfterFinished: 604800 # 7 días: queremos rastro de las migraciones
template:
metadata:
labels:
app: api-reservas
entorno: pre
spec:
restartPolicy: Never
serviceAccountName: api-reservas
automountServiceAccountToken: false
containers:
- name: migracion
image: registry.rutasnorte.example/api-reservas-migraciones:2.5.0
command:
- /bin/sh
- -c
- |
set -euo pipefail
echo "Migración 2.5.0 sobre reservas"
psql -h postgres-reservas -U "${PGUSER}" -d reservas -v ON_ERROR_STOP=1 <<'SQL'
BEGIN;
ALTER TABLE reservas
ADD COLUMN IF NOT EXISTS canal_venta text NOT NULL DEFAULT 'web';
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_reservas_canal
ON reservas (canal_venta);
INSERT INTO migraciones (version, aplicada_en)
VALUES ('2.5.0', now())
ON CONFLICT (version) DO NOTHING;
COMMIT;
SQL
echo "Migración 2.5.0 aplicada"
env:
- name: PGUSER
valueFrom:
secretKeyRef:
name: postgres-reservas-credenciales
key: usuario
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: postgres-reservas-credenciales
key: password
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256MiContrastes deliberados con informes-ocupacion:
| Decisión | Informe nocturno | Migración de esquema | Motivo |
|---|---|---|---|
backoffLimit |
2 | 0 | Reintentar sobre una base de datos a medio migrar puede empeorar el estado |
ttlSecondsAfterFinished |
48 h | 7 días | El rastro de qué migró y cuándo tiene valor de auditoría |
| Idempotencia | No aplica | IF NOT EXISTS, ON CONFLICT DO NOTHING |
Si alguien relanza el Job, no debe romper nada |
| Transacción | No | BEGIN/COMMIT con ON_ERROR_STOP=1 |
O se aplica todo o no se aplica nada |
ON_ERROR_STOP=1 es imprescindible: sin él, psql continúa tras un error y devuelve código 0, y el Job aparecería como completado con la migración a medias. Es una trampa clásica.
Una advertencia de diseño: este Job desacopla la migración del despliegue. Hay que ejecutarlo, verificar y solo entonces desplegar la versión 2.5.0 de api-reservas. La alternativa —encadenar la migración al arranque de cada pod de la aplicación mediante un initContainer— es el tema de la lección 06-04, y tiene sus propias ventajas e inconvenientes.
- Depuración de trabajos fallidos y limpieza de historial
Cuando un trabajo nocturno falla, el diagnóstico sigue siempre el mismo camino: CronJob → Job → Pod → logs.
Paso 1: estado de los Jobs
NAME STATUS COMPLETIONS DURATION AGE
informes-ocupacion-29175300 Complete 1/1 94s 2d
informes-ocupacion-29176740 Complete 1/1 88s 1d
informes-ocupacion-29178180 Failed 0/1 7m12s 3hEl sufijo numérico es la marca temporal programada, no un identificador aleatorio: los Jobs se ordenan cronológicamente solos.
Paso 2: por qué falló el Job
Name: informes-ocupacion-29178180
Parallelism: 1
Completions: 1
Pods Statuses: 0 Running / 0 Succeeded / 3 Failed
Conditions:
Type Status Reason Message
---- ------ ------ -------
Failed True BackoffLimitExceeded Job has reached the specified backoff limit
Events:
Type Reason Age From Message
---- ------ ---- -------------- -------
Normal SuccessfulCreate 3h job-controller Created pod: informes-ocupacion-29178180-4kx2p
Normal SuccessfulCreate 3h job-controller Created pod: informes-ocupacion-29178180-9dz7w
Normal SuccessfulCreate 3h job-controller Created pod: informes-ocupacion-29178180-t6m1c
Warning BackoffLimitExceeded 3h job-controller Job has reached the specified backoff limitLas razones más frecuentes y lo que significan:
| Razón | Significado | Dónde mirar |
|---|---|---|
BackoffLimitExceeded |
Los pods fallaron más veces de las permitidas | Logs de los pods, especialmente el primero |
DeadlineExceeded |
Se agotó activeDeadlineSeconds |
¿Bloqueo? ¿Consulta lenta? ¿Red cortada? |
FailedCreate |
No se pudo crear el pod | ResourceQuota agotada, PVC inexistente, SA sin permisos |
Paso 3: el pod correcto y sus logs
Con restartPolicy: Never hay un pod por intento. Mira el primero, que suele contener el error original sin ruido de reintentos:
kubectl get pods -n rutas-norte-pro \
-l job-name=informes-ocupacion-29178180 \
--sort-by=.metadata.creationTimestampNAME READY STATUS RESTARTS AGE
informes-ocupacion-29178180-4kx2p 0/1 Error 0 3h
informes-ocupacion-29178180-9dz7w 0/1 Error 0 3h
informes-ocupacion-29178180-t6m1c 0/1 Error 0 3hGenerando informe de ocupación de 2026-08-02
psql: error: connection to server at "postgres-reservas" (10.96.144.21), port 5432 failed:
Connection timed out
Is the server running on that host and accepting TCP/IP connections?Un tiempo de espera agotado hacia la base de datos, con el nombre resuelto correctamente, apunta casi siempre a una NetworkPolicy que no autoriza esa conversación: es exactamente lo que ocurriría si hubiéramos olvidado el manifiesto del apartado 7.
Atajos útiles:
# Logs de todos los pods del Job de una vez
kubectl logs -n rutas-norte-pro job/informes-ocupacion-29178180 --all-containers --tail=50
# Código de salida exacto del contenedor
kubectl get pod informes-ocupacion-29178180-4kx2p -n rutas-norte-pro \
-o jsonpath='{.status.containerStatuses[0].state.terminated.exitCode}{"\n"}'
# Con restartPolicy: OnFailure, el log del intento anterior
kubectl logs -n rutas-norte-pro <pod> --previousCódigos de salida que conviene reconocer:
| Código | Causa habitual |
|---|---|
| 1 | Error genérico de la aplicación |
| 2 | Uso incorrecto de un comando de shell |
| 126 | El comando existe pero no es ejecutable (permisos) |
| 127 | Comando no encontrado (típico de un command mal escrito) |
| 137 | SIGKILL: casi siempre OOMKilled, se superó limits.memory |
| 143 | SIGTERM: terminación ordenada, a menudo por activeDeadlineSeconds |
Limpieza del historial
Con ttlSecondsAfterFinished y los límites de historial, la limpieza es automática. Para el mantenimiento manual:
# Cuántos objetos terminados hay acumulados
kubectl get jobs -A --field-selector status.successful=1 --no-headers | wc -l
# Borrar Jobs completados de un namespace
kubectl delete jobs -n rutas-norte-pro --field-selector status.successful=1
# Borrar pods Completed y Error de todo el clúster
kubectl delete pods -A --field-selector status.phase=Succeeded
kubectl delete pods -A --field-selector status.phase=FailedBorrar un Job elimina también sus pods, porque el Job es su ownerReference (el mecanismo de propiedad que vimos en 02-02). Si quieres conservar los pods para inspeccionarlos:
Errores Comunes y Consejos
Poner restartPolicy: Always en un Job. La API lo rechaza con un mensaje claro (spec.template.spec.restartPolicy: Unsupported value: "Always"). Copiar y pegar de un Deployment es la causa habitual.
Olvidar la zona horaria. Sin timeZone, un schedule: "15 3 * * *" se dispara en UTC, y en verano eso son las 05:15 en España: dentro de la ventana de mantenimiento de otros equipos o justo cuando empieza a haber tráfico. Pon siempre timeZone: "Europe/Madrid".
Confiar en el concurrencyPolicy por defecto. Allow está bien para tareas triviales, pero para cualquier trabajo que toque la base de datos o escriba en un PVC compartido es una fuente de corrupción silenciosa. Piensa activamente qué valor quieres.
Un CronJob que no arranca nunca y no dice por qué. Mira el evento FailedNeedsStart con "too many missed start times". Ocurre tras una parada larga y se resuelve fijando startingDeadlineSeconds.
failedJobsHistoryLimit: 1. El valor por defecto te deja sin evidencia del primer fallo, que es el más informativo. Súbelo a 5 en cualquier trabajo con importancia operativa.
No poner ttlSecondsAfterFinished. Un CronJob cada cinco minutos genera casi 300 objetos al día. Sin TTL ni límites de historial, en semanas tendrás decenas de miles de Jobs y pods en etcd, y el apiserver se resentirá.
Olvidar la NetworkPolicy en rutas-norte-pro. El síntoma es un Connection timed out hacia un nombre que resuelve bien. Y recuerda incluir la regla de salida a CoreDNS: sin ella el fallo es un could not translate host name, todavía más desconcertante.
Ignorar las ResourceQuota. Los Jobs consumen cuota del namespace (03-04). Un Job con parallelism: 20 puede agotarla y bloquear el despliegue de api-reservas. El síntoma es un evento FailedCreate con exceeded quota.
Consejo: kubectl create job --from=cronjob/<nombre>. Es la herramienta de validación imprescindible. Nunca despliegues un CronJob nuevo sin haberlo disparado manualmente al menos una vez.
Consejo: haz tus trabajos idempotentes. Puede que se ejecuten dos veces: por un reintento, por una ejecución manual, por una recuperación tras caída. IF NOT EXISTS, ON CONFLICT DO NOTHING y sobrescribir en vez de anexar son la diferencia entre un incidente y un no-evento.
Consejo: registra siempre el principio y el final. Un echo al empezar y otro al terminar con el resultado convierten los logs en algo útil. Un Job que solo escribe cuando falla es un Job cuyo éxito no puedes verificar.
Ejercicios
Ejercicio 1: Job indexado que reparte líneas
En rutas-norte-dev, crea un Job llamado ocupacion-por-linea en modo Indexed con 6 finalizaciones y paralelismo 2, que use busybox:1.36. Cada pod debe imprimir qué línea de Rutas Norte le ha tocado (línea = índice + 1), tardar 5 segundos y terminar. Añade ttlSecondsAfterFinished de una hora.
Verifica que se han creado los 6 pods con sus índices y que nunca hubo más de 2 corriendo a la vez.
Ejercicio 2: CronJob con Forbid y prueba manual
Crea un CronJob resumen-ventas en rutas-norte-dev que se ejecute cada 5 minutos en horario de Madrid, con concurrencyPolicy: Forbid, startingDeadlineSeconds: 120, historial de 2 éxitos y 3 fallos, y ttlSecondsAfterFinished de una hora en su plantilla. El contenido debe simular un resumen que tarda 30 segundos.
Dispara una ejecución manual sin esperar al calendario y comprueba los logs. Después suspende el CronJob.
Ejercicio 3: diagnosticar un Job fallido
Crea deliberadamente un Job informe-roto en rutas-norte-dev con backoffLimit: 2 y restartPolicy: Never, cuyo contenedor intente conectar a un host inexistente postgres-inexistente y falle. Después:
- Averigua la razón por la que el Job está en
Failed. - Localiza el pod del primer intento y lee su log.
- Obtén el código de salida del contenedor.
- Limpia.
Soluciones
Solución 1
# /tmp/ocupacion-por-linea.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: ocupacion-por-linea
namespace: rutas-norte-dev
labels:
app: informes-ocupacion
app.kubernetes.io/part-of: rutas-norte
entorno: dev
spec:
completionMode: Indexed
completions: 6
parallelism: 2
backoffLimit: 3
ttlSecondsAfterFinished: 3600
template:
metadata:
labels:
app: informes-ocupacion
entorno: dev
spec:
restartPolicy: Never
automountServiceAccountToken: false
containers:
- name: calculo
image: busybox:1.36
command:
- sh
- -c
- |
LINEA=$(( JOB_COMPLETION_INDEX + 1 ))
echo "índice=$JOB_COMPLETION_INDEX -> línea L$LINEA de Rutas Norte"
sleep 5
echo "línea L$LINEA calculada"
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mikubectl apply -f /tmp/ocupacion-por-linea.yaml
kubectl get pods -n rutas-norte-dev -l job-name=ocupacion-por-linea -wDurante la ejecución nunca se ven más de dos pods en Running a la vez, porque parallelism: 2 lo impide:
NAME READY STATUS RESTARTS AGE
ocupacion-por-linea-0-p4m2x 1/1 Running 0 3s
ocupacion-por-linea-1-r7k9d 1/1 Running 0 3s
ocupacion-por-linea-0-p4m2x 0/1 Completed 0 9s
ocupacion-por-linea-2-w3j5t 1/1 Running 0 1skubectl get job ocupacion-por-linea -n rutas-norte-dev
kubectl logs -n rutas-norte-dev ocupacion-por-linea-4-b8n6qNAME STATUS COMPLETIONS DURATION AGE
ocupacion-por-linea Complete 6/6 27s 30s
índice=4 -> línea L5 de Rutas Norte
línea L5 calculadaEl índice 4 procesa la línea 5, de forma determinista y reproducible. Sin completionMode: Indexed los seis pods habrían ejecutado el mismo comando sin saber cuál era su porción.
Solución 2
# /tmp/resumen-ventas.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: resumen-ventas
namespace: rutas-norte-dev
labels:
app: informes-ocupacion
app.kubernetes.io/part-of: rutas-norte
entorno: dev
spec:
schedule: "*/5 * * * *"
timeZone: "Europe/Madrid"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 120
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 300
ttlSecondsAfterFinished: 3600
template:
metadata:
labels:
app: informes-ocupacion
entorno: dev
spec:
restartPolicy: Never
automountServiceAccountToken: false
containers:
- name: resumen
image: busybox:1.36
command:
- sh
- -c
- |
echo "$(date '+%Y-%m-%d %H:%M:%S') inicio del resumen de ventas"
sleep 30
echo "billetes vendidos ayer: 1842 (dato ficticio)"
echo "resumen completado"
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
cpu: 100m
memory: 64MiNAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
resumen-ventas */5 * * * * Europe/Madrid False 0 <none> 8s# Ejecución manual sin esperar al calendario
kubectl create job -n rutas-norte-dev --from=cronjob/resumen-ventas resumen-manual
kubectl wait --for=condition=complete job/resumen-manual -n rutas-norte-dev --timeout=120s
kubectl logs -n rutas-norte-dev job/resumen-manual2026-08-05 18:47:02 inicio del resumen de ventas
billetes vendidos ayer: 1842 (dato ficticio)
resumen completado# Suspender y comprobar
kubectl patch cronjob resumen-ventas -n rutas-norte-dev -p '{"spec":{"suspend":true}}'
kubectl get cronjob resumen-ventas -n rutas-norte-devNAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
resumen-ventas */5 * * * * Europe/Madrid True 0 3m 6mCon SUSPEND en True no se crearán más Jobs, pero el objeto y su historial siguen ahí. Es lo que harías antes de una ventana de mantenimiento de postgres-reservas.
Solución 3
# /tmp/informe-roto.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: informe-roto
namespace: rutas-norte-dev
labels:
app: informes-ocupacion
entorno: dev
spec:
backoffLimit: 2
activeDeadlineSeconds: 300
template:
metadata:
labels:
app: informes-ocupacion
entorno: dev
spec:
restartPolicy: Never
automountServiceAccountToken: false
containers:
- name: generador
image: postgres:16.4
command:
- sh
- -c
- |
echo "Conectando a la base de datos de reservas..."
psql -h postgres-inexistente -U rutasnorte -d reservas -c 'SELECT 1'
env:
- name: PGCONNECT_TIMEOUT
value: "5"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mikubectl apply -f /tmp/informe-roto.yaml
# Esperar unos dos minutos por el retroceso exponencial entre intentos
kubectl get job informe-roto -n rutas-norte-dev1. Razón del fallo:
Conditions:
Type Status Reason Message
---- ------ ------ -------
Failed True BackoffLimitExceeded Job has reached the specified backoff limitTres intentos (el original más dos reintentos con backoffLimit: 2) y ninguno tuvo éxito.
2. Pod del primer intento y su log:
kubectl get pods -n rutas-norte-dev -l job-name=informe-roto \
--sort-by=.metadata.creationTimestamp \
-o custom-columns=POD:.metadata.name,ESTADO:.status.phase,CREADO:.metadata.creationTimestampPOD ESTADO CREADO
informe-roto-2xh4m Failed 2026-08-05T18:52:03Z
informe-roto-8kq7p Failed 2026-08-05T18:52:18Z
informe-roto-v5n1w Failed 2026-08-05T18:52:49ZConectando a la base de datos de reservas...
psql: error: could not translate host name "postgres-inexistente" to address:
Name or service not knownEl mensaje could not translate host name indica un fallo de resolución DNS, no de conectividad: el nombre no existe. Si el nombre existiera pero la NetworkPolicy bloqueara el tráfico, veríamos Connection timed out. Distinguir esos dos mensajes ahorra muchísimo tiempo de diagnóstico.
3. Código de salida:
kubectl get pod informe-roto-2xh4m -n rutas-norte-dev \
-o jsonpath='{.status.containerStatuses[0].state.terminated.exitCode}{"\n"}'Código 2: psql no pudo conectar. Un 137 habría indicado OOMKilled y un 127 un comando inexistente.
4. Limpieza:
kubectl delete -f /tmp/informe-roto.yaml
kubectl delete -f /tmp/resumen-ventas.yaml
kubectl delete -f /tmp/ocupacion-por-linea.yaml
kubectl delete job -n rutas-norte-dev resumen-manual --ignore-not-foundConclusión
Los Jobs y los CronJobs completan el catálogo de cargas de trabajo de Kubernetes con las que terminan. El Job ejecuta pods hasta acumular completions éxitos, con parallelism como control de carga, backoffLimit y activeDeadlineSeconds como cortafuegos, y ttlSecondsAfterFinished como limpieza automática. La elección entre restartPolicy: Never y OnFailure decide si cada intento deja su propio pod con sus logs —casi siempre lo que quieres— o si se reinicia el contenedor en el sitio. Los tres patrones (tarea única, paralelismo fijo, cola de trabajo) cubren el trabajo por lotes, y completionMode: Indexed con JOB_COMPLETION_INDEX convierte el segundo en un reparto determinista y reintentable.
El CronJob añade el calendario: cinco campos, timeZone para no depender de UTC, concurrencyPolicy: Forbid cuando dos ejecuciones simultáneas serían dañinas, startingDeadlineSeconds para decidir qué hacer con las ejecuciones perdidas y los límites de historial para no llenar etcd.
Con esto Rutas Norte está completa: tienda-web, api-reservas, postgres-reservas sobre StatefulSet, redis-cache, worker-notificaciones y ahora informes-ocupacion generando cada madrugada a las 03:15 su informe sobre un PVC, con su ServiceAccount, sus recursos y la NetworkPolicy que le abre paso a través del deny-all.
Todos los pods que hemos escrito hasta aquí tienen algo en común: un único contenedor. Pero al desplegar la migración de esquema apareció una pregunta que dejamos sin responder: ¿y si en vez de un Job separado quisiéramos que la migración se ejecutase automáticamente antes de que arranque cada pod de api-reservas? ¿Y si api-reservas tuviera que esperar a que postgres-reservas aceptara conexiones antes de considerarse arrancada? ¿Y si quisiéramos exportar métricas de PostgreSQL sin modificar su imagen? Todo eso se resuelve poniendo más de un contenedor en el mismo pod, y es el tema de la siguiente lección: Init Containers, Sidecars y Patrones Multi-Contenedor.
Curso de Kubernetes
Módulo 1: Introducción a Kubernetes
- ¿Qué es Kubernetes?
- Arquitectura de Kubernetes
- Conceptos y Terminología Clave
- Configuración de un Clúster de Kubernetes
- La CLI de Kubernetes: kubectl
- Objetos, Manifiestos YAML y el Modelo Declarativo
- El Proyecto del Curso: la Plataforma Rutas Norte
Módulo 2: Componentes Principales de Kubernetes
- Pods
- ReplicaSets
- Deployments
- Actualizaciones, Rollbacks y Estrategias de Despliegue
- Servicios
- Namespaces
- Etiquetas, Selectores y Anotaciones
Módulo 3: Gestión de Configuración y Secretos
- ConfigMaps
- Secrets
- Variables de Entorno
- Cuotas y Límites de Recursos
- LimitRanges y Clases de Calidad de Servicio (QoS)
- ServiceAccounts y Acceso a la API desde los Pods
Módulo 4: Redes en Kubernetes
- Redes de Clúster
- Tipos de Servicios
- DNS Interno y Descubrimiento de Servicios
- Controladores de Ingress
- TLS y Gestión de Certificados con cert-manager
- Políticas de Red
Módulo 5: Almacenamiento en Kubernetes
- Volúmenes
- Volúmenes Persistentes
- Reclamaciones de Volúmenes Persistentes
- Clases de Almacenamiento
- Aprovisionamiento Dinámico, Expansión y Snapshots
- Copias de Seguridad y Restauración de Datos
Módulo 6: Conceptos Avanzados de Kubernetes
- StatefulSets
- DaemonSets
- Trabajos y CronJobs
- Init Containers, Sidecars y Patrones Multi-Contenedor
- Planificación: Afinidad, Taints y Tolerations
- Definiciones de Recursos Personalizados (CRDs)
- Operadores y el Patrón Controlador
Módulo 7: Monitoreo y Registro
- Verificaciones de Salud y Sondas
- Servidor de Métricas y kubectl top
- Monitoreo con Prometheus
- Visualización y Alertas con Grafana y Alertmanager
- Registro Centralizado con Elasticsearch, Fluentd y Kibana (EFK)
- Depuración de Aplicaciones y Eventos del Clúster
Módulo 8: Seguridad en Kubernetes
- Control de Acceso Basado en Roles (RBAC)
- Contextos de Seguridad y Endurecimiento del Contenedor
- Políticas de Seguridad de Pods y Pod Security Standards
- Seguridad de Red
- Seguridad de Imágenes
- Auditoría, Escaneo y Gestión de Vulnerabilidades
Módulo 9: Escalado y Rendimiento
- Autoescalado Horizontal de Pods
- Autoescalado Vertical de Pods
- Autoescalado de Clúster
- Escalado por Eventos y Métricas Personalizadas con KEDA
- Alta Disponibilidad: PodDisruptionBudgets y Topología
- Ajuste de Rendimiento
Módulo 10: Ecosistema y Herramientas de Kubernetes
- Minikube y Entornos Locales con kind
- Kubeadm
- Helm
- Kustomize
- GitOps con Argo CD y Flux
- Kubernetes Gestionado: EKS, AKS y GKE
Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real
- Despliegue de una Aplicación Web
- Ejecución de Aplicaciones con Estado
- CI/CD con Kubernetes
- Estrategias de Despliegue: Blue-Green y Canary
- Gestión Multi-Clúster
- Operación en Producción: Incidencias, Runbooks y Costes
