Почему backoffLimit=0 роняет пакетный Job на обслуживании узла — и как мы это закрыли
Клиент держит backoffLimit=0 на всех пакетных Job принципиально: если приложение упало — падать сразу, без слепого повтора, потому что повтор кривой миграции БД или дублирующей выгрузки в 1С дороже, чем ручной перезапуск. Логика понятная и правильная. Но при обновлении узлов до Kubernetes 1.36 мы получили ровно то, от чего backoffLimit=0 должен защищать: Job падал в Failed не потому, что приложение сломалось, а потому что kubectl drain вытолкнул под с узла под обслуживание. Ниже — как мы развели эти два разных «упал» на уровне API Job, без ослабления backoffLimit.
Симптом: один и тот же Failed на два разных события
Формулирую задачу так, как она стояла у нас: пакетная обработка (ночная выгрузка остатков в 1С, агрегация логов, batch-расчёт тарифов — конкретный кейс не важен, механика одна) запускается как Kubernetes Job с backoffLimit: 0. Смысл нулевого лимита — не тратить ни одной повторной попытки на ошибку самого приложения: если контейнер вышел с ненулевым кодом, значит баг в коде или в данных, и его нужно чинить руками, а не заливать повторными запусками.
Проблема вскрылась в плановое окно обновления узлов до 1.36: администратор кластера выполнил kubectl drain node-worker-07 --ignore-daemonsets --delete-emptydir-data, под с работающим Job попал под вытеснение — и Job тут же перешёл в status.conditions[].type: Failed с причиной BackoffLimitExceeded, а в событиях появилась строка «Job has reached the specified backoff limit». С точки зрения контроллера Job это правильное поведение: он считает провалившиеся поды и как только их число превышает backoffLimit, помечает весь Job неуспешным. С точки зрения эксплуатации это ложный негатив — приложение не при чём, отработать оно не успело физически.
Для нас как для подрядчика это не абстрактная теория API — это конкретная строка в отчёте клиенту: «ночная выгрузка не выполнена», хотя приложение отработало бы штатно, если бы его не прервали на середине планового окна обслуживания. Разбираться, была ли это ошибка кода или ошибка планирования узлов, приходится вручную по логам события Job и по журналу обновления кластера — и это тратит время инженера ровно на то, что должно определяться автоматически по одному полю в манифесте.
Что происходит с подом Job технически в момент drain
Чтобы решить проблему правильно, а не патчем «увеличить backoffLimit до 2 и надеяться», разберём цепочку событий по официальной документации Kubernetes и своим наблюдениям на кластере.
kubectl drain не убивает поды напрямую — он обращается к Eviction API. Для каждого пода, который не относится к DaemonSet, API создаёт объект Eviction, что равносильно контролируемому удалению с уважением PodDisruptionBudget, если он привязан к этим подам. Если под не защищён PDB (для Job-подов с restartPolicy: Never это почти всегда так — PDB на них никто не заводит), eviction проходит немедленно: kubelet посылает контейнеру SIGTERM, ждёт terminationGracePeriodSeconds и по истечении срока — SIGKILL.
Параллельно с этим Kubernetes проставляет поду условие DisruptionTarget в status.conditions — служебную пометку «этот под завершается из-за организационного сбоя (disruption), а не из-за своей логики». Она появляется при вытеснении через Eviction API, при preemption более приоритетным подом и при истечении таймаута node-not-ready. Функция называется PodDisruptionConditions, была стабилизирована в релизе 1.31 и на 1.36, с которой мы работали, флаг фичи из командной строки уже убран — условие проставляется всегда, отдельно включать не нужно.
Дальше в дело вступает контроллер Job. Он видит под в фазе Failed и по умолчанию считает это провалом наравне с любым другим — DisruptionTarget он сам по себе не читает. Отсюда и расход backoffLimit на событие, не связанное с приложением.
Отдельно стоит развести drain и «тихую» деградацию узла: если узел не выводится явно, а просто перестаёт отвечать (NotReady), kube-controller-manager не ждёт бесконечно — на узел автоматически накладывается taint node.kubernetes.io/not-ready или node.kubernetes.io/unreachable с эффектом NoExecute, и поды, не толерантные к этому taint'у дольше настроенного времени, вытесняются по истечении tolerationSeconds (по умолчанию 300 секунд, регулируется на уровне контроллера флагами --default-not-ready-toleration-seconds и --default-unreachable-toleration-seconds). Для Job-подов это тот же путь к DisruptionTarget, что и явный kubectl drain, поэтому правило Ignore на это условие закрывает оба сценария — и плановое обслуживание, и незапланированную деградацию узла, без необходимости различать их отдельными правилами.
Почему backoffLimit в принципе не различает причину сбоя
spec.backoffLimit (по умолчанию 6, если не задавать явно) — это простой счётчик числа подов Job, завершившихся неуспешно, без какой-либо классификации причины. Контроллер инкрементирует status.failed на каждый Failed-под и сравнивает с лимитом. При backoffLimit: 0 порог достигается на первом же неуспешном поде — будь то exit 1 из-за баги в коде, ошибка подключения к базе или вытеснение узлом на обслуживание.
Формально это не баг, а прямое следствие простоты API: Job создавался как примитив «досчитать completions попыток, укладываясь в backoffLimit ошибок», без встроенного понимания инфраструктурных причин отказа. Разделение причин добавили отдельным механизмом — podFailurePolicy, и по документации он обязателен, если нужно тонкое управление; сам backoffLimit трогать для этого не нужно и не следует.
| Что произошло с подом | Есть ли DisruptionTarget | Что видит backoffLimit без policy |
|---|---|---|
| Контейнер вышел с кодом ошибки приложения | нет | провал, засчитан |
| Узел выведен в drain, под вытеснен Eviction API | да | провал, засчитан — ошибочно |
| Preemption более приоритетным подом | да | провал, засчитан — ошибочно |
| Узел ушёл в NotReady дольше таймаута | да | провал, засчитан — ошибочно |
Три из четырёх строк — не вина приложения. Именно эту разницу нужно вернуть в решение «считать в backoffLimit или нет».
podFailurePolicy: разводим приложение и инфраструктуру правилами
spec.podFailurePolicy — набор правил rules, каждое из которых match'ит под либо по кодам выхода контейнеров (onExitCodes), либо по condition-ам пода (onPodConditions), и присваивает действие action: FailJob (немедленно провалить весь Job, остановив остальные поды), Ignore (не засчитывать провал в backoffLimit/backoffLimitPerIndex), Count (засчитать как обычно) или FailIndex (для Indexed Job — провалить только конкретный индекс). Правила проверяются по порядку, срабатывает первое подходящее.
Наш минимальный конфиг для того самого Job с выгрузкой в 1С выглядит так:
apiVersion: batch/v1
kind: Job
metadata:
name: nightly-1c-export
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: exporter
image: registry.itfresh.ru/exporter:2026.09
podFailurePolicy:
rules:
- action: Ignore
onPodConditions:
- type: DisruptionTarget
- action: FailJob
onExitCodes:
containerName: exporter
operator: In
values: [1, 2, 70]Смысл: если под ушёл в Failed с condition DisruptionTarget — это инфраструктурное событие, игнорируем его, backoffLimit не трогаем, Job-контроллер создаёт замену пода на другом узле и обработка продолжается с того же индекса. Если контейнер сам вышел с кодом 1, 2 или 70 (в нашей нумерации — общая ошибка, ошибка валидации данных, конфликт транзакции) — сразу FailJob, ни одной повторной попытки, разбираться руками. Поле containerName можно не указывать — тогда правило проверяется по всем контейнерам пода; values в onExitCodes не может содержать 0 — успешное завершение к сбоям не относится.
| action | Что делает контроллер Job | Когда мы применяем |
|---|---|---|
Ignore | не засчитывает провал, создаёт замену пода | DisruptionTarget — drain, preemption, not-ready |
Count | засчитывает провал как обычно (поведение без policy) | любой код выхода, не входящий в явные правила — задаём как fallback последним правилом |
FailJob | сразу проваливает весь Job, останавливает остальные поды | фатальные коды приложения: повреждение входных данных, конфликт схемы БД |
FailIndex | проваливает только текущий индекс Indexed Job | ошибка в данных одного конкретного индекса, остальные должны продолжать |
Мы всегда добавляем Count как последнее правило-заглушку на любой необработанный код выхода (onExitCodes с operator: NotIn и списком уже описанных кодов) — иначе непредусмотренный код по умолчанию тоже уйдёт в общий подсчёт, что обычно и есть нужное поведение, но полезно видеть это явно в манифесте, а не полагаться на неявный дефолт.
Ограничение, о которое спотыкаются почти все: restartPolicy: Never
Ключевая деталь, которую легко упустить при переносе policy на существующие Job: podFailurePolicy валидируется API-сервером только при restartPolicy: Never в шаблоне пода. Если у Job стоит restartPolicy: OnFailure (частый выбор для «пусть kubelet сам перезапускает контейнер локально»), манифест с podFailurePolicy будет отклонён на этапе admission — Job не создастся.
Причина ограничения — не искусственная бюрократия, а гонка состояний: при OnFailure kubelet перезапускает контейнер в том же поде без участия контроллера Job, и на момент, когда контроллер Job опрашивает статус, под может быть уже не в финальной фазе, а сам факт локального перезапуска не создаёт того самого Failed-события, по которому правило должно сработать. Разработчики API сознательно исключили эту неоднозначность, обязав restartPolicy: Never для любого Job, где задан podFailurePolicy.
Практический вывод для нас: если у клиента батчи исторически писались с OnFailure и локальными ретраями на уровне kubelet, перевод на podFailurePolicy начинается не с добавления секции правил, а с переноса логики повторных попыток из restartPolicy в приложение или в сам Job (через backoffLimit и, при необходимости, backoffLimitPerIndex). Иначе манифест просто не пройдёт валидацию, и это стоит проверить в CI до раскатки на прод, а не в момент следующего drain.
Indexed Job: backoffLimitPerIndex и maxFailedIndexes для точечного контроля
Если пакетная обработка запущена как Indexed Job (completionMode: Indexed, что типично для параллельной обработки N независимых кусков данных — у нас это, например, разбивка выгрузки по организациям), общий backoffLimit на весь Job слишком грубый инструмент: одна упавшая организация из тридцати не должна закрывать весь батч.
Для этого случая в API Job есть spec.backoffLimitPerIndex — лимит повторов не на весь Job, а на каждый индекс отдельно; функция вышла в GA в релизе 1.33 и на 1.36 доступна без feature gate. Индекс, превысивший свой лимит, попадает в status.failedIndexes (строка вида "3,7-9"), но остальные индексы продолжают обработку. Отдельным полем spec.maxFailedIndexes задаётся порог по количеству проваленных индексов — сколько организаций из тридцати можно потерять, прежде чем весь Job всё же будет помечен Failed целиком.
spec:
completionMode: Indexed
completions: 30
parallelism: 6
backoffLimitPerIndex: 1
maxFailedIndexes: 3
podFailurePolicy:
rules:
- action: Ignore
onPodConditions:
- type: DisruptionTarget
- action: FailIndex
onExitCodes:
operator: In
values: [1, 2, 70]Здесь FailIndex заменяет FailJob — ошибка приложения на одном индексе валит только этот индекс, а не всю обработку; drain узла с DisruptionTarget по-прежнему игнорируется полностью, независимо от режима индексации. Это единственный способ дать батчу переживать и точечные баги в данных, и обслуживание узлов одновременно, без общего backoffLimit на весь Job.
Важное ограничение: backoffLimitPerIndex имеет смысл только при completionMode: Indexed — для обычного (NonIndexed) Job это поле API не примет. Если пакетная обработка исторически писалась как один под без индексации, разбить её на Indexed Job с параметром окружения JOB_COMPLETION_INDEX внутри контейнера (Kubernetes прокидывает его автоматически) — обычно небольшая правка кода, но именно она открывает доступ к точечному контролю по индексам вместо грубого общего backoffLimit.
podReplacementPolicy, activeDeadlineSeconds и ttlSecondsAfterFinished — три поля, которые докручивают картину
podFailurePolicy решает вопрос «считать ли провал», но не вопрос «когда создавать замену пода». Здесь работает spec.podReplacementPolicy со значениями TerminatingOrFailed (контроллер создаёт замену, как только видит под терминирующимся или упавшим, не ждя полного завершения) и Failed (замена создаётся только после того, как старый под окончательно перешёл в фазу Failed или Succeeded). Функция вышла в GA в релизе 1.34. Важный нюанс, который легко пропустить: как только в Job задан podFailurePolicy, поле podReplacementPolicy автоматически принимает значение Failed и другого значения для такого Job уже не выставить — это не наша настройка по вкусу, а требование API, зафиксированное в валидации. Смысл требования тот же, что у обязательного restartPolicy: Never: правило policy должно успеть прочитать финальный DisruptionTarget или код выхода именно завершившегося пода, прежде чем контроллер создаст замену, — иначе на узле, который выводится в drain, можно кратковременно получить два активных пода на один и тот же слот, а для батчей с эксклюзивным доступом к внешнему ресурсу (блокировка строки в 1С, файл выгрузки) это недопустимо. На практике это значит: включая podFailurePolicy, вы автоматически получаете и защиту от гонки при пересоздании пода — отдельно настраивать её не нужно, достаточно понимать, что небольшая задержка на пересоздание — не побочный эффект, а часть контракта.
spec.activeDeadlineSeconds — независимый предохранитель сверху: общий бюджет времени на весь Job от старта, вне зависимости от количества попыток и правил podFailurePolicy. Если превышен — Job принудительно завершается с condition Failed и причиной DeadlineExceeded, все поды останавливаются. Для ночной выгрузки с окном в 2 часа мы ставим activeDeadlineSeconds: 7200 как страховку от ситуации «policy бесконечно игнорирует DisruptionTarget, а узел перезагружается по кругу» — такой сценарий policy сам по себе не ограничивает по времени.
spec.ttlSecondsAfterFinished — не про сбои, а про гигиену: через сколько секунд после завершения (успешного или неуспешного) TTL-контроллер удалит сам объект Job и его поды. Без этого поля объекты Job с частыми запусками копятся в неймспейсе и забивают вывод kubectl get job; у нас стандарт — 86400 (сутки), чтобы на утро оставался лог именно вчерашнего прогона.
Почему PodDisruptionBudget здесь не решение
Естественный вопрос: если проблема в том, что drain вытесняет под, почему не закрыть под PodDisruptionBudget, как делают для Deployment? Формально ничто не запрещает завести PDB с selector, попадающим на поды Job. Но у Job-подов restartPolicy: Never и, как правило, ровно одна реплика на индекс — а PDB по своей механике либо блокирует Eviction API целиком, если он не может выполниться без нарушения minAvailable/maxUnavailable, либо не даёт вообще никакой защиты, если реплика одна и minAvailable формально соблюсти нельзя. На практике это означает: kubectl drain на узле с таким подом просто не завершится в отведённое администратором окно обслуживания — он будет повторять попытку эвакуации, натыкаясь на PDB, пока кто-то не удалит под force или не истечёт таймаут самой команды drain.
Другими словами, PDB не решает исходную задачу «не тратить backoffLimit на обслуживание» — он просто переносит конфликт из «Job упал» в «узел не освобождается», что для планового обновления кластера хуже: обслуживание срывается по времени, а разработчик всё равно должен разбираться, почему. Правильное место для этой логики — podFailurePolicy с правилом на DisruptionTarget, оно работает на уровне «после того как эвакуация уже случилась», а не пытается её заблокировать.
Наш регламент внедрения при обновлении узлов до 1.36
Раскатывали изменение на кластерах клиентов пошагово, без остановки текущих батчей:
- Инвентаризация:
kubectl get jobs -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{.metadata.name} backoffLimit={.spec.backoffLimit} restartPolicy={.spec.template.spec.restartPolicy}{"\n"}{end}'— выгрузили список всех Job сbackoffLimit: 0и одновременно сверилиrestartPolicyв шаблоне пода, чтобы заранее увидеть, где придётся сначала менятьOnFailureнаNever. - Для каждого такого Job добавили
podFailurePolicyс правиломIgnoreнаDisruptionTargetпервым в списке правил и явнымиonExitCodesдля кодов ошибок приложения вторым — порядок важен, контроллер берёт первое совпавшее правило. - Там, где обработка Indexed, дополнительно перевели общий
backoffLimitвbackoffLimitPerIndex: 1и подобралиmaxFailedIndexesпо фактической доле допустимых единичных сбоев (у нас — не больше 10% индексов). - Добавили
activeDeadlineSecondsпод реальное окно каждого батча иttlSecondsAfterFinished: 86400для порядка в неймспейсе. - Проверили на тестовом узле: запустили тестовый Job, вручную выполнили
kubectl drain <node> --ignore-daemonsets --delete-emptydir-dataв момент его выполнения, убедились, что Job не упал, под пересоздался на другом узле, а обработка продолжилась с прерванного места (для Indexed) или полного перезапуска (для обычного Job — это ожидаемо, если задача не идемпотентна, значит нужен свой чекпойнт в приложении, а не только policy). - Только после чистого прогона на тесте включили обновление узлов на проде окно за окном, с мониторингом событий
BackoffLimitExceeded— их не должно быть ни одного за весь цикл обновления кластера.
Отдельно подчёркиваю пункт про идемпотентность: podFailurePolicy избавляет от ложного Failed Job, но не избавляет от того, что задача перезапускается с нуля на новом поде, если сама логика не умеет продолжать с точки останова. Для батчей с транзакционной записью в 1С мы отдельно проверяем idempotency-ключ на стороне приложения — иначе снятие backoffLimit-штрафа за drain компенсируется задвоением данных при перезапуске, а это ровно тот риск, ради которого изначально ставили backoffLimit=0.
Мониторинг после раскатки строим на двух сигналах: событие Job с причиной BackoffLimitExceeded (алерт — любое появление после включения policy означает, что либо появился новый код выхода приложения, не описанный правилами, либо policy отработала не так, как задумано, и это требует ручной проверки) и счётчик status.failedIndexes для Indexed Job, чтобы видеть тренд по единичным сбоям отдельно от общего состояния Job. Оба сигнала уходят в тот же канал уведомлений, что и остальная инфраструктурная телеметрия клиента, без отдельного дашборда под Kubernetes.
Частые вопросы
- Можно ли просто увеличить backoffLimit вместо настройки podFailurePolicy?
- Можно, но это не решает задачу, а маскирует её: повышенный лимит начнёт скрывать и настоящие ошибки приложения, тратя попытки на них так же, как раньше тратил на drain. podFailurePolicy разводит причины сбоя, backoffLimit остаётся нулевым именно для ошибок кода.
- Работает ли правило на DisruptionTarget при обычном удалении пода командой kubectl delete pod?
- Нет, ручное удаление без эвакуации через Eviction API не проставляет condition DisruptionTarget, и такой под будет засчитан как обычный провал. Условие появляется при вытеснении через drain, preemption и по таймауту node-not-ready.
- Нужно ли включать какой-то feature gate для podFailurePolicy и DisruptionTarget на кластере 1.36?
- На 1.36 обе функции стабильны и включены по умолчанию без флагов командной строки — можно писать правила прямо в манифест Job без изменений на уровне kube-apiserver или kube-controller-manager.
- Что если у Job уже стоит restartPolicy: OnFailure — можно добавить podFailurePolicy без переписывания приложения?
- API-сервер отклонит такой манифест: podFailurePolicy валиден только при restartPolicy: Never. Придётся перенести логику повторов из перезапуска контейнера в саму задачу или в backoffLimit/backoffLimitPerIndex Job.
- Как проверить, что именно drain, а не баг в коде, уронил конкретный Job в прошлом инциденте?
- Смотрите status.conditions упавшего пода в истории событий (или в выгруженных логах кластера) на наличие типа DisruptionTarget — если оно там есть, причина инфраструктурная; если пода уже нет, ориентируйтесь на событие Job и время относительно журнала обслуживания узлов.