Очередь работает, пока работает воркер. Воркер, запущенный командой в SSH-сессии, умирает вместе с ней. Воркер, упавший на исключении, не поднимается сам. Воркер, живущий неделю, съедает память и начинает вести себя странно.
Дальше — как я развожу запуск, перезапуск и периодические задачи между локальной средой и продом.
Три способа держать процесс живым
Отдельный сервис в compose. Контейнер, у которого команда — это воркер. Docker перезапускает его при падении.
worker:
build: .docker/php/
container_name: ${PROJECT_NAME}_worker
command: php bin/console messenger:consume async --time-limit=3600
restart: unless-stopped
depends_on: [db, redis]Прозрачно, логи через docker logs, масштабируется числом реплик. Минус: один процесс на контейнер. Три очереди — три сервиса и больше контейнеров в конфигурации, хотя слои образа на хосте остаются общими.
Supervisor внутри PHP-контейнера. Один контейнер, внутри менеджер процессов, который держит сколько нужно воркеров.
systemd на сервере. Вариант для развёртывания без Docker, где приложение живёт прямо в системе.
Я использую первый вариант в маленьких проектах и второй в тех, где очередей больше одной.
Конфигурация supervisor
[program:messenger-heavy]
command=php /var/www/html/bin/console messenger:consume heavy --time-limit=3600 --memory-limit=256M
user=www-data
numprocs=2
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true
startsecs=5
stopwaitsecs=120
stdout_logfile=/var/log/php/messenger-heavy.log
redirect_stderr=true
[program:messenger-default]
command=php /var/www/html/bin/console messenger:consume default notifications --time-limit=3600
user=www-data
numprocs=1
autostart=true
autorestart=true
stopwaitsecs=30
stdout_logfile=/var/log/php/messenger-default.log
redirect_stderr=truenumprocs=2 на тяжёлой очереди — два процесса, потому что задачи там ждут ответа внешнего сервиса, а не считают. Больше двух смысла нет: упрёмся в лимиты того сервиса.
stopwaitsecs=120 — сколько ждать корректного завершения перед принудительным убийством. Для тяжёлых задач это должно быть больше самой долгой задачи, иначе при перезапуске обработка обрывается на середине.
startsecs=5 — сколько процесс должен прожить, чтобы считаться успешно стартовавшим. Если он падает раньше, Supervisor считает запуск неудачным и после числа попыток из startretries переводит программу в состояние FATAL.
redirect_stderr=true — иначе ошибки уезжают в отдельный файл, и половина картины теряется.
Почему я ограничиваю время жизни воркера
--time-limit=3600 — не защита от зависания. Он ограничивает время, за которое процесс успевает накопить состояние.
Долгоживущий процесс накапливает состояние в сервисах приложения и сторонних библиотеках. Symfony очищает Doctrine EntityManager между сообщениями, но пользовательский код всё равно может удерживать объекты или буферы. В моём случае за сутки и примерно десять тысяч сообщений процесс вырастал с 40 мегабайт до полутора гигабайт.
Есть второй, более коварный эффект. Соединение с базой в долгоживущем процессе может отвалиться по тайм-ауту на стороне сервера. Перезапуск создаст новое соединение между сообщениями, но не спасёт запрос, который уже выполняется: для него отдельно нужна обработка разрыва и безопасный повтор.
Час жизни и лимит памяти сдерживают последствия: процесс завершается сам, Supervisor поднимает новый, состояние обнуляется. Причину роста памяти это не устраняет, поэтому лимиты остаются страховкой, а не исправлением утечки.
Кэш сущностей я всё равно чищу в обработчике после каждого сообщения:
$this->em->clear();Это не отменяет лимита времени, но убирает основной источник роста между перезапусками.
Перезапуск при деплое
Самая частая ошибка: выкатили новую версию, а воркеры крутят старый код. Они загрузили классы при старте и не собираются их перечитывать.
Симптом выглядит непонятно: в интерфейсе новое поведение, в фоновой обработке — старое. Иногда с ошибками про несуществующие свойства, потому что схема уже новая.
Правильный способ — не убивать процессы, а попросить их закончить текущее сообщение и выйти:
php bin/console messenger:stop-workersКоманда ставит флаг в кеш, воркеры проверяют его после каждого сообщения и завершаются штатно. Supervisor поднимает их заново, уже с новым кодом.
Для деплоя я использую именно эту команду: намерение видно прямо в скрипте, и остановка не зависит от того, как настроен менеджер процессов. supervisorctl restart тоже может завершить сообщение корректно, если установлен PCNTL, Messenger обрабатывает SIGTERM, а stopwaitsecs длиннее самой долгой задачи. Без этих условий Supervisor в конце ожидания пошлёт SIGKILL.
В скрипте развёртывания это идёт последней строкой, после миграций и очистки кеша:
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear
php bin/console messenger:stop-workersПорядок важен: сначала база, потом кеш, потом воркеры. Новый воркер должен стартовать в уже готовое окружение.
Планировщик
Периодические задачи — вторая половина той же истории. Реализация зависит от фреймворка: ниже отдельно показаны Laravel Scheduler и транспорт Symfony Scheduler.
В Laravel всё сводится к одной записи cron, а расписание живёт в коде:
* * * * * cd /var/www/html && php artisan schedule:run >> /var/log/php/scheduler.log 2>&1Одна строка в crontab, дальше расписание описывается в приложении и версионируется вместе с ним. Добавление задачи не требует захода на сервер.
В Symfony Scheduler расписание можно отдавать через настроенный транспорт Messenger. Ниже — фрагмент compose для проекта, где этот транспорт называется scheduler:
scheduler:
build: .docker/php/
container_name: ${PROJECT_NAME}_scheduler
command: php bin/console messenger:consume scheduler
restart: unless-stoppedПлюс перед cron: те же логи, тот же механизм перезапуска, никакого второго способа запускать код.
Минус: если сервис лежал полчаса, пропущенные запуски без отдельного механизма восстановления не выполнятся. Cron ведёт себя так же; наличие записи зависит уже от настроенного логирования и мониторинга, а не от самого cron.
Логи воркера
Стандартного вывода контейнера мне недостаточно: срок хранения зависит от log driver и ротации, а разбирать историю нескольких программ неудобно.
Логи supervisor пишутся в смонтированный каталог, отдельным файлом на программу. Ротацию делает сам supervisor:
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=5Пятьдесят мегабайт на программу — достаточно, чтобы разобрать вчерашний инцидент, и мало, чтобы забить диск.
Отдельная вещь, которую стоит добавить, — идентификатор процесса в записях лога приложения. Когда воркеров два и они пишут в один файл приложения, без этого невозможно понять, какие строки относятся к одному сообщению.
Разные профили для локальной среды и прода
Один набор файлов, разные значения через переменные.
Локально мне не нужны два процесса на тяжёлой очереди, и лимит времени в час только мешает: при отладке проще перезапустить руками. Поэтому число процессов и лимиты вынесены в переменные окружения, а не зашиты в конфигурацию.
Локально я вообще чаще запускаю воркер руками в отдельном окне:
make workerworker: ## Запустить воркер в текущем терминале
$(DC) exec webserver php bin/console messenger:consume async -vvФлаг подробного вывода показывает каждое сообщение и каждое исключение прямо в терминале. Для отладки это несравнимо удобнее, чем ходить в логи, и именно поэтому фоновый воркер локально у меня выключен по умолчанию.