Architecture logicielle

Patterns de conception microservices : résilience, découverte, données et observabilité

Avancé11 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Un système distribué tombe en panne d'une manière qu'un processus unique ne connaît pas, et le catalogue de patterns microservices existe pour que le système y survive. Ce guide regroupe les patterns selon le problème qu'ils résolvent (routage, résilience, cohérence des données, déploiement et observabilité), montre la configuration et le code de ceux que vous implémenterez réellement, et dit clairement ce que chacun coûte.

Ce que vous allez apprendre

  • Choisir le bon pattern de routage et de découverte pour une plateforme de déploiement donnée
  • Configurer timeouts, retries, circuit breakers et bulkheads pour que les pannes restent confinées
  • Rendre une opération idempotente afin que retries et redélivraisons soient sans danger
  • Choisir entre CQRS, event sourcing et un simple modèle de lecture partagé face à un problème de requêtage
  • Déterminer si un service mesh justifie sa complexité opérationnelle sur votre plateforme

Le catalogue de patterns microservices se lit comme une liste de courses, et le traiter comme tel est le plus court chemin vers un système que personne ne sait exploiter. Chaque pattern présenté ici existe parce qu'une panne bien précise a fait assez mal à quelqu'un pour être consignée, et chacun ajoute des pièces mobiles. La question utile n'est jamais « quels patterns devons-nous adopter », mais « à quelle panne suis-je exposé aujourd'hui, et quel est le moyen le moins coûteux de l'éliminer ».

Ce guide est organisé par problème plutôt que par nom de pattern. Il part du principe que vous avez déjà choisi le style et tracé vos frontières (voir microservices pour cela) et renvoie le traitement approfondi de la cohérence à transactions distribuées et cohérence : la saga et l'outbox n'y sont abordées que pour la forme qu'elles donnent à une conception.

Acheminer une requête vers la bonne instance

Gateway en bordure. Les clients n'ont pas à connaître la topologie de vos services. Un point d'entrée unique termine le TLS, authentifie, applique les limites de débit et route selon le chemin ou l'hôte vers le service approprié. Il regroupe les préoccupations transverses en un seul endroit et vous permet de basculer une route vers une nouvelle implémentation sans toucher aux clients — c'est précisément ce qui rend possible une migration strangler fig. La gateway doit rester légère : routage, politiques, traduction de protocoles, et rien qui encode des règles métier. Le guide gestion d'API traite en détail des produits et des politiques.

Backend for frontend (BFF). Lorsqu'une application mobile et une application web ont besoin d'agrégations sensiblement différentes, une API de gateway unique qui sert les deux devient un compromis qui ne convient à aucune. Un BFF est un petit service par type de client, pris en charge par l'équipe qui développe ce client, qui compose les appels aux services en aval et met en forme les réponses pour un seul consommateur. Le risque est la duplication de logique entre BFF ; cantonnez-les à la composition et à la mise en forme.

Découverte de services. Les instances apparaissent et disparaissent, si bien que l'appelant résout un nom logique en adresse au moment de l'appel. Il existe deux approches. Avec la découverte côté client, l'appelant interroge un registre et choisit lui-même une instance, ce qui autorise une sélection fine (instance la moins chargée, affinité de zone) au prix d'un client de découverte dans chaque langage utilisé. Avec la découverte côté serveur, l'appelant s'adresse à une adresse virtuelle stable, et c'est un proxy ou le load balancer de la plateforme qui choisit l'instance. Sur Kubernetes, c'est tout simplement un Service, et c'est la raison pour laquelle la plupart des nouveaux systèmes n'ont besoin d'aucun registre séparé.

ApprocheOù se fait le choixAdaptée à
Registre côté clientDans le processus appelantPlateformes hétérogènes, déploiements sur VM, routage fin
Proxy côté serveurDans un load balancer ou la plateformeOrchestration de conteneurs, parcs polyglottes
DNS seulRésolveur, avec cache selon le TTLTopologies simples ; attention aux caches périmés lors d'un basculement

Le load balancing appelle une mise en garde : le round-robin répartit les requêtes uniformément, ce qui est le mauvais objectif quand leur coût varie. Une politique « least outstanding requests » envoie le travail à l'instance qui a le moins d'appels en cours et s'adapte d'elle-même à une instance lente ; préférez-la chaque fois que votre proxy ou votre client la prend en charge.

Confiner les pannes

Tout appel distant peut mal finir de trois façons : il échoue vite, il échoue lentement, ou il ment. L'échec lent est le plus dangereux, car un appelant qui attend une dépendance mobilise un thread, une connexion et de la mémoire, et quelques milliers d'appelants dans cet état suffisent à épuiser sa propre capacité. Les patterns qui suivent s'attaquent tous à cette réaction en chaîne.

Timeouts. Le contrôle le plus rentable de tous, et celui qu'on laisse le plus souvent à une valeur par défaut de bibliothèque qui se compte en minutes. Chaque appel sortant a besoin d'une échéance calculée d'après ce que l'appelant peut se permettre, et non d'après la durée habituelle de l'appelé. Une discipline utile consiste à raisonner en budget par requête : une requête entrante qui dispose de 800 ms en alloue 300 à une dépendance et 200 à une autre, et tout appel qui dépasserait le budget restant n'est même pas tenté.

Retries avec backoff et jitter. Réessayer après un échec transitoire en vaut la peine ; réessayer immédiatement, depuis tous les clients, transforme un incident de cinq secondes en effet de troupeau (« thundering herd »). Utilisez un petit nombre maximal de tentatives, un backoff exponentiel et un jitter aléatoire pour que les clients ne se synchronisent pas. Ne réessayez jamais une requête qui n'est pas idempotente, et ne réessayez jamais après une réponse qui signale une erreur du client.

Circuit breaker. Suivez le taux d'échec récent d'une dépendance ; lorsqu'il franchit un seuil, cessez de l'appeler pendant une période de refroidissement et échouez immédiatement avec une solution de repli. Une fois la fenêtre écoulée, laissez passer quelques appels de sonde et refermez le breaker s'ils réussissent. Le bénéfice est double : l'appelant cesse de gaspiller des ressources sur des appels voués à l'échec, et la dépendance en difficulté a une chance de se rétablir au lieu d'être martelée.

Bulkhead. Cloisonnez les ressources de l'appelant pour qu'aucune dépendance ne puisse les consommer toutes. Concrètement, cela signifie un pool de threads borné ou un sémaphore qui limite les appels concurrents par dépendance, et des pools de connexions distincts plutôt qu'un pool partagé.

resilience4j:
  circuitbreaker:
    instances:
      pricing:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 50
        failureRateThreshold: 50          # ouverture à 50 % d'échecs sur la fenêtre
        slowCallDurationThreshold: 400ms  # un appel lent compte comme un échec
        slowCallRateThreshold: 60
        waitDurationInOpenState: 10s
        permittedNumberOfCallsInHalfOpenState: 5
  bulkhead:
    instances:
      pricing:
        maxConcurrentCalls: 20            # jamais plus de 20 appels en cours
        maxWaitDuration: 0                # rejeter immédiatement plutôt que mettre en file
  retry:
    instances:
      pricing:
        maxAttempts: 3
        waitDuration: 100ms
        enableExponentialBackoff: true
        exponentialBackoffMultiplier: 2
        retryExceptions:
          - java.io.IOException
@Service
class PricingClient {

    @CircuitBreaker(name = "pricing", fallbackMethod = "listPrice")
    @Bulkhead(name = "pricing")
    @Retry(name = "pricing")
    Money quote(String sku, String customerId) {
        return http.get("/quotes/{sku}?customer={c}", Money.class, sku, customerId);
    }

    // Se dégrader plutôt qu'échouer : le prix catalogue est moins bon qu'un devis, mais préférable à une page d'erreur.
    private Money listPrice(String sku, String customerId, Throwable cause) {
        log.warn("pricing degraded for {}: {}", sku, cause.toString());
        return catalogue.listPrice(sku);
    }
}

Le rate limiting et le délestage de charge protègent l'autre côté de la même relation, celui de l'appelé. Un service qui accepte toutes les requêtes jusqu'à s'effondrer ne sert plus personne ; un service qui rejette vite l'excédent avec un 429 continue de servir le trafic qu'il peut absorber. Délester par priorité (rejeter le trafic batch et de reporting avant le trafic interactif) est un raffinement simple et efficace.

Rendre les opérations rejouables sans danger

Les patterns de résilience garantissent que certaines requêtes seront délivrées plus d'une fois. La livraison at-least-once d'un broker garantit la même chose. La seule parade est l'idempotence : traiter deux fois la même opération logique produit le même effet que la traiter une seule fois.

Les lectures et les suppressions sont naturellement idempotentes, et PUT remplace une ressource entière, ce qui le rend généralement idempotent lui aussi. Le problème, ce sont la création et la modification. La réponse classique est une clé d'idempotence : le client la fournit, le serveur la stocke avec le résultat obtenu, et renvoie ce même résultat si la requête se répète.

-- C'est la contrainte d'unicité, et non le code applicatif, qui garantit la correction.
CREATE TABLE processed_request (
    idempotency_key VARCHAR(64) PRIMARY KEY,
    request_hash    CHAR(64)     NOT NULL,
    response_body   JSONB        NOT NULL,
    created_at      TIMESTAMPTZ  NOT NULL DEFAULT now()
);

Le handler insère la clé dans la même transaction que la modification métier. Si l'insertion viole la clé primaire, la requête est une répétition : renvoyez la réponse stockée. Si la même clé arrive avec un hash de payload différent, c'est un bug côté client : rejetez-la avec un 422 au lieu de renvoyer silencieusement l'ancienne réponse. Conservez les lignes plus longtemps que la fenêtre de retry de n'importe quel client, puis faites-les expirer.

Pour les consommateurs de messages, la même idée s'applique avec l'identifiant du message comme clé. Quand l'effet est une transition d'état, une variante moins coûteuse fonctionne bien : conditionnez la mise à jour à l'état courant, si bien que UPDATE orders SET status='PAID' WHERE id=? AND status='PENDING' n'affecte tout simplement aucune ligne sur un doublon.

Patterns de données entre services

Dès que chaque service possède ses données, trois problèmes récurrents apparaissent : écrire dans sa base et publier un événement de façon atomique, garder un processus métier cohérent à travers plusieurs services, et interroger des données qui ne vous appartiennent pas.

Transactional outbox. Un service ne peut pas écrire dans sa base et publier vers un broker dans une même transaction ; il écrit donc l'événement dans une table outbox au sein de la transaction métier, et un relais séparé lit cette table et publie. Rien n'est perdu si le processus meurt entre les deux étapes.

Saga. Un processus métier qui traverse plusieurs services devient une suite de transactions locales, chacune dotée d'une action de compensation si une étape ultérieure échoue. La chorégraphie relie les services par des événements ; l'orchestration confie la séquence à un coordinateur. Les deux sont traitées en détail, analyse des pannes comprise, dans le guide transactions distribuées.

CQRS. Séparez le modèle d'écriture du modèle de lecture. Le modèle d'écriture fait respecter les invariants et reste normalisé ; un ou plusieurs modèles de lecture sont taillés pour des requêtes précises et mis à jour à partir d'événements. Utilisez-le là où les besoins de lecture et d'écriture divergent réellement (recherche à fort volume sur des données au modèle d'écriture complexe) et non par défaut, car il introduit un décalage que chaque consommateur doit tolérer et autour duquel chaque interface doit être conçue.

Event sourcing. Persistez la suite des événements qui modifient l'état plutôt que l'état courant, et reconstruisez l'état en les rejouant. Vous y gagnez une piste d'audit parfaite, des requêtes temporelles et la possibilité de construire de nouvelles projections à partir de l'historique. Vous le payez par l'évolution du schéma d'événements qui vivent éternellement, par des snapshots pour les flux longs, et par un modèle mental peu familier à la plupart des équipes. Adoptez-le pour un bounded context où l'historique est le produit (registres comptables, trading, conformité) plutôt que pour tout un système.

Composition d'API. Le cas simple : un appelant interroge plusieurs services en parallèle et joint les résultats. Parfaitement raisonnable pour de petits jeux de résultats, impraticable dès qu'il faut filtrer ou trier à travers plusieurs services.

Patterns de déploiement et de plateforme

Sidecar. Déployez un conteneur auxiliaire à côté du service, dans la même unité, qui partage son cycle de vie et son espace de noms réseau, pour fournir une capacité que le service ne devrait pas implémenter lui-même : envoi des logs, rotation des identifiants, proxy. Le service reste centré sur le comportement métier ; le sidecar se met à jour indépendamment.

Service mesh. Un mesh, ce sont des sidecars partout : chaque instance reçoit un proxy, et un plan de contrôle les configure pour fournir TLS mutuel, retries, timeouts, répartition du trafic et télémétrie uniforme sans code applicatif. C'est réellement précieux dans un grand parc polyglotte. C'est aussi un second système distribué, avec son propre chemin de mise à jour, ses propres modes de défaillance et son coût en latence, et il duplique des capacités que vos bibliothèques fournissent peut-être déjà. Décidez en comptant : combien de langages, combien de services, et quelle part de la liste de fonctionnalités du mesh vous faudrait-il sinon écrire vous-même.

Configuration externalisée. La configuration vient de l'environnement, et non de l'artefact, de sorte qu'une même image est promue sans modification d'un environnement à l'autre. Les secrets viennent d'un gestionnaire de secrets et peuvent être renouvelés sans rebuild.

Endpoints de santé. Distinguez la liveness (le processus est-il cassé sans espoir de rétablissement : redémarrez-le) de la readiness (peut-il servir du trafic en ce moment : ne lui en envoyez pas tant que ce n'est pas le cas). Les confondre provoque des boucles de redémarrage dès qu'une dépendance est brièvement indisponible, ce qui transforme une panne partielle en panne totale.

Déploiements blue/green et canary. Orientez une petite part du trafic de production vers la nouvelle version, comparez son taux d'erreur et sa latence à ceux de l'ancienne, puis promouvez ou revenez en arrière automatiquement. C'est le pattern qui rend le déploiement indépendant sûr en pratique, et il suppose des interfaces et des évolutions de base de données rétrocompatibles.

Voir ce qui se passe

Dans un monolithe, une stack trace explique une panne. Dans un système distribué, rien ne l'explique si vous ne l'avez pas prévu.

Corrélation et tracing distribué. Chaque requête entrante reçoit un identifiant de trace, propagé sur chaque appel sortant via les en-têtes W3C traceparent, et chaque saut enregistre un span. La trace obtenue montre exactement où sont passées les 4 secondes du checkout. OpenTelemetry permet d'instrumenter tout cela sans se lier à un éditeur ; grâce à l'auto-instrumentation des frameworks courants, le socle de base demande peu d'effort.

Logs structurés. Des lignes JSON portant l'identifiant de trace, le nom du service, sa version et le tenant sur chaque enregistrement, agrégées de façon centralisée. Des logs en texte libre répartis sur douze services sont impossibles à fouiller.

Des métriques qui décrivent les utilisateurs. Débit de requêtes, taux d'erreur et percentiles de latence par endpoint, plus la saturation des pools que vous avez cloisonnés. Alertez sur des objectifs de niveau de service (« 99 % des checkouts aboutissent en moins de 1 s sur 30 jours ») plutôt que sur le CPU, qui ne vous dit rien sur la souffrance réelle des utilisateurs. Interpréter correctement ces percentiles est le sujet du guide performance applicative.

Santé agrégée. Un tableau de bord par service qui affiche les quatre signaux et l'état des circuit breakers suffit pour la plupart des incidents ; tout ce qui est plus élaboré finit par ne plus être regardé.

Points clés à retenir

  • Adoptez un pattern pour éliminer une panne à laquelle vous êtes réellement exposé ; chacun d'eux ajoute des pièces mobiles et de la surface opérationnelle.
  • Gateways et découverte de services tiennent les clients à l'écart de la topologie ; sur une plateforme de conteneurs, l'abstraction de service de la plateforme remplace généralement un registre séparé.
  • Timeouts, retries bornés avec jitter, circuit breakers et bulkheads fonctionnent comme un tout : configurez les retries dans une seule couche et placez-les à l'intérieur du breaker.
  • La livraison at-least-once et les retries rendent l'idempotence obligatoire ; garantissez-la avec une clé d'idempotence stockée ou une mise à jour conditionnée à l'état, pas avec une logique applicative pleine d'espoir.
  • Outbox, saga, CQRS et event sourcing résolvent des problèmes de données distincts ; utilisez le plus petit qui convient et ne partez pas de l'event sourcing par défaut.
  • Tracing distribué, logs structurés avec identifiant de corrélation et alerting fondé sur des SLO ne sont pas des options : sans eux, une panne de microservice reste inexpliquée.

Sources et lectures complémentaires

Questions fréquentes

Les retries doivent-ils vivre dans le client, la gateway ou le service mesh ?
À un seul endroit, et un seul. Des retries configurés à trois niveaux se multiplient : trois tentatives à chaque niveau donnent vingt-sept requêtes vers un service déjà en difficulté, et c'est ainsi qu'une dégradation devient une panne. Placez-les là où l'appelant sait si l'opération peut être rejouée sans risque, c'est-à-dire normalement dans la bibliothèque cliente, et désactivez-les partout ailleurs. Si le mesh s'en charge, l'application ne doit pas le faire.
Quelle différence entre un circuit breaker et un bulkhead ?
Un circuit breaker cesse d'appeler une dépendance qui est déjà en échec, pour que l'appelant échoue vite au lieu d'attendre en file derrière des timeouts. Un bulkhead limite la part des ressources propres de l'appelant (threads, connexions, requêtes en cours) qu'une dépendance donnée peut consommer, pour qu'une seule dépendance lente ne puisse pas affamer le reste de l'application. Ils traitent chacun une moitié du même problème et s'appliquent généralement ensemble.
Ai-je besoin d'un service mesh ?
Seulement si vous avez assez de services, dans assez de langages, pour que l'implémentation du mTLS, des retries, de la répartition du trafic et de la télémétrie dans chaque bibliothèque cliente soit devenue un vrai fardeau. Un mesh déplace ce travail dans des proxies sidecar, au prix d'un plan de contrôle distribué de plus à exploiter, mettre à jour et déboguer, et d'une petite latence supplémentaire à chaque saut. En dessous d'une douzaine de services environ, les bibliothèques reviennent généralement moins cher.
L'event sourcing est-il obligatoire pour faire du CQRS ?
Non, et confondre les deux engendre beaucoup de complexité inutile. CQRS signifie simplement que le modèle d'écriture et le modèle de lecture sont séparés, ce qui s'implémente avec une base de données ordinaire et une table de projection. L'event sourcing signifie que le journal d'événements est la source de vérité. Chacun est utile seul ; les combiner a surtout du sens quand il vous faut un historique d'audit complet et des requêtes temporelles.