PHP OPcache: configurazione ottimale per Laravel e Symfony in produzione

PHP OPcache: configurazione ottimale per Laravel e Symfony in produzione

In ogni intervento di performance tuning che faccio su VPS di clienti PMI, il primo parametro che controllo non è il database, non è Nginx, non è la dimensione della RAM - è la configurazione di OPcache. Su circa il 70% dei server che eredito, OPcache è in una di queste tre situazioni: non è abilitato (raro ma succede, tipicamente su server configurati da hosting economici), è abilitato con i valori di default che non sono ottimali per la produzione, o è abilitato ma con validate_timestamps=1 che causa centinaia di syscall stat() inutili ad ogni richiesta. In tutti e tre i casi, la configurazione corretta di OPcache produce un miglioramento misurabile del 40-60% nel tempo di risposta - il singolo intervento con il rapporto tempo/beneficio più alto in tutto il toolkit di ottimizzazione PHP.

La documentazione ufficiale di OPcache su php.net descrive un meccanismo di caching del bytecode compilato: invece di parsare, compilare e ottimizzare ogni file PHP ad ogni richiesta, OPcache salva il bytecode compilato in memoria condivisa e lo riutilizza. Per un'applicazione Laravel 11 con 1.200 file PHP tra framework, vendor e codice applicativo, questo elimina 1.200 operazioni di file read + parse + compile ad ogni richiesta - un risparmio di 15-40 millisecondi per richiesta a seconda della velocità del disco e della complessità del codice. Su un server che serve 100 richieste al secondo, sono 1.500-4.000 millisecondi di CPU al secondo liberati - risorse che possono servire 20-30% di richieste in più con lo stesso hardware.

Quali parametri di OPcache cambiano davvero le prestazioni in produzione?

I parametri critici sono cinque, e per ciascuno il valore di default è inadeguato per un'applicazione Laravel o Symfony in produzione. Il primo e più impattante è opcache.validate_timestamps. Il secondo è opcache.memory_consumption. Il terzo è opcache.max_accelerated_files. Il quarto è opcache.interned_strings_buffer. Il quinto, per PHP 8.0+, è il preloading.

opcache.validate_timestamps - il parametro che da solo vale il 20% del miglioramento. Con il valore di default 1, OPcache verifica ad ogni richiesta (o ogni revalidate_freq secondi) se i file PHP sul disco sono stati modificati rispetto alla versione cached - per ogni file, esegue una syscall stat() che interroga il filesystem. Su un'applicazione Laravel con 1.200 file, sono 1.200 syscall stat() per ogni richiesta. Su un disco NVMe la latenza è trascurabile (microsecondo per stat), ma su un disco virtio o su un filesystem di rete (NFS, GlusterFS) le latenze si accumulano rapidamente. In produzione, dove il codice non cambia tra un deploy e l'altro, queste verifiche sono completamente inutili. Impostando validate_timestamps=0, OPcache non verifica mai la freschezza dei file - il bytecode viene ricompilato solo quando si riavvia PHP-FPM o si svuota la cache manualmente. Ogni deploy deve includere un systemctl reload php8.2-fpm per forzare la ricompilazione - un requisito che è perfettamente compatibile con qualsiasi pipeline di deploy moderna e che nel mio workflow è già presente da sempre.

opcache.memory_consumption - la quantità di memoria condivisa allocata per il bytecode cached. Il default è 128 MB, che per un'applicazione Laravel media con 80 pacchetti Composer è sufficiente ma senza margine. Un'applicazione Symfony enterprise con 200+ pacchetti può superare i 128 MB e causare un degrado silenzioso: OPcache inizia a scartare i file meno usati dalla cache per fare spazio ai nuovi, causando ricompilazioni intermittenti che aumentano la latenza in modo non prevedibile. Il valore che uso come standard è 256 MB - abbastanza per qualsiasi applicazione PHP che ho incontrato in 20 anni, con margine del 40-50% per la crescita futura. Il costo è 256 MB di RAM condivisa tra tutti i worker FPM (non per worker - la cache è condivisa), un prezzo irrisorio per la garanzia che tutti i file siano sempre in cache.

opcache.max_accelerated_files - il numero massimo di file PHP che OPcache può cachare. Il default è 10.000, ma un'applicazione Laravel con vendor directory tipica ha tra 8.000 e 15.000 file PHP. Se il numero di file supera max_accelerated_files, i file in eccesso non vengono cached e vengono ricompilati ad ogni richiesta. Il valore che uso è 20.000 - il numero primo più vicino sopra la soglia (OPcache arrotonda internamente al numero primo successivo per l'hash table). Nel mio profilo professionale trovi il dettaglio dell'esperienza nel tuning di PHP su VPS di ogni dimensione - e la configurazione di OPcache è sempre il primo intervento che faccio, prima di qualsiasi ottimizzazione di database o di codice.

La configurazione completa che uso su ogni server in produzione

Ecco il file di configurazione OPcache che installo su ogni server di produzione per applicazioni Laravel e Symfony:

; /etc/php/8.2/fpm/conf.d/10-opcache.ini
; Configurazione OPcache ottimizzata per produzione

opcache.enable=1
opcache.enable_cli=0

; Memory: 256MB per il bytecode, 16MB per le stringhe internate
opcache.memory_consumption=256
opcache.interned_strings_buffer=16

; File: supporta fino a 20.000 file PHP
opcache.max_accelerated_files=20000

; Validazione: disabilitata in produzione
; Il deploy DEVE fare: systemctl reload php8.2-fpm
opcache.validate_timestamps=0

; Ottimizzazione del bytecode
opcache.save_comments=1
opcache.fast_shutdown=1

; JIT (PHP 8.0+): abilitato in modalità tracing
; Beneficio marginale per web (5-15%), significativo per CLI
opcache.jit_buffer_size=64M
opcache.jit=1255

Ogni parametro ha una ragione specifica. enable_cli=0 disabilita OPcache per i comandi CLI (Artisan, Composer) dove i file cambiano frequentemente e il caching creerebbe confusione. save_comments=1 è necessario per Laravel e Symfony che usano le annotation e i docblock per la configurazione (route attribute, DI attribute). interned_strings_buffer=16 aumenta il buffer per le stringhe internate (nomi di classe, nomi di metodo, stringhe costanti) che in un'applicazione con namespace profondi e molte classi può saturare rapidamente i 8 MB di default.

Il parametro JIT (opcache.jit=1255) merita un commento: JIT in PHP 8 compila il bytecode in codice macchina nativo per la CPU, eliminando l'overhead dell'interprete. Per le applicazioni web I/O-bound (che passano la maggior parte del tempo aspettando database, cache e servizi esterni), il beneficio è marginale - 5-15% di miglioramento sulle operazioni CPU come il rendering dei template e il parsing dei dati. Per script CLI che fanno calcoli intensivi (importazione batch, generazione report, processing di immagini), il beneficio può raggiungere il 200-300%. Il valore 1255 attiva il JIT in modalità tracing (la più aggressiva) con ottimizzazione completa - è il valore che produce i risultati migliori nei benchmark su applicazioni reali che ho testato.

Come verificare che OPcache funzioni correttamente

Dopo aver configurato OPcache, la verifica è fondamentale - una configurazione errata (ad esempio memory_consumption troppo bassa) può degradare le prestazioni invece di migliorarle. Lo strumento integrato è opcache_get_status(), che restituisce un array dettagliato con lo stato corrente della cache:

// Script di verifica OPcache (da eseguire via web, non CLI)
$status = opcache_get_status(false);

echo "Memoria usata: " . round($status['memory_usage']['used_memory'] / 1024 / 1024) . " MB\n";
echo "Memoria libera: " . round($status['memory_usage']['free_memory'] / 1024 / 1024) . " MB\n";
echo "File cached: " . $status['opcache_statistics']['num_cached_scripts'] . "\n";
echo "Hit rate: " . round($status['opcache_statistics']['opcache_hit_rate'], 2) . "%\n";
echo "Misses: " . $status['opcache_statistics']['misses'] . "\n";
echo "OOM restarts: " . $status['opcache_statistics']['oom_restarts'] . "\n";

Le metriche critiche sono: opcache_hit_rate deve essere sopra il 99% dopo il warm-up iniziale - se è sotto il 95%, i file non stanno in cache e OPcache sta ricalcolando; oom_restarts deve essere 0 - se è maggiore di 0, la memory_consumption è insufficiente e OPcache ha dovuto svuotare la cache per mancanza di memoria; free_memory deve essere almeno il 20% della memoria totale - se è sotto il 10%, sei vicino alla saturazione e dovresti aumentare memory_consumption.

Ho integrato queste metriche nel monitoring che installo su ogni server dei clienti - lo stesso stack di Laravel Pulse per il monitoring applicativo che ho descritto in un articolo dedicato, dove le metriche OPcache si affiancano a quelle di request latency, query lente e job falliti per dare una visione completa della salute dell'applicazione.

L'impatto reale misurato: numeri da tre applicazioni in produzione

I numeri che presento vengono da tre interventi di tuning OPcache su applicazioni in produzione di clienti PMI, misurati con Blackfire prima e dopo la modifica della configurazione, senza nessun altro cambiamento al codice o all'infrastruttura.

La prima applicazione - un gestionale Laravel 10 su VPS Hetzner CPX31 (4 vCPU, 8 GB RAM) - aveva OPcache con la configurazione di default di Debian 12. Il tempo di risposta mediano era 145 ms. Dopo la configurazione ottimizzata (validate_timestamps=0, memory_consumption=256, max_accelerated_files=20000), il tempo mediano è sceso a 78 ms - una riduzione del 46%. Il breakdown del risparmio: 32 ms risparmiati eliminando le syscall stat() (validate_timestamps=0), 18 ms risparmiati evitando ricompilazioni di file che superavano il max_accelerated_files di default, e 17 ms risparmiati dall'ottimizzazione del bytecode con le opzioni di compilazione più aggressive. Il tutto senza toccare una riga di codice PHP, senza aggiungere cache Redis, senza ottimizzare query - solo la configurazione di un'estensione che era già installata e attiva.

La seconda applicazione - un'API Symfony 7 con 45 endpoint su VPS OVH Rise-1 (4 core, 32 GB RAM) - aveva un problema specifico: il max_accelerated_files di default a 10.000 era insufficiente per i 14.200 file PHP dell'applicazione (Symfony + vendor + codice applicativo). OPcache scartava i file meno usati dalla cache per fare spazio, causando ricompilazioni intermittenti che si manifestavano come "spike" di latenza casuali - 3-4 richieste al minuto con tempo di risposta di 400+ ms in un contesto dove la mediana era 90 ms. Aumentando max_accelerated_files a 20.000, gli spike sono scomparsi completamente e il p99 è sceso da 420 ms a 130 ms. Il p50 (mediana) non è cambiato significativamente perché la maggior parte delle richieste usava file che stavano già in cache - ma il p99 è la metrica che gli utenti percepiscono come "il sito è lento a volte", e risolvere il p99 ha un impatto sulla soddisfazione utente sproporzionato rispetto al costo dell'intervento.

La terza applicazione - un e-commerce Laravel su VPS Contabo (6 core, 16 GB RAM) - era il caso peggiore: OPcache era disabilitato. Il tempo di risposta mediano era 380 ms per una pagina catalogo che caricava 200 file PHP tra framework, vendor e template Blade. Abilitare OPcache con la configurazione ottimizzata ha portato il tempo mediano a 95 ms - una riduzione del 75%. Il proprietario del VPS, che stava valutando un upgrade hardware per "risolvere la lentezza", ha risparmiato il costo dell'upgrade (circa 30 euro al mese) semplicemente attivando un'estensione già installata.

Preloading: il livello successivo per le applicazioni Symfony

PHP 8.0 ha introdotto il preloading - un meccanismo che permette di caricare file PHP in memoria all'avvio di PHP-FPM, rendendoli disponibili per tutte le richieste senza nemmeno il lookup nella cache OPcache. Per Symfony, che ha un sistema di class preloading integrato (config/preload.php generato dal framework), il beneficio è misurabile: 3-5% di riduzione della latenza rispetto al solo OPcache, perché elimina l'overhead di ricerca nella hash table per le classi più utilizzate. Per Laravel, il preloading è meno standardizzato - il pacchetto laravel/preload di Spatie genera un file di preload basato sull'analisi dell'uso delle classi in produzione, ma richiede configurazione manuale e testing per verificare che le classi preloaded non causino conflitti di stato.

La regola che applico è: preloading per Symfony (dove è integrato e testato dal framework), OPcache standard per Laravel (dove il preloading aggiunge complessità marginale per un beneficio marginale). L'eccezione è per le applicazioni Laravel ad altissimo traffico (>500 richieste al secondo) dove anche un 3% di miglioramento si traduce in 15 richieste al secondo in più - in quel caso il preloading vale la complessità aggiuntiva. Se le tue applicazioni PHP girano con la configurazione OPcache di default e non hai mai verificato il hit rate e l'utilizzo memoria, contattami per una sessione di tuning: in un'ora configuriamo OPcache in modo ottimale, verifichiamo le metriche, e misuriamo il delta di prestazioni prima e dopo - un intervento che si ripaga immediatamente e produce benefici permanenti.

Ultima modifica: