— Artikel — № 076

076 —PHP

PHP-FPM tunen: de zes settings achter de 40-user crash

Je traffic verdubbelt en de site wordt wit. PHP-FPM workers staan vast op vijf, de queue zit vol, Apache krijgt time-outs. Dit is de cheatsheet.

Bovenaanzicht van papieren PHP-FPM-werkblad, Apache 502-logvel, koperen FPM POOL-plaatje, lakzegel op linnen.
Hero · gestileerd stilleven№ 076

Het is 21:14 op een dinsdagavond. Een Nederlands bureau waar we mee werken heeft net een nieuwsbrief verstuurd naar veertigduizend klanten. De WordPress-site erachter, een build uit 2018 op PHP 8.1 via Apache en php-fpm, draaide de hele middag prima. Veertig seconden na het versturen geeft de homepage een witte pagina. htop laat vijf php-fpm child-processen zien, allemaal RUNNING, geen idle. De Apache-log loopt vol met (70007)The timeout specified has expired: AH01075.

De site is niet gecrasht. Hij liep tegen het plafond van zijn php-fpm pool aan, een pool die was ingesteld voor één developer die lokaal test. Zes settings in www.conf verklaren waarom, en met dezelfde zes settings kom je er weer uit. Dit is de cheatsheet.

pm: static, dynamic of ondemand

De eerste regel die ertoe doet in /etc/php/8.1/fpm/pool.d/www.conf is pm. Drie waarden:

  • static houdt een vast aantal workers in leven. Voorspelbaar, geen spin-up cost, verspilt RAM als er niets gebeurt.
  • dynamic houdt een groep workers warm en schaalt tussen min en max. De default op vrijwel elke distro. Redelijk voor gemengde traffic.
  • ondemand start workers pas als er een request binnenkomt. Bespaart RAM, maar de eerste request na een rustige minuut betaalt de fork cost.

Voor een legacy site op een 4 GB VPS klopt dynamic in negentig procent van de gevallen. Switch alleen naar static als je de gemiddelde worker-RSS hebt gemeten en je RAM hebt om weg te gooien. ondemand hoort op shared hosting en staging-bakken thuis, niet voor betalende klanten.

pm.max_children: de echte bottleneck

Dit is de setting die de 40-user muur verklaart. pm.max_children is de harde limiet op gelijktijdige PHP-requests. De default op Debian en Ubuntu is 5. Vijf. Als een gemiddelde request 250 ms duurt, zijn dat twintig requests per seconde voor de hele site, wat instort op het moment dat iemand een ongecachte admin-pagina opent.

Bepaal de waarde door beschikbaar RAM te delen door de gemiddelde worker-RSS:

ps --no-headers -o "rss,cmd" -C php-fpm8.1 | awk '{sum+=$1; n++} END {print sum/n/1024" MB avg"}'

Als het gemiddelde 80 MB is en je 2 GB vrij hebt voor PHP na MySQL en het OS, kun je ongeveer 2048 / 80 = 25 children draaien. Rond naar beneden af om ruimte over te houden:

pm = dynamic
pm.max_children = 24
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10

Die laatste drie zijn geen aparte gevechten. Ze vormen de warme band rond max_children: hoeveel workers je idle houdt zodat een traffic-piek geen fork cost betaalt. start_servers moet tussen min_spare en max_spare liggen, anders logt php-fpm een warning en overschrijft hij je waarde stilletjes met het midden.

pm.max_requests: de recycler voor lekken

Verouderde WordPress-plugins lekken geheugen. Oude Drupal ook, net als elke custom code die een static cache eeuwig vasthoudt. pm.max_requests zegt een worker dat hij na N requests moet stoppen, waardoor alles wat hij heeft verzameld wordt vrijgegeven. Het OS ruimt hem op, php-fpm forkt een verse.

pm.max_requests = 500

Vijfhonderd is een veilige default voor een WordPress front-end. Zet hem op 200 als je weet dat er ergens een lek zit dat je niet kunt vinden. Laat hem alleen op de upstream default 0 (nooit recyclen) staan als je de workers hebt geprofileerd en hebt bevestigd dat RSS plat blijft na duizenden requests. Die nul is de reden dat je oude bak een dagelijkse systemctl restart cron nodig heeft.

request_terminate_timeout: de killer voor trage requests

Dit is op worker-niveau wat een query timeout op database-niveau doet. Als een PHP-request langer loopt dan request_terminate_timeout, stuurt php-fpm SIGTERM, daarna SIGKILL. Zonder die setting kan één vastgelopen request naar admin-ajax.php een worker uren bezet houden, en zie je de pool leeglopen, één slot per keer.

request_terminate_timeout = 60s

Zet hem net boven je traagste legitieme request. Voor een WordPress front-end is dertig seconden ruim voldoende. Voor een Magento-import die via het web loopt, zet hem op 300s en verplaats het script daarna naar een CLI cron, waar het thuishoort.

request_slowlog_timeout en slowlog

Je kunt niet repareren wat je niet ziet. request_slowlog_timeout schrijft een PHP stack trace naar een logfile telkens als een request de drempel overschrijdt. Het is veruit het nuttigste debug-gereedschap dat php-fpm meelevert, en op de meeste bakken staat het uit.

request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/www-slow.log

Doe daarna tail -f op de log tijdens een druk uur. In gewone PHP backtraces zie je precies welke plugin met zijn wp_remote_get() acht seconden lang vastloopt op een externe API die in 2022 al verdween. De trage request fixen is meestal sneller dan de pool vergroten.

listen.backlog: de queue waarvan je vergat dat hij er was

Als alle workers bezet zijn, blijven nieuwe requests wachten in een socket queue op kernel-niveau tot er een slot vrijkomt. listen.backlog bepaalt de grootte van die queue. De php-fpm default is 511, maar op de meeste distro's staat de kernel-setting net.core.somaxconn op 128, wat hem stilzwijgend afkapt.

sysctl net.core.somaxconn
# net.core.somaxconn = 128

Als de queue vol raakt, dropt Linux de connectie en geeft de upstream (Apache, nginx) een 502 terug. Verhoog beide:

listen.backlog = 1024
echo "net.core.somaxconn = 1024" | sudo tee -a /etc/sysctl.d/99-fpm.conf
sudo sysctl -p /etc/sysctl.d/99-fpm.conf

Een grotere backlog maakt de site niet sneller. Hij koopt je een paar seconden tijdens een piek, zodat de workers kunnen bijbenen in plaats van dat de load balancer de bak als unhealthy markeert. Het verschil tussen een brownout en een echte outage.

De pool dimensioneren, van begin tot eind

De zes settings bij elkaar voor een 4 GB VPS met WordPress en MariaDB:

; /etc/php/8.1/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 24
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500
request_terminate_timeout = 60s
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/www-slow.log
listen.backlog = 1024

Reload, geen restart, zodat lopende requests netjes kunnen afronden:

sudo systemctl reload php8.1-fpm

Kijk daarna hoe de pool zich houdt onder echte traffic. Het php-fpm endpoint pm.status_path, als je dat aanzet, geeft je live cijfers over actieve processen, hoe vaak max children is geraakt, en de diepte van de listen queue. Apache's mod_status vertelt de andere kant van hetzelfde verhaal.

Wat je vandaag kunt doen

Open www.conf op de productie-bak, tel de workers, en deel het beschikbare RAM door de gemiddelde RSS. Het getal dat eruit komt is vrijwel zeker hoger dan vijf. We zien dit bij bijna elke legacy site audit die we doen. Toen we Pier bouwden, hebben we de pool config met zijn actuele waarden in de eerste audit-stap meegenomen, zodat de bovenstaande som tegen de echte bak loopt in plaats van tegen een onderbuikgevoel. De rest blijft hetzelfde: bewerken, reloaden, slowlog in de gaten houden.

— Vragen —

Hoe kies ik een veilige pm.max_children zonder de server plat te leggen?

Meet de gemiddelde worker-RSS met ps, deel het vrije RAM na MySQL en het OS daardoor, en rond twintig procent naar beneden af voor wat marge. Houd na de wijziging de slowlog in de gaten.

Waarom negeert php-fpm mijn pm.start_servers waarde?

start_servers moet tussen min_spare_servers en max_spare_servers liggen. Is dat niet zo, dan logt php-fpm een warning en herberekent hij de waarde stilletjes als het midden van de spare-range.

Overrulet request_terminate_timeout de max_execution_time?

Ja. De pool wint. Als php.ini 300s zegt en de pool 60s, sneuvelen requests op zestig seconden met SIGTERM in de fpm-log. Zet de pool-waarde boven je traagste legitieme request.