Negli ultimi anni non si sente altro che parlare di microservizi: se fai microservizi sei cool, altrimenti sei vecchio. Il salto da una applicazione monolitica ad una costellazione di piccole applicazioni però può essere più doloroso di quanto si pensi se non si hanno strumenti in grado di supportare le problematiche introdotte dai sistemi distribuiti.
Spring è sempre stato una garanzia e non sorprende se oggi come oggi Spring Cloud si sia ritagliato nel giro di poco tempo il ruolo di framework leader quando si pensa ai microservizi nel mondo Java.
Un po’ di teoria
In rete si trovano ormai tantissime informazioni che ci spiegano gli elementi fondanti di una architettura a microservizi. Il principio base è ridurre la complessità di una applicazione in modo che abbia un dominio estremamente ridotto e che sappia svolgere un solo compito, o una serie di piccoli compiti inerenti ad esso.
La collaborazione tra questi microservizi genera il servizio finale che diamo all’utente.
Questo semplice principio scatena una serie di corollari, alcuni vantaggiosi, altri un po’ meno. Per esempio:
- ogni servizio ha la propria base dati e deve accedere solo ad essa. Se ha bisogno di altre informazioni le chiede via HTTP o via scambio di messaggi ad altri servizi.
- è possibile replicare (su più processi) un certo servizio ritenuto critico o che è maggiormente stressato: la scalabilità è la grande promessa dell’architettura a microservizi.
- ogni servizio può essere realizzato con una tecnologia diversa dall’altra visto che possono comunicare solo via HTTP o messaggi. Questo permette di scegliere lo strumento giusto per risolvere un certo problema (avete mai visto “” di Hadi Hariri?). Inoltre, è molto più facile rimanere al passo con i tempi dal punto di vista tecnologico: ogni nuovo servizio può essere realizzato con i framework più nuovi e i progetti vecchi, essendo “piccoli”, sono più facilmente aggiornabili.
- visto che il sistema è distribuito, il suo aggiornamento può essere parziale: ogni modulo (servizio) infatti ha un proprio ciclo di vita e può essere sostituito senza impattare (quasi) nessuno degli altri servizi correlati.
Fin qui sembra che sia tutto perfetto, ma ovviamente non è tutto così semplice! Se questo approccio risolve e migliora i problemi che abbiamo appena descritto ne apre di nuovi. Dobbiamo sempre tenere in mente che si passa da una architettura di questo tipo:

ad una in cui ogni modulo ha una propria vita:

In una situazione di questo tipo, sono evidenti le seguenti criticità:
- ogni servizio deve sapere dove sono gli altri. C’è bisogno quindi di un “Service Registry” che permetta la “Service Discovery“, ovvero un registro dei servizi disponibili in modo che i moduli possano registrarvisi e trovarsi a vicenda.
- le configurazioni del sistema sono anch’esse distribuite in tutti i moduli. Fortunatamente esistono sistemi di “Configuration Management” che permettono di centralizzarle e ridistribuirle “a caldo”.
- un sistema distribuito deve essere tollerante al fallimento di una delle sue parti. Se uno o più moduli risultano non disponibili, il sistema deve essere in grado di isolare quella parte e dare una risposta di fallback: questa tecnica viene definita come “Circuit Breaker“.
- l’entrypoint del sistema è bene che rimanga sempre uno, in modo da non percepire l’atomicità dei sistemi sottostanti, che possono essere liberamente sostituiti o replicati. Questo pattern viene definito “API Gateway“.
- ogni sistema ha i propri log: dobbiamo quindi preoccuparci di recuperare e aggregare tutti i log in un unico sistema per riuscire a capire cosa sta avvenendo.
- in un sistema distribuito con queste caratteristiche, è difficile riuscire a mantenere una transazionalità di tipo ACID a cui siamo abituati. Dobbiamo quindi accettare l'”eventually consistent“.
- la gestione di un rilascio di una nuova feature può diventare più complessa perché può coinvolgere diverse applicazioni che vanno aggiornate insieme. E’ necessario quindi automatizzare il più possibile il processo.
- le dipendenze tra i moduli (ovvero tra i microservizi) non sono più a compile-time, bensì risiedono in un’interfaccia comune (tipicamente JSON) con la quale si scambiano i dati. Se non si è fatta una separazione chiara tra le responsabilità di ogni servizio, si può rischiare di creare referenze cicliche (il modulo A chiama B che chiama A) che ovviamente sono un male da evitare. Per mantenere la relazione tra i servizi gerarchica (che poi è quello che facciamo tutti i giorni nel nostro codice anche tra le classi e i package vero? 🙂 ), possiamo immaginare di suddividere i servizi in 3 macrogruppi (o layer se preferiamo):
- experience: sono i servizi indirizzati esplicitamente ad una certa UI (web o mobile che sia)
- aggregation: livello intermedio che aggrega e riorganizza i dati da passare al livello experience
- system: sono i servizi core (anche legacy) che espongono le API delle informazioni basilari del nostro dominio
Riuscire ad organizzare le relazioni tra i servizi almeno su due di questi 3 gruppi tende a tenere separate le responsabilità ed evita spiacevoli referenze cicliche.
Questi a mio avviso sono le nuove sfide che bisogna affrontare quando ci affacciamo a questo nuovo paradigma architetturale: ne vale veramente la pena? Dipende dal problema che dobbiamo affrontare: se una dei primi requisiti è la scalabilità, allora siete sulla strada giusta, altrimenti è una scelta da ponderare molto seriamente.
Bene, avete scelto i microservizi? Allora non reinventatevi la ruota! Dal momento che quelle esposte sono problematiche comuni, esistono anche soluzioni che permettono di semplificarci la vita. Vediamo come possiamo fare nel mondo Java.
Microservizi e Java
Come può essere fatto un microservizio in Java? Diciamo che in generale, quando si pensa ad un microservizio, l’idea è quella di produrre una applicazione stand-alone che espone una API via HTTP, cioè ha un server HTTP embedded per farlo… tutto l’opposto di quanto fatto fino oggi in pratica! Nel mondo Java infatti siamo cresciuti a pane e Application Server (come JBoss, WAS, ecc…) o Servlet Container (come Tomcat). Lo sviluppo era quindi finalizzato ad un artefatto deployabile in unico ambiente dove giravano altre applicazioni.
Nel mondo dei microservizi invece è necessario un ribaltamento del punto di vista: è l’artefatto stesso ad eseguire un server (Application Server o Servlet Container che sia) che ha il ciclo di vita pari a quello dell’applicazione. Con l’imporsi dell’architettura REST e della necessità di creare servizi stateless che permettano la scalabilità in modo efficace (senza sessioni distribuite e replicate per esempio), l’architettura a microservizi (che non è certo stata inventata ieri come concetto) ha trovato terreno fertile ed è esplosa come vera e propria buzzword!! Per concretizzare e domare questa buzzword, abbiamo bisogno di strumenti moderni che supportino le problematiche dei sistemi distribuiti.
Niente ci vieta di fare microservizi continuando a lavorare come facevamo ora, ovvero generando piccoli WAR o EAR da deployare (magari da soli) in un server: anche in quel caso stiamo facendo microservizi, ma non nel senso classico del termine. Se magari chiudiamo tutto dentro un container Docker già il discorso può cambiare: l’artefatto finale infatti è il container ed è stand-alone. Qua però si apre un altro filone, perché spostiamo il focus sull’infrastruttura…
Rimanendo invece a livello applicativo, come faccio in java la service discovery? E il balancing? La fault tolerance?
Un modo (ma non l’unico) ad oggi per risolvere questi problemi in modo coerente è l’accoppiata Spring Boot + Spring Cloud!
Spring Boot e Spring Cloud
Ormai alla sua terza candelina, Spring Boot ha letteralmente fatto riesplodere l’uso di Spring: se infatti il mondo Java EE aveva bene o male quasi (per essere ottimisti) colmato il gap almeno con Spring Framework, con l’uscita di Spring Boot c’è stato un nuovo salto in avanti perché è proprio lo strumento ideale per creare microservizi in Java!! Lo stesso payoff recita (tradotto liberamente 🙂 ):
Spring Boot rende facile creare applicazioni stand-alone, pronte per la produzione, basate su Spring che “semplicemente funzionano”.
Spring Boot infatti toglie tutto il peso della noiosa configurazione di Spring perché predilige le convenzioni alle configurazioni.
Se però questo è il tassello base che permette di creare applicazioni stand-alone, senza Spring Cloud saremmo sostanzialmente a piedi! Ma cos’è Spring Cloud? E’ un insieme di soluzioni ai problemi comuni dei sistemi distribuiti sopra citati (come configuration management, service discovery, circuit breakers…) organizzati in tanti sotto-progetti.
Conosciamoli più da vicino, grazie all’aiuto di un nostro progetto di esempio disponibile su GitHub a cui faremo spesso riferimento. Ogni argomento richiederebbe un post a sé stante di approfondimento, vediamo di seguito gli elementi fondamentali.
Spring Cloud Config
Come si può capire dal nome, grazie a questo componente abbiamo a disposizione un server centralizzato per gestire le configurazioni (e ovviamente il supporto ai client). Di default, esse vengono scaricate da un repository git contenente una serie di file che identificano il nome dell’applicazione (definita da ${spring.application.name}), il profilo attivo (${spring.profiles.active} se specificato, oppure default) e una label (che può essere il branch di riferimento). Un esempio è il repository creato per questo progetto.
Gestire le configurazioni da git può essere interessante per il versionamento, ma non è detto che piaccia a tutti. La documentazione ufficiale mostra che è possibile usare un database, il file system o addirittura HashiCorp Vault.
A prescindere dal “backend” usato per gestire le configurazioni, abbiamo a disposizione una serie di caratteristiche interessanti:
- è possibile richiedere via REST la configurazione di un servizio. Nel nostro esempio, la configurazione del servizio customer-services relativa al profilo di default sarà accessibile dalla risorsa
http://localhost:8888/customer-services/default
(localhost:8888 è il binding di default del server Spring Cloud Config)
- le property di un servizio possono essere aggiornate dinamicamente senza che questo sia riavviato! Le proprietà vengono aggiornate chiamando esplicitamente l’endpoint /refresh (magari tramite un webhook?) o
RefreshEndpoint.refresh()
(anche via JMX). Se vogliamo aggiornare il valore delle property iniettate (per esempio con@Value
), è necessario che il bean sia di tipo@RefreshScope
(come per esempio la classeCustomerResourceFallback
). E’ possibile propagare automaticamente le modifiche alle configurazioni per esempio con RabbitMQ, come specificato nel progetto di esempio di Spring Cloud Samples.
Spring Cloud Netflix
E’ il cuore della piattaforma Spring Cloud che in pratica integra Spring Boot con Netflix OSS, la piattaforma realizzata da Netflix per indirizzare le sfide dei sistemi distribuiti. I nomi noti che rientrano sotto il cappello di Netflix OSS sono:
- Eureka fa da service registry, abilitando la service discovery tra i servizi del nostro sistema. Di default, il server dovrebbe essere avviato sulla porta 8761 e i client, se non differentemente configurati, si aspettano di trovarlo su localhost:8761. Nel progetto si esempio, la configurazione centralizzata rende esplicito il default nel file application.properties. Un dettaglio approfondito del significato di gran parte dei parametri configurabili di Eureka è disponibile nel post “Eureka – The Hidden Manual“
- Hystrix è un circuit breaker. Che significa? Dobbiamo sempre tenere in mente che in un sistema distribuito una delle sue parti può non essere disponibile in un certo momento: come dobbiamo comportarci in questi casi? Hystrix ha la responsabilità di “aprire il circuito” delle chiamate fallimentari e fornire un default alternativo. Sarà sua cura ogni tanto controllare se il sistema escluso è nuovamente disponibile, richiudendo il “circuito delle chiamate”.
-
Ribbon: client side load balancer. Il termine è autoesplicativo, ma cerchiamo di capire meglio cosa significa. Generalmente in un sistema a microservizi la comunicazione può essere diretta oppure tramite proxy: nel primo caso, ogni servizio conosce dove sono gli altri (perché presi dal service registry), è quindi lato client che si decide chi chiamare; nel secondo invece, ogni servizio conosce solo il proxy ed è poi lui a bilanciare (bilanciamento lato server) le chiamate alle repliche di un servizio.
Ribbon quindi è integrato con Eureka, dal quale recupera tutti i riferimenti ai servizi e decide quale replica chiamare, di default in modalità round-robin. -
Feign è un client REST dichiarativo. Permette di effettuare chiamate tra servizi
semplicemente annotando una interfaccia Java con annotazioni Spring MVC o JAX-RS. Un esempio ne è la classeCustomerResource
. Oltre alla semplicità nel dichiarare un client REST, Feign si appoggia a Ribbon per scegliere lato client quale servizio chiamare e sfrutta Hystrix, se è nel classpath ed è abilitato (feign.hystrix.enabled=true). Avendo a disposizione Feign infatti sarà molto difficile dover “scendere di livello” ed usare direttamente Ribbon e Hystrix, a meno che non si abbiano esigenze particolari (che onestamente non mi sono ancora capitate…). -
Zuul è un API gateway. Fa da unico punto di ingresso al sistema e dirotta le chiamate ai servizi “sottostanti”. E’ basato su Hystrix e Ribbon così da garantire load balancing e circuit breaker.
Se abilitato come proxy (@EnableZuulProxy
), è capace quindi di mappare dinamicamente gli endpoint dei servizi “sottostanti”.
Facciamo un esempio pratico facendo riferimento al progetto demo: la risorsa /api/v1/customers del servizio customer-services è raggiungibile su localhost:9090 direttamentehttp://localhost:9090/api/v1/customers
o tramite Zuul (che risiede sulla 8080)
customer-services/api/v1/customers
Zuul quindi “proxa” sotto “/${spring.application.name}/**” tutte le risorse di quel servizio. E’ possibile disabilitare del tutto questo comportamento (zuul.ignoredServices='*') e/o impostare manualmente le rotte che vogliamo esporre tramite Zuul: questo approccio lo trovo molto interessante se si vuole rendere “pubbliche” alcune API e altre lasciarle interne. Sarà infatti solo Zuul ad essere mappato sul bilanciatore della nostra infrastruttura, quindi tutto quello che esporrà Zuul diventerà accessibile pubblicamente.
Aggiungendo esplicitamente una rotta come nel file zuul-gateway.yml, raggiungeremo la risorsa tramite il proxy come se stessimo chiamando il servizio vero e proprio:api/v1/customers
Inoltre, rimappare alcune API permette di renderle omogenee all’esterno senza far vedere come sono internamente organizzate.
Prendiamo per esempio l’API degli ordini del servizio order-services
// Chiamata diretta: http://localhost:9191/api/v1/customers/1/orders // Tramite proxy: order-services/api/v1/customers/1/orders
Sembra una sottorisorsa di /customers che potrebbe stare nel progetto customer-services, ma in realtà sta in un altro progetto (quello degli ordini appunto): possiamo quindi mapparla esplicitamente su Zuul in modo che dal proxy appaia effettivamente come una sua sottorisorsa, accessibile così:
api/v1/customers/1/orders
Pubblicamente quindi l’API apparirà omogenea, come un consumatore se l’aspetterebbe: internamente però è organizzata secondo le logiche di dominio.
Spring Cloud Sleuth
Dal punto di vista dello sviluppatore o di chi fa assistenza, il primo grande problema che introduce l’architettura a microservizi è la frammentazione dei log. Se c’è un problema, non è pensabile mettersi ad aprire tutti i file di log: anche se possiamo immaginare quale servizio andare a controllare, indovinare in quale replica è successo non dà la stessa soddisfazione di vincere al superenalotto!
E’ necessario quindi aggregare i log in una piattaforma centralizzata, in modo da poter essere consultati avendo ben chiara la sequenza temporale e l’origine di ogni log, per poi risalire al sistema interessato: per far questo è necessario un “correlation id” che viene passato tra tutti i sistemi coinvolti, diverso per ogni chiamata. Spring Cloud Sleuth fa proprio questo per noi! Basta aggiungere la dipendenza nel classpath e specificare la proprietà spring.application.name (occhio a questo commit) e vi ritroverete i log con una tupla formata da
[${spring.application.name}, Trace ID, Span ID, exportable?]
Il Trace ID è un hash unico per tutta la durata della richiesta, mentre lo Span ID, hash anche lui, varia in ogni sottosistema chiamato: Sleuth infatti si preoccupa di propagare questi valori via HTTP (e non solo) durante le chiamate senza che ce ne accorgiamo, in modo da avere una tracciatura completa del giro che effettua la chiamata proveniente dall’utente.
Questi valori possono quindi essere usati da un lato per aggregare i log tramite strumenti “grezzi” (ma alla portata di tutti) come SysLog, oppure più evoluti come ELK (ElasticSearch – LogStash – Kibana); dall’altro, sono utili per analizzare i tempi di risposta medi di una chiamata e delle sue componenti intermedie, in modo da identificare eventuali colli di bottiglia. Zipkin è lo strumento adatto a questo e il quarto parametro exportable? è proprio riferito al lui. ELK e Zipkin usati insieme ci permettono di monitorare in tempo reale l’andamento della nostra applicazione, identificare punti deboli dell’architettura ed intervenire tempestivamente in caso di problemi.
Prendiamo per esempio la chiamata passando da Zuul:
api/v1/customers/1/orders
essa investe 3 servizi:
- il proxy (zuul-gateway): crea il Trace ID e il primo Span ID (uguale al Trace ID)
- il servizio degli ordini (order-services): riceve il Trace ID e un nuovo Span ID (generato dal proxy) da usare per tracciare questa parte della chiamata.
- il servizio dei clienti per validarne l’id (customer-services): riceve sempre lo stesso Trace ID e un altro Span ID (questa volta generato dal servizio degli ordini).
Verranno prodotti 3 log di questo tipo:
2017-12-10 15:07:27.933 INFO [zuul-gateway,f9f588c743de146b,f9f588c743de146b,false] - Requested URI: /api/v1/customers/1/orders 2017-12-10 15:07:28.473 INFO [order-services,f9f588c743de146b,0e6984995310da01,false] - Searching orders for customer id 1 2017-12-10 15:07:28.505 INFO [customer-services,f9f588c743de146b,40b8f5fc5632bb3e,false] - Searching customer by id 1
Conclusioni
In questo post abbiamo cercato di affrontare i punti salienti dal punto di vista implementativo di una architettura a microservizi affrontata con Spring Cloud. Ovviamente possiamo fare microservizi in Java in svariati modi: ultimamente anche i vendor Java EE stanno proponendo soluzioni “embedded” dei propri application server come Wildfly Swarm (con un interessante generatore di progetti), Payara (derivato da GlassFish) e addirittura IBM che sta spingendo WebSphere Liberty in questa direzione. Tutte queste soluzioni supportano il nuovo MicroProfile, che fornisce, tra le altre cose, strumenti per l’health check e fault tolerance.
A prescindere dall’implementazione che decidiamo di adottare, a livello architetturale vi troverete davanti alle stesse sfide, quindi meglio avere gli strumenti più adatti a risolvere i vostri problemi.