Cron живёт на серверах с конца семидесятых, и за это время накопил репутацию инструмента, который кажется простым ровно до того момента, пока в три часа ночи не приходит звонок от дежурного админа. Пять полей через пробел, никаких зависимостей, поведение описано в man-странице на одной странице. И всё же именно cron остаётся одним из лидеров по количеству боевых инцидентов, связанных с расписанием. Двойные списания, пропущенные бэкапы, отчёты с битыми данными, дубликаты платежей на четверть миллиона долларов - всё это реальные истории, причина которых сводится к двум темам. Первая - неправильно понятый синтаксис. Вторая - переход на летнее или зимнее время.
Из чего состоит крон-выражение и почему пять звёздочек скрывают больше, чем кажется
Базовый шаблон в системном cron выглядит как пять полей, отвечающих за минуты, часы, день месяца, месяц и день недели. Команда идёт после полей. В системных файлах между расписанием и командой добавляется ещё одно поле - имя пользователя, от которого нужно запустить задачу. Личные crontab пользователя этого поля не содержат.
# ┌───────────── минута (0–59)
# │ ┌─────────── час (0–23)
# │ │ ┌───────── день месяца (1–31)
# │ │ │ ┌─────── месяц (1–12)
# │ │ │ │ ┌───── день недели (0–7, где 0 и 7 — воскресенье)
# │ │ │ │ │
# * * * * * команда_к_исполнению
Звёздочка означает все возможные значения. Запятая перечисляет конкретные. Дефис задаёт диапазон. Слеш описывает шаг. Эти четыре оператора покрывают примерно 95 процентов реальных задач, и именно их непонимание чаще всего ломает расписание. Например, многие полагают, что выражение */15 означает буквально каждые пятнадцать минут от любой стартовой точки. На самом деле оно раскрывается как список всех минут с шагом пятнадцать, начиная от нуля.
# Запуск в 0, 15, 30 и 45 минут каждого часа
*/15 * * * * /usr/local/bin/sync-data.sh
# То же самое, записанное явным перечислением
0,15,30,45 * * * * /usr/local/bin/sync-data.sh
Разница между этими двумя записями нулевая для пятнадцатиминутного интервала, но проявляется в неочевидных случаях. Выражение */20 запустит задачу в 0, 20 и 40 минут, а */40 сработает только в 0 и 40 минут, после чего пропустит интервал между 40 и следующим часом. Шаг не учитывает границы часа, и в этом кроется первая ловушка для тех, кто хочет получить ровно сорокаминутные интервалы.
Сложные расписания, в которых одно выражение делает работу нескольких задач
Когда задача требует не равных интервалов, а конкретных моментов в течение дня или недели, выручают комбинации операторов. Запуск каждые пятнадцать минут в рабочее время с понедельника по пятницу выглядит так:
# Каждые 15 минут с 9:00 до 17:45, по будням
*/15 9-17 * * 1-5 /opt/scripts/business-hours-job.sh
Здесь сразу две тонкости. Диапазон 9-17 в часах включает оба конца, поэтому задача отработает и в 17:00, и в 17:15, и в 17:30, и в 17:45. Если нужна жёсткая граница в 17:00, диапазон сокращается до 9-16 плюс отдельная строка для запуска ровно в семнадцать ноль ноль. Вторая тонкость касается дней недели. Воскресенье в большинстве систем кодируется и как 0, и как 7, что упрощает миграцию между разными реализациями cron.
Часто требуется запускать задачу несколько раз в сутки, но в нерегулярные моменты. Например, отчёт нужен утром, в обед и поздно вечером. Запятая решает задачу одной строкой:
# Запуск в 8:30, 13:00 и 22:45 каждый день
30 8 * * * /opt/reports/morning.sh
0 13 * * * /opt/reports/midday.sh
45 22 * * * /opt/reports/evening.sh
# Альтернатива через одну строку с перечислением часов
# (минуты должны совпадать у всех запусков)
0 8,13,22 * * * /opt/reports/run.sh
Перечисление через запятую работает только если совпадает значение остальных полей. Если у каждого запуска свои минуты, проще писать отдельные строки - читаемость важнее экономии байт в crontab.
Конструкции вида */N в поле часов часто работают не так, как ожидает автор. Запись 0 */6 * * * запустит задачу в 0:00, 6:00, 12:00 и 18:00, потому что шаг отсчитывается от нуля. А вот 0 */7 * * * отработает в 0:00, 7:00, 14:00 и 21:00, после чего следующий запуск произойдёт уже в 0:00 следующего дня - между 21:00 и полуночью пройдёт всего три часа вместо ожидаемых семи. Шаг ломается на границе суток, и для действительно равных интервалов длиннее шести часов нужны отдельные системные таймеры или внешние планировщики.
Отдельная история - последний день месяца. Стандартный Vixie cron, который работает в большинстве дистрибутивов Linux, не понимает символ L для обозначения последнего дня. Это расширение характерно для Quartz, Spring Scheduler и некоторых других реализаций. Обходной путь в обычном cron выглядит так:
# Запуск в 23:55 в последний день месяца
55 23 28-31 * * [ "$(date -d tomorrow +\%d)" = "01" ] && /opt/scripts/end-of-month.sh
Логика простая. Cron триггерит проверку каждый день с 28 по 31 число. Внутри команды условие проверяет, наступит ли завтра первое число. Если да, значит сегодня последний день месяца. Знак процента в crontab нужно экранировать обратным слешем, иначе cron интерпретирует его как переход на новую строку и команда оборвётся.
Почему задача не запускается, хотя выражение валидно, и где обычно прячется проблема
Самая частая причина молчания cron - окружение. Демон запускает задачи в минимальном окружении, без переменных, к которым привык интерактивный shell. Скрипт, который безотказно работает из терминала, в cron падает с ошибкой, потому что не находит исполняемые файлы. Спасает явное указание PATH в начале crontab или внутри самого скрипта:
# Объявление окружения в начале crontab
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SHELL=/bin/bash
MAILTO=Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript.
# Дальше идут задачи
*/15 * * * * /opt/scripts/check-status.sh
Переменная MAILTO заставляет cron отправлять stdout и stderr задачи на указанный адрес. Без неё вывод уходит в локальный почтовый ящик пользователя, который никто не читает. На современных серверах удобнее перенаправлять вывод в файл с ротацией:
# Запись stdout и stderr в лог
*/15 * * * * /opt/scripts/job.sh >> /var/log/job.log 2>&1
# Сохраняем только ошибки, успешный вывод выбрасываем
*/15 * * * * /opt/scripts/job.sh > /dev/null 2>> /var/log/job.err
Конструкция 2>&1 направляет поток ошибок в тот же файл, что и обычный вывод. Запись 2>> /var/log/job.err отправляет только ошибки в отдельный лог, а успешный вывод глушит. Второй вариант полезен для шумных задач, где нормальный stdout захламляет диск.
Вторая распространённая беда - перекрывающиеся запуски. Задача, которая обычно отрабатывает за минуту, в час пик может занять восемь. Если cron триггерит её каждые пятнадцать минут, а одна копия ещё работает, через два часа на сервере крутится одновременно восемь параллельных процессов. Решение - флок:
# Запуск с защитой от параллельных копий через flock
*/15 * * * * /usr/bin/flock -n /tmp/sync.lock /opt/scripts/sync-data.sh
Флаг -n означает неблокирующий режим. Если файл блокировки уже захвачен другим процессом, новая копия завершится сразу, не дожидаясь освобождения. Это поведение почти всегда правильное для повторяющихся задач - дубликат не нужен, проще пропустить итерацию и подождать следующей.
Главная мина первого года эксплуатации, которую закладывает переход на летнее время
Россия отказалась от перехода на летнее время в 2014 году, но огромная часть инфраструктуры обслуживает клиентов в США, Европе и Австралии, где DST по-прежнему в силе. Серверы в облачных провайдерах часто живут в часовом поясе региона размещения, а не в UTC. Любая задача с фиксированным локальным временем превращается в потенциальную мину дважды в год.
Поведение Vixie cron при переводе часов описано в man-странице демона. При сдвиге на час вперёд задачи, которые должны были выполниться в пропущенный интервал, запускаются сразу после смены времени. При сдвиге назад cron старается не запускать одну и ту же задачу дважды. Звучит надёжно, но действует это правило только для задач с гранулярностью больше часа и с фиксированным временем запуска. Для расписаний, которые выполняются чаще раза в час, никаких компенсаций нет.
Конкретный пример. Расписание 0 2 * * * означает запуск в два часа ночи. В США весной в воскресенье в марте часы переводят с 2:00 сразу на 3:00. Интервал с 2:00 до 2:59 буквально не существует. Vixie cron заметит пропуск и запустит задачу один раз сразу после перевода. Но если cron работает внутри Kubernetes CronJob или другого планировщика без такой логики, задача может вообще не отработать в этот день. Осенью при обратном переводе час с 1:00 до 1:59 проживается дважды, и расписание 0 1 * * * способно выполнить задачу два раза подряд - что для платёжных систем оборачивается реальными финансовыми потерями.
Кейс из практики команды, потерявшей четверть миллиона долларов на дублях платежей вендорам, описан публично. Расписание выглядело максимально безобидно:
# Kubernetes CronJob, который выглядит безопасным
apiVersion: batch/v1
kind: CronJob
metadata:
name: vendor-payments
spec:
schedule: "0 3 * * *" # каждый день в 3 утра
jobTemplate:
spec:
template:
spec:
containers:
- name: payments
image: payments:latest
Проблема в том, что 3:00 в часовом поясе с переходом на летнее время попадает прямо в зону риска. Когда часы переводят назад, задача отрабатывает дважды. Когда вперёд - может пропуститься. Решение, которое накатили после инцидента, состоит из двух частей. Первая - явное указание часового пояса. Вторая - перенос времени запуска за пределы окна перевода часов:
apiVersion: batch/v1
kind: CronJob
metadata:
name: vendor-payments
spec:
schedule: "0 7 * * *" # 7 утра, гарантированно вне DST-окна
timeZone: "Etc/UTC" # явный часовой пояс
jobTemplate:
spec:
template:
spec:
containers:
- name: payments
image: payments:latest
env:
- name: TZ
value: "UTC"
Поле timeZone в Kubernetes CronJob появилось как стабильное в версии 1.25 и принимает стандартные имена IANA. Это снимает большую часть проблем, но требует понимания, в каком именно поясе должна выполниться задача с точки зрения бизнес-логики, а не инфраструктуры.
Как правильно работать с часовыми поясами в обычном системном крон
В классическом cron из пакетов cronie или vixie-cron поддерживается переменная окружения CRON_TZ. Она указывает часовой пояс, в котором интерпретируются последующие строки расписания. Это работает не на всех системах - в частности, на Debian базовый пакет cron может игнорировать эту переменную. Уточнять стоит в man-странице конкретной сборки.
# Сначала задачи в нью-йоркском времени
CRON_TZ=America/New_York
0 9 * * 1-5 /opt/reports/ny-morning.sh
# Потом переключаемся на токийское
CRON_TZ=Asia/Tokyo
0 9 * * 1-5 /opt/reports/tokyo-morning.sh
# И обратно к серверному UTC для системных задач
CRON_TZ=UTC
0 0 * * * /opt/scripts/daily-cleanup.sh
Принципиально иное поведение у переменной TZ, которую часто путают с CRON_TZ. TZ устанавливается уже внутри окружения запускаемой задачи и не влияет на момент срабатывания. Cron триггернёт задачу по системному времени, а внутри скрипта время будет уже в указанном поясе. Это полезно для команд, которые должны видеть локальное время в логах, но бесполезно как способ управлять расписанием.
Универсальная рекомендация, которую дают практически все источники по эксплуатации - держать сервер в UTC и явно конвертировать локальные требования в UTC при составлении crontab. Минус подхода в том, что весной и осенью таблицу нужно править руками. Плюс - предсказуемость. UTC не знает летнего времени, и расписание ведёт себя одинаково круглый год.
# Сервер в UTC, задача должна срабатывать в 9:00 по Москве
# Москва живёт в UTC+3 без перехода на летнее время
0 6 * * * /opt/reports/moscow-morning.sh
# Та же задача для Берлина (UTC+1 зимой, UTC+2 летом)
# Зимний crontab
0 8 * * * /opt/reports/berlin-morning.sh
# Летний crontab после перевода часов
0 7 * * * /opt/reports/berlin-morning.sh
Ручная правка дважды в год выглядит примитивно, но в проде это часто надёжнее, чем доверять переменным окружения и реализации cron. Более зрелое решение - вынести логику расписания в приложение, которое умеет работать с IANA tz database и само корректно обрабатывает DST.
Идемпотентность и защита от двойного запуска как обязательная часть надёжного крона
Любая задача, которая может что-то изменить или перевести деньги, должна быть идемпотентной. Это означает, что повторный запуск с теми же входными данными не должен приводить к двойному эффекту. Принцип особенно критичен для cron-задач, потому что DST, перезапуски сервера, ретраи мониторинга и баги в самом планировщике способны спровоцировать дубль в самый неподходящий момент.
Самый простой механизм защиты - блокировка по имени и временному окну в базе данных. Перед выполнением задачи приложение пытается вставить запись с уникальным ключом, состоящим из идентификатора задачи и временной метки. Если вставка прошла - задача выполняется. Если упала из-за нарушения уникальности - значит, кто-то уже стартовал её в этом окне, и текущий запуск нужно тихо завершить.
-- Таблица для гарантии однократного выполнения
CREATE TABLE cron_runs (
job_id VARCHAR(64) NOT NULL,
scheduled_at TIMESTAMP NOT NULL,
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (job_id, scheduled_at)
);
-- Перед запуском задачи
INSERT INTO cron_runs (job_id, scheduled_at)
VALUES ('vendor_payments', '2026-03-08 03:00:00')
ON CONFLICT DO NOTHING
RETURNING started_at;
Если RETURNING вернул строку - задача стартует. Если пусто - дубль, тихо выходим. Такая конструкция защищает от любых причин повторного триггера, включая совершенно неожиданные.
Для распределённых систем привычнее использовать Redis с командой SET и флагами NX и EX:
# Захват блокировки на 300 секунд
redis-cli SET lock:vendor_payments:2026-03-08T03:00 "$HOSTNAME" NX EX 300
Флаг NX устанавливает значение только если ключ ещё не существует. EX задаёт TTL в секундах, чтобы сдохший без освобождения процесс не блокировал следующие запуски навечно. Возврат OK означает успешный захват, nil - блокировка уже взята кем-то другим.
Альтернативы кроне, которые стоит рассмотреть для серьёзных нагрузок
Старичок vixie cron остаётся базовым выбором для простых задач, но для всего, где цена ошибки выше пары неотправленных писем, индустрия давно использует более продвинутые инструменты. Systemd timers - встроенная замена в современных Linux-дистрибутивах, поддерживает зависимости между юнитами, корректную обработку часовых поясов и подробное логирование. Расписание описывается в отдельных файлах и легко версионируется в git.
# /etc/systemd/system/sync-data.timer
[Unit]
Description=Sync data every 15 minutes
[Timer]
OnCalendar=*:0/15
Persistent=true
RandomizedDelaySec=30
[Install]
WantedBy=timers.target
Параметр Persistent заставляет таймер выполнить пропущенные запуски после перезагрузки или периода простоя. RandomizedDelaySec добавляет случайную задержку до тридцати секунд - незаменимая вещь для парка из сотен серверов, которые иначе одновременно ломятся в одну базу.
Для сложных пайплайнов с зависимостями подходят Apache Airflow, Prefect или Dagster. Они стоят дороже в эксплуатации, но дают визуализацию, ретраи, бэкфилл, корректную работу с часовыми поясами и уведомления о падениях из коробки. В контейнерных окружениях популярен Supercronic - реализация cron, специально адаптированная под Docker, с правильным выводом логов в stdout и поддержкой переменных окружения без танцев.
Что должно быть в чек-листе перед тем как cron уйдёт в прод
Любая задача, которая попадает в crontab боевого сервера, должна пройти несколько проверок перед запуском. Первая - валидация выражения через crontab.guru или аналогичный сервис. Опечатка в одном символе превращает запуск раз в час в запуск каждую минуту, и это самый распространённый источник серверных аварий по теме cron.
Вторая - явное указание PATH, SHELL и MAILTO в начале crontab. Без них поведение задачи зависит от настроек системы, и переезд между серверами легко ломает всё расписание. Третья - защита от параллельных запусков через flock или внешний механизм блокировки. Четвёртая - перенаправление вывода в файл с ротацией, иначе диск заполняется письмами либо вывод теряется бесследно.
Пятая - явный часовой пояс через CRON_TZ или конвертация всех времён в UTC с документированием бизнес-намерений в комментариях. Шестая - тестовый прогон около дат перевода часов в марте и ноябре, особенно если задачи попадают в окно с 1:00 до 3:00. Седьмая - мониторинг успешного выполнения через внешний сервис типа Healthchecks или Cronitor. Cron ничего не сообщит о том, что задача упала или вообще не запустилась - наблюдение за этим целиком на стороне инженера.
Cron остаётся одним из самых полезных инструментов в арсенале сисадмина именно благодаря своей примитивности. У него нет лишних абстракций, нет состояния, нет зависимостей. Но эта же простота требует от того, кто его использует, понимания всех мелочей - от того, как раскрывается шаг в выражении, до того, что именно происходит с задачей в три часа ночи во второе воскресенье марта. Час, потраченный на изучение этих деталей, экономит дни на разбор инцидентов, которые иначе обязательно случатся.