Ottimizzare PHP-FPM per carichi elevati: pool, worker e tuning avanzato
Il 29 novembre 2024 - venerdì, Black Friday - alle 9:47 del mattino ho ricevuto una chiamata urgente dal responsabile tecnico di un'azienda del settore retail online. Il suo e-commerce Laravel su un VPS Hetzner AX42 (8 core Ryzen 5, 32 GB RAM, NVMe) aveva iniziato a restituire errori 502 Bad Gateway a tutti gli utenti. Il server non era sovraccarico in senso tradizionale: la CPU era al 35%, la RAM utilizzata al 42%, i dischi NVMe avevano latenza sotto il millisecondo. Il problema era che tutti i worker PHP-FPM erano occupati a processare richieste e non ce n'erano più di disponibili per le nuove - il parametro pm.max_children era impostato a 30, e durante il picco promozionale le richieste simultanee avevano superato le 30 in meno di due minuti dall'inizio della campagna email. Nginx riceveva le connessioni, le inoltrava al socket Unix di FPM, FPM le metteva in coda perché non aveva worker liberi, e dopo 60 secondi di attesa nella coda le richieste scadevano con timeout 502. L'ironia: il server aveva risorse sufficienti per gestire 200+ worker PHP simultanei - semplicemente, nessuno aveva configurato FPM per usarle.
Quello che è successo dopo - diagnosi, fix temporaneo e poi tuning strutturale - è un processo che ho ripetuto decine di volte su VPS di clienti PMI, perché la configurazione di default di PHP-FPM è pensata per ambienti condivisi con risorse limitate, non per un VPS dedicato con 32 GB di RAM che serve un'applicazione Laravel sotto carico. La documentazione ufficiale di PHP-FPM su php.net descrive i parametri disponibili, ma non dice come calibrarli per il tuo workload specifico - e il divario tra "conosco i parametri" e "so quali valori impostare" è dove la maggior parte dei team PMI senza un sysadmin dedicato si blocca.
Quanti worker PHP-FPM servono davvero al tuo server?
Il calcolo è empirico ma segue una formula solida: dividi la RAM disponibile per PHP-FPM per la memoria media consumata da un singolo worker. La RAM disponibile non è la RAM totale del server - devi sottrarre ciò che serve al sistema operativo (1-2 GB), al database (InnoDB buffer pool, tipicamente il 50-60% della RAM su un server dedicato al database, o 4-8 GB su un server condiviso), a Redis se lo usi (1-2 GB), e al web server Nginx (trascurabile, poche centinaia di MB). Ciò che resta è la RAM per PHP.
Per misurare la memoria di un singolo worker FPM, uso ps durante un carico realistico - non un carico di picco, ma il carico medio della giornata lavorativa:
# Misura la memoria RSS media dei worker PHP-FPM in produzione
ps -C php-fpm -o rss= | awk '{sum+=$1; count++} END {
printf "Worker: %d\nMedia RSS: %.0f MB\nTotale: %.0f MB\n",
count, sum/count/1024, sum/1024
}'Sul server del cliente, il risultato era: 30 worker attivi, media RSS 62 MB per worker, totale 1.860 MB. Con 32 GB di RAM totali, 8 GB per InnoDB, 2 GB per Redis, 2 GB per il sistema operativo e Nginx, restavano 20 GB disponibili per PHP. Al consumo medio di 62 MB per worker, il server poteva gestire fino a 320 worker simultanei - ma ne aveva solo 30 configurati. Il fix immediato è stato aumentare pm.max_children a 200 (con margine di sicurezza) e riavviare FPM - operazione che ha richiesto 45 secondi e ha risolto il problema immediatamente. Ma il fix strutturale - il tuning corretto che previene il problema prima che si verifichi - richiede una comprensione più profonda dei tre parametri chiave di FPM.
Nel mio profilo professionale trovi il dettaglio dell'esperienza nel tuning di PHP-FPM su VPS di ogni dimensione - da server con 4 GB di RAM che servono un gestionale interno a server con 128 GB che gestiscono e-commerce ad alto traffico. Il principio è sempre lo stesso: misura prima, configura dopo, e non fidarti mai dei valori di default.
Static vs dynamic vs ondemand: quale process manager scegliere
PHP-FPM offre tre modalità di gestione dei worker: static (un numero fisso di worker sempre attivi), dynamic (un numero variabile tra un minimo e un massimo, con creazione e distruzione dinamica), e ondemand (zero worker attivi a riposo, creazione su richiesta). La scelta dipende dal pattern di traffico dell'applicazione.
Static è la scelta migliore per applicazioni con traffico costante o con picchi prevedibili - tipicamente e-commerce, portali B2B, API con client dedicati. Tutti i worker sono pre-creati all'avvio di FPM e restano in memoria: nessun overhead di creazione/distruzione, latenza costante, consumo di RAM prevedibile. Il costo è che la RAM è allocata anche quando il traffico è basso (di notte, nei weekend), ma su un VPS dedicato con RAM abbondante questo è un costo accettabile per la garanzia di prestazioni costanti.
Dynamic è la scelta migliore per server condivisi o per applicazioni con traffico molto variabile - picchi alti durante il giorno e traffico quasi nullo di notte. FPM crea worker quando servono e li distrugge quando sono inattivi, risparmiando RAM nei periodi di basso traffico. Il costo è la latenza aggiuntiva alla creazione di un nuovo worker (10-50 ms per worker) durante i ramp-up di traffico - se 50 richieste arrivano simultaneamente e FPM ha solo 10 worker attivi, le prime 10 vengono servite subito e le altre 40 aspettano che i worker vengano creati.
Ondemand è la scelta peggiore per la produzione ad alto traffico e la migliore per ambienti con molti pool FPM che servono applicazioni a basso traffico - tipicamente un server condiviso con 20 siti WordPress, dove ogni sito ha il suo pool FPM ma solo 2-3 sono attivi contemporaneamente.
Per il cliente e-commerce, ho configurato il pool in modalità static:
; /etc/php/8.2/fpm/pool.d/www.conf
[www]
user = www-data
group = www-data
; Process manager: static per traffico costante
pm = static
pm.max_children = 200
; Status page per monitoring (accesso solo da localhost)
pm.status_path = /fpm-status
pm.status_listen = 127.0.0.1:9001
; Slow log: registra le richieste che superano i 3 secondi
slowlog = /var/log/php-fpm/slow.log
request_slowlog_timeout = 3s
; Termina i worker che consumano troppa memoria (leak protection)
pm.max_requests = 1000
; Timeout per richieste PHP
request_terminate_timeout = 60sDue parametri meritano attenzione particolare. Il primo è pm.max_requests = 1000: dopo aver servito 1.000 richieste, il worker viene terminato e ricreato. Questo protegge contro i memory leak - un problema comune nelle applicazioni PHP con componenti che non liberano correttamente la memoria (tipicamente estensioni PECL, driver di database custom, o librerie di processing immagini). Senza max_requests, un worker con memory leak cresce indefinitamente fino a consumare tutta la RAM disponibile e a causare un OOM kill del processo. Con max_requests = 1000, il worker viene riciclato preventivamente, e il consumo di memoria resta stabile nel tempo.
Il secondo è request_terminate_timeout = 60s: se una richiesta PHP impiega più di 60 secondi, FPM la termina forzatamente. Questo impedisce che richieste "zombie" (un loop infinito, una query SQL che non ritorna, una connessione HTTP a un servizio esterno in timeout) blocchino un worker per sempre, rendendolo indisponibile per le altre richieste. Senza questo parametro, un singolo bug nel codice PHP può saturare progressivamente tutti i worker del pool fino a rendere l'applicazione completamente irraggiungibile.
Il slow log: lo strumento diagnostico più sottovalutato
Il parametro request_slowlog_timeout = 3s attiva il slow log di FPM - un file che registra lo stack trace PHP completo di ogni richiesta che supera la soglia temporale specificata. A differenza del slow query log di MySQL (che registra solo le query lente), il slow log di FPM registra quale funzione PHP stava eseguendo quando la richiesta ha superato il timeout - il che ti dice non solo che una richiesta è lenta, ma dove nel codice PHP il tempo viene speso.
Sul server del cliente, dopo il fix del Black Friday, ho abilitato il slow log con soglia di 3 secondi e ho lasciato raccogliere dati per 48 ore di traffico reale. Il risultato: 127 richieste lente in 48 ore, di cui 89 bloccate nella stessa funzione - un metodo del controller che generava un PDF con dompdf per la conferma dell'ordine, sincrono nel request lifecycle. La soluzione (spostare la generazione PDF in un job asincrono con Horizon) ha eliminato l'89% delle richieste lente in 30 minuti di lavoro. Senza il slow log, quel collo di bottiglia sarebbe stato invisibile perché il rendering PDF non genera errori - semplicemente richiede 4-8 secondi per ordini con molte righe, e durante quei secondi un worker FPM è bloccato e indisponibile per le altre richieste.
Pool multipli: isolare le applicazioni ad alto consumo
Un pattern avanzato che implemento quando il server ospita più applicazioni o quando una singola applicazione ha endpoint con profili di consumo molto diversi è la configurazione di pool FPM separati. L'idea è semplice: se l'endpoint di generazione PDF consuma 120 MB di RAM per worker e impiega 5 secondi per risposta, mentre gli endpoint API standard consumano 40 MB e rispondono in 100 ms, mescolare i due tipi di richieste nello stesso pool significa dimensionare max_children sulla memoria del worker più pesante - sprecando 80 MB per ogni worker che serve una richiesta leggera. Con pool separati, il pool API ha 200 worker leggeri da 40 MB e il pool PDF ha 10 worker pesanti da 120 MB - la RAM totale è la stessa, ma la capacità di servire richieste API simultanee è molto più alta.
La configurazione richiede un secondo file di pool nella directory /etc/php/8.2/fpm/pool.d/ e una regola Nginx che instrada le richieste al pool corretto in base all'URL. Il pool pesante ascolta su un socket Unix dedicato (/run/php/php-fpm-heavy.sock) e il pool leggero su un altro (/run/php/php-fpm.sock). Nginx usa una direttiva location per instradare le richieste /api/export, /api/pdf e /api/report verso il pool pesante, e tutto il resto verso il pool standard. Questo isolamento ha un secondo vantaggio: se il pool pesante satura tutti i suoi worker (perché 10 utenti generano PDF contemporaneamente), il pool API continua a funzionare normalmente - gli utenti che navigano il sito non vedono rallentamenti causati da operazioni di export che non li riguardano. Senza l'isolamento in pool separati, i 10 utenti che generano PDF occuperebbero 10 worker del pool unico, riducendo la capacità residua per le richieste API.
La regola che applico è: un pool separato per ogni tipo di carico che ha un profilo di risorse significativamente diverso. Per la maggior parte delle applicazioni PMI, due pool sono sufficienti - uno per il traffico web standard e uno per le operazioni batch pesanti. Raramente serve più di tre pool sullo stesso server.
Ho documentato un approccio complementare al tuning di PHP-FPM e OPcache nel mio articolo sull'ottimizzazione delle performance PHP su Hetzner, OVH e Digital Ocean, dove il tuning di FPM è una delle quattro leve principali - insieme al database, all'OPcache e al caching applicativo - per portare un'applicazione PHP dalla lentezza alla velocità senza cambiare hardware.
Il server del cliente e-commerce ha superato il Cyber Monday successivo (dicembre 2024) senza un singolo errore 502: 3.200 sessioni simultanee al picco, 200 worker FPM attivi, CPU al 58%, RAM al 67%, tempi di risposta stabili sotto i 250 ms. Il tuning di FPM non è stato l'unica modifica - ho anche ottimizzato il pool di connessioni MySQL e il caching Redis - ma è stato il singolo intervento che ha avuto l'impatto più immediato e più visibile, perché è il parametro che determina quante richieste il tuo server può gestire simultaneamente, indipendentemente da quanto sono veloci. Se il tuo VPS mostra errori 502 sotto carico o se non hai mai toccato la configurazione di default di PHP-FPM, contattami per una sessione di tuning: in mezza giornata misuriamo il profilo di memoria dei worker, calcoliamo il numero ottimale, configuriamo il pool e impostiamo il monitoring per non essere sorpresi dal prossimo picco di traffico.