- Опубликовано: 16 июл 2026
- 19
Cron, systemd-таймеры и скрипты: автоматизируем рутину сервера
Часть 12 из 14 · Серия «Сервер в бою»
За одиннадцать статей у нас накопился целый список обещаний со словами «по расписанию»: prune для Docker и autoremove для пакетов (статья про диск), сброс кэшей (кэширование), heartbeat для мониторинга (Uptime Kuma), продление сертификатов, бэкапы, наконец. Автоматизация — это клей, который держит всё остальное: правильный сервер обслуживает себя сам, а человеку оставляет только чтение отчётов.
Но у этого клея есть фирменный дефект, вынесенный в анонс: cron молча падает. Точнее — молча не работает, что ещё хуже: упавший сервис вы заметите по алертам, а cron-задача, которая полгода тихо завершается с ошибкой, обнаруживается в самый интересный момент — когда нужен вчерашний бэкап, а последний датирован мартом. Сегодня разберём, почему так происходит (спойлер: по пяти разным причинам, и все — классика), как заставить любую задачу отчитываться о жизни и смерти, и когда вместо дедушки crontab стоит взять systemd-таймеры — у них половина кроновских болезней не предусмотрена конструкцией.
Cron за два абзаца — и сразу к болезням
Синтаксис знают все, поэтому телеграфно: crontab -e — личное расписание пользователя, пять полей времени плюс команда:
# мин час день месяц день_недели команда
30 4 * * 0 /usr/local/bin/backup.sh # вс, 4:30
*/5 * * * * /usr/local/bin/heartbeat.sh # каждые 5 минут
Есть ещё системный /etc/cron.d/ и папки cron.daily/weekly/monthly — для задач, не привязанных к пользователю. На этом хорошие новости заканчиваются: написать строчку — одна минута, а вот сделать так, чтобы через год она всё ещё работала и вы об этом знали, — тема остатка статьи.
Пять причин, почему cron молча падает
Причина 1, царица всех остальных: другое окружение. Скрипт, отлаженный в терминале, попадает в cron голым: PATH обрезан до /usr/bin:/bin, никакого .bashrc, никаких переменных вашей сессии, рабочая директория — не та, что вы думаете. Отсюда великий феномен «руками работает, в кроне — нет»: скрипт зовёт docker или certbot, которых нет в кроновском PATH, — и умирает на первой строке. Лечение — привычка: в скриптах для cron все пути абсолютные (/usr/bin/docker, cd /var/www/site1 первой строкой), а нужные переменные объявляются явно.
Причина 2: вывод уходит в никуда. По задумке 1975 года cron отправляет вывод задачи почтой локальному пользователю. На типичном VPS почтовая система не настроена — и все сообщения об ошибках буквально испаряются. Задача может год печатать Permission denied — в пустоту. Минимальное лечение — руками направить вывод в файл:
30 4 * * 0 /usr/local/bin/backup.sh >> /var/log/cron/backup.log 2>&1
(2>&1 — «ошибки туда же»; и не забудьте этот лог в logrotate — пятая статья предупреждала.)
Причина 3: скрипт «успешно» игнорирует собственные ошибки. Shell по умолчанию исполняет скрипт до конца, что бы ни падало по дороге: дамп базы не создался — а архивирование и удаление старых копий прекрасно отработали. Итог: бэкапы «делаются», ротация работает, файлы пустые. Три волшебные буквы в начало каждого скрипта:
#!/bin/bash
set -euo pipefail
# -e: любая ошибка — стоп; -u: необъявленная переменная — стоп;
# pipefail: ошибка в середине пайпа — тоже ошибка
Причина 4: наложение запусков. Задача раз в 5 минут однажды выполняется 7 минут (база подросла, диск занят) — и поверх неё стартует вторая копия. Два rsync в один каталог, два дампа одной базы — дальше фантазируйте сами. Лечение штатное — flock, замок на файле:
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /usr/local/bin/sync.sh
-n — «замок занят — молча выйти», вторая копия просто не стартует.
Причина 5: символ процента. Вишенка для коллекции: в crontab символ % — спецсимвол (означает перевод строки), и невинное date +%F в строке задачи ломает её тихо и намертво. Либо экранировать (date +\%F), либо — правильнее — не писать логику в crontab вообще: одна строка = вызов одного скрипта, вся логика внутри скрипта.
Ловим падения: правило «тишина — это тоже сигнал»
Логи из причины 2 — это возможность расследовать падение. Но кто скажет, что расследовать пора? Читать по утрам логи двадцати задач вы не будете, будем честны. Нужен механизм, который сам поднимет тревогу, — и мы его уже построили в четвёртой статье, осталось применить: push-мониторы Uptime Kuma, они же dead man's switch. Логика обратная обычной: не «сообщи, если умер», а «сообщай, что жив, — а замолчишь, я подниму тревогу».
Заводим в Kuma push-монитор «Weekly backup» с периодом чуть больше интервала задачи, получаем персональный URL — и дописываем в конец скрипта одну строку, которая выполнится только если всё выше прошло без ошибок (спасибо set -e):
curl -fsS --retry 3 "https://ваша-kuma/api/push/AbCdEf123" > /dev/null
Всё. Бэкап не создался, диск кончился, сервер лежал, скрипт удалили при переустановке, крон забыли перенести на новый сервер — любой сценарий молчаливой смерти теперь заканчивается одинаково: пуш не пришёл, Kuma прислала тревогу в Telegram. Это самые окупаемые полминуты настройки на задачу во всей серии: класс проблем «узнал через полгода» закрывается целиком.
Собирём всё в шаблон, который стоит копировать для каждой новой задачи:
#!/bin/bash
set -euo pipefail
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
cd /var/www/site1
# --- полезная работа, абсолютные пути ---
/usr/bin/mysqldump --single-transaction site1_db | /usr/bin/gzip > /backup/site1-$(date +\%F).sql.gz
/usr/bin/find /backup -name "site1-*.sql.gz" -mtime +14 -delete
# --- дошли сюда = всё живо ---
curl -fsS --retry 3 "https://ваша-kuma/api/push/AbCdEf123" > /dev/null
Systemd-таймеры: тот же cron, но с прожектором
Теперь честный разговор про альтернативу. Systemd-таймеры — это «запускать юнит по расписанию», и большинство кроновских болезней у них не лечатся, а отсутствуют по конструкции:
- Вывод и ошибки идут в journalctl — в тот самый журнал из второй статьи:
journalctl -u backup.service— и вся история запусков с полным выводом, без самодельных редиректов. Причина 2 не существует. - Наложение невозможно: сервис — одиночка, пока экземпляр работает, второй не стартует. Причина 4 не существует.
Persistent=true: если сервер был выключен в момент по расписанию (перезагрузка после обновлений, помните шестую статью?) — задача выполнится при старте. Cron пропущенное просто пропускает: воскресный бэкап, совпавший с ребутом, у крона не случится вовсе.systemctl list-timers— вся картина: что, когда запускалось, когда запустится. У cron аналога нет в принципе.
Цена — многословность: вместо одной строки — два файла. Тот же бэкап, /etc/systemd/system/backup.service:
[Unit]
Description=Weekly site backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
и /etc/systemd/system/backup.timer:
[Unit]
Description=Run backup weekly
[Timer]
OnCalendar=Sun 04:30
Persistent=true
RandomizedDelaySec=10m ; не толпиться с другими задачами секунда в секунду
[Install]
WantedBy=timers.target
systemctl daemon-reload && systemctl enable --now backup.timer
systemctl list-timers backup* # проверить расписание
systemctl start backup.service # прогнать руками, не дожидаясь воскресенья
Последняя команда — отдельное удовольствие: задача по расписанию тестируется штатно, тем же путём, каким запустится ночью, — с тем же окружением и тем же журналом. У cron «проверить как оно запустится» — всегда народное творчество.
Вердикт без фанатизма: важное — на таймеры, мелочь — можно на cron. Бэкапы, обновление сертификатов, синхронизации — то, чья молчаливая смерть дорога, — заслуживают журнала, одиночности и Persistent. Пятиминутный heartbeat или чистка tmp — переживут и crontab. А push в Kuma вешайте и там и там: journalctl расскажет почему умерло, но что умерло — первым скажет монитор.
Контрольный пример: сертификаты, которые продлеваются сами (правда ли?)
Соберём всю статью на одном жизненном кейсе. Certbot при установке сам заводит себе таймер — проверьте:
systemctl list-timers | grep certbot
Продление работает из коробки, и тут прячется самая коварная грабля темы: сертификат продлевается, а nginx продолжает раздавать старый — файл-то на диске обновился, но nginx держит в памяти тот, с которым стартовал. Итог: «у нас всё автоматизировано», а через 90 дней сайт встречает посетителей предупреждением браузера. Лечение — deploy-hook, команда, которую certbot выполнит после успешного продления:
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
systemctl reload nginx
(chmod +x не забыть.) Репетиция всего пайплайна, не дожидаясь настоящего продления:
certbot renew --dry-run
И — по заветам этой статьи — страховка на случай, если вся эта автоматика однажды молча развалится: алерт Kuma «сертификат истекает через 7 дней» из четвёртой статьи. Заметьте архитектуру: автоматика делает работу, dry-run проверяет автоматику, внешний монитор проверяет результат независимо ни от чего. Три слоя — и тема сертификатов закрыта навсегда.
Грабли: сводная таблица
| Грабля | Симптом | Лечение |
|---|---|---|
| Относительные пути / голый PATH | «Руками работает, в cron — нет» | Абсолютные пути, PATH в скрипте |
| Вывод в несуществующую почту | Ошибки испаряются годами | >> лог 2>&1 — или таймер с journalctl |
| Скрипт без set -e | Бэкапы «делаются», файлы пустые | set -euo pipefail в каждый скрипт |
| Наложение запусков | Два rsync дерутся за каталог | flock -n — или таймер (там не бывает) |
% в crontab |
Задача с date молча мертва | Логика — в скрипт, crontab — одна строка |
| Ребут в момент запуска | Воскресный бэкап не случился | Persistent=true у таймера |
| Никто не заметит смерть задачи | «Последний бэкап — за март» | Push-монитор Kuma на каждую важную задачу |
| Сертификат обновился, nginx — нет | Через 90 дней — ошибка браузера | deploy-hook с reload + --dry-run + алерт |
Что в итоге
Рутина сервера теперь замкнута: диск чистится сам, сертификаты продлеваются сами (и перечитываются nginx'ом), бэкапы делаются сами — и, главное, о смерти любого из этих «сами» вы узнаете за часы, а не за месяцы, потому что каждая задача обязана регулярно доказывать, что жива. Правило, которое стоит унести: автоматизация без наблюдения — это отложенная авария; автоматизация с dead man's switch — это действительно снятая с человека работа.
Осталась одна большая рутина, которую мы ещё не автоматизировали, — доставка кода. Если вы всё ещё выкатываете сайт перетаскиванием файлов в FileZilla — следующая статья для вас: git pull вместо FTP, хук, который сбрасывает кэши (все обещания из статей 9 и 11 — в одном месте), выкатка одной командой и откат без слёз. CI/CD на своём VPS — звучит громко, делается за вечер.
- 1 Сервер упал в три ночи: разбор полётов без паники
- 2 Логи, которые говорят: учимся читать journalctl, nginx и syslog
- 3 Fail2ban и брутфорс: как перестать кормить ботов
- 4 Мониторинг для одного сервера: Uptime Kuma, Netdata и здравый смысл
- 5 Диск закончился внезапно: куда деваются гигабайты на VPS
- 6 Обновления без страха: apt upgrade, который ничего не сломает
- 7 Свой VPN на VPS за полчаса: WireGuard без магии
- 8 Несколько сайтов на одном VPS: делим ресурсы без драки
- 9 Тормозит сайт — виноват не хостинг: тюним nginx и PHP-FPM
- 10 MySQL на маленьком VPS: почему база съедает всю память
- 11 Кэширование на пальцах: Redis, FastCGI cache и когда хватит просто заголовков
- 12 Cron, systemd-таймеры и скрипты: автоматизируем рутину сервера вы здесь
- 13 Деплой без FTP в 2026-м: git pull, хуки и простой CI/CD на своём VPS скоро
- 14 Переезд на другой VPS без даунтайма: чек-лист миграции скоро
Была статья полезной: