La resilienza nel software si riferisce alla capacità di un'applicazione di continuare a funzionare in modo fluido e affidabile, anche di fronte a problemi o guasti imprevisti. Nei progetti Fintech la resilienza è di particolare importanza per diversi motivi. In primo luogo, le aziende sono obbligate a soddisfare i requisiti normativi e gli enti di regolamentazione finanziaria sottolineano la resilienza operativa per mantenere la stabilità all'interno del sistema. Inoltre, la proliferazione di strumenti digitali e la dipendenza da fornitori di servizi terzi espone le aziende Fintech a maggiori minacce alla sicurezza. La resilienza aiuta anche a mitigare i rischi di interruzioni causate da vari fattori come minacce informatiche, pandemie o eventi geopolitici, salvaguardando le operazioni aziendali principali e le risorse critiche.
Con modelli di resilienza intendiamo un insieme di best practice e strategie progettate per garantire che il software possa resistere alle interruzioni e mantenere le sue operazioni. Questi modelli agiscono come reti di sicurezza, fornendo meccanismi per gestire gli errori, gestire il carico e recuperare dai guasti, assicurando così che le applicazioni rimangano robuste e affidabili in condizioni avverse.
Le strategie di resilienza più comuni includono bulkhead, cache, fallback, retry e circuit breaker. In questo articolo, ne parlerò più in dettaglio, con esempi di problemi che possono aiutare a risolvere.
Diamo un'occhiata all'impostazione di cui sopra. Abbiamo un'applicazione molto ordinaria con diversi backend dietro di noi da cui ottenere alcuni dati. Ci sono diversi client HTTP connessi a questi backend. Si scopre che tutti condividono lo stesso pool di connessioni! E anche altre risorse come CPU e RAM.
Cosa succederà se uno dei backend riscontra qualche tipo di problema che causa un'elevata latenza delle richieste? A causa dell'elevato tempo di risposta, l'intero pool di connessioni sarà completamente occupato da richieste in attesa di risposte dal backend1. Di conseguenza, le richieste destinate al backend2 e al backend3 sani non potranno procedere perché il pool è esaurito. Ciò significa che un errore in uno dei nostri backend può causare un errore nell'intera applicazione. Idealmente, vogliamo che solo la funzionalità associata al backend in errore subisca un degrado, mentre il resto dell'applicazione continua a funzionare normalmente.
Cos'è il modello Bulkhead?
Il termine Bulkhead pattern deriva dalla costruzione navale e consiste nel creare diversi compartimenti isolati all'interno di una nave. Se si verifica una perdita in un compartimento, questo si riempie d'acqua, ma gli altri compartimenti rimangono inalterati. Questo isolamento impedisce all'intera nave di affondare a causa di una singola falla.
Il pattern Bulkhead può essere utilizzato per isolare vari tipi di risorse all'interno di un'applicazione, impedendo che un guasto in una parte influenzi l'intero sistema. Ecco come possiamo applicarlo al nostro problema:
Supponiamo che i nostri sistemi backend abbiano una bassa probabilità di riscontrare errori individualmente. Tuttavia, quando un'operazione comporta l'interrogazione di tutti questi backend in parallelo, ognuno può restituire un errore in modo indipendente. Poiché questi errori si verificano in modo indipendente, la probabilità complessiva di un errore nella nostra applicazione è superiore alla probabilità di errore di qualsiasi singolo backend. La probabilità di errore cumulativa può essere calcolata utilizzando la formula P_total=1−(1−p)^n, dove n è il numero di sistemi backend.
Ad esempio, se abbiamo dieci backend, ciascuno con una probabilità di errore di p=0,001 (corrispondente a un SLA del 99,9%), la probabilità di errore risultante è:
P_totale=1−(1−0,001)^10=0,009955
Ciò significa che il nostro SLA combinato scende a circa il 99%, il che dimostra come l'affidabilità complessiva diminuisca quando si interrogano più backend in parallelo. Per mitigare questo problema, possiamo implementare una cache in memoria.
Una cache in memoria funge da buffer di dati ad alta velocità, memorizzando dati a cui si accede di frequente ed eliminando la necessità di recuperarli ogni volta da fonti potenzialmente lente. Poiché le cache memorizzate in memoria hanno una probabilità di errore pari allo 0% rispetto al recupero dei dati tramite la rete, aumentano significativamente l'affidabilità della nostra applicazione. Inoltre, la memorizzazione nella cache riduce il traffico di rete, riducendo ulteriormente la possibilità di errori. Di conseguenza, utilizzando una cache in memoria, possiamo ottenere un tasso di errore ancora più basso nella nostra applicazione rispetto ai nostri sistemi backend. Inoltre, le cache in memoria offrono un recupero dei dati più rapido rispetto al recupero basato sulla rete, riducendo così la latenza dell'applicazione, un vantaggio notevole.
Per i dati personalizzati, come profili utente o raccomandazioni, utilizzare cache in memoria può essere molto efficace. Ma dobbiamo assicurarci che tutte le richieste di un utente vadano costantemente alla stessa istanza dell'applicazione per utilizzare i dati memorizzati nella cache per loro, il che richiede sessioni sticky. L'implementazione di sessioni sticky può essere impegnativa, ma per questo scenario non abbiamo bisogno di meccanismi complessi. Un piccolo ribilanciamento del traffico è accettabile, quindi un algoritmo di bilanciamento del carico stabile come l'hashing coerente sarà sufficiente.
Inoltre, in caso di guasto di un nodo, l'hashing coerente assicura che solo gli utenti associati al nodo guasto subiscano il ribilanciamento, riducendo al minimo l'interruzione del sistema. Questo approccio semplifica la gestione delle cache personalizzate e migliora la stabilità e le prestazioni complessive della nostra applicazione.
Se i dati che intendiamo mettere in cache sono critici e utilizzati in ogni richiesta gestita dal nostro sistema, come policy di accesso, piani di abbonamento o altre entità vitali nel nostro dominio, la fonte di questi dati potrebbe rappresentare un punto di errore significativo nel nostro sistema. Per affrontare questa sfida, un approccio è replicare completamente questi dati direttamente nella memoria della nostra applicazione.
In questo scenario, se il volume di dati nella sorgente è gestibile, possiamo avviare il processo scaricando uno snapshot di questi dati all'inizio della nostra applicazione. Successivamente, possiamo ricevere eventi di aggiornamento per garantire che i dati memorizzati nella cache rimangano sincronizzati con la sorgente. Adottando questo metodo, miglioriamo l'affidabilità dell'accesso a questi dati cruciali, poiché ogni recupero avviene direttamente dalla memoria con una probabilità di errore dello 0%. Inoltre, il recupero dei dati dalla memoria è eccezionalmente veloce, ottimizzando così le prestazioni della nostra applicazione. Questa strategia mitiga efficacemente il rischio associato all'affidamento a una sorgente dati esterna, garantendo un accesso coerente e affidabile alle informazioni critiche per il funzionamento della nostra applicazione.
Tuttavia, la necessità di scaricare dati all'avvio dell'applicazione, ritardando così il processo di avvio, viola uno dei principi dell'applicazione a 12 fattori che propugna l'avvio rapido dell'applicazione. Ma non vogliamo rinunciare ai vantaggi dell'uso della memorizzazione nella cache. Per risolvere questo dilemma, esploriamo possibili soluzioni.
L'avvio rapido è fondamentale, soprattutto per piattaforme come Kubernetes, che si basano sulla rapida migrazione delle applicazioni su diversi nodi fisici. Fortunatamente, Kubernetes può gestire le applicazioni ad avvio lento utilizzando funzionalità come le sonde di avvio.
Un'altra sfida che potremmo affrontare è l'aggiornamento delle configurazioni mentre l'applicazione è in esecuzione. Spesso, è necessario regolare i tempi di cache o i timeout delle richieste per risolvere i problemi di produzione. Anche se possiamo distribuire rapidamente i file di configurazione aggiornati alla nostra applicazione, l'applicazione di queste modifiche richiede in genere un riavvio. Con il tempo di avvio esteso di ogni applicazione, un riavvio progressivo può ritardare notevolmente la distribuzione delle correzioni ai nostri utenti.
Per risolvere questo problema, una soluzione è quella di memorizzare le configurazioni in una variabile concorrente e di farla aggiornare periodicamente da un thread in background. Tuttavia, alcuni parametri, come i timeout delle richieste HTTP, potrebbero richiedere la reinizializzazione dei client HTTP o del database quando cambia la configurazione corrispondente, ponendo una potenziale sfida. Tuttavia, alcuni client, come il driver Cassandra per Java, supportano il ricaricamento automatico delle configurazioni, semplificando questo processo.
L'implementazione di configurazioni ricaricabili può mitigare l'impatto negativo dei lunghi tempi di avvio delle applicazioni e offrire ulteriori vantaggi, come la facilitazione delle implementazioni dei feature flag. Questo approccio ci consente di mantenere l'affidabilità e la reattività delle applicazioni, gestendo al contempo in modo efficiente gli aggiornamenti delle configurazioni.
Ora diamo un'occhiata a un altro problema: nel nostro sistema, quando una richiesta utente viene ricevuta ed elaborata inviando una query a un backend o a un database, occasionalmente, viene ricevuta una risposta di errore invece dei dati previsti. Successivamente, il nostro sistema risponde all'utente con un 'errore'.
Tuttavia, in molti scenari, potrebbe essere preferibile visualizzare dati leggermente obsoleti insieme a un messaggio che indica un ritardo nell'aggiornamento dei dati, piuttosto che lasciare l'utente con un grande messaggio di errore rosso.
Per risolvere questo problema e migliorare il comportamento del nostro sistema, possiamo implementare il modello Fallback. Il concetto alla base di questo modello prevede di avere una fonte dati secondaria, che potrebbe contenere dati di qualità o freschezza inferiori rispetto alla fonte primaria. Se la fonte dati primaria non è disponibile o restituisce un errore, il sistema può ricorrere al recupero dei dati da questa fonte secondaria, assicurando che all'utente venga presentata una qualche forma di informazione anziché visualizzare un messaggio di errore.
Osservando l'immagine qui sopra, noterete una somiglianza tra il problema che stiamo affrontando ora e quello riscontrato con l'esempio della cache.
Per risolverlo, possiamo prendere in considerazione l'implementazione di un pattern noto come retry. Invece di affidarsi alle cache, il sistema può essere progettato per inviare automaticamente di nuovo la richiesta in caso di errore. Questo pattern di retry offre un'alternativa più semplice e può ridurre efficacemente la probabilità di errori nella nostra applicazione. A differenza della memorizzazione nella cache, che spesso richiede complessi meccanismi di invalidazione della cache per gestire le modifiche dei dati, il retry delle richieste non riuscite è relativamente semplice da implementare. Poiché l'invalidazione della cache è ampiamente considerata una delle attività più impegnative nell'ingegneria del software, l'adozione di una strategia di retry può semplificare la gestione degli errori e migliorare la resilienza del sistema.
Tuttavia, adottare una strategia di ripetizione dei tentativi senza considerare le potenziali conseguenze può portare a ulteriori complicazioni.
Immaginiamo che uno dei nostri backend subisca un guasto. In uno scenario del genere, l'avvio di nuovi tentativi sul backend in errore potrebbe comportare un aumento significativo del volume di traffico. Questa improvvisa impennata di traffico potrebbe sopraffare il backend, esacerbando il guasto e potenzialmente causando un effetto a cascata in tutto il sistema.
Per far fronte a questa sfida, è importante integrare il modello di ripetizione con il modello di interruttore di circuito. L'interruttore di circuito funge da meccanismo di salvaguardia che monitora il tasso di errore dei servizi downstream. Quando il tasso di errore supera una soglia predefinita, l'interruttore di circuito interrompe le richieste al servizio interessato per una durata specificata. Durante questo periodo, il sistema si astiene dall'inviare richieste aggiuntive per consentire al servizio in errore di recuperare. Dopo l'intervallo designato, l'interruttore di circuito consente cautamente il passaggio di un numero limitato di richieste, verificando se il servizio si è stabilizzato. Se il servizio si è ripristinato, il traffico normale viene gradualmente ripristinato; in caso contrario, il circuito rimane aperto, continuando a bloccare le richieste finché il servizio non riprende il normale funzionamento. Integrando il modello di interruttore di circuito insieme alla logica di ripetizione, possiamo gestire efficacemente le situazioni di errore e prevenire il sovraccarico del sistema durante i guasti del backend.
In conclusione, implementando questi modelli di resilienza, possiamo rafforzare le nostre applicazioni contro le emergenze, mantenere un'elevata disponibilità e offrire un'esperienza fluida agli utenti. Inoltre, vorrei sottolineare che la telemetria è un altro strumento che non dovrebbe essere trascurato quando si fornisce resilienza al progetto. Buoni log e metriche possono migliorare significativamente la qualità dei servizi e fornire preziose informazioni sulle loro prestazioni, aiutando a prendere decisioni informate per migliorarli ulteriormente.