Weby, které opravdu fungují, poznáte podle jedné vlastnosti: pomalé a nespolehlivé operace v nich neblokují uživatele. E-mail se neodesílá uprostřed HTTP requestu, PDF se negeneruje ve chvíli, kdy někdo klikne na tlačítko, a volání cizího API nezpůsobí, že se stránka točí osm vteřin a pak spadne na timeout. Tohle všechno patří do fronty a zpracují to workery běžící na pozadí.
Háček je v tom, že worker je dlouhoběžící proces — a ten se chová jinak než obyčejný webový request. V tomto článku vám ukážu, proč tyhle věci do fronty patří, a hlavně jak workery provozovat tak, aby nepadaly, nežraly paměť a nezaplnily vám disk logem. Ověřeno na Debianu 12 a Ubuntu 22.04+.
Proč pomalé věci nepatří do HTTP requestu
Webový request má být rychlý a předvídatelný. Když do něj vložíte operaci, která trvá vteřiny a může selhat, koupíte si čtyři problémy najednou:
- Uživatel čeká. Místo okamžité odezvy kouká na točící se kolečko.
- Blokujete PHP-FPM proces. Pool procesů je omezený; jeden pomalý request ubírá kapacitu všem ostatním.
- Riziko timeoutu.
max_execution_timenebo nginxproxy_read_timeoutrequest v půlce utne. - Špatně se opakuje. Když odeslání e-mailu selže, uživatel dostane chybu a operace je nenávratně pryč.
Řešení je oddělit přijetí úkolu od jeho provedení. Do fronty (Redis, databáze, RabbitMQ) uložíte během milisekund zprávu „odešli tenhle e-mail" a request skončí. Zpracování si vyzvedne worker běžící mimo web. Když job selže, worker ho zopakuje až do nastaveného maxima pokusů (v Laravelu přepínač --tries, v Symfony max_retries v konfiguraci); když spadne cizí API, nezhroutí se vám kvůli tomu objednávka.
Worker je dlouhoběžící proces
Klasický PHP request naběhne, odbaví se a zemře — paměť se uvolní, spojení zavře. Worker naopak jednou nabootuje a pak zpracovává job za jobem třeba hodiny. Z toho plynou tři důsledky, na které musíte myslet: proces se musí sám nastartovat a po pádu obnovit, musí mít strop na paměť a čas, a při nasazení nové verze ho musíte umět bezpečně restartovat. Postupně.
Proces manažer: Supervisor nebo systemd
Worker nikdy nespouštějte ručně přes nohup nebo v screenu. Potřebujete proces manažer, který ho hlídá a po pádu restartuje.
Supervisor
Instalace na Debianu/Ubuntu:
apt install supervisor
Konfigurace workeru do /etc/supervisor/conf.d/worker.conf:
[program:app-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/worker.log
stopwaitsecs=3600
Načtení a start:
supervisorctl reread # jen znovu načte konfiguraci a vypíše rozdíly — nic nepřidá ani nespustí
supervisorctl update # teprve teď se změny aplikují: přidá/odebere/restartuje dotčené programy
supervisorctl status # vypíše stav spravovaných procesů (RUNNING, FATAL, PID, uptime…)
Pozor na rozdíl mezi těmi příkazy: reread sám o sobě nic nezmění, jen ohlásí, co je nové nebo změněné. Změny zavede až update — spustí nově přidané programy (s autostart=true), restartuje ty se změněnou konfigurací a zastaví a odebere ty, které z konfigurace zmizely. Samostatný reread před update proto není nutný.
numprocs=4 spustí čtyři paralelní workery. autorestart=true je nahodí zpět po každém ukončení bez ohledu na exit kód — ať spadnou, nebo skončí čistě. stopwaitsecs musí být větší než nejdelší job — jinak Supervisor po vypršení pošle SIGKILL a utne rozdělanou práci. stopasgroup/killasgroup zajistí, že signál dostanou i potomci procesu.
Pozor na rozdíl mezi pádem za běhu a pádem při startu. Worker, který normálně běžel (přežil startsecs, výchozí 1 s) a pak spadl, řeší autorestart a nahodí ho prakticky bez omezení — čítač pokusů se totiž po každém úspěšném startu nuluje. Ale když worker padá hned při startu — typicky chyba konfigurace — dřív, než vydrží běžet startsecs, zkusí to Supervisor jen startretrieskrát (výchozí 3), pak ho označí za FATAL a vzdá to. Proto potřebujete i monitoring, ne jen autorestart.
systemd
Na moderním Debianu/Ubuntu můžete použít přímo systemd. Šablona /etc/systemd/system/[email protected]:
[Unit]
Description=App worker %i
After=network.target
[Service]
ExecStart=/usr/bin/php /var/www/app/bin/console messenger:consume async --time-limit=3600 --memory-limit=128M
Restart=always
RestartSec=5
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target
Spuštění čtyř instancí:
systemctl daemon-reload
systemctl enable --now worker@1 worker@2 worker@3 worker@4
journalctl -u 'worker@*' -f
Restart=always dělá totéž co autorestart. Hlídejte StartLimitBurst/StartLimitIntervalSec — když proces restartuje moc rychle a moc často, systemd ho zastaví s hláškou „start request repeated too quickly".
| Vlastnost | Supervisor | systemd |
|---|---|---|
| Instalace | balíček supervisor |
součást systému |
| Víc instancí | numprocs |
šablona @.service |
| Restart po pádu | autorestart=true |
Restart=always |
| Logy | vlastní soubor | journald |
Limity paměti a času a recyklace procesu
Dlouhoběžící PHP proces má jeden nepříjemný rys: paměť po zpracování jobu ne vždy plně vrátí. Drobný memory leak v jednom jobu vám za pár hodin nafoukne proces do stovek megabajtů. A CLI SAPI má v balíčkovaném PHP na Debianu ve výchozím stavu memory_limit = -1, tedy bez limitu — proces poroste, dokud nesní RAM a nepřijde OOM killer.
Řešení není spoléhat na memory_limit, ale nechat worker po překročení stropu čistě skončit a proces manažer ho nastartuje znovu se svěží pamětí. K tomu slouží vestavěné přepínače:
- Laravel:
queue:work --memory=128 --max-time=3600 --max-jobs=1000 - Symfony:
messenger:consume --memory-limit=128M --time-limit=3600 --limit=1000
--memory/--memory-limit = po překročení worker skončí. --max-time/--time-limit = skončí po X vteřinách běhu. --max-jobs/--limit = skončí po X zpracovaných zprávách. Tomuhle cyklení se říká recyklace procesu a je to úplně normální, žádoucí chování — worker se pravidelně „narodí znovu" s čistou pamětí.
Graceful restart při nasazení
Když nasadíte novou verzi kódu, běžící workery o ní nevědí. Dlouhoběžící worker si drží zavedený stav aplikace a jednou načtené třídy v paměti a používá je pro všechny další joby — nové verze kódu si tak nevšimne, dokud proces nerestartujete. Starý worker tak dál jede na starém kódu, klidně i s chybou, kterou jste právě opravili. Nesmíte ho ale zabít uprostřed jobu.
Řešení je signalizovaný graceful restart:
php artisan queue:restart # Laravel
php bin/console messenger:stop-workers # Symfony
Oba příkazy jen nastaví příznak; každý worker dokončí aktuální job a pak sám skončí. Proces manažer ho vzápětí nastartuje znovu — už s novým kódem. Zařaďte tenhle příkaz na konec deploy skriptu, hned po composer install a migracích. systemctl restart funguje taky: systemd pošle SIGTERM a workery Laravelu i Symfony si ho samy hlídají — dokončí rozdělaný job a teprve pak skončí (bez vlastního ošetření signálu by je SIGTERM zabil okamžitě uprostřed práce). Nastavte jen TimeoutStopSec delší, než trvá nejdelší job — jinak systemd po jeho vypršení eskaluje na SIGKILL.
Časté příčiny pádů
Chybějící ovladač/rozšíření pro CLI
Nejčastější „ale na webu to funguje" záhada. Web běží pod SAPI php-fpm, worker pod SAPI CLI — a na Debianu/Ubuntu má každé SAPI vlastní php.ini i vlastní conf.d, takže se může lišit i sada reálně načtených rozšíření:
php -i | grep "Loaded Configuration File"
# /etc/php/8.2/cli/php.ini
Když je databázové rozšíření (třeba pdo_mysql) zapnuté jen pro FPM, worker spadne na could not find driver, i když web jede bez problémů. Ověření a náprava:
php -m | grep -i mysql # co má CLI reálně načteno
phpenmod -v 8.2 -s cli pdo_mysql # zapnout jen pro CLI
systemctl restart supervisor
Stejná past číhá u disable_functions v cli/php.ini: když hosting zakáže pcntl_*/posix_*, přestane fungovat signalizace i per-job timeout.
Zaseknutý job
Job, který visí na volání cizího API bez timeoutu, zablokuje celý worker — ten pak nedělá nic. Dvě obrany: vždy nastavte timeout na HTTP klienta (Guzzle timeout, cURL) a použijte per-job timeout. V Laravelu ho řeší queue:work --timeout=60 (staví na rozšíření pcntl, které je v CLI standardně k dispozici). Pravidelný --max-time navíc worker preventivně recykluje.
Memory leak
Viz výše — bránou je --memory/--memory-limit plus recyklace. Když leak roste rychle, snižte --max-jobs, ať se proces obměňuje častěji.
Rotace logů: runaway log zaplní disk
Worker, který loguje každý job, umí vyrobit gigabajty za den. Plný disk pak položí celou aplikaci — a to nechcete.
Supervisor rotuje svůj zachytávaný log sám: stdout_logfile_maxbytes (výchozí 50 MB) a stdout_logfile_backups (výchozí 10). U systemd jde výstup do journaldu, který omezíte přes SystemMaxUse= v /etc/systemd/journald.conf.
Když worker píše do vlastního souboru, nasaďte logrotate do /etc/logrotate.d/app-worker:
/var/www/app/storage/logs/worker.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
}
Klíčové je copytruncate. Worker drží logovací soubor otevřený přes jeho deskriptor; kdyby ho logrotate jen přejmenoval a vedle vytvořil nový, worker by dál psal do toho přejmenovaného (rotovaného) souboru — nové řádky by končily ve starém worker.log.1, ne v novém worker.log. copytruncate místo toho obsah zkopíruje a původní soubor zkrátí na místě (inode zůstane stejný), takže worker píše dál bez nutnosti reloadu. (Má malé okno, kdy se pár řádků může ztratit — pro provozní log workeru přijatelná cena.)
Shrnutí
- Pomalé a nespolehlivé operace (e-maily, generování, cizí API) patří do fronty mimo HTTP request — request zůstane rychlý a odolný.
- Worker je dlouhoběžící proces: potřebuje proces manažer (Supervisor nebo systemd) s automatickým restartem.
- Nastavte limit paměti a času a nechte proces recyklovat — memory leaky vás tím přestanou trápit.
- Při deployi dělejte graceful restart (
queue:restart/messenger:stop-workers), ať workery naběhnou na nový kód. - Hlídejte tři klasické pády: rozšíření chybějící pro CLI SAPI, zaseknutý job bez timeoutu a memory leak.
- Nasaďte rotaci logů (
copytruncate), jinak vám runaway log zaplní disk.
Fronta a workery nejsou luxus navíc — jsou to ty součástky, díky kterým appka drží pod zátěží a při výpadku cizí služby jen počká, místo aby spadla.