Job и CronJob: разовые и периодические задачи
Deployment, ReplicaSet, DaemonSet — это workloads, которые работают бесконечно: их Pod-ы должны быть всегда live. Но в production масса задач — другие: миграция БД при деплое, обработка batch-пачки данных, бэкап раз в сутки, тренировка ML-модели. Это конечные задачи: запускается, отрабатывает, заканчивается с exit code, и больше её делать не нужно (или нужно по расписанию).
Для этого в Kubernetes есть два API-объекта: Job (одноразовая задача) и CronJob (Job по расписанию). На CKAD они входят в обязательный scope и часто попадают в задания.
crontab: расписание задач в Unix-стиле
Job: одноразовая задача
apiVersion: batch/v1
kind: Job
metadata:
name: db-migration
spec:
completions: 1
parallelism: 1
backoffLimit: 3
activeDeadlineSeconds: 600
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: OnFailure
containers:
- name: migrate
image: myapp/migrate:1.0
command: ["./migrate.sh"]
Поля:
spec.completions— сколько успешных завершений Pod-ов нужно (default 1). Job завершается когдаcompletionsPod-ов вышли с exit code 0.spec.parallelism— сколько Pod-ов параллельно запускать одновременно (default 1).spec.backoffLimit— после стольких подряд неудачных Pod-ов (failures) Job помечаетсяFailed(default 6).spec.activeDeadlineSeconds— timeout на ВЕСЬ Job. По истечении — Job terminated, все Pods убиты.spec.ttlSecondsAfterFinished— через сколько секунд после finishing (Complete или Failed) удалить Job вместе с Pods.spec.template— Pod template. restartPolicy ОБЯЗАТЕЛЬНОNeverилиOnFailure.
Job-у нельзя restartPolicy: Always. Always означает “контейнер должен работать вечно”, что противоречит самой идее Job. API server отклонит такую спецификацию.
restartPolicy: Never vs OnFailure
Эти две политики дают очень разное поведение при сбое.
OnFailure:
- Pros: один Pod, история retries в
restartCountконтейнера, экономия — нет создания новых Pod-объектов - Cons: ресурсы Pod-а (node assignment, volumes) фиксированы — если нода умерла, перезапуск контейнера ничего не даст
Never:
- Pros: каждый retry — полноценный новый Pod, может попасть на другую ноду
- Cons: больше нагрузки на API server (каждый retry — new Pod object), volumes пересоздаются
В большинстве случаев лучше Never — даёт более robust retry. OnFailure имеет смысл когда контейнер делает что-то быстрое, что хочется ретраить без нагрузки на scheduler.
backoffLimit и exponential delay
Если Pod падает (контейнер failed, и для Never создан новый Pod, и он тоже failed) — это считается одной неудачей. spec.backoffLimit (default 6) — после стольких неудач Job помечается Failed и больше попыток не будет.
Между retries — exponential backoff: 10s, 20s, 40s, 80s, …, capped at 6 minutes (360s). Это защита от ситуации “сломанный Pod рестартится бесконечно быстро и забивает API server”.
# Смотрим как Job деградирует
kubectl get jobs -w
# NAME COMPLETIONS DURATION AGE
# bad-job 0/1 3s
# bad-job 0/1 10s 13s # ← после 10s second retry
# bad-job 0/1 40s 50s # ← после 30s third retry (10+20)
# ...
# Дальше Job переходит в Failed после backoffLimit неудач
activeDeadlineSeconds: hard timeout
spec:
activeDeadlineSeconds: 600
Это абсолютный timeout на весь Job. Через 600 секунд после старта Job (независимо от того, сколько retries осталось по backoffLimit) controller помечает Job Failed с reason DeadlineExceeded и убивает все live Pods.
Используется для long-running задач, которые в норме должны завершиться за известное время — если они зависли, лучше убить и поднять алерт.
activeDeadlineSeconds побеждает над backoffLimit: даже если backoffLimit ещё не исчерпан, deadline режет.
podFailurePolicy: тонкий контроль над failures
Default-поведение Job — любой failed Pod увеличивает счётчик backoffLimit. Это грубо: retry на network blip оправдан, retry на «invalid input» (exit 2) — пустая трата ресурсов.
podFailurePolicy (GA с v1.31) разрешает решать действие по конкретному exit code или Pod condition:
spec:
backoffLimit: 6
podFailurePolicy:
rules:
- action: FailJob # сразу пометить Job Failed, не делать retry
onExitCodes:
containerName: main
operator: In
values: [42] # exit 42 — bad input, retry бессмыслен
- action: Ignore # не считать в backoffLimit
onPodConditions:
- type: DisruptionTarget # preemption / node drain — не вина workload
Действия:
FailJob— Job сразуFailed, retry прекращается.Ignore— Pod не учитывается вbackoffLimit(как будто failure не было).Count— обычный учёт (default-поведение).
Особенно полезно с restartPolicy: Never и spot/preemptible nodes — DisruptionTarget condition освобождает Job от ложных счётчиков.
completions и parallelism: распараллеливание
Эти два параметра вместе определяют шаблон параллельной обработки.
spec:
completions: 10
parallelism: 3
Это значит: “нужно 10 успешных Pod-ов, можно запускать одновременно не больше 3-х”. Controller держит 3 активных Pod, при successful completion одного запускает следующий, пока не будет 10 successful.
completions: null (не указано вообще) с parallelism > 1 — особый режим: Pods продолжают создаваться пока хотя бы один не вернёт exit 0. Подразумевается, что Pods сами координируются через external queue.
Indexed Job: каждый Pod знает свой индекс
С Kubernetes v1.24 (GA) появился режим completionMode: Indexed — для batch processing с partitioning.
spec:
completions: 10
parallelism: 3
completionMode: Indexed
template:
spec:
containers:
- name: worker
image: myapp/worker:1.0
env:
- name: JOB_COMPLETION_INDEX
valueFrom:
fieldRef:
fieldPath: metadata.annotations['batch.kubernetes.io/job-completion-index']
В этом режиме:
- Каждый Pod получает уникальный индекс 0..completions-1
- Индекс доступен в env
JOB_COMPLETION_INDEXи в hostname<job-name>-<index>-<random> - Controller отслеживает completions ПО ИНДЕКСАМ — для успеха нужно чтобы КАЖДЫЙ индекс 0..N-1 завершился успешно
- Если индекс 3 упал — будет retry для индекса 3, а не просто очередной random Pod
Indexed Job — идеален для data parallel вычислений. Если у вас 10 шардов БД и нужно сделать бекап каждого — задаёте completions=10, indexed, и каждый Pod по своему JOB_COMPLETION_INDEX знает, какой шард он обрабатывает. Если упал один — будет retry только этого шарда.
ttlSecondsAfterFinished: автоматическая очистка
spec:
ttlSecondsAfterFinished: 3600
Через 3600 секунд после того как Job вошёл в финальное состояние (Complete или Failed), специальный TTL controller удалит Job. Cascade удалит и все его Pod-ы.
Без этого Jobs накапливаются в кластере, забивая etcd, особенно если у вас CronJob — каждые 5 минут создаётся новый Job.
ttlSecondsAfterFinished: 0 — удалить сразу после завершения (лог можно прочитать только если кто-то успел).
Полный пример Job
apiVersion: batch/v1
kind: Job
metadata:
name: nightly-report
spec:
completions: 1
parallelism: 1
backoffLimit: 2
activeDeadlineSeconds: 1800 # 30 минут максимум
ttlSecondsAfterFinished: 86400 # удалить через сутки
template:
spec:
restartPolicy: Never
containers:
- name: report
image: myapp/report-generator:2.5
command: ["./generate-nightly-report.sh"]
env:
- name: REPORT_DATE
value: "yesterday"
resources:
requests:
cpu: "1"
memory: "1Gi"
limits:
cpu: "2"
memory: "2Gi"
# Imperative create
kubectl create job nightly-report --image=myapp/report-generator:2.5
# Создать Job из CronJob template (для отладки)
kubectl create job manual-test --from=cronjob/nightly-report
# Статус
kubectl get jobs
# NAME COMPLETIONS DURATION AGE
# nightly-report 1/1 42s 1m
# Логи
kubectl logs -l job-name=nightly-report
# Подробности
kubectl describe job/nightly-report
CronJob: Job по расписанию
CronJob — это шаблон, который создаёт Job-ы по расписанию. API группа та же batch/v1.
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-report
spec:
schedule: "0 2 * * *"
timeZone: "Europe/Moscow"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
startingDeadlineSeconds: 300
jobTemplate:
spec:
backoffLimit: 2
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
containers:
- name: report
image: myapp/report-generator:2.5
command: ["./generate.sh"]
Поля:
spec.schedule— cron-выражение в форматеминута час день-месяца месяц день-неделиspec.timeZone— timezone для расписания (с v1.24+ — раньше всегда UTC)spec.jobTemplate— template Job, который будет создаваться при каждом тикеspec.concurrencyPolicy— что делать если предыдущий Job ещё не закончилspec.successfulJobsHistoryLimit(default 3) — сколько completed Jobs хранитьspec.failedJobsHistoryLimit(default 1) — сколько failed Jobs хранитьspec.startingDeadlineSeconds— за сколько секунд позже scheduled time всё ещё можно стартоватьspec.suspend— еслиtrue, CronJob не создаёт новые Jobs (но существующие — продолжаются)
Cron syntax: гайд
┌───────────── минута (0–59)
│ ┌───────────── час (0–23)
│ │ ┌───────────── день месяца (1–31)
│ │ │ ┌───────────── месяц (1–12)
│ │ │ │ ┌───────────── день недели (0–6, 0=воскресенье)
│ │ │ │ │
* * * * *
Примеры:
"0 2 * * *" каждый день в 02:00
"*/15 * * * *" каждые 15 минут
"0 */6 * * *" каждые 6 часов (00:00, 06:00, 12:00, 18:00)
"0 9 * * 1-5" в 09:00 в будни (пн-пт)
"30 23 1 * *" в 23:30 1-го числа каждого месяца
"@hourly" ярлык — каждый час (=0 * * * *)
"@daily" каждый день в полночь (=0 0 * * *)
"@weekly" каждое воскресенье в 00:00
Ярлыки @yearly, @monthly, @weekly, @daily, @hourly поддерживаются.
timeZone: killer момент
До Kubernetes v1.24 все CronJob-ы исполнялись в UTC, независимо от того, какой часовой пояс на нодах. Это была распространённая ловушка: ставишь schedule: "0 2 * * *" ожидая 02:00 Москвы, а получаешь 02:00 UTC = 05:00 Москвы.
С v1.24 (alpha) и v1.27 (GA) есть поле spec.timeZone:
spec:
schedule: "0 2 * * *"
timeZone: "Europe/Moscow"
Это запускает в 02:00 МСК независимо от того, где работает control plane. Список валидных TZ — стандартная IANA TZDB: Europe/Moscow, America/New_York, Asia/Tokyo, UTC.
Если в кластере < v1.27 — timeZone либо игнорируется (старая версия), либо нужен feature gate CronJobTimeZone. Проверьте версию кластера прежде чем полагаться на это поле. Для совместимости со старыми версиями лучше явно считать smещение и писать schedule в UTC.
concurrencyPolicy: что если предыдущий Job ещё работает
CronJob тикает по расписанию. Что если в момент тика предыдущий Job ещё не закончился?
Для большинства случаев — Forbid. Это самая безопасная стратегия.
startingDeadlineSeconds: что если controller лагал
Если CronJob controller не работал в момент scheduled time (например, control plane был недоступен), а потом восстановился — CronJob может попытаться “догнать” пропущенные тики.
startingDeadlineSeconds ограничивает это окно. Если scheduled time был N секунд назад, и N > deadline, CronJob НЕ запустит этот пропущенный Job.
spec:
schedule: "0 * * * *"
startingDeadlineSeconds: 600 # 10 минут
Если scheduled time был в 14:00, а контроллер пришёл в себя только в 14:15 — этот Job НЕ создастся (опоздание 15 мин > deadline 10 мин). Если бы пришёл в 14:08 — создался бы.
Без startingDeadlineSeconds есть особый дефолт: если controller видит >100 пропущенных тиков подряд, он перестаёт их догонять и логирует error. Это защита от “штормов”.
history limits
spec:
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
CronJob хранит последние N completed/failed Job-объектов (с их Pods). Это упрощает дебаг — можно посмотреть, что было в прошлый раз.
kubectl get jobs -l app=nightly-report
# NAME COMPLETIONS DURATION AGE
# nightly-report-28567890 1/1 42s 1d
# nightly-report-28568970 1/1 45s 1h
# nightly-report-28569540 1/1 38s 1m
# Логи последнего Job
kubectl logs -l job-name=nightly-report-28569540
kubectl: imperative создание
CKAD часто требует быстро создать Job/CronJob. Imperative команды экономят время:
# Job из image
kubectl create job hello --image=busybox:1.36 -- echo hello
# Job из CronJob (для ручного запуска)
kubectl create job manual-run --from=cronjob/nightly-report
# CronJob
kubectl create cronjob hello \
--schedule="*/5 * * * *" \
--image=busybox:1.36 \
-- echo "hello at $(date)"
# Сгенерировать YAML без создания
kubectl create cronjob hello \
--schedule="0 * * * *" \
--image=busybox \
-- /bin/sh -c "echo hello" \
--dry-run=client -o yaml