Cinq étudiants et trois magasins clé-valeur distribués

Ou comment nous avons écrit une bibliothèque client C++ pour ZooKeeper, etcd et Consul KV

Dans le monde des systèmes distribués, il existe un certain nombre de tâches typiques : stocker des informations sur la composition du cluster, gérer la configuration des nœuds, détecter les nœuds défaillants, choisir un leader et d'autres. Pour résoudre ces problèmes, des systèmes distribués spéciaux ont été créés - les services de coordination. Nous allons maintenant nous intéresser à trois d'entre eux : ZooKeeper, etcd et Consul. Parmi toutes les riches fonctionnalités de Consul, nous nous concentrerons sur Consul KV.

Cinq étudiants et trois magasins clé-valeur distribués

Essentiellement, tous ces systèmes sont des magasins clé-valeur linéarisables et tolérants aux pannes. Bien que leurs modèles de données présentent des différences significatives, dont nous parlerons plus tard, ils résolvent les mêmes problèmes pratiques. Évidemment, chaque application qui utilise le service de coordination est liée à l'une d'elles, ce qui peut conduire à la nécessité de prendre en charge plusieurs systèmes dans un même centre de données qui résolvent les mêmes problèmes pour différentes applications.

L’idée de résoudre ce problème est née dans une agence de conseil australienne, et c’est à nous, une petite équipe d’étudiants, de la mettre en œuvre, c’est ce dont je vais parler.

Nous avons réussi à créer une bibliothèque qui fournit une interface commune pour travailler avec ZooKeeper, etcd et Consul KV. La bibliothèque est écrite en C++, mais il est prévu de la porter dans d'autres langages.

Modèles de données

Pour développer une interface commune à trois systèmes différents, vous devez comprendre ce qu'ils ont en commun et en quoi ils diffèrent. Voyons cela.

Gardien de zoo

Cinq étudiants et trois magasins clé-valeur distribués

Les clés sont organisées en arborescence et sont appelées nœuds. En conséquence, pour un nœud, vous pouvez obtenir une liste de ses enfants. Les opérations de création d'un znode (create) et de modification d'une valeur (setData) sont séparées : seules les clés existantes peuvent être lues et modifiées. Des montres peuvent être attachées aux opérations de vérification de l'existence d'un nœud, de lecture d'une valeur et d'obtention d'enfants. Watch est un déclencheur unique qui se déclenche lorsque la version des données correspondantes sur le serveur change. Les nœuds éphémères sont utilisés pour détecter les pannes. Ils sont liés à la session du client qui les a créés. Lorsqu'un client ferme une session ou cesse de notifier ZooKeeper de son existence, ces nœuds sont automatiquement supprimés. Les transactions simples sont prises en charge - un ensemble d'opérations qui réussissent toutes ou échouent si cela n'est pas possible pour au moins l'une d'entre elles.

etcd

Cinq étudiants et trois magasins clé-valeur distribués

Les développeurs de ce système se sont clairement inspirés de ZooKeeper, et ont donc tout fait différemment. Il n’y a pas de hiérarchie de clés, mais elles forment un ensemble ordonné lexicographiquement. Vous pouvez obtenir ou supprimer toutes les clés appartenant à une certaine plage. Cette structure peut paraître étrange, mais elle est en réalité très expressive et une vision hiérarchique peut facilement être imitée à travers elle.

etcd n'a pas d'opération standard de comparaison et de définition, mais il a quelque chose de mieux : les transactions. Bien sûr, ils existent dans les trois systèmes, mais les transactions etcd sont particulièrement intéressantes. Ils se composent de trois blocs : contrôle, succès, échec. Le premier bloc contient un ensemble de conditions, les deuxième et troisième opérations. La transaction est exécutée de manière atomique. Si toutes les conditions sont vraies, alors le bloc de réussite est exécuté, sinon le bloc d'échec est exécuté. Dans l'API 3.3, les blocs de réussite et d'échec peuvent contenir des transactions imbriquées. Autrement dit, il est possible d’exécuter atomiquement des constructions conditionnelles d’un niveau d’imbrication presque arbitraire. Vous pouvez en savoir plus sur les contrôles et les opérations existants à partir de documentation.

Ici aussi, les montres existent, même si elles sont un peu plus compliquées et réutilisables. Autrement dit, après avoir installé une montre sur une gamme clé, vous recevrez toutes les mises à jour de cette gamme jusqu'à ce que vous annuliez la montre, et pas seulement la première. Dans etcd, l'analogue des sessions client ZooKeeper sont les baux.

Consul K.V.

Il n'y a pas non plus de structure hiérarchique stricte ici, mais Consul peut créer l'impression qu'elle existe : vous pouvez obtenir et supprimer toutes les clés avec le préfixe spécifié, c'est-à-dire travailler avec le « sous-arbre » de la clé. De telles requêtes sont dites récursives. De plus, Consul peut sélectionner uniquement les touches qui ne contiennent pas le caractère spécifié après le préfixe, ce qui correspond à l'obtention d'« enfants » immédiats. Mais il convient de rappeler qu'il s'agit précisément de l'apparition d'une structure hiérarchique : il est tout à fait possible de créer une clé si son parent n'existe pas ou de supprimer une clé qui a des enfants, tandis que les enfants continueront à être stockés dans le système.

Cinq étudiants et trois magasins clé-valeur distribués
Au lieu de surveiller, Consul bloque les requêtes HTTP. Essentiellement, il s'agit d'appels ordinaires à la méthode de lecture de données, pour lesquels, avec d'autres paramètres, la dernière version connue des données est indiquée. Si la version actuelle des données correspondantes sur le serveur est supérieure à celle spécifiée, la réponse est renvoyée immédiatement, sinon - lorsque la valeur change. Il existe également des sessions qui peuvent être attachées aux clés à tout moment. Il est à noter que contrairement à etcd et ZooKeeper, où la suppression de sessions entraîne la suppression des clés associées, il existe un mode dans lequel la session en est simplement dissociée. Disponible transactions, sans succursales, mais avec toutes sortes de chèques.

Mettre tous ensemble

ZooKeeper possède le modèle de données le plus rigoureux. Les requêtes de plage expressives disponibles dans etcd ne peuvent pas être efficacement émulées dans ZooKeeper ou Consul. En essayant d'incorporer le meilleur de tous les services, nous nous sommes retrouvés avec une interface presque équivalente à l'interface ZooKeeper avec les exceptions significatives suivantes :

  • nœuds de séquence, de conteneur et TTL non supporté
  • Les ACL ne sont pas prises en charge
  • la méthode set crée une clé si elle n'existe pas (dans ZK setData renvoie une erreur dans ce cas)
  • Les méthodes set et cas sont séparées (dans ZK, elles sont essentiellement la même chose)
  • la méthode d'effacement supprime un nœud ainsi que son sous-arbre (dans ZK delete renvoie une erreur si le nœud a des enfants)
  • Pour chaque clé, il n'y a qu'une seule version - la version valeur (en ZK il y en a trois)

Le rejet des nœuds séquentiels est dû au fait qu'etcd et Consul ne les prennent pas en charge de manière intégrée, et ils peuvent être facilement implémentés par l'utilisateur au-dessus de l'interface de bibliothèque résultante.

L'implémentation d'un comportement similaire à ZooKeeper lors de la suppression d'un sommet nécessiterait de conserver un compteur enfant distinct pour chaque clé dans etcd et Consul. Puisque nous avons essayé d'éviter de stocker des méta-informations, il a été décidé de supprimer l'intégralité du sous-arbre.

Subtilités de mise en œuvre

Examinons de plus près certains aspects de l'implémentation de l'interface de la bibliothèque dans différents systèmes.

Hiérarchie dans etcd

Maintenir une vue hiérarchique dans etcd s'est avéré être l'une des tâches les plus intéressantes. Les requêtes par plage facilitent la récupération d'une liste de clés avec un préfixe spécifié. Par exemple, si vous avez besoin de tout ce qui commence par "/foo", vous demandez une plage ["/foo", "/fop"). Mais cela renverrait l’intégralité du sous-arbre de la clé, ce qui pourrait ne pas être acceptable si le sous-arbre est grand. Au début, nous avions prévu d'utiliser un mécanisme de traduction clé, implémenté dans zetcd. Il s'agit d'ajouter un octet au début de la clé, égal à la profondeur du nœud dans l'arborescence. Laisse moi te donner un exemple.

"/foo" -> "u01/foo"
"/foo/bar" -> "u02/foo/bar"

Ensuite, récupérez tous les enfants immédiats de la clé "/foo" possible en demandant une gamme ["u02/foo/", "u02/foo0"). Oui, en ASCII "0" se tient juste après "/".

Mais comment mettre en œuvre la suppression d’un sommet dans ce cas ? Il s'avère que vous devez supprimer toutes les plages du type ["uXX/foo/", "uXX/foo0") pour XX de 01 à FF. Et puis nous sommes tombés sur limite du nombre d'opérations en une seule transaction.

En conséquence, un système simple de conversion de clé a été inventé, qui a permis de mettre en œuvre efficacement à la fois la suppression d'une clé et l'obtention d'une liste d'enfants. Il suffit d'ajouter un caractère spécial avant le dernier jeton. Par exemple:

"/very" -> "/u00very"
"/very/long" -> "/very/u00long"
"/very/long/path" -> "/very/long/u00path"

Puis supprimer la clé "/very" se transforme en suppression "/u00very" et gamme ["/very/", "/very0"), et obtenir tous les enfants - dans une demande de clés de la gamme ["/very/u00", "/very/u01").

Supprimer une clé dans ZooKeeper

Comme je l'ai déjà mentionné, dans ZooKeeper, vous ne pouvez pas supprimer un nœud s'il a des enfants. Nous voulons supprimer la clé ainsi que le sous-arbre. Que dois-je faire? Nous le faisons avec optimisme. Tout d’abord, nous parcourons le sous-arbre de manière récursive, obtenant les enfants de chaque sommet avec une requête distincte. Ensuite, nous construisons une transaction qui tente de supprimer tous les nœuds du sous-arbre dans le bon ordre. Bien entendu, des changements peuvent survenir entre la lecture d’un sous-arbre et sa suppression. Dans ce cas, la transaction échouera. De plus, le sous-arbre peut changer au cours du processus de lecture. Une requête pour les enfants du nœud suivant peut renvoyer une erreur si, par exemple, ce nœud a déjà été supprimé. Dans les deux cas, nous répétons tout le processus.

Cette approche rend la suppression d'une clé très inefficace si elle a des enfants, et encore plus si l'application continue de travailler avec le sous-arbre, en supprimant et en créant des clés. Cependant, cela nous a permis d'éviter de compliquer la mise en œuvre d'autres méthodes dans etcd et Consul.

défini dans ZooKeeper

Dans ZooKeeper, il existe des méthodes distinctes qui fonctionnent avec la structure arborescente (create, delete, getChildren) et qui fonctionnent avec les données dans les nœuds (setData, getData). De plus, toutes les méthodes ont des conditions préalables strictes : create renverra une erreur si le nœud a déjà été créé, supprimez ou setData – s’il n’existe pas déjà. Nous avions besoin d'une méthode définie qui puisse être appelée sans penser à la présence d'une clé.

Une option consiste à adopter une approche optimiste, comme pour la suppression. Vérifiez si un nœud existe. S'il existe, appelez setData, sinon créez. Si la dernière méthode a renvoyé une erreur, répétez-la à nouveau. La première chose à noter est que le test d’existence est inutile. Vous pouvez immédiatement appeler create. Une réussite signifie que le nœud n’existait pas et qu’il a été créé. Sinon, create renverra l'erreur appropriée, après quoi vous devrez appeler setData. Bien entendu, entre les appels, un sommet pourrait être supprimé par un appel concurrent, et setData renverrait également une erreur. Dans ce cas, vous pouvez tout recommencer, mais est-ce que cela en vaut la peine ?

Si les deux méthodes renvoient une erreur, nous savons avec certitude qu’une suppression concurrente a eu lieu. Imaginons que cette suppression ait eu lieu après l'appel de set. Alors, quelle que soit la signification que nous essayons d’établir, elle est déjà effacée. Cela signifie que nous pouvons supposer que set a été exécuté avec succès, même si rien n’a été écrit.

Plus de détails techniques

Dans cette section, nous ferons une pause dans les systèmes distribués et parlerons de codage.
L'une des principales exigences du client était le multiplateforme : au moins un des services devait être pris en charge sous Linux, MacOS et Windows. Au départ, nous avons développé uniquement pour Linux et avons commencé à tester sur d'autres systèmes plus tard. Cela a causé de nombreux problèmes qui, pendant un certain temps, ne comprenaient absolument pas comment les aborder. En conséquence, les trois services de coordination sont désormais pris en charge sous Linux et MacOS, tandis que seul Consul KV est pris en charge sous Windows.

Dès le début, nous avons essayé d'utiliser des bibliothèques prêtes à l'emploi pour accéder aux services. Dans le cas de ZooKeeper, le choix s'est porté sur ZooKeeperC++, qui n'a finalement pas réussi à se compiler sous Windows. Cela n'est cependant pas surprenant : la bibliothèque est positionnée comme étant uniquement Linux. Pour Consul, la seule option était ppconsul. Il fallait y ajouter un support sessions и transactions. Pour etcd, aucune bibliothèque à part entière prenant en charge la dernière version du protocole n'a été trouvée, nous avons donc simplement client grpc généré.

Inspirés par l'interface asynchrone de la bibliothèque ZooKeeper C++, nous avons décidé d'implémenter également une interface asynchrone. ZooKeeper C++ utilise des primitives futur/promesse pour cela. En STL, malheureusement, ils sont implémentés de manière très modeste. Par exemple, non puis méthode, qui applique la fonction passée au résultat du futur lorsqu'il devient disponible. Dans notre cas, une telle méthode est nécessaire pour convertir le résultat au format de notre bibliothèque. Pour contourner ce problème, nous avons dû implémenter notre propre pool de threads simple, car à la demande du client, nous ne pouvions pas utiliser de bibliothèques tierces lourdes telles que Boost.

Notre implémentation d'alors fonctionne comme ceci. Lorsqu’elle est appelée, une paire promesse/futur supplémentaire est créée. Le nouveau futur est renvoyé et celui transmis est placé avec la fonction correspondante et une promesse supplémentaire dans la file d'attente. Un thread du pool sélectionne plusieurs futurs dans la file d'attente et les interroge à l'aide de wait_for. Lorsqu'un résultat devient disponible, la fonction correspondante est appelée et sa valeur de retour est transmise à la promesse.

Nous avons utilisé le même pool de threads pour exécuter des requêtes vers etcd et Consul. Cela signifie que les bibliothèques sous-jacentes sont accessibles par plusieurs threads différents. ppconsul n'est pas thread-safe, donc les appels vers celui-ci sont protégés par des verrous.
Vous pouvez travailler avec grpc à partir de plusieurs threads, mais il existe des subtilités. Dans etcd, les surveillances sont implémentées via des flux grpc. Ce sont des canaux bidirectionnels pour les messages d'un certain type. La bibliothèque crée un seul thread pour toutes les surveillances et un seul thread qui traite les messages entrants. Grpc interdit donc les écritures parallèles dans le flux. Cela signifie que lors de l'initialisation ou de la suppression d'une montre, vous devez attendre que la requête précédente ait terminé l'envoi avant d'envoyer la suivante. Nous utilisons pour la synchronisation variables conditionnelles.

Total

Voyez vous-même: liboffkv.

Notre équipe: Raed Romanov, Ivan Gluchenkov, Dmitri Kamaldinov, Victor Krapivenski, Vitali Ivanine.

Source: habr.com

Ajouter un commentaire