Kubernetes для самых маленьких. Часть 9: Как работают Job и CronJob?
Все части серии «Kubernetes для самых маленьких»Deployment из части 7, StatefulSet и DaemonSet из прошлой части решают одну задачу: держать процессы работающими всегда. Если контейнер завершился, его перезапускают — даже с кодом 0, потому что у этих контроллеров restartPolicy пода может быть только Always. Но многие задачи должны выполниться один раз и закончиться: миграция схемы базы перед выпуском новой версии, ночной дамп, пересчёт отчёта, обработка пачки файлов. Для них нужен контроллер, который понимает, что такое «успешно завершилось», умеет повторять при сбое, но не бесконечно, и умеет раздать работу нескольким подам. Это Job. А CronJob создаёт такие Job по расписанию — как cron на сервере, только с повторами, историей и без привязки к конкретной машине.
Job: под, который должен завершиться
Минимальный Job:
apiVersion: batch/v1
kind: Job
metadata:
name: once
spec:
template:
spec:
restartPolicy: Never
containers:
- name: once
image: busybox:1.37
command: ["sh", "-c", "echo start; sleep 3; echo done"]
Внутри — обычный шаблон пода из части 6, с одним ограничением: restartPolicy только Never или OnFailure. Always API отвергнет (spec.template.spec.restartPolicy: Required value: valid values: "OnFailure", "Never"): под, который перезапускается после успешного выхода, никогда не завершится, и Job не сможет зафиксировать успех.
Что происходит после kubectl apply. Job — объект группы batch/v1, его обслуживает контроллер Job в kube-controller-manager. Контроллер видит Job без подов и создаёт под по шаблону, добавляя к нему:
- метки
batch.kubernetes.io/job-name: onceиbatch.kubernetes.io/controller-uid: <uid Job>— по ним Job находит свои поды. Селектор Job генерируется автоматически поcontroller-uid, чтобы два Job с одинаковыми метками в шаблоне не перепутали чужие поды; задать его руками можно только сmanualSelector: true, и делать это почти никогда не нужно; - ссылку на владельца (
ownerReferences) — при удалении Job сборщик мусора удалит и поды; - финализатор
batch.kubernetes.io/job-tracking. Финализатор — запись вmetadata.finalizers, которая не даёт объекту исчезнуть из API, пока её не уберёт тот, кто поставил. Контроллер снимает его, только когда учёл под в статусе Job как успешный или неудачный. Без этого под, удалённый кем-то до того, как контроллер его увидел, пропал бы из подсчёта.
Дальше kubelet запускает под как обычно, контейнер завершается, под переходит в фазу Succeeded или Failed. Контроллер увеличивает status.succeeded или status.failed и решает, что делать дальше: создать ещё под, повторить или закончить. Готовность Job фиксируется условиями в status.conditions:
| Условие | Когда появляется |
|---|---|
SuccessCriteriaMet |
критерии успеха выполнены, оставшиеся поды останавливаются |
FailureTarget |
решено, что Job провалился, оставшиеся поды останавливаются |
Complete |
окончательный успех: все поды уже остановлены |
Failed |
окончательный провал: все поды уже остановлены |
Suspended |
Job приостановлен (suspend: true) |
Промежуточные условия появились, потому что между «решили завершить» и «все поды действительно остановились» проходит время — terminationGracePeriodSeconds у пода может быть и минуту. С 1.31 Complete и Failed ставятся только после остановки последнего пода, и тот, кто ждёт Job (например, скрипт выпуска), может положиться на то, что больше ничего не работает. Колонка STATUS в kubectl get job показывает последнее из условий, так что промежуточные SuccessCriteriaMet и FailureTarget видны и там.
После завершения Job и его поды остаются в API: можно посмотреть статус и прочитать логи. Удалять их — либо руками, либо полем ttlSecondsAfterFinished (о нём ниже). Job — не замена Deployment: если процесс должен работать постоянно, а его завернули в Job с OnFailure, он будет перезапускаться, пока не исчерпает лимит, и в итоге Job провалится.
completions и parallelism
Два поля определяют, сколько работы и с какой степенью параллельности:
completions— сколько успешных завершений нужно для успеха всего Job;parallelism— сколько подов может работать одновременно (по умолчанию 1; 0 фактически ставит Job на паузу).
Из их сочетания получаются три сценария:
1. Одна задача completions: 1 (или не задано), parallelism: 1
[под] ──успех──► Complete
2. Фиксированное число completions: 6, parallelism: 2
[под][под] → [под][под] → [под][под] 6 успехов → Complete
3. Очередь работы completions не задано, parallelism: 3
[под][под][под] каждый сам берёт задания из внешней очереди;
первый успех → новых подов нет, ждём остальных → Complete
Третий сценарий подходит, когда задания лежат во внешней очереди (брокер сообщений, таблица в базе): поды сами решают, что очередь пуста, и выходят с кодом 0. После первого успешного выхода контроллер новых подов не создаёт и считает Job выполненным, когда остановятся все остальные. От приложения здесь требуется аккуратность: успешный выход означает «работы больше нет», и остальные поды должны прийти к тому же выводу.
Indexed Job
Во втором сценарии все шесть подов одинаковы и не знают, какую часть работы им делать. Режим completionMode: Indexed (стабилен с 1.24) даёт каждому поду номер от 0 до completions - 1:
- в переменной окружения
JOB_COMPLETION_INDEX; - в аннотации и метке
batch.kubernetes.io/job-completion-index; - в имени пода (
shards-2-pk6ns) и в имени хоста пода:<имя Job>-<номер>.
Job считается выполненным, когда для каждого номера есть один успешный под. Если под с номером 3 упал, замена получит тот же номер 3. Так статическое разбиение работы делается без внешней очереди: под с номером i обрабатывает i-ю часть файла, i-й диапазон ключей, i-го клиента. С headless-сервисом из прошлой части поды индексированного Job ещё и находят друг друга по именам — так запускают распределённые вычисления, где у каждого участника свой номер.
У индексированных Job есть и удобства, которых нет у обычных:
backoffLimitPerIndexиmaxFailedIndexes(стабильны с 1.33) — лимит повторов на каждый номер отдельно. Без них один упорно падающий номер исчерпывает общий лимит повторов и проваливает весь Job, хотя остальные 99 частей отработали бы. С ними упавший номер записывается вstatus.failedIndexes, остальные продолжают, а Job в конце помечается неудачным (Failedс причинойFailedIndexes), но с понятным списком, что именно переделать: на стенде Job из четырёх номеров сbackoffLimitPerIndex: 1, где номер 2 всегда падает, закончился сcompletedIndexes: 0,1,3иfailedIndexes: "2"после двух попыток этого номера.maxFailedIndexesограничивает, сколько номеров может упасть, прежде чем Job прервётся целиком.- Эластичность (стабильна с 1.31): у работающего индексированного Job можно менять
completionsвместе сparallelism, если они равны. Поменять толькоcompletionsAPI не даст (spec.completions: Invalid value: 2: can only be modified in tandem with spec.parallelism). При уменьшении удаляются поды со старшими номерами.
Сбои: backoffLimit и restartPolicy
backoffLimit (по умолчанию 6) — сколько неудач допускается, прежде чем Job будет помечен Failed с причиной BackoffLimitExceeded. Повторы идут с экспоненциальной задержкой: 10 секунд, 20, 40 и так далее, но не больше десяти минут. Задержка нужна, чтобы задание, которое падает из-за недоступной базы, не долбило её каждую секунду.
Как считаются неудачи, зависит от restartPolicy в шаблоне, и разница существенная:
restartPolicy: Never. Упавший под остаётся в фазе Failed, контроллер Job создаёт новый под. Неудача = упавший под. Каждая попытка — отдельный под со своими логами, и после провала Job все они лежат для разбора: kubectl logs fail-rs22z.
restartPolicy: OnFailure. Под остаётся тот же, а kubelet перезапускает контейнер внутри него — с той же экспоненциальной задержкой, которую вы видели как CrashLoopBackOff. Неудачей считается каждый перезапуск контейнера. Когда лимит исчерпан, контроллер удаляет под (событие SuccessfulDelete … Deleted pod), и вместе с ним пропадают логи последней попытки — документация прямо предупреждает, что это мешает разбору. Плюс OnFailure: перезапуск в том же поде быстрее, не нужно заново планировать и скачивать образ, и сохраняется содержимое emptyDir.
Рекомендация: Never по умолчанию. Логи всех попыток сохраняются, неудачи считаются понятно, и только с Never работает политика сбоев podFailurePolicy из следующего раздела. OnFailure — для заданий, где быстрый перезапуск важнее разбора, а логи всё равно уходят в систему сбора логов (часть 33).
Ещё одна деталь: в какой момент создавать замену. По умолчанию (podReplacementPolicy: TerminatingOrFailed) контроллер создаёт новый под, как только старый начал останавливаться, — то есть некоторое время работают оба. Удалённый под при этом засчитывается как неудача: на стенде после kubectl delete pod у такого Job в статусе сразу стало active: 1, failed: 1, terminating: 1. Для заданий, которым нельзя работать вдвоём (держат блокировку, пишут в один файл), есть podReplacementPolicy: Failed (стабильно с 1.34): замена появляется только после того, как старый под окончательно перешёл в Failed. Число останавливающихся подов видно в status.terminating.
podFailurePolicy: не все сбои одинаковы
backoffLimit считает все неудачи одинаково. Но причины бывают разные:
- ошибка в коде или данных — повторять бессмысленно, сколько ни пытайся, результат будет тот же; лучше сразу провалить Job и не тратить шесть попыток и десятки минут на задержки между ними;
- сбой инфраструктуры — узел выводят на обслуживание (
kubectl drain), под вытеснил более приоритетный, узел пропал. Задание не виновато, и такие неудачи не стоит засчитывать в лимит.
Для этого есть podFailurePolicy (стабильна с 1.31) — упорядоченный список правил, которые проверяются для каждого упавшего пода:
spec:
backoffLimit: 3
podFailurePolicy:
rules:
- action: FailJob # код 42 = «данные битые», повторять бессмысленно
onExitCodes:
containerName: worker
operator: In
values: [42]
- action: Ignore # под выселили — не считать попыткой
onPodConditions:
- type: DisruptionTarget
template:
spec:
restartPolicy: Never # обязательно для podFailurePolicy
containers:
- name: worker
# ...
Действия правил:
| Действие | Что делает |
|---|---|
FailJob |
сразу провалить весь Job и остановить остальные поды |
Ignore |
не увеличивать счётчик неудач, просто создать замену |
Count |
обычная обработка: засчитать в backoffLimit |
FailIndex |
для индексированных Job с backoffLimitPerIndex: провалить только этот номер, без повторов |
Условие onExitCodes смотрит на коды выхода контейнеров (In или NotIn), onPodConditions — на условия пода. DisruptionTarget kubelet и контроллеры ставят на под, который удаляют по внешней причине: вытеснение, выселение через API (так работает drain, причина условия — EvictionByEvictionAPI), удаление с узла с taint NoExecute, сбор мусора на исчезнувшем узле. Правила проверяются по порядку, срабатывает первое подходящее; если не подошло ни одно, сбой засчитывается как обычно. На стенде Job с backoffLimit: 0 и правилом Ignore пережил выселение своего пода: счётчик failed не вырос, а замену контроллер создал, когда выселенный под исчез.
Требования: restartPolicy: Never в шаблоне (иначе only "Never" is supported when podFailurePolicy is specified), а podReplacementPolicy при политике сбоев может быть только Failed — контроллер должен увидеть окончательную фазу пода и его коды выхода, прежде чем решать. Если поле не задано, API подставит Failed сам, а TerminatingOrFailed отвергнет с ошибкой Unsupported value: "TerminatingOrFailed": supported values: "Failed".
Для приложения из этого следует простое правило: договоритесь о кодах выхода. Код 0 — успех, временная ошибка (база недоступна) — обычный ненулевой код, который стоит повторить, постоянная ошибка (неверные параметры, битый вход) — отдельный код, на который настроен FailJob.
successPolicy: когда успех наступает раньше
Обычно индексированный Job успешен, когда отработали все номера. Бывает, что достаточно части: из пяти вариантов расчёта нужен один, сошедшийся; в схеме «ведущий и рабочие» итог определяет только ведущий с номером 0. Для этого есть successPolicy (стабильна с 1.33, только для completionMode: Indexed):
spec:
completionMode: Indexed
completions: 5
parallelism: 5
successPolicy:
rules:
- succeededIndexes: "0" # успех, как только номер 0 завершился успешно
У правила два поля, которые можно сочетать: succeededIndexes — список номеров и диапазонов ("0,2-3"), succeededCount — сколько из них должно завершиться. Правило из одного succeededCount: 3 означает «любые три номера». Когда правило выполнено, Job получает условие SuccessCriteriaMet с причиной SuccessPolicy, контроллер останавливает оставшиеся поды и, когда они остановились, ставит Complete. Если одновременно сработал критерий провала (backoffLimit, podFailurePolicy), побеждает провал.
Время и уборка: activeDeadlineSeconds, ttlSecondsAfterFinished, suspend
activeDeadlineSeconds в спецификации Job — предельная длительность всего Job, со всеми повторами. По истечении контроллер останавливает работающие поды и помечает Job Failed с причиной DeadlineExceeded (Job was active longer than specified deadline), даже если backoffLimit ещё не исчерпан. Не путать с одноимённым полем в спецификации пода: оно ограничивает жизнь одного пода, и Job его не учитывает. Для любого задания по расписанию предел на уровне Job стоит ставить всегда: зависший дамп не должен работать до следующего запуска.
ttlSecondsAfterFinished — через сколько секунд после окончания (успешного или нет) Job удаляется вместе с подами. Этим занимается отдельный TTL-контроллер в kube-controller-manager. Документация рекомендует ставить это поле всем Job, созданным напрямую (не через CronJob): завершённые объекты копятся в etcd и нагружают API-сервер. К тому же у таких Job политика удаления по умолчанию — orphanDependents: если удалить Job через API, не указав каскадное удаление, его поды останутся (kubectl delete сам просит удалить и их). Значение 0 — удалить сразу после окончания; значение, достаточное, чтобы успеть прочитать логи, — разумнее: час или сутки.
suspend: true приостанавливает Job: работающие поды удаляются, новые не создаются, Job получает условие Suspended с причиной JobSuspended. Снять приостановку — вернуть false: появится событие Resumed, а status.startTime получит новое значение, так что отсчёт activeDeadlineSeconds (и колонка DURATION) начнётся заново. Так внешние системы очередей (например, Kueue — диспетчер пакетных заданий для Kubernetes, который распределяет квоты между командами) создают Job приостановленными и запускают, когда для них нашлись ресурсы. У приостановленного Job можно менять ограничения размещения в шаблоне, а с 1.36 в статусе beta — и ресурсы контейнеров.
И о sidecar-контейнерах из части 6. Раньше они были проблемой для Job: прокси или пересыльщик логов в поде никогда не завершается сам, и под с ним не доходил до Succeeded. Встроенные sidecar-контейнеры (init-контейнер с restartPolicy: Always, стабильно с 1.33) это решают: kubelet останавливает их, когда завершились основные контейнеры, и Job получает успешный под.
CronJob: Job по расписанию
CronJob — это шаблон Job плюс расписание:
apiVersion: batch/v1
kind: CronJob
metadata:
name: tick
spec:
schedule: "*/1 * * * *"
timeZone: Etc/UTC
jobTemplate:
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: tick
image: busybox:1.37
command: ["sh", "-c", "date -u; echo tick from $(hostname)"]
Его обслуживает контроллер CronJob в kube-controller-manager. Он не держит таймеров на каждое расписание: для каждого CronJob вычисляет следующий момент запуска, ставит объект в очередь на это время, а когда время пришло, создаёт Job из jobTemplate и записывает время в status.lastScheduleTime, а после успешного окончания Job — в status.lastSuccessfulTime. Дальше Job живёт своей жизнью, CronJob только следит за историей.
Формат расписания
Пять полей, как в классическом cron:
┌───────────── минута (0–59)
│ ┌─────────── час (0–23)
│ │ ┌───────── день месяца (1–31)
│ │ │ ┌─────── месяц (1–12)
│ │ │ │ ┌───── день недели (0–6, 0 = воскресенье; или sun–sat)
│ │ │ │ │
30 1 * * * каждый день в 01:30
0 */4 * * * каждые четыре часа, ровно в начале часа
15 3 * * 1-5 в 03:15 по будням
0 0 1 * * в полночь первого числа месяца
Работают диапазоны (1-5), списки (1,15), шаги (*/4, 0-23/2), ? как синоним * и макросы @hourly, @daily (он же @midnight), @weekly, @monthly, @yearly (он же @annually). Поля секунд нет: минимальный интервал — минута, а расписание из шести полей API отвергнет (expected exactly 5 fields, found 6).
Часовой пояс. Без поля timeZone расписание считается в часовом поясе процесса kube-controller-manager. У kubeadm он работает в контейнере статического пода, куда /etc/localtime узла не смонтирован, так что на практике это UTC — и сюрприз для того, кто написал 0 2 * * *, имея в виду два часа ночи по местному времени. На стенде узлы живут в поясе +03, а CronJob без timeZone с расписанием 38 7 * * * запустился в 07:38 UTC. Поле timeZone (стабильно с 1.27) принимает имя из базы часовых поясов IANA: Etc/UTC, Europe/Berlin, Asia/Tokyo; опечатку API не пропустит (unknown time zone Europe/Minskk). База встроена в бинарные файлы Kubernetes, так что от узла она не зависит. Писать часовой пояс внутри расписания (CRON_TZ=Europe/Berlin 0 2 * * *) нельзя: такой CronJob API отвергнет с ошибкой cannot use TZ or CRON_TZ in schedule, use timeZone field instead.
С местным часовым поясом есть одна ловушка: в дни перехода на летнее и зимнее время один час суток не существует или повторяется. Задание, назначенное на такой час, может не запуститься или сдвинуться. Важные задания либо планируют в UTC, либо ставят на время, которое переходы не задевают.
Имена, история и ручной запуск
Job, созданный по расписанию, называется <имя CronJob>-<время запуска в минутах от начала эпохи Unix>: tick-29886232. Имя CronJob ограничено 52 символами (must be no more than 52 characters): сейчас суффикс занимает девять символов — дефис и восемь цифр, но документация резервирует под него 11, а имя Job не может быть длиннее 63. Запланированное время записывается в аннотацию batch.kubernetes.io/cronjob-scheduled-timestamp в формате даты и времени RFC 3339 (с 1.32).
Завершённые Job CronJob удаляет сам, оставляя successfulJobsHistoryLimit успешных (по умолчанию 3) и failedJobsHistoryLimit неудачных (по умолчанию 1). Неудачные стоит хранить дольше — хотя бы 3, — чтобы было с чем разбираться утром.
Запустить задание вне расписания — например, проверить новый CronJob, не дожидаясь ночи:
$ kubectl create job --from=cronjob/pg-backup pg-backup-manual-1
job.batch/pg-backup-manual-1 created
kubectl берёт jobTemplate из CronJob и создаёт Job с аннотацией cronjob.kubernetes.io/instantiate: manual и ссылкой на CronJob в ownerReferences. Расписание это не сдвигает. Из-за ссылки на владельца ручной Job становится частью истории CronJob: контроллер пишет о нём предупреждение UnexpectedJob («Saw a job that the controller did not create or forgot»), учитывает его в successfulJobsHistoryLimit и удаляет вместе с CronJob.
concurrencyPolicy: что если прошлый запуск ещё идёт
| Значение | Поведение |
|---|---|
Allow (по умолчанию) |
новый Job создаётся, даже если предыдущий работает; они идут параллельно |
Forbid |
новый запуск откладывается, пока предыдущий не закончится |
Replace |
предыдущий Job удаляется, вместо него запускается новый |
Политика действует только на Job одного CronJob. Для резервных копий, миграций и всего, что держит блокировки, нужен Forbid: два дампа одной базы одновременно в лучшем случае вдвое нагружают её, в худшем — портят файл. Replace подходит заданиям, где нужен только самый свежий результат, а старый запуск уже неактуален.
У Forbid есть тонкость: запуск, пришедшийся на работающий Job, не теряется сразу. Контроллер пишет событие JobAlreadyActive («Not starting job because prior execution is running and concurrency policy is Forbid»), а когда предыдущий Job заканчивается, запускает отложенный с опозданием — если опоздание укладывается в startingDeadlineSeconds. На стенде CronJob раз в минуту с заданием на 90 секунд запустил Job за 07:38 в 07:38:49, сразу после окончания предыдущего, а запуск 07:39 пропустил. Без startingDeadlineSeconds опоздание не ограничено, и после долгого дампа следующий может начаться сразу же, днём.
startingDeadlineSeconds и пропущенные запуски
Контроллер может не успеть создать Job вовремя: kube-controller-manager перезапускался, мастер обслуживали, при Forbid предыдущий запуск ещё шёл. startingDeadlineSeconds говорит, насколько опоздавший запуск ещё имеет смысл: при значении 600 Job, который не удалось создать в течение десяти минут после назначенного времени, пропускается и считается неудачным. Без этого поля опоздание не ограничено.
Сколько бы запусков ни пропустилось, контроллер не догоняет их по одному: он создаёт не больше одного Job — для последнего пропущенного времени. Заодно он считает, сколько запусков пропущено с последнего запланированного времени (или, если поле задано, только за последние startingDeadlineSeconds секунд), и если их больше 100, пишет событие TooManyMissedTimes: too many missed start times. Set or decrease .spec.startingDeadlineSeconds or check clock skew. Для CronJob раз в минуту это наступает после 100 минут простоя. Документация говорит, что в этом случае Job не запускается, но в 1.36 это только предупреждение: на стенде CronJob раз в минуту, у которого последний запуск был три часа назад, получил это событие и всё равно создал Job для текущей минуты. С startingDeadlineSeconds окно подсчёта короткое, и предупреждения нет. Значение меньше 10 секунд ставить не стоит: контроллер проверяет расписания раз в 10 секунд и может не успеть вообще.
suspend
suspend: true у CronJob останавливает создание новых Job; уже запущенные продолжают работать. Удобно на время обслуживания базы: kubectl patch cronjob pg-backup -p '{"spec":{"suspend":true}}'. Пока CronJob приостановлен, запуски копятся как пропущенные, и после снятия приостановки контроллер сразу создаст Job для последнего из них, если startingDeadlineSeconds это позволяет.
Задания должны быть идемпотентными
Документация говорит прямо: CronJob создаёт Job примерно один раз на каждое время по расписанию. В редких случаях — сбой контроллера в момент создания, повторная попытка — Job может быть создан дважды или не создан вовсе. Плюс собственные повторы Job по backoffLimit: упавший на середине дамп будет запущен заново. Значит, задание должно быть идемпотентным: повторный запуск с теми же входными данными даёт тот же результат и ничего не ломает.
Практические приёмы:
- результат по имени запуска, а не по текущему времени. Повторный запуск того же расписания перезапишет тот же файл, а не создаст второй;
- запись во временный файл и атомарное переименование. Упавший на середине процесс оставит
.tmp, а не обрезанный файл с правильным именем, который потом примут за целый; - проверка «уже сделано» в начале: если результат за этот запуск есть и он целый, выйти с кодом 0;
- блокировка во внешней системе, если задание меняет общие данные: рекомендательная блокировка (advisory lock) в базе или объект Lease в API — им же компоненты Kubernetes договариваются, кто из них сейчас главный;
- для миграций — инструмент, который сам ведёт журнал применённых шагов и не применяет их повторно.
Пример: дамп PostgreSQL по расписанию
Соберём всё вместе. В кластере работает учебный PostgreSQL — реляционная СУБД — с сервисом pg на порту 5432; пароль лежит в Secret pg-credentials (как правильно хранить и передавать секреты — в части 12, как ставить базы в кластер — в частях 25 и 28). Дампы складываются на том из заявки pg-backups: в части 23 она будет выделена на NFS-сервере, а до тех пор под будет ждать её в Pending.
apiVersion: batch/v1
kind: CronJob
metadata:
name: pg-backup
spec:
schedule: "30 1 * * *" # 01:30 — переходы на летнее время не задевают
timeZone: Europe/Berlin
concurrencyPolicy: Forbid # два дампа одновременно не нужны
startingDeadlineSeconds: 1800 # опоздал больше чем на 30 минут — пропустить
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600 # не больше часа со всеми повторами
podFailurePolicy:
rules:
- action: Ignore # выселение с узла не считается попыткой
onPodConditions:
- type: DisruptionTarget
template:
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 70 # пользователь postgres в образе alpine
runAsGroup: 70
fsGroup: 70
containers:
- name: dump
image: postgres:18.6-alpine
env:
- name: PGHOST
value: pg
- name: PGUSER
value: app
- name: PGDATABASE
value: app
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: pg-credentials
key: password
- name: JOB_NAME
valueFrom:
fieldRef:
fieldPath: metadata.labels['batch.kubernetes.io/job-name']
command:
- sh
- -ec
- |
out="/backups/${PGDATABASE}-${JOB_NAME}.dump"
if [ -s "$out" ]; then echo "already done: $out"; exit 0; fi
pg_dump --format=custom --file="$out.tmp"
pg_restore --list "$out.tmp" > /dev/null # проверить, что архив читается
mv "$out.tmp" "$out"
find /backups -name '*.dump' -mtime +14 -delete
echo "ok: $out $(du -h "$out" | cut -f1)"
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 512Mi
volumeMounts:
- name: backups
mountPath: /backups
volumes:
- name: backups
persistentVolumeClaim:
claimName: pg-backups
Что здесь сделано для надёжности:
- имя файла — из имени Job, которое содержит запланированное время: запуск 29 октября 2026 года в 01:30 по Берлину запишет
app-pg-backup-29887230.dump. Имя попадает в контейнер через Downward API — механизм, которым под получает сведения о самом себе (метки, имя, ресурсы) в переменные или файлы. Повтор того же запуска найдёт готовый файл и выйдет, ручной запускpg-backup-manual-1создаст отдельный файл; - дамп пишется в
.tmp, проверяетсяpg_restore --listи только потом переименовывается;mvв пределах одного каталога атомарен; sh -eпрерывает скрипт на первой ошибке, и под завершится с ненулевым кодом, а не отчитается об успехе после неудачногоpg_dump;Forbid,activeDeadlineSeconds,startingDeadlineSeconds— дамп не наложится на дамп, не будет висеть сутками и не запустится днём после ночного простоя;- пароль — из Secret через
secretKeyRef, а не в тексте манифеста;PGPASSWORD— переменная, которую читают все утилиты PostgreSQL; - процесс не от root и с ограничением памяти;
fsGroup: 70отдаёт каталог тома группе 70, и пользовательpostgresможет в него писать; - сохраняются три неудачных Job, чтобы утром было что читать.
С временной базой и временным томом пример отрабатывает так: первый ручной запуск печатает ok: /backups/app-pg-backup-manual-1.dump 4.0K, а повторный запуск Job с тем же именем — already done: /backups/app-pg-backup-manual-1.dump.
Чего этот пример не делает и что нужно добавить в настоящей эксплуатации: копию дампов вне кластера и вне того же хранилища (резервная копия на том же NFS, что и база, не переживёт потерю NFS) и регулярную проверку восстановления — дамп, из которого ни разу не восстанавливались, резервной копией считать рано. И оповещение о том, что последний успешный дамп старше суток: CronJob, который тихо перестал работать, — частая история. Метрика kube_cronjob_status_last_successful_time из kube-state-metrics (экспортёра метрик о состоянии объектов Kubernetes) и правило оповещения в системе мониторинга Prometheus закрывают это; о них в части 32.
Смотрим руками
Начнём с индексированного Job. Четыре части работы, по две одновременно:
apiVersion: batch/v1
kind: Job
metadata:
name: shards
spec:
completionMode: Indexed
completions: 4
parallelism: 2
ttlSecondsAfterFinished: 3600
template:
spec:
restartPolicy: Never
containers:
- name: worker
image: busybox:1.37
command: ["sh", "-c", "echo shard $JOB_COMPLETION_INDEX of 4 on $(hostname); sleep 5"]
$ kubectl apply -f shards.yaml
job.batch/shards created
$ kubectl get pods -l batch.kubernetes.io/job-name=shards -w
NAME READY STATUS RESTARTS AGE
shards-0-plmbf 0/1 Pending 0 0s
shards-1-6589g 0/1 Pending 0 0s
shards-0-plmbf 0/1 Pending 0 0s
shards-1-6589g 0/1 Pending 0 0s
shards-0-plmbf 0/1 ContainerCreating 0 0s
shards-1-6589g 0/1 ContainerCreating 0 0s
shards-0-plmbf 0/1 ContainerCreating 0 1s
shards-1-6589g 0/1 ContainerCreating 0 2s
shards-0-plmbf 0/1 ContainerCreating 0 3s
shards-1-6589g 0/1 ContainerCreating 0 4s
shards-0-plmbf 1/1 Running 0 4s
shards-1-6589g 1/1 Running 0 5s
shards-0-plmbf 0/1 Completed 0 10s
shards-1-6589g 0/1 Completed 0 11s
shards-0-plmbf 0/1 Completed 0 12s
shards-1-6589g 0/1 Completed 0 12s
shards-0-plmbf 0/1 Completed 0 13s
shards-1-6589g 0/1 Completed 0 13s
shards-2-pk6ns 0/1 Pending 0 0s
shards-2-pk6ns 0/1 Pending 0 0s
shards-3-hwx4w 0/1 Pending 0 0s
shards-3-hwx4w 0/1 Pending 0 0s
shards-2-pk6ns 0/1 ContainerCreating 0 0s
shards-0-plmbf 0/1 Completed 0 15s
shards-1-6589g 0/1 Completed 0 15s
shards-3-hwx4w 0/1 ContainerCreating 0 1s
shards-2-pk6ns 0/1 ContainerCreating 0 2s
shards-3-hwx4w 0/1 ContainerCreating 0 2s
shards-2-pk6ns 0/1 ContainerCreating 0 3s
shards-3-hwx4w 0/1 ContainerCreating 0 4s
shards-2-pk6ns 1/1 Running 0 5s
shards-3-hwx4w 1/1 Running 0 6s
shards-2-pk6ns 0/1 Completed 0 11s
shards-3-hwx4w 0/1 Completed 0 12s
shards-3-hwx4w 0/1 Completed 0 13s
shards-2-pk6ns 0/1 Completed 0 13s
shards-3-hwx4w 0/1 Completed 0 14s
shards-3-hwx4w 0/1 Completed 0 14s
shards-2-pk6ns 0/1 Completed 0 14s
$ kubectl logs shards-2-pk6ns
shard 2 of 4 on shards-2
kubectl get -w печатает строку на каждое изменение объекта, поэтому одинаковые строки идут подряд: после Completed у пода меняются статус и финализаторы, и каждое такое изменение — ещё одна строка. Номер — в имени пода и в имени хоста. Пока под работает, на нём видны финализатор и метки Job:
$ kubectl get pod shards-1-6589g -o jsonpath='{.metadata.finalizers}{"\n"}{.metadata.labels}{"\n"}'
["batch.kubernetes.io/job-tracking"]
{"batch.kubernetes.io/controller-uid":"24fe1b9f-84b8-4ba6-907b-100e61078139","batch.kubernetes.io/job-completion-index":"1","batch.kubernetes.io/job-name":"shards","controller-uid":"24fe1b9f-84b8-4ba6-907b-100e61078139","job-name":"shards"}
Метки без префикса batch.kubernetes.io/ — старые имена, которые контроллер ставит для совместимости. Статус Job после окончания:
$ kubectl get job shards
NAME STATUS COMPLETIONS DURATION AGE
shards Complete 4/4 29s 32s
$ kubectl get job shards -o yaml | sed -n '/^status:/,$p'
status:
completedIndexes: 0-3
completionTime: "2026-10-28T07:30:31Z"
conditions:
- lastProbeTime: "2026-10-28T07:30:31Z"
lastTransitionTime: "2026-10-28T07:30:31Z"
message: Reached expected number of succeeded pods
reason: CompletionsReached
status: "True"
type: SuccessCriteriaMet
- lastProbeTime: "2026-10-28T07:30:31Z"
lastTransitionTime: "2026-10-28T07:30:31Z"
message: Reached expected number of succeeded pods
reason: CompletionsReached
status: "True"
type: Complete
ready: 0
startTime: "2026-10-28T07:30:02Z"
succeeded: 4
terminating: 0
uncountedTerminatedPods: {}
uncountedTerminatedPods — поды, которые уже завершились, но ещё не учтены в счётчиках: именно с них контроллер снимает финализатор после учёта.
Теперь сбой. Job, который всегда падает, с backoffLimit: 2:
apiVersion: batch/v1
kind: Job
metadata:
name: fail
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: worker
image: busybox:1.37
command: ["sh", "-c", "echo trying; exit 1"]
$ kubectl apply -f fail.yaml
job.batch/fail created
$ kubectl get pods -l batch.kubernetes.io/job-name=fail
NAME READY STATUS RESTARTS AGE
fail-6xrgr 0/1 Error 0 39s
fail-hlzgt 0/1 Error 0 61s
fail-rs22z 0/1 Error 0 75s
$ kubectl describe job fail | sed -n '/^Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulCreate 75s job-controller Created pod: fail-rs22z
Normal SuccessfulCreate 61s job-controller Created pod: fail-hlzgt
Normal SuccessfulCreate 39s job-controller Created pod: fail-6xrgr
Warning BackoffLimitExceeded 32s job-controller Job has reached the specified backoff limit
При restartPolicy: Never каждая попытка — отдельный под: первая и две повторные. Паузы считаются от падения предыдущего пода: контейнер работает около трёх секунд, затем 10 секунд ждём до второй попытки и 20 — до третьей. Все три пода остались, и логи каждого можно прочитать. В статусе Job, как и при успехе, два условия: FailureTarget и Failed, оба с причиной BackoffLimitExceeded.
Политика сбоев. Job datacheck — тот же манифест, но контейнер выходит с кодом 42, а в спецификации стоит podFailurePolicy из раздела выше:
$ kubectl get job datacheck -o jsonpath='{range .status.conditions[*]}{.type}{"\t"}{.reason}{"\t"}{.message}{"\n"}{end}'
FailureTarget PodFailurePolicy Container worker for pod default/datacheck-h7pnp failed with exit code 42 matching FailJob rule at index 0
Failed PodFailurePolicy Container worker for pod default/datacheck-h7pnp failed with exit code 42 matching FailJob rule at index 0
$ kubectl get pods -l batch.kubernetes.io/job-name=datacheck
NAME READY STATUS RESTARTS AGE
datacheck-h7pnp 0/1 Error 0 20s
Одна попытка вместо четырёх: правило сработало на первом же сбое.
Промежуточные условия лучше всего видны на successPolicy. Job leader: пять номеров, номер 0 работает пять секунд, остальные — десять минут:
apiVersion: batch/v1
kind: Job
metadata:
name: leader
spec:
completionMode: Indexed
completions: 5
parallelism: 5
successPolicy:
rules:
- succeededIndexes: "0"
template:
spec:
restartPolicy: Never
containers:
- name: worker
image: busybox:1.37
command: ["sh", "-c", "if [ $JOB_COMPLETION_INDEX -eq 0 ]; then sleep 5; else sleep 600; fi"]
$ kubectl get job leader
NAME STATUS COMPLETIONS DURATION AGE
leader SuccessCriteriaMet 1/5 46s 46s
$ kubectl get pods -l batch.kubernetes.io/job-name=leader
NAME READY STATUS RESTARTS AGE
leader-0-92xjf 0/1 Completed 0 46s
leader-1-76jp9 1/1 Terminating 0 46s
leader-2-l9r54 1/1 Terminating 0 46s
leader-3-8jrs7 1/1 Terminating 0 46s
leader-4-268qv 1/1 Terminating 0 46s
$ kubectl get job leader -o jsonpath='{range .status.conditions[*]}{.type}{"\t"}{.reason}{"\t"}{.lastTransitionTime}{"\n"}{end}'
SuccessCriteriaMet SuccessPolicy 2026-10-28T07:34:41Z
Complete SuccessPolicy 2026-10-28T07:35:18Z
Номер 0 закончился, правило выполнено, и контроллер останавливает остальные поды. sh в роли PID 1 сигнал SIGTERM не обрабатывает, поэтому поды живут до конца terminationGracePeriodSeconds (30 секунд по умолчанию), и Complete появляется через 37 секунд после SuccessCriteriaMet, когда остановился последний. Этот Job дальше не понадобится:
$ kubectl delete job leader
job.batch "leader" deleted from default namespace
Теперь CronJob tick из раздела о CronJob, раз в минуту:
$ kubectl apply -f tick.yaml
cronjob.batch/tick created
$ kubectl get cronjob tick
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
tick */1 * * * * Etc/UTC False 0 55s 3m5s
$ kubectl get jobs
NAME STATUS COMPLETIONS DURATION AGE
datacheck Failed 0/1 3m20s 3m20s
fail Failed 0/1 4m30s 4m30s
shards Complete 4/4 30s 16m
tick-29886241 Complete 1/1 7s 2m55s
tick-29886242 Complete 1/1 7s 115s
tick-29886243 Complete 1/1 7s 55s
$ kubectl get job tick-29886243 -o jsonpath='{.metadata.annotations}{"\n"}{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}{"\n"}'
{"batch.kubernetes.io/cronjob-scheduled-timestamp":"2026-10-28T08:03:00Z"}
CronJob/tick
$ kubectl logs job/tick-29886243
Wed Oct 28 08:03:03 UTC 2026
tick from tick-29886243-vscfd
29886243 минуты от 1 января 1970 года — это 28 октября 2026, 08:03 UTC, что и записано в аннотации. У проваленных fail и datacheck колонка DURATION растёт вместе с AGE: completionTime ставится только успешному Job, и kubectl считает длительность до текущего момента. Через пять минут в списке по-прежнему будет три Job от tick: старые удаляются по successfulJobsHistoryLimit. Ручной запуск и приостановка:
$ kubectl create job --from=cronjob/tick tick-manual
job.batch/tick-manual created
$ kubectl get job tick-manual -o jsonpath='{.metadata.annotations}{"\n"}{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}{"\n"}'
{"cronjob.kubernetes.io/instantiate":"manual"}
CronJob/tick
$ kubectl patch cronjob tick -p '{"spec":{"suspend":true}}'
cronjob.batch/tick patched
$ kubectl get cronjob tick
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
tick */1 * * * * Etc/UTC True 0 81s 3m31s
$ kubectl get jobs
NAME STATUS COMPLETIONS DURATION AGE
datacheck Failed 0/1 3m46s 3m46s
fail Failed 0/1 4m56s 4m56s
shards Complete 4/4 30s 16m
tick-29886242 Complete 1/1 7s 2m21s
tick-29886243 Complete 1/1 7s 81s
tick-manual Complete 1/1 7s 25s
$ kubectl describe cronjob tick | sed -n '/^Events:/,$p'
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal SuccessfulCreate 3m21s cronjob-controller Created job tick-29886241
Normal SawCompletedJob 3m14s cronjob-controller Saw completed job: tick-29886241, condition: Complete
Normal SuccessfulCreate 2m21s cronjob-controller Created job tick-29886242
Normal SawCompletedJob 2m14s cronjob-controller Saw completed job: tick-29886242, condition: Complete
Normal SuccessfulCreate 81s cronjob-controller Created job tick-29886243
Normal SawCompletedJob 74s cronjob-controller Saw completed job: tick-29886243, condition: Complete
Warning UnexpectedJob 18s (x5 over 25s) cronjob-controller Saw a job that the controller did not create or forgot: tick-manual
Normal SuccessfulDelete 18s cronjob-controller Deleted job tick-29886241
Ручной tick-manual принадлежит CronJob, поэтому попал в историю: успешных Job снова три, и самый старый, tick-29886241, контроллер удалил, хотя после приостановки новых запусков по расписанию не было.
И последнее — где всё это лежит. Job и CronJob хранятся в etcd — хранилище, где API-сервер держит все объекты кластера, — под ключами /registry/jobs/<неймспейс>/<имя> и /registry/cronjobs/<неймспейс>/<имя>, как любые другие объекты (как читать etcd напрямую — в части 34). Никакого состояния «вне API» у этих контроллеров нет: перезапуск kube-controller-manager ничего не теряет, новый экземпляр прочитает Job, поды и их финализаторы и продолжит с того же места. Удалим учебные объекты:
$ kubectl delete cronjob tick
cronjob.batch "tick" deleted from default namespace
$ kubectl delete job fail datacheck
job.batch "fail" deleted from default namespace
job.batch "datacheck" deleted from default namespace
Job, созданные CronJob, включая ручной tick-manual, сборщик мусора удаляет вместе с ним: их владелец — CronJob. Job shards удалит TTL-контроллер через час после окончания.
В примерах этой и прошлой части у контейнеров встречались resources с requests и limits, но что эти числа значат, пока не объяснялось.
Источники
- Kubernetes: Job (режимы
completionsиparallelism, Indexed Job,backoffLimitи задержки,backoffLimitPerIndex,podFailurePolicy,successPolicy, условия Job,suspend, эластичные индексированные Job,podReplacementPolicy, финализаторjob-tracking), CronJob (формат расписания,timeZone,concurrencyPolicy,startingDeadlineSeconds, история, ограничения), автоматическая уборка завершённых Job, политика сбоев подов по шагам, индексированный Job со статическим разбиением работы, задачи по расписанию с CronJob. - Справочник API: Job, CronJob; известные метки и аннотации (
batch.kubernetes.io/job-name,job-completion-index,cronjob-scheduled-timestamp),kubectl create job, feature gates (MutablePodResourcesForSuspendedJobs). - Объекты и поды: финализаторы, владельцы и зависимые объекты, сборка мусора, условия прерывания подов (
DisruptionTarget), sidecar-контейнеры, Downward API, Lease. - Исходный код контроллеров в ветке 1.36: контроллер Job (предел задержки
MaxJobPodFailureBackOff— десять минут), расчёт пропущенных запусков CronJob. - Kueue, база часовых поясов IANA, метрики CronJob в kube-state-metrics.
- PostgreSQL: pg_dump, pg_restore, переменные окружения libpq (
PGHOST,PGPASSWORD), образ postgres.
Дальше — requests и limits: как планировщик по requests решает, поместится ли под на узел, во что limits превращаются в cgroup v2 на узле, что такое CPU throttling и OOMKilled, как классы QoS определяют, кого выселят первым при нехватке памяти, и что делают LimitRange и ResourceQuota.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.