Groupement de serveurs

Bon ! vous êtes arrivé jusque-là. J’estime donc que vous comprenez, dans les grandes lignes, ce qu’est CouchDB et comment l’API fonctionne. Peut-être avez-vous même déployé une ou deux applications et que vous rencontrez un certain succès, ce qui vous amène à envisager le passage à l’échelle. Dans ce chapitre, nous allons mettre en place une grappe de serveurs (cluster en anglais) partitionnée ou fragmentée (sharded en anglais), laquelle va croître à un taux exponentiel à compter de sa mise en service.

Dans un premier temps, nous nous intéresserons à la distribution des requêtes et des réponses dans une grappe CouchDB stable. Ensuite, nous verrons comment y inclure de la redondance pour rendre un nœud hautement disponible pour que vous n’ayez plus à vous soucier de la perte d’une machine. En effet, dans une large grappe, vous devriez prévoir que 5 à 10% de vos machines connaîtront des problèmes (défaillance, performances dégradées) ; cela doit être pris en compte lors de la conception de la grappe pour qu’une défaillance ne nuise pas à la stabilité de l’ensemble. Enfin, nous nous pencherons sur la modification dynamique de la répartition des données en subdivisant ou en fusionnant des nœuds à l’aide du mécanisme de réplication.

Présentation du CouchDB Lounge

CouchDB Lounge est une application mandataire (proxy) — originellement développée pour Meebo, service de messagerie instantanée par navigateur — qui permet de partitionner et de grappeler des instances CouchDB. Lounge se compose de deux modules : le premier, dumbproxy s’occupe des « simples » requêtes GET et PUT visant à obtenir un document ; le second, smartproxy répartit les requêtes nécessitant de recourir à des vues.

dumbproxy traite toutes les requêtes qui n’utilisent pas le mécanisme de vues de CouchDB. C’est un module de nginx, serveur mandataire frontal (en anglais, reverse proxy) HTTP très performant. Puisque ce serveur est le point d’entrée de votre infrastructure, et parce qu’il intègre les mécanismes de mandataire, vous pouvez le configurer pour apporter la sécurité que vous désirez, le chiffrement, la répartition de charge, la compression des flux et, bien sûr, l’antémémoire (en anglais, cache) pour soulager vos bases de données.

Quant à smartproxy, il traite uniquement les requêtes sur les vues CouchDB qu’il distribue entre les nœuds de la grappe afin que les performances soient fonction de la puissance de celle-ci. Cela prend la forme d’un démon qui hante Twisted, framework Python de développement réseau orienté évènements et à hautes performances en plus d’être populaire.

Empreintes stabilisées

NdT : le terme anglais consistent hashing (empreintes stabilisées) ne semble pas avoir de traduction française courante. Il s’agit d’une technique de génération d’empreintes qui évite d’avoir à recalculer toutes les empreintes en cas d’ajout ou de suppression d’un espace (voir ci-après). Vous pourriez trouver l’explication anglaise de Wikipédia plus compréhensible que celle donnée ci-dessous.

Le modèle de persistance CouchDB recourt à des identifiants uniques (ID) pour sauvegarder et ouvrir des documents. Aussi, c’est avec logique qu’au cœur de Lounge se trouve un système d’empreinte (hash en anglais) qui transforme ces identifiants de manière cohérente. À l’aide des premières lettres de cette empreinte, le mandataire sait vers quel nœud envoyer la requête. Vous pouvez paramétrer ce comportement en écrivant une shard map, laquelle se trouve dans un fichier texte de configuration.

La clé d’espace (keyspace en anglais) compose la première partie de l’empreinte et identifie chaque nœud, ce qui vous permet de définir autant de nœuds que vous le désirez. La fonction d’empreinte produit une chaîne hexadécimale qui ne semble avoir aucune relation avec les identifiants de vos documents et, cela couplée à la clé d’espace, assure une répartition globalement équitable entre les nœuds. De plus, puisque la fonction d’empreinte est cohérente, plusieurs requêtes d’accès à un même document seront toujours dirigées vers le même nœud.

L’idée qui consiste à diviser un ensemble de fragments à l’aide d’une clé d’espace peut être illustrée par un anneau serti d’empreintes. Chaque degré (NdT : imaginez les marques d’une horloge) délimite la frontière entre deux partitions. La fonction d’empreinte assure la correspondance entre un identifiant de document à l’intérieur de l’anneau et sa position à l’extérieur de celui-ci. En outre, l’anneau est continu, ce qui vous permet toujours d’y ajouter de nouveaux nœuds ; pour cela, vous subdivisez un espace en plusieurs espaces. Ainsi, avec quatre serveurs physiques, vous pouvez partager l’espace en seize partitions indépendantes que vous répartissez de la manière suivante :

A0,1,2,3
B4,5,6,7
C8,9,a,b
Dc,d,e,f

Si l’empreinte de votre document commence par 0, la requête sera envoyée au fragment A. Il en va de même pour 1, 2 et 3. En revanche, si elle commence par c, d, e ou f, la requête est envoyée à D. Par exemple, l’empreinte 71db329b58378c8fa8876f0ec04c72e5 est dirigée vers le nœud B, dans la base de données 7, ce qui pourrait correspondre à http://B.couches.local/db-7/ dans votre environnement. De cette manière, la table de correspondance fait le lien entre une empreinte et une base de données. Ne vous inquiétez pas si cela vous semble très compliqué : vous n’avez qu’à fournir le lien entre les fragments et les bases, Lounge s’occupera de générer l’anneau sans que vous ayez à mettre les mains dans le cambouis si vous ne le désirez pas.

Nous pouvons faire le parallèle avec le web : puisque CouchDB utilise HTTP, le serveur mandataire peut répartir les documents en fonction de l’URL sans avoir à connaître le contenu accessible par l’URL. C’est un principe de l’approche REST et c’est un des avantages que nous procure HTTP. En pratique, cette correspondance est assurée par une empreinte de l’URL demandée, laquelle est comparée à la table de correspondance pour trouver la clé d’espace appropriée. Lougne n’a plus qu’à trouver le fragment correspondant dans sa configuration pour faire suivre la requête vers le bon serveur CouchDB.

Le mécanisme d’empreintes stabilisées vous garantit l’accès aux documents que vous sauvegardez ainsi que l’égale répartition du stockage sur vos nœuds. De plus, la simplicité de la fonction d’empreinte (basée sur CRC32) vous permet de concevoir vos propres mandataires HTTP ou vos propres clients capables de s’adresser au bon serveur.

Redondance du stockage

Le recours aux empreintes stabilisées répond à la problématique de répartition d’une base de données en fragments égaux, lesquels peuvent être répartis à leur tour sur différents serveurs. En revanche, cela ne procure en rien un abri à vos données. En effet, les données demeurent stockées à un seul endroit, les mettant ainsi à la merci d’un incident matériel ou logiciel. Vous ne pouvez considérer vos données en sécurité qu’à la condition qu’elles soient présentes en au moins deux lieux différents, si possible géographiquement éloignées l’un de l’autre.

Le mécanisme de réplication de CouchDB permet d’obtenir sans trop d’efforts les fonctionnalités de basculement immédiat vers des bases esclaves redondantes ou de répartition de charge entre bases de données multimaîtresses (c’est-à-dire en réplication bidirectionnelle). Le paramétrage de la réplication est expliqué au chapitre 16, Réplication. Ce qui importe ici, c’est de comprendre que maintenir des copies redondantes doit être dissocié de la tâche, plus complexe, consistant à toujours faire correspondre à un identifiant de document donné, un fragment donné.

Considérons que vous avez deux ou trois copies de chaque donnée pour en assurer la sécurité. Vous avez alors deux ou trois copies d’un même fragment. Du point de vue de la grappe, il n’existe qu’un fragment ; la duplication des données, la bascule en cas de défaillance et la répartition de charge sont assurées par le fragment en question.

Redondance des serveurs mandataires

De la même manière que nous ne pouvons laisser une éventuelle défaillance matérielle provoquer une perte de données, nous aurons recours à plusieurs serveurs mandataires afin que la perte de l’un d’eux ne rende pas nos bases inaccessibles. Leur redondance ainsi que la répartition de charge entre eux augmentera la disponibilité et la capacité totale à absorber les requêtes.

Fusion des vues

Le mécanisme d’empreintes stabilisées dépose les documents sur les bons nœuds. Toutefois, ils peuvent toujours émettre (avec emit()) n’importe quelle clé. L’intérêt de MapReduce incrémental est de déporter le traitement aux nœuds de sorte que nous n’avons pas à redistribuer les clés émises. Il s’agit de faire suivre la requête du client, via le mandataire HTTP, à tous les nœuds et de fusionner les résultats à l’aide du Twisted Python Smartproxy.

Smartproxy envoie chaque requête de vue à tous les nœuds. Il doit donc fusionner les résultats avant de les envoyer au client. Heureusement, cette opération n’est pas consommatrice de ressources, car la fusion peut s’opérer à périmètre mémoire constant quelle que soit la volumétrie. Smartproxy reçoit le premier enregistrement de chaque nœud de la grappe et les compare. Nous trions les nœuds selon leur clé d’enregistrement en appliquant les règles d’inteclassement de CouchDB. Ensuite, Smartproxy récupère le premier enregistrement du nœud qu’il vient de classer premier et l’envoie au client.

Ce processus peut se poursuivre tant que les clients envoient des enregistrements, mais si une limite est donnée par le client, Smartproxy doit achever le traitement aussitôt et dédaigner les autres enregistrements dont lui font part les nœuds.

Cette approche est simple et faiblement couplée. Elle présente l’avantage d’être simple, ce qui permet de plus facilement appréhender la topologie et d’analyser les erreurs. Il y a du travail pour réussir à transplanter ce comportement en Erlang, mais cela nous permettrait de gérer des grappes dynamiques et d’intégrer le contrôle des ces dernières dans le cœur de CouchDB.

Étendre la grappe

L’utilisation de CouchDB à l’échelle du web nécessite vraisemblablement d’avoir des grappes qui peuvent s’étendre de manière dynamique. Les sites en forte croissance ajoutent sans cesse de nouveaux espaces de stockage ; nous avons par conséquent besoin de pouvoir augmenter la taille de notre grappe sans pour autant l’arrêter. En outre, certaines charges provoquent une augmentation épisodique de la taille de la base, ce qui induit la nécessité d’un processus de réduction de la grappe sans interrompre son fonctionnement.

Dans cette section, nous verrons comment utiliser les filtres de réplication de CouchDB pour subdiviser une base de données en plusieurs fragments, et comment utiliser cette technique pour étendre notre grappe sans l’arrêter. Toutefois, vous pouvez en quelques étapes éviter de partitionner les bases de données en étendant la grappe.

La technique dite de fragmentation zélée (oversharding en anglais) consiste à partitionner la grappe de manière à avoir plusieurs fragments sur un même hôte physique. En effet, déplacer un fragment d’une machine vers une autre est une tâche plus simple que de le subdiviser, car seule la configuration de la grappe telle que décrite dans le serveur mandataire doit être mise à jour pour pointer vers le nouveau serveur, plutôt que de devoir y inclure de nouveaux fragments. Cela nécessite moins de ressources de migrer un fragment que de le subdiviser.

La question est alors de savoir quel zèle est nécessaire. La réponse est fonction de votre application et de votre déploiement, mais il est des éléments qui nous poussent davantage d’un côté que de l’autre. Si nous taillons correctement nos fragments, nous obtiendrons un serveur qui pourra s’étendre de manière optimale.

Dans la section Fusion des vues, nous avons vu que la fusion se fait à espace constant quel que soit le nombre d’enregistrements retournés. En revanche, l’espace mémoire et les ressources réseau nécessaires à la fusion des vues, tout comme la correspondance d’un identifiant de document à un fragment, croissent linéairement en fonction du nombre de fragments gérés par un mandataire. Cependant, nous ne pouvons souffrir une borne supérieure à la taille de notre grappe. En conséquence, la solution réside dans l’utilisation d’un arbre de serveurs mandataires dans lequel la racine fait suivre à des mandataires secondaires, lesquelles accèdent aux nœuds.

Les facteurs à prendre en compte lors de la détermination du nombre de fragments que chaque mandataire doit pouvoir gérer sont : l’espace disque disponible pour chaque nœud, l’estimation de croissance des données, le réseau, les ressources mémoires mises à la disposition des serveurs mandataires et la latence acceptable pour traverser la grappe.

Ainsi, avec un réglage conservateur de 64 fragments par mandataire et de 1 TB d’espace utile par nœud (avec la compression, ces nœuds auront besoin d’environ 2 TB d’espace disque), nous pouvons voir qu’avec un seul mandataire devant les bases CouchDB, nous pourrons stocker au maximum 64 TB de données (sur 128 ou peut-être 192 nœuds, selon le niveau de redondance nécessaire) avant d’avoir à augmenter le nombre de fragments.

En substituant des nœuds par un nouveau mandataire, et en subdivisant chacun des 64 fragments en 64 nouveaux fragments, nous arrivons à 4 096 fragments et une profondeur d’arbre de 2. De la même manière que le système initial pouvait faire tenir 64 fragments sur seulement quelques nœuds, nous pouvons passer à un arbre à deux niveaux sans avoir besoin de milliers de machines. Si nous acceptons l’hypothèse que chaque mandataire doit avoir son propre serveur et qu’une base de données peut accueillir 16 fragments, nous arrivons à 65 serveurs mandataires et 256 serveurs de bases de données (sans prendre en compte ceux requis pour la redondance qui multiplient ce nombre par deux ou trois). Ainsi, pour commencer avec une grappe qui pourra croître tranquillement de 64 TB à 4 PB, nous pouvons commencer avec environ 600 à 1 000 nœuds, en ajoutant de nouveaux nœuds quand la taille des données augmente et en déplaçant les fragments vers de nouvelles machines.

Nous savons donc qu’une grappe avec une profondeur d’arbre de deux peut accueillir une grande quantité de données. Des opérations mathématiques simples nous montrent qu’en appliquant le même raisonnement à une grappe d’une profondeur de trois, nous pouvons héberger 262 PB sur des milliers de machines. Des estimations conservatrices estiment la latence introduite par chaque niveau à 100 ms, donc même sans ajustements, le temps de réponse serait de 300 ms pour une profondeur d’arbre de 3, et nous serions capables d’exécuter des requêtes sur plus d’un exaoctet de données en moins d’une seconde.

En recourant à la technique de fragmentation zélée et en replaçant les fragments pleins (full shard en anglais ; nœuds qui hébergent un unique fragment) par des serveurs mandataires, nous pouvons étendre la grappe jusqu’à une taille colossale tout en subissant une latence minimale.

Maintenant, nous allons nous pencher sur les mécanismes de deux processus qui permettent à la grappe de croître : déplacer un fragment d’un serveur surchargé à un nœud vide et subdiviser un fragment. Déplacer des fragments est une opération simple, ce qui invite à l’utiliser tant que c’est possible. Ainsi, nous gardons l’opération plus gourmande de subdivision pour les cas où un fragment devient si gros qu’un seul, ou deux fragments occupent à eux seuls tout un serveur.

Déplacer des fragments

Comme nous l’avons dit plus tôt, chaque fragment se compose de n bases de données CouchDB redondantes, chacun étant hébergé sur un serveur physique différent. Pour garder les choses simples, disons que toute opération doit être appliquée sur toutes les copies, et ce, de manière automatique. Pour simplifier notre discours, nous parlerons uniquement des fragments au sens abstrait du terme, mais gardez à l’esprit que leurs copies font toutes la même taille et nécessitent de subir les mêmes opérations lors de l’extension de la grappe.

Le moyen le plus simple de déplacer un fragment d’un nœud vers un autre est de créer une base de données vierge et d’utiliser le mécanisme de réplication de CouchDB pour le remplir avec le contenu de l’ancien nœud. Dès que la copie est à jour par rapport à l’original, les serveurs mandataires peuvent être reconfigurés pour pointer vers la nouvelle machine. Cela fait, il reste à exécuter une dernière fois la réplication pour être certain d’avoir tout à jour, puis vous pouvez supprimer l’ancien fragment et récupérer l’espace disque qu’il occupait.

Alternativement, vous pouvez synchroniser les fichiers au niveau disque, de l’ancien nœud vers le nouveau. Selon l’âge de la dernière compaction, cela pourrait être un moyen efficace minimisant la consommation de CPU. Ensuite, le mécanisme de réplication peut être utilisé pour récupérer les derniers changements intervenus depuis le rsync. Pour en savoir davantage sur rsync et la réplication, référez-vous au chapitre 16, Réplication.

Subdiviser une partition

La dernière opération dont nous aurons besoin sur une grappe CouchDB, c’est la subdivision d’une partition (ou fragment) trop grande. Dans le chapitre 16, Réplication, nous avons vu comment assurer une réplication permanente en nous basant sur la propriété _changes de l’API. Celle-ci peut inclure des filtres (cf. chapitre 20, Notification des modifications), ce qui permet de configurer la réplication pour utiliser une fonction de filtrage et ne répliquer qu’une partie d’une base de données. La subdivision d’une partition s’obtient donc en créant les fragments cibles et en les configurant pour recevoir le sous-ensemble de clés qui les intéresse. Il leur est alors possible d’utiliser la fonction de filtrage pour répliquer la base de données source et ne demander que les documents qui font partie du sous-ensemble désigné. Il en résulte de multiples copies partielles de la base de données, le tout organisé de manière à répartir également les données entre les nouveaux fragments. En les additionnant, on obtient l’ensemble des données d’origine. Une fois la réplication achevée, et leurs copies redondantes propagées, on met en ligne un nouveau serveur mandataire capable de distribuer les requêtes aux nouveaux nœuds, ce qui permet de faire pointer le mandataire d’origine vers ce nouveau mandataire au lieu de la base qu’il référençait. Enfin, de la même manière que lors du déplacement d’une partition, il reste à donner un dernier coup de réplication pour s’assurer de ne pas perdre de données avant de supprimer l’ancienne partition pour pouvoir réutiliser les ressources matérielles ainsi récupérées.