PHP-FPM che crasha sotto carico su VPS: come ho diagnosticato un OOM killer silenzioso su un portale Laravel con 200 utenti concorrenti
A luglio 2025, il responsabile operativo di un portale B2B per la distribuzione di ricambi auto in provincia di Modena mi ha segnalato un problema ricorrente: ogni giorno, tra le 10:00 e le 11:00 del mattino, l'applicazione diventava irraggiungibile per 3-5 minuti. Gli utenti vedevano una pagina bianca con "502 Bad Gateway" di Nginx, poi tutto tornava normale. Il portale - un'applicazione Laravel 10 su VPS OVH Value (4 vCPU, 8 GB RAM, 80 GB SSD NVMe) - gestiva circa 200 utenti concorrenti nell'ora di punta, quando i magazzinieri di tutta Italia inserivano gli ordini della giornata. Il problema andava avanti da tre settimane, e il precedente intervento dell'hosting provider era stato "provi ad aumentare la RAM" - un consiglio da 25 euro al mese che non avrebbe risolto nulla.
Il 502 Bad Gateway di Nginx, in uno stack LEMP, significa quasi sempre una cosa sola: Nginx ha provato a passare la richiesta a PHP-FPM e PHP-FPM non era disponibile - o perché era morto, o perché tutti i worker erano occupati. La domanda era: perché moriva esattamente alla stessa ora ogni giorno?
Cosa succede davvero quando PHP-FPM va in crash su un VPS e Nginx restituisce 502?
Nginx e PHP-FPM comunicano tramite un socket Unix (o TCP). Nginx riceve la richiesta HTTP, la passa a PHP-FPM che la processa e restituisce la risposta. Se PHP-FPM non è in ascolto sul socket - perché è crashato o perché tutti i worker sono occupati e la coda è piena - Nginx non ha nessuno a cui passare la richiesta e restituisce 502. Il primo passo diagnostico è sempre capire quale delle due situazioni è:
# PHP-FPM è vivo?
systemctl is-active php8.2-fpm
# Se è attivo, quanti worker sono occupati?
# (richiede pm.status_path configurato in pool.d/www.conf)
curl -s http://127.0.0.1/fpm-status | grep -E "active|idle|total"
# Se è morto, perché?
journalctl -u php8.2-fpm --since "today" --no-pager | tail -30Sul VPS modenese, systemctl is-active restituiva inactive (dead) durante le finestre di downtime. PHP-FPM non era sovraccarico - era proprio morto. Il log di journalctl mostrava la firma inconfondibile dell'OOM killer del kernel Linux:
Jul 03 10:17:42 vps kernel: Out of memory: Killed process 18423 (php-fpm8.2)
Jul 03 10:17:42 vps kernel: php-fpm8.2 invoked oom-killer: [...] total-vm:6842368kB
Jul 03 10:17:42 vps systemd[1]: php8.2-fpm.service: A process has been killed by the OOM killer.L'OOM killer è il meccanismo del kernel Linux che interviene quando la RAM fisica più lo swap sono esauriti: sceglie il processo che consuma più memoria e lo termina. Su un VPS con 8 GB di RAM dove PHP-FPM era il processo più grosso, era la vittima naturale. Ma il kernel non distingue tra "processo essenziale per il business" e "processo sacrificabile" - uccide il più grosso, punto.
Stai cercando un Consulente Informatico esperto per diagnosticare e risolvere crash PHP-FPM sulla tua infrastruttura Laravel? Nel mio profilo professionale trovi l'esperienza concreta su tuning PHP-FPM, ottimizzazione memoria e gestione di VPS Hetzner, OVH, Contabo e Digital Ocean per PMI.
La causa: pm.max_children e l'aritmetica che non torna
La configurazione del pool PHP-FPM nel file /etc/php/8.2/fpm/pool.d/www.conf conteneva questa riga:
pm = dynamic
pm.max_children = 200Duecento worker PHP-FPM. Su un server con 8 GB di RAM. Il calcolo è quello che insegno a ogni team che gestisce un VPS con PHP: la formula per pm.max_children è RAM disponibile per PHP / memoria media per worker. Per misurare la memoria media per worker:
ps --no-headers -o "rss,cmd" -C php-fpm8.2 | awk '{ sum+=$1; n++ } END { printf "%d MB (su %d worker)\n", sum/n/1024, n }'Sul VPS modenese, ogni worker consumava in media 82 MB di RSS. Con 8 GB di RAM totali, di cui circa 1,5 GB occupati da MySQL, Nginx, Redis e il sistema operativo, restavano circa 6,5 GB per PHP-FPM. 6.500 MB / 82 MB = 79 worker, non 200. Con 200 worker a pieno regime (200 × 82 MB = 16,4 GB), PHP-FPM richiedeva il doppio della RAM disponibile. Ogni giorno tra le 10 e le 11, quando i 200 utenti concorrenti generavano abbastanza richieste da attivare tutti i worker, la RAM si esauriva e l'OOM killer interveniva.
La correzione:
; /etc/php/8.2/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 70 ; 6500 MB / 82 MB = 79, arrotondato giù con margine 12%
pm.start_servers = 17 ; 25% di max_children
pm.min_spare_servers = 17 ; 25% di max_children
pm.max_spare_servers = 52 ; 75% di max_children
pm.max_requests = 500 ; recicla ogni worker dopo 500 richieste (previene memory leak)Il parametro pm.max_requests = 500 è spesso dimenticato ed è fondamentale: forza il riciclo di ogni worker dopo 500 richieste. Se un'estensione PHP o un pezzo di codice applicativo ha un memory leak - anche piccolo, anche pochi KB per richiesta - senza pm.max_requests il worker cresce indefinitamente fino a consumare tutta la RAM assegnata. Con il riciclo, il leak viene resettato ad ogni ciclo di 500 richieste.
Diagnosticare il memory leak: quando pm.max_requests non basta
Sul VPS modenese, dopo il fix del pm.max_children, il problema immediato era risolto. Ma durante il monitoring successivo ho notato un pattern: la memoria media per worker cresceva da 82 MB al restart a 110 MB dopo 400 richieste. Un memory leak di circa 70 KB per richiesta - piccolo, ma cumulativo. Con pm.max_requests = 500, il worker veniva riciclato prima di diventare critico. Ma il leak restava, e in futuro con più traffico poteva diventare un problema anche con il max_children corretto.
Per trovare il colpevole, ho abilitato la status page di PHP-FPM - una funzionalità che la maggior parte delle installazioni di produzione non attiva e che è la fonte più preziosa di informazioni sul comportamento dei worker:
; /etc/php/8.2/fpm/pool.d/www.conf
pm.status_path = /fpm-status
pm.status_listen = 127.0.0.1:9001# Status completo con dettaglio per-worker
curl -s "http://127.0.0.1:9001/fpm-status?full" | grep -E "pid|requests|duration|memory"L'output mostra per ogni worker: PID, numero di richieste servite, durata dell'ultima richiesta, e - il dato cruciale - la memoria consumata. Confrontando la memoria tra worker appena spawnati e worker con 400+ richieste, ho isolato il pattern: i worker che processavano la rotta /api/catalog/search crescevano di 200 KB per richiesta, gli altri di 10-20 KB. La causa era un'istanza di Eloquent Collection che veniva accumulata in una proprietà statica di un service class - un anti-pattern comune in Laravel dove un service registrato come singleton nel container accumula stato tra le richieste. Il fix: aggiungere un metodo flush() e chiamarlo nel middleware Terminate. Dopo il fix, il leak si è ridotto a 5 KB per richiesta - fisiologico per PHP.
Per chi gestisce applicazioni Laravel con problemi di performance che vanno oltre PHP-FPM - query lente, indici mancanti, buffer pool MySQL sottodimensionato - ho descritto il metodo di diagnostica completo nel mio articolo sul refactoring di database MySQL su Laravel, dove un report che impiegava 47 minuti è stato portato a 11 secondi con quattro giorni di lavoro.
Auto-restart: la rete di sicurezza che manca a ogni installazione di default
L'aspetto più grave dell'incidente non era il crash in sé - era che PHP-FPM non si riavviava automaticamente dopo l'OOM kill. Il service systemd di default per PHP-FPM non include una direttiva Restart=, il che significa che se il processo viene ucciso dall'OOM killer, resta morto fino al riavvio manuale. Su un VPS senza monitoring, questo può significare ore di downtime - esattamente quello che era successo al cliente modenese per tre settimane. PHP-FPM moriva alle 10:17, il titolare chiamava l'helpdesk OVH alle 10:20, l'helpdesk verificava che il VPS fosse "online" (lo era - era PHP-FPM a essere morto, non il server), il titolare restartava manualmente PHP-FPM dopo 20 minuti di ticket. Ogni giorno.
La fix è un override di systemd:
systemctl edit php8.2-fpm# Contenuto dell'override
[Service]
Restart=always
RestartSec=5
OOMScoreAdjust=-200Restart=always fa ripartire PHP-FPM dopo qualunque tipo di terminazione. RestartSec=5 aspetta 5 secondi prima del restart (per evitare loop di crash). OOMScoreAdjust=-200 abbassa la priorità di PHP-FPM nella classifica dell'OOM killer - non lo rende immune, ma lo rende meno probabile come vittima rispetto a processi meno critici. La stessa configurazione di emergency restart è documentata nella sezione global di PHP-FPM con i parametri emergency_restart_threshold e emergency_restart_interval:
; /etc/php/8.2/fpm/php-fpm.conf - sezione global
emergency_restart_threshold = 5
emergency_restart_interval = 1mQuesto dice a PHP-FPM: se 5 worker child crashano (segfault o errore fatale) in 1 minuto, riavvia l'intero master process. È la rete di sicurezza interna di FPM, complementare al Restart=always di systemd che copre il caso in cui è il master stesso a morire.
Prevenzione: monitoring della memoria e alert prima del crash
Il tuning è il fix. Il monitoring è la prevenzione. Senza monitoring, il prossimo problema di memoria - che arriverà, perché il traffico cresce e il codice cambia - verrà scoperto di nuovo dal titolare che vede il 502.
Lo stack che ho installato sul VPS modenese:
# Alert Prometheus per memoria PHP-FPM sopra il 70% della RAM disponibile
# (da aggiungere a /etc/prometheus/rules/php-fpm.yml)groups:
- name: php-fpm
rules:
- alert: PHPFPMMemoryHigh
expr: sum(process_resident_memory_bytes{job="php-fpm"}) / 1024 / 1024 > 5600
for: 5m
labels:
severity: warning
annotations:
summary: "PHP-FPM usa più di 5.6 GB di RAM da 5 minuti"
runbook: "Verificare pm.max_children, pm.max_requests, memory leak"
- alert: PHPFPMDown
expr: up{job="php-fpm"} == 0
for: 30s
labels:
severity: critical
annotations:
summary: "PHP-FPM non risponde"
runbook: "systemctl status php8.2-fpm, journalctl -u php8.2-fpm, dmesg | grep oom"L'alert PHPFPMMemoryHigh scatta quando PHP-FPM usa più del 70% della RAM disponibile per PHP (5,6 GB su 8 GB totali con 6,5 GB disponibili) - abbastanza presto da intervenire prima che l'OOM killer si attivi, abbastanza tardi da non generare falsi positivi nell'ora di punta normale. Ho descritto lo stack completo di monitoring Prometheus + Grafana + Alertmanager nel mio articolo sul monitoring proattivo per Laravel su VPS unmanaged - includendo le regole per disco, CPU, code Laravel, job falliti e certificati SSL.
Cosa ho misurato dopo il fix
Dopo il tuning di pm.max_children, la risoluzione del memory leak, l'aggiunta dell'auto-restart e l'attivazione del monitoring, ho monitorato il VPS per 7 giorni. I risultati:
- Zero crash PHP-FPM in 7 giorni (prima: uno al giorno, stesso orario).
- RAM peak nell'ora di punta: 6,1 GB su 8 GB - 76% di utilizzo, con margine sufficiente per assorbire picchi anomali senza coinvolgere l'OOM killer.
- Tempo di risposta mediano: da 1.800 ms (con worker in coda per saturazione) a 340 ms (con worker adeguatamente dimensionati).
- p99 response time: 1.200 ms, accettabile per un portale B2B con query di catalogo complesse.
- Memory leak post-fix: 5 KB per richiesta, fisiologico - con
pm.max_requests = 500, ogni worker viene riciclato dopo aver accumulato al massimo 2,5 MB in più della baseline. Nessun impatto operativo.
Il costo totale dell'intervento: mezza giornata di lavoro. Il costo dell'alternativa proposta dal provider (upgrade RAM da 8 a 16 GB): 25 euro al mese, 300 euro l'anno - che avrebbe mascherato il problema senza risolverlo, perché con 200 worker a 82 MB ciascuno anche 16 GB non sarebbero bastati (200 × 82 = 16.400 MB, praticamente tutta la RAM disponibile).
Per il quadro completo di ottimizzazione dello stack PHP - non solo FPM ma anche OPcache, JIT, e caching applicativo - ho descritto il metodo nel mio articolo sulla riduzione di un checkout da 4,2 secondi a 280 millisecondi su Hetzner senza upgrade hardware. E per chi sta valutando il passaggio da PHP-FPM a un runtime persistente come Swoole o RoadRunner, la decisione va presa con dati alla mano - ne ho parlato nella guida su Laravel Octane nel 2026 e quando ha senso per una PMI.
Se la tua applicazione Laravel mostra 502 intermittenti - soprattutto se compaiono sempre nelle fasce di traffico più alto - prima di comprare RAM o upgradare il VPS, controlla dmesg | grep -i "out of memory" e ps --no-headers -o rss -C php-fpmX.Y | awk '{sum+=$1} END {print sum/1024 " MB"}'. Se la somma della RAM dei worker supera la RAM disponibile, il problema è nel pm.max_children, non nell'hardware. E se il tuo PHP-FPM non ha Restart=always nel service systemd - cosa che puoi verificare con systemctl cat php8.2-fpm | grep Restart - aggiungilo oggi. Ci vogliono 30 secondi e possono fare la differenza tra un downtime di 5 secondi (auto-restart) e un downtime di 5 ore (nessuno se ne accorge fino alla mattina dopo). Contattami se hai bisogno di un tuning PHP-FPM: in mezza giornata diagnostico il pattern di consumo, calcolo il max_children corretto per il tuo workload e configuro auto-restart e monitoring per evitare downtime futuri.