Performance testing di API Laravel con k6: load test realistico prima del go-live

Performance testing di API Laravel con k6: load test realistico prima del go-live

A febbraio 2026 un cliente del settore e-commerce mi ha chiesto di validare la capacità della sua API Laravel prima del lancio di un nuovo prodotto - un lancio che avrebbe generato una campagna email verso 80.000 contatti con un redirect alla pagina del prodotto sul portale. L'API Laravel serviva il portale Vue.js con endpoint per catalogo, carrello, checkout e tracking ordini. Il team stimava un picco di 1.000 richieste al secondo nei primi 30 minuti dopo l'invio della campagna. La domanda era semplice: "L'API regge?" La risposta, prima del mio intervento, era: "Non lo sappiamo." Nessuno aveva mai fatto un load test. L'unica informazione disponibile era che "durante il Black Friday il sito era un po' lento" - un dato qualitativo che non dice nulla sulla capacità reale del sistema né su dove si trova il collo di bottiglia.

Ho costruito un load test realistico con k6 - uno strumento di load testing open source scritto in Go che esegue script JavaScript - basato sui log reali di traffico del portale. Non uno stress test generico che bombarda un singolo endpoint con richieste identiche, ma una simulazione che replica il comportamento reale degli utenti: mix di endpoint diversi con percentuali derivate dall'analytics, sessioni con login e navigazione multi-pagina, think time tra le richieste per simulare il tempo di lettura dell'utente, e ramp-up graduale del carico per identificare il punto di saturazione. Il test ha trovato tre colli di bottiglia che sarebbero diventati catastrofici sotto il carico del lancio: un pattern N+1 nell'endpoint del catalogo che generava 400 query per pagina, un lock sul database durante l'aggiornamento delle giacenze nel checkout, e una configurazione di PHP-FPM con max_children insufficiente per il carico previsto. Tutti e tre sono stati corretti prima del lancio, e la campagna email è stata inviata con la certezza - non la speranza - che l'infrastruttura avrebbe retto.

Perché i load test generici sono inutili e come costruire scenari realistici?

Il primo errore che trovo in ogni tentativo di load testing che non è stato progettato da qualcuno con esperienza è lo scenario generico: un singolo endpoint bombardato con richieste identiche per 60 secondi. Questo tipo di test misura la capacità massima di quell'endpoint specifico in condizioni che non si verificano mai nella realtà - perché nel mondo reale gli utenti non chiamano tutti lo stesso endpoint nello stesso istante, ma navigano tra pagine diverse con tempi diversi. Un endpoint catalogo che regge 5.000 richieste al secondo in isolamento può crollare a 200 richieste al secondo quando il 30% del traffico concorrente va all'endpoint del checkout, che tiene un lock sul database per 50 ms per ogni operazione di decremento giacenza.

Lo scenario realistico parte dall'analisi dei log di traffico reale dell'applicazione. Da Google Analytics o dai log di accesso Nginx estraggo quattro informazioni: la distribuzione percentuale del traffico tra gli endpoint (nel caso del portale, il 45% delle richieste era al catalogo, il 25% alla pagina prodotto, il 15% al carrello, il 10% al checkout, il 5% a endpoint vari), il tempo medio tra una richiesta e la successiva per lo stesso utente (think time, circa 8 secondi in media), la durata media di una sessione utente (12 minuti, 8-10 richieste), e il pattern di ramp-up del traffico durante un evento (dal 10% al 100% in 5 minuti per una campagna email).

Nel mio profilo professionale trovi il dettaglio dell'esperienza che porto nel performance testing di applicazioni web - un'area dove la qualità dello scenario di test determina completamente l'utilità dei risultati. Un test con scenario sbagliato produce numeri impressionanti che non corrispondono alla realtà; un test con scenario realistico produce numeri modesti ma affidabili su cui puoi prendere decisioni operative.

Lo script k6: simulare il comportamento reale degli utenti

k6 usa JavaScript come linguaggio di scripting, il che lo rende accessibile a qualsiasi sviluppatore web. Il vantaggio rispetto a JMeter (Java, configurazione XML, interfaccia grafica pesante) è la leggerezza e la versionabilità - lo script k6 è un file .js nel repository, eseguibile dalla CLI con k6 run script.js, e integrabile nella pipeline CI/CD.

Lo script che ho costruito per il portale e-commerce simula una sessione utente completa - dal caricamento del catalogo all'aggiunta al carrello, dal checkout alla conferma dell'ordine:

// load-test/scenario-lancio.js
import http from 'k6/http'
import { check, sleep } from 'k6'
import { Rate, Trend } from 'k6/metrics'

// Metriche custom per il business
const checkoutSuccess = new Rate('checkout_success')
const catalogLatency = new Trend('catalog_latency')

export const options = {
    scenarios: {
        lancio_prodotto: {
            executor: 'ramping-vus',
            startVUs: 0,
            stages: [
                { duration: '2m', target: 100 },   // Ramp-up graduale
                { duration: '5m', target: 500 },   // Picco previsto
                { duration: '3m', target: 1000 },  // Stress test
                { duration: '2m', target: 0 },     // Cool-down
            ],
        },
    },
    thresholds: {
        http_req_duration: ['p95<2000'],  // Il 95% sotto i 2 secondi
        checkout_success: ['rate>0.95'],   // 95% checkout riusciti
        http_req_failed: ['rate<0.01'],    // Meno dell'1% di errori
    },
}

const BASE_URL = __ENV.API_URL || 'https://staging.portale.it'
const AUTH_TOKEN = __ENV.AUTH_TOKEN

export default function () {
    // Simula una sessione utente realistica

    // 1. Carica il catalogo prodotti (45% del traffico)
    const catalog = http.get(`${BASE_URL}/api/prodotti?page=1`, {
        headers: { Authorization: `Bearer ${AUTH_TOKEN}` },
    })
    catalogLatency.add(catalog.timings.duration)
    check(catalog, { 'catalogo OK': (r) => r.status === 200 })
    sleep(Math.random() * 5 + 3) // Think time: 3-8 secondi

    // 2. Visualizza un prodotto specifico (25% del traffico)
    const productId = Math.floor(Math.random() * 500) + 1
    const product = http.get(`${BASE_URL}/api/prodotti/${productId}`)
    check(product, { 'prodotto OK': (r) => r.status === 200 })
    sleep(Math.random() * 10 + 5) // Lettura: 5-15 secondi

    // 3. Aggiungi al carrello (15% del traffico)
    const cart = http.post(`${BASE_URL}/api/carrello`, JSON.stringify({
        prodotto_id: productId,
        quantita: 1,
    }), { headers: { 'Content-Type': 'application/json' } })
    check(cart, { 'carrello OK': (r) => r.status === 200 })
    sleep(Math.random() * 3 + 2)

    // 4. Checkout (10% del traffico - non tutti completano)
    if (Math.random() < 0.3) {
        const checkout = http.post(`${BASE_URL}/api/checkout`, JSON.stringify({
            metodo_pagamento: 'stripe',
            indirizzo_id: 1,
        }), { headers: { 'Content-Type': 'application/json' } })
        checkoutSuccess.add(checkout.status === 200)
    }
}

Il sleep() tra le richieste è fondamentale: senza di esso, k6 bombarda il server con richieste consecutive senza pausa - un pattern che non corrisponde al comportamento degli utenti reali e che produce risultati fuorvianti. Un utente reale impiega 3-15 secondi tra un click e l'altro per leggere il contenuto della pagina. Senza think time, 100 utenti virtuali generano 1.000+ richieste al secondo; con think time realistico, 100 utenti generano 15-20 richieste al secondo - una differenza di 50x che cambia completamente il significato del test.

Analisi dei risultati: dove guardare e cosa significano i numeri

L'output di k6 produce metriche aggregate - ma non tutte le metriche sono ugualmente importanti per le decisioni operative. Le tre metriche che guardo per prime sono: http_req_duration p95 (il tempo di risposta al 95° percentile - se è sotto i 2 secondi, il 95% degli utenti ha un'esperienza accettabile), http_req_failed rate (la percentuale di richieste fallite - se è sopra l'1%, il server sta saturando), e vus_max (il numero massimo di utenti virtuali simultanei al momento in cui le metriche iniziano a degradare - questo è il punto di saturazione del sistema).

Nel test del portale e-commerce, il punto di saturazione è stato raggiunto a 320 utenti virtuali simultanei: la latenza p95 è salita da 180 ms a 3.400 ms, il tasso di errore è passato dallo 0,1% al 12%, e il throughput si è appiattito a 280 richieste al secondo invece di continuare a salire linearmente. Il target era 1.000 richieste al secondo - il sistema reggeva un quarto del carico previsto. Senza il load test, questo si sarebbe scoperto durante il lancio, con 80.000 email già inviate e migliaia di utenti su un sito che restituisce errori 500.

Il profiling post-test (Blackfire + slow query log MySQL) ha identificato i tre colli di bottiglia descritti nell'apertura: il pattern N+1 nel catalogo (risolto con eager loading, da 400 query a 3 query per pagina), il lock sulle giacenze nel checkout (risolto con SELECT ... FOR UPDATE mirato sulla singola riga invece che sull'intera tabella), e la configurazione FPM (aumentata da 50 a 200 worker, come ho descritto nel mio articolo sul tuning di PHP-FPM per carichi elevati). Dopo le correzioni, il re-test ha mostrato 1.200 richieste al secondo con latenza p95 di 450 ms - il 20% sopra il target, con margine di sicurezza.

I dati di test: non usare produzione, non usare dati vuoti

Un errore che ho visto in ogni primo tentativo di load testing è l'uso di un database vuoto o con 10 record per il test. Le prestazioni di un'applicazione con 10 prodotti nel catalogo e 5 ordini nel database non hanno nessuna correlazione con le prestazioni della stessa applicazione con 50.000 prodotti e 200.000 ordini - le query che sono istantanee su 10 record possono diventare secondi su 200.000 record quando manca un indice o quando il working set non sta nel buffer pool di InnoDB. Il load test deve essere eseguito su un database che replica le dimensioni e la distribuzione dei dati di produzione.

La soluzione che uso è un dump anonimizzato del database di produzione: esporto lo schema e i dati, sostituisco i dati personali (nomi, email, indirizzi, numeri di telefono) con dati generati da Faker, e importo il dump nell'ambiente di staging prima del test. Lo script di anonimizzazione è un comando Artisan dedicato che identifica le colonne contenenti PII (basandosi su una lista configurabile di nomi di colonna: email, phone, name, address, tax_id) e le sostituisce con valori fittizi mantenendo il formato e la distribuzione originale. Questo approccio garantisce che il test sia realistico in termini di volume e distribuzione dei dati, conforme al GDPR perché nessun dato personale reale è presente nell'ambiente di staging, e ripetibile perché lo script di anonimizzazione può essere eseguito automaticamente prima di ogni ciclo di test.

Un ultimo aspetto che influisce significativamente sui risultati è la configurazione dell'ambiente di staging: il server di staging deve avere le stesse specifiche hardware del server di produzione. Se la produzione gira su un Hetzner AX42 con 8 core e 32 GB RAM e lo staging gira su un CPX11 con 2 core e 2 GB RAM, i risultati del load test sono completamente inapplicabili alla produzione. Per i clienti dove il costo di un server di staging identico alla produzione non è giustificabile full-time, uso server Hetzner Cloud con fatturazione oraria: creo il server la mattina del test, eseguo il load test, raccolgo i risultati, e distruggo il server alla fine della giornata - costo totale: 2-3 euro per un server equivalente alla produzione per 8 ore.

Integrare k6 nella pipeline CI/CD: test di performance automatici

Il load test non deve essere un evento una tantum prima del go-live - deve essere un gate nella pipeline CI/CD che verifica ad ogni release che le prestazioni non siano regredite. k6 si integra con GitHub Actions con un'azione dedicata che esegue lo script contro l'ambiente di staging e fallisce se le threshold non sono rispettate. Il costo operativo è minimo: lo script è nel repository, l'esecuzione su staging dura 5-10 minuti, e il feedback è automatico. Se qualcuno introduce un pattern N+1 in un nuovo endpoint, il load test della prossima release lo cattura prima che arrivi in produzione.

Il lancio del prodotto è avvenuto il 15 marzo 2026. La campagna email è stata inviata a 80.000 contatti alle 10:00 del mattino. Il picco di traffico ha raggiunto 820 richieste al secondo alle 10:12 - sotto il target testato di 1.200. La latenza p95 durante il picco è stata di 380 ms. Zero errori 500. Zero downtime. Il titolare ha ricevuto la conferma di 1.200 ordini nelle prime due ore. Senza il load test, quella mattina sarebbe stata un disastro - non per incapacità del team, ma per mancanza di dati sulla capacità reale del sistema. Se hai un'API Laravel che deve reggere un carico previsto e non hai mai fatto un load test realistico, contattami per costruire lo scenario: in una giornata analizziamo il traffico reale, scriviamo lo script k6 con scenari calibrati, eseguiamo il test, e identifichiamo i colli di bottiglia da correggere prima dell'evento.

Ultima modifica: