Cron появился в Unix ещё в конце 1970-х, а привычный вид записям заданий придал Пол Викси в 1987 году. С тех пор сменилось несколько поколений операционных систем, но пять полей расписания остались прежними, и любой сервер Linux по-прежнему умеет будить скрипты по минутам, часам и дням недели. За простоту приходится платить: у планировщика репутация молчуна, который то не запускает задание без всяких объяснений, то заваливает почтовый ящик сотней одинаковых писем, то оставляет работать две копии одного тяжёлого скрипта одновременно. Все три беды лечатся настройкой, и ниже разобраны именно они: аккуратный синтаксис времени, вывод в лог-файлы вместо системной почты и замок на время выполнения долгой задачи.

Пять полей расписания, из которых складывается момент запуска

Строка задания в личном crontab устроена предельно просто: пять полей времени и сама команда.

минута  час  день_месяца  месяц  день_недели  команда
30      2    *            *      *            /usr/local/bin/backup.sh

Такая запись запускает резервное копирование каждый день в 02:30. Минута принимает значения от 0 до 59, час от 0 до 23, день месяца от 1 до 31, месяц от 1 до 12, день недели от 0 до 7, причём и 0, и 7 означают воскресенье. Звёздочка заменяет любое допустимое значение, дефис задаёт диапазон, запятая перечисляет варианты, а слэш после звёздочки или диапазона устанавливает шаг.

*/15 8-18 * * mon-fri   /usr/local/bin/poll.sh
0   4    1 * *          /usr/local/bin/monthly-report.sh
0   9    * * mon,wed,fri /usr/local/bin/collect-stats.sh

Первая строка опрашивает систему каждые 15 минут с 8:00 до 18:45 по рабочим дням, вторая собирает отчёт в 04:00 первого числа каждого месяца, третья поднимает статистику в 9 утра по понедельникам, средам и пятницам. Месяцы и дни допустимо писать словами, берутся первые три буквы без учёта регистра: jan-mar, mon-fri. Реализация cronie, на которой работают RHEL, Fedora и ряд других дистрибутивов, умеет ещё и случайность: запись 6~15 в поле минут выбирает случайную минуту от 6 до 15, значение фиксируется один раз при разборе файла. Приём выручает, когда сотня серверов должна дернуть обновление примерно одновременно.

Есть и ловушка, о которую спотыкаются почти все. Два поля дня, день месяца и день недели, объединяются логикой ИЛИ: строка "0 3 1 * mon" выполнит задание и 1-го числа, и в каждый понедельник, хотя интуиция требует пересечения. Сюда же относится летнее время: задания, попавшие в исчезнувший при переводе стрелок час, не выполнятся вовсе, а в повторяющийся час выполнятся дважды.

Для типовых интервалов есть короткие формы, заменяющие все пять полей:

@reboot  /usr/local/bin/warm-cache.sh
@daily   /usr/local/bin/cleanup.sh
@weekly  /usr/local/bin/rotate-keys.sh

@daily означает 0 0 , @weekly означает 0 0 0, @hourly означает 0 *. Отдельно ценна запись @reboot: команда уходит в работу один раз после старта системы, без привязки к конкретному времени. Служба cron сверяет расписание каждую минуту и замечает правки файла по времени его изменения, поэтому после crontab -e ничего перезапускать не нужно.

Где хранятся задания и чем системный crontab отличается от личного

Править задания принято только через crontab -e: команда открывает редактор из переменной EDITOR, проверяет синтаксис и кладёт файл в системный каталог. У обычного пользователя задание лежит в /var/spool/cron/crontabs в Debian и Ubuntu или в /var/spool/cron в RHEL, владелец root и права 600, так что ручная правка текстовым редактором только портит жизнь. Посмотреть текущий список можно командой crontab -l, а crontab -r стирает файл целиком и без вопросов, поэтому осторожные администраторы добавляют ключ -i, который спрашивает подтверждение перед удалением.

Системные задания живут в /etc/crontab и в файлах каталога /etc/cron.d. Там колонок шесть: между расписанием и командой вставляется имя пользователя, от которого запускать.

17 *  * * * root  cd / && run-parts --report /etc/cron.hourly
25 6  * * * root  test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily )

В Debian почасовая уборка исторически назначена на 17-ю минуту часа, ежедневная на 6:25, еженедельная на 6:47 воскресенья, ежемесячная на 6:52 первого числа. Утилита run-parts по очереди запускает все исполняемые файлы каталога, у которых в имени нет точек, поэтому добавить скрипт в /etc/cron.daily проще, чем редактировать общие файлы.

Окружение у заданий спартанское. SHELL по умолчанию /bin/sh, PATH в Debian для пользовательских заданий равен /usr/bin:/bin, HOME и LOGNAME берутся из учётной записи владельца. Отсюда первое правило выживания: либо абсолютные пути в командах, либо собственная шапка в начале crontab.

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""

Вторая ловушка подстерегает в символе процента. Всё после первого % в командной части строки уходит на стандартный вход команды, а сам знак превращается в перенос строки. Команда с шаблоном даты вида $(date +%F) в crontab молча выдаст пустое имя файла. Проценты экранируют:

0 3 * * * tar czf /var/backups/etc-$(date +\%F).tar.gz /etc

Вывод заданий в лог вместо системной почты

Cron собирает всё, что задание выплеснуло в стандартный вывод и в поток ошибок, и отправляет письмом владельцу задания. Письма складываются в /var/mail/root и читаются командой mail, если установлен хоть какой-то локальный почтовый агент. Если агента нет, Debian пишет в системный журнал предупреждение про отсутствие MTA и просто выбрасывает вывод. Знакомая картина: ящик root разросся на сотни мегабайт одинаковых отчётов, а письмо с первой реальной ошибкой потерялось среди шума. Лечится это двумя ходами.

Первый ход, глобальный. Строка MAILTO="" в шапке crontab отключает почту для всего файла, а строка MAILTO=Адрес электронной почты защищен от спам-ботов. Для просмотра адреса в браузере должен быть включен Javascript., наоборот, перенаправляет все письма на внешний адрес. Второй ход, построчный: перенаправление прямо в строке задания.

0  3 * * * /usr/local/bin/backup-db.sh >> /var/log/backup-db.log 2>&1
30 3 * * * /usr/local/bin/sync.sh     >> /var/log/sync.log 2>> /var/log/sync.err
0  4 * * * /usr/local/bin/cleanup.sh  >/dev/null 2>&1

Два знака "больше" дописывают в конец файла, один перезаписывает его с нуля, конструкция 2>&1 после перенаправления уводит туда же и поток ошибок. Первый вариант копит полную историю, второй разделяет нормальные сообщения и ошибки, третий глушит всё и годится только для заведомо шумных утилит. В лог попадает то, что скрипт напечатал сам, поэтому полезно снабжать сообщения метками времени прямо в коде, а для коротких событий удобна утилита logger: строка logger -t backup-db "копия готова" уйдёт в системный журнал и в journalctl.

Лог имеет свойство расти. Стандартный ответ на это, файл /etc/logrotate.d/backup-db:

/var/log/backup-db.log {
    weekly
    rotate 8
    compress
    missingok
    notifempty
}

Раз в неделю журнал сожмут, а восемь старых копий пронесут по кругу. Следы самих запусков ищутся по-разному: grep CRON /var/log/syslog в Debian показывает строки вида CRON[41234]: (root) CMD (/usr/local/bin/backup-db.sh), в RHEL те же события складываются в /var/log/cron, на системах с systemd быстрее всего journalctl -u cron --since today. Владельцу root доступен ещё один трюк: если строка задания начинается с дефиса, скажем -30 2 * /usr/local/bin/noisy.sh, запуск не попадёт в системный журнал.

Замок на время работы, чтобы скрипт не исполнялся в двух копиях

Сценарий известен многим: задание срабатывает каждые 15 минут, а выполняется 20, к вечеру накопились три параллельные копии, каждая держит соединение с базой, а синхронизация задваивает записи. Наивное лекарство с pid-файлом хрупко: процесс завершился по ошибке, файл остался, и задача больше не запустится до ручной чистки. Утилита flock из состава util-linux работает иначе. Она берёт рекомендательную блокировку ядра на файл, а при завершении процесса, хоть штатном, хоть аварийном, блокировка освобождается сама. Забытых замков не остаётся.

В строке crontab это выглядит так:

*/15 * * * * flock -n /var/lock/sync.job /usr/local/bin/sync.sh >> /var/log/sync.log 2>&1

Ключ -n означает не ждать: если замок занят, запуск молча пропустится. Без ключа flock будет стоять в очереди, сколько потребуется, и задание просто отложится до освобождения файла. Промежуточный вариант -w 30 подождёт полминуты и сдастся, а параметр -E 75 задаёт код возврата при отказе, по умолчанию он равен 1. Логичнее всего держать замок не в расписании, а внутри самого скрипта, тогда и ручной запуск окажется защищён:

#!/bin/bash
set -euo pipefail
exec 9>/var/lock/sync.job.lock
flock -n 9 || exit 0

# основная работа скрипта

Дескриптор 9 держит файл открытым всё время работы, значит, держится и блокировка. Ноль вместо единицы в случае занятого замка выбран не случайно: вторая копия не ошибка, а штатный пропуск запуска, и письмо администратору тут только шумит. Для самоблокировки скрипта без внешних обёрток в примерах man flock приведён шаблон с переменной FLOCKER, который переисполняет скрипт уже под замком, если тот запущен впервые. Бывает, что flock в системе нет, скажем в минимальном образе без util-linux, тогда выручает атомарность mkdir:

if mkdir /var/lock/sync 2>/dev/null; then
    trap 'rmdir /var/lock/sync' EXIT
else
    exit 0
fi

Второй параллельный mkdir вернёт ошибку и уйдёт в ветку пропуска, а снятие через trap сработает при любом финале, включая ошибку.

Сквозной пример ночной резервной копии от начала до конца

Теперь всё сказанное можно собрать в один рабочий комплект. Скрипт /usr/local/bin/backup-db.sh:

#!/bin/bash
set -euo pipefail
exec 9>/var/lock/backup-db.lock
flock -n 9 || exit 0

TS=$(date +%F_%H%M)
OUT=/var/backups/db-$TS.sql.gz

mysqldump --single-transaction --routines --triggers mydb | gzip > "$OUT"
find /var/backups -name "db-*.sql.gz" -mtime +14 -delete
logger -t backup-db "готово, файл $OUT, размер $(du -h "$OUT" | cut -f1)"

Строка set -euo pipefail не украшение: без неё упавший mysqldump оставил бы пустой архив, а код возврата взялся бы от gzip, и снаружи задача выглядела бы удачной. Опция --single-transaction даёт согласованный снимок таблиц InnoDB без остановки записи в базу, ключ --routines нужен обязательно, потому что хранимые процедуры без него в дамп не попадают, а --triggers, хотя триггеры и так включаются по умолчанию, делает намерение видимым. Find в конце удаляет копии старше 14 дней, logger оставляет в журнале короткий отчёт с размером файла.

Расписание после chmod +x /usr/local/bin/backup-db.sh выглядит так:

MAILTO=""
SHELL=/bin/bash
0 3 * * * timeout 2h /usr/local/bin/backup-db.sh >> /var/log/backup-db.log 2>&1

timeout страхует от зависания: если копирование не уложится в два часа, утилита отправит скрипту SIGTERM, процесс завершится, и замок освободится сам по себе. Перед вписыванием в расписание скрипт стоит прогнать руками от того же пользователя и заглянуть в /var/log/backup-db.log. Через пару ночей в логе появится собственная хронология, и расследование сведётся к чтению файла, а не к догадкам.

Диагностика молчащих заданий и границы применимости cron

Когда задание молчит, поиск причины занимает минуты, если идти по накатанному маршруту:

  1. убедиться, что служба жива, командой systemctl status cron в Debian и Ubuntu или systemctl status crond в RHEL и Fedora;
  2. найти след запуска в журнале через grep CRON /var/log/syslog или journalctl -u cron --since today;
  3. прогнать команду руками в почти пустом окружении, env -i /bin/sh -c "команда", и посмотреть, не споткнётся ли она без PATH;
  4. проверить шапку crontab на предмет PATH и MAILTO, а заодно убедиться, что проценты в датах экранированы;
  5. вспомнить про часы, исчезающие при переводе времени, и про логику ИЛИ в двух полях дней.

Первые же пункты этого списка закрывают большинство жалоб. Отдельная история у машин, которые не работают круглосуточно: задание на 3 часа ночи домашний сервер просто не увидит. Для таких случаев существует anacron: он хранит отметки последнего запуска в /var/spool/anacron, а после старта системы догоняет пропущенные ежедневные, еженедельные и ежемесячные работы по правилам из /etc/anacrontab.

Более современная альтернатива, таймеры systemd, описывает расписание собственным синтаксисом OnCalendar, журналирует каждый запуск отдельно и умеет догонять пропуски через настройку Persistent=true. Очередь таймеров показывается командой systemctl list-timers. Выбор обычно диктует простота: для десятка скриптов cron компактнее и понятнее, для сервисов с зависимостями и строгой отчётностью удобнее таймеры.

За сорок с лишним лет синтаксис cron почти не изменился, и это его главная сила. Запись из man-страницы 1980-х годов без правок заработает на роутере с BusyBox, на виртуалке с Debian и на сервере с RHEL. Пока задачи сводятся к строке "когда, от кого и что запустить", планировщик остаётся самым коротким путём от идеи до работающей автоматизации.