Un Job de Kubernetes ejecuta uno o varios pods hasta que terminan con éxito, reintentándolos si fallan. Un CronJob crea Jobs de forma periódica según una expresión cron, igual que crontab pero dentro del clúster. En este tutorial crearás un Job sencillo, lo ajustarás con reintentos, límites de tiempo y paralelismo, y terminarás con un CronJob real que hace una copia diaria de una base de datos PostgreSQL.

Requisitos previos

Para seguir esta guía necesitas:

  • Un clúster de Kubernetes 1.30 o superior, por ejemplo sobre VPS de CubePath con Ubuntu 24.04.
  • kubectl configurado contra el clúster con permisos para crear recursos en un namespace.
  • Para el paso 6, una StorageClass que pueda crear volúmenes (compruébalo con kubectl get storageclass) y un servidor PostgreSQL accesible desde el clúster.

Crea un namespace para las pruebas y hazlo el predeterminado de tu contexto, así no tendrás que añadir -n a cada comando:

kubectl create namespace batch
kubectl config set-context --current --namespace=batch

Paso 1: Crear tu primer Job

Un Job contiene una plantilla de pod igual que un Deployment, con una diferencia importante: restartPolicy debe ser Never u OnFailure, porque el pod tiene que poder terminar.

nano hello-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: hello
spec:
  template:
    spec:
      containers:
        - name: hello
          image: busybox:1.36
          command: ["sh", "-c", "echo 'Procesando...'; sleep 5; echo 'Hecho'"]
      restartPolicy: Never

Crea el Job y espera a que termine. kubectl wait sale en cuanto el Job alcanza la condición Complete:

kubectl apply -f hello-job.yaml
kubectl wait --for=condition=complete job/hello --timeout=60s
job.batch/hello condition met

Revisa el estado y la salida del pod. Kubernetes etiqueta los pods de un Job con job-name, y kubectl logs job/<nombre> los encuentra directamente:

kubectl get job hello
kubectl logs job/hello
NAME    STATUS     COMPLETIONS   DURATION   AGE
hello   Complete   1/1           8s         15s
Procesando...
Hecho

El Job y su pod se quedan en el clúster después de terminar para que puedas leer los registros. Bórralo cuando ya no lo necesites:

kubectl delete job hello

Paso 2: Controlar reintentos, duración y limpieza

Los campos de spec de un Job definen qué pasa cuando algo va mal y cuánto tiempo se conserva:

CampoQué haceValor por defecto
backoffLimitNúmero de reintentos antes de marcar el Job como fallido6
activeDeadlineSecondsTiempo máximo total del Job; al superarlo se detienen sus podsSin límite
ttlSecondsAfterFinishedSegundos tras terminar (con éxito o no) antes de borrar el Job y sus podsSin borrado

Los reintentos se hacen con una espera exponencial (10 s, 20 s, 40 s...) hasta un máximo de seis minutos. Para verlo, crea un Job que siempre falla:

nano failing-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: failing
spec:
  backoffLimit: 2
  activeDeadlineSeconds: 300
  ttlSecondsAfterFinished: 600
  template:
    spec:
      containers:
        - name: task
          image: busybox:1.36
          command: ["sh", "-c", "echo 'No encuentro el archivo de entrada'; exit 1"]
      restartPolicy: Never
kubectl apply -f failing-job.yaml
kubectl wait --for=condition=failed job/failing --timeout=120s
kubectl get pods -l job-name=failing

Verás tres pods en estado Error: el intento original y los dos reintentos:

NAME            READY   STATUS   RESTARTS   AGE
failing-4ktzq   0/1     Error    0          52s
failing-8w2mn   0/1     Error    0          41s
failing-vx9rd   0/1     Error    0          20s

El motivo del fallo aparece en los eventos del Job:

kubectl describe job failing | tail -n 5
  Normal   SuccessfulCreate      52s   job-controller  Created pod: failing-4ktzq
  Normal   SuccessfulCreate      41s   job-controller  Created pod: failing-8w2mn
  Normal   SuccessfulCreate      20s   job-controller  Created pod: failing-vx9rd
  Warning  BackoffLimitExceeded  10s   job-controller  Job has reached the specified backoff limit

Gracias a ttlSecondsAfterFinished: 600, el Job y sus pods desaparecerán solos a los diez minutos.

Paso 3: Ejecutar tareas en paralelo

Dos campos controlan cuántos pods se ejecutan y cuántos deben terminar bien:

  • completions: número de pods que deben acabar con éxito para dar el Job por completado.
  • parallelism: número máximo de pods ejecutándose a la vez.

Con completionMode: Indexed, cada pod recibe un índice único de 0 a completions - 1 en la variable de entorno JOB_COMPLETION_INDEX. Es la forma más sencilla de repartir trabajo: cada pod procesa su trozo según su índice.

nano indexed-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: process-chunks
spec:
  completions: 6
  parallelism: 3
  completionMode: Indexed
  ttlSecondsAfterFinished: 3600
  template:
    spec:
      containers:
        - name: worker
          image: busybox:1.36
          command: ["sh", "-c", "echo \"Procesando el fragmento $JOB_COMPLETION_INDEX\"; sleep 10"]
      restartPolicy: Never
kubectl apply -f indexed-job.yaml
kubectl get pods -l job-name=process-chunks --watch

Verás que nunca hay más de tres pods en Running al mismo tiempo. Pulsa Ctrl+C cuando todos estén en Completed y comprueba la salida de cada índice:

kubectl logs -l job-name=process-chunks --prefix
[pod/process-chunks-0-jx7fp/worker] Procesando el fragmento 0
[pod/process-chunks-1-b2kqd/worker] Procesando el fragmento 1
[pod/process-chunks-2-m8wzs/worker] Procesando el fragmento 2
[pod/process-chunks-3-r4tlc/worker] Procesando el fragmento 3
[pod/process-chunks-4-n6vhp/worker] Procesando el fragmento 4
[pod/process-chunks-5-q9dfx/worker] Procesando el fragmento 5
kubectl get job process-chunks
NAME             STATUS     COMPLETIONS   DURATION   AGE
process-chunks   Complete   6/6           24s        40s

Paso 4: Programar tareas con un CronJob

Un CronJob tiene dos partes: la programación (schedule) y una plantilla de Job (jobTemplate) que se usa en cada ejecución. La expresión schedule usa los cinco campos clásicos de cron:

CampoRangoEjemplo
Minuto0-59*/15 cada 15 minutos
Hora0-232 a las 02:00
Día del mes1-311 el día 1
Mes1-12* todos los meses
Día de la semana0-6 (0 es domingo)1-5 de lunes a viernes

Algunas expresiones habituales: 0 2 * * * (todos los días a las 02:00), */15 * * * * (cada 15 minutos), 0 9 * * 1-5 (días laborables a las 09:00) y 0 3 1 * * (el día 1 de cada mes a las 03:00). También se admiten las macros @hourly, @daily, @weekly y @monthly.

Crea un CronJob que se ejecuta cada minuto para verlo funcionar rápido:

nano heartbeat-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: heartbeat
spec:
  schedule: "* * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: heartbeat
              image: busybox:1.36
              command: ["sh", "-c", "date; echo 'Tarea programada ejecutada'"]
          restartPolicy: OnFailure
kubectl apply -f heartbeat-cronjob.yaml
kubectl get cronjob heartbeat
NAME        SCHEDULE    TIMEZONE   SUSPEND   ACTIVE   LAST SCHEDULE   AGE
heartbeat   * * * * *   <none>     False     0        <none>          5s

Espera un par de minutos y lista los Jobs que ha creado. Cada nombre lleva un sufijo derivado de la hora programada:

kubectl get jobs
NAME                 STATUS     COMPLETIONS   DURATION   AGE
heartbeat-29311562   Complete   1/1           4s         72s
heartbeat-29311563   Complete   1/1           4s         12s

No hace falta esperar a la siguiente ejecución para probar un CronJob: puedes crear un Job a partir de él en cualquier momento.

kubectl create job --from=cronjob/heartbeat heartbeat-manual
kubectl logs job/heartbeat-manual
Thu Sep 24 10:31:17 UTC 2026
Tarea programada ejecutada

Borra este CronJob de prueba antes de continuar:

kubectl delete cronjob heartbeat
kubectl delete job heartbeat-manual

Paso 5: Ajustar zona horaria, concurrencia e historial

Por defecto el horario se interpreta en la zona horaria del kube-controller-manager, que casi siempre es UTC. Estos campos controlan el comportamiento del CronJob:

CampoQué hace
timeZoneZona horaria IANA para schedule, por ejemplo Europe/Madrid. Tiene en cuenta el horario de verano
concurrencyPolicyAllow (por defecto) permite ejecuciones solapadas, Forbid salta la nueva si la anterior sigue activa, Replace cancela la anterior
startingDeadlineSecondsSi el controlador no pudo lanzar el Job a su hora, segundos de margen para lanzarlo tarde; pasado ese margen se salta
successfulJobsHistoryLimitJobs correctos que se conservan (por defecto 3)
failedJobsHistoryLimitJobs fallidos que se conservan (por defecto 1)
suspendtrue detiene las ejecuciones futuras sin borrar el CronJob

Para tareas que no deben solaparse, como copias de seguridad, usa siempre concurrencyPolicy: Forbid. Lo verás aplicado en el siguiente paso.

Para pausar un CronJob durante un mantenimiento y reanudarlo después:

kubectl patch cronjob <nombre> -p '{"spec":{"suspend":true}}'
kubectl patch cronjob <nombre> -p '{"spec":{"suspend":false}}'

Paso 6: Crear una copia diaria de PostgreSQL

Este ejemplo junta todo lo anterior: un CronJob que cada noche a las 02:30 (hora de Madrid) ejecuta pg_dump, guarda el resultado comprimido en un volumen persistente y borra las copias de más de siete días.

Primero guarda las credenciales en un Secret. Sustituye los valores por los de tu base de datos; your_strong_password es un marcador:

kubectl create secret generic pg-backup-credentials \
  --from-literal=PGHOST=postgres.databases.svc.cluster.local \
  --from-literal=PGUSER=backup \
  --from-literal=PGPASSWORD=your_strong_password \
  --from-literal=PGDATABASE=app

pg_dump lee directamente las variables PGHOST, PGUSER, PGPASSWORD y PGDATABASE, así que no hace falta pasarlas como argumentos.

Crea el manifiesto con el volumen y el CronJob:

nano pg-backup.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pg-backups
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 10Gi
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: pg-backup
spec:
  schedule: "30 2 * * *"
  timeZone: "Europe/Madrid"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      activeDeadlineSeconds: 3600
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: pg-dump
              image: postgres:17
              envFrom:
                - secretRef:
                    name: pg-backup-credentials
              command:
                - /bin/bash
                - -c
                - |
                  set -euo pipefail
                  file="/backups/${PGDATABASE}-$(date +%Y%m%d-%H%M%S).sql.gz"
                  pg_dump --no-owner | gzip > "$file"
                  echo "Copia creada: $file ($(du -h "$file" | cut -f1))"
                  find /backups -name '*.sql.gz' -mtime +7 -delete
              volumeMounts:
                - name: backups
                  mountPath: /backups
          volumes:
            - name: backups
              persistentVolumeClaim:
                claimName: pg-backups

Algunos detalles importantes:

  • La versión de la imagen postgres debe ser igual o superior a la del servidor, porque pg_dump se niega a volcar servidores más nuevos que él.
  • set -o pipefail hace que el Job falle si pg_dump falla, aunque gzip termine bien. Sin él tendrías copias vacías marcadas como correctas.
  • Si no tienes StorageClass predeterminada, añade storageClassName al PVC.

Aplica el manifiesto y lanza una ejecución manual para comprobarlo sin esperar a la noche:

kubectl apply -f pg-backup.yaml
kubectl create job --from=cronjob/pg-backup pg-backup-test
kubectl wait --for=condition=complete job/pg-backup-test --timeout=300s
kubectl logs job/pg-backup-test
Copia creada: /backups/app-20260924-103512.sql.gz (4.2M)

Comprueba que el CronJob muestra la zona horaria correcta:

kubectl get cronjob pg-backup
NAME        SCHEDULE     TIMEZONE        SUSPEND   ACTIVE   LAST SCHEDULE   AGE
pg-backup   30 2 * * *   Europe/Madrid   False     0        <none>          1m

Solución de problemas

El Job no crea pods. Revisa los eventos con kubectl describe job <nombre>. Las causas típicas son una ResourceQuota agotada en el namespace o un error de validación en la plantilla.

Los pods se quedan en Pending o ImagePullBackOff. No es un problema del Job sino del pod: kubectl describe pod <pod> muestra si faltan recursos en los nodos, si el PVC no se enlaza o si la imagen no existe.

El CronJob no se ejecuta a la hora esperada. Comprueba timeZone y que suspend sea False con kubectl get cronjob. Si el controlador estuvo caído más tiempo que startingDeadlineSeconds, esa ejecución se salta y aparece un evento en kubectl describe cronjob <nombre>.

Se acumulan Jobs antiguos. Los Jobs creados por un CronJob se limpian según successfulJobsHistoryLimit y failedJobsHistoryLimit. Para Jobs creados a mano, añade ttlSecondsAfterFinished.

Conclusión

Has creado Jobs con reintentos, límites de tiempo y limpieza automática, has repartido trabajo entre pods en paralelo con índices y has programado una copia diaria de PostgreSQL con zona horaria y sin solapamientos. Como siguientes pasos puedes enviar a un sistema de alertas los Jobs fallidos (por ejemplo con kube-state-metrics y Prometheus), sacar las copias del clúster a un almacenamiento externo o usar podFailurePolicy para no reintentar errores que nunca se van a resolver solos.