Salta al contenuto principale

03 - Quattro Postmortem di Produzione: Perché i Test Verdi Non Bastano

Quattro incidenti di produzione partiti da build verdi: un commento Flyway, un gate ArchUnit morto, una race sul JAR e un bean duplicato.

Cosa ne pensi di questo articolo?

03 - Quattro Postmortem di Produzione: Perché i Test Verdi Non Bastano
Original Researchfedericocalo.dev

In Breve

Quattro incidenti di produzione partiti da build verdi: un commento Flyway, un gate ArchUnit morto, una race sul JAR e un bean duplicato.

Risposta diretta

Quattro incidenti di produzione reali, ognuno partito da una build completamente verde: un crash Flyway da un commento rimosso, un gate ArchUnit che eseguiva zero test da mesi, una race condition nel deploy del JAR e un bean Spring duplicato. Codice, spiegazione e regola anti-ripetizione per ognuno sono in un repository pubblico riproducibile.

"I test sono verdi" non significa "il deploy è sicuro". È una frase che ripeto da anni ma che ho continuato a violare nei fatti, fino a quando quattro incidenti di produzione distinti - separati per settimane, con cause completamente diverse - mi hanno costretto a prenderla sul serio. In tutti e quattro i casi la build era verde. In tutti e quattro il problema era invisibile a qualunque suite di unit test, perché viveva esattamente nel confine tra "il codice compila ed è testato in isolamento" e "il sistema si avvia davvero in produzione".

Quattro crash, tutti partiti da una build verde

Non sono incidenti immaginari o costruiti per fare bella figura in un articolo. Sono quattro postmortem reali di un backend Spring Boot in produzione, ognuno con il proprio file di dettaglio nel repository collegato: sintomo, causa radice, perché i test non l'hanno intercettato, il fix e la regola per non ripeterlo.

  • Flyway checksum mismatch: rimuovere un singolo commento da una migration già applicata ha fatto crashare il boot in produzione.
  • Gate ArchUnit silenziosamente disattivato: un test di architettura scritto per proteggere un confine di sicurezza eseguiva zero test da mesi, riportando comunque "build success".
  • Race condition nel deploy del JAR: una sostituzione in-place del file eseguibile sotto una JVM ancora in avvio ha corrotto il class loading.
  • Bean Spring duplicato: due classi con lo stesso nome semplice in moduli diversi hanno fatto collassare il boot con ConflictingBeanDefinitionException.

I quattro casi toccano livelli diversi del sistema (il database, i test, il deploy, l'avvio del contesto Spring) e l'ordine dell'elenco non è una graduatoria di gravità: ciò che li accomuna è che il segnale verde misurava una cosa diversa da quella che poi si è rotta, come mostra la sezione sul confine dell'immutabilità più sotto.

Incidente 1: il commento che ha rotto Flyway

Flyway calcola il checksum sull'intero contenuto risolto di un file di migration, non solo sulle istruzioni SQL eseguibili. Una pulizia di codice, apparentemente innocua, aveva rimosso una riga di commento obsoleta da una migration già applicata al database di produzione. Zero righe di SQL toccate. Al boot successivo, Flyway ha confrontato il checksum registrato nella tabella di schema-history con quello del file su disco: non coincidevano più, e Flyway ha rifiutato di procedere, facendo fallire l'inizializzazione del contesto Spring.

Nessuna suite di unit test tocca mai una tabella di schema-history reale: il problema era invisibile per costruzione a qualunque test che non avviasse un contesto Spring completo contro un database reale.

Incidente 2: il gate ArchUnit che non girava mai

Un test ArchUnit scritto con il pattern @AnalyzeClasses + @ArchTest a campo statico dipende da un motore di test JUnit Platform separato (archunit-junit5) perché venga effettivamente scoperto ed eseguito. Con una certa combinazione di versioni di Surefire e JUnit Platform, quel motore non veniva incluso nell'insieme di motori eseguiti da Surefire. Il test riportava BUILD SUCCESS e "0 violazioni" - ma il report reale diceva Tests run: 0. Il gate pensato per proteggere un confine di sicurezza tra layer applicativi non aveva mai eseguito una singola asserzione, su nessun commit, da quando era stato introdotto.

Questo è il caso più scomodo dei quattro: non è che i test "non hanno intercettato" il problema nel senso classico, è che il problema ERA il test stesso. Una dashboard CI verde non induce quasi mai a controllare manualmente quanti test siano effettivamente girati per una classe specifica.

Incidente 3: la race condition nel deploy del JAR

Lo script di deploy installava il nuovo artefatto con una copia in-place sopra il JAR esistente, seguita da un riavvio del servizio. Se un processo precedente era ancora in fase di avvio in quel preciso istante (per esempio un riavvio automatico dopo un health-check fallito poco prima), la copia sovrascriveva i byte del JAR sul disco MENTRE quella JVM stava ancora caricando classi in modo lazy da quel file. Il risultato: class loading corrotto, con errori NoClassDefFoundError su classi del framework stesso (non del codice applicativo) - un segnale diagnostico forte che il file JAR era cambiato sotto un processo vivo, non che il build fosse rotto.

Incidente 4: due classi con lo stesso nome, un bean in conflitto

Spring deriva l'id di default di un bean dal nome semplice della classe, decapitalizzato, non dal nome completamente qualificato. Due moduli sviluppati in modo indipendente definivano ciascuno una classe annotata con lo stesso nome semplice in package diversi. Il component-scan a startup ha trovato due bean che volevano lo stesso id di default, e Spring si è rifiutato di registrarli entrambi: ConflictingBeanDefinitionException a boot, servizio giù. Nessun test unitario lo avrebbe mai intercettato, perché i test unitari istanziano la classe direttamente e non caricano mai un contesto Spring completo.

Cosa hanno in comune: il confine dell'immutabilità

Guardati insieme, i quattro incidenti condividono la stessa causa radice strutturale, anche se il sintomo è diverso ogni volta: un confine di immutabilità - un file di migration già applicato, un artefatto binario servito da un processo vivo, un bean id univoco per contratto implicito - è stato violato senza che nessun meccanismo lo impedisse meccanicamente. In ogni caso il fix definitivo non è stato "fai più attenzione", ma un vincolo strutturale: mai editare una migration applicata, sostituire un artefatto solo con stop più rename atomico, dare un nome di bean esplicito e prefissato per modulo quando la collisione è plausibile.

Per la versione di questi incidenti letta come lavoro di una piattaforma (quale guardrail sostituisce quale regola, e quanto costa) vedi Tre postmortem di produzione. Sul gate ArchUnit morto e sulla baseline congelata che lo ha sostituito, vedi ArchUnit e Clean Architecture.

Il gate di architettura vale il costo di setup? Un trade-off onesto

L'incidente 2 solleva una domanda scomoda che vale la pena affrontare di petto, invece di limitarsi a raccontare il fix: un gate di architettura tipo ArchUnit vale davvero il costo di manutenzione per ogni team? La mia risposta, dopo aver vissuto un gate silenziosamente morto per mesi, è: dipende dalla dimensione del codice, non dalle buone intenzioni.

Su una codebase piccola, mantenuta da una sola persona, con meno di qualche decina di classi per layer, un gate di architettura automatico aggiunge più costo di manutenzione (versioni da allineare, un motore di test in più da verificare che sia davvero eseguito) di quanto tolga in protezione: la disciplina personale basta, ed è più economica. Superata quella soglia - più contributor, più moduli, un confine che qualcuno DEVE poter violare per errore senza accorgersene - il gate diventa indispensabile, ma con una condizione non negoziabile: va verificato che esegua davvero test, non solo che riporti "build success". Un gate che riporta zero violazioni perché non gira mai è strettamente peggiore di nessun gate, perché genera una falsa sicurezza attiva invece di un'assenza di sicurezza dichiarata.

Riepilogo dei quattro incidenti di produzione, causa e tipo di fix
Incidente Confine violato Rilevato da unit test?
Flyway checksum Immutabilità migration applicata No
Gate ArchUnit morto Esecuzione reale del test stesso No (il test era il gap)
Race deploy JAR Atomicità sostituzione artefatto No
Bean Spring duplicato Univocità id bean tra moduli No (serve boot completo)
4 incidenti reali, 4 confini di immutabilità/esecuzione violati, 0 intercettati da unit test isolati. Dettagli completi nel repository collegato.

Addendum di ottobre 2026: sei incidenti non di boot, ma di processo

I quattro incidenti sopra sono nati dentro il backend. Nelle settimane successive lo stesso schema è riapparso fuori dal codice applicativo: negli script, nella configurazione del reverse proxy, nel routing del server SSR e nel lavoro di agenti automatici sul repository. Il segnale era sempre verde (exit 0, pagina che risponde 200, build riuscita) e il segnale non misurava la cosa che contava. Li aggiungo qui, datati, perché l'articolo originale sosteneva che i test unitari non bastano; questi sei dicono che non basta nemmeno il codice di uscita di un comando.

1. Lo script che cancellava i file quando la validazione falliva

Lo script di traduzione degli articoli, quando la validazione di una traduzione falliva, eseguiva f.unlink() sui file en.html e en.json dell'articolo, e stampava «validation fail, file rimosso». Ha cancellato 238 file in due riprese e ha prodotto, nello stesso corpus, 225 articoli inglesi corrotti su 1419 (il 15,9%), con il boilerplate del modello e segnaposto XPHX<n>XPHX al posto del testo. I file sono tornati perché erano tracciati in git: non per un merito del progetto dello script. Il difetto di progetto è un default distruttivo: un controllo che fallisce deve mettere in quarantena, non eliminare. La regola ora in vigore è «non si usa, nemmeno per riparare il danno che ha prodotto».

2. Il processo che è sopravvissuto a chi l'aveva lanciato

La prima volta la causa è stata attribuita a un agente che aveva seguito la propria definizione invece del brief. Era una spiegazione plausibile, ed era sbagliata: alla seconda volta si è visto che le cancellazioni venivano dallo script di cui sopra, lanciato in background e rimasto in vita dopo la fine dell'agente. SIGTERM non lo fermava: serviva SIGKILL. Qui la lezione è sul metodo di diagnosi: una causa che si adatta ai fatti non è una causa dimostrata, e solo il secondo episodio l'ha smentita. La regola operativa è un controllo di due righe dopo ogni giro di lavoro automatico: cercare processi superstiti (ps -eo pid,cmd | grep translate-articles) e contare i file cancellati nel working tree (git status --short | grep -cE '^( D|D )').

3. Il gate di conformità PDF che non poteva fallire

Un controllo di conformità sui PDF si fidava del codice di uscita di gs. Ghostscript esce con 0 anche quando fallisce l'operazione richiesta, quindi il gate era cieco allo stesso modo del test ArchUnit dell'incidente 2: riportava successo senza aver verificato nulla. Un gate si verifica facendogli fallire apposta un input noto e controllando che segnali; il controllo giusto legge l'output o il file prodotto, non lo stato di uscita.

4. nginx: add_header aggiunge, non sostituisce

curl -I sulla favicon restituiva due intestazioni Cache-Control in conflitto: quella dell'origine (86400 secondi) e quella impostata dalla location (30 giorni). Per RFC 9111 due direttive contraddittorie sono una risposta malformata e il destinatario può usare la prima, la seconda o nessuna. Il rimedio è proxy_hide_header Cache-Control; nella location che usa add_header davanti a un proxy_pass. Costo nascosto: la location ha proxy_cache_valid 200 30d, quindi senza una purge della cache il vecchio comportamento resta servito un mese. Il difetto è emerso indagando un altro problema, l'icona generica nei risultati di ricerca, che aveva una causa diversa e precedente.

5. app.use con un path fa match di prefisso

Un handler montato su /blog/:slug reclamava anche /blog/series/<x>, assegnando slug = 'series' e rispondendo 404: sono rimaste fuori tutte le 167 pagine delle serie. Nessun test sulla singola rotta poteva vederlo, perché la rotta funzionava per ciò che doveva servire. Regola: un guard montato su un path parametrico deve verificare che lo slug sia l'ultimo segmento del percorso.

6. Il «non trovato» con HTTP 200

Le pagine di dettaglio dei dataset catturavano ogni errore del caricamento dell'anteprima con .catch(() => null) e mostravano una tabella vuota con stato 200. Dietro c'erano tre difetti sovrapposti: i dati non caricati, una mappa slug-tabella ferma a un catalogo vecchio (3 dataset risolti su 41 pubblicati) e l'errore ingoiato. Per un motore di ricerca una pagina vuota con 200 è peggio di un 404: è contenuto indicizzabile e duplicato. La correzione separa gli stati: 404 diventa «non pubblicato», errori di rete, 5xx, 429 e timeout diventano «errore» con un tasto Riprova, e un 200 con colonne e righe vuote conta come «non pubblicato».

Altri due, dal backend: la stessa classe, a luglio

Altri due incidenti dello stesso tipo, nel rapporto fra backend e ml-service, sono raccontati nella serie sull'architettura a servizi, nell'articolo sui pattern di comunicazione: un circuit breaker condiviso fra circa trenta endpoint e un timeout configurato ma mai applicato.

Segnale verde e cosa non misurava, per i sei incidenti di processo di ottobre 2026
Incidente Segnale che sembrava buono Cosa non misurava
Script che cancellaMessaggio di fallimento controllato e gestitoChe la gestione fosse distruttiva
Processo superstiteAgente terminatoChe il figlio fosse ancora vivo
Gate PDFgs esce con 0L'esito dell'operazione
nginx add_headerLa location risponde con l'header giustoL'header dell'origine, ancora presente
app.use su prefissoLa rotta singola funzionaLe rotte più profonde catturate
Soft-404HTTP 200Che il contenuto esistesse

Il costo di queste regole. Il controllo dopo ogni giro di agenti richiede qualche secondo; una verifica che fa fallire apposta un gate richiede un caso di prova per ogni gate; la distinzione degli stati di una pagina costa un componente in più e una suite di test. È il prezzo giusto quando un errore silenzioso può cancellare 238 file o rendere non indicizzabili 41 pagine, ed è un lusso quando lo strumento è usa-e-getta e girato una volta sola. Il discrimine è lo stesso della sezione sul gate di architettura: dove un errore non lascia traccia, il controllo deve lasciarla lui.

Cosa cambierei oggi, con il senno di poi

Se dovessi ripartire da zero, l'unica cosa che cambierei per tutti e quattro gli incidenti insieme è la stessa: aggiungere, per ogni meccanismo che dipende da uno stato invisibile al compilatore (uno schema-history, un file servito da un processo vivo, un component-scan a runtime), un controllo esplicito eseguito almeno una volta prima del deploy, non solo "il codice compila ed è testato in isolamento". Non è un consiglio generico: è la lezione specifica, ripetuta quattro volte con sintomi diversi, che il compilatore e i test unitari proteggono un sottoinsieme più piccolo del sistema di quanto sembri.


Nella serie «Original Research», precedente: 02 - GEO Retrofit Benchmark; successivo: 04 - INP e Core Web Vitals: da 59 a 88.

Domande frequenti

Perché rimuovere un commento da una migration Flyway ha fatto crashare la produzione?

Flyway calcola il checksum sull'intero contenuto risolto del file di migration, commenti inclusi, non solo sulle istruzioni SQL eseguibili. Rimuovere anche un solo commento da una migration già applicata cambia il checksum locale, che a boot non coincide più con quello registrato nella tabella di schema-history, e Flyway rifiuta di avviarsi.

Come fa un test ArchUnit a riportare "build success" senza eseguire alcun test?

Il pattern @AnalyzeClasses + @ArchTest a campo statico dipende dal motore di test separato archunit-junit5 per essere scoperto ed eseguito. Con certe combinazioni di versioni di Surefire e JUnit Platform, quel motore non viene incluso tra quelli eseguiti: il report finale mostra "0 violazioni" perché in realtà mostra "0 test eseguiti", non perché il codice sia conforme.

Perché una copia in-place del JAR durante il deploy ha corrotto l'avvio della JVM?

Una JVM carica le classi in modo lazy, non tutte insieme all'avvio. Se il file JAR viene sovrascritto in-place mentre un processo precedente sta ancora leggendo quel file per caricare classi, quel processo può finire per leggere un file parzialmente riscritto: alcuni byte dalla vecchia versione, altri dalla nuova, corrompendo il class loading anche su classi interne del framework stesso.

Perché due classi con lo stesso nome in moduli diversi fanno fallire il boot di Spring?

Spring genera l'id di default di un bean dal nome semplice della classe, decapitalizzato, indipendentemente dal package. Se due classi annotate in moduli diversi condividono lo stesso nome semplice, il component-scan a runtime le fa competere per lo stesso id di bean, e Spring rifiuta di registrarle entrambe, lanciando un'eccezione di definizione in conflitto che blocca l'avvio del contesto.

Un gate di architettura come ArchUnit vale sempre il costo di setup?

No. Su una codebase piccola, mantenuta da una persona sola, il costo di manutenzione del gate (versioni da allineare, verifica che esegua davvero test) può superare il beneficio rispetto alla semplice disciplina personale. Diventa indispensabile quando cresce il numero di contributor o moduli, ma solo a condizione di verificare che esegua davvero test (Tests run: N con N maggiore di zero), non solo che riporti "build success".

Fonti e approfondimenti

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti