«Раз в час» звучит как законченное требование ровно до трёх вопросов. Что делать, если реплик три — запускать трижды или один раз? Что делать, если планировщик проспал два часа: наверстать оба пропущенных запуска, один, или ни одного? Что делать, если предыдущий запуск ещё идёт, а пора начинать следующий?
Ни на один из них расписание само не отвечает. Ответы задаются политиками, у политик есть умолчания, и умолчания редко совпадают с тем, что человек имел в виду, когда писал «раз в час». Эта статья — про эти политики: что они означают, как проверяются и как выбираются.
Всё поведение ниже снято прогонами на живом кластере Kubernetes, а не пересказано по документации. Это важнее, чем кажется: у планировщика заметная часть семантики нигде не выражена явно и обнаруживается только замером — например, сколько именно запусков он наверстает после простоя.
Это вторая статья серии «Фоновые задачи и планировщики». Первая — про аренду и жизненный цикл джобы, третья — про очередь на PostgreSQLготовится, с 21 сентября. Здесь речь только про запуск по времени: что происходит с джобой после того, как её поставили в очередь, разобрано в первой.
В статье
- Три вопроса, на которые расписание не отвечает
- Один запуск на N реплик
- Перекрытие: что делать, если предыдущий ещё идёт
- Пропущенный запуск: догонять или забыть
startingDeadlineSeconds: срок годности догона- Полночь — худшее время для задачи
- Часовой пояс, которого нет
- Что смотреть в проде
- Где живёт расписание: карта выбора
- Демо и версии
- Документация
Три вопроса, на которые расписание не отвечает
Cron-выражение задаёт моменты времени. Всё остальное — вне его.
Кто запускает. Если сервис работает в трёх репликах и в каждой тикает свой планировщик, «раз в час» превращается в три запуска в час. Три письма клиенту, три пересчёта отчёта, три параллельные записи в одну строку.
Что делать с пропущенным. Планировщик может не работать в момент, на который назначен запуск: деплой, перезапуск, авария, приостановка. Когда он вернётся — наверстать пропущенное или считать, что момент ушёл?
Что делать с перекрытием. Запуск раз в пять минут при задаче, которая иногда идёт семь, рано или поздно упрётся в вопрос: начинать новый поверх идущего, пропустить, или прервать идущий.
Дальше — каждый вопрос отдельно, с ответом, снятым живьём.
Один запуск на N реплик
Самый частый способ получить N запусков вместо одного — запустить планировщик внутри приложения и отмасштабировать приложение.
Лечится это координацией: перед выполнением тика реплики договариваются, кто его выполняет. В стенде к статье координация сделана рекомендательной блокировкой PostgreSQL — но интереснее не она сама, а то, что одной блокировки оказалось мало.
Тик в стенде устроен так, как обычно и выглядит запланированная работа: проверить и сделать. Посмотреть, не отработан ли уже этот интервал, и отработать, если нет — «сделай, если ещё не сделано». Наивно кажется, что такой проверки достаточно и без всякой координации.
Не достаточно, и это первое, что стоит понять про распределённые расписания. При read committed — уровне изоляции по умолчанию в PostgreSQL — все три реплики успевают прочитать «слота ещё нет» до того, как любая из них зафиксирует свою запись, и вставляют все три. Классическая гонка проверки и действия: каждая по отдельности убедилась, что работа не сделана, и каждая её сделала.
Уровень изоляции тут стоит назвать явно, потому что «так работают транзакции» было бы неверным обобщением. При repeatable read гонка сохранится — реплики вставляют разные строки, конфликта записей нет. А serializable её, по идее, поймал бы как зависимость чтения и записи и отменил бы часть транзакций. Последнее я на стенде не проверял, поэтому привожу как рассуждение о механизме, а не как измеренное.
Роль блокировки здесь именно в этом: не «кто-то один работает, остальные ждут», а пара «проверка плюс запись» становится неделимой. Реплика, не получившая блокировку, сразу уходит — это не её тик.
Отдельная деталь, которая едва не сорвала демонстрацию: транзакционная рекомендательная блокировка снимается на коммите, а не на границе интервала расписания. Поэтому вариант «взять блокировку и слепо записать» тоже дал бы дубли — следующая реплика получила бы освободившуюся блокировку и записала бы тот же слот ещё раз. Работает именно связка: проверка и запись под одной блокировкой.
Три реплики, тик раз в 500 мс, прогон 5 секунд, никаких ограничений уникальности в таблице:
--- РАБОЧИЙ ВАРИАНТ ---
реплика s1: записала тиков=5 (lock=true)
реплика s2: записала тиков=2 (lock=true)
реплика s3: записала тиков=2 (lock=true)
всего_тиков=9 уникальных_слотов=9
--- ПАДАЮЩИЙ ВАРИАНТ ---
реплика s2: записала тиков=9 (lock=false)
реплика s1: записала тиков=8 (lock=false)
реплика s3: записала тиков=9 (lock=false)
всего_тиков=26 уникальных_слотов=9 отношение=2.89xС блокировкой девять слотов и девять записей: ни один момент времени не отработан дважды. Без неё — двадцать шесть записей на те же девять слотов.
Две детали этого замера стоят отдельного внимания, потому что обе — про то, как легко здесь обмануться.
В таблице намеренно нет ограничения уникальности. Если бы на колонке слота стоял UNIQUE, инвариант «один запуск на слот» обеспечила бы база, а не блокировка, и демонстрация доказывала бы работу ограничения. Проверял: с UNIQUE и выключенной блокировкой картина выглядит такой же правильной.
Слоты достаются репликам неравномерно — 5, 2 и 2. Это не дефект: шанс, что конкретная реплика не выиграет ни одного тика за короткий прогон, вполне реален. Планировщик с координацией не распределяет нагрузку, он её ограничивает.
И честная оговорка про число: отношение в прогонах вышло 2.89, 3.00 и 3.00. Соблазнительно записать «кратно числу реплик», но первый прогон кратности не даёт. Причина — та самая щель между проверкой и коммитом, о которой шла речь выше: она узкая, и иногда одна из реплик в неё не попадает, обнаруживая слот уже занятым. Кратность числу реплик получается тогда, когда в щель успели все. Устойчиво здесь только неравенство: без координации записей строго больше, чем слотов.
Механика самих распределённых блокировок — аренда, fencing-токены, спор о корректности Redlock, когда нужен настоящий консенсус — разобрана в «Распределённые блокировки и координация». Здесь блокировка используется как готовый инструмент, а её внутренности не важны: важно лишь, что координация нужна и что без неё запусков ровно столько, сколько реплик.
Альтернатива координации в приложении — вынести расписание наружу, к тому, кто по определению один на кластер. В Kubernetes это CronJob, и дальше речь про него.
Перекрытие: что делать, если предыдущий ещё идёт
Kubernetes отвечает на этот вопрос полем concurrencyPolicy с тремя значениями. Проверим все три при заведомом перекрытии: расписание раз в минуту, работа — сто секунд.
spec:
schedule: "* * * * *"
concurrencyPolicy: Allow # Forbid / Replace у соседних веток
jobTemplate:
spec:
template:
spec:
restartPolicy: Never # обязателен в шаблоне пода Job
containers:
- name: work
image: busybox:1.37
command: ["sh","-c","date -u +%H:%M:%S; sleep 100"]Три CronJob отличаются только значением политики. Результат:
| Политика | Максимум одновременно активных | Прерванных джоб |
|---|---|---|
Allow (по умолчанию) |
2 | 0 |
Forbid |
1 | 0 |
Replace |
1 | 4–5 |
Allow — умолчание, и оно означает «мне всё равно». Запуски накладываются: при работе в сто секунд и тике раз в минуту второй стартует поверх первого. Если задача не рассчитана на параллельный запуск самой себя, это тот самый случай, когда ничего не настраивали и получили гонку.
Forbid — очередной запуск пропускается, пока идёт предыдущий. Обратите внимание: пропускается насовсем, а не откладывается. Расписание при этом фактически разрежается — за то же окно наблюдения джоб создаётся меньше.
Replace — активная джоба всегда одна, как при Forbid, но общее число созданных джоб такое же, как при Allow. Разница в том, чем заканчиваются старые: их убивают.
Последнее важно не перепутать, поэтому в стенде это показано не словом «заменена», а событиями кластера — Killing с остановкой контейнера и SuccessfulDelete на самой джобе. То есть работа была оборвана на середине, а не завершилась сама. Для задачи, которая пишет в базу или в файл, Replace означает необходимость быть готовой к тому, что её прервут в произвольной точке.
Абсолютное число созданных джоб в таблицу я не вынес, но по другой причине, чем можно подумать. Разница между политиками там настоящая и содержательная — Forbid за то же окно создаёт меньше джоб, потому что пропускает срабатывания. А вот само число плавает от прогона к прогону на единицу по каждой политике (мой прогон дал 5/3/5 созданных, независимый — 6/4/6): в пятиминутное окно наблюдения попадает то четыре тика, то пять, в зависимости от того, в какой момент минуты запущен замер. Воспроизводятся максимумы и наличие прерываний, они и вынесены как результат.
Пропущенный запуск: догонять или забыть
Теперь главный вопрос — и тот, где интуиция подводит чаще всего.
Планировщик не работал какое-то время. Пропущено, скажем, шесть срабатываний. Он возвращается. Сколько запусков он сделает?
Распространённый ответ — «шесть, он же наверстает». Проверим. Расписание раз в минуту, расписание приостановлено на три минуты, потом возобновлено:
dl-nodeadline (startingDeadlineSeconds не задано):
lastScheduleTime до паузы: 2026-07-19T11:03:00Z
lastScheduleTime после: 2026-07-19T11:09:00Z
догоняющих джоб (слот РАНЬШЕ снятия паузы): 1
штатных джоб (слот ПОСЛЕ снятия паузы): 3Между отметками времени последнего запуска — шесть пропущенных срабатываний. (Шесть, а не три: пауза длилась три минуты, но к ней добавляются подготовка и ожидание нужного момента для снятия — существенно здесь то, что пропущено заведомо больше одного.) Догоняющая джоба — одна.
Kubernetes не хранит очередь пропущенных запусков. Он берёт ближайшее пропущенное срабатывание и выполняет только его; всё, что было раньше, теряется молча.
Отсюда практический вывод, который стоит принять до аварии, а не после: если задача обязана отработать за каждый пропущенный интервал, расписание вам этого не даст. Ежечасный отчёт, пропустивший шесть часов, не станет шестью отчётами. Либо задача сама умеет обрабатывать накопившийся диапазон — то есть смотрит, с какого момента данные не обработаны, и берёт всё, — либо пропуски придётся восстанавливать руками.
Но «один догоняющий запуск» верно не всегда, и вторая граница неприятнее первой. Считая пропущенные срабатывания, контроллер перебирает их подряд от последнего известного запуска, и если их набирается больше сотни, он прекращает счёт и не запускает ничего, сообщая, что не может определить момент старта. Для расписания раз в минуту сотня набегает меньше чем за два часа.
То есть по мере роста простоя поведение меняется дважды: до сотни пропусков — ровно один догоняющий запуск, после — ноль и ошибка в событиях. Ежечасное расписание переживёт четверо суток простоя, ежеминутное — меньше двух часов.
Выход из этого — как раз startingDeadlineSeconds из следующего раздела: с заданным дедлайном контроллер считает пропуски не от последнего запуска, а только внутри окна дедлайна, и в лимит просто не упирается. То есть у поля есть вторая, эксплуатационная причина существования помимо «устаревший запуск бесполезен».
Оговорка: порог в сотню я не воспроизводил — простои в моих прогонах были короче. Это известное поведение контроллера, а не измеренное мной; в отличие от всего остального в этом разделе.
Честная граница этой демонстрации
Пропуски выше созданы приостановкой расписания (suspend), а не остановкой самого контроллера Kubernetes. Настоящий простой kube-controller-manager я не вызывал: на кластере, которым пользуются и другие работы, останавливать управляющий компонент ради демонстрации — плохая идея.
Насколько это одно и то же? Правдоподобно, что одно: оба случая сводятся к «расписание не обслуживалось N минут, затем возобновилось», и оба читают одно и то же поле с временем последнего запуска. Но я это не проверял, и выдавать предположение за факт не буду. Что измерено — то, как ведёт себя контроллер после паузы расписания.
startingDeadlineSeconds: срок годности догона
Одну догоняющую джобу тоже можно отменить. Поле startingDeadlineSeconds задаёт, насколько устаревшим может быть пропущенное срабатывание, чтобы его ещё имело смысл выполнять.
Две ветки, отличающиеся только этим полем, одинаковая пауза, снятие в один момент:
| Ветка | Пропущено | Догоняющих джоб |
|---|---|---|
без startingDeadlineSeconds |
6 | 1 |
startingDeadlineSeconds: 30 |
6 | 0 |
С дедлайном в тридцать секунд догон не выполняется вовсе — расписание возобновляется со следующего штатного срабатывания.
Смысл поля именно такой: «если мы опоздали больше чем на столько, запуск уже бесполезен, пропустим его». Для задачи вроде «разослать утреннюю сводку» это разумно — сводка, отправленная в обед, хуже неотправленной. Для задачи вроде «дочистить очередь» — наоборот, лучше поздно.
В других планировщиках то же самое называется политиками misfire: в Quartz, например, для каждого триггера настраивается, что делать с опоздавшим срабатыванием — выполнить немедленно, пропустить или перенести. Kubernetes даёт из этого набора только «выполнить, если не слишком опоздали», и вся настройка сводится к одному числу.
Ловушка здесь в том, что поле сравнивается с возрастом ближайшего пропущенного срабатывания, а не с длительностью простоя. Я на этом и споткнулся, когда собирал демонстрацию: при расписании раз в минуту ближайшее пропущенное срабатывание почти всегда моложе тридцати секунд, и дедлайн просто не срабатывает — независимо от того, длился простой три минуты или три часа. Первая версия замера показывала догоняющую джобу в обеих ветках, и различие, ради которого всё затевалось, не проявлялось.
Практически это означает: startingDeadlineSeconds имеет смысл соизмерять с интервалом расписания, а не с ожидаемым простоем. Дедлайн меньше интервала будет отменять догон почти всегда; дедлайн заметно больше интервала не отменит его почти никогда.
Полночь — худшее время для задачи
Расписания пишут людям, а люди любят круглые числа. 0 0 * * *, 0 * * * *, */5 * * * *. В результате в полночь просыпается всё сразу — и ваши задачи, и задачи соседей по кластеру, и задачи чужих систем, в которые ваши задачи ходят.
Эффект известен как thundering herd, и у него две стороны. Внутри кластера синхронный старт даёт пик потребления там, где нагрузка могла быть ровной. Снаружи — если сто ваших инсталляций ходят в один чужой API ровно в полночь, вы устраиваете ему пик, которого никто не заказывал.
Лечение простое и почти бесплатное: разброс (он же джиттер). Либо сдвинуть расписание на некруглое время (17 3 * * * вместо 0 3 * * *), либо добавить случайную задержку в начале самой задачи, либо развести инсталляции по времени детерминированно — например, взяв смещение из идентификатора установки, чтобы оно было стабильным между запусками.
Kubernetes встроенного разброса для CronJob не даёт, так что задержка добавляется в саму задачу. Сам факт разброса здесь важнее его величины: даже минута случайного сдвига превращает синхронный залп в размазанный поток.
Тема разбора перегрузки и того, чем на неё отвечают, — в «Ограничение скорости и throttling»; здесь достаточно того, что синхронное расписание создаёт перегрузку на ровном месте.
Часовой пояс, которого нет
Мелочь, которая регулярно всплывает в проде. Если timeZone не задан, расписание считается в часовом поясе того компонента управляющего слоя, который его обслуживает — kube-controller-manager. На практике это почти всегда UTC, потому что в его образе обычно нет базы часовых поясов, но это следствие, а не гарантия. Часовой пояс узлов, приложения и того, кто писал манифест, здесь не участвует вовсе.
В моих прогонах это видно прямо — колонка часового пояса пуста, а времена в выводе идут по UTC:
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE
dl-nodeadline * * * * * <none> False 1 46sТо есть на этом кластере 0 0 * * * — полночь UTC. Для Москвы это три часа ночи, для Владивостока — десять утра. Если задача привязана к бизнес-суткам («отчёт за вчера»), несовпадение суток даст неверные границы данных, и заметят это не сразу.
Начиная с Kubernetes 1.27 часовой пояс задаётся явно полем timeZone — и если задача привязана к местному времени, задавать его стоит всегда, а не полагаться на то, что кластер и так «где надо». Отдельно помните про переход на летнее время там, где он есть: в такие сутки локальное расписание либо пропустит час, либо повторит его.
Что смотреть в проде
У расписания метрик немного, но почти все — про то, чего в логах не видно.
| Метрика | Что означает | Когда тревожно |
|---|---|---|
| Время с последнего успешного запуска | работает ли расписание вообще | превысило интервал заметно — расписание встало, и молча |
| Пропущенные срабатывания | сколько раз момент прошёл впустую | любой ненулевой счёт стоит разобрать |
| Неуспешные запуски | падения самой задачи | отличать от пропусков: это разные отказы |
| Длительность запуска относительно интервала | приближается ли перекрытие | подошла к интервалу — политика перекрытия вот-вот начнёт срабатывать по-настоящему |
| Число активных запусков одновременно | сработала ли политика | больше одного при Forbid или Replace — политика не та, что вы думаете |
Главное здесь — первая строка, и она же чаще всего отсутствует. Расписание, которое перестало срабатывать, не генерирует ни ошибок, ни записей в лог: оно просто ничего не делает. Отказ «задача не запускалась двое суток» обнаруживается не мониторингом задачи, а мониторингом её отсутствия — то есть метрикой возраста последнего успешного запуска, с порогом, превышающим интервал.
Пропуски отдельно стоит выводить из событий кластера, а не выводить из числа выполненных запусков: как показано выше, шесть пропущенных срабатываний дадут один запуск, а сотня — ни одного, и по одному лишь счётчику выполненных эти случаи не различить.
Где живёт расписание: карта выбора
Четыре типовых места, без претензии на полноту.
CronJob в Kubernetes. Планировщик один на кластер, координация не нужна, политики перекрытия и догона настраиваются. Платите за это тем, что задача становится отдельной единицей развёртывания со своим образом, и тем, что семантика пропусков — та, что описана выше, а не та, которую вы бы выбрали.
Планировщик внутри приложения плюс блокировка. Задача живёт в том же коде и том же процессе, что и остальная логика, — не нужно ни отдельного образа, ни манифеста. Платите координацией: нужна блокировка, и её отказ означает либо двойной запуск, либо ни одного. Разумно, когда задач много и они мелкие.
Temporal Schedules. Расписание становится частью долговременного процесса с историей выполнения, повторами и компенсациями. Это уже не «запустить в срок», а «довести до конца», и брать его стоит тогда, когда сама задача — процесс из шагов, а не одно действие. Подробнее — в «Temporal: durable execution».
Обе развилки этой статьи там тоже есть, и в более подробном виде: перекрытие настраивается набором политик (разрешить все, пропустить, буферизовать одно, буферизовать все, отменить или прервать предыдущее), а для догона задаётся окно наверстывания. Первая из них — прямой аналог того самого Allow, который в Kubernetes стоит умолчанием. То есть выбор между «пропустить» и «прервать идущее» никуда не девается при переезде — меняется только богатство набора.
Внешний планировщик. Отдельная система, дёргающая ваш HTTP-эндпоинт по расписанию. Хорош тем, что переживает полную недоступность кластера и виден отдельно от него; плох тем, что это ещё одна система в цепочке, и её собственные гарантии доставки надо выяснять отдельно.
Выбор между первыми двумя чаще решается не техникой, а тем, кто эксплуатирует: расписание в манифесте видно тому, кто смотрит на кластер, расписание в коде — тому, кто смотрит в репозиторий. Худший вариант — когда оно есть в обоих местах и никто не помнит, какое из них рабочее.
Демо и версии
Стенд: digital-cookbook/architecture/background-jobs — k8s-манифесты и скрипты снятия обоих артефактов этой статьи (k8s/concurrency-demo.sh, k8s/deadline-demo.sh), планировщик на рекомендательной блокировке (scripts/scheduler-demo.sh), а также очередь на PostgreSQL для первой и третьейготовится, с 21 сентября статей серии.
Снято на кластере Kubernetes v1.36.2 (Talos v1.13.6), три control-plane и три рабочих узла, образ нагрузки busybox:1.37. Размер кластера взят из потребностей других работ: обе демонстрации касаются контроллера, а не размещения подов, поэтому от числа узлов зависеть не должны — но на одном узле я их не перепроверял.
Каждая демонстрация печатает падающий вариант — что наблюдалось бы, если бы проверяемый механизм не работал. Замер догона прошёл через три дефекта конструкции, и стоят они здесь не для самобичевания: каждый давал правдоподобный, но неверный результат, то есть был бы принят на веру, если бы не такая проверка.
Первый — пауза проверялась только в момент выставления. Прогон показал джобы, созданные внутри окна паузы. Замер не отличал «пауза держалась, срабатывания пропущены» от «пауза не применилась, всё шло штатно» — а счётчик догоняющих в обоих случаях был бы ненулевым и выглядел бы правдоподобно. Теперь состояние паузы проверяется на каждом шаге, и при её потере скрипт падает вместо того, чтобы считать бессмыслицу.
Второй — момент снятия паузы не контролировался. Обе ветки показывали одинаковый результат, и скрипт сам объявил провал демонстрации, вместо того чтобы выдать правдоподобные числа. Это и есть та ошибка про соотношение дедлайна с интервалом расписания, о которой шла речь выше.
Третий — догоняющие считались по времени появления. Так не выйдет: штатное срабатывание, случившееся вскоре после снятия паузы, попадёт в то же окно. Признак по существу — слот расписания, который Kubernetes кодирует прямо в имени джобы (суффикс — номер минуты Unix-времени). Догоняющая та, чей слот раньше момента снятия паузы. Пока классификация шла по времени, ветка с дедлайном «показывала» догоняющую джобу, которой у неё не было.
Скрипты требуют явного указания kubeconfig и дополнительно проверяют, что кластер тестовый, по именам узлов. Это не формальность: скрипт создаёт и удаляет пространство имён, и запуск его по ошибке против рабочего кластера недопустим. Работоспособность самой защиты проверена подстановкой конфига рабочего кластера — оба скрипта падают до создания чего-либо, пространство имён там не появляется. Если воспроизводите у себя — поменяйте префикс проверки на свой, но не убирайте её.
Комментарии