В Kubernetes CronJob выглядит простым: есть расписание — по нему создаются Job, а Job уже поднимают Pod и выполняют задачу. На практике чаще всплывают одни и те же симптомы: запуск «не по времени», дубли, сотни Job в неймспейсе, зависшие или падающие задания, и удивление от того, что «пропущенный» запуск не догоняется.
Ниже — практичный разбор полей CronJob, которые реально решают эксплуатационные проблемы: concurrencyPolicy, startingDeadlineSeconds, suspend, timeZone, а также диагностика stuck jobs / failed jobs и уборка через ttlSecondsAfterFinished. Параллельно разберемся, где в цепочке участвует kube-controller-manager, и как отличать проблемы контроллеров от проблем вашего приложения.
Как CronJob работает изнутри: что важно помнить
Базовая модель такая:
- CronJob по расписанию создает Job.
- Job создает Pod(ы) и добивается завершения в рамках настроек Job (
backoffLimit,completions,parallelism). - После завершения статус Job (Succeeded/Failed) остается в кластере до ручного удаления или автоматической уборки.
Отсюда два практических следствия:
- Если «CronJob создает слишком много объектов» — обычно речь про накопление Job-объектов, а не Pod.
- Если «задание не запускается по расписанию» — чаще виноваты таймзона/время, пропущенные окна (
startingDeadlineSeconds), политика параллельности (concurrencyPolicy) или пауза (suspend).
concurrencyPolicy: что делать, если расписание быстрее выполнения
concurrencyPolicy определяет, что делать, если наступило время нового запуска, а предыдущий Job (созданный этим CronJob) еще не завершился. Это критично для бэкапов, ETL, отчетов, миграций и любой работы с блокировками и внешними ресурсами.
Варианты concurrencyPolicy
Allow(по умолчанию): разрешать параллельные запуски. Если Job выполняется 10 минут, а расписание каждые 2 минуты — получите несколько Job одновременно.Forbid: если предыдущий запуск еще активен — новый пропускается.Replace: новый запуск «заменяет» активный — Kubernetes удалит активный Job и создаст новый.
В эксплуатации чаще всего безопаснее начинать с
Forbidдля неидемпотентных задач (бэкапы/миграции) и использоватьAllowтолько когда вы уверены, что приложение корректно выдерживает параллельность.
Практические сценарии и подводные камни
Дубли и гонки данных. Если периодически «ломается бэкап» или «таблица внезапно заблокирована», частая причина — параллельные Job при Allow. Для задач с блокировками почти всегда выбирайте Forbid или делайте блокировку внутри задания (например, advisory lock в БД).
Replace опасен для долгих операций. Удаление Job приводит к завершению Pod. Если приложение не обрабатывает SIGTERM корректно, можно получить частично выполненные действия. Используйте Replace только если задача безопасно прерывается и перезапускается без побочных эффектов.
Forbid и «почему пропустило запуск». Это нормальное поведение: новый запуск не состоится, пока старый активен. В мониторинге учитывайте это: «нет Job в текущую минуту» не всегда инцидент.
Пример CronJob с Forbid
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-backup
spec:
schedule: "0 2 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: backup
image: alpine:3.20
command: ["sh", "-c", "echo backup && sleep 30"]
Если вам нужно запускать такие задачи на выделенных ресурсах, с предсказуемыми лимитами и возможностью тонко контролировать окружение, удобнее держать их на VDS и уже оттуда работать с кластером/CI (вместо запуска «тяжелых» вспомогательных утилит с локальных рабочих станций).

startingDeadlineSeconds: как Kubernetes «догоняет» пропущенные окна
startingDeadlineSeconds задает окно, в пределах которого CronJob еще имеет смысл запустить, если планировщик пропустил точное время. Пропуски случаются из-за проблем с API, перезапуска компонентов, перегрузки контроллеров, снятия/установки suspend и т. п.
Как это работает
- Если момент запуска пропущен, контроллер оценивает, насколько мы опоздали.
- Если опоздание меньше либо равно
startingDeadlineSeconds— Job создается (догоняем). - Если опоздание больше — запуск считается окончательно пропущенным, и Job не будет создан.
startingDeadlineSecondsне превращает CronJob в очередь заданий. Он задает допустимую задержку старта, а не гарантирует выполнение всех пропущенных запусков.
Рекомендации по значениям
- Для частых задач (каждые 1–5 минут) часто достаточно 30–120 секунд, чтобы сгладить мелкие задержки.
- Для редких задач (раз в час/сутки) обычно ставят 10–30 минут: пережить краткий даунтайм контроллеров, но не запускать «вчерашнее» завтра.
Пример: разрешаем запуск при задержке до 10 минут
apiVersion: batch/v1
kind: CronJob
metadata:
name: hourly-report
spec:
schedule: "0 * * * *"
startingDeadlineSeconds: 600
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: report
image: busybox:1.36
command: ["sh", "-c", "date; echo generate report"]
suspend: пауза CronJob без удаления и типичные ловушки
Поле suspend позволяет «заморозить» CronJob: новые Job не создаются, но объект CronJob остается, и вы не теряете спецификацию. Это удобно для техработ, аварийной остановки «шумных» задач и временного отключения интеграций.
Что происходит при suspend
- Новые Job по расписанию перестают создаваться.
- Уже созданные Job продолжают выполняться как обычно.
Что часто путают
«Я поставил suspend, но Pod еще работает». Ожидаемо: suspend не отменяет активные Job. Если нужно остановить прямо сейчас — разбирайтесь с активным Job/Pod отдельно (обычно удалением Job).
«Снял suspend, и оно не догнало пропущенные запуски». Догон возможен только в пределах startingDeadlineSeconds. Если окно маленькое, а пауза была долгой — пропущенные периоды не воспроизведутся.
Команды для проверки состояния
kubectl get cronjob
kubectl describe cronjob nightly-backup
kubectl get job --sort-by=.metadata.creationTimestamp
kubectl get pod --sort-by=.metadata.creationTimestamp
timeZone в CronJob: почему «не в то время» и как сделать правильно
Симптом «CronJob запускается не в то время» почти всегда сводится к одному из случаев:
- расписание интерпретируется в неожиданной таймзоне;
- кластер живет в UTC, а вы ожидаете локальное время;
- ожидания разъехались из-за сезонных правил времени в вашей зоне.
Поле timeZone
В современных версиях Kubernetes у CronJob есть поле timeZone, позволяющее явно указать IANA-таймзону (например, Europe/Moscow). Тогда таймзона становится частью спецификации, и расписание считается именно в этой зоне.
apiVersion: batch/v1
kind: CronJob
metadata:
name: local-midnight-task
spec:
schedule: "0 0 * * *"
timeZone: "Europe/Moscow"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: Never
containers:
- name: task
image: busybox:1.36
command: ["sh", "-c", "date; echo do work"]
Если timeZone недоступен в вашем кластере
На старых кластерах или при ограничениях политики приходится жить с UTC и «переводить» расписание вручную. Практичное правило: храните cron-выражение в UTC, а рядом (в описании, в README репозитория, в комментарии к манифесту) фиксируйте локальную привязку, чтобы через полгода никто не гадал, почему стоит именно это выражение.
Не путайте timeZone CronJob и TZ внутри контейнера
Даже если CronJob запускается в «правильное время», приложение внутри контейнера может логировать время в UTC (или наоборот). Это отдельная настройка окружения контейнера (например, переменная TZ) и она не влияет на то, когда контроллер создаст Job.
Если для задач вам нужны «глобальные» логи и удобная диагностика по времени, следите за единым стандартом (часто это UTC) и фиксируйте его в командных утилитах/логгере. А для публичных сервисов не забывайте про TLS: для внешних эндпоинтов и вебхуков удобнее сразу поставить корректные SSL-сертификаты, чтобы не тратить время на нестабильные интеграции.

Stuck и failed Jobs: диагностика на уровне CronJob, Job и kube-controller-manager
Когда говорят «CronJob сломан», обычно ломается одно из звеньев цепочки. В эксплуатации удобно разделять симптомы:
- Job не создается — проблема на уровне CronJob/контроллера.
- Job создается, но Pod не стартует — проблема планирования/ресурсов/образа/политик.
- Pod стартует, но Job зависает — проблема приложения/внешних зависимостей/блокировок.
- Job падает (failed) — смотрим логи контейнера и параметры retry.
Быстрый чек-лист команд
kubectl get cronjob local-midnight-task -o wide
kubectl describe cronjob local-midnight-task
kubectl get job --selector=cronjob-name=local-midnight-task
kubectl describe job <job-name>
kubectl get pod --selector=job-name=<job-name>
kubectl logs <pod-name> --all-containers=true
kubectl get events --sort-by=.lastTimestamp
Признаки stuck jobs (зависших Job)
- Job долго в состоянии
Active, а Pod «Running» без прогресса. - Pod в
Pendingиз-за нехватки ресурсов или неразрешимых constraints (affinity, nodeSelector, taints/tolerations). - Pod в
CrashLoopBackOff, и Job остается активным, потому что идут перезапуски/повторы.
Тактика:
- Проверьте
activeDeadlineSecondsв Job — это ограничение на максимальную длительность выполнения. Если задача может зависнуть (ожидание внешнего сервиса), лучше выставить предел и считать таймаут ошибкой. - Проверьте
backoffLimit— чтобы failed jobs не пытались бесконечно. - Проверьте ресурсы Pod:
resources.requestsи квоты namespace, чтобы не застревать в Pending.
Если вам нужен «одиночный» запуск без параллельности (даже при ручном старте и ретраях), часто выручает файловая или распределенная блокировка. По теме пригодится заметка про блокировку cron-задач: как сделать singleton-задачу с flock/lockfile.
failed jobs: где искать причину
Если Job завершается с ошибкой:
- сначала смотрим логи Pod: приложение, миграции, доступы к БД, секреты;
- потом события Pod: образ не скачался, нет прав, проблемы с volume;
- потом параметры повторов:
backoffLimitи поведение приложения на временных ошибках.
Почему CronJob не создает Job и при чем тут kube-controller-manager
За создание Job по расписанию отвечает контроллер CronJob, работающий внутри kube-controller-manager. При системных проблемах (перегрузка API, задержки reconcile, проблемы лидер-элекции в HA) CronJob может пропускать окна запусков или создавать Job с задержкой.
Косвенные признаки проблем на стороне контроллера:
- много разных CronJob в кластере одновременно «опаздывают»;
- в событиях CronJob есть ошибки листинга/записи в API;
- видно, что события/обновления статусов приходят «волнами».
В managed-кластерах доступ к логам управляющего плейна часто ограничен — тогда ориентируйтесь на массовость симптомов и события объектов. В self-hosted кластере проверяйте логи компонента и статус лидер-элекции.
ttlSecondsAfterFinished: как не копить тысячи Job
Даже если CronJob работает идеально, со временем он создаст много Job-объектов. Это:
- засоряет namespace;
- увеличивает нагрузку на API при листинге;
- мешает быстро находить актуальные failed jobs среди «исторических».
Для автоматической уборки используйте ttlSecondsAfterFinished в спецификации Job (обычно через jobTemplate CronJob). После завершения (Succeeded/Failed) Job будет удален через заданный TTL.
apiVersion: batch/v1
kind: CronJob
metadata:
name: cleanup-demo
spec:
schedule: "*/15 * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
ttlSecondsAfterFinished: 3600
backoffLimit: 1
template:
spec:
restartPolicy: Never
containers:
- name: task
image: busybox:1.36
command: ["sh", "-c", "date; echo done"]
Связанные поля истории CronJob
Помимо TTL, у CronJob есть:
successfulJobsHistoryLimit— сколько успешных Job хранить как историю;failedJobsHistoryLimit— сколько неуспешных Job хранить.
Их можно использовать вместе с TTL: лимиты сокращают визуальный «мусор», а TTL гарантирует уборку по времени, даже если лимиты по какой-то причине не применились так, как вы ожидали.
Набор «боевых» пресетов: подставьте под свои задачи
Пресет 1: бэкап/миграции (важно не параллелить)
concurrencyPolicy: ForbidstartingDeadlineSeconds: 1800 (30 минут) или меньшеactiveDeadlineSecondsв Job: ограничить максимальное времяttlSecondsAfterFinished: 86400 (сутки) или меньше
Пресет 2: идемпотентная синхронизация (можно параллелить, но контролируем)
concurrencyPolicy: AllowилиForbid(если внешняя система не любит параллельность)startingDeadlineSeconds: 60–120backoffLimit: 2–5ttlSecondsAfterFinished: 3600–21600
Пресет 3: регулярный отчет «строго актуальный» (старые догонять нельзя)
startingDeadlineSeconds: небольшой (например, 60–300)concurrencyPolicy: Replace— только если безопасно прерыватьtimeZone: зафиксировать явно, если критично «по локальному времени»
Частые вопросы и типичные ошибки
Почему CronJob запускается дважды?
Проверьте concurrencyPolicy (возможно, стоит Allow), а также задержки контроллера: если кластер был перегружен, он мог создать Job с опозданием, а затем наступило следующее окно.
Почему Job «висит активным», но Pod уже завершился?
Такое бывает при проблемах с обновлением статуса или при нестабильной связи с API. Смотрите события и общее состояние кластера. Если массово — подозрение на контроллеры/управляющий плейн. Если единично — часто это гонки при удалении Pod или проблемах с нодой.
Почему после включения timeZone время все равно странное?
Убедитесь, что вы сравниваете именно время создания Job (метаданные и события) с ожидаемым. Затем отдельно проверьте, что показывает date внутри контейнера: это может быть другая таймзона, не влияющая на расписание.
Итог: что настроить в первую очередь
Чтобы быстро привести CronJob к «эксплуатационному» виду, обычно достаточно:
- Выбрать правильный
concurrencyPolicy(частоForbidдля критичных задач). - Задать
startingDeadlineSeconds, чтобы понимать, догоняем ли пропуски и насколько. - Явно зафиксировать
timeZone, если расписание привязано к локальному времени. - Ограничить «вечные» выполнения через
activeDeadlineSeconds(в Job). - Настроить уборку:
ttlSecondsAfterFinishedплюс адекватныеsuccessfulJobsHistoryLimitиfailedJobsHistoryLimit.
После этого большинство ситуаций из разряда «suspended cronjob», «stuck jobs», «failed jobs» становятся предсказуемыми: вы быстрее отличаете проблему приложения от проблем кластера и понимаете, что именно произошло в каждом окне расписания.


