MAATRIX / Блог / Cron не отработал ночью и никто не заметил: разбор тихого сбоя

Cron не отработал ночью и никто не заметил: разбор тихого сбоя

MAATRIX

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

Как обычно всплывает проблема

Классический сценарий выглядит одинаково почти во всех компаниях. Диск на проде сгорел, нужно поднять базу из последнего бэкапа — а в директории с бэкапами лежит файл трёхдневной давности. Или маркетолог просит отчёт за вчера, а скрипт агрегации данных молчал уже неделю, и таблица пуста. Или на сервере закончилось место, потому что задача очистки временных файлов не выполнялась месяц.

Во всех случаях последовательность одна: задача была настроена один раз, «работала и работала», её никто не проверял, а разбираться начали только тогда, когда результат стал критически нужен. Это и есть тихий сбой — cron не упал с треском, он просто не отработал, а система не сообщила об этом никому.

Отличить это от обычной аварии просто по одному признаку: при явном сбое (сервис упал, диск переполнен, сеть недоступна) обычно есть хоть какой-то сигнал — алерт, лог с ошибкой, недоступность сервиса. При тихом сбое cron сигнала нет вообще, потому что по умолчанию у cron нет ни одного канала, который бы кто-то реально читал.

Почему cron-задачи ломаются именно молча

У молчаливого сбоя есть техническая причина, и она банальна: cron был спроектирован в эпоху, когда «мониторинг» — это чтение почты root на самой машине.

Вывод скрипта уходит в системную почту, которую никто не читает. Если задача в crontab не перенаправляет stdout и stderr явно, cron по умолчанию пытается отправить их локальному пользователю через MTA (sendmail/postfix), настроенному через переменную MAILTO. На современном VPS часто нет вообще никакого MTA, либо он есть, но почта root никогда не проверяется. Результат — ошибка была, письмо «отправилось» в никуда (или легло в /var/mail/root, куда никто не заходит), а на экране и в привычных логах ничего не видно.

# проверить, куда cron пытается слать вывод
grep -i mailto /etc/crontab /etc/cron.d/* 2>/dev/null
crontab -l | grep -i mailto

# если почта локальная, но её никто не читает
mail -u root   # или ls -la /var/mail/root

Скрипт зависит от переменных окружения, которых нет в контексте cron. Это самая частая техническая причина сбоя, и объясняет классическую фразу «руками запускаю — работает, по крону — нет». Cron запускает задачи в минимальном окружении: короткий PATH (обычно /usr/bin:/bin), без переменных из ~/.bashrc, ~/.profile или ~/.bash_profile, без активированного virtualenv, без NVM/rbenv/pyenv shims, иногда без HOME в ожидаемом виде. Скрипт, который в интерактивном шелле находит psql, aws, node или кастомный бинарник через расширенный PATH, в cron этот бинарник просто не находит — и падает с command not found, которая опять же улетает в ту самую нечитаемую почту.

Задача тихо завершается с ненулевым кодом возврата, а set -e в скрипте не выставлен. Если в bash-скрипте нет set -e, одна упавшая команда посередине пайплайна не останавливает выполнение — скрипт бодро идёт дальше, обрабатывает часть данных или вообще ничего не делает, и на выходе получает exit code 0, потому что последняя команда в скрипте отработала успешно. Cron видит «успех» там, где по сути был провал.

Блокировка предыдущего запуска. Если предыдущий запуск задачи завис (например, ждёт lock на базе или сетевой таймаут), а новый запуск стартует поверх без проверки — либо оба процесса начинают конфликтовать за ресурсы, либо новый запуск падает на попытке взять тот же lock-файл, и опять же без внятного сигнала наружу.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Диагностика: что смотреть в первую очередь

Когда обнаружили, что задача не отработала, порядок действий такой:

1. Проверить, что cron вообще запускал задачу. На системах с syslog:

grep CRON /var/log/syslog | grep -i имя_задачи
# или конкретный диапазон времени
grep CRON /var/log/syslog | grep "Sep  4 0[0-3]"

На системах с journald (Ubuntu 22.04+, Debian с systemd-cron, большинство современных дистрибутивов):

journalctl -u cron --since "2026-09-04 00:00" --until "2026-09-04 06:00"
# на некоторых системах юнит называется crond
journalctl -u crond --since "yesterday"

Если строк с именем вашей задачи в этом диапазоне нет вообще — cron не пытался её запускать. Это означает проблему уровня расписания: неверный синтаксис в crontab, файл задачи не подхватился, демон cron не был запущен (проверьте systemctl status cron), либо crontab редактировали не тем пользователем.

2. Если запуск был — проверить exit code. Сам journalctl/syslog обычно фиксирует факт запуска команды, но не код возврата. Код возврата нужно логировать явно из самого скрипта:

#!/bin/bash
set -euo pipefail

/usr/local/bin/backup.sh
echo "Exit code: $?" >> /var/log/backup-cron.log

Если такого логирования не было — это первое, что нужно добавить, прежде чем разбираться дальше. Без exit code в отдельном файле вы каждый раз будете гадать, упала задача или отработала «пусто».

3. Проверить окружение, в котором реально выполняется команда. Быстрый способ увидеть разницу между интерактивным шеллом и cron-контекстом — временно добавить диагностическую задачу:

* * * * * env > /tmp/cron-env.txt 2>&1

Через минуту сравните /tmp/cron-env.txt с выводом env в обычной SSH-сессии — разница в PATH, HOME, LANG и прочих переменных обычно сразу видна и объясняет проблему.

4. Проверить права и владельца задачи. Если crontab настроен через crontab -e от одного пользователя, а скрипт ожидает права другого (например, доступ к файлам приложения принадлежит www-data, а задача стоит в crontab у root или наоборот) — задача может тихо завершаться на первой же операции с файлом без вывода куда-либо.

Фикс: делаем скрипт и его окружение предсказуемыми

После диагностики правки обычно ложатся в три группы.

Явно задаём окружение внутри самого скрипта, не полагаясь на то, что подхватит cron:

#!/bin/bash
set -euo pipefail

export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export HOME="/root"

# если нужен virtualenv
source /opt/app/venv/bin/activate

# если нужен node через nvm
export NVM_DIR="/root/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && source "$NVM_DIR/nvm.sh"

set -euo pipefail — здесь ключевая строка: -e останавливает скрипт на первой ошибке, -u ловит обращение к неинициализированной переменной, pipefail не даёт пайплайну (cmd1 | cmd2) молча проглотить ошибку первой команды.

Перенаправляем вывод в файл вместо системной почты, явно и с ротацией:

0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup-cron.log 2>&1

Файл /var/log/backup-cron.log заводим под logrotate, чтобы он не рос бесконечно:

# /etc/logrotate.d/backup-cron
/var/log/backup-cron.log {
    weekly
    rotate 8
    compress
    missingok
    notifempty
}

Защищаемся от параллельного запуска через flock, чтобы зависший предыдущий запуск не плодил конфликты:

0 3 * * * /usr/bin/flock -n /var/lock/backup-cron.lock /usr/local/bin/backup.sh >> /var/log/backup-cron.log 2>&1

Флаг -n (non-blocking) означает, что если lock уже занят, новый запуск сразу выходит, а не встаёт в очередь — это важно, чтобы задачи не накапливались одна на другую.

Если задача важная и требует более развитого управления, чем crontab — переходим на systemd timer. У таймеров есть встроенное журналирование через journald и возможность повесить OnFailure= на unit, который среагирует при сбое:

# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup
OnFailure=notify-failure@%n.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup nightly

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true отдельно решает ещё одну частую причину «пропущенной ночи» — если сервер был выключен или перезагружался в момент срабатывания таймера, при следующей загрузке systemd досрочно выполнит пропущенный запуск, вместо того чтобы просто ждать следующего расписания.

Профилактика: мониторинг подтверждения выполнения, а не факта запуска

Главный вывод из всей ситуации: нельзя полагаться на «cron сам справится» — нужен механизм, который явно фиксирует, что задача не просто запустилась, а успешно завершилась, и поднимает тревогу, если отметки не было.

Идея dead man's switch (принцип, на котором строится healthchecks.io и аналогичные сервисы) простая: задача сама «отмечается» после успешного завершения через HTTP-запрос на внешний сервис. Если отметка не пришла в ожидаемое окно времени — сервис присылает алерт. В отличие от обычного мониторинга (который проверяет, что сервис отвечает прямо сейчас), это ловит именно отсутствие события — то есть ровно тот случай, когда cron ничего не сделал и, соответственно, никакой ошибки никуда не послал.

Минимальная реализация с самостоятельным self-hosted инструментом (например, healthchecks.io можно развернуть на своём сервере) выглядит так:

#!/bin/bash
set -euo pipefail

/usr/local/bin/backup.sh

# отметка об успехе отправляется только если backup.sh отработал без ошибки
curl -fsS -m 10 --retry 3 https://hc-ping.com/ваш-уникальный-uuid > /dev/null

Ключевой момент — curl с пингом стоит после команды бэкапа и выполнится только если предыдущая команда завершилась успешно (благодаря set -e, если бэкап упадёт, скрипт остановится до строки с пингом). Если пинг не пришёл вовремя — сервис мониторинга сам присылает уведомление в Telegram, email или Slack, независимо от того, работает ли cron вообще.

Для более простого варианта без внешнего сервиса — минимальная самодельная проверка на другом сервере или через systemd timer, который сверяет время модификации файла-результата:

#!/bin/bash
# проверка, что бэкап создавался не позже N часов назад
BACKUP_FILE="/backups/latest.tar.gz"
MAX_AGE_HOURS=26

if [ ! -f "$BACKUP_FILE" ]; then
    echo "Файл бэкапа отсутствует" | mail -s "ALERT: backup missing" you@example.com
    exit 1
fi

FILE_AGE=$(( ( $(date +%s) - $(stat -c %Y "$BACKUP_FILE") ) / 3600 ))
if [ "$FILE_AGE" -gt "$MAX_AGE_HOURS" ]; then
    echo "Бэкап старше $FILE_AGE часов" | mail -s "ALERT: backup stale" you@example.com
fi

Такую проверку тоже стоит вешать через cron или systemd timer — но, в отличие от исходной задачи, здесь достаточно, чтобы сам факт непроверки был редким и не критичным: даже если проверяющая задача один раз не отработает, следующая всё равно поймает старение файла.

Дополнительно к пингу-подтверждению полезно завести уведомления о самих ошибках выполнения — например, через готовую связку алертов в Telegram, которая шлёт сообщение прямо при ненулевом exit code, не дожидаясь, пока кто-то полезет проверять логи вручную.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему письма от cron вообще не приходят, если MTA настроен?

Часто MTA настроен только на локальную доставку (не может слать во внешний мир из-за отсутствия релея или заблокированного 25 порта у хостера), поэтому письмо технически «отправлено», но остаётся в локальном почтовом ящике root, который никто не проверяет. Проще полностью отказаться от почты как канала и перенаправлять вывод в файл или внешний мониторинг.

В чём разница между grep CRON /var/log/syslog и journalctl -u cron?

Это два способа читать один и тот же журнал запусков демона cron — первый актуален для систем с классическим rsyslog (многие Debian/Ubuntu-инсталляции без journald-логирования по умолчанию), второй — для систем, где cron логируется через systemd-journal. Если один способ не дал результата, стоит попробовать второй, прежде чем делать вывод, что записей нет вообще.

Что делать, если задача не отработала один раз, а не системно?

Разовый пропуск (например, из-за перезагрузки сервера в момент срабатывания) не обязательно требует переделки скрипта — для этого случая как раз подходит Persistent=true в systemd timer или проверка возраста файла-результата, описанная выше, вместо переписывания всей логики.

Обязательно ли переходить с cron на systemd timers?

Нет, для большинства простых задач классический crontab с flock и явным логированием exit code полностью решает проблему. Timers имеет смысл заводить, когда нужна встроенная защита от пропуска при перезагрузке, зависимости между задачами или единый журнал через journalctl.

Сколько задержки допустимо для пинга подтверждения выполнения?

Это стоит настраивать под реальное окно выполнения задачи с запасом — например, если бэкап обычно занимает 20-30 минут, разумно ставить порог тревоги в 2-3 часа, чтобы разовое замедление (из-за нагрузки на диск или сеть) не создавало ложных алертов, но при этом реальный пропуск ловился в течение той же ночи, а не через несколько дней.

Обсудить статью, задать вопрос или начать новую тему

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

Перейти в сообщество →