February 12, 2024

Réflexion sur les microservices: avantages, inconvénients, patterns, complexité accidentelle

Les avantages des microservices sont souvent énoncés mais c’est également une approche posant de nombreux challenges au quotidien. Au final, est ce que ça vaut le coup ?

Introduction

Cela fait plus de 10 ans (je pense ?) que l’on voit les microservices déferler dans nos SI. Le cloud et les outils d’orchestration de conteneurs ont également accompagnés cette montée en puissance des microservices au cours des années, simplifiant en partie leurs gestions.

Les microservices apparaissent souvent comme une solution pour avoir de petites équipes travaillant de manière décentralisée. Le Domain Driven Design (DDD) est également une méthode de conception souvent mise en avant dans les microservices, où chaque service ou groupe de service sera responsable d’un domaine métier, domaine qui peut donc être assigné à une équipe dédiée.
La théorie nous dit aussi que chaque service doit avoir sa propre base de données, car chaque service est le "propriétaire" de la donnée de son domaine: c’est lui la source de vérité unique de cette donnée.

Sur le papier, tout ça est très intéressant. On a donc des services (et donc équipes, loi de Conway oblige) faiblement couplés, avec des dev travaillant en autonomie sur des bases de code réduites et spécialisées, et donc plus faciles à maintenir. Les services peuvent également être conçus dans des langages de programmations différents, et monter en charge de manière indépendante.

Bien sûr, there ain’t no such thing as a free lunch. Et il est tentant d’ignorer les nombreux problèmes apportés par les microservices, ou de ne leur apporter qu’une solution partielle.

Je donnerai dans cet article ma vision sur le sujet, en décrivant quelques patterns (ou antipatterns) que j’ai eu l’occasion de rencontrer au cours de ma carrière de manière la plus simple et objective possible.

Je ne vais pas parler tant que ça de découpage d’un monolithe. C’est un sujet très important mais de nombreuses personnes le font déjà mieux que moi. Regardez par exemple le talk de Julien Topçu et Josian Chevalier sur le sujet:

Et l’avantage d’un blog, c’est que cela me permet également de relire mes propres articles quelques années après et de voir comment ma vision des choses a évoluée ;)

Exploitation en production

Gérer le déploiement et l’orchestration de plusieurs centaines de services en production (et dans les autres environnements: QA, Staging…​) peut vite devenir un challenge.

Heureusement, l’outillage a énormément évolué ces dernières années. Kubernetes, our Lord and Savior, aide par exemple énormément grâce à ce qu’il fournit nativement (tolérance aux pannes, service discovery, gestion du firewalling, health checks, rolling restart, load balancing…​). Ce n’est pas la seule solution mais dans tous les cas il est nécessaire d’avoir un moyen standardisé de gérer le cycle de vie d’un service de la CI à la production.
Si chaque service doit avoir sa propre base de données (ou autre dépendance infra dont nous parlerons plus tard), il faut aussi être capable de provisioner et gérer dans le temps tous ces composants, pour chaque microservice.

Bien sûr, c’est aussi le cas pour un monolithe: on n’imagine pas un monolithe sans tolérance aux pannes ou sans pipeline de déploiement continu (voir un de mes articles sur le sujet). D’ailleurs, les microservices permettent de déployer en production des changements plus petits, plus souvent, avec un "blast radius" plus faible en cas d’incident. Cela peut également être assez appréciable.

Néanmoins, le debugging s’en trouve complexifié. Une requête HTTP d’un client peut facilement "passer" par plusieurs services (de manière synchrone ou asynchrone), et bien sûr planter à chaque étape. Cela peut être dû à un problème logiciel (bug dans un des services) ou tout simplement à cause d’un problème de l’infrastructure sous-jacente. Le microservice gérant vos utilisateurs doit par exemple envoyer des requêtes au service d’envoie d’emails, alors que dans un monolithe tout se ferait dans le même process.
Des outils comme le tracing distribué (cliquez, vous verrez c’est intéressant :D) deviennent donc obligatoires pour tenter de comprendre ce qu’il se passe dans le système d’un point de vue macro.
Ajoutez des métriques et des alertes sur vos services, configurez des SLO (plus facile qu’à dire qu’à faire, en réalité c’est une approche difficile à faire adopter). J’ai plusieurs articles sur le sujet des métriques si ça vous intéresse.

On en arrive vite à rajouter une certaine complexité sur l’infrastructure et les services à cause des microservices: service mesh pour tenter de créer une infrastructure réseau plus robuste (c’est souvent l’inverse qui arrive), circuit breakers pour protéger des services ou dépendances en cas de pannes, retries à divers endroits…​ Bref, comme dit Murphy, "Anything that can go wrong will go wrong". Une certaine maturité technique sera nécessaire pour aboutir à une plateforme robuste.

Le platform engineering est également obligatoire. Vos SRE devront travailler main dans la main avec vos développeurs pour que chaque équipe puisse travailler en autonomie sans avoir les ops dans le chemin critique. Cela est vrai également si vous ne faites pas de microservices d’ailleurs. J’avais fait un talk sur le sujet que je redonnerai dans une version complètement retravaillée prochainement:

Une solution pour résoudre les problèmes de fiabilité (et surtout de cascading failures, où la panne d’un composant cause la panne d’autres services en cascade) est de tenter de découpler totalement les services. Serait-il possible d’avoir des services totalement autonomes et donc pouvant continuer de fonctionner en cas de panne partielle de système ? En théorie oui, mais nous verrons ça dans la section suivante.

Communication, transaction, données

On retrouve généralement dans l’informatique deux grands moyens de faire communiquer deux services entre eux:

  • Synchrone: un service appelle un autre service via un protocole comme HTTP (ou gRPC par exemple) et s’attend à une réponse immédiate. Si le service cible n’est pas disponible, une erreur se produit.

  • Asynchrone: un service pousse un événement un message dans un bus de message et un autre service consommera (éventuellement) ce message un jour pour faire quelque chose avec.

Synchrone et asynchrone: représentation graphique

Ces définitions sont bien sûr un peu trop généralistes. Des outils comme Nats supportent par exemple des patterns de type request-reply via des queues asynchrones (on simule donc du synchrone), ce qui peut être utile dans certains cas.

Les appels synchrones créent un couplage entre l’appelant et l’appelé. Comme dit précédemment, si le service cible n’est plus joignable, le service appelant ne pourra pas fonctionner tant que la cible n’est pas restaurée.
Ceci peut éventuellement être un problème (ou pas, nous y reviendrons). D’autres approches émergent pour tenter de découpler les services.

Bus de message

On voit dans ce nombreuses implémentations des microservices l’utilisation d’outils comme Apache Kafka pour distribuer des événements entre services, et donc tenter de les découpler.

Rapide introduction à Kafka

Kafka est un bus de message permettant à des consommateurs de s’abonner à des topics. Un topic est composé de plusieurs "queues" de messages appelés partitions. Lorsqu’un message est produit dans un topic, il est assigné à une partition en fonction d’une clé de partitionnement (généralement une valeur du message, comme par exemple l’ID de la ressource représentée dans l’événement). Cela est très utile car on a comme ça une garantie d’ordre: tous les messages d’une partition sont consommés en mode FIFO par les consommateurs et tous les événements pour une entité donnée seront consommés dans l’ordre d’arrivée.

Architecture Kafka avec un producteur, 1 topic avec 2 partitions et plusieurs consommateurs

Dans cet exemple, un service produit des événements concernant des actions sur des utilisateurs (création de compte, mise à jour, suppression). Ces événements sont envoyés dans un topic composé de 2 partitions, partitionnées par ID de l’utilisateur.
Les consommateurs consomment le topic. Chaque consommateur (aussi appelé consumer group dans le langage Kafka) lira l’entièreté des messages du topic. Les messages dans Kafka ne sont pas supprimés après lecture mais après un TTL (7 jours par exemple), ce qui permet de relire à tout moment d’anciens événements si nécessaire (tant qu’ils sont plus récents que le TTL).

N’hésitez pas à googler si vous voulez en savoir plus sur Kafka, ça vaut le coup et il y a des tonnes de contenus sur le sujet.

Fin

Un service peut par exemple réagir sur la mise à jour d’une entité qu’il gère (de son domaine) en publiant un événement de domaine (une projection de la donnée du servicex). D’autres services, dont le service producteur n’a pas connaissance, peuvent consommer à ces événements et déclencher des actions en fonction de ce qu’ils reçoivent. A l’inscription d’un utilisateur le microservice responsable de la création du compte publiera par exemple un événement UserCreated (contenant les informations de l’utilisateur) qui pourra être consommé par un autre service qui déclenchera lui même une action.

On peut aussi envoyer via le bus de message des actions (commandes) à réaliser par d’autres services, par exemple "Envoie cet email à l’adresse foo@example.com" qui sera ensuite consommé par un service chargé d’envoyer des emails.

Ici, le découplage entre le producteur et les consommateurs est total. En cas de panne d’un consommateur, ce n’est pas grave: à son redémarrage il pourra reconsommer les événements envoyés par le producteur (avec du retard), ce dernier continuant de fonctionner normalement. Comme dit précédemment, c’est aussi un bon moyen d’avoir une communication 1 → N entre services, plusieurs consommateurs parallèle sur les mêmes événements étant possibe.

Transactions

Dans ce genre de système, l’ordre des événements est très important. En effet, il existe de très nombreux cas où le résultat d’une application d’une série d’actions sur une entité donnée dépend de l’ordre de ces actions. Les outils comme Kafka permettent de garantir l’ordre des messages en fonction de la clé de partitionnement des messages.

Mais c’est là où les problèmes peuvent commencer également à survenir.

Il est courant d’avoir des cas de figures où un service:

  • A besoin d’écrire dans une base de données.

  • Doit ensuite, une fois l’action réalisée, publier un événement dans un bus de message pour annoncer l’action aux autres services.

On a donc en résumé besoin de réaliser une transaction entre deux systèmes. TL:DR: ce n’est pas possible à faire simplement et on peut rapidement se retrouver dans des cas où par exemple la donnée est écrite, mais l’événement non publié. Cet événement non publié ne sera pas donc propagé au reste du système, ce qui peut causer des problèmes d’incohérences.

Comment résoudre ce problème ?

Découpler les actions

Découplons les deux actions avec un service intermédiaire, pour que chaque service ne fasse qu’une action transactionnelle et non deux:

Exemple de comment linéariser les actions dans une infra microservice

Dans 1), on a notre situation initiale: Le service A exécute les deux actions l’une après l’autre, sans garantie de transactionnalité. Dans 2), on utilise un pattern un peu différent: Le service A écrit d’abord dans le bus de message. Ce message est lu par Service B (qui pourrait même être Service A lui même qui reconsomme ses propres messages pour éviter de maintenir un autre service) qui lui écrit dans les base de données.

On voit dans la seconde solution qu’on a gagné: nous n’avons plus cette transaction entre deux systèmes, au prix d’une complexité un peu supérieure.

La solution 2) contient pourtant un inconvénient majeur: la possibilité de problèmes de type read-after-write.

Imaginons le cas suivant:

  • Un utilisateur décide de changer son nom dans l’application: Service A reçoit la requête, et pousse un événement de type Utilisateur Foo change son nom en Bar.

  • Le service A n’a ici aucune idée de quand l’action sera réellement exécutée par le Service B (il n’a même pas connaissance de ce service), mais il répond quand même à l’utilisateur "l’action est effectuée".

  • Si l’utilisateur rafraichit sa page avant que service B ait consommé l’événement et réalisé l’écriture en base, l’ancien nom sera toujours visible. Bizarre non ? Et que se passe t-il si Service B est en fait en panne et que le message met une heure à être consommé ?

Bref, c’est problématique mais dans certains cas où le read-after-write n’est pas un problème cela peut être suffisant. Mais on reparlera de ce pattern plus loin ;)

Outbox pattern

Une autre technique possible est d’utiliser l’outbox pattern. Il est extrêmement bien décrit dans cet article donc je n’irai pas dans les détails.
L’idée est de ne plus avoir à écrire la donnée dans deux systèmes à la suite (base de données et bus de message), mais d’écrire la donnée ET l’événement à envoyer dans une même transaction au même endroit: dans la base de données. L’événement sera écrit dans une table spécifique qui sera ensuite lue régulièrement par un système externe, et publié dans notre bus de message.

On a cela grâce aux propriétés transactionnelles des bases de données (notamment SQL) la garantie que les deux actions seront faites, ou annulées. Mais jamais le cas intermédiaire.

Que vaut ce pattern ? Ca marche, mais le coût de mise en oeuvre est important. Des outils populaires comme Debezium (très utilisé aussi pour un autre pattern appelé Change Data Capture) sont généralement utilisés mais ils demandent souvent une connaissance fine du fonctionnement d’une base de données (replication des WAL par exemple), et peuvent être assez sensibles à gérer en production. Here be dragons.

Réconciliations

Un autre pattern courant dans les systèmes distribués est de travailler avec des boucles de réconciliations.

J’ai eu l’occasion de travailler presque 4 ans chez un cloud provider utilisant ce pattern. C’est également un pattern utilisé par énormément d’outils, notamment dans le monde de l’infrastructure (Kubernetes est un exemple).

Dans un système distribué, tout peut arriver: problèmes d’infrastructures divers, problèmes réseau, bug dans une dépendance, crash d’un service…​ Le gros risque de tout ça est comme décrit précédemment l’interruption d’une action en cours de traitement.
Pire, il faut pouvoir faire revenir à l’état nominal du système. Je vois encore trop d’équipes réconciliant des données "à la main" dans ce type d’architectures distribuées, car les applications n’ont aucun mécanisme de reprise.

Et ce mécanisme peut être les boucles de réconciliations.

Un très grand nombre de systèmes peuvent se définir en terme de "workflow": une série d’actions à exécuter pour faire converger une entité dans un état voulu. Dans notre exemple précédent, ces actions étaient au nombre de deux: écrire dans la base de données et publier un message dans le bus de message. Mais en réalité, il est assez commun d’avoir des workflow plus complexes: écrire dans une base de données, appel HTTP vers un service d’un partenaire, traitement de la réponse et nouvelle écriture en base, publication d’un message…​

Pourrions-nous construire un système spécifiant que lorqu’une action est acceptée par le système, on ait la garantie qu’elle va jusqu’au bout de son exécution même en cas de problème ? Oui.

Dans mon expérience précédente, toutes les actions sur une entité (un enregistrement en base de données) pouvaient être décrites sous forme de machines à états finis déclenchant des workflow représentés par des arbres syntaxiques abstraits. J’ai apprécié cette manière de concevoir des services backend et les systèmes étaient extrêmement robustes. Qu’est ce que tout ça veut dire ?

Nous utilisions une librairies (disponible d’ailleurs sur Github) pour définir simplement une série d’actions à réaliser. Ces actions pouvaient être appelées l’une après l’autre ou en parallèle (pour accélérer l’exécution), le tout de manière très flexible et avec des choses comme la gestion d’erreur ou le retry inclus.

Exemple d’exécution de jobs via un AST

On voit dans cet exemple une série de 5 actions, dont deux sont réalisées en parallèle. L’ordre des actions est ici numéroté. Lors d’une action sur le service (appel HTTP, événement Kafka…​) l’entité pouvait optionellement changer d’état ("destroying", "updating", "upgrading…​ ce qui pouvait être aussi utile pour interdire des actions parallèles sur une entité en vérifiant son état) et une réconciliation était déclenchée. Si cette dernière plantait, elle était retentée plus tard (immédiatement, ou X secondes…​c’était configurable). En fin de réconciliation l’entité repassait dans un état nominal ("running" par exemple). Du tooling nous permettait de suivre en temps réel l’exécution de chaque étape d’un job et de forcer la réconciliation d’une entité.
Ces slides (notamment à partir de la 47) décrivent briévement ce pattern, qui fonctionne très bien dans beaucoup de cas.

On entend d’ailleurs de plus en plus parler de workflows et de "durable execution" en ce moment, qui est un concept assez similaire mais avec une vraie gestion de la temporalité. La grosse hype du moment dans ce domaine est Temporal, que je n’ai malheureusement toujours pas eu le temps de tester. Je vous conseille fortement de cliquer sur le lien et de jeter un coup d’oeil, ces patterns fonctionnant aussi dans des architectures classiques.

L’idée à retenir de tout ça: construisez des systèmes capables de s’auto réparer sans actions de votre part.

Saga

Tout ça me fait aussi penser au pattern Saga. Le problème est toujours le même: comment exécuter une transaction distribuée (à voir ensuite si l’on souhaite vraiment des transactions entre services), et voir comment rollback en cas d’échec. Certains "frameworks" implémentant ce pattern sentent un peu le "framework d’entreprise™" mais il y a toute une littérature intéressante sur ce sujet.

L’idempotence

L’idempotence est obligatoire dans ce type de système. On parle d’idempotence lorsque la même action répétée plusieurs fois conduit au même effet.
Pourquoi est-ce une notion critique en microservice ? Car la majorité des systèmes de ce type fournissent des garanties de type "at least once". Si le même événement est envoyé deux fois par un producteur dans un bus de message (à cause d’un bug, d’un retry…​), les consommateurs le recevront deux fois mais doivent produire le même résultat que si il n’avait été envoyé qu’une fois. Même chose pour des requêtes HTTP.

Cela est assez facile à faire dans certains cas, plus difficile pour d’autres. Attacher un UUID d’idempotence aux actions peut être une solution (de nombreux SaaS dont Stripe fonctionnent comme ça par exemple), mais cela force les consommateurs à d’une manière ou d’une autre stocker et vérifier ces ID à chaque message reçu, et à gérer leurs expirations.

Accès aux données

The elephant in the room.

Comme dit précédemment, l’état de l’art en microservice énonce que chaque service doit avoir sa propre base de données. Ce service doit être le seul à modifier cette donnée, et doit être la source de vérité pour l’état de cette donnée.

Cette image montre une architecture monolithique (un service et une base de données) et une architecture microservice (3 services, une base de données par service, un bus de message)

Duplication

Sauf qu’on se retrouve parfois dans des cas où un service a besoin d’une donnée d’un autre service. Prenons l’exemple d’un site de e-commerce, et l’exemple de duplication de données que l’on retrouve dans la majorité des architectures microservices (et que j’ai vu dès le début de ma carrière): la recherche.
Un service peut par exemple être responsable de la gestion des produits, et un autre de la recherche. Le service produit pourrait utiliser une base SQL classique, et le service de recherche un outil comme Elasticsearch.

Cette image montre les deux services Produit et Recherche ont chacun une base de données (Elasticsearch pour recherche). Les données passent de Produit à Recherche via le bus de message

Lorsqu’un nouveau produit est créé ou supprimé dans le service produit, ou modifié, un événement est émis par ce service dans le bus de message (le pattern Change Data Capture peut s’avérer également utile ici pour fiabiliser la production des événements). Le service de recherche consomme cet événement et met à jour sa propre base de données.

Ceci est intéressant: les deux services peuvent fonctionner (et scale) en totale autonomie. Une panne du service de recherche n’affectera pas le service produit, et vice versa.

Les caractéristiques de ce type de duplication de données doivent pourtant être bien comprises. Ici, l’intérêt est évident: faire de la recherche demande des technologies dédiées, des données ayant une forme différente par rapport à celles du service source (champs spécifiques, structure différente…​) et il fait sens de découpler les deux fonctionnalités.

Un peu de duplication est donc conseillé, mais il ne faut pas en abuser et il faut toujours se poser la question du pourquoi on duplique, de comment, et si une solution alternative est possible. En effet, de nombreux cas limites existent avec cette approche.

Désynchronisation

Les données dans le service dupliquant les données (ici la recherche) peuvent être en désynchronisation avec le service producteur pour plusieurs raisons. Peut être par exemple que le service producteur a échoué à produire des messages, et je vous garantis que ça va arriver même avec la meilleure volonté du monde, il y aura des bugs dans votre système.
Peut être également que la propagation entre le service producteur et consommateur prendra plus de temps que prévu, et donc que les mises à jours arriveront avec 10, 20, 30…​ secondes de retard.

Bref, vous ne serez jamais sûr que les données sont complètement synchronisées des deux côtés. Est-ce acceptable pour votre fonctionnalité ? On peut considérer pour la recherche que quelques résultats incorrects (description non mise à jour, article qui vient d’être ajouté mais qui n’apparait pas encore dans les résultats…​) peut être "acceptable": à vous de définir vos SLO (service level objective) sur vos données. Dans d’autre cas, ça ne l’est pas. Imaginez la catastrophe si par exemple un utilisateur ayant son compte suspendu puisse continuer d’utiliser un sous-ensemble de votre service car sa suspension n’a pas été propagée à l’ensemble du système ? Les conséquences ne seraient sûrement pas les mêmes.

Reconstruction

Les données dupliquées doivent pouvoir être reconstruites car comme dit précédemment elles seront désynchronisées de manière subtile avec le temps. Si on reprend notre exemple de la recherche, peut être également qu’un nouveau champ a été ajouté dans la base de données du producteur et que l’on souhaite l’indexer dans le moteur de recherche. Ce process de reconstruction doit en plus fonctionner sans causer de downtime que ce soit d’un côté ou de l’autre. Et il faut bien sûr pouvoir également construire cette duplication une première fois quand le nouveau service est créé.

Poser vous la question de comment faire avant d’implémenter le pattern. Une solution simple pourrait être de déclencher d’une manière ou d’une autre une action sur le service producteur pour reproduire la totalité des événements représentant sa base de données dans le bus de message, événements qui pourront être reconsommés par le service de recherche qui mettra à jour ses enregistrements.
Selon le volume de données, ça peut piquer. Mais pour des cas simples (quelques millions ou dizaines de millions d’enregistrements) cela peut être suffisant. Le process peut être optimisé selon les besoins: utilisation d’un microservice de reindexation dédié, utilisation d’un replica de la base de données du producteur pour extraire les données…​ Bref, à voir en fonction du volume. Attention aussi à ne pas impacter d’autres services en faisant ça: rappelez vous qu’en microservice plusieurs consommateurs peuvent généralement écouter la même queue d’événement, créez en une dédiée à la réindexation pour éviter que l’ensemble des consommateurs aient à reconsommer un volume énorme d’événements.

Avoir un peu d’outillage pour pouvoir très simplement réindexer une entité peut également être utile. Vous découvrez qu’un produit est mal indexé ? Un simple appel POST /reindex/<id> pourrait déclencher sa réconciliation.

Attention aussi à la gestion des suppressions. Rater un événement de création ou de mise à jour n’est pas grave, il suffit de le reproduire depuis la source pour le réindexer correctement. Mais que se passe t-il si une donnée n’existe plus dans la source mais existe toujours dans la destination car l’événement de suppression a été perdu ? Cela montre aussi les avantages d’une réindexation complète malgré les challenges techniques qu’elle amène.

Pour la petite anecdote, j’ai travaillé en 2017 sur un projet à base de microservices (dans Kubernetes), Kafka pour le bus de message, et avec chaque service ayant une base de données SQL dédiée. C’était un projet en grand groupe, pas encore en production mais il y avait quand même une soixantaine de personnes travaillant dessus (~40 dev de mémoire) et on avait déjà un environnement de QA utilisable.

Les architectes avaient fait le choix de dupliquer de très nombreuses tables métier entre microservices via Kafka, un peu comme dans l’exemple de la recherche mais pour des données assez génériques et sans réelles transformations de ces dernières. On avait donc la majorité des services avec des duplications locales de données d’autres services, très souvent pour de mauvaises raisons (on y reviendra), bien sûr désynchronisées, et c’était devenu un bordel sans nom au point qu’il fallait régulièrement "reset" toutes les bases (supprimer toutes les données) pour repartir d’un état propre car au bout d’un moment l’utilisation normale de la plateforme devenait impossible ! Heureusement, c’était pas en prod (et j’ai quitté le navire avant^^)

EDIT: voir le commentaire de Mcsolar sur le sujet en fin d’article qui me dit avoir travaillé également sur ce projet: Nous n’avons supprimé les données qu’une seule fois, et c’était pour faire un clean de l’environnement de production avant son ouverture. Ce n’est pas du tout mon souvenir (et je suis parti avant la mise en production) mais j’accepte le droit de réponse ;)

Compaction et stream first

Depuis le début de cet article, je parle du bus de messages comme un moyen d’échanger des événements métiers ou des commandes entre plusieurs services. Mais on peut aller plus loin.

Et si il était possible d’utiliser le bus de message comme une base de données ? Les données représentant des entités (les produits par exemple) n’expireraient jamais mais resteraient indéfiniment dans le bus, prêtes à être reconsommées à tout moment. C’est possible sur des technologies comme Kafka avec la compaction.

J’étais comme beaucoup un peu hypé par le stream processing il y a une dizaine d’années. On avait depuis quelques années des outils comme Apache Storm, ou Spark Streaming qui ouvraient la voie à une nouvelle approche du traitement de données par rapport au batch (qui se rappelle de MapReduce ?).

Puis est arrivé Apache Kafka (je me rappelle avoir travaillé avec la version 0.8 en 2015 de mémoire), Je n’ai jamais stoppé d’utiliser cette technologie depuis. J’avais un peu décrit le fonctionnement de Kafka en début d’article mais je n’ai pas parlé de la compaction. Mon premier contact avec cette feature fut chez Exoscale où elle était utilisée pour gérer les zone DNS (voir cet article de pyr, mon ancien CTO).

Clairement le genre de features où lorsqu’on vous l’explique pour la première fois vous êtes comme ça:

mind blown

Comme dit en début d’article, Kafka garde les messages qu’on lui envoie durant une certaine durée configurable (TTL), comme par exemple 7 jours. Après ça, elles sont tout isimplement supprimées.
Sauf avec des topics en mode compaction.

Avec la compaction, Kafka gardera dans son topic le dernier événement pour chaque clé unique de message. Rappelez vous ce que j’écrivais en début d’article: lorsqu’on envoie un message dans Kafka, on choisit une clé pour ce dernier.

Imaginons que nous envoyons dans Kafka les détails d’un utilisateur à chaque changement de ce dernier (création, mise à jour ou suppression). On aura donc une série d’événements dans Kafka contenant ces informations, chaque événement ayant comme clé l’ID de l’utilisateur. En mode Kafka "classique", les services consommateurs consomment ces événements, puis après quelques jours (valeur du TTL) ils seront supprimés de Kafka.
En mode compaction, le dernier événement (le plus récent) pour chaque utilisateur sera gardé (voir la doc officielle sur ce sujet).

Explication de la compaction kafka: le dernier message pour chaque utilisateur est gardé

Les consommateurs consomment les changements du topic consommé au fil de l’eau. A chaque mise à jour d’une entité, le changement est toujours propagé aux consommateurs. Sauf qu’ici, toutes les données de vos utilisateurs sont disponibles dans le topic. Grâce à la compaction, la taille du topic reste limitée (=~ le nombre d’utilisateurs dans votre système dans cet exemple). Il est également possible de configurer un consommateur pour retraiter l’entièreté du topic (et donc l’entièreté des données) !

Revenons à l’exemple du service Produit et du service Recherche décrit précédemment: avec un topic compacté, reindexer les données dans le service de Recherche devient facile: il faut juste lui dire de reconsommer le topic depuis le début.

Je vous conseille fortement de le talk Turning the database inside-out du célèbre Martin Kleppman décrivant une architecture orientée streaming.

L’implémentation "habituelle" de ce pattern est la suivante:

  • Un service a une base de données "classique" et est le propriétaire de ces données

  • En cas de mise à jour, il écrit dans sa base et pousse un événement dans le topic compacté

  • La suppression fonctionne de la même manière: il faut pousser un événement spécial (de type tombstone) dans Kafka pour supprimer une clé d’un topic compacté, sinon l’entité restera pour toujours dans le topic. Pensez y, c’est obligatoire rien que pour des raisons légales (RGPD). Du soft-delete côté base de données peut être très intéressant pour faire re-converger ce topic (mais vous la base de données sera plus grosse).

Ici se pose encore une fois la question de la qualité des données du topic compacté. Si un service écrit dans sa base mais ne pousse pas le message, le topic n’est pas mis à jour. On revient au problème (et solutions) décrites plus tôt dans l’article.

Martin Kleppman va même plus loin: pourrions-nous faire une architecture 100 % orientée streaming, où la source de vérité n’est pas la base de données locale de chaque service mais le topic Kafka ?

Présentation d’une architecture orientée streaming: Des topics Kafka sont utilisés pour construire des materialized views qui sont ensuite utilisées par des services backend

Dans cet exemple simplifié, deux topics avec la compaction servent à créer 2 "materialized views". Les écritures se font directement dans les topics, et des consommateurs (non représentés ici) se chargent de reconstruire des vues dans des base de données depuis les nouveaux événements.
C’est intéressant car ici la source de vérité est Kafka, et chaque vue peut être facilement reconstruire "from scratch". On limite fortement les problèmes de désynchronisations par rapport à la solution précédente.

La difficulté ici est d’intégrer ce modèle au monde d’aujourd’hui, notamment HTTP. Quand un utilisateur exécute une action côté frontend, il s’attend à avoir une réponse rapidement. On veut éviter également les problèmes de type read-after-write comme décrit en début d’article, donc répondre "OK" au client alors que la donnée n’est pas propagée aux vues (donc invisible côté client) est problématique.

Une solution pourrait être d’attendre dans les services (comme dans le Service User) qu’un callback produit par un service consommateur arrive avant de répondre au client ( je link encore une fois la doc de Nats pour faire cela par exemple). Mais que faire si le callback ne vient jamais ? On ne peut pas répondre au client "réessaye", l’action est probablement toujours en cours dans le bus. On ne peut pas lui dire "OK", si il change son prénom, rafraîchit la page et voit toujours la même donnée, il ne va pas comprendre. Pour des systèmes critiques il vaut probablement mieux écrire dans la base de données avant.

Par contre, pour du service interne où on a la main sur le client (contrairement à un browser web), ça peut marcher, notamment si l’on est pas intéressé par recevoir une réponse immédiate.

Dernier conseil: lisez absolument le livre Designing Data Intensive Applications dont Martin Kleppman est l’auteur, il est génial.

data intensive

Mauvais pattern de duplications

Parfois, les équipes dupliquent les données entre services pour les mauvaises raisons. Il devrait y avoir une règle universelle quand on fait des microservices qui ressemblerait à quelque chose comme On tourne 7 fois son clavier dans ses mains avant de dupliquer de la donnée.

Mauvais découpage des domaines

Erreur classique mais fréquente. Vous pensiez avoir bien identifié votre domaine, en fait vous aviez tort. Vouloir créer des microservices "trop petits" est je pense une erreur courante, notamment lors du découpage d’un monolithe. Découper à la tronçonneuse, c’est facile, mais quand on se rend compte de la boulette c’est trop tard et il faut passer du temps à re-fusionner des services ensemble (et on sait tous qu’il y a de grandes chances qu’on vous dise "ah non on a déjà fait l’effort de découper, on va pas encore refaire !").

Parfois, l’erreur n’est pas un bug mais une feature: "oui je sais c’est le même domaine mais moi je veux coder en Rust et le service existant est en PHP donc je ne veux pas contribuer dessus", mais là on est plus dans un problème organisationnel que technique.

Bref, faites attention à ça.

Découpage pour la scalabilité et la tolérance aux pannes

Un cas que j’ai déjà rencontré dans le passé. Un microservice pour un domaine tourne tranquillement en production. Puis vient un nouveau besoin dans ce même domaine mais qui doit scale de manière différente. Il est décidé d’écrire un autre microservice, avec sa propre base de donnée dupliquée via un bus de message pour isoler ce nouveau besoin du reste pour que les deux services puissent tourner et scale de manière indépendante en production.

put the keyboard down !

Je ne pense pas qu’ici écrire un service et dupliquer la donnée soit la bonne stratégie dans le cas général.

Récapitulons ce que l’on veut:

  • Une nouvelle fonctionnalité dans le même domaine (ayant besoin des mêmes données que le service existant)

  • Pouvoir tolérer la perte du service existant et ne pas être impacté par ce dernier (latence etc)

  • Pouvoir scale (horizontalement par exemple) les deux services de manière indépendante

Un pattern que j’ai déjà utilisé par le passé quand j’ai eu besoin d’avoir 2 services en production venant du même domaine est tout simplement d’écrire dans la même base de code 2 fonctions main: la première démarre un service, la seconde le second. On lancerait par exemple le premier via ./service-name subservice1 et l’autre via ./service-name subservice2. Les deux services auront le même cycle de vie (même CI/CD, déployés en même temps par exemple).
Cela a de nombreux avantages: en production les deux services évolueront de manière indépendante et vous n’avez pas à split votre domaine.

Les deux peuvent partager la même base de données (avec un utilisateur read-only pour le second si il ne fait pas d’écritures) mais pour plus de résilience il est même possible de faire en sorte que le second service lise ses données depuis un read-replica de la base de données principale pour plus de résilience et une meilleure isolation. Et vous aurez toujours accès à des données fraîches ;)

J’explique dans ce schema la situation décrite au dessus où l’architecture cible contient 2 services démarrés depuis la même base de code

Dupliquer ou non les données ?

On a vu les avantages et inconvénients de dupliquer les données juste avant: autonomie des services mais risque d’incohérence des données (+ coût en développement plus important mais on en reparlera plus loin).

Est ce qu’on atteint pas ici les limites des microservices, est ce qu’il ne faudrait pas "calmer le jeux" quand ça commence à arriver trop fréquemment ?

Envoyer des emails

Imaginons un système où un microservice est en charge d’envoyer des emails. Les clients de ce service souhaitent pouvoir envoyer des emails à des utilisateurs selon plusieurs critères:

  • Envoyer un email à un utilisateur particulier, par utilisateur ID

  • Envoyer un email à tous les utilisateurs faisant partis de la même organisation

  • Envoyer un email à tous les utilisateurs faisant partis de la même organisation et ayant le rôle "admin"

Il peut être tentant de dupliquer la table utilisateur (venant du service utilisateur) directement dans le service email pour qu’il puisse réaliser sa fonction de manière indépendante:

La table email contient une copie de la table utilisateur

Ca fonctionne mais on retombe sur une duplication de données pénible à gérer. De plus, le service "email" devrait être beaucoup plus simple et ne recevoir que des commandes d’actions à réaliser, et pas vraiment réaliser de la "business logic". D’autant plus qu’on a problablement pleins d’autres services qui veulent envoyer des emails en fonctions d’autres critères.

Si on prend la théorie du microservice "by the book", on va plutôt introduire un service intermédiaire "Notifications" pour réaliser cela. Ce service pourrait également être en charge d’envoyer des SMS et contiendrait les préférences des utilisateurs (moyen de communication préféré etc).

Le service notification sert d’intermédiaire pour envoyer des emails

Ici, un message est envoyé dans Kafka par un service demandant d’envoyer un email spécifique aux membres d’une organisation. Ce message entre dans le bus de message puis:

1) Est consommé par le service notification. Ce service maintient un cache local des informations des utilisateurs via le bus de message.
2) Un nouveau message à destination du service email, dérivé du premier, est publié dans le bus de message par le service notification. Ce message contiendra toutes les informations nécessaires pour envoyer les emails.

Mais on retrouve encore la duplication des données. Et c’est là où j’ai envie de rappeller que personne ne nous force à faire du microservice découpé aussi finement. Personne ne vous met un flingue sur la tempe en hurlant "PLZ PLZ SPLIT MOI TOUT CA".

Un service implémente les domaines utilisateurs et notifications.

Pourquoi ne pas avoir un service "modulaire" implémentant les deux domaines (utilisateurs et notifications) ? Certes, la base de code sera un peu plus grosse, mais est-ce vraiment un problème si le projet est correctement architecturé ? Les deux domaines peuvent avoir des caractéristiques de scaling différentes (mais là encore le pattern "même codebase plusieurs fonctions main" peut fonctionner), mais soyons honnête deux minutes: peu d’entreprises, encore plus en France, ont des problèmes de performance/scaling.

Ici, on évite tout simplement la duplication de donnée en gardant les deux domaines dans le même service. Si on peut diviser par exemple par 2 notre nombre de microservices et problèmes associés, pourquoi s’en priver ?

Kafka stream

Il est également possible de réaliser des "join" entre topics directement dans Kafka grâce à Kafka Stream.

On réalise un join entre un topic contenant des emails à envoyer et une Ktable kafka. Le join rajoute à chaque message l’email de l’utilisateur et republie le résultat dans un topic Kafka consommé par le service Email

Ici, on crée une "ktable" (un composant dans Kafka stream) depuis un topic contenant les informations de nos utilisateurs. Il est possible de créer des "join" entre cette ktable et des messages arrivant d’un autre topic (ici emails_commands_user_id, très mal nommé d’ailleurs). A chaque fois qu’un message est poussé dans emails_commands_user_id, il est enrichit avec l’email de l’utilisateur grâce au contenu de la ktable, et republié dans un autre topic (email_commands) qui sera consommé par le service email.

Attention, ça semble simple sur papier mais il faut comprendre de nombreux concepts au moment de l’implémentation.

Appels synchrones ?

Vous lirez à droite à gauche de ne jamais faire d’appels synchrones (HTTP par exemple) en microservice.

On voit pourtant que les alternatives sont coûteuses (implémentation, risques de désynchronisation…​), donc pourquoi parfois ne pas rester pragmatique et accepter de faire un appel HTTP entre deux services ?

Si je reprends mon exemple précédent des services "Utilisateur" + "Notification" + "Email", serait-ce vraiment grave que le service notification fasse une requête HTTP au service utilisateur pour récupérer ses données à la place d’avoir un cache local ? Certes, on crée du couplage car le service Notification ne fonctionnera plus si le service Utilisateur ne répond plus, mais c’est aussi pour ça qu’on a des SLA. Le deal "on perd en fiabilité (et encore c’est à voir, on rappelle que 100 % du web fonctionne comme ça) mais on gagne en simplicité" peut également être intéressant, surtout si à la fin on parle de 100 requêtes par seconde.

A force de lire que faire de l’HTTP (ou gRPC…​) est le mal absolu, on retrouve les équipes de dev un peu comme ça quand elles font de l’archi:

Un même disant Must Not Do Synchronous calls

Pourtant, il y a des cas où faire des appels HTTP est obligatoire, j’en parlais au début. On doit tous interagir avec des SaaS externes ou des enterprises/SI parnetaires depuis nos services.

Je ne sais pas vous mais personnellement je n’ai jamais vu un partenaire externe me filer un broker Kafka pour consommer ou produire des messages. Et pourtant, tout le monde s’en sort. Je pense qu’il y a beaucoup à apprendre de cela car un partenaire externe nous force en quelque sorte à un découplage total car forcé. Pourquoi ne pas parfois reproduire ça en interne ?

Testing

Je discutais il y a quelques années avec des SRE d’une grosse entreprise Française du e-commerce. Le sujet de la discussion était (comme d’habitude lol) les microservices et plus spécifiquement les environnements de tests à la demande.

Leurs problème est courant: les équipes se retrouvent avec des microservices et ne savent plus comment tester, donc rapidement on demande aux SRE de développer de l’outillage pour pouvoir facilement créer des environnements à la demande, environnements contenant l’ensemble des microservices et permettant donc de tester un changement de manière globale.

Ma réponse à cette problématique les a un peu surpris: Ne faites pas ça.

Reconstruire le monolithe

Lorsque des équipes commencent à travailler en microservice, la question de "comment tester" arrive très rapidement.
Un monolithe reste assez simple à tester. En microservice, vous avez maintenant un système distribué. C’est complètement différent car on a une chorégraphie de services discutant entre eux via le réseau.

Le premier réflexe dans ce cas est de reconstruire le monolithe pour tester. C’est humain, tout le monde est je pense passé par cette phase dans sa carrière. C’est en effet rassurant de voir que le système dans son entièreté "fonctionne" avant de merge son travail et de l’envoyer en production. Sauf qu’en faisant ça, on passe complètement à côté des avantages du microservice.

Tout est fait pour éviter le couplage entre service, pour favoriser des équipes autonomes travaillant sur des services et domaines spécifiques. Et maintenant on ne peut plus tester sans recréer ce couplage ?

Environnement de dev local

J’avais écrit il y a un an un article appelé La clé de la productivité des dev: un environnement de dev le plus simple possible. Cela est d’autant plus vrai quand on fait du microservice.

Dans ma première expérience en microservice en 2017, on avait la même problématique: tout le monde voulait tester l’entièreté de la plateforme au moindre changement "local" sur un service. On avait donc tout un outillage en local pour:

  • Démarrer Kubernetes (via Minikube)

  • Déployer tous les services dessus avec également un load balancer pour exposer les services (front inclus)

  • Avoir les dépendances infrastructures (Kafka, base de données, Elastic…​) en local, dans du docker-compose de mémoire

  • Pouvoir "tester" en faisant du click click dans l’interface que tout fonctionnait encore à peu près bien (bien sûr on ne teste toujours que le happy path)

  • Ensuite on poussait notre travail

Spoiler: c’était de la merde.

On avait toujours un bon 25 % du plateau qui n’arrivait pas à démarrer l’environnement local: problèmes de virtualbox pour Minikube qui empêchait son démarrage, plantages lors du déploiement des applications dans Kubernetes, problèmes pour initialiser les base de données, problèmes à chaque mise à jour OS (on était sur Windows, ça aide pas faut dire)…​

Mais on avait un vrai effet tunnel là dessus. Il fallait que l’on puisse tester de cette manière, sinon comment bosser ?
Il existait aussi une méthode de test alternative où Minikube était remplacé par du Netflix Spring Cloud (Zuul, Ribbon…​) pour le service discovery si je me souviens bien, stack qu’on utilisait même pas en prod. Et au final, au bout d’un moment le nombre de services et dépendances infrastructures est tellement gros que ce n’est plus possible de faire tout tourner en local: plus assez de ressources disponibles.

Environnement à la demande Cloud

On pourrait se dire que l’on peut déporter le problème sur le cloud, où les ressources sont "infinies" (je suis d’accord avec cette affirmation tant que ce n’est pas ma carte bancaire utilisée).

Le problème sera toujours là mais la patate chaude est refilée à l’équipe infrastructure:

  • Toute une machinerie doit être mise en place pour gérer le cycle de vie de ses environnements (applications, dépendances infrastructure…​)

  • Cela coûte très cher, que ce soit en humain ou en infrastructure cloud

  • Le problème initial n’est toujours pas résolu

Pousez-vous sincèrement la question: avez-vous fait vraiment cet énorme effort pour faire des microservices pour vous retrouver dans une situation de couplage totale pour merge un changement ? Est-ce souhaitable ? Est-ce scalable ? A quel moment ça ne fonctionne plus ? Quan on déploie 30 services par Pull Request ? 50 ? 100 ? 300 ?

Une histoire de confiance

Ces demandes d’environnement globaux ne viennent pas de nulle part. C’est tout simplement que les équipes ont perdu la confiance de cliquer sur le bouton "merge" sans exercer manuellement leurs services.

Faire du microservice demande un changement de mentalité. J’aime énormément cette citation de Tyler Treat dans l’excellente série d’article de Cindy Sridharan Testing in Production (lisez les):

lmao at this microservices discussion on HN. “Devs should be able to run entire env locally. Anything else is just a sign of bad tooling.” Ok yeah please tell me how well it works running two dozen services with different dbs and dependencies on your macbook. But yeah I’m sure docker compose has you covered.

This is all too common. People start building microservices with a monolith mindset and it always ends up a shitshow. “I need to run everything on my machine with this particular configuration of services to test this one change.” jfc.

If anyone so much as sneezes my service become untestable. Good luck with that. Also, massive integration tests spanning numerous services are an anti-pattern, but it still seems like an uphill battle convincing people. Moving to microservices also means using the right tools and techniques. Stop applying the old ones in a new context.

Mais donc, comment on fait ?

Découpler, tests inclus

Je ne sais pas pour vous mais de mon côté quitte à faire du microservice, je veux pouvoir travailler dessus sans me soucier du reste du monde. Quelques pistes pour réaliser cela:

  • Avoir des contrats (schémas) clairs pour toutes les I/O: gRPC utilise protobuf pour cela, HTTP a OpenAPI, pour des messages bus comme Kafka on peut également utiliser des schemas de sérialisations comme Protobuf ou Avro (voir Kafka Schema Registry).
    L’intêret des formats comme Protobuf est que le client est autogénéré pour vous depuis la spec, ce qui limite fortement les risques d’erreurs. Cela est également possible pour OpenAPI même si c’est par expérience un peu plus pénible (mais il existe également du tooling pour écrire des tests validant vos payloads sur une spec).
    En plus on a des tonnes d’outils pour construire des générateurs depuis des contrats, donc vous pouvez gagner gratuitement des choses comme le Property Based Testing.

  • Utiliser les feature flags pour pouvoir itérer rapidement sans activer la fonctionnalité en production (l’équipe de QA pourra donc la tester sur un environnement de pré production par exemple), ou l’activer en production seulement pour des utilisateurs spécifiques.

  • Vérifiez vos parcours utilisateurs principaux en production via des tests end to end. La feature obscure que personne utilise, on "s’en fiche" (toute proportion gardée) si ça tombe. C’est pas la même si plus personne peut se connecter sur votre site.

  • Avoir du bon monitoring et stratégie de déploiement et rollbacks en cas de problèmes. D’ailleurs, les pires problèmes en production ne sont pas ceux qui font planter le service dès son démarrage (toutes les plateformes type Kubernetes gèrent ça sans problème) mais ceux qui laissent passer des bugs subtils qui ne sont que détectés qu’après un temps important.

Je sais, ça fait un peu captain obvious comme liste, mais de toute façon vous n’avez pas trop le choix.

Un problème de lead time et de complexité accidentelle

Etes-vous Galactus ?

Parfois, il faut savoir prendre du recul, sortir de la théorie et voir ce que tout cela donne en pratique.

Combien d’entreprises ont des équipes ayant la maturité suffisante pour gérer un système de ce type ? Gagnez-vous vraiment en vélocité sur le long terme ?

Entre garder un domaine dans un service existant, ou faire un nouveau service où l’équipe passera des semaines à écrire des systèmes de stream processing pour dupliquer ou enrichir de la donnée, puis à gérer tous les edge cases que cela produira, est ce que ça vaut vraiment le coup ?

On sait tous que les microservices sont fortement liés à l’organisation interne de l’entreprise. La technique vient souvent après.
Pourtant, je pense qu’il est également possible d’avoir plusieurs équipes travaillant sur la même base de code sans se marcher dessus, chacune ayant l’ownership sur son ou ses domaines dans le service.

Le problème que je vois avec les microservices est que beaucoup de gens se posent des questions après avoir découpé. On se retrouve donc avec des systèmes difficiles à gérer et avec des bugs subtiles car la montée en compétence sur ce type d’architecture, ses avantages et inconvénients arrive tardivement.

Comment améliorer cela ?

Ralentir

Une première solution est de ne pas découper, Ou découper mais d’abord en "gros morceaux". Cela est d’autant plus vrai si vous avez des systèmes avec une charge faible (je mets par exemple quelques centaines ou milliers de requêtes par seconde dans "faible").
Il est toujours possible de créer de nouveaux services autonomes quand on est sûr de son domaine et qu’on a une stratégie claire sur la gestion des données, avec une réponse pour chaque problème que cela apporte. Bref, faire un peu de "Hammock Driven Development"

Organiser

Prenons l’hypothèque qu’après analyse, vous choisissez de partir sur une architecture basée sur Kafka et Kafka Stream pour la consolidation des données. Vous aurez donc de plus en plus de cas où vos équipes devront s’intégrer dans la "plateforme" Kafka Stream pour leurs besoins, avec tout ce que cela implique (temps de développement, maintenance, observability…​).

Kafka Stream est une technologie bien spécifique. Ca tourne sur la JVM (il faut donc connaître Java et son écosystème) et l’implémentation n’est pas facile (nombreux concepts, codebase qui peut être "compliquée" à entrer dedans…​).
C’est là où je pense qu’il ne faut pas déplacer cette charge de travail directement sur les équipes de développement produit. Transformez d’abord Kafka Stream en commodité prête à être utilisée et consommée par les équipes sans qu’elles aient à coder quoi que ce soit. Si vous vous rendez compte que les patterns d’utilisation sont toujours les mêmes (INNER JOIN entre certaines KTables globales et topics), faites en sorte de pouvoir exprimer cela via un simple fichier de configuration ou DSL: 10 lignes à ajouter dans une conf et hop, c’est en prod.

Il sera plus simple d’avoir une équipe spécialisée sur la technologie construisant une bonne abstraction au dessus plutôt que de laisser de nombreuses personnes connaissant plus ou moins la techno travailler dessus tous les 6 mois (et donc avec une phase de réapprentissage obligatoire) en fonction des besoins. Créez plutôt rapidement quelques experts sur la technologie qui faciliteront sa mise en oeuvre de manière globale.

C’est la même chose pour les duplications de données locales. Définissez votre stratégie, bossez le tooling, et ensuite diffusez le. Dans le cas contraire vous allez juste avoir des équipes réimplementant encore et encore la même chose avec les mêmes problèmes, comme dans mon exemple de 2017.

Mesurez le lead time et mettez vous des objectifs ambitieux: "Faire un join avec Kafka Stream (all inclusive) doit pouvoir être implémenté en moins d’une demi journée", "créer une duplication locale avec réconciliation/reconstruction built in doit pouvoir se faire en deux heures".

Je le répète: c’est en fournissant des fonctionnalités/patterns "haut niveau" comme des commodités que l’on arrive à scale un département tech, il faut tuer la complexité à sa source. Construisez des plateformes et produits internes consommables facilement avant de diffuser des pratiques. Vous payerez un coup d’entrée un peu plus cher mais vous serez largement gagnant rapidement que ce soit en temps de développement ou en homogénéité des pratiques.

Conclusion

Pour finir sur un peu d’humour: les microservices ont quand même un énorme avantage, ça crée des postes pour gérer le bouzin et la tech continue d’être un secteur dynamique !

PS: j’avais commencé toute une section sur l’authentification en microservices, mais l’article est déjà bien trop long et je commence à avoir un peu la flemme d’écrire mais voici un rapide résumé de cette section:

  • L’authentification et l’autorisation est nécessaire en microservice, comme partout.

  • On utilise très souvent une approche décentralisée pour l’authentification: un service de gestion de tokens et de permissions est contacté aussi bien lorsqu’une requête externe (via une API Gateway par exemple) ou interne arrive et distribue des tokens portant des permissions. Ces tokens sont vérifiables dans les microservices directement sans appels externes car signés. Les permissions dans le token sont ensuite vérifiées par le service qui rejette ou non la requête.

  • Il existe plusieurs formats de tokens. Lisez ce super article pour plus d’informations sur le sujet. Bien que ne l’ayant jamais utilisé, Biscuit a quand l’air même bien cool, rien que pour Datalog. Peut être connaissez vous la citation Any sufficiently complicated C or Fortran program contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of Common Lisp ? A force de voir des permissions encodées dans du JSON, j’ai envie de la changer en Any sufficiently JSON permission scheme contains an ad hoc, informally-specified, bug-ridden, slow implementation of half of Datalog, change my mind.

  • L’authentification par token décentralisée marche très bien, et c’est super que les tokens soient autosuffisants pour être vérifiés. Mais ils peuvent être difficiles à expirer, et ça marche moins bien quand vos permissions deviennent complexes et que vous commencez à voir apparaître des erreurs HTTP Request Header Fields Too Large dans vos logs.

  • Une approche centralisée est aussi possible, via un service central contacté directement par les microservices. Lisez par exemple le papier de Google sur Zanzibar.

yolo

Tags: devops programming

Add a comment








If you have a bug/issue with the commenting system, please send me an email (my email is in the "About" section).

Top of page