Скрипт работает из консоли, но не запускается по расписанию — самая частая жалоба, с которой к нам приходят на диагностику VPS. Причина почти никогда не в cron как таковом: демон crond у большинства провайдеров работает штатно. Проблема в окружении, в котором задача выполняется без интерактивной сессии. Ниже — по каким местам мы проходим при разборе таких кейсов и что обычно находим.
Cron не выполняется
Первое, что мы проверяем — а вообще ли задача попала в crontab демона. crontab -l показывает список для текущего пользователя, но если задачу добавляли через sudo crontab -e под root, а искать её потом стали в crontab -l от имени обычного пользователя — она просто в другом файле. То же самое с systemd-таймерами: они не отображаются в выводе crontab -l вообще, это отдельный механизм (см. ниже).
Второй частый случай — синтаксис расписания. Пять полей * * * * * (минута, час, день месяца, месяц, день недели) многие путают местами или забывают, что день месяца и день недели соединены логическим ИЛИ, а не И — если оба поля не *, задача выполнится когда сработает любое из условий. Формат и допустимые значения — в man 5 crontab.
Третье — сам crond может не видеть новый файл, если crontab правили не штатной командой crontab, а редактированием файла в /var/spool/cron напрямую. Демон в большинстве реализаций перечитывает spool по времени модификации файла, но правки мимо crontab -e — источник неочевидных багов, надёжнее их избегать.
Пути, окружение и права
Cron запускает задачи в сильно урезанном окружении: своя PATH (часто просто /usr/bin:/bin), нет переменных из .bashrc или .profile, нет текущей рабочей директории пользователя. Скрипт, который в интерактивном шелле находит php, node или python3 по алиасу или через nvm/pyenv, в cron их просто не видит — и падает с «command not found» ещё до того, как дошёл до логики.
Практика, которая закрывает большинство таких случаев:
- в самой cron-строке или в начале скрипта прописывать полный путь к бинарнику (
/usr/bin/php, выводwhich php), а не рассчитывать наPATH; - явно переходить в рабочую директорию (
cd /var/www/project && ...) вместо относительных путей; - для задач, которым нужны переменные окружения — прописывать
.env-файл и грузить его в самом скрипте, а не надеяться, что cron унаследует пользовательский шелл.
Права — вторая по частоте причина. Скрипт с правами 644 без исполняемого бита не запустится через прямой вызов (./script.sh), только через явный вызов интерпретатора (bash script.sh). Если задача пишет файлы (логи, кэш, экспорт) — обычно всплывает, что cron выполняется от другого пользователя, чем веб-сервер, и упирается в права на директорию.
PHP/Python/Node версии
На VPS с несколькими версиями PHP (типичная ситуация после миграции или при разных проектах на одном сервере) php в PATH может указывать не на ту версию, что использует сайт. Для WordPress и WooCommerce это критично: cron-задача, вызывающая WP-CLI под PHP 8.0, пока сайт работает на 8.1 (или наоборот), может либо тихо завершаться без результата, либо ронять фатальную ошибку несовместимости расширений.
Правило простое: в cron-команде фиксировать версию явно — не php script.php, а /usr/bin/php8.1 script.php, или через update-alternatives проверять, что дефолтный php действительно совпадает с версией сайта. Для Python — то же самое с виртуальным окружением: cron не активирует venv автоматически, путь к интерпретатору должен указывать прямо в venv/bin/python. Для Node — аналогично с nvm: cron не читает .nvmrc, нужен либо абсолютный путь к нужной версии, либо обёртка через nvm exec.
Логи и stderr
По умолчанию cron отправляет вывод задачи (stdout и stderr) на системную почту пользователя — если MTA на сервере не настроен, письмо просто теряется, и задача «падает молча». Первое, что мы добавляем при разборе — явное перенаправление в файл:
* * * * * /usr/bin/php /var/www/project/task.php >> /var/log/project/task.log 2>&1
Без 2>&1 в лог попадёт только stdout, а именно stderr обычно и содержит причину падения — fatal error, unhandled exception, permission denied. Отдельно стоит проверять, не переполнился ли лог: без ротации (logrotate) файл может расти без ограничений и не в лучшую сторону влиять на диск.
Для WordPress отдельный источник правды — wp-content/debug.log, если включён WP_DEBUG_LOG в wp-config.php (подробности параметров отладки — в официальной документации WordPress). Для задач через WP-CLI полезно добавлять --debug на время диагностики, чтобы увидеть полный трейс вместо общей ошибки.
Блокировки и параллельные запуски
Если задача выполняется дольше интервала запуска (например, импорт товаров занимает семь минут при расписании раз в пять), cron честно запускает вторую копию поверх первой. Результат — гонка за одни и те же файлы, дублирование записей в БД, а иногда и блокировка таблиц WooCommerce на время, которое сайт замечает как подвисание оформления заказа.
Стандартное решение — lock-файл или flock:
* * * * * /usr/bin/flock -n /tmp/task.lock /usr/bin/php /var/www/project/task.php >> /var/log/project/task.log 2>&1
flock -n не запустит вторую копию, если предыдущая ещё держит блокировку, а просто выйдет — это дешевле, чем городить самодельную проверку PID-файла, и не требует отдельной очистки после падения процесса.
systemd timers и очереди
На современных дистрибутивах (systemd в качестве init) вместо classic cron всё чаще используют systemd timers — пара файлов .service + .timer. Плюс перед cron: таймер логируется через journalctl -u имя.service, что снимает половину проблем из раздела про логи — не нужно отдельно настраивать перенаправление вывода. Минус — синтаксис OnCalendar= менее привычный, чем пять полей cron, и требует systemctl daemon-reload после правки юнита.
Отдельная категория — фоновые очереди самого приложения, а не системный планировщик. У WordPress это WP-Cron — он не демон, а псевдо-cron, который триггерится реальным посещением сайта (детали механизма — в документации WordPress). На сайте с низким трафиком ночью задачи, завязанные на WP-Cron (запланированные публикации, повторяющиеся email, некоторые хуки WooCommerce), могут откладываться на часы, потому что просто некому дёрнуть страницу. Решение — отключить DISABLE_WP_CRON в wp-config.php и вызывать wp-cron.php системным cron с фиксированным интервалом; это убирает зависимость от случайных визитов.
Мониторинг и уведомления
Задача, которая выполняется беззвучно и падает беззвучно — источник инцидентов, которые вскрываются только когда клиент замечает: «отчёт за вчера не пришёл» или «остатки на сайте не обновились». Минимальный уровень контроля, который мы обычно настраиваем:
- healthcheck-пинг из скрипта на внешний сервис после успешного завершения — если пинга нет N раз подряд, сервис уведомляет отдельно от самого приложения;
- код возврата скрипта проверяется, а не только факт запуска —
exit 1при ошибке, чтобы обвязка могла отличить «выполнилось с ошибкой» от «выполнилось успешно»; - уведомление в Telegram-бот на критичные фоновые задачи (синхронизация остатков, обработка платежей) — отдельным вызовом при неуспехе, без завязки на почту сервера, которая на VPS часто не настроена вообще.
Чек-лист диагностики
- Проверили, в чьём crontab лежит задача (
crontab -lпод правильным пользователем) и не потерялась ли она в/var/spool/cron - Сверили синтаксис расписания и логику day-of-month / day-of-week (ИЛИ, не И)
- В команде указан полный путь к интерпретатору нужной версии, а не голое имя из PATH
- Скрипт не полагается на переменные окружения интерактивного шелла — либо явный
.env, либо абсолютные пути - Указан явный
cdв рабочую директорию перед выполнением - Права на файл и исполняемый бит соответствуют способу вызова
- Вывод (
>> log 2>&1) перенаправлен в файл, а не улетает в несуществующую почту - Настроена ротация логов, чтобы файл не рос неограниченно
- Для задач дольше интервала запуска — есть
flockили аналог, исключающий параллельный запуск - Для WordPress — WP-Cron либо отключён в пользу системного cron, либо трафика достаточно для его штатной работы
- Есть внешний сигнал об ошибке (healthcheck, код возврата, уведомление), а не только факт «задача когда-то запускалась»
Большинство пунктов из этого списка закрываются один раз при настройке сервера — дальше это просто работает, без ручного контроля после каждого деплоя. Если фоновые задачи на текущем VPS ведут себя непредсказуемо, разумнее сразу пройтись по конфигурации сервера целиком, а не чинить симптомы по одному: у нас есть отдельная услуга «Настройка VPS-сервера под ключ», которая закрывает окружение, права, cron/systemd и мониторинг за один проход.
Если после чек-листа причина всё ещё не нашлась — пришли вывод crontab -l, содержимое лога задачи и версию ОС, разберём точечно.
Частые вопросы
Как проверить, что cron-задача вообще запускается?
Смотри вывод crontab -l под тем же пользователем, под которым задачу добавляли, и перенаправляй stdout и stderr в отдельный лог-файл — без этого cron молча пишет в системную почту, которая на VPS обычно не настроена. Если лог пустой при каждом запуске — задача либо не попадает в crontab, либо падает раньше первой строчки скрипта.
Почему скрипт работает руками, но не через cron?
Обычно дело в окружении: у cron своя урезанная PATH без алиасов и переменных из .bashrc, поэтому интерпретатор, который находится в интерактивном шелле, в cron просто не виден. Лечится явным полным путём к бинарнику в самой cron-строке, а не переносом логики скрипта.
Cron или systemd timer — что выбрать на новом сервере?
Если дистрибутив на systemd — таймер удобнее: логи сразу идут в journalctl, не нужно отдельно настраивать перенаправление вывода. Классический cron проще для одной-двух задач и привычнее большинству админов — держать смесь того и другого на одном сервере не проблема, если задачи не дублируют друг друга.