LaraPlugins, audit delle performance: 12 fix che hanno salvato il mio server
Il mio VPS Hetzner da 8 GB stava al 90%+ di utilizzo della memoria, tutto il giorno, ogni giorno. Gli alert di New Relic non smettevano di arrivarmi. Il rumore era tale che avevo iniziato a ignorarli, ed è esattamente in quel momento che i problemi veri ti sfuggono.
LaraPlugins.io indicizza oltre 76.000 pacchetti Laravel. Esegue health check, sincronizza dati da GitHub, importa le versioni da Packagist e serve una directory pubblica. Dietro le quinte, i worker di Horizon macinano job 24/7. Qualche settimana fa, il server riusciva a malapena a tenere il passo.
Ho passato una settimana a scavare nei colli di bottiglia. Ecco i 12 interventi che hanno fatto davvero la differenza, con i numeri reali.
Il punto di partenza
Dodici supervisor Horizon, ognuno con elaborazione dei job illimitata. Worker da 500 MB ciascuno. Un supervisor maintenance monolitico che girava per due ore di fila. E durante il ricalcolo settimanale della salute dei plugin, un singolo tipo di job generava 50.000 job di purge della cache Cloudflare in un solo ciclo.
Il server aveva 8 GB di RAM. Non erano abbastanza.
Fix 1: Dividere il monolite maintenance
Il supervisor maintenance era un unico worker che processava tutto: sync da GitHub, calcoli di salute, analytics e attività in background. Girava con un timeout di 7.200 secondi. Se qualcosa si bloccava, si bloccava tutto.
L'ho diviso in supervisor dedicati: github-sync (2 worker, timeout 300s, 400 MB) e health (2 worker, timeout 600s, 500 MB). Ognuno fa una cosa sola. Se l'API di GitHub mette in rate limit il worker di sync, il worker health continua a girare.
Il vecchio supervisor analytics è diventato background, unito ai task a bassa priorità, e sceso da 250 MB a 128 MB. Nomi generici come “low” e “analytics” sono diventati nomi che descrivono cosa fa davvero la coda. Il debugging è più veloce quando qualcosa si rompe alle 3 di notte.
Fix 2: Limiti di memoria ovunque
Ogni worker di Horizon aveva maxJobs: 0, cioè illimitato. Un worker poteva processare job all'infinito, perdendo memoria lentamente finché l'OOM killer non interveniva.
Ho impostato maxJobs: 100 su tutti i supervisor. Dopo 100 job, il worker si riavvia. La memoria torna al sistema operativo. Anche i default sono scesi: 500 MB sono diventati 256 MB sulla coda default, 384 MB su packagist-high e 128 MB su background. Nel complesso, il tetto di memoria è calato di circa 2 GB su tutti i worker.
La finestra di retention dei job falliti è passata da 10.080 minuti (7 giorni) a 7.080 (5 giorni). L'intervallo degli snapshot di queue trim di Horizon è schizzato da 12 a 2.016 minuti. Sono piccole modifiche di configurazione che, sommate, riducono il ricambio di memoria su Redis.
Fix 3: Il problema dei 50.000 job
Ogni volta che i dati di un plugin venivano aggiornati, partiva un job per fare il purge della sua cache Cloudflare. Durante il ricalcolo settimanale della salute, con tutti i 56.000 plugin in refresh, oltre 50.000 job InvalidatePluginCloudflareCache finivano in coda.
Sono 50.000 push individuali su Redis, 50.000 prelievi da parte dei worker, 50.000 chiamate all'API di Cloudflare distribuite lungo la coda. La coda si intasava. I worker spendevano cicli a dispatchare e prelevare job di purge invece di fare i calcoli di salute veri e propri.
Il fix: un SADD su Redis raccoglie i path da purgare in un set. Un unico FlushPendingCloudflarePurgesJob preleva 30 URL alla volta, li invia all'endpoint di bulk purge di Cloudflare e si riprogramma da solo se ne restano altri. Massimo 10 batch per ciclo, ovvero 300 URL per flush. Invece di 50.000 job, è un solo job che richiama se stesso. Il set Redis funge anche da deduplicatore naturale: se lo stesso path di un plugin viene marcato per il purge due volte, SADD lo memorizza una sola volta.
Fix 4: La colonna che nessuno legge
La tabella plugins ha una colonna packagist_metadata. È un blob JSON che contiene i metadati di ogni versione: requisiti, dipendenze, descrizioni. È grande. Ed era caricata su ogni singola query al modello Plugin, anche se i job di sync non la leggevano mai. La scrivevano soltanto.
Ho aggiunto uno scope globale WithoutPackagistMetadataScope al modello Plugin. Ora ogni query esclude quella colonna per default. I job che hanno bisogno dei metadati la caricano esplicitamente con withoutGlobalScope(). Il payload delle query per le operazioni di lista è calato in modo significativo. MySQL passava meno tempo a spostare blob JSON sulla rete.
Fix 5: Indici che sarebbero dovuti esserci
Tre indici mancanti: le colonne delle versioni supportate in plugin_versions (usate in ogni controllo di compatibilità), failed_jobs.queue (la dashboard di Horizon la interroga di continuo) e il percorso di query della pagina di dettaglio del vendor. Non erano indici composti esotici. Erano banali indici su singola colonna che semplicemente non avevo aggiunto quando ho creato le tabelle.
La pagina di dettaglio del vendor ha ricevuto anche una passata di selezione delle colonne: invece di SELECT *, ora seleziona solo le colonne che effettivamente renderizza. Cambiamento piccolo, ma la pagina è passata da visibilmente lenta a istantanea.
Fix 6: Cache più lunga
Le query aggregate (conteggi di plugin per tag, distribuzioni degli health score, statistiche sulle versioni PHP) erano in cache per una finestra troppo breve. Ho alzato i TTL a 6 ore e 24 ore a seconda della query. Questi numeri non cambiano tra un deploy e l'altro. Non c'è motivo di ricalcolarli ogni 30 minuti.
Fix 7: Spostare le analytics MCP fuori dal percorso critico
LaraPlugins espone un server MCP per lo strumento di ricerca dei plugin. Ogni richiesta registrava eventi analytics anonimizzati in modo sincrono prima di restituire la risposta. Una scrittura di evento significava una risposta MCP lenta. Ho spostato il logging su un job in coda nella coda background. Le risposte MCP sono rimaste veloci a prescindere dal volume di log.
Fix 8: Health check che non controllavano nulla
Diversi job di health check giravano a frequenze che non corrispondevano alla freschezza dei dati che proteggevano. Alcuni erano controlli ridondanti che un altro job eseguiva già. Ho ridotto la schedulazione per adeguarla alle reali esigenze di aggiornamento e rimosso i duplicati.
Fix 9: Tuning di MySQL
Ho aumentato sort_buffer_size nella config MySQL di Docker Compose. LaraPlugins esegue query pesanti in termini di ordinamento sulle liste di plugin (per download, per stelle, per health score). Il sort buffer di default era troppo piccolo per la dimensione del dataset, causando ordinamenti su disco che rallentavano le pagine di elenco.
Fix 10: Riduzione della retention di Horizon
Horizon conserva la cronologia dei job in Redis. La retention dei job completati era a 60 minuti, l'intervallo degli snapshot di queue trim a 12 minuti. Vanno bene per una piccola applicazione. Per LaraPlugins, con migliaia di job all'ora, la memoria di Redis stava finendo nello storage della cronologia job. Ho portato il queue trim a 2.016 minuti e ridotto la retention dei job falliti a 5 giorni. La memoria di Redis si è stabilizzata.
Fix 11: Recupero dal rate limit
L'API di GitHub applica rate limit in modo aggressivo. Quando il worker di sync raggiungeva il limite, andava in crash e Horizon lo ritentava, a volte prima che la finestra del rate limit si resettasse. Ho aggiunto il rilevamento esplicito del rate limit con un backoff adeguato: quando GitHub restituisce un 429 o un header retry-after, il job si rilascia di nuovo in coda con un ritardo pari alla finestra di reset. Basta girare a vuoto contro un muro di rate limit.
Fix 12: Silenziare Nightwatch
Laravel Nightwatch stava ingerendo ogni job in coda, ogni richiesta, ogni log nel suo agent. Per un'applicazione piena di job come LaraPlugins, significava un volume enorme di dati di osservabilità, per la maggior parte rumore. Ho ridotto l'ingestion degli eventi a solo ciò che guardo davvero. Il container dell'agent ha smesso di essere un salasso di risorse.
I risultati
Il VPS da 8 GB è sceso dal 90%+ di utilizzo della memoria con alert continui a un range stabile tra il 65 e l'80%. New Relic è diventato silenzioso. I tempi di risposta sono rimasti piatti, sotto i 100 ms di media. Nessuna regressione.
La coda di Horizon si è ridotta da 12 supervisor che facevano cose vaghe a 9 supervisor che fanno cose specifiche. I memory leak dovuti all'elaborazione illimitata dei job si sono fermati. Il picco di 50.000 job di purge Cloudflare è diventato un unico job che si riprogramma da solo.
E posso dormire senza che il telefono ronzi per un server che “probabilmente va bene”.
Cosa farei diversamente
La maggior parte di questi fix non era particolarmente ingegnosa. Era pulizia. Indici che mi ero dimenticato di aggiungere. Una colonna che nessuno leggeva, caricata su ogni query. Un TTL di cache troppo corto senza un buon motivo. Nomi di code che rendevano il debugging più difficile.
Se ricominciassi LaraPlugins da zero, aggiungerei maxJobs a ogni supervisor di Horizon dal primo giorno. Userei di default gli scope globali per le colonne JSON di grandi dimensioni sulle tabelle a lettura intensiva. E raggrupperei le chiamate alle API esterne per default, invece di dispatchare un job per chiamata. Non sono decisioni architetturali. Sono abitudini.
La lezione più grande: quando il tuo monitoring diventa rumoroso, hai esattamente due scelte. Risolvere la causa alla radice o tarare gli alert. Ogni giorno in cui tari gli alert senza risolvere la causa, ti stai allenando a ignorare la tua stessa dashboard.
Altri articoli recenti
Laravel AI 1.1 e 1.2: Agent Skills, Cohere e structured output più solidi
Le release 1.1.0 e 1.2.0 di laravel/ai portano gli Agent Skills, l'approvazione obbligatoria per i tool built-in e nuovi provider come Cohere e Laya self-hosted. Sul fronte qualità arrivano i fix allo structured output per Bedrock e il supporto alle provider options. Aggiornati anche i modelli di default, con Claude 4.7+ che ignora i sampling params.
Laravel 13.35.0: scheduler più controllabile, code più flessibili e response Markdown
Laravel 13.35.0 porta Schedule::alwaysOnOneServer(), il supporto a schedule:interrupt per i comandi di lunga durata, --memory in percentuale per queue:work e il nuovo metodo markdown() sulla response factory. Tante piccole migliorie anche a test, view compilate in parallelo e dischi local.
BashBox: un interprete Bash in sandbox per agenti AI in PHP
BashBox è un interprete bash scritto in puro PHP che permette di eseguire gli script generati dagli agenti AI senza mai ricorrere a proc_open, exec o shell_exec. Ogni comando è una classe PHP che lavora su un filesystem virtuale, con limiti di esecuzione, quote disco e accesso di rete configurabili. Ideale per chi sviluppa agenti con il Laravel AI SDK o altre librerie simili.