Cron в Linux для PHP-разработчиков: расписание, логи, блокировки и отладка

PHP-разработчику cron нужен в первый же день после деплоя: рассылки, импорт прайсов, очистка кеша, сборка статистики, проверка очередей. Разберём, как правильно цеплять PHP-скрипты к крон-таблице на Linux-сервере, куда писать логи, как защититься от наложения двух запусков и почему скрипт, который работал «руками», в кроне молча падает.

Синтаксис строки crontab

Каждое задание — это одна строка из пяти полей времени и команды. Поля разделяются пробелами, звёздочка означает «любое значение».

Синтаксис строки crontab: пять полей времени
Пять полей crontab и примеры расписаний

Полезные синтаксические сокращения:

  • */N — каждые N единиц (минут, часов и т.д.);
  • 1-5 — диапазон (с понедельника по пятницу при поле dow);
  • 1,15,30 — список конкретных значений;
  • @reboot, @daily, @hourly — псевдонимы вместо пяти звёздочек.

Открыть свою crontab для редактирования: crontab -e. Посмотреть текущий список заданий: crontab -l. Удалить всё (осторожно): crontab -r.


Запуск PHP-скрипта: три рабочих варианта

В кроне у PHP нет окружения веб-сервера: ни $_SERVER['HTTP_HOST'], ни сессий, ни сборок Composer-autoload, если их не подцепил сам скрипт. Поэтому всегда указывайте абсолютные пути.

# 1. Прямой вызов CLI-интерпретатора PHP
*/10 * * * * /usr/bin/php /var/www/app/cron/queue.php

# 2. Запуск curl-ом веб-эндпоинта (если задача — HTTP-страница)
*/5 * * * * /usr/bin/curl -fsS --max-time 60 https://example.com/cron/check > /dev/null

# 3. Через wrapper-скрипт с переменными окружения
0 3 * * * /home/app/scripts/nightly.sh

Вариант с CLI предпочтителен: нет ограничений max_execution_time и memory_limit из php-fpm, нет таймаутов nginx, можно спокойно работать часами. Узнать путь к PHP: which php или command -v php.

Если на сервере несколько версий PHP (например, 8.1 и 8.3), используйте полный путь именно к той, что нужна проекту, иначе скрипт может стартовать на «не той» версии:

0 4 * * * /opt/php83/bin/php /var/www/app/artisan schedule:run

Логирование: куда уходят вывод и ошибки

По умолчанию cron шлёт весь stdout/stderr задачи письмом владельцу crontab — на shared-хостингах эти письма обычно никуда не доходят. Поэтому пишите вывод в файл сами.

# stdout → лог, stderr → тот же лог (2>&1), дозапись в конец (>>)
*/15 * * * * /usr/bin/php /var/www/app/cron/sync.php >> /var/log/app/sync.log 2>&1

# Разделить успехи и ошибки в разные файлы
0 * * * * /usr/bin/php /var/www/app/cron/report.php >> /var/log/app/report.log 2>> /var/log/app/report.err

Внутри PHP-скрипта тоже логируйтесь явно — не полагайтесь на display_errors:

<?php
// /var/www/app/cron/sync.php
declare(strict_types=1);

ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/var/log/app/php-cron.log');
error_reporting(E_ALL);

require __DIR__ . '/../vendor/autoload.php';

$started = microtime(true);

try {
    $synced = (new App\Sync\PriceImport())->run();
    fwrite(STDOUT, sprintf(
        "[%s] sync ok: %d товаров за %.2f сек\n",
        date('c'), $synced, microtime(true) - $started
    ));
} catch (\Throwable $e) {
    fwrite(STDERR, sprintf(
        "[%s] sync FAIL: %s (%s:%d)\n",
        date('c'), $e->getMessage(), $e->getFile(), $e->getLine()
    ));
    exit(1);
}

Несколько правил, которые экономят часы отладки:

  • пишите ISO-8601 дату в каждую строку лога — без неё трудно сопоставить с метриками;
  • возвращайте ненулевой exit-код при ошибке — это позволит мониторингу детектить падения;
  • не используйте echo вперемешку с print_r в проде — для отладки лучше отдельный verbose-флаг.

Защита от наложения: lock-файл

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

Решение в bash — утилита flock:

# -n: не ждать, если занято — сразу выйти
# -x: эксклюзивный лок
* * * * * /usr/bin/flock -n /tmp/queue.lock /usr/bin/php /var/www/app/cron/queue.php >> /var/log/app/queue.log 2>&1

Если предпочитаете держать блокировку внутри PHP (например, лог из скрипта же), используйте flock() на дескрипторе:

<?php
$lockFile = '/tmp/queue.lock';
$fp = fopen($lockFile, 'c');

if (!$fp || !flock($fp, LOCK_EX | LOCK_NB)) {
    fwrite(STDERR, "Уже запущен другой экземпляр\n");
    exit(0); // exit 0 — это не ошибка, просто пропускаем тик
}

// Записываем PID — пригодится для диагностики
ftruncate($fp, 0);
fwrite($fp, (string) getmypid());

try {
    // Полезная работа: обработка очереди
    (new App\Queue\Worker())->processBatch(100);
} finally {
    flock($fp, LOCK_UN);
    fclose($fp);
    @unlink($lockFile);
}

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


Окружение cron: PATH, переменные, кодировки

Cron запускает задание в минимальном окружении. В нём почти ничего нет: PATH урезан до /usr/bin:/bin, нет переменных из ~/.bashrc, нет LANG. Это причина большинства «у меня всё работает, а в кроне падает».

Задавайте переменные явно в начале crontab:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=ru_RU.UTF-8
MAILTO=""

# Дальше идут задачи
*/15 * * * * cd /var/www/app && /usr/bin/php artisan queue:work --once

Обратите внимание на cd перед командой: некоторые фреймворки (Laravel, Symfony) ожидают, что текущая директория — корень проекта. Альтернатива — флаг --working-dir Composer-команд или chdir() в начале PHP-скрипта.

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

<?php
// Корень проекта = на один уровень выше cron/
chdir(__DIR__ . '/..');

// Загружаем .env из корня
require_once 'vendor/autoload.php';
Dotenv\Dotenv::createImmutable(__DIR__ . '/..')->load();

Отладка: что делать, если задача «не сработала»

Алгоритм диагностики проблемного задания:

  1. Убедитесь, что сам cron-демон жив: systemctl status cron (Debian/Ubuntu) или systemctl status crond (CentOS/RHEL).
  2. Посмотрите системный лог cron: grep CRON /var/log/syslog или journalctl -u cron --since "1 hour ago". Там видно, какие задания стартовали и от какого пользователя.
  3. Запустите команду из crontab вручную из-под того же пользователя — большинство ошибок воспроизводится сразу: sudo -u www-data bash -c 'cd /var/www/app && /usr/bin/php artisan ...'.
  4. Проверьте права на лог-файл и его директорию: если у пользователя нет прав на запись, скрипт упадёт молча.
  5. Добавьте отладочный пинг в начало скрипта: запись текущего времени в отдельный файл. Если этот файл не появляется — задача вообще не запускалась (проблема в crontab), если появляется — ищите ошибку в самом PHP.

Полезная команда, чтобы убедиться, что crontab сохранён корректно:

# Показывает crontab текущего пользователя
crontab -l

# Crontab другого пользователя (нужен root)
sudo crontab -u www-data -l

# Системный crontab (расписания пакетов)
cat /etc/crontab
ls /etc/cron.d/ /etc/cron.daily/ /etc/cron.hourly/

Чеклист: cron-задание, которое не сломается ночью

  • Абсолютные пути — и к PHP, и к скрипту, и к логам. Никаких относительных ./script.php.
  • Перенаправление stdout и stderr в файл (>> log 2>&1), чтобы вывод не терялся в почте.
  • Lock-файл через flock или flock() в PHP — если задача может занимать больше интервала.
  • Дата в каждой строке лога и ненулевой exit-код при ошибке для мониторинга.
  • Явный PATH и LANG в начале crontab, особенно если работаете с UTF-8 и внешними утилитами.
  • Ротация логов через logrotate — иначе файл вырастет до десятков гигабайт за пару месяцев.
  • Мониторинг живости: deadman-эндпоинт (Healthchecks.io, UptimeRobot) или собственная метрика «время последнего успешного запуска».

Cron — крошечный инструмент, но именно на нём держится фоновая работа большинства PHP-проектов. Один раз настроенная по этим правилам схема избавит от ночных звонков о «не отправились письма» и «не сформировался отчёт».