Dall’architettura monolitica ai microservizi

L’approccio classico nello sviluppo di un software prevede l’utilizzo di un’architettura monolitica, il che vuol dire che tutti i processi sono strettamente correlati e sono eseguiti come se fossero un solo servizio. In questo contesto, in caso di malfunzionamento di un singolo processo il disservizio avrà effetto sull’intero sistema. Se c’è un picco delle richieste su uno specifico processo, essendoci uno stretto legame tra tutte le componenti interne, si deve ridimensionare l’intera architettura per adeguarla al nuovo carico di lavoro. Va citato, anche se non è un inconveniente tecnico in senso stretto, che con l’approccio monolitico si crea un impedimento all’adozione di nuove tecnologie, poiché sarebbe necessario convertire l’intero software. Ai sopracitati problemi di natura tecnica si va ad aggiungere anche uno che riguarda la gestione delle risorse umane nel contesto aziendale: quando uno sviluppatore viene aggiunto ad un progetto si vorrebbe che diventasse operativo il più velocemente possibile, ma comprendere il funzionamento dell’intero software ha una difficoltà che cresce con l’aumentare della complessità del progetto.

L’architettura a microservizi è nata per superare le problematiche tipiche dell’approccio monolitico. Si presenta come un insieme di componenti indipendenti, responsabili di singoli servizi, che comunicano tra di loro solo tramite API esposte. Essendo le parti che compongono questa architettura indipendenti, un malfunzionamento di un servizio non ha ripercussioni su tutto il software; inoltre, supponendo di avere un picco di richieste per un modulo, questo approccio permette un ridimensionamento solo dei moduli che hanno necessità di avere più risorse a disposizione, lasciando invariato il resto. Come già detto, i moduli comunicano tra loro solo tramite API e non hanno la necessità di condividere alcun codice, questo permette di poter realizzare ogni componente con tecnologie diverse tra loro, o di riscrivere un modulo, senza doversi preoccupare del resto dell’applicazione. Ad esempio, supponiamo di avere un componente che si occupa della gestione un determinato servizio; in seguito compare un framework, una tecnologia o qualsiasi altro motivo per cui ci conviene usare, ad esempio, un nuovo linguaggio con un nuovo framework, possiamo effettuare la migrazione molto più facilmente rispetto all’architettura monolitica. La maggiore facilità è figlia dell’indipendenza dei componenti, in quanto possiamo effettuare modifiche localizzate, e delle loro dimensioni che, occupandosi di specifici servizi, tendono ad essere relativamente contenute. Nella realizzazione di applicazioni che si basano sull’architettura a microservizi è possibile divide il team di sviluppo in gruppetti, ognuno responsabile di un servizio e indipendente dagli altri. Infine, anche l’inserimento di nuove risorse all’interno del progetto risulta facilitato, lo sviluppatore deve essere messo a conoscenza solo delle informazioni necessarie a lavorare sul servizio a cui il suo gruppo di appartenenza è assegnato.

Problemi dell’architettura a microservizi

Dopo aver esposto i motivi per cui conviene passare ai microservizi partendo dal monolite classico, vediamo le difficoltà e le problematiche che questo nuovo approccio porta con sè.

Consideriamo uno dei vantaggi esposti dell’architettura a microservizi, la ridotta complessità e l’indipendenza dei moduli; la fase di test del servizio risulta agevolato, va controllato solo quel modulo. Il problema nasce quando si va a fare un test d’integrazione, verificare la correttezza dell’interazione dei vari moduli del sistema tra loro risulta abbastanza complicato.

L’architettura a microservizi è di fatto una rete distribuita, quindi vi sono anche tutti i problemi di quest’ultima. Quando il software è monolitico abbiamo delle chiamate a dei metodi e non ci sono problemi rilevanti, con i microservizi vanno considerate la latenza nella comunicazione con un servizio e l’irraggiungibilità di un nodo. Cosa fare quando un servizio per completare il compito ha bisogno della risposta di un altro servizio che risulta irraggiungibile? E se c’è un rallentamento della rete? Inoltre, essendo i moduli sviluppati in maniera indipendente, occorre implementare un modo per comunicare a tutti i nodi della rete quale sono i servizi disponibili e come raggiungerli.

La gestione delle basi di dati nell’architettura a microservizi, che più avanti è riportato con maggiore dettaglio, è un argomento di particolare rilievo. Nelle applicazioni monolitiche di solito abbiamo un solo database, con i microservizi si possono usare diversi approcci: un DB per ogni microservizio, un DB condiviso oppure una soluzione mista. Tutte le soluzione portano con se qualche problematica da affrontare.

La gestione dell’autenticazione, anch’essa riportata in dettaglio più avanti, è un altro argomento delicato quando si sceglie di usare i microservizi. Nelle applicazione monolitiche l’utente effettua il login e ha accesso a tutte le funzionalità, con i microservizi il problema nasce dal fatto che l’autenticazione fatta su un modulo deve valere anche per gli altri che girano su server diversi. Ad esempio, un utente chiama il servizio A su un nodo, effettua il login per soddisfare la richiesta e, in seguito, chiama il servizio B, su un nodo diverso, disponibile solo per gli utenti loggati. Come mantenere le informazioni di login garantendo l’indipendenza dei moduli tipica della soluzione a microservizi?

Fino ad ora i problemi riportati sono di natura tecnica, però l’utilizzo di questa architettura introduce anche delle difficoltà progettuali. I servizi sono delle “black box” che all’esterno espongono delle API, il rischio è il loro numero sia eccessivo. Avendo troppe API, si correrebbe il rischio di compromettere il concetto di black box e rendere difficoltose le modifiche, cambiare il software potrebbe necessitare il riadattamento anche delle API.

Gestione dei database

Di solito, nelle applicazioni monolitiche si ha un solo database, con la soluzione a microservizi ci sono diverse possibile soluzioni tra cui scegliere: un database per ogni servizio, uno condiviso oppure una soluzione mista.

In precedenza è stato detto che l’utilizzo dei microservizi permette di utilizzare tecnologie diverse in ogni modulo, ciò può essere esteso anche a quanto riguarda le basi di dati. Utilizzando una soluzione che preveda un database per ogni servizio non si hanno particolari vincoli, ad esempio si potrebbe ritenere più efficiente l’utilizzo di un DB SQL in un modulo e uno NoSQL in un altro. Partendo da una soluzione tradizionale monolitica, si potrebbe pensa di spostare all’interno di un modulo la tabella, le tabelle o lo schema di competenza del servizio. Scegliendo questa implementazione si incontrano, ovviamente, anche delle problematiche. La prima difficoltà che di solito si presenta è quando si vuole fare una join tra tabelle che si trovano su servizi diversi, su server diversi. In alcuni casi, si sceglie di usare un servizio destinato alle join, che carica i dati parziali ed effettua le join, però anche qui l’efficienza ne può risentire (l’immagine che segue sintetizza la soluzione appena descritta per le join). Le altre problematiche sono relative alla crescita di difficoltà nel gestire lo scambi di informazioni, si richiede una maggiore attenzione nella cooperazione tra i vari servizi e nella gestione di eventuali dati duplicati, sincronizzazione nel salvataggio delle modifiche e meccanisimi per garantire la consistenza dei dati.

Una soluzione alternativa è usare un solo database condiviso tra tutti i servizi. Questa scelta risulta più semplice e familiare alla maggior parte dei programmatori, non presenta i problemi di sincronizzazione e di consistenza dei dati visti nella soluzione con più database. Tuttavia ci sono delle problematiche anche qui: c’è un forte accoppiamento tra i servizi, le modifiche al DB devono essere sincronizzate, è necessario sincronizzare le operazioni che i vari servizi effettuano sul DB, devono essere evitate e gestite le potenziali interferenze e, infine, si obbligano tutti i servizi ad adottare una tecnologia condivisa per la gestione dei dati.

Oltre alle soluzioni precedenti è possibile scegliere un’implementazione mista in cui ogni servizio ha un proprio database oltre alla base dati condivisa. Nei DB locali ci sono delle informazioni strettamente legate al servizio, mentre quelle più generali sono memorizzate nel DB condiviso. Questa soluzione porta con se i vantaggi e le problematiche viste prima. Ovviamente, è richiesta un’analisi per scegliere quali informazioni devono essere memorizzate nel database condiviso e quali memorizzate localmente.

Elementi necessari nell’architettura a microservizi

In ogni applicazione che utilizzi l’architettura a microservizi deve esserci un Service Registry che permetta la Service Discovery, in altre parole, deve esserci un registro dei servizi disponibili che dia la possibilità a tutti i servizi di registrarsi e trovarsi a vicenda.

Le configurazioni del sistema sono distribuite tra tutti i moduli, perciò c’è la necessità di gestirle. Esistono dei sistemi di configuration management per centralizzare e ridistribuire tali configurazioni.

Una caratteristica importante dei sistemi distribuiti è la tolleranza al fallimento, il guasto di una sua parte non deve compromettere il funzionamento generale. Ovviamente ciò è valido anche per le applicazioni realizzate con un’architettura a microservizi, perciò si ha la necessità di avere un componente che si occupi del circuit breaker. Il suo scopo è quello di isolare i moduli in casi di funzionamento e dare una risposta di fallback.

Ogni servizio ha i suoi log, o dovrebbe averli, però è importante che ci sia anche un sistema centralizzato dei log che li aggreghi tutti e ne permetta una “analisi unitaria”.

Punti d’entrata

Avendo a disposizione più servizi su più server, si potrebbe pensare che i client comunichino direttamente con il servizio desiderato. Tuttavia un approccio di questo tipo crea un forte accoppiamento tra il client e il server che fornisce un servizio. In questo contesto, supponiamo di voler cambiare il modo in cui viene gestito un servizio oppure aggiungerne uno nuovo, ci sarebbe difficile farlo. Per questo motivo, nelle applicazioni implementate con un’architettura a microservizi si preferisce inserire un componente che si occupa di instradare le richieste del client verso il servizio richiesto, l’API Gateway. Così facendo eventuali modifiche interne sarebbero trasparenti al client che continuerebbe a funzionare allo stesso modo.

Si può modificare leggermente questo schema inserendo altri punti d’accesso oltre l’API Gateway principale. Una variante è chiamata Backends For Frontends, si prevendono più API Gateway, almeno uno per tipologia, ad esempio possiamo averne 3: uno per i client mobile, uno per le web app e uno per le API pubbliche.

Autenticazione e autorizzazioni

Nelle applicazioni monolitiche gli utenti inviano i dati di login, il server ne verifica la correttezza, risponde con delle informazioni relative alla connessione, i cookies, e apre una sessione che permette all’utente di continuare la navigazione, compatibilmente con le autorizzazioni di cui dispone. Risulta subito evidente che un approccio di questo tipo non è applicabile nel caso dei microservizi, o almeno non senza delle modifiche. Tale incompatibilità si manifesta poiché non abbiamo più un “processo centrale”, questi è sostituito da vari moduli indipendenti che devono condividere le informazioni di accesso dell’utente. Per una corretta gestione di autenticazione e autorizzazioni dobbiamo garantire le seguenti caratteristiche:

  • l’autenticazione e le autorizzazioni devono essere gestibili da ogni microservizio;
  • ogni microservizio dovrebbe essere responsabile solo delle funzionalità che implementa, quindi al suo interno non dovrebbe essere inserita la logica globale di autenticazione e autorizzazioni;
  • essendo l’http un protocollo stateless, si ha la necessità di mantenere le informazioni delle chiamate precedenti, specialmente per quanto riguarda l’autenticazione dell’utente;
  • si deve garantire la sicurezza dell’intera applicazione, in particolare con l’aumento della complessità dell’architettura.

Il problema dell’autenticazione e delle autorizzazioni può essere affrontato in diversi modi:

  1. distribuited session management, che a sua volta può essere implementato in 3 modi diversi:
    • sticky session, si fa in modo che tutte le richieste di un determinato utente vengano gestite dallo stesso servizio che ricevuto la prima richiesta, a cui si fa gestire l’autenticazione e memorizzare in locale le informazioni della sessione. Questa soluzione risulta inefficiente, non permette il bilanciamento del traffico sulla rete e quando un utente inizia a comunicare con un altro servizio deve effettuare di nuovo l’accesso;
    • session replication, quando un microservizio riceve una richiesta deve gestire l’autenticazione, memorizzare le informazioni di sessione e inviarle nella rete affinché siano copiate anche sugli altri microservizi. Con questa soluzione si crea un carico sulla rete che aumenta al crescere del numero di microservizi;
    • centralized session storage, si condividono le informazioni di login in un’area accessibile a tutti i microservizi, quest’ultimi consultano quei dati ogni volta che ricevono una richiesta da un cliente. L’autenticazione può essere gestita dai singoli servizi o, preferibilmente, da un server ad hoc. Questa soluzione è buona, migliore delle precedenti, tuttavia implica l’adozione di meccanismi di protezione dei dati di sessione per garantire un’adeguata sicurezza.
  2. client token, questa soluzione è simile a quella utilizzata nelle applicazioni monolitiche, la differenza principale è che le informazioni di sessione sono memorizzate lato client. Per salvare tali dati si usa un Token che viene inviato al server ad ogni chiamata per comunicare lo stato della sessione. Spostare queste informazioni dal server al client richiede dei meccanismi di sicurezza che evitino la falsificazione dell’identità. Il principale standard usato per i token è chiamato JWT, Json Web Token, e ne definisce il formato. La sua struttura consiste in 3 parti: header, contenente informazioni sul formato e il tipo di cifratura, payload, contente informazioni sull’utente, e signature, contenente la firma per verificare l’identità del token.
  3. single sign-on, l’utente effettua il login una sola volta e mantiene la sessione attiva per l’utilizzo successivo dei servizi. Questo funzionamento è garantito dalla presenza di un server, chiamato SSO, che si occupa del login e della gestione della sessione. Tutte le chiamate all’applicazione, indipendentemente dal servizio richiesto, vengono reindirizzate al server SSO che verifica se l’utente è loggato e se ha i permessi necessari, a quel punto si procede al normale flusso di esecuzione. Questa soluzione tende a risultare inefficiente con l’aumento del numero del numero di microservizi, inoltre il server SSO rappresenta per l’applicazione un single point of failure.
  4. client token con API Gateway, questa soluzione è simile a quella vista in precedenza con il token, tuttavia con questa implementazione il token non viene usato direttamente per comunicare con il microservizio. Il token generato in fase di login viene sostituito da un altro generato dall’API Gateway che lo usa internamente nelle chiamate ai microservizi. La corrispondenza tra i 2 token è conosciuta sola dall’API Gateway.
  5. accesso di applicazioni di terze parti, se l’accesso deve essere garantito ad applicazioni di terze parti si possono applicare 2 possibili soluzioni:
    • API Token, vengono utilizzate con lo scopo primario di evitare di esporre le informazioni di login dell’utente, user e password. In fase di generazione del Token si possono settare i permessi che il suo possesso garantisce, ad esempio i dati a cui è possibile accedere. In caso di problemi di qualsiasi tipo è possibile revocare l’autorizzazione al token e, se servisse, generarne un altro;
    • OAuth, questa soluzione viene usata quando l’accesso tramite applicazioni di terze parti deve poter avvenire da utenze diverse;
  6. mutual authentication, abbiamo visto le comunicazioni con l’utente e quelle con applicazioni di terze parti, c’è n’è un altro tipo da considerare: le comunicazioni generate dal traffico interno all’applicazione. I computer dove risiedono i servizi possono trovarsi all’interno della stessa rete oppure in data center differenti, ma in entrambi i casi ci si espone alla possibilità di attacchi. Risulta quindi necessario adottare dei meccanismi che garantiscano che la comunicazione interna avvenga in sicurezza, ad esempio criptando i dati e controllando l’identità del servizio chiamante.

Comunicazioni asincrone

Spesso nell’architettura a microservizi le comunicazioni interne usano un modello asincrono. Al contrario di quanto avviene con le chiamate sincrone, in questo caso quando un microservizio ne contatta un altro non ha bisogno di attendere la risposta. Spesso si parla di architettura guidata ad eventi poiché l’invio di un messaggio è da ritenersi un evento che va gestito. Gli eventi vanno salvati in un’area comune in modo da essere accessibile a tutti i microservizi interessati.

Esistono due possibili modelli di architettura guidata ad eventi:

  • pubblicazione/sottoscrizione, un microservizio può pubblicare informazioni che risultano di interesse per altri servizi. Quindi quest’ultimi, detti consumer, effettuano una sottoscrizione che viene memorizzata all’interno dell’infrastruttura. Dopo una pubblicazione, l’evento viene inviato a tutti i consumer, che sono gli unici autorizzati a vederlo, e in seguito alla ricezione i dati non sono più disponibili. Perciò in questo modello la fase di lettura è gestita dall’infrastruttura. Si può usare questo modello per garantire la coerenza dei dati, pubblicando un evento ogni volta che c’è una modifica;
  • streaming eventi, in questo modello sono i microservizi interessati che devono andare a leggere gli eventi pubblicati nell’area comune. Quindi la gestione della fase di lettura è compito dei consumer. Chi è interessato può andare a leggere in qualsiasi momento, al contrario del caso precedente in cui la sottoscrizione va fatta prima della pubblicazione dell’evento.

L’utilizzo dell’architettura guidata ad eventi presenta diversi vantaggi:

  • accoppiamento ridotto tra chi manda i messaggi e chi gli riceve, quindi non è richiesta una conoscenza reciproca;
  • più microservizi possono effettuare una sottoscrizione per ricevere i messaggi che un servizio pubblica;
  • gestione più efficiente degli errori, in caso il consumer non sia disponibile, oppure quando si è verifica un errore, i messaggi possono essere letti anche in un secondo momento. Con questa caratteristica si garantisce il funzionamento generale anche se un servizio non è momentaneamente disponibile, ad esempio quando lo si sta aggiornando;
  • il fatto di non dover attendere una risposta del consumer permette di ridurre i tempi di risposta del producer;
  • livellamento del carico di lavoro, una coda può fungere da buffer e permette a chi deve ricevere i messaggi di lavorare alla propria velocità.

Nota: nelle code c’è la possibilità di inserire dei checkpoint dopo ogni passaggio del flusso di lavoro.

Transazioni distribuite

Spesso ci sono transazioni che si estendono su più microservizi, ad esempio il servizio A chiama B che a sua volta chiama C. Se tutte le chiamate vanno a buon fine allora la transazione totale è ritenuta completata, altrimenti è fallita anche se è solo una chiamata a non essere andare a buon fine. Ad esempio, in una piattaforma di e-commerce c’è un servizio che permette all’utente di acquistare un articolo, uno per pagare e uno per gestire l’invio del prodotto. Supponiamo di avere una “transazione globale” che, cliccando su di un articolo, vada a verificare la disponibilità del prodotto, effettui il pagamento e avvii le procedure di spedizione (supponiamo anche che i dati di pagamento e l’indirizzo siano memorizzati, quindi non serva inserirle). Se, ad esempio, l’invio del prodotto non può avvenire è necessario che le operazioni eseguite vengano annullate; nel caso considerato, il pagamento deve essere rimborsato e il prodotto reinserito tra i disponibili all’acquisto. È in questo contesto che la possibilità di inserire dei checkpoint nella coda dei messaggi risulta particolarmente utile, analizzando la coda stessa si può capire dove si è bloccato il completamento e agire di conseguenza per annullare le operazioni intermedie completate.

In alcuni casi le operazioni di annullamento possono essere delicate, perciò in questi contesti si preferisce impostare il sistema in modo che mandi una mail, o SMS, ad un operatore umano che effettui manualmente le operazioni.

Link utili

A seguire riporto alcuni link per chi volesse approfondire l’argomento: