Zitadel in alta disponibilità: due nodi e upgrade a zero downtime
Un binario stateless si scala orizzontalmente banalmente: ne accendi due, li metti dietro un bilanciatore, e siccome nessuno dei due tiene stato in memoria non ti servono sessioni appiccicose né una cache condivisa. Con Zitadel questa parte è davvero facile, ed è il motivo per cui l'ho scelto per un impianto che doveva reggere il fermo di una macchina senza che nessuno se ne accorgesse.
La parte che non è facile è l'aggiornamento. Perché esiste una fase, setup, che applica migrazioni allo schema del database e alle proiezioni, e quella fase non è simmetrica fra i nodi: va eseguita da uno solo, in un momento preciso, e ciò che succede prima e dopo determina se durante l'aggiornamento il servizio resta in piedi o se hai una finestra di errori 502 che i tuoi utenti chiameranno disservizio.
Nei quattro articoli precedenti ho spiegato il modello di identità e autorizzazioni di Zitadel, perché l'ho preferito a Keycloak, cosa cambia rispetto ad Auth0 e Okta e come si installa nativamente su Debian. Qui la macchina diventa due e l'installazione diventa una procedura ripetibile.
Anche qui la premessa di onestà è d'obbligo: per l'alta disponibilità Zitadel raccomanda Kubernetes con il proprio chart. Quello che descrivo è due nodi con unità di sistema dietro un bilanciatore, che è una topologia che il progetto non documenta più. La faccio perché due macchine sono la scala reale di moltissime PMI e perché tirare su un cluster Kubernetes per reggere due istanze di un binario è un costo di complessità che non si ripaga. Se hai già Kubernetes in casa, usa il chart e salta questo articolo.
Perché due nodi identici non si aggiornano allo stesso modo?
Perché una delle tre fasi tocca uno stato condiviso. init si esegue una volta nella vita del sistema e crea database e utenti. start è l'esercizio, ed è quello che l'unità di sistema esegue su entrambi i nodi. In mezzo c'è setup, che applica le migrazioni: è idempotente, quindi rieseguirlo non fa danni, ma se due nodi lo lanciano nello stesso istante lavorano sullo stesso schema contemporaneamente, ed è una condizione che non vuoi verificare in produzione per scoprire come va a finire.
Da qui discende tutto il resto. I due nodi non hanno ruoli diversi durante l'esercizio, sono intercambiabili e servono le stesse richieste; hanno ruoli diversi solo durante l'aggiornamento, e la distinzione è puramente procedurale. Nella mia nomenclatura chiamo un nodo master, ed è quello che esegue setup, e l'altro slave, che aggiorna solo il proprio binario. Non c'è nessuna replica fra i due e nessuna promozione: condividono lo stesso database e basta.
La conseguenza da tenere a mente quando si progetta la procedura: durante l'aggiornamento esiste una finestra in cui un nodo gira con il binario nuovo e uno con quello vecchio, contro lo stesso schema. È il momento delicato dell'intera operazione, e la sequenza serve a renderlo il più breve e il più prevedibile possibile.
L'architettura minima che regge il fermo di una macchina
Due macchine con il binario, un PostgreSQL raggiungibile da entrambe, un bilanciatore davanti che termina il TLS e distribuisce sulle due. Nessuna sessione appiccicosa: l'autenticazione viaggia su token firmati, quindi qualunque nodo può servire qualunque richiesta. La chiave maestra è identica sui due nodi, perché è quella con cui si decifrano i dati del database, e un nodo con la chiave sbagliata si avvia e poi fallisce in modo poco leggibile.
Va detto con chiarezza dove sta il punto di fallimento residuo, perché una topologia a due nodi non è magia: il database è singolo. Se cade lui, cadono entrambi i nodi. La ridondanza che stai comprando è quella del livello applicativo, cioè riavvii, aggiornamenti e guasti hardware di una delle due macchine; la continuità del database è un problema diverso, si risolve con replica e promozione, e va progettato a parte. Vendere due nodi applicativi come alta disponibilità completa è il modo migliore per avere una brutta sorpresa.
Con la versione 4 c'è una seconda cosa da moltiplicare: l'applicazione di login. Se il core è ridondato e la login no, hai reso resiliente tutto tranne la pagina da cui si entra. Nella pratica la faccio girare su entrambe le macchine e la metto dietro lo stesso bilanciatore, con la regola di instradamento che manda /ui/v2/login alla porta della login e il resto al core.
Lo script, e le quattro cose che lo rendono sicuro
L'aggiornamento a mano funziona la prima volta e sbaglia la terza. Lo script che uso fa poche cose ma le fa in un ordine che non dipende da chi lo esegue. Quattro dettagli meritano di essere spiegati, perché sono la differenza fra uno script e una procedura affidabile.
Il lock esclusivo. Prima di qualunque altra cosa, lo script prende un lock su un file e si arrende se qualcun altro ce l'ha già:
exec 200>"$LOCK_FILE" || { echo "[ERROR] Cannot open lock $LOCK_FILE"; exit 1; }
if ! flock -n 200; then
echo "[ERROR] Another upgrade process is in progress."
exit 4
fiServe contro lo scenario più banale e più frequente: due persone che aggiornano lo stesso nodo nello stesso momento, o una pipeline che riparte mentre la precedente non è finita. Il rilascio del lock è affidato a una trappola sull'uscita, quindi vale anche se lo script viene interrotto.
La verifica dell'archivio prima di toccare qualsiasi cosa. Uno scaricamento troncato produce un archivio che si estrae a metà, e se lo scopri dopo aver sovrascritto il binario hai un nodo fermo:
tar -tzf "$TMPDIR/$tar" >/dev/null || { echo "[ERROR] Corrupted tarball." >&2; exit 1; }L'installazione con install(1) invece di mv. Proprietario, gruppo e permessi vengono impostati nell'operazione stessa, e non in tre comandi successivi che qualcuno un giorno dimenticherà:
sudo install -o "$ZITADEL_USER" -g "$ZITADEL_USER" -m 0755 \
"$TMPDIR/zitadel-linux-${ARCH}/zitadel" "$ZITADEL_BIN"I controlli preliminari che interrompono prima di iniziare. Lo script verifica che l'utente di servizio esista, che l'unità di sistema sia nota a systemd e che tutti i file di configurazione siano al loro posto, e si ferma se manca qualcosa. È la parte più noiosa e quella che salva più spesso: fallire prima di scaricare è gratis, fallire a metà no.
Il ruolo del nodo è un semplice interruttore, e determina se le fasi che toccano il database vengono eseguite o saltate:
perform_binary_upgrade
[[ "$ROLE" == "master" ]] && run_setup_step
[[ "${RESTART:-false}" == true && "$ROLE" == "master" ]] && systemctl restart "$SYSTEMD_UNIT"I codici di uscita, ovvero come lo rendi automatizzabile
Uno script di aggiornamento che comunica solo con messaggi sullo schermo non si può mettere in una pipeline. Quello che uso distingue gli esiti con codici di uscita diversi, ed è questa la parte che lo rende utilizzabile da un sistema di automazione senza interpretare testo:
| Codice | Significato | Cosa ne fa la pipeline |
|---|---|---|
| 0 | tutto a posto, niente da fare | prosegue senza rumore |
| 1 | errore generico o controllo preliminare fallito | interrompe e segnala |
| 2 | aggiornamento disponibile, restituito dal comando di verifica | apre un cambio programmato |
| 3 | installazione o aggiornamento completati | prosegue con il nodo successivo |
| 4 | un'altra esecuzione è già in corso | riprova più tardi, non è un errore |
La distinzione fra 0 e 2 è quella che consente di eseguire una verifica pianificata senza generare falsi allarmi: niente da fare non è un fallimento, e un sistema di monitoraggio che tratta i due casi allo stesso modo verrà silenziato dopo la seconda settimana. La distinzione fra 1 e 4 è altrettanto pratica: un'esecuzione concorrente non è un guasto, è una cosa che capita e che si risolve aspettando.
Se stai costruendo una procedura di aggiornamento per un componente che non puoi permetterti di fermare, e vuoi che qualcuno la riveda prima che la esegua un collega alle sette del mattino, nel mio profilo professionale trovi il percorso su continuità operativa, disaster recovery e gestione di infrastrutture Linux in produzione.
La sequenza, passo per passo
Questa è la parte da stampare. L'ordine non è negoziabile e ogni passo ha una ragione.
Prima di tutto, il backup del database e la verifica dello stato di salute dei due nodi. Il backup va fatto adesso, non ieri, e va verificato che il comando sia andato a buon fine. Lo stato di salute dei due nodi va registrato prima, perché serve come termine di paragone dopo: se un nodo era già degradato prima dell'aggiornamento, devi saperlo ora e non fra venti minuti.
Verifica di cosa gira e cosa è disponibile, con il comando che restituisce 2 se c'è un aggiornamento. Va fatto su entrambi i nodi, perché due nodi che credi allineati a volte non lo sono.
Aggiorna prima lo slave, solo il binario. Scarica, verifica, installa, e non riavviare. A questo punto lo slave ha il binario nuovo su disco ma sta ancora servendo traffico con il processo vecchio in memoria, contro lo schema vecchio. Nessun impatto, nessun rischio.
Aggiorna il master: binario, poi setup, poi riavvio. Questo è il momento critico ed è l'unico. Il master installa il binario nuovo, esegue le migrazioni sullo schema, e riparte con il codice nuovo su schema nuovo. Durante il suo riavvio, che dura secondi, il traffico è servito interamente dallo slave, che gira ancora con il codice vecchio su schema nuovo. Le migrazioni di Zitadel sono progettate per essere compatibili all'indietro entro un salto di versione, ed è precisamente questa proprietà che rende possibile la finestra.
Prova a fondo il master prima di toccare lo slave. Un accesso completo, l'emissione di un token, una lettura e una scrittura via API, uno sguardo agli errori nei log. Se qualcosa non va, ti fermi qui, e ti fermi in una condizione in cui hai ancora un nodo con il codice precedente che serve il traffico.
Riavvia lo slave. Adesso raccoglie il binario nuovo e i due nodi sono allineati. L'operazione è finita.
La proprietà che rende questa sequenza priva di fermo è una sola e vale la pena isolarla: in nessun momento entrambi i nodi sono fermi contemporaneamente, e il nodo che resta in piedi durante il riavvio dell'altro sta sempre servendo con una combinazione di codice e schema che il progetto supporta.
Il rollback, e perché ne esistono due
La domanda giusta non è se sai tornare indietro, ma da che punto, perché la risposta cambia radicalmente.
Prima che setup sia stato eseguito il rollback è banale: rimetti il binario precedente e riavvii. Lo schema non è stato toccato, non hai perso niente, l'operazione dura un minuto. È il motivo per cui aggiornare lo slave per primo è sicuro: fino al momento in cui il master lancia le migrazioni, sei in territorio completamente reversibile.
Dopo che setup è stato eseguito il rollback è un ripristino del database, con tutto quello che comporta: fermare il servizio, ripristinare lo snapshot, ripartire con il binario vecchio, e accettare di perdere le scritture avvenute fra il backup e adesso. Su un identity provider quelle scritture sono accessi, cambi password e registrazioni di secondo fattore, cioè cose che gli utenti si accorgono di aver perso.
C'è una terza via che vale la pena conoscere perché è quella che uso più spesso nella realtà, e non compare in nessun manuale: non tornare indietro affatto, e andare avanti. Se il problema che hai trovato dopo le migrazioni è circoscritto, per esempio una funzionalità secondaria che si comporta diversamente, il rimedio meno rischioso è quasi sempre attendere la versione correttiva invece di ripristinare il database. Un ripristino è un'operazione distruttiva su un sistema che sta funzionando in modo parziale, e la parzialità è spesso preferibile all'operazione. La regola che applico: si ripristina solo se il servizio è inutilizzabile, non se è imperfetto, e questa distinzione va fatta a mente fredda scrivendo la procedura, non alle sette del mattino con il telefono che squilla.
La conclusione operativa è netta e conviene scriverla nella procedura: la finestra di reversibilità a costo zero si chiude nell'istante in cui parte setup sul master. Tutto ciò che vuoi verificare a basso costo va verificato prima di quel momento. È anche il motivo per cui il backup immediatamente precedente non è una formalità burocratica: è l'unica cosa che rende praticabile il secondo tipo di rollback.
Cosa è cambiato con la versione 4
Due cose, e nessuna delle due compare nelle procedure scritte per le versioni precedenti.
I componenti da aggiornare sono due. Oltre al binario del core c'è l'applicazione di login, che ha il proprio artefatto di rilascio e la propria unità di sistema. La sequenza che consiglio è aggiornare il core per primo e la login subito dopo, perché la login è un client del core e non tocca lo schema: aggiornarla per ultima significa che in caso di problema hai un solo componente da riportare indietro. In pratica la procedura descritta sopra si applica al core, e la login diventa un passo aggiuntivo alla fine, per nodo.
Chi viene dalla versione 3 deve gestire un avviso tecnico dedicato. Il salto alla 4 ha un advisory sulle chiavi web OIDC, identificato come A-10017, da eseguire prima o durante la migrazione. Non è opzionale e non è una raccomandazione di stile: riguarda il materiale crittografico con cui vengono firmati i token, quindi salta direttamente sulla capacità delle applicazioni di validarli. Se il tuo impianto è nato sulla versione 4 la questione non ti riguarda; se stai aggiornando, va letto e pianificato prima di toccare qualunque cosa.
Vale infine la pena ricordare il ritmo, che si legge nella pagina dei rilasci del progetto: una major ogni tre mesi, con un mese di candidate prima della stabile e la deprecazione della versione precedente circa sei mesi dopo. Per il self-hosting il riferimento da seguire è il canale stabile e non l'ultima immagine disponibile. Il che significa che questa procedura la eseguirai quattro volte l'anno, ed è esattamente per questo che deve stare in uno script con dei codici di uscita e non nella memoria di una persona.
Cosa monitorare, se il fermo lo vuoi scoprire tu
Un impianto ridondato che nessuno guarda è un impianto con un guasto silenzioso in attesa di diventare doppio. La ridondanza a due nodi ha una proprietà crudele: quando un nodo cade, il servizio continua a funzionare, quindi nessuno se ne accorge, e continui a girare con zero margine finché non cade anche il secondo. Il monitoraggio non serve a scoprire il disservizio, serve a scoprire di aver perso la ridondanza.
Le quattro cose che sorveglio, in ordine di utilità.
Lo stato di salute di ciascun nodo separatamente, non attraverso il bilanciatore. Interrogare l'indirizzo pubblico dice solo che almeno uno dei due risponde, che è precisamente l'informazione inutile. Ogni nodo va interrogato al suo indirizzo interno, e l'allarme scatta sul singolo nodo.
La versione in esercizio su ciascun nodo. Due nodi con versioni diverse per più del tempo di un aggiornamento significano una procedura interrotta a metà, ed è una condizione che va segnalata perché tende a passare inosservata per settimane.
La raggiungibilità del database da ciascun nodo. È il punto di fallimento singolo dichiarato più sopra, e conviene misurarlo dal punto di vista dei nodi e non da quello del database: un problema di rete fra un nodo e il database produce lo stesso sintomo di un database fermo, ma richiede un intervento diverso.
La scadenza del certificato del dominio pubblico. Sembra fuori tema in un articolo sull'alta disponibilità, ed è invece la causa più banale di fermo totale di un identity provider: il certificato scade, ogni client rifiuta la connessione, e nessuna ridondanza applicativa ti salva. Il rinnovo automatico può fallire in silenzio, quindi si monitora il risultato e non il meccanismo.
Su questi quattro punti l'allarme deve arrivare a una persona, non a una casella condivisa che nessuno legge. È la parte meno tecnica e quella che fa la differenza fra un piano di continuità scritto e uno che funziona.
La verifica finale
Dopo il riavvio dello slave, i controlli che chiudono l'operazione: entrambi i nodi rispondono con lo stesso numero di versione, entrambi rispondono all'endpoint di stato di salute, un accesso completo va a buon fine passando dal bilanciatore, un token emesso viene validato da un'applicazione reale e non solo da un comando, e i log di entrambi i nodi non mostrano errori nuovi rispetto al termine di paragone che avevi registrato all'inizio.
Quest'ultimo punto è quello che salta più spesso e vale la pena insistere: guardare i log solo dopo un aggiornamento non serve a niente se non sai com'erano prima. Un identity provider produce sempre qualche errore di routine, e senza un riferimento non distingui il rumore di fondo dal sintomo.
Una nota per chi legge questo articolo da una posizione di responsabilità e non di esecuzione, perché è il punto su cui vedo più spesso un disallineamento fra aspettativa e realtà. Due nodi non sono un obiettivo di continuità: sono una misura. L'obiettivo si esprime in due numeri, cioè quanto tempo puoi stare fermo e quanti dati puoi perdere, e sono numeri che decide il business, non chi installa. Due nodi applicativi rispondono bene al primo quando la causa è il riavvio o il guasto di una macchina; non rispondono affatto al secondo, che dipende interamente dalla frequenza dei backup e dalla replica del database.
La domanda da fare in sede di valutazione, quindi, non è "abbiamo l'alta disponibilità" ma "quali guasti copriamo e quali no, e chi ha deciso che quelli scoperti sono accettabili". Nel caso di questa architettura la risposta onesta è: copriamo il guasto di un nodo applicativo e l'aggiornamento pianificato, non copriamo la perdita del database né quella del datacenter. Se la seconda metà di quella frase è inaccettabile per il tuo contesto, il lavoro da fare è sul database e sulla collocazione geografica, non sull'aggiungere un terzo nodo applicativo, che è invece l'errore che vedo commettere più spesso perché è il più semplice da eseguire.
Con questo la parte infrastrutturale della serie è chiusa. Dal prossimo articolo si passa al codice delle applicazioni che si federano con l'identity provider, e la prima cosa da mettere a fuoco è che il token non è un dettaglio di trasporto ma la credenziale stessa, con tutto quello che ne consegue su come si valida. Se stai progettando una procedura di aggiornamento per un componente critico e vuoi confrontarla con una che è stata eseguita più volte in produzione, scrivimi pure: la differenza fra una procedura che funziona e una che regge quando qualcosa va storto sta quasi sempre in due o tre dettagli come quelli di questo articolo.