Magazine Focus Emploi

ZeroMQ et load-balancing : un exemple concret

Publié le 05 décembre 2011 par Abouchard

Il y a 2 mois, j’ai écrit un article au sujet de ZeroMQ. Si vous ne l’avez pas encore lu, je vous le conseille, je pense avoir réussi à expliquer de manière assez simple les concepts de base de cette bibliothèque réseau aux fonctionnalités très puissantes.

Pour joindre l’utile à l’agréable (comprendre : pour faire un peu de R&D, monter mes équipes en compétence sur des projets intéressants pour l’entreprise), j’ai demandé à mon administrateur système de sortir de sa «zone de confort», et de coder un petit projet en PHP utilisant ZeroMQ.

Le projet

L’idée est de mettre en place un serveur de sauvegarde centralisé. Ce serveur contient un gros disque dur (en fait, plusieurs, mais j’y reviendrai), sur lequel est stocké une copie de tous les fichiers qui doivent être sauvegardés sur les postes de travail. Tous les jours, ce serveur doit lancer une série de rsync pour synchroniser le disque dur local avec les machines à sauvegarder. C’est l’étape de “backup”.

Par la suite, nous recopions les données sur un second disque dur. Chaque dimanche, une copie complète est effectuée, alors que tous les autres jours de la semaine on se contente de faire une copie incrémentale pour ajouter les nouveaux fichiers. C’est l’étape d’“archivage”.

(oui, je sais, on pourrait utiliser un logiciel de sauvegarde comme Amanda, mais l’aspect R&D serait vachement moins évident, hein)

La réalisation

Tout cela n’a rien de bien sorcier. On pourrait faire un simple programme, lancé par crontab, qui lanceraient séquentiellement un rsync sur chaque poste de travail, puis qui effectuerait séquentiellement l’archivage de chaque machine. Le truc, c’est qu’en faisant ainsi, on perdrait un temps fou. De nos jours, on a des processeurs multi-cœurs, des réseaux gigabit ou plus… Ce serait quand même idiot de ne pas mener plusieurs sauvegardes en parallèle.

La première possibilité qu’on pourrait envisager serait simplement de lancer autant de programmes qu’il y a de machines à sauvegarder. Mais ce serait peut-être un peu violent et difficile à surveiller efficacement. Et plus le nombre de machine à sauvegarder augmente, moins cette méthode serait efficace.

On a donc décidé de mettre en place une architecture comprenant  un serveur qui coordonne le travail de plusieurs « workers », des programmes qui effectuent le boulot réel. L’idée est de démarrer un nombre fini de workers, ce qui détermine le nombre de tâches effectuées simultanément, et de leur indiquer les machines à sauvegarder.
ZeroMQ excelle dans ce genre de situation. Les workers vont se connecter au serveur, et attendre qu’il leur envoie des ordres. Le serveur, lui se contentera d’envoyer des ordres séquentiels sur sa socket ; ZeroMQ se chargera de les délivrer en les répartissant aux différents clients (c’est la fonctionnalité de load-balancing intégrée à ZeroMQ).

ZeroMQ et load-balancing : un exemple concret

Petit rappel : À la base, ZeroMQ fournit 3 types de communication. Le REQ/REP sert à faire du client-serveur classique (on fait une requête, on reçoit une réponse) ; le PUSH/PULL pour envoyer des données à sens unique ; le PUB/SUB pour envoyer des données à tous ceux qui s’y sont abonnés. La principale différence entre les deux derniers est que le PUB envoie ses paquets de données − en même temps − à tous les SUB qui y sont connectés, alors que le PUSH envoie ses données successivement à chacun des PULL connectés − l’un après l’autre.

Le problème

Mon admin sys. est revenu vers moi avec un comportement étrange. Quelque soit le nombre de workers, un seul d’entre eux recevait tous les paquets de données. Aïe.

Voici, en très simplifié, le code du serveur :

// création de la socket ZMQ
$ctx = new ZMQContext();
$socket = new ZMQSocket($ctx, ZMQ::SOCKET_PUSH);
$socket->bind('tcp://*:1234');

// création des workers en tâche de fond
for ($i = 0; $i < $nbrWorkers; $i++)
   exec('/path/to/worker.php >> /path/to/log 2>&1 &');

// envoi des ordres de sauvegarde
foreach ($machines as $machine)
   $socket->send($machine);

// envoi des ordres de "suicide"
for ($i = 0; $i < $nbrWorkers; $i++)
   $socket->send('KILL');

Le code du client (là encore, très simplifié) :

// création de la socket ZMQ
$ctx = new ZMQContext();
$socket = new ZMQSocket($ctx, ZMQ::SOCKET_PULL);
$socket->connect('tcp://localhost:1234');

// traitement
while (true) {
   // réception des données
   $msg = $socket->recv();

   // gestion du "suicide"
   if ($msg == 'KILL')
   exit(0);

   // sauvegarde de la machine demandée
   backup($msg);
}

Reprenons le déroulement. Le serveur commence par créer sa socket ZMQ. Puis il crée des sous-processus pour instancier autant de workers que prévu. Dans la foulée, il envoie les noms des machines à sauvegarder. Puis il envoie autant d’ordres de « suicide » qu’il a créé de workers (pour leur demander de s’arrêter une fois que le travail est terminé).

Imaginons que nous avons cinq machines à sauvegarder (nommées A, B, C, D et E), et trois workers (nommés Prime, Seconde et Tierce).
Le serveur envoie les messages dans l’ordre suivant : A, B, C, D, E, KILL, KILL, KILL.

On peut imaginer que les réceptions se fassent de la sorte :

  • A => Prime
  • B => Seconde
  • C => Tierce
  • D => Prime
  • E => Seconde
  • KILL => Tierce
  • KILL => Prime
  • KILL => Seconde

Tout irait bien ; on peut voir que toutes les machines seraient sauvegardées en parallélisant les traitements (jusqu’à 3 sauvegardes simultanées), puis que chaque worker recevrait bien une instruction lui demandant de s’arrêter. Et pourtant, ce n’est pas le cas. L’un des workers reçoit tous les messages, et les deux autres rien du tout.

L’explication

ZeroMQ est une bibliothèque dont l’exécution est très rapide. Dans le code présenté ci-dessus, la partie la plus lente de l’exécution tient dans la connexion réseau ; le moment où les socket BSD entrent en jeu pour connecter un programme à un autre.

En fait, au moment où le premier worker se connecte au serveur, ZeroMQ a déjà dans sa file d’attente interne tous les messages qui doivent être envoyés. Donc, dès que cette première connexion est établie, il lui balance tout. Ce qui est normal, car à ce moment-là il n’y a pas encore d’autre connexion avec laquelle faire le load-balancing.

La solution est donc d’attendre que toutes les connexions soient effectuées avant d’envoyer les données. Il y a deux manières d’y arriver ; l’une est rapide mais crado, l’autre est bien plus propre mais un poil plus complexe.

Méthode quick and dirty

Le plus simple, pour bien comprendre où se situait le problème, est d’ajouter une temporisation entre la création des workers et l’envoi des données. Cela afin de laisser aux workers le temps de se connecter au serveur et d’être « enregistrés » dans le load-balancing.

Voici le code du serveur adapté :

// création de la socket ZMQ
$ctx = new ZMQContext();
$socket = new ZMQSocket($ctx, ZMQ::SOCKET_PUSH);
$socket->bind('tcp://*:1234');

// création des workers en tâche de fond
for ($i = 0; $i < $nbrWorkers; $i++)
   exec('/path/to/worker.php >> /path/to/log 2>&1 &');

// temporisation de 3 secondes
sleep(3);

// envoi des ordres de sauvegarde
foreach ($machines as $machine)
   $socket->send($machine);

// envoi des ordres de "suicide"
for ($i = 0; $i < $nbrWorkers; $i++)
   $socket->send('KILL');

Ah oui, je sais, c’est sale. Mais j’avais prévenu, et ça marche. Par contre, si on augmente le nombre de workers à lancer, on risque d’avoir une temporisation insuffisante. Et si la machine est spécialement lente à ce moment-là, on risque encore de rater des workers.

Méthode propre

Pour bien faire les choses, il faut que les workers envoient un message au serveur, pour lui signifier qu’ils sont prêts à recevoir des données. Ainsi, le serveur n’enverra ses ordres qu’après que tous les workers se soient déclarés.

Comme bien souvent avec ZeroMQ, cela impose d’ouvrir un canal de communication supplémentaire. Mais, comme toujours avec ZeroMQ, il ne faut pas avoir peur de le faire, car c’est simple et rapide à mettre en œuvre.

Cela donne donc une infrastructure de la forme suivante :

ZeroMQ et load-balancing : un exemple concret

Le serveur doit donc ouvrir deux sockets, l’une qui lui servira à envoyer ses ordres aux workers, l’autre pour recevoir les messages envoyés par ceux-ci.
Cette seconde socket est d’ailleurs bien utile : elle permettra de remonter d’autres types d’informations, par exemple pour avertir le serveur à chaque fois qu’une sauvegarde est terminée.

Cela nous amène à un serveur qui ressemble à ceci :

// création des sockets ZMQ
$ctx = new ZMQContext();
$output = new ZMQSocket($ctx, ZMQ::SOCKET_PUSH);
$output->bind('tcp://*:1234');
$input = new ZMQSocket($ctx, ZMQ::SOCKET_PULL);
$input->bind('tcp://*:1235');

// création des workers en tâche de fond
for ($i = 0; $i < $nbrWorkers; $i++)
   exec('/path/to/worker.php >> /path/to/log 2>&1 &');

// réception des confirmations des workers
for ($i = 0; $i < $nbrWorkers; $i++)
   $input->recv();

// envoi des ordres de sauvegarde
foreach ($machines as $machine)
   $output->send($machine);

// envoi des ordres de "suicide"
for $i = 0; $i < $nbrWorkers; $i++)
   $output->send('KILL');

Le code du client :

// création des sockets ZMQ
$ctx = new ZMQContext();
$input = new ZMQSocket($ctx, ZMQ::SOCKET_PULL);
$input->connect('tcp://localhost:1234');
$output = new ZMQSocket($ctx, ZMQ::SOCKET_PUSH);
$output->connect('tcp://localhost:1235');

// envoi de la confirmation de connexion au serveur
$output->send(1);

// traitement
while (true) {
   // réception des données
   $msg = $input->recv();

   // gestion du "suicide"
   if ($msg == 'KILL')
   exit(0);

   // sauvegarde de la machine demandée
   backup($msg);
}

Et là, tout fonctionne à la perfection.


Retour à La Une de Logo Paperblog

A propos de l’auteur


Abouchard 392 partages Voir son profil
Voir son blog

l'auteur n'a pas encore renseigné son compte l'auteur n'a pas encore renseigné son compte

Dossier Paperblog