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.
kubectlconfigurado 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:
| Campo | Qué hace | Valor por defecto |
|---|---|---|
backoffLimit | Número de reintentos antes de marcar el Job como fallido | 6 |
activeDeadlineSeconds | Tiempo máximo total del Job; al superarlo se detienen sus pods | Sin límite |
ttlSecondsAfterFinished | Segundos tras terminar (con éxito o no) antes de borrar el Job y sus pods | Sin 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.
Consejocon
restartPolicy: OnFailureel contenedor se reinicia dentro del mismo pod en lugar de crear uno nuevo. Ocupa menos recursos, pero pierdes los registros de los intentos anteriores.Neveres más fácil de depurar.
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:
| Campo | Rango | Ejemplo |
|---|---|---|
| Minuto | 0-59 | */15 cada 15 minutos |
| Hora | 0-23 | 2 a las 02:00 |
| Día del mes | 1-31 | 1 el día 1 |
| Mes | 1-12 | * todos los meses |
| Día de la semana | 0-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:
| Campo | Qué hace |
|---|---|
timeZone | Zona horaria IANA para schedule, por ejemplo Europe/Madrid. Tiene en cuenta el horario de verano |
concurrencyPolicy | Allow (por defecto) permite ejecuciones solapadas, Forbid salta la nueva si la anterior sigue activa, Replace cancela la anterior |
startingDeadlineSeconds | Si el controlador no pudo lanzar el Job a su hora, segundos de margen para lanzarlo tarde; pasado ese margen se salta |
successfulJobsHistoryLimit | Jobs correctos que se conservan (por defecto 3) |
failedJobsHistoryLimit | Jobs fallidos que se conservan (por defecto 1) |
suspend | true 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
postgresdebe ser igual o superior a la del servidor, porquepg_dumpse niega a volcar servidores más nuevos que él. set -o pipefailhace que el Job falle sipg_dumpfalla, aunquegziptermine bien. Sin él tendrías copias vacías marcadas como correctas.- Si no tienes StorageClass predeterminada, añade
storageClassNameal 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
Importanteuna copia guardada en el mismo clúster no te protege si pierdes el clúster. Copia periódicamente el contenido del volumen a otro lugar, por ejemplo a un almacenamiento de objetos compatible con S3.
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.
