Salta al contenuto principale

Tre Postmortem di Produzione: Flyway, Deploy JAR e Bean Duplicati

Un commento in una migration Flyway applicata rompe il checksum. Un cp non atomico durante il deploy causa NoClassDefFoundError intermittente. Due bean con lo stesso simple name crashano il boot. Tre incidenti reali, una sola causa radice: l'immutabilità violata.

Cosa ne pensi di questo articolo?

Tre Postmortem di Produzione: Flyway, Deploy JAR e Bean Duplicati
Platform Engineeringfedericocalo.dev

In Breve

Un commento in una migration Flyway applicata rompe il checksum. Un cp non atomico durante il deploy causa NoClassDefFoundError intermittente. Due bean con lo stesso simple name crashano il boot. Tre incidenti reali, una sola causa radice: l'immutabilità violata.

Risposta diretta

Le migration Flyway applicate sono immutabili per contratto, non per suggerimento: cambiare anche un solo commento in un file già applicato in produzione fa fallire il boot con un mismatch di checksum. L'ho rotto io stesso una volta, con una modifica cosmetica. Su 189 migration in produzione, quella è stata la lezione più costosa: un editor di testo può crashare un backend Spring Boot più velocemente di un bug applicativo.

La disciplina sulle migration di database è la parte più noiosa e più sottovalutata dell'ingegneria backend, finché non ti crasha la produzione. Su questo portfolio, 189 migration Flyway (fino a V199) governano lo schema PostgreSQL di un backend con oltre 3.100 classi Java. In tre incidenti di produzione distinti, ognuno riconducibile a una violazione della stessa regola implicita "non toccare mai qualcosa che il sistema considera già scritto e verificato", ho imparato quanto sia stretto il margine tra disciplina noiosa e boot crash in produzione.

Postmortem 1: un commento ha rotto il checksum

Flyway calcola un checksum del contenuto testuale di ogni file di migration già applicato, e lo confronta ad ogni avvio contro il valore salvato nella tabella flyway_schema_history. Se non combaciano, Flyway rifiuta di avviare l'applicazione. Nel 2026-06-20 ho modificato un singolo commento SQL in una migration già applicata (V152), pensando che un commento non potesse avere effetti funzionali, per chiarire un'intenzione poco chiara a distanza di mesi. Il backend non è ripartito: FlywayException: Migration checksum mismatch. Nessuna colonna, nessuna tabella, nessuna logica era cambiata: solo testo dopo un doppio trattino --. Flyway non distingue tra commento e statement, tratta il file come un blob di byte con un checksum, punto.

La regola operativa che applico ora, senza eccezioni: una migration applicata è congelata per sempre, incluso ogni commento. Se serve chiarire un'intenzione, si scrive una nuova migration che documenta il perché nel messaggio di commit o in un commento nella nuova migration, mai modificando la vecchia.

Postmortem 2: la race del deploy JAR

Il deploy manuale del backend segue lo schema git pull + mvn package + systemctl restart. Prima della correzione, lo script copiava il nuovo JAR sopra quello in uso (cp) mentre la JVM precedente era ancora in esecuzione e teneva il file mappato in memoria. Il risultato osservato in produzione: NoClassDefFoundError su classi core di Spring, perché il classloader stava leggendo un file parzialmente sovrascritto a metà di una cp non atomica, mentre il vecchio processo era ancora vivo e serviva traffico. Il sintomo è particolarmente insidioso perché non è un errore di compilazione né un errore applicativo, è un file binario corrotto a runtime che sembra un bug di dipendenze mancanti.

Il fix ha tre parti, e tutte e tre sono necessarie, nessuna da sola basta: fermare il servizio prima di copiare (non dopo), copiare il nuovo JAR con una mv atomica invece di un cp in-place, e resettare lo stato failed di systemd se il precedente restart era andato storto, prima di riavviare. Saltare uno qualunque dei tre passi riproduce la race in modo intermittente, che è il tipo di bug più costoso da diagnosticare perché non si ripresenta ad ogni deploy.

Postmortem 3: due bean, stesso nome semplice, boot crash

Due classi @Service in package Java diversi ma con lo stesso simple name (es. ExportService in due verticali di business separati) fanno esplodere il boot Spring con ConflictingBeanDefinitionException, perché Spring, senza un nome esplicito, deriva il nome del bean dal simple class name, non dal fully qualified name. Il punto davvero scomodo: i test unitari non catturano questo problema, perché nessun singolo test carica l'intero application context con entrambi i verticali insieme, solo un test di integrazione full-context lo rivela, e nella pratica quel test full-context viene eseguito raramente durante lo sviluppo di routine, spesso solo prima del deploy o mai.

Con oltre 25 verticali di business separati aggiunti nel tempo, il rischio di collisione sul simple name cresce combinatoriamente, non linearmente, con il numero di verticali. La regola che ho adottato: ogni nuovo @Service in un vertical business specifico riceve un nome esplicito con prefisso (@Service("phExportService")), non lasciato al default, e prima di ogni deploy gira almeno un test di integrazione full-context.

Tre postmortem di produzione e la regola anti-ripetizione derivata
Incidente Sintomo osservato Regola anti-ripetizione
Modifica commento migration applicata (V152) Boot crash, FlywayException checksum mismatch Migration applicate immutabili, sempre, anche per un commento
Race deploy JAR in-place NoClassDefFoundError intermittente Stop-prima-di-copiare + mv atomica + reset-failed
Bean duplicati stesso simple name ConflictingBeanDefinitionException al boot Nome bean esplicito con prefisso vertical + test full-context pre-deploy
Tre incidenti reali di produzione sul backend Spring Boot 3.4, 189 migration Flyway (max V199), deploy manuale VPS Hetzner.

Il pattern comune: l'immutabilità come confine, non come stile

I tre postmortem sembrano indipendenti, ma condividono la stessa causa radice: qualcosa che il sistema tratta come già scritto, verificato, immutabile, viene modificato o sovrascritto in un momento in cui il sistema non se lo aspetta. Una migration applicata, un binario in esecuzione, un simple name che Spring assume univoco. L'immutabilità non è uno stile di programmazione elegante da seguire quando conviene, è un confine di sistema che, se violato, produce un crash deterministico o una race intermittente, mai un warning gentile.

Quando questa disciplina è overkill

Su un side project a persona singola con un solo ambiente e restart manuale poco frequente, il rischio di race sul deploy JAR è basso e il costo di uno script di deploy sofisticato con stop-prima-di-copiare non si giustifica. Il punto di rottura reale è la produzione con traffico continuo: lì anche una finestra di race di pochi secondi diventa una finestra di errore visibile agli utenti, e la disciplina smette di essere burocrazia e diventa protezione necessaria.


Domande frequenti

Perché modificare un commento in una migration Flyway già applicata rompe il boot?

Flyway calcola un checksum sul contenuto testuale completo di ogni migration già applicata e lo confronta ad ogni avvio con il valore salvato nella tabella flyway_schema_history. Non distingue tra commento e statement SQL: qualunque modifica al testo, anche cosmetica, cambia il checksum e fa fallire l'avvio con un mismatch.

Cosa causa un NoClassDefFoundError durante il deploy di un JAR Spring Boot?

Se il nuovo JAR viene copiato in-place sopra quello in uso mentre la JVM precedente è ancora in esecuzione e ha il file mappato in memoria, il classloader può leggere un file parzialmente sovrascritto a metà di una copia non atomica. Il sintomo appare come una dipendenza mancante, ma la causa reale è un file binario corrotto a runtime durante il deploy.

Come si evita la race condition nel deploy di un JAR Spring Boot?

Fermando il servizio prima di copiare il nuovo JAR (non dopo), usando una mv atomica invece di una cp in-place, e resettando lo stato failed di systemd se un restart precedente era andato storto. Tutti e tre i passi sono necessari insieme; saltarne uno riproduce la race in modo intermittente.

Perché due bean Spring con lo stesso simple name fanno crashare il boot?

Senza un nome esplicito, Spring deriva il nome di un bean @Service dal simple class name, non dal fully qualified name. Due classi in package diversi ma con lo stesso simple name generano una ConflictingBeanDefinitionException al boot, un problema che i test unitari isolati non catturano perché nessun singolo test carica entrambi i verticali nello stesso application context.

Cosa hanno in comune i tre postmortem di produzione descritti qui?

Condividono la stessa causa radice: qualcosa che il sistema tratta come già scritto, verificato o immutabile (una migration applicata, un binario in esecuzione, un simple name assunto univoco) viene modificato o sovrascritto in un momento in cui il sistema non se lo aspetta, producendo un crash deterministico o una race intermittente.

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