Applicazioni distribuite con ZMQ Clone Pattern
ZMQ è una libreria di comunicazione che recentemente ha reso possibile l’utilizzo in modo semplice di pattern di messaggistica. Quando dobbiamo mettere in comunicazione due processi software, molto spesso l’unica modalità che viene in mente è il client-server, in cui la parte server fornisce un servizio ad uno o più client. In realtà questo non è un pattern vero e proprio perché ci sono molte modalità con cui un servizio può essere fornito, anzi, generalmente è un modo rapido per identificare quale parte rimane in ascolto rispetto a quella che si connette. ZMQ cerca di astrarre il modo in cui due parti si connettono tra loro, ponendo invece l’attenzione su come vogliamo che si svolga la messaggistica. I pattern built-in principali di ZMQ sono i seguenti:
- Request-Reply: Richiesta di uno o più servizi da parte di client, utile per implementare meccanismi di RPC.
- Publisher-Subscriber: Distribuzione di dati tramite pubblicazione, utile per notificare aggiornamenti ad una platea di riceventi.
- Pipeline: Esecuzione di task in parallelo e distribuzione, utile per propagare aggiornamenti di dati.
- Exclusive-Pair: Connessione esclusiva tra due thread, utile per implementare meccanismi di IPC.
ZMQ dispone di varie tipologie di socket per utilizzare i pattern base di messaggistica. Attraverso i pattern base si possono poi creare pattern di secondo livello, a seconda delle esigenze dell’applicazione che vogliamo mettere in piedi. In questo breve articolo mostrerò quali sono stati i motivi che hanno reso necessario l’utilizzo di un pattern di messaggistica avanzato come il Clone Pattern.
Problema dell’interconnessione di moduli
Quando abbiamo a che fare con un sistema distribuito, oppure vogliamo separare la logica complessa del nostro sistema su più moduli indipendenti, dobbiamo soprattutto pensare a come essi dovranno comunicare. Ciò comporta lavoro e costi in più, perciò non sempre è conveniente prendere questa decisione, soprattutto su progetti piccoli. Tuttavia l’organizzazione in moduli separati presenta alcuni vantaggi tra cui:
- Un componente più piccolo solitamente ha una complessità inferiore di uno più grande, quindi è più facile da mantenere.
- È possibile lavorare in modo più semplice su team formati da molte persone.
- È più semplice confinare le responsabilità, in caso di malfunzionamenti.
- Si possono aggiornare una o più parti del sistema minimizzando il disservizio.
- Il sistema complessivamente scala meglio, se organizzato correttamente.
- Un eventuale crash rimane confinato al modulo che manifesta il bug.
Siccome la logica dell’intero sistema è distribuita su più moduli, anche lo stato risulta essere distribuito. Di fatto ogni modulo o componente del sistema ha il proprio stato. Spesso la soluzione più immediata si traduce in una connessione diretta tra i moduli, come illustrato in figura.
Questa architettura decentralizzata risulta molto flessibile in quanto non ci sono vincoli. Spesso però una enorme flessibilità si traduce in complessità di gestione. Infatti per ogni modulo deve essere definita la modalità di comunicazione, le politiche di aggiornamento di stato e anche gli endpoint di rete. Al crescere del numero N di moduli, la tendenza è quella di ottenere collegamenti NxN, una situazione non sempre piacevole.
Per dare un taglio più strutturato a questo problema si può utilizzare un’architettura centralizzata. In questo tipo di architettura esiste un componente speciale chiamato broker che assume il ruolo di smistamento e storage dello stato dell’applicazione distribuita. Introdurre un componente centrale porta con sé una serie di pro e contro, che è importante valutare:
- Con un broker si possono evitare problemi di discovery di componenti di rete.
- Ogni componente si collega al broker nello stesso modo, senza dover ridefinire tutte le volte chi deve fare bind e chi connect.
- Il broker è un collo di bottiglia, quindi si deve valutare l’entità del traffico dati.
- Il sistema scala meglio al crescere dei nodi, anche se aumenta il traffico dati del broker. Ad esempio se il sistema è composto da 100 moduli, sarà più semplice connetterli tutti al broker piuttosto che tra loro. Inoltre l’aggiunta di un nuovo modulo non comporta la modifica di quelli già presenti.
- Ci può essere una latenza e jittering sull’invio dei messaggi, in funzione del traffico dati.
Nella figura seguente è mostrata un’architettura centralizzata basata su broker.
Condivisione dello stato
Al fine di mettere in comunicazione più moduli ed al contempo di condividere uno stato comune, il Clone Pattern di ZMQ può essere la soluzione. L’architettura del sistema è centralizzata e basata su broker. Tuttavia il compito del broker non è limitato al solo smistamento dei messaggi, bensì esso svolge anche la funzione di storage dello stato comune. Tale funzionalità di storage è una variazione rispetto al modello centralizzato generico, descritto al paragrafo precedente. Questo pattern non è uno di quelli built-in, però ne sfrutta altri di base per crearne uno più complesso. I pattern built-in utilizzati in Clone sono i seguenti:
- Publisher-Subscriber per distribuire gli aggiornamenti dati ai componenti.
- Pipeline per inoltrare aggiornamenti di stato e messaggi al broker.
- Request-Reply per effettuare sincronizzazioni di stato quando un modulo si inserisce nella rete.
La condivisione dello stato è resa possibile per mezzo del broker, dal momento che i moduli software non possono comunicare direttamente tra loro. Vediamo più in dettaglio qual è la modalità di funzionamento di questo meccanismo:
- Operazione di sincronizzazione. È la prima operazione che un modulo deve espletare, al momento del suo inserimento nella rete Clone. L’obiettivo è la sincronizzazione con tutto lo stato del broker (o meglio della sola sottoparte che interessa). La sincronizzazione avviene tramite una coppia di socket REQ-REP (request-reply). Il broker rimane sempre in ascolto di tali richieste di stato. Ogni qual volta ne riceve una, interrompe il suo flusso di funzionamento normale per servire la richiesta ed inviare tutto lo stato al modulo.
- Operazione di aggiornamento. Rappresenta lo stile di propagazione dei dati all’interno della rete ed avviene attraverso una coppia di socket PUB-SUB (publisher-subscriber). Ogni tipologia di dato prende nome di topic ed un modulo generalmente si sottoscrive solo ai topic a cui è interessato. Possiamo dire che lo stato è un insieme di dati chiave-valore, dove il topic assume il significato di chiave, mentre il valore è un payload binario generico. Il broker, durante il suo funzionamento normale, pubblica aggiornamenti ai moduli ogni volta che avviene una modifica sui dati.
- Operazione di modifica. Rappresenta la modalità con cui un modulo effettua una modifica sui dati di stato. In particolare ogni modulo inoltra la propria modifica di stato attraverso una coppia di socket PUSH-PULL (pipeline). Il broker a sua volta sequenzializza tutte le modifiche ricevute dai moduli e, per ognuna di esse, aggiorna il proprio stato e ripubblica infine la modifica.
Una corretta implementazione di questo pattern deve tener conto delle disconnessioni, che possono avvenire tra il broker e gli altri moduli. Tale funzionalità si può implementare per mezzo di un meccanismo di diagnostica, con pacchetti di heartbeat, inviati continuamente dal broker come se fosse una normale distribuzione di dati. Ogni componente dovrà rilevare tale heartbeat e procedere con la resincronizzazione dello stato in caso di assenza di comunicazione.
Il Clone Pattern definisce soltanto il modo con cui i messaggi passano tra un modulo e un altro, ma non pone dei vincoli invece sul payload. Affinché le interfacce dei componenti software siano chiare e ben definite, il payload dei messaggi scambiati si può definire con altri formalismi come ASN.1, MessagePack, JSON ed altri ancora. Questa decisione però spetta alla business logic degli applicativi.
Utilizzando questo meccanismo di comunicazione in un sistema reale è stato sperimentato un round-trip time di 2 ms per messaggio. In presenza di un carico sufficientemente elevato, ad esempio un messaggio di circa 800 bytes ogni 200 ms, si può avere una latenza che va da 15 ms a 50 ms (dati indicativi). Questa è una performance del tutto accettabile per applicazioni non real-time o che debbano soddisfare deadline strette.
Replica dello stato
Esistono contesti in cui la replica dello stato è fondamentale per avere un certo grado di ridondanza dell’intero sistema. La ridondanza si può utilizzare per aumentare la disponibilità oppure la sicurezza di un sistema. Il caso pratico che vogliamo discutere è relativo al primo punto. Cioè vogliamo estendere questa architettura centralizzata per ottenere un meccanismo a riserva calda (hot stand-by), per aumentare la disponibilità. In questo contesto usiamo il termine sistema per definire l’insieme di tutti i moduli connessi ad un broker, in esecuzione su una macchina. Vogliamo perciò istanziare due sistemi e collegarli tra loro in qualche modo.
Una soluzione possibile è collegare i due broker tra loro. Il componente broker si può estendere ulteriormente in modo che disponga, al proprio interno, della stessa logica di connessione e gestione che ritroviamo nei moduli software. Quindi un broker si presenta all’altro come se fosse un semplice modulo (approccio in stile federation), invece che avere canali di comunicazione privilegiati (approccio in stile peering). Il broker del sistema che assume il ruolo di riserva calda (sistema B) si sottoscrive a tutto lo stato del sistema attivo (sistema A), senza alcuna limitazione. Per costruzione quindi il sistema B otterrà tutti gli aggiornamenti e manterrà allineato il proprio stato con quello del sistema A.
Il sistema B dispone degli stessi moduli del sistema A, ma essi non potranno essere in esecuzione fino a che il sistema A è attivo e funzionante. Sotto questa condizione, la sola funzione del sistema B è mantenere il suo stato allineato. D’altra parte, l’attivazione del sistema B può avvenire con lo stesso meccanismo di heartbeating definito in precedenza. Per esempio se il sistema A smette di funzionare, il sistema B se ne accorge e subentra diventando attivo. A quel punto tutti i moduli del sistema B vengono avviati e la prima operazione che svolgono è quella di sincronizzarsi con lo stato del loro broker. Ne risulta che il funzionamento globale del sistema è ridondato, aumentando la disponibilità.
Naturalmente ci sono alcuni problemi pratici da risolvere, per la decisione di eseguire il changeover tra le due unità:
- Bisogna tarare correttamente i timeout per assicurarsi che il sistema master sia davvero morto, altrimenti si potrebbero verificare changeover indesiderati.
- Sarebbe opportuno avere un doppio canale di comunicazione tra i due broker, per evitare che un guasto di rete possa essere scambiato per un malfunzionamento del sistema attivo.
- I due sistemi devono arrivare ad un consenso se entrambi sono spare, soprattutto al momento del primo avvio.
- È opportuno minimizzare le fasi di changeover in quanto il servizio è down durante il cambio di mastership.
- Prima di attivare il sistema spare, tutti i moduli nel sistema attivo devono essere disattivati.
Conclusioni
ZMQ è una libreria di basso livello che mette a disposizione pattern di messaggistica, che fino a pochi anni fa erano molto complessi da implementare ed utilizzare. Tuttavia focalizzando l’attenzione sulle esigenze della nostra applicazione, possiamo interconnettere in modo efficiente i componenti software. Al giorno d’oggi è decisamente più facile realizzare un’applicazione distribuita.