php artisan queue:work завантажує фреймворк один раз і далі крутить цикл: узяв задачу з Redis чи бази, виконав, пішов по наступну. Процес живе годинами, але колись обовʼязково завершиться: фатальна помилка в задачі, вбивство від OOM-killer, ребут сервера або штатний вихід по --max-time=3600, --max-jobs=1000, --memory=128. Останні три ставлять навмисно, бо довгоживучий PHP-процес накопичує памʼять і стан. Тобто періодично помирати для воркера нормально, і поруч має бути щось, що це помічає й піднімає новий процес. nohup ... & чи вкладка в screen на цю роль не годяться.
Під Laravel найчастіше беруть supervisor, для junior він і найпростіший. Конфіг кладуть у /etc/supervisor/conf.d/, numprocs задає кількість паралельних воркерів (supervisor сам розводить їх за process_name), autorestart=true піднімає процес після будь-якого коду виходу, включно з нульовим після --max-time. Значення unexpected тут не підходить: штатний вихід воркера помилкою не є, але перезапустити його все одно треба. Сам конфіг після редагування не перечитується, потрібні supervisorctl reread і supervisorctl update, а точковий рестарт групи робиться як supervisorctl restart queue-worker:*. Команда в command= мусить лишатись на передньому плані: якщо запускати обгортку, яка демонізується, supervisor побачить миттєве завершення й піде в цикл перезапусків до статусу FATAL.
Systemd дає те саме без зайвого демона, якщо на сервері й так systemd. Замість numprocs використовують шаблонний юніт queue-worker@.service і вмикають потрібну кількість екземплярів: systemctl enable --now queue-worker@{1..4}. Restart=always разом із RestartSec=5 відповідає autorestart, але тут є пастка, на якій регулярно спотикаються: за замовчуванням пʼять перезапусків за десять секунд переводять юніт у стан failed, і далі він просто лежить, поки хтось не зробить systemctl reset-failed. Для воркера, який іноді падає на старті, StartLimitIntervalSec=0 у секції [Unit] знімає це обмеження. Змінні оточення підвантажують через EnvironmentFile=, а не покладаються на профіль користувача, бо systemd не читає ~/.bashrc.
Обидва менеджери зупиняють воркер через SIGTERM, і Laravel ловить цей сигнал за допомогою ext-pcntl: обробник виставляє внутрішній прапорець, поточна задача доробляється до кінця, і тільки після цього процес виходить. Часу на це дають рівно стільки, скільки написано в stopwaitsecs (supervisor, типово 10 секунд) або TimeoutStopSec (systemd, типово 90). Далі йде SIGKILL, який нічого не питає. Задача, обірвана на SIGKILL, лишається зарезервованою в черзі, і через retry_after секунд її підхопить інший воркер, тобто половина роботи виконається двічі. Звідси два правила: час очікування ставлять більшим за найдовшу задачу, а retry_after у config/queue.php - більшим за --timeout воркера. Якщо ext-pcntl у збірці PHP немає, мʼякої зупинки не існує взагалі, і кожен деплой ріже задачі посеред виконання.
Логи воркерів розʼїжджаються по трьох місцях, і на співбесіді їх краще розділити вголос. Те, що воркер пише в stdout (рядки про оброблені задачі), у supervisor іде в stdout_logfile з вбудованою ротацією по stdout_logfile_maxbytes і stdout_logfile_backups, а в systemd - у journald, звідки читається як journalctl -u 'queue-worker@*' -f. Винятки всередині задач ідуть окремо, у звичайний лог-канал застосунку, і для черг корисно завести власний канал, щоб не змішувати їх із HTTP-трафіком. У контейнері картина простіша: власного менеджера не додають, перезапуском займається Compose або Kubernetes, CMD пишуть в exec-формі (інакше PID 1 стане /bin/sh і проковтне SIGTERM), лог шлють у stderr без файлів і ротації, а час на зупинку піднімають явно: docker stop дає десять секунд, Kubernetes - тридцять, і для задач на кілька хвилин цього мало.
; /etc/supervisor/conf.d/queue-worker.conf
[program:queue-worker]
command=php /var/www/app/artisan queue:work redis --tries=3 --max-time=3600
user=www-data
numprocs=4 ; чотири незалежні процеси
process_name=%(program_name)s_%(process_num)02d
autostart=true
autorestart=true ; піднімати після будь-якого виходу, зокрема по --max-time
startsecs=1 ; менше секунди життя = невдалий старт
startretries=3 ; після трьох невдалих стартів статус FATAL, спроб більше не буде
stopwaitsecs=180 ; довше за найдовшу задачу, інакше прилетить SIGKILL
stopasgroup=true ; сигнал усій групі процесів (потрібно для horizon і queue:listen)
killasgroup=true
redirect_stderr=true
stdout_logfile=/var/log/queue-worker.log
stdout_logfile_maxbytes=50MB ; 0 вимикає ротацію: так роблять, коли лог іде в stdout контейнера
; supervisorctl reread && supervisorctl update - підхопити зміни конфігу
; supervisorctl restart queue-worker:* - перезапустити всі чотири процеси
# /etc/systemd/system/queue-worker@.service - шаблон, %i це номер екземпляра
[Unit]
Description=Queue worker %i
After=network.target redis.service
StartLimitIntervalSec=0 # без цього 5 падінь за 10 с назавжди лишать юніт у failed
[Service]
User=www-data
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/php /var/www/app/artisan queue:work redis --tries=3 --max-time=3600
ExecReload=/usr/bin/php /var/www/app/artisan queue:restart
Restart=always
RestartSec=5
TimeoutStopSec=180 # KillSignal і так SIGTERM; після таймауту буде SIGKILL
[Install]
WantedBy=multi-user.target
# systemctl enable --now queue-worker@{1..4}
# journalctl -u 'queue-worker@*' -f - логи, бо stdout іде в journald
Назвіть три рівні відповідальності одним реченням: «фреймворк дбає про мʼякий вихід по SIGTERM, менеджер процесів - про перезапуск і час очікування, деплой - про `queue:restart`». Далі згадайте, що `stopwaitsecs` ставлять більшим за найдовшу задачу - це та деталь, яку називають лише ті, хто справді ловив обрізані задачі.