concurrencyPolicy: Forbid не запрещает ручной Job: механика Kubernetes 1.37
Обмен с маркетплейсом подвис, дежурный запустил повтор через `kubectl create job --from=cronjob`, а следом расписание создало собственный Job. В базе появились дубли, хотя в CronJob было указано `concurrencyPolicy: Forbid`. Я разберу поведение Kubernetes 1.37 по документации и исходному коду: что контроллер действительно проверяет, почему ручной Job получает родство с CronJob, но не попадает в `.status.active`, какие следы остаются в событиях и чем защищать пакетную операцию, если повторная обработка затрагивает деньги, товар или документы.
Forbid управляет расписанием, а не всеми похожими Jobs
Начну с ограничения, которое легко прочитать слишком широко. Официальная документация говорит, что политика конкуренции применяется только к Jobs, созданным одним и тем же CronJob. У Forbid узкая задача: когда наступает очередное время расписания, контроллер не должен создавать новый Job, если в активном списке этого CronJob уже есть незавершённый плановый запуск. Из этого не следует запрет на создание любых Jobs с тем же образом, командами или шаблоном.
Команда kubectl create job NAME --from=cronjob/NAME работает по другому пути. Kubectl получает существующий CronJob, копирует spec.jobTemplate.spec в новый объект Job и отправляет запрос на его создание через Kubernetes API. Клиент не спрашивает у контроллера CronJob разрешения, не проверяет concurrencyPolicy и не ждёт ближайшего тика расписания. API-сервер валидирует создаваемый объект и права пользователя, но не превращает Forbid в межобъектную блокировку.
Поэтому ручной Job может стартовать рядом с уже работающим плановым заданием. Обратная последовательность тоже опасна: пока ручной Job выполняется, наступает время расписания, и контроллер создаёт плановый Job, если .status.active не содержит другого незавершённого планового запуска. Само сходство шаблонов ничего не меняет. Kubernetes видит два отдельных объекта Job, а не две попытки войти в одну бизнес-критическую секцию.
Здесь важно разделить три понятия. Расписание отвечает за момент попытки запуска. concurrencyPolicy регулирует решения одного контроллера относительно учтённых им запусков. Гарантия однократной бизнес-операции относится уже к приложению и данным. Когда эти уровни смешивают, поле манифеста начинают считать распределённым локом, хотя ни API, ни реализация такого обещания не дают.
В моей практике последствия чаще всего проявляются не как красивый конфликт в Kubernetes, а как тихая порча прикладного результата. Два процесса успешно получают Pods, оба завершаются с кодом 0, а ошибку замечают позже — в документах, остатках, платёжном реестре или отправленных сообщениях. Для оркестратора оба Job могли быть полностью успешными.
- обмены заказами и остатками — повторное создание документов, резервов и движений товара;
- платёжные реестры — повторная отправка операции без идемпотентного ключа;
- рассылки — два письма или уведомления одному получателю;
- пересчёты и миграции — гонки при записи в общие таблицы;
- формирование файлов — одновременная запись, повторная доставка или публикация неполного результата.
Что именно проверяет контроллер Kubernetes 1.37
В исходнике Kubernetes 1.37.0 проверка видна без догадок. В файле pkg/controller/cronjob/cronjob_controllerv2.go ветка ForbidConcurrent проверяет, что длина cronJob.Status.Active больше нуля. Если условие выполняется, контроллер записывает событие с reason JobAlreadyActive и не создаёт Job для текущего тика. В Kubernetes 1.37 это событие имеет тип Normal, а не Warning.
Поле .status.active — список ссылок на активные Jobs, который ведёт сам контроллер. Сразу после успешного создания планового Job он добавляет ссылку в этот список и обновляет статус CronJob. Когда дочерний Job получает завершённое состояние Complete или Failed, контроллер удаляет ссылку. В комментарии рядом с проверкой разработчики отдельно признают, что из-за задержек наблюдения теоретическое пересечение возможно даже при Forbid; политика уменьшает вероятность конкуренции плановых запусков, но не предоставляет строгого взаимоисключения.
У ручного запуска есть парадоксальная на первый взгляд особенность. Исходник kubectl версии 1.37 находится в репозитории kubernetes/kubectl под тегом v0.37.0. Функция createJobFromCronJob ставит аннотацию cronjob.kubernetes.io/instantiate: manual, копирует метки и спецификацию шаблона, а также создаёт ownerReference на CronJob с controller: true. Значит, ручной Job действительно является дочерним объектом исходного CronJob.
Однако kubectl не обновляет .status.active, а контроллер не добавляет туда уже найденный ручной Job. Эта ссылка появляется только в ветке, где контроллер сам создал плановый Job. Получается важное различие: родство есть, а записи в активном списке нет. Именно поэтому ручной Job попадает в выборку дочерних объектов контроллера, но не останавливает проверку Forbid.
Контроллер замечает несоответствие. Если он видит незавершённый дочерний Job, которого нет в .status.active, то пишет событие типа Warning с reason UnexpectedJob и сообщением Saw a job that the controller did not create or forgot. Это не безусловное доказательство одновременной работы: такое состояние возможно и после сбоя контроллера между созданием Job и обновлением статуса. Но вместе с аннотациями, временем создания Jobs и прикладными логами событие хорошо восстанавливает картину.
Первую проверку я делаю следующими командами. Они показывают содержимое .status.active, затем раскладывают все дочерние Jobs по имени, способу запуска, плановому времени и счётчикам состояния. Аннотация batch.kubernetes.io/cronjob-scheduled-timestamp появилась у создаваемых CronJob заданий начиная с Kubernetes 1.32 и содержит исходное плановое время в формате RFC 3339.
NS=integrations
CJ=mp-orders-sync
kubectl -n "$NS" get cronjob "$CJ" \
-o jsonpath='{range .status.active[*]}{.name}{"\n"}{end}'
kubectl -n "$NS" get jobs -o json | jq -r --arg cj "$CJ" '
.items[]
| select(any(.metadata.ownerReferences[]?;
.kind == "CronJob" and .name == $cj))
| [
.metadata.name,
(.metadata.annotations["cronjob.kubernetes.io/instantiate"] // "schedule"),
(.metadata.annotations["batch.kubernetes.io/cronjob-scheduled-timestamp"] // "-"),
(.status.active // 0),
(.status.succeeded // 0),
(.status.failed // 0)
]
| @tsv'События лучше фильтровать не только по reason, но и по объекту CronJob. Поле reason и поля involvedObject официально поддерживаются для Event field selectors. В стандартной конфигурации kube-apiserver параметр --event-ttl равен одному часу, но администратор кластера может изменить его, поэтому час нельзя считать гарантированным сроком хранения.
kubectl -n integrations get events \
--field-selector='involvedObject.kind=CronJob,involvedObject.name=mp-orders-sync,reason=UnexpectedJob' \
-o wide
kubectl -n integrations get events \
--field-selector='involvedObject.kind=CronJob,involvedObject.name=mp-orders-sync,reason=JobAlreadyActive' \
-o wideРазбор практики: консалтинговая компания «Бизнес-Ресурс», 21 РМ
Условный клиент — консалтинговая компания «Бизнес-Ресурс», 21 РМ. Интеграционные сервисы работали в отдельном tenant-неймспейсе integrations кластера с тремя worker-узлами. На момент инцидента использовался Kubernetes 1.37.0: официальный выпуск этой версии состоялся 26 августа 2026 года. Нужный CronJob назывался mp-orders-sync и каждые десять минут забирал заказы из API маркетплейса, после чего через HTTP-сервис 1С создавал документы реализации.
Значимые для разбора поля CronJob выглядели так. Выражение */10 * * * * означает запуск через каждые десять минут. Поле .spec.timeZone стабильно с Kubernetes 1.27 и принимает имя часового пояса; Europe/Moscow — имя из базы часовых поясов. startingDeadlineSeconds: 120 задаёт двухминутное окно для запоздавшего старта, а не длительность выполнения Job.
apiVersion: batch/v1
kind: CronJob
metadata:
name: mp-orders-sync
namespace: integrations
spec:
schedule: "*/10 * * * *"
timeZone: "Europe/Moscow"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 120
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: sync
image: registry.local/integrations/mp-syncВ обычном режиме один прогон занимал от 40 до 90 секунд, поэтому соседние плановые запуски не пересекались. Затем API маркетплейса около двух часов отвечал кодом HTTP 503, а очередь выросла примерно до 6800 заказов. После восстановления внешнего API дежурный в 14:03 создал догоняющий Job. Синтаксис команды соответствует справочнику kubectl: после имени нового Job указывается источник --from=cronjob/<имя>.
kubectl -n integrations create job mp-orders-sync-fix \
--from=cronjob/mp-orders-syncРучной прогон обрабатывал накопившуюся очередь 26 минут. В 14:10 контроллер дошёл до очередного времени расписания. В .status.active ручного Job не было, поэтому Forbid не сработал и плановый Job был создан. Этот запуск завершился до следующего тика. В 14:20 активный список снова был пуст, и контроллер создал ещё один плановый Job. За период инцидента очередь обрабатывали три разных Job, хотя все три не обязательно работали одновременно каждую секунду.
Прикладной результат сохранился без изменений: 412 задвоенных документов реализации и 39 позиций с двойным резервом. В событиях CronJob была серия UnexpectedJob; её счётчик успел вырасти до трёх. Событий JobAlreadyActive за этот период не было, потому что два плановых Job не пересеклись друг с другом, а ручной Job политика не учитывала. Восстановить точные интервалы удалось по логам приложения и журналу регистрации 1С.
Историю дополнительно осложнил successfulJobsHistoryLimit: 3. По умолчанию Kubernetes хранит три успешных и один неуспешный Job, если эти поля не переопределены. Поскольку ручной Job имеет контролирующую ссылку на CronJob, контроллер включает его в список дочерних Jobs при очистке истории. Завершённый ручной запуск также способен повлиять на .status.lastSuccessfulTime. После нескольких новых успешных запусков старые Jobs, их Pods и доступные через них локальные логи были удалены.
Исправление сделали в два слоя. На стороне приёмника добавили уникальность по источнику и внешнему идентификатору заказа, чтобы повтор не создавал второй документ. В обработчике ввели блокировку для экономии квоты API и мощности сервиса 1С. За следующие три месяца дубли не повторились. Попытки пересечения всё ещё появлялись в метриках, но проигравший блокировку процесс завершался без обработки очереди и обязательно оставлял отдельный диагностический сигнал.
Что выглядит защитой, но не закрывает гонку
Первый частый совет — поставить .spec.suspend: true. Поле действительно останавливает будущие запуски CronJob, однако официальная документация прямо уточняет: уже запущенные Jobs оно не затрагивает. Изменение объекта CronJob также не переписывает существующие Jobs и Pods. Поэтому порядок «сначала приостановить расписание, затем убедиться, что работающих заданий нет, потом создать ручной Job» полезен как процедура. Попытка включить suspend после обнаружения пересечения уже работающие процессы не остановит.
Есть и обратная сторона. Запуски, пришедшиеся на период приостановки, считаются пропущенными. Если startingDeadlineSeconds не задан, при переводе существующего CronJob из suspend: true обратно в false пропущенные задания могут быть запланированы немедленно. При заданном дедлайне контроллер учитывает его окно. Значит, после ручного прогона нужно ожидать возможный догоняющий Job, а не считать снятие suspend нейтральной операцией.
concurrencyPolicy: Replace тоже не становится глобальной блокировкой. В Kubernetes 1.37 контроллер проходит по ссылкам из .status.active и удаляет перечисленные там Jobs перед созданием нового. Ручной Job в этом списке отсутствует, поэтому Replace его не остановит. Кроме того, принудительное удаление планового Job может оборвать обработку между внешним вызовом и фиксацией результата. Без транзакционности и идемпотентности вместо дубля легко получить частично выполненную операцию.
Поле .spec.startingDeadlineSeconds решает другую задачу: определяет допустимое опоздание между временем расписания и попыткой создать Job. Если значение меньше десяти секунд, документация предупреждает, что CronJob может вообще не успеть запланироваться, поскольку контроллер проверяет расписания примерно каждые десять секунд. Уменьшать дедлайн ради взаимного исключения бессмысленно: он не проверяет ручные Jobs и не блокирует работающий процесс.
Ограничение в сто пропущенных расписаний тоже часто пересказывают неверно. Когда контроллер насчитал больше ста пропусков в проверяемом интервале, он не создаёт догоняющий Job и пишет в журнал сообщение too many missed start times. Set or decrease .spec.startingDeadlineSeconds or check clock skew. Это относится к catch-up scheduling и не означает окончательную остановку всех будущих запусков. Если дедлайн задан, количество пропусков считается внутри окна дедлайна, а не обязательно со времени последнего успешного запуска.
Наконец, отдельный CronJob для ручных прогонов не объединяет политики. Документация прямо разрешает одновременную работу Jobs разных CronJob. Одинаковый образ, одинаковая очередь, общая метка и даже совпадающая команда контейнера не образуют между объектами связь взаимного исключения.
Сам Kubernetes формулирует границу гарантии честно: CronJob создаёт Job приблизительно один раз на момент расписания; при определённых обстоятельствах Jobs может оказаться два или ни одного. Поэтому документация требует проектировать задания идемпотентными. В исходнике рядом с проверкой Forbid дополнительно отмечен риск не увидеть активный Job из-за задержки состояния.
- `suspend: true` — блокирует будущие тики, но не меняет уже созданные Jobs;
- `Replace` — удаляет Jobs из `.status.active`, а ручной запуск туда не входит;
- `startingDeadlineSeconds` — ограничивает запоздалый старт, а не параллельную работу;
- редкое расписание — только уменьшает вероятность пересечения;
- второй CronJob — отдельный источник запусков без общей политики конкуренции;
- фиксированное имя Job — защищает лишь от повторного создания объекта с тем же именем, пока этот объект существует.
Идемпотентность сначала, блокировка потом
Я начинаю с защиты данных. Для заказа естественным ключом обычно служит пара «источник плюс внешний идентификатор». Для платёжного реестра — идентификатор операции, согласованный с банком. Для рассылки — сочетание кампании и получателя. Повторный запрос должен находить уже обработанный ключ и возвращать прежний результат либо безопасно пропускать действие, а не создавать второй объект.
Проверять существование отдельным запросом перед вставкой недостаточно: два процесса могут одновременно увидеть отсутствие строки и оба продолжить работу. Нужна атомарная гарантия в точке записи — уникальное ограничение, условная вставка, идемпотентный ключ внешнего API или эквивалентный механизм 1С. Приложение должно распознавать конфликт уникальности как ожидаемый повтор, но не превращать любую ошибку базы в успешный исход.
Идемпотентность защищает не только от ручного запуска. Job с restartPolicy: Never и ненулевым backoffLimit может получить новую попытку после неуспеха Pod. Сетевой тайм-аут не доказывает, что внешняя система не приняла запрос. Контроллер CronJob тоже не обещает строгого единственного создания. Все эти пути сходятся в одном требовании: повтор одной логической операции не должен менять итог после первого успешного применения.
Блокировка остаётся полезной, но выполняет вторую роль. Она не даёт двум тяжёлым обработчикам одновременно читать одну очередь, расходовать лимиты внешнего API и нагружать 1С. Если блокировку потеряли или два участника на короткое время считают себя владельцами, данные всё равно должна удержать идемпотентность.
Когда у задания уже есть PostgreSQL, я предпочитаю session-level advisory lock. pg_try_advisory_lock либо немедленно получает эксклюзивную блокировку и возвращает true, либо без ожидания возвращает false. Вариант с двумя аргументами принимает два 32-битных целых ключа; значения выбираются приложением и должны быть одинаковыми у всех экземпляров одного обработчика.
SELECT pg_try_advisory_lock(742091, 1);
-- После завершения работы, в той же сессии:
SELECT pg_advisory_unlock(742091, 1);Критическая деталь — соединение должно оставаться тем же самым всё время обработки. Если выполнить первый запрос отдельным процессом psql, завершить его, а затем запустить приложение, блокировка освободится вместе с сессией ещё до полезной работы. При использовании пула соединение нужно закрепить за обработчиком. PostgreSQL также освобождает все session-level advisory locks при завершении сессии, включая негладкий разрыв клиента.
Lease из API-группы coordination.k8s.io подходит, когда общей базы нет, но сам объект Lease не является автоматически истекающим mutex. Истечение leaseDurationSeconds не удаляет объект и не заставляет простой повторный create внезапно пройти. Участники должны читать Lease, продлевать renewTime и перехватывать лидерство атомарным update с проверкой resourceVersion. Для этого разумнее использовать проверенную реализацию, например k8s.io/client-go/tools/leaderelection, а не shell-скрипт из нескольких запросов.
У стандартной реализации leader election тоже есть честно описанное ограничение: она не гарантирует fencing, то есть при неблагоприятных задержках нельзя математически исключить двух участников, считающих себя лидером. Защищаемая операция должна останавливаться при потере лидерства, а хранилище всё равно обязано отклонять повтор. Значения LeaseDuration, RenewDeadline и RetryPeriod подбирают с учётом задержек API и допустимой скорости смены лидера; слепо переносить чужие интервалы в пакетное задание не стоит.
Для доступа к Lease недостаточно создать Role — её надо связать с ServiceAccount, которым пользуется Pod. Минимальному клиенту, работающему через LeaseLock, нужны операции get, create и update над ресурсом leases. Если реализация использует дополнительные операции или пишет Events, права расширяют по фактическим запросам, а не выдают роль на весь кластер.
apiVersion: v1
kind: ServiceAccount
metadata:
name: mp-sync
namespace: integrations
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: mp-sync-lease
namespace: integrations
rules:
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "create", "update"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: mp-sync-lease
namespace: integrations
subjects:
- kind: ServiceAccount
name: mp-sync
namespace: integrations
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: mp-sync-leaseServiceAccount указывается именно в шаблоне Pod, иначе Job продолжит работать с default ServiceAccount и получит отказ RBAC. Ни Role, ни RoleBinding сами по себе не меняют учётную запись уже созданных Pods.
spec:
jobTemplate:
spec:
template:
spec:
serviceAccountName: mp-syncБезопасная процедура ручного запуска до внедрения лока
Пока приложение не умеет блокироваться, я использую временную операционную процедуру: зафиксировать исходное состояние suspend, приостановить расписание, проверить все незавершённые дочерние Jobs, создать ручной Job с постоянным именем, дождаться результата и восстановить расписание. Это снижает риск, но не заменяет идемпотентность и не даёт транзакции между несколькими запросами к API.
Сначала нужно убедиться, что CronJob изначально не был приостановлен по другой причине. Затем включаем suspend. Для диагностики я проверяю и .status.active, и фактические дочерние Jobs: статус CronJob может отставать, а ручной Job в активный список не входит. Незавершённым считаю Job без истинного условия Complete или Failed.
NS=integrations
CJ=mp-orders-sync
kubectl -n "$NS" get cronjob "$CJ" \
-o jsonpath='{.spec.suspend}{"\n"}'
kubectl -n "$NS" patch cronjob "$CJ" \
--type=merge \
-p '{"spec":{"suspend":true}}'
kubectl -n "$NS" get cronjob "$CJ" \
-o jsonpath='{range .status.active[*]}{.name}{"\n"}{end}'
kubectl -n "$NS" get jobs -o json | jq -r --arg cj "$CJ" '
.items[]
| select(any(.metadata.ownerReferences[]?;
.kind == "CronJob" and .name == $cj))
| select([
.status.conditions[]?
| select(
((.type == "Complete") or (.type == "Failed"))
and .status == "True"
)
] | length == 0)
| .metadata.name'Переходить дальше можно только при пустом выводе обеих проверок. После patch я повторяю проверку ещё раз через интервал контроллера, потому что запрос на изменение CronJob не отменяет Job, создание которого уже началось. Документация указывает, что контроллер проверяет расписания примерно каждые десять секунд; это полезный ориентир для повторной проверки, но не строгая граница завершения всех внутренних операций.
Ручному Job даю фиксированное имя. Kubernetes не позволит создать второй объект Job с тем же именем в том же namespace, пока первый существует. Это защищает от двух операторов, одновременно выполняющих одну инструкцию, но только при условии, что никто заранее не удалил объект. После создания жду истинного условия Complete не более часа. Если ожидание завершилось ошибкой или тайм-аутом, расписание не включаю автоматически: сначала разбираю состояние Job и возможный частично применённый результат.
kubectl -n integrations create job mp-orders-sync-manual \
--from=cronjob/mp-orders-sync
kubectl -n integrations wait \
--for=condition=complete \
job/mp-orders-sync-manual \
--timeout=1h
kubectl -n integrations logs \
job/mp-orders-sync-manual \
--all-containers=trueПосле успешной проверки результата удаляю ручной Job, если логи уже отправлены во внешнее хранилище, и возвращаю исходное состояние CronJob. В обычном сценарии оно было false. Если CronJob был приостановлен до начала работ, переводить его в false нельзя.
kubectl -n integrations delete job mp-orders-sync-manual
kubectl -n integrations patch cronjob mp-orders-sync \
--type=merge \
-p '{"spec":{"suspend":false}}'Снятие suspend способно вызвать догоняющий запуск. В конфигурации консалтинговой компании «Бизнес-Ресурс», 21 РМ действовал startingDeadlineSeconds: 120, поэтому контроллер пропускал запуск, опоздавший больше чем на 120 секунд, но мог создать более свежий пропущенный Job, ещё попадающий в окно. После восстановления расписания я проверяю новые Jobs и нагрузку, а не закрываю инцидент сразу после успешного patch.
Фиксированное имя имеет ещё одно следствие: завершённый Job продолжает занимать имя. Это полезно против случайного повторного нажатия, но следующий санкционированный ручной запуск потребует осознанно удалить или переименовать старый объект. Автоматическое удаление сразу после любого исхода лишает оператора логов и доказательств, поэтому очистка должна происходить только после проверки результата.
Плановые имена в Kubernetes 1.37 формируются детерминированно: к имени CronJob добавляется дефис и Unix-время планового запуска, делённое на 60. Использовать предвычисленное плановое имя как самодельную блокировку не стоит: это внутренняя деталь реализации, она вмешивается в восстановление статуса контроллером и плохо читается в раннбуке.
Имя CronJob не должно превышать 52 символа. Документация объясняет ограничение тем, что контроллер добавляет 11 символов, а имя Job ограничено 63 символами. Формально имя CronJob допускает формат DNS subdomain, но для совместимости и предсказуемых имён хостов документация советует более строгий формат DNS label.
Приоритеты исправления и наблюдаемость
Ревизию я начинаю не со всех CronJob подряд, а с последствий повтора. У консалтинговой компании «Бизнес-Ресурс», 21 РМ в первую группу попали обмены заказами, платежами и документами 1С. Очистка временных файлов, сбор технических метрик и перестроение безопасного кэша могли оставаться с concurrencyPolicy: Allow, если двойной запуск не нарушал данные и не создавал опасную нагрузку.
Для критичных заданий фиксирую естественный идемпотентный ключ, точку атомарной записи и поведение при повторе. Затем проверяю каждый внешний вызов: умеет ли получатель принимать идемпотентный ключ, можно ли однозначно запросить результат после сетевого тайм-аута, не возникает ли необратимое действие до записи локального статуса. Только после этого выбираю advisory lock или корректно реализованный Lease.
Историю Jobs на время расследований имеет смысл хранить дольше стандартных трёх успешных и одного неуспешного запуска. Значение 10 из нашей практики — эксплуатационная настройка, а не рекомендация проекта Kubernetes и не универсальный минимум. Если CronJob работает каждую минуту, десять объектов дают короткое окно; если раз в сутки — могут быть избыточны. Срок хранения логов задаётся требованиями расследования, а не только history limits.
События тоже нужно экспортировать. При стандартном --event-ttl=1h они исчезают примерно через час; в конкретном кластере срок может быть другим. Я отдельно считаю UnexpectedJob и JobAlreadyActive. Первое показывает дочерний Job вне активного списка — часто это ручной запуск, но возможен и сбой обновления статуса. Второе означает, что Forbid пропустил тик из-за учтённого активного Job.
Высокая частота JobAlreadyActive не означает, что CronJob окончательно остановится после ста событий. Однако это полезный симптом: длительность работы сравнялась с интервалом расписания, а пропущенные тики накапливаются для расчёта catch-up. Алерт должен учитывать нормальную длительность задания и дедлайн, иначе единичный ожидаемый пропуск превратится в постоянный шум.
Для доказательства реального пересечения я сопоставляю четыре времени: metadata.creationTimestamp, status.startTime, status.completionTime и аннотацию планового времени. Затем добавляю интервалы из централизованных логов. Аннотация cronjob.kubernetes.io/instantiate: manual отличает Job, созданный kubectl по шаблону, а batch.kubernetes.io/cronjob-scheduled-timestamp показывает исходное время планового запуска.
Итог остаётся практичным. Forbid полезен: он обычно не даёт плановому запуску наложиться на другой учтённый плановый запуск того же CronJob. Но ручные Jobs, разные CronJob и прикладные повторы требуют собственной координации. Границу целостности данных нужно проводить там, где операция становится необратимой, а не у поля манифеста.
- 1. Инвентаризировать CronJob и отметить операции, где повтор меняет деньги, товар, документы или внешние коммуникации.
- 2. Ввести атомарную идемпотентность по естественному ключу в приёмнике или внешнем API.
- 3. Добавить блокировку: session-level advisory lock на одном соединении либо Lease с продлением, атомарным перехватом и остановкой при потере лидерства.
- 4. Настроить внешнее хранение логов и обоснованные `successfulJobsHistoryLimit` и `failedJobsHistoryLimit`.
- 5. Зафиксировать процедуру `suspend → проверка всех Jobs → ручной Job с фиксированным именем → проверка результата → восстановление расписания`.
- 6. Экспортировать Events и отдельно наблюдать `UnexpectedJob`, `JobAlreadyActive`, неуспешные Jobs и длительность выполнения.
- 7. Провести тест с двумя одновременными экземплярами и доказать, что данные остаются корректными даже при отказе блокировки.
Частые вопросы
Почему `concurrencyPolicy: Forbid` не остановил ручной Job?
Потому что `Forbid` проверяется контроллером CronJob при создании очередного планового Job. В Kubernetes 1.37 условие опирается на `.status.active`. Kubectl создаёт ручной Job из шаблона, но не добавляет его в этот список, поэтому ручной и плановый запуски могут пересечься.
Есть ли у ручного Job ownerReference на CronJob?
Да. В kubectl для Kubernetes 1.37 функция `createJobFromCronJob` создаёт ownerReference на исходный CronJob с `controller: true`. Поэтому контроллер видит Job как дочерний и может учитывать его при очистке истории, но родство само по себе не добавляет Job в `.status.active`.
Как отличить ручной Job от планового?
Kubectl ставит ручному Job аннотацию `cronjob.kubernetes.io/instantiate: manual`. Плановый Job, созданный CronJob начиная с Kubernetes 1.32, получает `batch.kubernetes.io/cronjob-scheduled-timestamp` с временем расписания в формате RFC 3339. Дополнительно проверяйте ownerReference, времена Job и события CronJob.
Доказывает ли `UnexpectedJob` параллельную работу?
Не само по себе. Событие означает, что контроллер увидел незавершённый дочерний Job, отсутствующий в `.status.active`. Так бывает при ручном создании, но возможно и после сбоя между созданием планового Job и обновлением статуса. Для доказательства пересечения сопоставьте интервалы Jobs и прикладные логи.
Поможет ли `suspend: true`, если Job уже работает?
Нет. Suspend останавливает будущие запуски CronJob, но не меняет уже созданные Jobs и Pods. Его полезно включать до ручного запуска, после чего нужно проверить фактический список незавершённых Jobs. При снятии suspend возможен догон пропущенного расписания.
Может ли `Replace` удалить ручной Job?
В рассматриваемой реализации Kubernetes 1.37 — нет: ветка Replace перебирает ссылки из `.status.active`. Ручной Job туда не добавлен, поэтому продолжает работать. Replace также опасен для нетранзакционного обработчика, поскольку удаляет учтённый Job в произвольной точке выполнения.
Какой лок лучше: PostgreSQL advisory lock или Kubernetes Lease?
Если все экземпляры работают с одной PostgreSQL, session-level advisory lock обычно проще, но соединение нужно удерживать до завершения обработки. Lease подходит без общей базы, однако требует продления, атомарного перехвата и остановки работы при потере лидерства. Оба варианта дополняют идемпотентность, а не заменяют её.
Удаляется ли Lease автоматически после `leaseDurationSeconds`?
Нет. Поле описывает срок, который участники используют при решении о перехвате лидерства; объект Lease сам не исчезает. Скрипт, который только пытается выполнить `create` и ждёт истечения времени, навсегда упрётся в `AlreadyExists`, пока Lease не удалят или корректно не обновят.
Останавливается ли CronJob навсегда после 100 пропущенных расписаний?
Нет. Если контроллер насчитал больше 100 пропусков в рассматриваемом интервале, он отказывается от конкретного catch-up и пишет ошибку в журнал. Документация отдельно уточняет, что это не означает остановку будущих запусков.
Достаточно ли увеличить `successfulJobsHistoryLimit`?
Нет. Увеличение лимита лишь оставляет больше объектов Job для расследования. Логи Pods всё равно нужно экспортировать, Events имеют отдельный TTL, а сохранённая история не предотвращает дубли. Значение лимита выбирают по частоте запусков и требуемому окну расследования.
Источники
- Kubernetes — CronJob — Официальная документация, актуальная для Kubernetes 1.37: concurrency policy, schedule suspension, history limits, time zones, ограничения имён, приблизительное создание Jobs, аннотация планового времени, десятисекундный цикл и предел более 100 пропусков. https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/
- Kubernetes 1.37 release — Официальная страница ветки Kubernetes 1.37: версия 1.37.0 выпущена 26 августа 2026 года, даты поддержки и ссылка на changelog. https://kubernetes.io/releases/1.37/
- kubernetes/kubernetes — CronJob controller v1.37.0 — Исходник `pkg/controller/cronjob/cronjob_controllerv2.go` в теге v1.37.0: индекс дочерних Jobs, `UnexpectedJob`, проверка `ForbidConcurrent` по `cronJob.Status.Active`, `JobAlreadyActive`, Replace, очистка истории и детерминированное имя Job. https://github.com/kubernetes/kubernetes/blob/v1.37.0/pkg/controller/cronjob/cronjob_controllerv2.go
- kubernetes/kubectl — create_job.go v0.37.0 — Исходник kubectl для релиза 1.37: функция `createJobFromCronJob`, аннотация `cronjob.kubernetes.io/instantiate: manual`, копирование JobTemplate и ownerReference на CronJob с `controller: true`. https://github.com/kubernetes/kubectl/blob/v0.37.0/pkg/cmd/create/create_job.go
- Kubernetes — Well-Known Labels, Annotations and Taints — Официальные определения `cronjob.kubernetes.io/instantiate` и `batch.kubernetes.io/cronjob-scheduled-timestamp`, назначение объектов и формат значений. https://kubernetes.io/docs/reference/labels-annotations-taints/#cronjob-kubernetes-io-instantiate
- Kubernetes — kubectl create job — Официальный справочник kubectl 1.37: синтаксис `kubectl create job NAME --from=cronjob/name` и описание флага `--from`. https://kubernetes.io/docs/reference/kubectl/generated/kubectl_create/kubectl_create_job/
- Kubernetes — Field Selectors — Официальный список поддерживаемых field selectors: для Event доступны `reason` и поля `involvedObject`, для Job — `status.successful`. https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/
- Kubernetes — kube-apiserver — Официальный справочник параметров kube-apiserver: `--event-ttl duration`, значение по умолчанию `1h0m0s`, назначение — срок хранения событий. https://kubernetes.io/docs/reference/command-line-tools-reference/kube-apiserver/
- Kubernetes — Leases — Официальное описание объектов Lease из API-группы `coordination.k8s.io`, обновления `spec.renewTime`, node heartbeats и leader election. https://kubernetes.io/docs/concepts/architecture/leases/
- Kubernetes API — Lease v1 — Официальная API reference для `coordination.k8s.io/v1`: поля LeaseSpec, включая `holderIdentity`, `leaseDurationSeconds`, `acquireTime` и `renewTime`. https://kubernetes.io/docs/reference/kubernetes-api/coordination-resources/lease-v1/
- client-go — leaderelection — Официальная документация пакета `k8s.io/client-go/tools/leaderelection`: LeaseDuration, RenewDeadline, RetryPeriod, ReleaseOnCancel и предупреждение об отсутствии гарантии fencing. https://pkg.go.dev/k8s.io/client-go/tools/leaderelection
- PostgreSQL — Advisory Lock Functions — Официальная документация PostgreSQL 18, раздел 9.28.10: `pg_try_advisory_lock`, session-level locks, немедленный возврат false при конфликте и освобождение блокировок при завершении сессии. https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
