almdev Технический блог

Воркеры и планировщик внутри контейнера
Зачем ограничивать время жизни воркера и что перезапускать при деплое.

Три способа держать обработчик очереди живым, конфигурация supervisor с лимитом времени жизни, перезапуск воркеров при выкладке, планировщик в контейнере и куда писать логи.

Подкатегория: Docker

Чтение
5 мин
Технологии / версии
Docker · supervisor · Messenger · cron
9 июн 2026 · 5 мин · 6 просмотров · Ops
Воркеры работают по циклу вокруг часового планировщика
Docker 06/2026

Очередь работает, пока работает воркер. Воркер, запущенный командой в 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=true

numprocs=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 worker
worker: ## Запустить воркер в текущем терминале
	$(DC) exec webserver php bin/console messenger:consume async -vv

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

#docker #supervisor #messenger #cron