Cron или systemd timers: что выбрать для запуска скриптов по расписанию
Cron в Linux запускает задачу по расписанию одной строкой в crontab. Он не умеет ждать монтирования NFS, не догоняет пропущенные запуски после перезагрузки и оставляет в журнале минимум следов. Systemd timers закрывают эти пробелы: зависимости в секции [Unit], Persistent=true для пропущенных окон, вывод задачи в journalctl, отдельный пользователь и sandbox для процесса.
Короткий ответ по выбору. Простые периодические операции вроде очистки /tmp или ротации мелких файлов спокойно живут в cron. Задачи, от которых зависит прод, а это бэкапы, синхронизация данных, продление сертификатов, надёжнее переводить на timers: там есть контроль зависимостей, повтор упавших запусков и единая точка диагностики. Обе подсистемы в 2026 году есть в Ubuntu 22.04+, Debian 12+, RHEL 9+ и в большинстве современных дистрибутивов, поэтому выбор определяет задача, а не наличие пакета.
Если расписание нужно внутри кластера, а не на хосте, смотрите на Job и CronJob в Kubernetes: там свои правила ретраев, parallelism и отладки, и перекладывать хост-расписание в манифест без причины не стоит.
| Критерий | cron | systemd timers |
|---|---|---|
| Формат описания | строка в crontab: пять полей времени и команда | два unit-файла: .timer и .service |
| Зависимости | нет, проверки пишутся внутри скрипта | After=, Requires=, RequiresMountsFor= |
| Пропущенные запуски | теряются | Persistent=true запускает задачу после включения сервера |
| Точность запуска | минута | секунды, плюс RandomizedDelaySec |
| Логи | /var/log/syslog или /var/log/cron, почта через MAILTO | journalctl -u backup.service, виден exit code и время старта |
| Окружение | минимальный набор переменных из crontab и /etc/environment | Environment=, EnvironmentFile=, WorkingDirectory= |
| Изоляция | только смена пользователя | User=, ProtectSystem=, PrivateTmp=, NoNewPrivileges= |
| Обработка сбоев | MAILTO и код возврата скрипта | OnFailure=, Restart= для долгоживущих сервисов |
| Цена внедрения | одна команда crontab -e | два файла, daemon-reload, enable --now |
Ключевые различия cron и systemd timers
Cron не знает о состоянии системы. Если задача должна выполниться только после того, как смонтирован /mnt/data, придётся писать проверку в самом скрипте: ждать в цикле появления каталога, проверять mountpoint, выходить с ненулевым кодом. В timers та же логика описывается декларативно: After=network-online.target, RequiresMountsFor=/mnt/data. Systemd не запустит сервис, пока условие не выполнено, и это видно в journalctl без единой строчки отладки в скрипте.
Второе различие в природе расписания. У crontab есть только календарное время и точность до минуты. У timers два независимых механизма: OnCalendar считает по календарю (например, каждый день в 03:00), OnUnitActiveSec задаёт интервал от последнего запуска юнита (например, 15 минут после завершения предыдущего прогона). Смешивать их можно, но путаница между ними приводит к неожиданно частым запускам.
Третье различие в наблюдаемости. Тихий сбой ночного бэкапа в cron без MAILTO не оставит вообще ничего, кроме строки о факте запуска. У timers каждый прогон попадает в journal с exit code, длительностью и полным stderr, а неуспех можно повесить на отдельный юнит через OnFailure=.
Когда cron всё ещё уместен
Cron остаётся разумным выбором в четырёх ситуациях.
- Простые периодические операции: очистка каталогов, ротация небольших логов, удаление старых архивов. Ценность timers здесь не окупает двух unit-файлов.
- Окружения без systemd: контейнеры с минимальными образами, старые сборки, часть BSD-систем.
- Расписания, которые формирует внешний инструмент. Классический пример, TrueNAS и его маршрутизация процессов и задач обслуживания, где cron-задания создаются через веб-интерфейс вместе со скриптами и уведомлениями.
- Быстрые одноразовые напоминания: нужно через час разбудить скрипт и больше к этому не возвращаться.
Практический пример, который мы используем как базу для сравнения: ежедневная очистка /tmp через валидную строку crontab 30 4 * * * find /tmp -type f -atime +7 -delete. Условие atime +7 защищает свежие файлы, а перенаправление вывода в лог оставляет след выполнения.
Синтаксис cron и systemd timers: примеры конфигов
Пять полей crontab читаются слева направо: минуты (0-59), часы (0-23), день месяца (1-31), месяц (1-12), день недели (0-7, где 0 и 7 это воскресенье). Звёздочка означает любое значение, список задаётся через запятую, диапазон через дефис, шаг через слэш. Команда после полей выполняется через SHELL, указанный в crontab или в /etc/crontab.
Пример cron-задачи для запуска скрипта
crontab -e SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin MAILTO=ops@example.com 0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
Разбираем строку. Задача стартует каждый день в 03:00. Вывод и ошибки уходят в /var/log/backup.log, потому что 2>&1 объединяет stderr со stdout. Если убрать перенаправление, cron попробует отправить вывод на адрес из MAILTO, а без MAILTO просто выбросит его.
Два условия, без которых cron-задача не заработает. В скрипте должна быть строка #!/bin/bash первой строкой, а сам файл обязан иметь право на выполнение: chmod +x /usr/local/bin/backup.sh. Ещё одна частая ошибка, относительные пути внутри скрипта. В cron рабочего каталога пользователя может не быть, поэтому либо переходите в нужный каталог внутри скрипта, либо задавайте абсолютные пути к файлам.
Проверить, что строка записана и cron её видит, можно командой crontab -l. Отдельный системный вариант расписания лежит в /etc/cron.d/ и требует указания пользователя шестым полем перед командой.
Пример systemd timer и service
Timer не выполняет команды сам, он запускает сервис. Поэтому создаём пару файлов. Сначала /etc/systemd/system/backup.service:
[Unit] Description=Nightly backup After=network-online.target Wants=network-online.target RequiresMountsFor=/mnt/data [Service] Type=oneshot User=backup Group=backup WorkingDirectory=/opt/backup EnvironmentFile=/etc/default/backup ExecStart=/usr/local/bin/backup.sh StandardOutput=journal StandardError=journal TimeoutStartSec=3600
Затем расписание, /etc/systemd/system/backup.timer:
[Unit] Description=Run nightly backup [Timer] OnCalendar=*-*-* 03:00:00 Persistent=true RandomizedDelaySec=300 Unit=backup.service [Install] WantedBy=timers.target
Что делают ключевые параметры. Type=oneshot говорит, что процесс выполняется до конца и завершается, постоянного демона нет. Persistent=true запоминает факт запуска на диске: если сервер был выключен в 03:00, задача выполнится при ближайшей загрузке. RandomizedDelaySec=300 размазывает старт на пять минут, чтобы сотня хостов не ударила по хранилищу одновременно. RequiresMountsFor=/mnt/data не даст сервису стартовать, пока точка монтирования недоступна.
Активация и проверка:
sudo systemctl daemon-reload sudo systemctl enable --now backup.timer systemctl list-timers --all systemctl status backup.timer
Править unit-файлы удобнее через sudo systemctl edit --full backup.timer: команда открывает копию в редакторе и создаёт drop-in или переопределение, после чего daemon-reload подхватывает изменения. Правки файлов в /etc/systemd/system без reload не действуют, это самая частая причина вопроса «я поменял расписание, а оно не сработало».
Почему скрипт работает вручную, но падает в cron: PATH и переменные окружения
Интерактивная сессия и cron запускают один и тот же скрипт в разных условиях. Оболочка читает /etc/profile, ~/.bashrc, ~/.profile и собирает PATH с /usr/local/bin, каталогами pyenv или nvm, путями к venv. Cron ничего этого не читает. Задаче достаётся минимальный набор переменных, и именно поэтому python3, kubectl, aws или docker «не находятся», хотя вручную всё работает.
Как cron формирует окружение
Демон cron передаёт задаче SHELL, PATH, HOME и LOGNAME. Значения берутся из /etc/crontab, /etc/environment и настроек конкретного crontab, а не из вашей оболочки. Типичный PATH для системного расписания в Debian и Ubuntu выглядит как /usr/bin:/bin, иногда с добавлением /usr/sbin:/sbin. Каталог /usr/local/bin в него часто не входит, а именно там лежат установленные вручную утилиты.
Диагностика занимает одну минуту. Добавьте временную задачу, которая сохраняет окружение:
* * * * * env > /tmp/cron-env.txt
Через минуту сравните файл с выводом env в своей сессии: diff /tmp/cron-env.txt <(env | sort). Разница в PATH, отсутствие HOME и пользовательских переменных сразу объяснит падение. Не забудьте удалить временную строку из crontab.
Три способа исправить. Первый, самый простой, использовать в скрипте и в crontab абсолютные пути: /usr/bin/python3 вместо python3, /usr/local/bin/aws вместо aws. Второй, задать PATH прямо в crontab выше строк с задачами, как в примере выше. Третий, подключить env-файл внутри задачи:
0 3 * * * . /etc/default/backup; /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
Точка в начале строки выполняет файл в текущей оболочке, поэтому переменные попадают в окружение скрипта. Файл /etc/default/backup при этом должен быть читаем пользователем, от имени которого работает cron.
Настройка окружения в systemd timers
Systemd не читает .bashrc и .profile тем более: менеджер запускает процесс напрямую, минуя интерактивную оболочку. Переменные передаются явно.
[Service] Environment=PATH=/usr/local/bin:/usr/bin:/bin Environment=APP_ENV=production EnvironmentFile=/etc/default/backup WorkingDirectory=/opt/app ExecStart=/usr/local/bin/backup.sh
Environment= подходит для несекретных значений, EnvironmentFile= для набора переменных из файла в формате KEY=value. Файл читает сам systemd от имени root, поэтому права 600 и владелец root работают и при запуске сервиса от непривилегированного пользователя. WorkingDirectory= задаёт каталог, откуда стартует процесс, и снимает половину проблем с относительными путями.
Проверить итоговое окружение можно без запуска задачи: systemctl show backup.service -p Environment -p EnvironmentFiles. Для более тонкой работы есть LoadCredential=, который передаёт секрет в приватный каталог процесса и не раскрывает его в списке аргументов.
Логирование и отладка задач в cron и systemd timers
Отладка расписания сводится к трём вопросам: задача вообще стартовала, с каким кодом завершилась, что писала в stderr. Для cron и timers ответы лежат в разных местах.
Где искать логи cron
На Debian и Ubuntu события планировщика попадают в /var/log/syslog, на RHEL-семействе в /var/log/cron; запись выполняет rsyslog. Факт запуска ищется так:
grep CRON /var/log/syslog journalctl -t CRON --since today
Первая команда показывает запуски, вторая работает на системах, где rsyslog перенаправляет сообщения в journald. Важно понимать границу: в лог попадает факт старта задачи, а не её собственный вывод. stdout и stderr уходят на почту из MAILTO либо, при перенаправлении, в указанный файл. Поэтому в проде всегда либо задавайте MAILTO, либо перенаправляйте вывод в файл с ротацией, иначе диагностировать сбой будет нечем.
Отладка systemd timers через journalctl
Здесь картина полнее: journald хранит stdout, stderr, код возврата и время работы сервиса.
journalctl -u backup.service -n 50 journalctl -u backup.service --since today journalctl -u backup.service -f journalctl -u backup.timer
Типичный сценарий диагностики: timer сработал, сервис упал. Сначала смотрите journalctl -u backup.timer, он подтверждает, что срабатывание было, затем journalctl -u backup.service, где виден exit code и текст ошибки. Параметр StandardOutput=journal в unit-файле гарантирует, что вывод скрипта окажется в журнале, а не потеряется. Дополнительные приёмы фильтрации по времени, PID и приоритету, настройка постоянного хранения и ротации разобраны в отдельном руководстве по journalctl: фильтры, хранение и ротация логов.
Список всех таймеров с временем следующего запуска даёт systemctl list-timers --all, а состояние конкретного расписания показывает systemctl status backup.timer с полями Trigger и Active.
Обработка ошибок и надёжность запусков
Расписание, которое молча не работает, хуже отсутствия расписания: вы уверены, что бэкап есть, а его нет. Надёжность строится из трёх вещей: скрипт возвращает честный код, параллельные запуски исключены, о падении кто-то узнаёт.
Начните со скрипта. Строка set -euo pipefail останавливает выполнение на первой ошибке, на необъявленной переменной и в середине конвейера. Без неё скрипт может завершиться с кодом 0 после неудачной команды, и планировщик посчитает задачу успешной.
Предотвращение параллельных запусков
В cron защиту даёт flock: если предыдущий запуск ещё держит блокировку, новый просто не стартует.
*/5 * * * * flock -n /tmp/job.lock /usr/local/bin/job.sh
Флаг -n задаёт неблокирующий режим: вместо ожидания задача сразу завершается. Для задач, которые могут зависнуть, добавьте таймаут внутри скрипта, иначе блокировка останется занятой и все последующие запуски будут отброшены.
У timers защита встроена: пока юнит активен, повторный старт того же юнита systemd не выполняет. Дополнительно держите Type=oneshot и следите, чтобы интервал в OnCalendar превышал реальное время работы задачи. Если бэкап идёт 40 минут, а расписание стоит каждые 15, вы получите пропуски без ошибок в логе. Проверить сетку расписания заранее помогает systemd-analyze calendar. Отдельно выставляйте TimeoutStartSec: по умолчанию сервис без явного лимита может висеть часами.
Примеры готовых скриптов с проверкой кодов возврата, идемпотентностью и контролем восстановления собраны в подборке автоматизации резервного копирования и восстановления.
Уведомления об ошибках
В cron простейший механизм настроен по умолчанию: MAILTO=admin@example.com в начале crontab. Письмо придёт только при непустом выводе, то есть при ошибке или предупреждении. Минус в том, что почта на сервере часто не настроена, а письмо легко потерять.
В timers уведомление привязывается к факту ненулевого exit code через OnFailure=. Добавьте в backup.service строку OnFailure=notify-failure@%n.service и создайте шаблон /etc/systemd/system/notify-failure@.service:
[Unit] Description=Notify about failure of %i [Service] Type=oneshot ExecStart=/usr/local/bin/notify.sh %i
Спецификатор %i подставит имя упавшего юнита, а notify.sh отправит сообщение в Telegram, почту или систему мониторинга. Важная деталь: Restart=on-failure осмысленен для долгоживущих сервисов Type=simple, а для задач Type=oneshot основным механизмом остаётся OnFailure=, потому что перезапуск разовой задачи редко имеет смысл.
Проверить срабатывание уведомления можно принудительно, запустив сервис с заведомо ложным условием, и убедившись, что сообщение пришло. Такой тест стоит делать при каждой правке скрипта уведомления.
Миграция с cron на systemd timers: пошаговый план
Перенос делается без простоя: старая задача продолжает работать, пока новая не проверена. Порядок такой.
- Сохраните текущее состояние: crontab -l > ~/backup-cron.txt и скопируйте файлы из /etc/cron.d/.
- Для каждой задачи создайте пару unit-файлов .service и .timer в /etc/systemd/system/.
- Перенесите окружение: PATH, переменные, рабочий каталог, пользователя. Это главный источник расхождений между планировщиками.
- Проверьте расписание через systemd-analyze calendar до включения таймера.
- Запустите задачу вручную: systemctl start backup.service, затем посмотрите журнал.
- Отключите строку в crontab, оставив комментарий с датой.
- Включите таймер: systemctl daemon-reload и systemctl enable --now backup.timer.
- Проверьте systemctl list-timers, настройте OnFailure= и запишите в документацию, где лежат логи и unit-файлы.
Живой пример такой миграции, продление сертификатов, разобран в руководстве по настройке автоматического продления SSL-сертификатов Let's Encrypt для Nginx с Certbot: там показаны оба варианта расписания, dry-run проверка и перезагрузка Nginx без простоя.
Проверка расписания через systemd-analyze calendar
Команда парсит выражение OnCalendar и показывает нормализованную форму и момент следующего запуска:
systemd-analyze calendar '*-*-* 03:00:00' systemd-analyze calendar 'Mon..Fri *-*-* 09:00:00' systemd-analyze calendar '*-*-* 00/15:00:00' systemd-analyze calendar '*-*-* *:0/15:00'
Первое выражение означает ежедневно в 03:00, второе по будням в 09:00, третье каждые 15 минут в течение часа (00, 15, 30, 45), четвёртое каждые 15 минут каждого часа. В выводе смотрите на строку с нормализованной формой и на время следующего срабатывания: если выражение разобрано как n/a, systemd не загрузит таймер и напишет об ошибке в журнал. Проверить это заранее дешевле, чем искать причину незапустившейся задачи.
Чек-лист миграции
- Сохранён дамп crontab и файлы из /etc/cron.d/.
- Для каждой задачи есть .service и .timer, имена совпадают, в .timer указан Unit=.
- Пути в ExecStart абсолютные, права на файл скрипта сохранены (chmod +x).
- Окружение перенесено через Environment=, EnvironmentFile=, WorkingDirectory=.
- Расписание проверено systemd-analyze calendar, интервал больше времени выполнения задачи.
- Persistent=true выставлен там, где пропуск запуска критичен.
- Задача протестирована командой systemctl start backup.service, журнал прочитан.
- Настроены OnFailure= и, при необходимости, TimeoutStartSec.
- Строка в cron закомментирована, а не удалена до первых успешных автозапусков.
- Документация обновлена: имя юнита, расписание, где логи.
Проверка и тестирование задач по расписанию
Ждать три часа до первого срабатывания не нужно. Оба планировщика позволяют увидеть расписание и запустить задачу вручную.
Как проверить, что timer сработает
Основная команда, systemctl list-timers --all. В выводе важны колонки NEXT (когда следующий запуск), LEFT (сколько осталось), LAST (когда был предыдущий), PASSED (сколько прошло с него), UNIT и ACTIVATES (какой сервис будет запущен). Если для таймера нет значения в NEXT либо он отсутствует в списке без флага --all, значит таймер неактивен: проверьте systemctl status backup.timer и строку WantedBy=timers.target в секции [Install].
systemctl list-timers --all systemctl status backup.timer systemctl cat backup.timer
Команда systemctl cat показывает, какие файлы и drop-in переопределения реально применились. Это спасает, когда правка лежит в /etc/systemd/system/backup.timer.d/override.conf, а кажется, что вы изменили основной файл.
Тестовый запуск без ожидания
Для timers достаточно запустить сервис напрямую, таймер в этом не участвует:
sudo systemctl start backup.service journalctl -u backup.service -f systemctl show backup.service -p ExecMainStatus -p ExecMainExitTimestamp
Так вы проверяете сам скрипт, права пользователя и окружение, не трогая расписание. Для проверки cron временно поставьте * * * * * и подождите минуту, после чего обязательно верните исходное расписание: забытая тестовая строка запускает бэкап или синхронизацию каждую минуту.
Правки unit-файлов требуют перезагрузки конфигурации: systemctl daemon-reload, а затем systemctl restart backup.timer, если менялось расписание. Правки скрипта reload не требуют. Ещё один безопасный приём, заранее поставить OnCalendar=*-*-* *:*:00 на пару минут, убедиться, что журнал и уведомления в порядке, и только потом вернуть рабочее время.
Безопасность и изоляция задач по расписанию
Задача по расписанию с широкими правами это готовый вектор атаки: скрипт, доступный на запись, плюс root по cron равен root-доступу для любого, кто может изменить файл. Ограничить ущерб проще, чем кажется.
Запуск от непривилегированного пользователя
В systemd за это отвечает User= и Group= в секции [Service]. Заведите отдельного пользователя, например backup, и выдайте ему права только на нужные каталоги:
[Service] User=backup Group=backup WorkingDirectory=/opt/backup ProtectSystem=strict ReadWritePaths=/mnt/data /var/log/backup PrivateTmp=true NoNewPrivileges=true ProtectHome=true ProtectKernelTunables=true
ProtectSystem=strict делает файловую систему доступной только для чтения, а ReadWritePaths= возвращает запись конкретным каталогам. PrivateTmp=true выделяет задаче отдельный /tmp, NoNewPrivileges=true запрещает повышать права через setuid-бинарники, ProtectHome=true скрывает домашние каталоги. Такой набор заметно сокращает последствия компрометации скрипта.
В cron аналог частичный: задачу можно поставить в crontab нужного пользователя через sudo crontab -u backup -e, а для операций, требующих root, лучше выдать ограниченное правило sudo на конкретную команду, чем выполнять весь скрипт от root.
Защита секретов в unit-файлах
Пароли и токены не должны попадать в ExecStart: командная строка целиком видна в journalctl и в ps. Для этого есть EnvironmentFile=.
sudo install -m 600 -o root -g root /dev/null /etc/default/backup sudoedit /etc/default/backup
Файл читает systemd от имени root, поэтому права 600 и владелец root не мешают запуску сервиса от пользователя backup. В cron так не получится: скрипт выполняется от своего пользователя, значит env-файл должен быть читаем этим пользователем, и права 640 с владельцем root и группой backup здесь практичнее. Ещё надёжнее передавать секрет через LoadCredential=, тогда он окажется в приватном каталоге процесса и не появится ни в журнале, ни в списке аргументов.
Проверьте готовую задачу перед включением: systemctl cat backup.service, затем systemctl start backup.service, затем journalctl -u backup.service --since today. Если в журнале видны токены, значит они попали в командную строку или в echo внутри скрипта, и это надо исправить до перевода задачи в расписание.