Le blog d'Archiloque

Comment couper un monolithe en deux ?

Ce texte est une traduction d’un article de tef.


Ça dépend.

Le problème avec les systèmes distribués est que, quelle que soit la question, la réponse est inévitablement “Ça dépend”.

Quand vous coupez un service d’une certaine taille en deux, l’endroit où il faut découper dépend de la latence, des ressources et de l’accès aux états, mais il dépend aussi de la gestion des erreurs, de la disponibilité et des processus de reprise. Ça dépend, mais vous ne voulez probablement pas d’un bus de messages.

Utiliser un bus de messages pour distribuer du travail est comme croiser un répartiteur de charge avec une base de données, avec les inconvénients des deux et aucun des avantages.

Les bus de messages, ou queues persistantes auxquelles on accède par un mécanisme de publication-souscription, sont une manière populaire de séparer deux composants à travers un réseau. Ils sont populaires parce que leur coût de mise en place est souvent faible, qu’ils proposent un accès facile à des fonctionnalités de découverte de services, mais ils peuvent venir avec un coût opérationnel important, en fonction de l’endroit où vous les placez dans vos systèmes.

En pratique, un bus de messages est un service qui transforme des erreurs réseau et des défaillances de machines en problèmes de disques remplis. Et puis vous augmentez la taille du disque. L’avantage de la publication-souscription est qu’elle isole les composants l’un de l’autre, mais le problème est généralement de les coller ensemble.

Pour les tâches à durée de vie courte, vous voulez un répartiteur de charge

Pour les tâches à durée de vie courte, la publication-souscription est une manière pratique de construire rapidement un système, mais vous finirez immanquablement par implémenter un nouveau protocole au dessus de l’existant. Vous avez de la publication-souscription, mais ce que vous voulez vraiment c’est de la requête-réponse. Si vous voulez que quelque chose soit calculé, vous allez probablement vouloir connaître le résultat.

Démarrer avec une publication-souscription facilite le travail : les tâches sont ajoutées à la queue, les workers les suppriment chacun à leur tour. Malheureusement, cela rend difficile de savoir ce qui s’est passé et vous allez devoir ajouter une autre queue pour envoyer les résultats en retour.

Une fois que vous pouvez prendre en compte les cas qui se passent bien, il est temps de s’occuper des erreurs. La première étape est souvent d’ajouter du code pour rééssayer les demandes un certain nombre de fois. Quand le système s’écroule sous la charge, vous ajoutez un appel à sleep(). Quand le système s’écroule lentement sous la charge, chaque réessai attend deux fois plus longtemps que le précédent.

(En passant :la synchronisation accidentelle est toujours un problème, car attendre pour réessayer n’empêche pas qu’un tas de chose n’arrivent en même temps.)

Quand les workers n’arrivent plus à tenir, les clients abandonnent et le mécanisme de réessai se met en route, mais la requête demandée plus tôt est toujours en attente de traitement. La solution est de remettre une partie de la queue du côté des clients, en leur demandant de ne pas soumettre de nouvelles demandes tant qu’une demande n’a pas été acceptée  via de la contre-pression (back-pressure) ou des acquittements.

Bien que les composants interragissent via publication-souscription, nous avons créé un système de requête-réponse au dessus. Désormais le bus de messages s’occupe de deux choses véritablement utiles : la découverte de services et la répartition de charge. Il s’occupe également de deux choses pas tellement utiles : ajouter des requêtes dans la queue et les persister.

Pour les tâches à durée de vie courte, la persistance n’est pas nécessaire : le client reste de toute façon dans le coin le temps que la tâche soit exécutée et doit gérer la reprise. L’utilisation d’une queue n’est pas tellement nécessaire non plus.

Les queues atteignent inévitablement deux états : pleines ou vides. Si votre queue est pleine, il faut pousser plus de travail vers les extrémités et si elle est vide elle agit comme un lent répartiteur de charge.

Une queue presque vide fonctionne toujours dans un mode premier arrivé, premier servi, servant de point de contention pour les requêtes. Un bus de messages ne fait souvent rien à part attendre que les workers viennent récupérer de nouveaux messages. Si votre queue est faite pour être vide, pourquoi attendre pour transmettre une requête ?

(En passant : quelque chose comme une répartition de charge aléatoire va fonctionner, mais l’approche join-idle-queue vaut largement la peine de s’y intéresser.)

Pour les tâches à durée de vie courte, vous pouvez utiliser un bus de messages, mais vous allez vous retrouver à construire un répartiteur de charge, avec un système d’appel de code à distance et de la latence en plus.

Pour les tâches à durée de vie longue, vous aurez besoin d’une base de données

Un répartiteur de charge avec de la découverte de services ne vous aidera pas pour les tâches à durée de vie longue, ou pour les tâches dont la durée est supérieure à la durée de vie du client, ou pour gérer le débit. Vous allez vouloir de la persistance, mais pas dans votre bus de messages. Pour les tâches à durée de vie longue, vous allez plutôt vouloir une base de données.

Bien que la persistance et les queues soit un inconvénient pour les tâches à durée de vie courte, les problèmes sont moins évidents pour les tâches à durée de vie longue, mais le même genre de choses peut mal se passer.

Si vous vous intéressez au résultat d’une tâche, vous allez vouloir stocker le fait que quelque chose a besoin d’elle ailleurs que dans la queue persistante. Si la tâche est lancée mais échoue à mi-chemin, il faut que quelque chose en prenne la responsabilité et le bus de messages aura oublié. C’est pour cela que vous voulez une base de données.

Les messages dupliqués dans une queue causent souvent d’autres maux de têtes, parce que les tâches à durée de vie longue ont plus de chances de se chevaucher. Bien que nous utilisons le bus de messages pour distribuer du travail, nous l’utilisons aussi implicitement comme un système de gestion d’exclusion mutuelle (mutex). Pour empêcher les tâches de se chevaucher, vous ajoutez un verrou (lock) au dessus du système. Après qu’il ait planté un certain nombre de choix, vous le remplacez par un système d’emprunt, en ajoutant des durées d’expiration.

(Note : ce n’est pas pour cette raison que vous voulez une base de données, utiliser des transactions pour des les tâches à durée de vie longue est très pénible. Il vaut mieux modéliser les processus dont l’exécution est longue comme des machines à états.)

Quand la base de données devient la source principale de vérité, vous êtes capable de gérer le cas d’un bus de messages qui devient indisponible, ou d’un bus de messages qui perd le contenu d’une queue, en récupérant le contenu depuis la base de données. Par conséquent, vous n’avez pas besoin d’ajouter directement les tâches dans la queue du bus de messages, mais vous pouvez les marquer comme telles dans la base de données et attendre qu’un autre composant s’en charge.

Dans l’hypothèse où ce composant n’est pas une personne qui a été réveillée pour ça.

Une pompe de message peut régulièrement parcourir la base de données et envoyer les demandes de travail au bus de messages. Ajouter les tâches dans une queue par lot peut être un moyen efficace de survivre avec un appel coûteux à la base de données. La pompe responsable d’ajouter le travail peut aussi suivre si celui-ci s’est terminé et ainsi gérer les reprises et les réessais.

Il reste toujours le problème des tâches en attente, alors vous allez vouloir utiliser la contre-pression pour garder la queue relativement vide et seulement la remplir depuis la base de données quand c’est nécéssaire. Bien qu’un bus de messages puisse accepter une surcharge temporaire, la contre-pression devrait faire en sorte qu’il n’ait jamais à le faire.

À cette étape, le bus de messages fournit réellement deux choses : la découverte de services et l’assignation de tâches, mais ce dont vous avez réellement besoin c’est un ordonnanceur. Un ordonnanceur est ce qui parcourt la base de données, détermine quelles tâches doivent être lancées et souvent aussi qui les lance. Un ordonnanceur est ce qui prend la responsabilité de gérer les erreurs.

(En passant : écrire un ordonnanceur est difficile. Il est beaucoup plus simple d’avoir 1 000 boucles while qui attendent le bon moment, plutôt que d’écrire une boucle qui attend le prochain parmi 1 000. Un ordonnanceur peut suivre la dernière fois qu’il a lancé quelque chose, mais une tâche ne peut pas compter sur le fait qu’il s’agit de la dernière fois où elle a été lancée. L’idempotence n’est pas seulement votre amie, elle est votre sauveuse.)

Vous pouvez utiliser un bus de messages pour les tâches à durée de vie longue, mais vous allez devoir construire un gestionnaire de verrous, une base de données et un ordonnanceur, plus un système maison de requête-réponse de plus.

La requête-réponse c’est pour isoler des composants

Le problème avec le fait d’exécuter des tâches avec de la publication-souscription c’est que ce que vous voulez vraiment c’est de la requête-réponse. Le problème avec le fait d’utiliser des queues pour assigner des tâches c’est que vous ne voulez pas attendre jusqu’à ce qu’un worker demande ce qu’il doit faire.

Le problème avec le fait de se reposer sur une queue persistante pour la reprise c’est que la reprise doit être gérée ailleurs et que le problème avec les bus de messages est que rien d’autre ne rend la découverte de services aussi triviale.

Les bus de messages peuvent être mal utilisés, mais cela ne signifie pas qu’ils n’ont pas d’utilité. Les bus de messages fonctionnent bien quand vous avez à traverser les frontières d’un système.

Bien que vous vouliez garder les queues vides entre composants, il est pratique de pouvoir avoir des tampons (buffers) aux extrémités de votre système, pour cacher des défaillances aux clients externes. Quand vous donnez la responsabilité de gérer les fautes externes aux extrémités, vous évitez d’avoir à le faire dans vos composants internes. L’intérieur de votre système peut se concentrer sur le fait de gérer les problèmes internes, sachant qu’il y en a suffisamment.

Un bus de messages peut être utilisé pour créer des tampons aux extrémités, mais il peut aussi être utilisé comme une optimisation, pour démarrer du travail un peu plus tôt que nécessaire. Un bus de messages peut envoyer une notification indiquant qu’une donnée a été modifié et le système peut récupérer cette donnée par une autre API.

(En passant : si vous utilisez un bus de messages pour accélérer un processus, au bout d’un moment le système s’appuiera dessus pour être performant. Les personnes utilisent des caches pour accélérer les appels de base de données, mais de nombreux systèmes ne travaillent pas suffisamment vite tant que le cache n’est pas chaud, rempli de donnée. Bien que vous ne reposiez pas sur le bus de messages pour la fiabilité, se reposer dessus pour la performance est tout aussi risqué.)

Parfois vous voulez un répartiteur de charge, parfois vous allez avoir besoin d’une base de données, mais parfois un bus de messages peut être un bon choix.

Bien que la persistance ne puisse pas gérer beaucoup d’erreur, elle est pratique si vous avez besoin de redémarrer après avoir modifié du code ou de la configuration, sans perdre de données. Parfois la gestion d’erreur qui est fournie est exactement celle qu’il vous faut.

Bien qu’une queue persistante vous fournisse des protections contre des défaillances, elle ne peut rien faire quand quelque chose se passe mal au milieu d’une tâche. Pour être capable de reprendre après une défaillance vous devez arrêter de la cacher, vous devez ajouter des acquittements, de la contre-pression, de la gestion d’erreur, pour pouvoir revenir à un système qui fonctionne.

Une queue de message persistante n’est pas mauvaise en elle-même, mais s’appuyer dessus pour la reprise et par extension, pour un comportement correct, est un chemin semé d’embûches.

Les systèmes croissent en poussant les responsabilités aux extrémités

La performance n’est pas facile non plus. Vous ne voulez pas de queues, ou de persistance dans les couches centrales ou inférieures de votre système. Vous les voulez aux extrémités.

C’est lent est le problème le plus difficile à corriger et souvent la raison est que quelque chose est coincée dans une queue. Pour les tâches à durée de vie longue et courte, nous avons utilisé la contre-pression pour garder la queue vide, pour réduire la latence.

Quand vous avez plusieurs queues entre vous et le worker, il devient encore plus important de ne pas avoir de queues au centre du réseau. Des décennies de travail ont été passées sur le contrôle de congestion de TCP pour éviter cette situation.

Si cela excite votre curiosité, l’histoire de la congestion de TCP est une lecture intéressante. Bien que les extrémités d’une connexion TCP étaient responsables de gérer les défaillances et les rééssais, les routeurs étaient responsables de gérer la congestion, c’est-à-dire de laisser tomber des choses quand il y en avait trop.

Le problème est que ça a fonctionné jusqu’à ce que le réseau soit saturé et — d’une manière similaire aux tâches en attente dans des queues — quand c’est arrivé les erreurs se sont produites en cascades. La solution a été similaire : la contre-pression. De la même manière que le fait d’attendre deux fois plus longtemps en cas d’erreur, TCP envoie deux fois moins de paquets, avant d’augmenter progressivement leurs nombres quand les choses s’améliorent.

La contre-pression consiste à pousser le travail aux extrémités, en laissant les extrémités de la conversation s’occuper de la stabilité, plutôt que d’essayer d’optimiser tous les liens intermédiaires de manière isolée. Le contrôle de congestion consiste à utiliser la base de données pour garder les queues intermédiaires aussi vides que possible, pour garder une latence faible et pour augmenter le débit en évitant d’avoir besoin de laisser tomber des paquets.

C’est en poussant le travail aux extrémités que votre système se met à l’échelle. Beaucoup de temps et une quantité considérable d’argent a été investi dans le multicast IP, mais rien n’a jamais été aussi efficace que BitTorrent. Au lieu de s’appuyer sur des routeurs intelligents pour déterminer comment diffuser des données, on s’appuie sur des clients intelligents qui se parlent les uns aux autres.

Pour que votre système gère les défaillances il faut pousser la reprise vers les couches externes. Dans les exemples pré-cités, on a besoin que le client ou l’ordonnanceur gère le cycle de vie de la tâche, car il a une durée de vie supérieure à la présence de la tâche dans la queue.

La reprise sur erreur dans les couches bases d’un système est une optimisation et il est impossible de pousser le travail au centre du réseau de le mettre à l’échelle. C’est le principe de bout en bout et c’est l’une des idées les plus importantes dans la conception de systèmes.

Le principe de bout en bout est la raison pour laquelle vous pouvez redémarrer votre box, quand elle plante, sans qu’elle ait besoin de rejouer tous les sites que vous vouliez visiter avant de vous laisser ouvrir une nouvelle page. Le navigateur (et votre ordinateur) est responsable de la reprise et pas les ordinateurs au milieu.

Ce n’est pas une idée nouvelle et Erlang/OTP lui doit beaucoup. OTP organise un programme en train de s’exécuter en un arbre de supervision. Les processus ont souvent un processus au-dessus d’eux et le redémarrent en cas de défaillance et encore au-dessus, un autre superviseur qui fait la même chose.

(En passant : les pipelines ne sont pas incompatibles avec la supervision de processus, une manière de s’y prendre est que chaque programme soit responsable de lancer le programme qui le suit et qui lit sa sortie. De cette manière une erreur en bas de la chaîne peut se propager pour être prise en compte correctement.)

Bien que chaque programme prenne en compte certaines erreurs, les niveaux supérieurs de l’arbre de supervision prend en compte les défaillances plus grave avec des redémarrages. De la même manière, c’est agréable si votre page web peut se remettre d’une erreur, mais inévitablement quelqu’un aura besoin à un moment donné de cliquer sur le bouton rafraichir.

Le principe de bout-en-bout c’est la réalisation que, quel que soit le nombre d’exceptions que vous prenez en compte à l’intérieur de votre programme, certaines s’échapperont et quelque chose dans la couche extérieure devra s’en occuper.

Parfois s’en occuper signifie écrire des choses dans un journal d’audit et les bus de messages sont plutôt bon à ça.

En passant : mais qu’en est-il des journaux répliqués ?

 — Comment est ce que je fais pour souscrire à un sujet du bus de messages ?

 — Ce n’est pas un bus de messages, c’est un journal répliqué

 — OK, comment est ce que je fais pour souscrire au journal répliqué ?

— `Il me semble l'avoir fait, Bob`
jrecursive

Bien qu’un journal répliqué soit souvent confondu avec un bus de messages, il ne vous immunise pas contre la gestion d’erreurs. Bien que ça soit une bonne chose que les composants soient isolés les uns des autres, ils doivent tout de même être intégrés dans le système en lui-même. Les deux fournissent un flux à sens unique pour faire du partage et les deux proposent une interface qui ressemble à de la contre-pression, mais leur objectifs sont radicalement différents.

Un journal répliqué a souvent pour but l’audit ou la reprise : avoir un point de vérité central pour pouvoir prendre des décisions. Parfois un journal répliqué a pour but l’aggrégation (fan-in) ou la diffusion (fan-out) de données, mais il s’agit toujours de construire un système ou les données circulent dans une direction.

La manière la plus simple de voir la différence entre un journal répliqué et un bus de messages c’est de demander à un·e ingénieur·e de dessiner un diagramme de la manière dont les éléments se connectent.

Si le diagramme ressemble à un système à sens unique, il s’agit d’un journal répliqué. Si presque tous les composants lui parlent, il s’agit d’un bus de messages. Si vous pouvez le dessiner sous forme d’un flow chart, c’est un journal répliqué. Si vous enlevez toutes les flèches et ce qui vous reste c’est un diagramme de Venn des “chose qui se parlent”, c’est un bus de messages.

Soyez prévenu·e·s : un système distribué est quelque chose qu’on peut dessiner assez facilement sur un tableau blanc, mais il faut des heures pour expliquer comment tous les éléments interagissent.

Vous coupez un monolithe avec un protocole

La manière de couper un monolithe dépend souvent plus de la manière de séparer les responsabilités dans une équipe plutôt que de la manière de le découper en composants. Ça dépend vraiment des cas et souvent plus des aspects personnels que des aspects sociaux, mais vous êtes tout de même responsable du protocole que vous créez.

Si les systèmes distribués sont désordonnés, ce n’est pas pas parce que des composants interagissent mais à cause de la manière dont les interactions ont lieu. La complexité d’un système distribué ne vient pas du fait d’avoir des centaines de machines, mais du fait que ces machines ont des centaines de manière d’interagir. Un protocole doit prendre en compte la performance, la sécurité, la stabilité, la disponibilité et le plus important, la gestion d’erreurs.

Quand nous parlons de systèmes distribués, nous parlons de structure de pouvoir : comment les resources soit réparties ? comment le travail est divisé ? comment le contrôle est partagé ? ou comment l’ordre est maintenu au travers de systèmes construits ostensiblement avec des composants bien intentionnés mais défectueux ?

Un protocole définit les règles et les attentes de participation à un système et comment les éléments sont redevables les uns aux autres. Un protocole définit qui est responsable en cas de défaillance.

Le problème avec les bus de messages et les queues est que personne ne l’est.

Utiliser un bus de messages n’est pas la fin du monde, ou le signe d’une ingénierie de mauvaise qualité. Utiliser un bus de messages est un compromis. Utilisez-les librement en sachant qu’ils fonctionnent bien aux extrémités d’un système en tant que tampon. Utilisez-les à bon escient en sachant que la responsabilité doit se situer ailleurs. Utilisez-les sans vous stresser pour faire fonctionner quelque chose.

Je dis que vous ne devez pas vous appuyer sur un bus de messages, mais je ne peux pas vous donner de réponse toute prête. HTTP et DNS sont des protocoles remarquables, mais je n’ai pas de bonne solution pour la découverte de services.

De nombreux logiciels sont régulièrement utilisés très largement en dehors des cas pour lesquels ils ont été conçus et les bus de messages n’y font pas exception. Bien que les mauvaises habitudes autour des bus de messages et la facilité relative d’obtenir un prototype qui fonctionne aboutissent à de mauvaises surprise lors des mises à l’échelle, vous n’avez pas besoin de tout construire d’un coup.

La complexité d’un système réside dans son protocole et pas dans sa topologie et un protocole est ce que vous créez lorsque vous coupez un monolithe en morceaux. Si la construction de logiciel s’appuie sur la modularité, la manière de découper un logiciel s’appuie sur un protocole.

La tâche principale de l’analyste en ingénierie n’est pas seulement d’obtenir des “solutions” mais plutôt de comprendre le comportement dynamique du système de manière à révéler les secrets du mécanisme, de manière à ce qu’il soit construit sans comporter aucune surprise [pour il·elle·s]. Plutôt que des expérimentations physiques exhaustives, c’est la seule approche solide pour la conception technique et il n’est pas rare que l’ignorance de ce principe fondamental conduise au désastre.

— Analyse de systèmes de contrôles non linéaires
Dustan Graham et Duane McRuer, p 436

Le protocole est la raison pour laquelle “ça dépend” et la raison pour laquelle vous ne deviez pas dépendre d’un bus de messages : vous pouvez utiliser un bus de messages pour assembler des systèmes, mais n’en utilisez jamais pour séparer des systèmes.