Звонок мониторинга в час ночи: диск забит логами, сервис лёг, утром вас ждут письма от клиентов. А ведь достаточно было одной строчки в crontab, чтобы ротация работала сама. Планировщик задач в Linux ровно про это - перевести рутину из режима «не забыть руками» в режим «идёт само».
Crontab (от «cron table») - файл расписания для демона cron. Демон просыпается раз в минуту, смотрит в таблицу и запускает то, что пора. Инструменту полвека: первые версии написал Кен Томпсон в начале 1970-х, в Unix V7 (1979) cron переписал Брайан Керниган, а привычный нам синтаксис с пятью полями и @-алиасами пришёл из Vixie cron 1987 года. С тех пор cron стоит везде - от Debian на домашнем сервере до RHEL в корпоративном ЦОДе.
Каждая строка в crontab - это расписание в формате «когда» плюс «что делать». Пять полей времени, дальше команда:
* * * * * /path/to/command
Поля идут строго в этом порядке:
| Позиция | Поле | Значения |
|---|---|---|
| 1 | Минута | 0-59 |
| 2 | Час | 0-23 |
| 3 | День месяца | 1-31 |
| 4 | Месяц | 1-12 или jan-dec |
| 5 | День недели | 0-7, где 0 и 7 - воскресенье; или sun-sat |
| 6 | Команда | что запускать, полным путём |
Внутри полей работает горстка спецсимволов. Держите шпаргалку - к ней придётся возвращаться:
| Символ | Что означает | Пример | Читается как |
|---|---|---|---|
| * | Любое значение | * * * * * | каждую минуту |
| , | Перечисление | 15,45 * * * * | в :15 и в :45 каждого часа |
| - | Диапазон | 0 9-18 * * * | каждый час с 9:00 до 18:00 |
| / | Шаг | */10 * * * * | каждые 10 минут |
| - и / вместе | Шаг внутри диапазона | 0 0 1-15/3 * * | в полночь 1, 4, 7, 10 и 13 числа |
| @ | Готовый алиас вместо пяти полей | @daily | раз в сутки в полночь |
Шестой символ, который стоит запомнить, - процент. В полях расписания он не используется, зато в команде ведёт себя неожиданно: cron превращает знак процента в перевод строки, и всё, что стоит после первого неэкранированного процента, уходит команде на stdin вместо аргументов. Поэтому в crontab пишут \% с обратным слэшем. Подробный пример - в разделе с бэкапом MySQL.
И есть правило, на котором спотыкаются чаще всего:
Дальше - готовые строки, которые закрывают почти любую рутину. Их удобно держать под рукой и копировать:
| Расписание | Что делает |
|---|---|
| * * * * * | Каждую минуту |
| */5 * * * * | Каждые 5 минут |
| */15 * * * * | Каждые 15 минут |
| 0,30 * * * * | Каждые полчаса - в :00 и :30 |
| 0 * * * * | Раз в час, в начале часа |
| 0 */4 * * * | Каждые 4 часа |
| 30 2 * * * | Ежедневно в 2:30 ночи |
| 0 9,18 * * * | Дважды в день - в 9:00 и 18:00 |
| 0 9 * * 1-5 | В 9:00 по будням |
| 0 3 * * 0 | В 3:00 в воскресенье |
| 0 0 1 * * | В полночь 1-го числа |
| 0 0 1,15 * * | В полночь 1-го и 15-го числа |
Двух расписаний в этой таблице нет, и не случайно: cron не умеет ни «первый понедельник месяца», ни «последний день месяца». Синтаксиса для этого не существует - ни L, ни #, как в планировщиках вроде Quartz. Обходятся проверкой даты в самой команде:
# первый понедельник месяца, в 4:00
0 4 1-7 * * [ "$(date +\%u)" = "1" ] && /opt/scripts/report.sh
# последний день месяца, в 23:55
55 23 28-31 * * [ "$(date -d tomorrow +\%d)" = "01" ] && /opt/scripts/close-month.sh
Логика простая. Cron будит задачу в широком окне - в первую неделю месяца или в последние четыре дня, - а команда сама решает, тот ли сегодня день. Обратите внимание на \% в обоих случаях: без экранирования строка оборвётся.
Работа с пользовательским crontab идёт через утилиту crontab:
Есть ещё системный файл /etc/crontab. Он отличается от пользовательского дополнительным полем username между расписанием и командой:
# /etc/crontab
0 3 * * * root /usr/local/bin/backup.sh
Здесь явно указано, от какого пользователя запускать задачу. В пользовательских crontab (через crontab -e) этого поля нет - задача идёт от имени владельца файла.
Помимо пяти полей, cron поддерживает сокращения. Ничего нового по функционалу они не дают, зато экономят время и снижают риск ошибки:
| Алиас | Эквивалент | Когда запускается |
|---|---|---|
| @reboot | - | При загрузке системы |
| @hourly | 0 * * * * | В начале каждого часа |
| @daily | 0 0 * * * | В полночь |
| @weekly | 0 0 * * 0 | В полночь воскресенья |
| @monthly | 0 0 1 * * | В полночь 1-го числа |
| @yearly | 0 0 1 1 * | 1 января в полночь |
@reboot - особенно полезная вещь. Запуск VPN-тоннеля, поднятие tmux-сессии, прогрев кэша после перезагрузки: всё это удобно вешать именно на этот алиас. В Windows Task Scheduler для того же результата нужно пройти мастер создания задачи, указать триггер, действие и условия. Здесь - одна строка.
У @reboot есть нюанс: задача запускается в момент старта демона cron, а не в момент загрузки ядра. Если cron стартует раньше сетевого стека, ваш скрипт не сможет обратиться к внешним сервисам. Для таких сценариев надёжнее systemd-юнит с зависимостью After=network-online.target.
Cron запускает задачи в минимальном окружении. PATH будет отличаться от того, к чему вы привыкли в интерактивном терминале. HOME может указывать не туда, куда ожидаете. Переменных из .bashrc и .bash_profile не будет вовсе - cron их не читает. Отсюда классика жанра: скрипт работает руками и падает в cron.
Проверить разницу можно за десять секунд. Выполните env -i /bin/sh -c 'echo $PATH' и сравните с тем, что показывает echo $PATH в обычном терминале. Cron работает примерно в таких же спартанских условиях.
Решения два. Первое - прописывать полные пути к бинарникам внутри скриптов (/usr/bin/python3 вместо python3). Второе - задать переменные прямо в crontab, в шапке файла:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=admin@example.com
0 3 * * * /opt/scripts/backup.sh
Строка SHELL здесь не для красоты. По умолчанию cron выполняет команды через /bin/sh, а на Debian и Ubuntu это dash - он заметно беднее bash. Массивы, подстановки вида ${var//a/b}, переменная $RANDOM в dash не работают. Если в командах crontab есть bash-специфика, SHELL=/bin/bash обязателен.
Переменная MAILTO управляет почтой. По умолчанию cron отправляет весь stdout и stderr задачи владельцу crontab. Если вывод не нужен, его отправляют в /dev/null:
0 3 * * * /opt/scripts/backup.sh > /dev/null 2>&1
А если нужны только ошибки - перенаправьте один stdout:
0 3 * * * /opt/scripts/backup.sh > /dev/null
Так stderr всё ещё будет приходить на почту, а обычный вывод отбросится. Если конструкции с номерами дескрипторов путают, разберитесь с ними отдельно - перенаправление потоков в Linux пригодится далеко за пределами cron.
Cron живёт по системному времени машины. Проверить, какое оно, можно командой timedatectl - она покажет и часовой пояс, и синхронизацию по NTP. Если сервер стоит в Москве, а обслуживает филиал во Владивостоке, «ночной» бэкап в 2:00 уйдёт туда на девять утра - ровно тогда, когда люди садятся работать.
Дальше начинается расхождение между дистрибутивами, и это как раз тот случай, когда чужой совет из интернета может не сработать.
В cronie - планировщике из семейства RHEL (CentOS Stream, Rocky, AlmaLinux и российские сборки на их основе) - есть переменная CRON_TZ. Она задаёт часовой пояс для всех строк ниже, до следующего CRON_TZ:
CRON_TZ=Asia/Novosibirsk
0 2 * * * /opt/scripts/backup.sh
CRON_TZ=Europe/Moscow
0 9 * * 1-5 /opt/scripts/daily-report.sh
А в cron на Debian и Ubuntu (а значит, и в Astra Linux) этой переменной нет. Man-страница cron(8) формулирует прямо: демон работает в одном часовом поясе. Строка CRON_TZ в файле ошибки не вызовет: для этого cron она просто ещё одна переменная окружения, которую передадут задаче. Расписание останется системным. Ошибки вы не увидите. Правильного времени запуска - тоже.
Если CRON_TZ недоступен, вариантов два. Пересчитать время вручную в системный пояс - работает, но становится источником ошибок при каждом изменении расписания. Или передавать пояс самой задаче: TZ=Asia/Novosibirsk /opt/scripts/report.sh - тогда скрипт увидит нужное время внутри, хотя момент запуска всё равно определит системный пояс.
Ещё одна тонкость - для серверов в странах, где часы переводят посезонно. В «пропавший» весенний час задачи не выполнятся, в «задвоенный» осенний могут выполниться дважды. Debian-версия cron это частично сглаживает: при сдвиге часов меньше чем на три часа она добирает или пропускает задания. Полагаться на такую логику при финансовых операциях не стоит - для таких серверов проще держать системное время в UTC.
Теперь конкретика. Вот набор задач, которые закрывают типичные сценарии.
Бэкап MySQL ежедневно в 2:00. Первым делом уберём пароль из командной строки: всё, что стоит в строке запуска, видно в выводе ps любому пользователю машины. Пароль кладут в отдельный файл с правами 600:
# /root/.mysqldump.cnf, chmod 600 - строка живёт в crontab root'а
[mysqldump]
user=backupuser
password=SecretPass
И тогда строка в crontab выглядит так:
0 2 * * * /usr/bin/mysqldump --defaults-extra-file=/root/.mysqldump.cnf --all-databases | gzip > /backups/mysql/$(date +\%Y\%m\%d).sql.gz
Здесь виден тот самый нюанс с процентом: в date +\%Y\%m\%d каждый символ экранирован обратным слэшем. Без этого cron оборвёт строку на первом же проценте, и вместо даты вы получите пустое имя файла. На этом спотыкаются даже опытные админы.
Если бэкап-скрипт сложнее одной строки - вынесите логику в отдельный .sh-файл и вызывайте его из crontab. Так проще и отлаживать, и поддерживать. А чтобы сами бэкапы не стали слабым звеном, стоит заранее продумать выбор сервера для хранения резервных копий - от этого зависит, насколько быстро удастся восстановиться после аварии.
Мониторинг свободного места, предупреждение при заполнении выше 90%:
*/10 * * * * df -h / | awk 'NR==2 && int($5)>90 {print "DISK ALERT: "$5" used"}' | mail -s "Disk Warning" admin@example.com
Проверка каждые 10 минут ловит стремительно растущий лог до того, как диск забьётся под ноль. Если точек монтирования несколько, логику лучше вынести в скрипт с циклом по df и отдельным порогом для каждого раздела. Одну-две машины такие самописные проверки закрывают нормально, но на десятке серверов упираются в потолок - дальше нужен полноценный мониторинг серверов с историей и порогами.
Очистка временных файлов старше 7 дней:
0 4 * * 0 find /var/lib/myapp/tmp -type f -mtime +7 -delete
Запуск в 4 утра по воскресеньям попадает в период минимальной нагрузки на большинстве серверов. Флаг -delete быстрее классического -exec rm {} \; - тот порождает отдельный процесс на каждый найденный файл.
Обратите внимание на путь. Убирать так системный /tmp - плохая идея: на системах с systemd этим занимается systemd-tmpfiles по своим правилам, а под раздачу легко попадут файлы работающих в этот момент процессов. Чистите каталоги своего приложения. И первый запуск любой такой строки делайте без -delete, с -print - посмотрите глазами, что именно она собралась удалить.
Синхронизация файлов между серверами каждые 6 часов:
0 */6 * * * rsync -azq --delete /data/ backup-server:/data/
Задача добавлена, синтаксис верный, но ничего не происходит. Знакомо? Чаще всего проблема сидит в одном из пяти мест, и находится за пару минут, если знать, куда смотреть.
Первый шаг - заглянуть в лог. На большинстве дистрибутивов cron пишет в /var/log/cron или /var/log/syslog:
grep CRON /var/log/syslog | tail -20
Если журнал у вас в journald, то же самое даст journalctl -u cron --since today. Что вообще стоит держать в поле зрения на сервере, разбирали отдельно - как смотреть логи сервера.
Если cron вообще не упоминает вашу задачу - значит, он её не видит. Проверьте, запущен ли демон: systemctl status cron (или crond на CentOS и RHEL). Убедитесь, что редактировали crontab нужного пользователя. Классика: задача добавлена через sudo crontab -e и попала в crontab root'а, а ждут её от своего пользователя.
Если в логе видно, что cron задачу запускает, а результата нет - проблема в самом скрипте. Добавьте логирование вывода:
0 3 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
Перенаправление 2>&1 здесь критично. Без него stderr улетит на почту или в никуда, если MAILTO не настроен, и вы никогда не узнаете, что именно сломалось.
Типичные причины, по которым cron-задачи молча падают:
На продакшн-сервере crontab - потенциальная точка входа. Если скомпрометированный пользователь может создавать cron-задачи, он способен запускать что угодно с периодичностью в минуту. Контролировать это помогают два инструмента: файлы доступа и блокировки.
Файлы cron.allow и cron.deny лежат в /etc/ и определяют, кто вообще имеет право пользоваться crontab. Логика такая: если существует cron.allow - доступ есть только у перечисленных в нём, а cron.deny игнорируется. Если cron.allow нет, но есть cron.deny - заблокированы только указанные в нём. Если нет ни того ни другого - поведение зависит от дистрибутива: в Debian доступ открыт всем, в Red Hat и производных - только root.
Для продакшна разумнее создать cron.allow и внести туда тех, кому crontab действительно нужен. Перечислять всех «лишних» в cron.deny - заведомо проигрышная гонка.
flock решает другую проблему - конкурентный запуск. Представьте: бэкап выполняется 40 минут, а интервал запуска 30. Без защиты два экземпляра работают параллельно, пишут в один файл и дерутся за диск. На выходе - повреждённый архив и просевшая нагрузка. Решение:
*/30 * * * * /usr/bin/flock -n /run/myjob.lock /opt/scripts/heavy-task.sh
Флаг -n означает «не ждать, выйти сразу, если лок занят». Второй экземпляр просто не запустится, пока работает первый. Есть и режим -w timeout: подождать указанное число секунд и сдаться. Для долгих задач с непредсказуемым временем выполнения -n безопаснее.
Про место для лок-файла. В /tmp его держать не стоит: туда пишет кто угодно, и подменить файл там проще, чем кажется. Каталог /run/lock тоже перестал быть надёжным вариантом - в systemd 258 его сделали доступным на запись только root'у и собираются убрать совсем. Для задач от root подойдёт /run, для сервисного пользователя - каталог, которым он владеет. А если возиться не хочется, заблокируйте сам скрипт: flock -n /opt/scripts/heavy-task.sh /opt/scripts/heavy-task.sh. Файл уже существует, прав на чтение достаточно, отдельный каталог заводить не нужно.
Помимо пользовательских crontab-файлов, есть набор системных каталогов, и у каждого своя специфика:
Тонкость: скрипты в cron.daily и соседних каталогах запускает не сам cron напрямую, а anacron или запись в /etc/crontab. Время запуска настраивается в /etc/crontab или /etc/anacrontab. Anacron полезен для машин, которые не работают круглосуточно: он подхватывает пропущенные задачи после включения.
На systemd-дистрибутивах, а это сейчас подавляющее большинство серверных ОС, есть альтернатива - systemd-таймеры. Они решают те же задачи, но с другими компромиссами:
| Критерий | cron | systemd-таймеры |
|---|---|---|
| Сложность настройки | Одна строка | Два файла (.timer + .service) |
| Логирование | /var/log/cron, MAILTO | journalctl, встроенное |
| Зависимости | Нет | Можно указать зависимости от сервисов |
| Точность | 1 минута | До секунды, поддержка монотонных таймеров |
| Запуск пропущенных | Только через anacron | Встроенный Persistent=true |
| Часовые пояса | Один на весь демон (кроме cronie) | OnCalendar с указанием пояса |
| Управление ресурсами | Нет | cgroups, лимиты CPU и RAM через unit-файлы |
Для простых периодических задач cron выигрывает лаконичностью: одна строка вместо двух файлов. Но если нужен контроль ресурсов, зависимости от сервисов или посекундная точность - systemd-таймеры дают больше. Как устроены сами юниты и чем ими управлять, разбирали в гайде по systemd для администратора.
Перевод задачи из cron в systemd выглядит так. Вместо строки 0 3 * * * /opt/scripts/backup.sh создаются два файла:
# /etc/systemd/system/backup.timer
[Unit]
Description=Daily backup timer
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
# /etc/systemd/system/backup.service
[Unit]
Description=Daily backup
[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh
Затем systemctl daemon-reload, чтобы systemd перечитал каталог юнитов, и systemctl enable --now backup.timer. Громоздко? Да. Зато journalctl -u backup.service покажет полный вывод задачи с временными метками, а systemctl list-timers - когда была последняя и когда будет следующая активация.
Случайная задержка. Если десять серверов одновременно ломятся на бэкап-хранилище в 3:00, полоса делится на всех. NFS-шара стонет, rsync-сессии таймаутятся, мониторинг рисует красные графики. Лечится разбросом запусков:
0 3 * * * sleep $(shuf -i 0-900 -n 1) && /opt/scripts/backup.sh
Это раскидает старты по 15-минутному окну. В интернете часто встречается вариант с sleep $((RANDOM \% 900)) - не копируйте его вслепую. $RANDOM есть только в bash, а cron по умолчанию работает через /bin/sh; на Debian и Ubuntu это dash, где переменная пуста, выражение схлопывается в ноль и задержки не будет вообще. И никакого предупреждения: задачи просто продолжат стартовать в одну секунду. Если вариант с RANDOM привычнее, поставьте в шапке crontab SHELL=/bin/bash. Утилита shuf из coreutils работает в любом шелле.
Логирование с временными метками. Голое >> в файл не покажет, когда задача выполнялась:
0 * * * * /opt/scripts/check.sh 2>&1 | while read -r line; do echo "$(date '+\%F \%T') $line"; done >> /var/log/check.log
Crontab под контролем версий. Держите эталонный файл в Git и раскатывайте командой crontab /path/to/crontab-file. Тогда всегда видно, кто и когда менял расписание. Ansible, Salt, Puppet умеют управлять crontab-записями - для инфраструктуры из десятков серверов это логичный следующий шаг. Файлы расписания удобно держать в одном репозитории с самими скриптами: и правки, и ревью в одном месте.
Онлайн-валидаторы. Если выражение вызывает сомнения, есть сервисы вроде crontab.guru: вводите строку, получаете расшифровку человеческим языком и список ближайших запусков. Экономит нервы на хитрых расписаниях с диапазонами и шагами.
Cron пережил контейнеры, облака и Kubernetes. В k8s есть CronJob, в systemd - таймеры, в облаках свои планировщики вроде Cloud Scheduler и EventBridge. Но если у вас стоит Linux-сервер и нужно запускать скрипт по расписанию, crontab делает это за минуту и без единой зависимости. Полвека в строю. Для утилиты командной строки рекомендация неплохая.
Настраиваете регламентные задачи и бэкапы на своём железе?
Инженеры ITTELO подберут конфигурацию под ваш сценарий - объём данных, окно бэкапа, требования к дискам, - соберут и протестируют сервер под задачу перед отгрузкой, с гарантией и поддержкой после продажи. На рынке серверов 11+ лет.