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, если они равны. Поменять только completions API не даст (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, но что эти числа значат, пока не объяснялось.

Источники


Дальше — requests и limits: как планировщик по requests решает, поместится ли под на узел, во что limits превращаются в cgroup v2 на узле, что такое CPU throttling и OOMKilled, как классы QoS определяют, кого выселят первым при нехватке памяти, и что делают LimitRange и ResourceQuota.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.