Chamagne Bastien
Développeur indépendant à Pau

Distribution native d'Erlang

Modèle Acteur Erlang Elixir Système Distribué

Objectif

On l’a vu tout au début ; pour faire un système distribué, il faut plusieurs nœuds qui communiquent ensemble. Dans la plupart des langages de programmation, il n’y a pas de mécanisme intégré pour le faire. Il faut généralement utiliser un service tiers comme un broker de message ou ouvrir et maintenir des sockets et construire tout un système autour.

En Erlang (et tous les langages de la machine virtuelle), le mécanisme existe déjà. On l’appelle : Distributed Erlang. Nous allons voir comment fonctionne ce mécanisme qui sera la base de tous les systèmes distribués que nous verrons ensemble.

Comment ça marche

Le Protocole de distribution utilise une connexion TCP entre chaque nœud du système. Chaque nœud doit avoir un nom soit long soit court. Une authentification via un cookie est requise pour établir la connexion. Un processus en arrière-plan nommé Erlang Port Mapper Daemon s’occupera de la communication entre les nœuds (locaux et distants). Celui-ci sera démarré automatiquement par tout nœud ayant un nom.

La connexion entre les nœuds est bi-directionnelle : si A connaît B alors B connaît A.

Sur la machine virtuelle d’Erlang, les pid sont location transparent : ils contiennent l’information du nœud. Toutes les fonctions qui utilisent un pid fonctionneront automatiquement à travers les nœuds (ex: Process.send(pid, msg)). Les named processes en revanche sont locaux et il faudra souvent les accompagner du nom du nœud de destination (ex: :rpc.server_call(node, name, ref, msg)).

Confiance

Deux nœuds connectés ensemble se font intégralement confiance. Il n’y a pas de contrôle d’accès ni rien de ce genre. Si un nœud est connecté à un autre, il peut tout faire dessus (démarrer des acteurs, tuer des acteurs, écrire sur le disque etc.). C’est très binaire.

Anecdote : C’est pour cette raison que sur la blockchain Archethic, nous n’avons pas pu utiliser la distribution native et avons dû faire notre propre couche peer-to-peer.

Nom de nœud

Pour pouvoir communiquer, un nœud doit donc avoir un nom. Je ne suis pas sûr de l’intérêt des noms courts donc je ne parlerai que de noms longs. Un longname est assigné via le flag --name et ressemble à node1@127.0.0.1 ou node1@chamagne.fr.

La fonction node/0 ou Node.self/0 retourne le nom du nœud actuel. Pour connaître tous les autres nœuds liés, il y a la fonction Node.list/0.

Connexion

Pour connecter deux nœuds entre eux, il suffit que l’un connaisse le nom de l’autre et qu’ils aient tous les deux le même cookie. N’importe quelle fonction qui prend en paramètre un node (nom de nœud) établira la connexion si elle n’est pas déjà effectuée. Node.connect/1 existe uniquement à cette fin mais :net_adm.ping/1 le fera aussi par exemple.

Dans l’exemple ci-dessus, les deux nœuds sont sur ma machine. Le cookie utilisé est donc ~/.erlang.cookie qui sera le même dans les 2 cas. Dans le cas de machines distantes, il faudra utiliser le flag --cookie. Si les cookies sont différents, cette erreur sera notée sur le nœud de destination :

10:52:52.789 [error] ** Connection attempt from node :"node1@127.0.0.1" rejected. Invalid challenge reply. **

Location transparency

Pour démontrer ce principe, à partir de node1, créons un acteur sur node2 qui affichera les valeurs de node() et self().

Après avoir démarré les deux nœuds, collez ceci dans le terminal du node1 :

  n2 = :"node2@127.0.0.1"
  pid = Node.spawn(n2, fn ->
    receive do
      {from, msg} ->
        send(from, "Process #{inspect(self())} from node #{node()} received message: #{msg}")
    end
  end)
  send(pid, {self(), "Msg from node1"})
  flush

Les retours devraient ressembler à ceci :

:"node2@127.0.0.1"
#PID<13235.117.0>
{#PID<0.111.0>, "Msg from node1"}
"Process #PID<0.117.0> from node node2@127.0.0.1 received message: Msg from node1"

On voit que <0.111.0> est le pid du shell du node1. <0.117.0> est le pid de l’acteur créé sur node2 mais node1 le voit avec ce pid: <13235.117.0>. Ceci prouve donc que la fonction anonyme s’est exécutée dans node2. Vous remarquerez que l’envoi de message n’indique pas se trouve l’acteur. C’est le principe de location transparency.

Envoi de messages à un nom

Pour démontrer l’envoi de messages via un nom d’acteur distant, on va tout simplement nommer le shell du node2 et lui envoyer un message via le module :rpc.

Comme on peut le voir, il a fallu préciser à quel nœud envoyer le message. Il existe aussi la fonction abcast/2 qui permet d’envoyer le message à tous les nœuds connus. Ici, la fonction abcast/3 n’attend aucun retour, mais le module :rpc est rempli de fonctions propres à chaque cas d’utilisation.

Noms globaux

Le module :global est un registre de noms commun à tous les nœuds liés. On va pouvoir s’en servir par exemple pour enregistrer des noms globaux. C’est-à-dire des noms que tous les nœuds connaissent et peuvent adresser (sans connaître le nœud où il se trouve).

Comme on peut le voir, dès que les nœuds se connectent, le registre global est partagé. Ici la démonstration est très manuelle mais, sachez que la plupart des modules l’intègrent et on n’a jamais besoin d’utiliser le module :global directement. On pourra simplement préciser {:global, :scheduler} à la création de l’acteur et dans les fonctions du genre GenServer.call/3 et GenServer.cast/2.

Si un acteur derrière un nom global est arrêté, l’information est propagée automatiquement et le nom sera enlevé du registre dans tous les nœuds.

Conclusion

On a vu que grâce à la location transparency, on envoie des messages à un acteur distant de la même façon qu’à un acteur local. Pour le reste, des modules comme Node, :rpc, :pg, :global etc. faciliteront grandement le travail. Il n’est donc vraiment pas difficile de passer d’un système centralisé à un système distribué quand on développe via le modèle d’acteur.

Dans le prochain article, on va développer un exemple concret.