Демон cron знаком каждому, кто админит Linux. Он крутит бэкапы, чистит временные файлы и запускает скрипты, о которых все уже давно забыли. Однако многие не знают, что одна строка в crontab способна снести данные, забить диск, устроить форк-бомбу или открыть лазейку для хакера. Под катом я разберу, когда и как именно cron становится опасным, и какие команды помогут держать его в узде.

Откуда взялся cron

Cron появился ещё в классическом Unix. В Version 7 Unix, выпущенной Bell Labs в 1979 году, он уже входил в систему и запускался при переходе в многопользовательский режим. Системный планировщик задач раз в минуту открывал /usr/lib/crontab, сверял строки со временем и выполнял соответствующие команды.

В первые версии cron входил один системный crontab, немного регулярных задач и обслуживание самой системы. Когда Unix начали использовать на машинах с десятками учётных записей, демон давал сбой. Чтобы такого не происходило, в Университете Пердью сделали cron с очередью событий и пользовательскими crontab, который получал доступ к расписанию. Позже эта линия попала в Unix System V, BSD и коммерческие Unix-системы.

В конце 1980 годов Пол Викси выпустил свою реализацию планировщика. С ним обычно и связывают формат, который многие до сих пор видят в Linux: crontab, пять полей времени, команда в конце строки, переменные окружения внутри расписания, @reboot, @daily и интервалы вида */5. Однако в Red Hat-подобных системах сегодня чаще всего стоит cronie, а в Debian и Ubuntu используется Vixie cron 3.0pl1 с патчами. Но эта разница практически ни на что не влияет. 

Сейчас через cron запускают бэкапы, чистку /tmp, самодельную ротацию логов, обновление кеша, проверку сертификатов, выгрузки для отчётов и небольшие сервисные скрипты. Из-за того, что одни строки добавляет текущая команда, другие прилетают с пакетами, а третьи остаются после миграций, через пару лет в crontab набирается много непонятных команд. Зачастую их даже не чистят. Но проблему создают не только они, поэтому дальше я разберу каждое «опасное» место этого инструмента. 

Ошибки в cron не видны 

Когда cron запускает процесс, у него нет терминала и интерактивной shell-сессии. Если скрипт завершится с ошибкой, то вы её не увидите. Всё, что программа пишет в stdout или stderr, cron попытается отправить владельцу crontab или адресу, указанному в MAILTO. Когда-то это действительно работало, ведь на серверах был настроен локальный почтовый агент, системную почту читали, а сообщения об ошибках не терялись. На современных VPS редко установлен MTA, а почтовый ящик никто не проверяет. В итоге ошибка просто исчезает.

Из этого всего следует, что у cron-задачи сразу должен быть понятный вывод. Для начала можно писать stdout и stderr в отдельный лог: * * * * * /path/to/script.sh >> /var/log/my-task.log 2>&1.

Этого уже достаточно, чтобы понимать, что происходит. Однако есть условия: пользователь, от которого запускается задача, должен иметь право писать в /var/log/my-task.log, а сам файл нужно отдать в ротацию. В иной ситуации через какое-то время cron перестанет быть проблемой, потому что первой проблемой станет заполненный диск. Тут для простого cron-лога можно добавить файл /etc/logrotate.d/my-task: 

/var/log/my-task.log { daily rotate 14 compress missingok notifempty copytruncate }

Параметр copytruncate подходит для большинства cron-задач — скрипт быстро запускается, записывает вывод и завершается. Если логов становится много, их нужно собирать централизованно. Удобнее сразу отправлять сообщения в syslog или journald. Для этого достаточно стандартной утилиты logger: * * * * * /path/to/script.sh 2>&1 | logger -t my-task. После этого сообщения будут доступны через journald:

journalctl -t my-task --since "1 hour ago" 

Место хранения логов зависит от дистрибутива. В Debian и Ubuntu записи cron обычно находятся в /var/log/syslog. В RHEL, AlmaLinux, Rocky Linux и других системах семейства Red Hat чаще используется отдельный журнал. 

Если cron запущен через systemd, имя сервиса зависит от дистрибутива. Можно проверить оба варианта.

journalctl -u cron -S today

journalctl -u crond -S today

У варианта с logger есть нюанс — код возврата такой строки будет кодом последней команды в пайпе, то есть logger, а не вашего скрипта. В связи с этим, для бэкапов, выгрузок и задач с данными лучше использовать wrapper, где отдельно сохраняется лог, exit code и блокировка от повторного запуска.

В таких случаях лучше использовать небольшой wrapper-скрипт. В нём удобно сразу собрать всё, что обычно требуется для эксплуатации: блокировку повторного запуска, логирование и проверку кода завершения.

#!/bin/bash

set -Eeuo pipefail

LOCKFILE="/run/lock/my-task.lock"

LOGFILE="/var/log/my-task.log"

exec 200>"$LOCKFILE"

if ! flock -n 200; then

echo "$(date -Is): already running, exiting" >> "$LOGFILE"

exit 0

fi

echo "$(date -Is): starting" >> "$LOGFILE"

/path/to/actual/script.sh >> "$LOGFILE" 2>&1

rc=$?

if [ "$rc" -ne 0 ]; then

echo "$(date -Is): failed with exit code $rc" >> "$LOGFILE"

exit "$rc"

fi

echo "$(date -Is): finished OK" >> "$LOGFILE"

Тогда в crontab остаётся всего одна строка: * * * * * /usr/local/bin/cronjobs/my-task-wrapper.sh.  Если вы хотите оставить почтовый вывод, лучше указать точный адрес. Но сначала стоит проверить, что почта с сервера вообще уходит: 

echo "cron mail test" | mail -s "cron test" admin@example.com

Если письмо не пришло, MAILTO в crontab не спасёт бэкапы. В таком случае лучше сразу писать в файл с ротацией, syslog, journald или во внешний healthcheck. Для быстрой проверки самого cron можно временно добавить тестовую строку: * * * * * date -Is >> /tmp/cron-test.log 2>&1. Через пару минут проверьте файл:

cat /tmp/cron-test.log

После проверки строку лучше удалить. Временные cron-задачи слишком легко остаются на сервере навсегда.

Ещё один момент: запись в системном журнале показывает только запуск команды. Она не доказывает, что бэкап создался, архив уехал в хранилище, а старые копии удалились без ошибки. Для задач восстановления лучше оставить отдельный признак успешного завершения: 

/path/to/backup.sh >> /var/log/backup.log 2>&1

rc=$?

if [ "$rc" -eq 0 ]; then

    date -Is > /var/lib/backup/last-success

fi

exit "$rc"

Такой файл уже можно проверять мониторингом: 

stat /var/lib/backup/last-success

Его можно завести в Prometheus, Zabbix, свой healthcheck или любой другой мониторинг. Главное, чтобы у cron-задачи был проверяемый след успешного выполнения, а не только факт запуска.

Cron запускает задачу не в вашем окружении

Вторая проблема состоит в том, что зачастую при тестировании команда работает, а из cron падает. Обычно дело в окружении, ведь по умолчанию у задачи есть только минимальный набор переменных. SHELL обычно указывается в /bin/sh, HOME и LOGNAME берутся из записи пользователя в /etc/passwd, а PATH часто оказывается короче, чем в консоли. Из-за этого скрипт может внезапно запуститься не тем интерпретатором, Node.js не найдётся, а mysqldump возьмётся из другого каталога.

В связи с этим, перед добавлением задачи в cron, её лучше прогнать в пустом окружении. Например: 

env -i SHELL=/bin/sh HOME=/root PATH=/usr/bin:/bin /bin/sh -c '/path/to/command'

Для сервисного пользователя лучше проверять сразу от его имени:

sudo -u backup env -i \

  SHELL=/bin/sh \

  HOME=/home/backup \

  PATH=/usr/bin:/bin \

  /bin/sh -c '/usr/local/bin/backup-wrapper.sh'

Это не полная копия cron, потому что разные реализации могут добавлять свои переменные и учитывать PAM, но проверка ловит совсем грубые проблемы. Если команда не работает с пустым окружением, в crontab она тоже не заработает. 

В самом crontab окружение лучше задавать точно. Особенно если речь идёт о бэкапах, выгрузках или задачах, которые запускаются не один месяц: 

SHELL=/bin/sh

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

MAILTO=admin@example.com

15 2 * * * /usr/local/bin/cronjobs/backup-wrapper.sh

Для Python и Node.js лучше не надеяться на менеджеры версий из интерактивной сессии. Если задача должна идти через виртуальное окружение, указывайте полный путь к интерпретатору: 30 * * * * /opt/myapp/.venv/bin/python /opt/myapp/jobs/recalculate.py.

Для Node.js указывайте тот бинарник, который должен запускаться на сервере: */10 * * * * /usr/bin/node /opt/myapp/jobs/sync.js.

Если приложению нужны переменные из отдельного файла, загрузите их в wrapper-скрипте. Так проще понять, откуда берётся конфиг, и не приходится надеяться на случайные переменные: 

#!/bin/bash

set -u

set -a

. /etc/myapp/cron.env

set +a

exec /opt/myapp/.venv/bin/python /opt/myapp/jobs/sync.py

Однако файл с переменными не должен быть доступен всем подряд, особенно если там лежат токены, пароли или ключи: 

sudo chown root:myapp /etc/myapp/cron.env

sudo chmod 640 /etc/myapp/cron.env

Отдельно помните про /bin/sh. В Debian и Ubuntu это обычно dash, а не bash. В Red Hat-подобных системах /bin/sh может указывать на bash, но он запускается как sh и ведёт себя иначе. Поэтому конструкции вроде [[ ... ]], массивов и process substitution лучше не писать прямо в crontab. 

Логику лучше вынести в скрипт и там указать shell: */5 * * * * /usr/local/bin/cronjobs/app-run-wrapper.sh. Сам wrapper:

#!/bin/bash

set -u

if [[ -f /tmp/flag ]]; then

    exec /opt/app/run.sh

fi

Перед тем, как отправлять такой скрипт в cron, его полезно прогнать через shellcheck: 

shellcheck /usr/local/bin/cronjobs/app-run-wrapper.sh

Он не заменяет ревью, но ловит забытые кавычки, неинициализированные переменные и проблемы с запуском.

Cron не проверяет, закончилась ли прошлая задача

Ещё одна проблема появляется, когда задача работает дольше своего интервала. Это происходит из-за того, что cron не следит за тем, завершился ли прошлый запуск. Быстро проверить, не копятся ли такие процессы, можно через pgrep:

pgrep -a -f 'sync-job'

pgrep -c -f 'sync-job'

Это грубая проверка, потому что pgrep -f может поймать wrapper, shell и похожие команды, но для оценки её хватает. Если вместо одного процесса вы видите десятки, cron уже начал размножать задачу.

Самый простой способ остановить повторный запуск, использовать flock из util-linux: */5 * * * * flock -n /run/lock/sync-job.lock /usr/local/bin/cronjobs/sync-job-wrapper.sh.

Ключ -n отмечает, что flock не будет ждать освобождения lock-файла. Если предыдущая копия ещё работает, новая сразу завершится. Также для важных задач я бы держал flock внутри wrapper-скрипта. Так рядом остаются лог, код возврата и понятное поведение при повторном запуске:

#!/bin/bash

set -u

LOCKFILE="/run/lock/sync-job.lock"

LOGFILE="/var/log/sync-job.log"

exec 200>"$LOCKFILE"

if ! flock -n 200; then

    echo "$(date -Is): previous run is still active, skipping" >> "$LOGFILE"

    exit 0

fi

echo "$(date -Is): started" >> "$LOGFILE"

/opt/app/bin/sync-job >> "$LOGFILE" 2>&1

rc=$?

echo "$(date -Is): finished with exit code $rc" >> "$LOGFILE"

exit "$rc"

В crontab после этого остаётся обычный вызов wrapper: */5 * * * * /usr/local/bin/cronjobs/sync-job-wrapper.sh. Когда lock уже занят, я в таком случае часто возвращаю 0. Если пропуск запуска для вас — это проблема, то можно вернуть 1 и отдельно настроить алерт на такие события.

Бывает и другая ситуация, когда задача не просто работает долго, а зависает. В этом случае lock защитит от новых копий, но старая так и останется висеть. Тут можно добавить таймаут: */5 * * * * flock -n /run/lock/sync-job.lock timeout 10m /usr/local/bin/cronjobs/sync-job-wrapper.sh.

Таймаут завершит процесс после заданного времени, но он не отменяет нормальную обработку ошибок внутри самой задачи. 

Cron плохо дружит с локальным временем

Cron берёт расписание из системного времени сервера. На машинах с локальным часовым поясом возникают проблемы, поэтому для серверов проще держать UTC: 

sudo timedatectl set-timezone UTC

timedatectl status

После этого расписание лучше писать уже с учётом UTC. Однако, если вы админите не один, то не забудьте предупредить об этом команду. 

Есть ещё одна проблема, уже связанная с простоями. Обычный cron не выполняет задачу, которая была пропущена из-за выключения сервера или перезагрузки. Если машина была недоступна в 02:15, а бэкап стоял именно на это время, он чаще всего не стартует.

Для серверов, которые иногда выключаются, исторически использовали anacron. В системах с systemd похожую задачу часто решают через timer с Persistent=true:

[Timer]

OnCalendar=*-*-* 02:15:00

Persistent=true

Такой таймер запомнит пропущенный запуск и выполнит задачу после следующей активации таймера. Но и он не заменяет проверку самого бэкапа.

Cron легко прячет лишние задачи

Cron удобен не только админам, но и хакерам. Строка в crontab переживает перезагрузку, запускается без участия пользователя и ничем не выделяется. К слову, зачастую подозрительная cron-задача выглядит банально. Например, curl или wget в одну строку, запуск из /tmp или /dev/shm, base64, bash -c, @reboot, непонятный домен и скрипт в домашнем каталоге старого пользователя.

Начать аудит можно с системных задач: 

sudo ls -la \

  /etc/crontab \

  /etc/cron.d \

  /etc/cron.hourly \

  /etc/cron.daily \

  /etc/cron.weekly \

  /etc/cron.monthly 2>/dev/null

После посмотрите пользовательские crontab. Чтобы не писать временные файлы прямо в /tmp, удобнее создать отдельный временный каталог: 

tmpdir="$(mktemp -d)"

for user in $(getent passwd | cut -d: -f1); do

    if sudo crontab -u "$user" -l > "$tmpdir/cron-$user" 2>/dev/null; then

        echo "=== $user ==="

        cat "$tmpdir/cron-$user"

    fi

done

rm -rf "$tmpdir"

Кстати, на старых корпоративных серверах после такой проверки часто находятся задачи от пользователей, которых уже нет в команде. Недавние изменения также можно посмотреть через find:

sudo find /etc/cron* /var/spool/cron* -type f -mtime -7 -ls 2>/dev/null

Для быстрого поиска подозрительных строк подойдёт обычный grep:

sudo grep -RnsE '(@reboot|curl|wget|base64|bash -c|/tmp/|/dev/shm|nc[[:space:]])' \

  /etc/cron* /var/spool/cron* 2>/dev/null

Отдельно проверьте права. Файлы cron и скрипты, которые он запускает, не должны быть доступны всем подряд: 

sudo find /etc/cron* /var/spool/cron* -type f -perm /022 -ls 2>/dev/null

После этого пройдитесь уже по самим скриптам из расписания. Ручной crontab -e на проде лучше оставить для экстренных случаев. Для одиночного сервера можно хотя бы подключить etckeeper, чтобы изменения в /etc не исчезали бесследно:

sudo apt-get install etckeeper

sudo etckeeper init

sudo etckeeper commit "initial /etc snapshot"

Напомню, что у cron нет встроенного ревью, истории правок и проверки смысла команд — всё это придётся делать снаружи. 

Что нужно проверить перед добавлением задачи в cron

Лучше потратить пять минут на минимальную проверку, чем потом страдать. Сначала убедитесь, что все команды вызываются по полным путям. В интерактивной сессии mysqldump, python, node, flock или logger могут находиться через ваш PATH, а в cron такого PATH уже не будет.

command -v mysqldump

command -v python3

command -v flock

command -v logger

После этого команду нужно запустить от того же пользователя, от которого она будет идти в cron. Если это отдельная учётка, проверять надо именно её.

sudo -u backup /usr/local/bin/cronjobs/backup-wrapper.sh

Следующий шаг — прогон в почти пустом окружении. На этом этапе как раз и всплывают проблемы с PATH, переменными, домашним каталогом и зависимостью от .bashrc:

sudo -u backup env -i \

  SHELL=/bin/sh \

  HOME=/home/backup \

  PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \

  /bin/sh -c '/usr/local/bin/cronjobs/backup-wrapper.sh'

Если wrapper написан на bash, это должно быть видно в первой строке файла: #!/bin/bash. Сам файл перед добавлением в cron лучше проверить хотя бы базово: 

bash -n /usr/local/bin/cronjobs/backup-wrapper.sh

shellcheck /usr/local/bin/cronjobs/backup-wrapper.sh

Есть момент, на минимальной VPS может отсутствовать shellcheck, поэтому его придётся поставить из репозитория дистрибутива. Для Debian и Ubuntu это обычно делается так: 

sudo apt-get update

sudo apt-get install shellcheck

Для RHEL-подобных систем команда зависит от подключённых репозиториев, но чаще всего выглядит так: 

sudo dnf install ShellCheck

После не забудьте проверить права. Cron не должен запускать скрипт, который может редактировать любой пользователь на сервере. Особенно если задача идёт от root: 

ls -l /usr/local/bin/cronjobs/backup-wrapper.sh

namei -l /usr/local/bin/cronjobs/backup-wrapper.sh

Для обычного root-скрипта права могут быть такими: 

sudo chown root:root /usr/local/bin/cronjobs/backup-wrapper.sh

sudo chmod 750 /usr/local/bin/cronjobs/backup-wrapper.sh

Если скрипт пишет лог, каталог и файл тоже надо проверить заранее. Иначе задача запустится, упадёт на записи в лог, а вы будете искать ошибку не там: 

sudo mkdir -p /var/log/cronjobs

sudo touch /var/log/cronjobs/backup.log

sudo chown backup:backup /var/log/cronjobs/backup.log

sudo chmod 640 /var/log/cronjobs/backup.log

После этого можно сделать тестовую запись в crontab на ближайшее время. Так вы сразу увидите, что cron действительно запускает задачу: * * * * * /usr/local/bin/cronjobs/backup-wrapper.sh. Через пару минут проверьте лог: 

tail -100 /var/log/cronjobs/backup.log

И системный журнал cron: 

journalctl -u cron -S "10 minutes ago"

journalctl -u crond -S "10 minutes ago"

На Debian и Ubuntu можно дополнительно посмотреть /var/log/syslog: 

grep CRON /var/log/syslog | tail -50

На RHEL-подобных системах часто есть отдельный файл /var/log/cron:

sudo tail -50 /var/log/cron

Когда тест прошёл, временную строку надо удалить и поставить нормальное расписание: 15 2 * * * /usr/local/bin/cronjobs/backup-wrapper.sh. И последний пункт, который лучше сделать сразу. Оставьте рядом с задачей комментарий: 

# owner: infra, daily database backup, writes to /var/log/cronjobs/backup.log

15 2 * * * /usr/local/bin/cronjobs/backup-wrapper.sh

Через год этот комментарий может сэкономить больше времени, чем вся первоначальная настройка.

Что запомнить

Сам по себе cron не плохой, ведь он делает именно то, для чего его придумали. Поэтому для простых задач не рекомендую менять его на Systemd timers, Cronie или Jobber. Для важных задач, таких как бэкапы, уже можно поглядеть альтернативы. А что насчёт cron, то тут важна дисциплина. Будет она — не будет проблем.  

Делитесь в комментариях, какие кроновые аварии ловили вы и как из них выбирались. А также пишите, если нужна подборка аналогов.

© 2026 ООО «МТ ФИНАНС»

Комментарии (0)