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_time nebo nginx proxy_read_timeout request 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.