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

  1. Cargas que terminan frente a cargas que no terminan
  2. El objeto Job: anatomía y campos de control
  3. restartPolicy: Never frente a OnFailure
  4. Los tres patrones de Job
  5. completionMode: Indexed y el reparto determinista
  6. El objeto CronJob: calendario y política de concurrencia
  7. Caso central: informes-ocupacion como CronJob nocturno
  8. Segundo ejemplo: un Job de migración de esquema
  9. Depuración de trabajos fallidos y limpieza de historial

  1. 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

  1. 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:

spec:
  backoffLimit: 3
  activeDeadlineSeconds: 1800   # 30 minutos como mucho, pase lo que pase

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

spec:
  ttlSecondsAfterFinished: 86400   # se autodestruye 24 h después de terminar

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).

  1. restartPolicy: Never frente a OnFailure

La 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:

kubectl get pods -n rutas-norte-dev -l job-name=migracion-esquema
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          1m

Tres pods, uno por intento, cada uno con sus logs íntegros. Con OnFailure verías:

NAME                      READY   STATUS             RESTARTS      AGE
migracion-esquema-4kx2p   0/1     CrashLoopBackOff   3 (45s ago)   3m

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.

  1. 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:

kubectl get job recalculo-ocupacion-lineas -n rutas-norte-dev -w
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        39s

El 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.

  1. completionMode: Indexed y el reparto determinista

El 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-index del 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.name
NAME                                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          18s
kubectl logs -n rutas-norte-dev recalculo-ocupacion-lineas-7-x1v4b
Pod con índice 7 -> recalculando la línea 8 de Rutas Norte
Línea 8 recalculada

Propiedades 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"

  1. 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.

spec:
  schedule: "15 3 * * *"
  timeZone: "Europe/Madrid"

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.
spec:
  schedule: "15 3 * * *"
  timeZone: "Europe/Madrid"
  startingDeadlineSeconds: 600

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 skew

Es 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: false

El 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.

  1. Caso central: informes-ocupacion como CronJob nocturno

Es 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: 5Gi

La 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: false

La 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: 5432

La 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-datos

Repaso de las decisiones, que resumen media docena de lecciones anteriores:

  • schedule a las 03:15 con timeZone: 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: 172800 más los límites de historial: dos capas de limpieza automática.
  • Credenciales por Secret (03-02), nunca en el manifiesto. PGPASSWORD y PGUSER son variables que psql reconoce directamente.
  • Recursos declarados: sin ellos el pod sería BestEffort (03-05) y el primer nodo con presión de memoria lo desalojaría.
  • securityContext con usuario no root y fsGroup para 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-pro
NAME                 SCHEDULE     TIMEZONE        SUSPEND   ACTIVE   LAST SCHEDULE   AGE
informes-ocupacion   15 3 * * *   Europe/Madrid   False     0        <none>          20s

El comando imprescindible: disparar una ejecución manual a partir del CronJob.

kubectl create job -n rutas-norte-pro \
  --from=cronjob/informes-ocupacion informes-prueba-manual
job.batch/informes-prueba-manual created
kubectl wait --for=condition=complete job/informes-prueba-manual \
  -n rutas-norte-pro --timeout=600s
kubectl logs -n rutas-norte-pro job/informes-prueba-manual
Generando 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 eliminados

kubectl 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.

  1. 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: 256Mi

Contrastes 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.

  1. 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

kubectl get jobs -n rutas-norte-pro
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      3h

El 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

kubectl describe job informes-ocupacion-29178180 -n rutas-norte-pro
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 limit

Las 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.creationTimestamp
NAME                                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          3h
kubectl logs -n rutas-norte-pro informes-ocupacion-29178180-4kx2p
Generando 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> --previous

Có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=Failed

Borrar 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:

kubectl delete job informes-ocupacion-29178180 -n rutas-norte-pro --cascade=orphan

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:

  1. Averigua la razón por la que el Job está en Failed.
  2. Localiza el pod del primer intento y lee su log.
  3. Obtén el código de salida del contenedor.
  4. 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: 64Mi
kubectl apply -f /tmp/ocupacion-por-linea.yaml
kubectl get pods -n rutas-norte-dev -l job-name=ocupacion-por-linea -w

Durante 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          1s
kubectl get job ocupacion-por-linea -n rutas-norte-dev
kubectl logs -n rutas-norte-dev ocupacion-por-linea-4-b8n6q
NAME                  STATUS     COMPLETIONS   DURATION   AGE
ocupacion-por-linea   Complete   6/6           27s        30s

índice=4 -> línea L5 de Rutas Norte
línea L5 calculada

El í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: 64Mi
kubectl apply -f /tmp/resumen-ventas.yaml
kubectl get cronjob resumen-ventas -n rutas-norte-dev
NAME             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-manual
2026-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-dev
NAME             SCHEDULE      TIMEZONE        SUSPEND   ACTIVE   LAST SCHEDULE   AGE
resumen-ventas   */5 * * * *   Europe/Madrid   True      0        3m              6m

Con 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: 128Mi
kubectl apply -f /tmp/informe-roto.yaml
# Esperar unos dos minutos por el retroceso exponencial entre intentos
kubectl get job informe-roto -n rutas-norte-dev
NAME           STATUS   COMPLETIONS   DURATION   AGE
informe-roto   Failed   0/1           2m14s      2m30s

1. Razón del fallo:

kubectl describe job informe-roto -n rutas-norte-dev | grep -A5 Conditions
Conditions:
  Type     Status  Reason                Message
  ----     ------  ------                -------
  Failed   True    BackoffLimitExceeded  Job has reached the specified backoff limit

Tres 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.creationTimestamp
POD                  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:49Z
kubectl logs -n rutas-norte-dev informe-roto-2xh4m
Conectando a la base de datos de reservas...
psql: error: could not translate host name "postgres-inexistente" to address:
      Name or service not known

El 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"}'
2

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-found

Conclusió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

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados