Lavoro e studio Elixir da qualche anno ma ci sono aspetti che non avevo ancora approfondito nonostante alcuni siano dei “Cavalli di battaglia” della BEAM.
La Beam, macchina astratta (e vagamente mitologica)
Mi sono avvicinato ad Elixir più per il linguaggio che per la piattaforma (mi incuriosiscono i linguaggi di programmazione) ma col tempo ho scoperto che sotto un elegante linguaggio si nasconde una potentissima e solidissima piattaforma: la BEAM (Bogdan/Björn’s Erlang Abstract Machine).
Una delle caratteristiche più decantate della BEAM è il fatto che permetta di far girare le applicazioni su un cluster di macchine (chiamate nodi) rendendole scalabili all’infinito. Quello che oggi è fattibile grazie a Kubernetes o altri strumenti per gestire applicazioni distribuite, Elixir (e suo zio Erlang) lo fanno da quando sono nati, senza avvalersi di strumenti esterni.
È un aspetto che non avevo mai approfondito un po’ per la reale mancanza di necessità, un po’ per interessi personali, fino a qualche settimana fa quando mi sono deciso ad affrontare la tematica e a capire come funziona in pratica la distribuzione delle applicazioni Elixir.
In questo articolo vorrei raccontarvi cosa ho imparato.
Le connessioni
Ogni applicazione Elixir gira su una istanza della BEAM, i processi e i messaggi che invia e riceve stanno all’interno dell’istanza. Ma un’applicazione Elixir (e Erlang) può essere messa in connessione con un’altra istanza e ogni istanza viene chiamata nodo. Questo permette ad un’applicazione di essere ridondante e di distribuire il carico sui vari nodi su cui è in esecuzione.
Per mostrare il meccanismo di base si possono eseguire due REPL su du istanze del terminale e metterle in comunicazione:
Terminale 1
$ iex --name n1@127.0.0.1
Erlang/OTP 26 [erts-14.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit] [dtrace]
Interactive Elixir (1.16.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(n1@127.0.0.1)1>
Terminale 2
$ iex --name n2@127.0.0.1
Erlang/OTP 26 [erts-14.2.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit] [dtrace]
Interactive Elixir (1.16.1) - press Ctrl+C to exit (type h() ENTER for help)
iex(n1@127.0.0.1)1>
Iniziamo con l’avviare due istanze di iex
specificando un nome, tramite il flag –name, che serve per identificare il nodi all’interno della rete. Le due istanze sono in esecuzione ma non si “conoscono”, per potersi scambiare messaggi devono essere messe all’interno dello stesso cluster.
Elixir permette di farlo usando la funzione connect
del modulo Node:
iex(n1@127.0.0.1)1> Node.connect(:"n2@127.0.0.1")
Dopo questo comando i due nodi sono nello stesso cluster e possono comunicare, di fatto possono essere considerati quasi una stessa istanza della BEAM.
Il prossimo step è registrare il nodo 1, usando la funzione register_name
del modulo :global
di Erlang.
iex(n1@127.0.0.1)1> :global.register_name(:n1, self())
:yes
Questa registrazione permette agli altri nodi di avere informazioni sul nodo 1, informazioni che servono ad esempio a recuperare il PID del processo IEX:
iex(n2@127.0.0.1)2> :global.whereis_name(:n1)
#PID<13352.119.0>
iex(n2@127.0.0.1)3> send(pid("13352.119.0"), :hello)
:hello
La send
invia il messaggio :hello al nodo 1. Notare come il PID, a differenza del solito, non inizia con 0 ma con un numero a 5 cifre: i PID che iniziano con un numero diverso da zero sono PID remoti (se eseguite il comando self sul nodo 1 otterrete #PID<0.119.0>
Per verificare che il messaggio sia effettivamente arrivato possiamo eseguire una flush
sul nodo 2 che dovrebbe aver ricevuto il messaggio:
iex(n1@127.0.0.1)2> flush
:hello
:ok
Questo è il punto di partenza, diciamo che sono le basi. Naturalmente qui è stato utilizzato IEX come esempio, ma si potrebbero fare le stesse operazioni scrivendo un’applicazione che all’avvio effettua il collegamento con il nodo remoto e avvia lo scambio di messaggi, anche eventualmente utilizzando un GenServer.
Fattibile ma non pratico.
Costruire un cluster con libcluster e horde
Naturalmente eseguire manualmente la costruzione del cluster non è comodo. Per questo ci si avvale di librerie che ci evitano il boilerplate per il setup oltre che a gestire alcuni scenari che in produzione si possono rivelare interessanti.
Una delle più popolari librerie Elixir per il setup di un cluster è libcluster la libreria effettua la predisposizione di un cluster di applicazioni e permette l’aggiunta e rimozione a caldo di nodi scegliendo anche che tipo di algoritmo si vuole utilizzare per la gestione del cluster.
Oltre a questo, in una applicazione reale, serviranno un altro paio di strumenti: un supervisor che sia in grado di spawnare processi su diversi nodi (eventualmente applicando politiche di bilanciamento) e un registro distribuito per poter trovare i processi sui vari nodi del cluster. Queste due funzionalità si trovano nella libreria horde
che tra poco vedremo in azione.
Per avere un riscontro pratico di quello che sto scrivendo ho realizzato un’applicazione d’esempio: il classico kata del Game of Life…distrubuito!
Le celle sono state modellate come processi e il supervisor le crea sui diversi nodi su cui l’applicazione è distribuita! Qui trovate il codice sorgente.
Prima di addentrarci negli aspetti relativi alla distribuzione, analizziamo come è stato realizzato il Game of Life.
Game of Life distribuito
La prima cosa che si nota durante lo sviluppo è che non si deve scrivere il codice tenendo in considerazione il fatto che dovrà essere distribuito su N nodi: il codice che governa le regole del gioco (la business logic) è agnostico dall’ambiente in cui andrà deployato. Vanno però rispettati i principi del buon codice.
Il Game of Life è un automa cellulare ideato dal matematico John Conway in cui le cellule vivono e prosperano (o muoiono) all’interno di un mondo discreto (ogni elemento del mondo è rappresentato dalla sua posizione e dal suo stato) e si basa su 4 semplici regole:
- Qualsiasi cella viva con meno di due celle vive adiacenti muore, come per effetto d’isolamento;
- Qualsiasi cella viva con due o tre celle vive adiacenti sopravvive alla generazione successiva;
- Qualsiasi cella viva con più di tre celle vive adiacenti muore, come per effetto di sovrappopolazione;
- Qualsiasi cella morta con esattamente tre celle vive adiacenti diventa una cella viva, come per effetto di riproduzione.
Questo gioco vede numerossisime implementazioni in tanti linguaggi, è anche un ottimo esercizio di programmazione per una serie di caratteristiche intrinseche del gioco. Ad esempio nella sua formulazione il Game of Life si sviluppa in un mondo senza limiti fisici, mentre spesso i programmatori partono dalla definizione del mondo in termini di larghezza e altezza.
A parte il folklore sul quale sarebbe bello andare avanti a discutere, tornando al progetto usato come esempio, le celle sono state modellate come GenServer nel cui stato viene persistita la posizione {x, y} e la generazione (non strettamente necessaria ma interessante per valutare l’età di una cella).
Questo modulo contiene la logica per le prime 3 regole del gioco che riguardano la continuazione o l’interruzione della vita di una cella. Viene fatto in 2 step, nel primo (:define_next_gen
) viene deciso che cosa succederà alla cella nel prossimo ciclo di clock, nel secondo step (:apply
) viene effettivamente applicato il nuovo stato. Questa separazione serve per poter avere il mondo “freezato” mentre le varie celle verificano lo stato del vicinato.
defmodule Golex.Cell do
use GenServer, restart: :transient
def start_link([coord]) do
GenServer.start_link(__MODULE__, [coord], name: via_tuple(coord))
end
def init([coord]) do
{:ok, %{coord: coord, gen: 0, next_state: :none}}
end
def define_next_gen(coord) do
GenServer.call(via_tuple(coord), :define_next_gen)
end
def apply(coord) do
GenServer.call(via_tuple(coord), :apply)
end
def handle_call(:define_next_gen, _from, %{coord: coord, next_state: :none} = state) do
if count_neighbours(coord) == 2 || count_neighbours(coord) == 3 do
{:reply, %{state | next_state: :live}, %{state | next_state: :live}}
else
{:reply, %{state | next_state: :dead}, %{state | next_state: :dead}}
end
end
def handle_call(:apply, _from, %{next_state: :dead} = state), do: {:stop, :shutdown, state, state}
def handle_call(:apply, _from, %{next_state: :live} = state), do: {:reply, state, %{state | gen: state.gen + 1, next_state: :none}}
def count_neighbours({x, y}) do
[ {x-1, y}, {x+1, y}, {x-1, y-1}, {x-1, y+1}, {x+1, y-1}, {x+1, y+1}, {x, y-1}, {x, y+1}]
|> Enum.reduce(0, fn coord, acc ->
case Horde.Registry.lookup(Golex.CellRegistry, coord) do
[{_pid, _}] -> acc + 1
[] -> acc
end
end)
end
defp via_tuple(coord) do
{:via, Horde.Registry, {Golex.CellRegistry, coord}}
end
end
Non c’è molto da dire su questa parte di codice, rifaccio solo notare che non ci sono riferimenti a “situazioni distribuite” si tratta di un normale genserver che gestisce le logiche del gioco.
La quarta regola del gioco viene gestita dal modulo God che si occupa di trovare le celle vuote con il giusto numero di vicini, quando ne trova una con 3 celle vive adiacenti fa partire un nuovo processo:
live_cells = Horde.Registry.select(Golex.CellRegistry, [{{:"$1", :_, :_}, [], [:"$1"]}])
live_cells
|> find_potential_neighbours()
|> count_neighbours()
|> select_new_live_cells(live_cells)
|> born_life()
L’elenco delle celle vive viene fornito dal registry di Horde
che funge da “database” di tutte le celle vive sugli N nodi che compongo il cluster applicativo.
Dare vita ad una nuova cella consiste nel chiedere al supervisor delle celle di avviare un nuovo processo:
{:ok, _} = Golex.HordeSupervisor.start_cell([cell])
HordeSupervisor
è il supervisor distribuito che monitora le celle vive.
La gestione della distribuzione, abbiamo già detto, passa da libcluster
e horde
e la configurazione avviene nel modulo Application
dove vengono avviati i vari processi:
def start(_type, _args) do
topologies =
children = [
{Cluster.Supervisor, [[
golex: [strategy: Cluster.Strategy.Gossip]
], [name: Golex.ClusterSupervisor]]},
{Horde.Registry, [members: :auto, keys: :unique, name: Gole x.CellRegistry]},
Golex.HordeRegistry,
Golex.HordeSupervisor,
Golex.NodeObserver,
]
opts = [strategy: :one_for_one, name: Golex.Supervisor]
Supervisor.start_link(children, opts)
end
Nel supervisor tree dell’applicazione vengono avviati alcuni processi che concorrono a rendere l’applicazione facilmente distribuitile su più nodi. Il Cluster.Supervisor si occupa della gestione dei nodi del cluster, fa parte della libreria libscluster
e si occupa di gestire i nodi.
HordeRegistry
e HordeSupervisor
sono il registro distribuito e il supervisor fornito dalla libreria Horde. In questo caso sono stati leggermente customizzati, scrivendo dei moduli ad-hoc, per integrarsi con libcluster
in modo che ogni nuova istanza avviata dell’applicazione passi l’elenco dei nodi al Registry e al Supervisor per poter dinamicamente aggiornare l’elenco.
NodeObserver è un GenServer
che rimane in ascolto dei messaggi nodeup
e nodedown
registrandosi tramite una chiamata a :net_kernel.monitor_nodes(true, node_type: :visible)
. Questo processo è responsabile dell’aggiunta e rimozione a runtime dei nodi. Qualora venisse avviato un nuovo nodo la gestione del messaggio di nodeup permette l’aggiunta al cluster del nodo e il relativo ribilanciamento dei processi.
Il codice completo dell’applicazione si trova qui, si può scaricare e usare da IEX usando le funzioni del modulo Utils
per generare celle e vederle evolvere nel tempo.
Questo è tutto quello che è necessario fare per rendere l’applicazione distribuita su più nodi.
Cosa succede adesso?
Se l’applicazione viene avviata come singola istanza si comporta come se fosse una normale applicazione “monolitica“, le celle create durante il run (si veda il modulo Utlils per avere un po’ di macro funzioni) vengono avviate e ad ogni tick viene decisa la loro sorte.
Se però si fa partire un’altra istanza dell’applicazione viene creato un cluster di due nodi e parte delle celle in esecuzione sul primo nodo potranno essere spostate sul nuovo nodo per bilanciare il carico. Anche le nuove celle create o nate dall’avanzamento del clock del gioco verranno create su una delle istanze del cluster con l’obiettivo di tenere bilanciato il carico. Aggiungere nuovi nodi causa un ribilanciamento del carico come pure il rimuovere nodi: le celle attive sul nodo che è stato rimosso verranno riavviate su quelli rimasti.
Quindi l’applicazione si comporta come un’applicazione distribuita in grado di scalare continuamente semplicemente aggiungendo nuovi nodi al cluster anche in modo dinamico.
Implementando l’applicazione di esempio sono rimasto stupito da due aspetti: il primo è che può essere scritta senza pensare al fatto che diventerà un’applicazione distribuita, ma semplicemente tenendo conto dei soliti principi di progettazione di un’applicazione che sfrutta l’actor model (isolamento dello stato, comunicazione tramite messaggi, ecc…).
La seconda è la semplicità con cui l’applicazione passa da essere a “singola istanza” a “multi nodo” grazie anche all’uso delle librerie libcluster
e horde
.
Ancora una volta Elixir e la piattaforma Erlang si confermano essere strumenti molto evoluti e adatti alle sfide moderne. 💜