Тысяча cron-задач на одном сервере: где начинается наложение и потеря запусков
Cron не умеет считать, сколько задач у него уже запущено и хватает ли серверу ресурсов на ещё одну. Он просто смотрит в таблицу расписания и в нужную минуту стартует всё, что должно стартовать — параллельно, без очереди и без вопросов. Пока задач десяток, это никого не беспокоит. Когда их счёт идёт на сотни и тысячи, а часть из них так и норовит собраться в одной и той же минуте, сервер рано или поздно упирается в реальный предел — и начинает либо накладывать запуски друг на друга, либо тихо терять их вовсе. Разберём, где именно проходит эта граница, как её посчитать заранее и что с ней делать.
Содержание
- Что происходит при наложении cron-задач
- Как посчитать реальный предел сервера
- Пиковые минуты: 00:00, начало часа и почему это не совпадение
- Флок-файлы: защита от наложения на уровне скрипта
- systemd timers вместо cron: что меняется при тысяче задач
- Распределение нагрузки по времени и мониторинг пропущенных запусков
Что происходит при наложении cron-задач
Наложение (по-английски overlap) — ситуация, когда очередной запуск задачи стартует раньше, чем закончился предыдущий. Cron не проверяет, жив ли ещё процесс от прошлого запуска: у него нет состояния «задача выполняется», есть только расписание. Наступила минута — запускается новая копия, вне зависимости от того, чем занята предыдущая.
Для задачи, которая выполняется 10 секунд и запланирована раз в час, это не проблема — она физически не может наложиться сама на себя. Проблема начинается там, где время выполнения приближается к интервалу запуска или превышает его:
- скрипт синхронизации файлов запланирован каждую минуту (
* * * * *), а сеть в какой-то момент подтормозила и синхронизация заняла полторы минуты — вторая копия стартует поверх первой; - бэкап базы данных растёт вместе с самой базой и однажды перестаёт укладываться в отведённый час между запусками;
- скрипт агрегации логов упирается в диск в момент, когда параллельно идёт ещё десяток похожих задач, и вместо обычных 5 минут работает 40.
Дальше срабатывает эффект снежного кома. Две копии одного скрипта конкурируют за одни и те же файлы, блокировки в БД или сетевые сокеты — вторая копия работает не быстрее первой, а медленнее обеих по отдельности, потому что они мешают друг другу. Третий запуск накладывается уже на две работающие копии, и через несколько итераций сервер оказывается с десятком зависших процессов одного и того же скрипта — каждый потребляет память и CPU, ничего толком не делая. Похожий сценарий, но с @reboot вместо периодического расписания, разобран в статье про 30 копий сервиса после перезагрузки: cron там честно исполнил ровно то, что было в списке, просто список оказался испорчен.
У наложения есть и менее очевидный побочный эффект — гонка данных. Если две копии скрипта одновременно пишут в один файл или один ряд таблицы, результат непредсказуем: от дублирующихся записей до битого файла, который читающий процесс не сможет разобрать.
Как посчитать реальный предел сервера
Главная ошибка при оценке — считать в штуках задач: «у нас сервер держит 500 cron-задач, значит 1000 — это в два раза больше нагрузки». Так не работает, потому что задачи не равны друг другу ни по длительности, ни по потреблению ресурсов. Правильная единица измерения — не количество задач, а суммарное «занятое время» (CPU-время, время диска, время сети) на минутном интервале.
Базовая формула для одного ресурса:
занятость_ресурса(t) = Σ (время_выполнения_задачи_i), для всех задач,
чей запуск попадает в минуту t
Если у вас 1000 задач и каждая в среднем занимает CPU на 3 секунды, а расписание размазывает их равномерно по 1440 минутам суток — средняя занятость на минуту около 2 секунд CPU-времени, что для любого современного сервера несущественно. Но если 200 из этих 1000 задач стоят на 0 0 * * * (полночь), в эту одну минуту сходится не 2 секунды суммарной работы, а 200 × 3 секунды = 600 секунд работы, которую системе нужно втиснуть в реальные 60 секунд, распределив по доступным ядрам.
Отсюда практическая методика:
- Выгрузите все задачи со всех crontab и
/etc/cron.d/:for f in /var/spool/cron/crontabs/* /etc/cron.d/* /etc/crontab; do cat "$f"; done | grep -v '^#'. - Узнайте среднее время выполнения каждой задачи — из логов, из
timeв обёртке скрипта или из истории мониторинга (см. ниже). - Постройте гистограмму: сколько суммарного «время-CPU» и «время-диска» приходится на каждую минуту суток с учётом расписания каждой задачи.
- Найдите пиковые минуты — именно они, а не среднее по суткам, определяют, выдержит сервер тысячу задач или нет.
- Сравните пик с реальными ресурсами: число ядер × 60 секунд — верхний предел CPU-времени, которое можно исполнить за минуту без ухода в очередь.
Отдельно стоит проверить упор в диск — CPU почти всегда освобождается первым, а дисковые IOPS у бюджетных SSD и тем более сетевых хранилищ кончаются заметно раньше. Как это измерить и отличить от упора в CPU, подробно разобрано в статье про предел IOPS и как отличить упор в диск от упора во всё остальное — методика оттуда напрямую применима к оценке пиковых минут cron.
Грубый ориентир, который стоит проверять на своём железе, а не принимать на веру: если суммарное время исполнения задач в пиковую минуту превышает примерно 70–80% от (число ядер × 60 секунд), запаса на случайные задержки уже нет — диск подтормозил, сеть моргнула, соседняя VM на хосте забрала CPU — и любое из этих событий сдвигает задачи в наложение. Это ориентир для прикидки, а не измеренный порог: у вас цифра может быть другой в зависимости от профиля нагрузки и диска.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПиковые минуты: 00:00, начало часа и почему это не совпадение
Проблема тысячи cron-задач редко распределена равномерно — она концентрируется в нескольких «магнитных» минутах суток, к которым тянется абсолютное большинство расписаний:
- **00:00 (
0 0 * * *)** — самая перегруженная минута на любом сервере с большим числом задач. Сюда стекаются ежедневные бэкапы, ротация логов, очистка временных файлов, генерация отчётов за прошедшие сутки — просто потому, что «раз в день в полночь» кажется всем администраторам самым логичным вариантом по умолчанию. - **Начало каждого часа (
0 * * * *)** — вторая по нагруженности точка: почасовые синхронизации, проверки статусов, отправка накопленных уведомлений. - **Начало пятиминутки (
*/5 * * * *) и получаса (*/15,*/30)** — легче по абсолютной нагрузке, но повторяется 12–288 раз в сутки, и если хотя бы часть задач с этим шагом склонна иногда «залипать», наложение будет происходить регулярно, а не разово.
Механика проста: каждый инженер, добавляющий новую задачу, независимо выбирает «круглое» время — это самый очевидный вариант. Через год эксплуатации сервера с десятками сервисов и командой из нескольких человек в crontab неизбежно образуется толпа задач, стартующих в одну и ту же секунду, просто потому что никто не координировал расписания друг с другом.
Проверить это на своём сервере можно быстро — посчитать, сколько строк в объединённом crontab имеют минутное поле 0:
for f in /var/spool/cron/crontabs/* /etc/cron.d/*; do cat "$f" 2>/dev/null; done \
| grep -v '^#' | awk '{print $1}' | sort | uniq -c | sort -rn | head
Если у поля 0 (полночь при часе 0) счётчик на порядок больше, чем у соседних значений — у вас классическая картина стягивания нагрузки в одну точку, и дальше стоит смотреть, что происходит с диском и CPU именно в эту минуту через sar или dstat, снятые за прошлые сутки.
Флок-файлы: защита от наложения на уровне скрипта
Флок (file lock через flock(1)) — самый дешёвый и надёжный способ гарантировать, что вторая копия задачи не стартует, пока работает первая. Работает это на уровне самого скрипта, независимо от того, что именно его запускает — cron, ручной вызов или сторонний планировщик.
Базовый паттерн — обернуть команду в crontab:
* * * * * /usr/bin/flock -n /var/run/sync_files.lock /opt/scripts/sync_files.sh
-n (non-blocking) означает: если лок уже занят, flock не ждёт, а сразу завершается с ненулевым кодом — новый запуск просто не стартует, вместо того чтобы встать в очередь и выполниться позже вперемешку с текущим. Без -n получится очередь из зависших процессов, ждущих лока, — та же проблема наложения, только отложенная на несколько минут.
Тот же приём внутри самого скрипта, если нужно управлять поведением при занятой блокировке (например, писать в лог, а не просто молча выходить):
#!/bin/bash
exec 9>/var/run/sync_files.lock
if ! flock -n 9; then
echo "$(date '+%F %T') предыдущий запуск ещё не завершился, выходим" >> /var/log/sync_files.log
exit 1
fi
# основная работа скрипта
/opt/scripts/do_sync.sh
Для тысячи задач вручную оборачивать каждую в flock — рутина, но она легко автоматизируется одним общим враппером, который генерирует имя лок-файла из имени самой команды:
#!/bin/bash
# cron_lock.sh — универсальная обёртка
LOCK_NAME=$(echo "$*" | md5sum | cut -d' ' -f1)
exec /usr/bin/flock -n "/var/run/cron_locks/${LOCK_NAME}.lock" "$@"
и вызывать его в crontab как * * * * * /opt/scripts/cron_lock.sh /opt/scripts/sync_files.sh.
Важное ограничение: flock защищает от наложения одной и той же задачи саму на себя, но не решает конкуренцию за ресурсы между разными задачами. Сто разных скриптов со своими локами, стартующих в 00:00, всё равно запустятся одновременно и будут делить CPU и диск — просто ни один не запустится дважды. Флок — защита от дублей, а не инструмент распределения нагрузки.
systemd timers вместо cron: что меняется при тысяче задач
При таком объёме задач cron начинает проигрывать systemd timers по нескольким практическим причинам, а не из идеологических соображений «systemd лучше».
Встроенная защита от наложения. Юнит типа oneshot, запущенный из-под таймера, по умолчанию не стартует повторно, пока предыдущий запуск того же юнита не завершился — systemd просто не ставит новый Start в очередь поверх активного. Это то же самое, что даёт flock, но без необходимости оборачивать каждый скрипт вручную — поведение встроено в саму модель unit-файлов.
Рандомизация (jitter) из коробки. Директива RandomizedDelaySec в таймере размазывает фактический момент запуска в пределах заданного окна — не изменяя логическое время в расписании:
[Timer]
OnCalendar=*-*-* 00:00:00
RandomizedDelaySec=600
Persistent=true
[Install]
WantedBy=timers.target
Здесь задача логически привязана к полуночи, но реально стартует в случайный момент в пределах 10 минут после — ровно то распределение нагрузки, которое в cron пришлось бы вручную вписывать в минутное поле для каждой из тысячи задач по отдельности.
Единый и структурированный лог. journalctl -u имя.service сразу даёт время старта, завершения, exit-код и весь stdout/stderr — без плясок с MAILTO и ротацией файлов, которые приходится городить вручную вокруг cron. При тысяче задач искать причину сбоя по разрозненным логам от тысячи разных скриптов — само по себе трудоёмкая задача.
Явные зависимости и параллелизм под контролем. Юнит может объявить After=, Requires= — не просто «запускать в такое-то время», а «запускать после того, как поднялась база» — из cron такое приходится эмулировать проверками в самом скрипте. И наоборот, если задачам нельзя пересекаться по ресурсам, их можно развести через Conflicts= или отдельные таймеры.
Ложка дёгтя: миграция тысячи существующих cron-строк в unit- и timer-файлы — не одна команда, а тысяча пар файлов или генератор, который их создаёт, и это разовые трудозатраты, окупающиеся не сразу, а через несколько месяцев эксплуатации за счёт меньшего числа инцидентов с наложением. Для небольшого числа задач овчинка часто не стоит выделки — там flock в crontab решает проблему дешевле. И если сервис переезжает между локациями с разными системными часовыми поясами, стоит заранее свериться с историей о том, как systemd-таймер жил по UTC и отчёты уезжали на три часа — грабля актуальна что для cron, что для таймеров одинаково.
Распределение нагрузки по времени и мониторинг пропущенных запусков
Даже с флок-файлами и переходом на systemd timers сама проблема концентрации задач в «круглых» минутах никуда не девается сама по себе — её нужно разгребать руками.
Практические приёмы распределения:
- Разносите расписание вручную по правилу «не круглое время». Вместо
0 0 * * *для десятка бэкапов используйте3 0 * * *,7 0 * * *,11 0 * * *— с шагом, достаточным, чтобы предыдущая задача успела освободить диск и CPU. - Используйте встроенный джиттер там, где он есть. Помимо
RandomizedDelaySecв systemd, некоторые утилиты бэкапа и синхронизации имеют собственный флаг случайной задержки. - Группируйте задачи по нагрузочному профилю, а не по логическому смыслу. Дисковые задачи (бэкапы, архивация) и CPU-задачи (пересчёт отчётов, шифрование) чередуйте по времени, а не ставьте рядом — так они меньше конкурируют за один ресурс.
- Заведите отдельное окно для по-настоящему тяжёлых задач. Если бэкап занимает 40 минут и упирается в диск — не ставьте рядом ещё пять похожих задач «на всякий случай», разнесите их так, чтобы тяжёлые операции не пересекались друг с другом.
С мониторингом пропущенных запусков ситуация принципиально другая: cron сам по себе не сообщает, что задача не выполнилась или выполнилась не вовремя — вы узнаёте об этом либо по косвенным последствиям (бэкапа нет, отчёт не пришёл), либо через отдельный механизм подтверждения. Практическая схема на pull-принципе (сервер сам стучится наружу после завершения задачи) подробно разобрана в статье про Healthchecks.io и мониторинг cron-задач — идея «выключателя мертвеца» особенно хорошо ложится именно на большое число мелких задач: настраивается один раз на скрипт-обёртку, а не индивидуально на каждую задачу.
Минимальный набор для тысячи задач без готового сервиса мониторинга — самостоятельный лог начала и конца каждого запуска с последующей сверкой:
#!/bin/bash
JOB="$1"; shift
echo "$(date -Iseconds) START $JOB" >> /var/log/cron_runs.log
"$@"
STATUS=$?
echo "$(date -Iseconds) END $JOB status=$STATUS" >> /var/log/cron_runs.log
exit $STATUS
и раз в сутки — скрипт, который проверяет по этому логу, для каких задач ожидаемый запуск не появился в отведённое окно (по расписанию из crontab плюс запас). Это грубее, чем внешний сервис подтверждений, но закрывает главный риск — узнать о пропуске задачи не через три недели по факту отсутствия данных, а на следующее утро.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Сколько cron-задач реально выдерживает один сервер?
Универсального числа нет — предел определяется не количеством задач, а суммарным временем их выполнения в пиковую минуту относительно доступных ядер и IOPS диска. Тысяча лёгких задач, равномерно размазанных по суткам, может нагружать сервер меньше, чем полсотни тяжёлых, собранных в одной минуте.
Достаточно ли одного flock без перехода на systemd timers?
Для защиты от наложения одной и той же задачи самой на себя — да, этого достаточно. Для проблемы конкуренции за ресурсы между разными задачами в пиковые минуты — нет, flock эту проблему не решает вообще, нужно распределение расписания.
Как быстро понять, есть ли уже проблема наложения на сервере?
Проверьте, есть ли зависшие процессы одного и того же скрипта: ps aux | grep имя_скрипта | grep -v grep | wc -l — если число больше единицы в момент, когда должна работать только одна копия, наложение уже происходит.
Стоит ли переводить сразу все тысячу задач на systemd timers?
Обычно нет смысла делать это разом — начните с самых длительных и самых частых задач, где риск наложения и стоимость ошибки выше всего, а мелкие редкие задачи можно оставить на cron с flock, если они и так укладываются в свой интервал с запасом.
Почему при перегрузке сервера задачи не ставятся в очередь автоматически?
Потому что у cron нет очереди в принципе — это не планировщик задач с приоритетами и слотами, а таблица «время → команда». Роль очереди приходится реализовывать самостоятельно через flock, systemd-юниты или внешний оркестратор задач.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →