L'ouvrage qui doit bouger pour ne pas casser

Sur Le Chantier, un tablier de béton de plusieurs centaines de mètres s'allonge de plusieurs centimètres entre l'hiver et l'été. S'il est coulé d'un seul bloc, la contrainte n'a nulle part où aller et l'ouvrage fissure. On découpe donc volontairement la structure et on laisse du jeu entre les morceaux : ce sont les joints de dilatation.

Même logique pour les lots. Le lot électricité et le lot plomberie ne se soudent pas l'un à l'autre. Chacun arrive à un point de raccordement normalisé, une gaine, un tableau, un collecteur. On peut reprendre l'un sans démonter l'autre.

Un système informatique qui monte en charge vit exactement ce problème. Tant que le trafic reste modeste, tout tient, y compris les liaisons soudées. Le jour où il faut doubler la capacité, ce sont ces soudures qui cassent en premier.

Ce qui tombe sous la charge, ce n'est presque jamais le composant surchargé. C'est le lien rigide entre deux composants qui n'auraient jamais dû être solidaires.

Ce billet ouvre l'architecture système, le niveau au-dessus du composant. Après les patterns du billet précédent, qui organisaient l'intérieur d'un module, on regarde comment les modules tiennent ensemble.


Vue d'ensemble : trois décisions structurantes

Trois décisions, et elles se prennent tôt parce qu'elles sont pénibles à rattraper.

Le couplage lâche. Chaque service doit pouvoir être remplacé, déplacé, redéployé, réécrit dans un autre langage, sans que ses voisins s'en aperçoivent. C'est le joint de dilatation du logiciel.

L'absence d'état. Un service ne garde rien en mémoire entre deux requêtes. C'est ce qui autorise à en lancer dix copies derrière un répartiteur de charge, ou à en tuer une sans conséquence.

Le cache. Rapprocher la donnée de celui qui la lit, pour ne pas payer l'aller-retour vers le magasin de données à chaque requête. C'est le gain de performance le plus rentable, et le plus facile à mal faire.

Ces trois décisions se renforcent : un service sans état est facile à multiplier, un service faiblement couplé est facile à remplacer par une version qui cache mieux.

Prises séparément, elles se sabotent : un cache local mal placé est exactement l'état qu'on venait de sortir du processus.

Deux sujets voisins ne sont pas traités ici : la façon dont les services se parlent (files de messages, notifications, REST) et la supervision centralisée. Ils font l'objet du billet suivant de la série.


Couplage lâche : ne pas souder les lots

Sur Le Chantier, personne n'accepterait que le lot chauffage impose sa marque de tuyaux au lot sanitaire. Le raccordement se fait sur une norme, un diamètre, un filetage. C'est ce qui permet de changer de fournisseur à mi-parcours.

En informatique, le couplage se glisse à des endroits qu'on ne surveille pas. Deux formes reviennent constamment.

Le couplage de plateforme

Un service expose un objet sérialisé dans un format propre à sa technologie. Le consommateur doit tourner sur la même plateforme pour le désérialiser. On a beau avoir deux services séparés, on n'a plus qu'un seul choix technique pour les deux.

La conséquence arrive plus tard : le jour où l'équipe veut réécrire un service en Go pour des raisons de latence, elle découvre que quatre consommateurs dépendent du format d'origine.

La parade tient en une phrase : le contrat entre deux services doit être neutre. HTTP plus JSON, ou un schéma explicite et versionné, mais jamais un artefact que seule une plateforme sait lire.

Le couplage d'URL

Celui-là est plus courant encore. Chaque service connaît l'adresse exacte de ceux qu'il appelle, écrite en dur dans sa configuration.

# Couplage d'adresse : chaque cible est figée
services:
  facturation: http://10.42.7.18:8081/api/v1
  stock: http://10.42.7.23:8082/api/v1
  clients: http://10.42.7.31:8083/api/v1

Ça marche parfaitement jusqu'au premier changement. Déplacer la facturation sur une autre machine oblige à modifier et redéployer tous ses consommateurs. Ajouter une deuxième instance de stock n'apporte rien, personne ne sait qu'elle existe.

Le nombre de liens à maintenir croît vite. Avec N services qui se connaissent tous, on a N × (N - 1) dépendances dirigées. À six services, cela fait trente adresses réparties dans six fichiers de configuration.

flowchart TB subgraph avant["Adresses en dur"] A1["Commandes"] A2["Facturation"] A3["Stock"] A4["Clients"] A1 --> A2 A1 --> A3 A2 --> A3 A2 --> A4 A3 --> A4 A4 --> A2 end subgraph apres["Passerelle et annuaire"] B1["Commandes"] B0["Passerelle"] R["Annuaire de services"] B2["Facturation"] B3["Stock"] B4["Clients"] B1 --> B0 B0 -. "résolution du nom logique" .-> R B0 --> B2 B0 --> B3 B0 --> B4 end classDef dur fill:#a0413e,stroke:#5f5e5a,color:#f5f2ec classDef ok fill:#d89253,stroke:#5f5e5a,color:#1a1a1a classDef infra fill:#b5651d,stroke:#5f5e5a,color:#f5f2ec class A1,A2,A3,A4 dur class B1,B2,B3,B4 ok class B0,R infra

Les deux parades

L'annuaire de services. Un composant tient la table des noms logiques et des adresses réelles. Un appelant demande « où est le service de facturation », et l'annuaire répond avec une instance disponible. Les instances s'enregistrent au démarrage et se retirent à l'arrêt.

C'est exactement ce que fait le DNS interne d'un cluster Kubernetes, ou un annuaire dédié comme Consul.

# Nom logique : l'adresse réelle est résolue à l'exécution
services:
  facturation: http://facturation.commandes.svc.cluster.local/api/v1

La passerelle. Elle va plus loin : les appelants ne connaissent qu'une seule adresse, celle de la passerelle, qui route vers le bon service. Elle devient au passage l'endroit naturel pour l'authentification, la limitation de débit et la journalisation d'entrée.

Le prix à payer est réel. La passerelle est un saut réseau supplémentaire, et un point de défaillance qu'il faut redonder. On l'introduit quand le nombre de services justifie la centralisation, pas à trois services.

Le bon test du couplage : peux-tu déplacer un service sur une autre machine, un mardi après-midi, sans toucher au code de ses consommateurs ? Si la réponse est non, tu as une soudure.
Ce qui est couplé Symptôme au moment de la montée en charge Parade
La plateforme Impossible de réécrire un service dans une autre technologie Contrat neutre et versionné, HTTP plus JSON
L'adresse Ajouter une instance ne sert à rien, déplacer un service casse ses appelants Annuaire de services, passerelle
Le temps de réponse Un service lent bloque tous ses appelants en cascade Appel asynchrone, file de messages (billet suivant)
Le magasin de données Deux services écrivent dans la même table et se marchent dessus Une base par service, échange par contrat

Stateless : l'état ne vit qu'à deux endroits

Sur Le Chantier, le chef de chantier ne garde pas dans sa tête l'avancement de chaque lot. Il le consigne au journal. C'est ce qui permet à son remplaçant de prendre la suite le lendemain sans que le chantier reparte de zéro.

Un service qui garde de l'information en mémoire entre deux requêtes est un chef de chantier qui ne note rien. Tant qu'il est seul, ça tient. Dès qu'on en met deux, ça casse.

Le panier qui se vide

Le scénario est classique. Une application de commerce garde le panier en mémoire, dans la session du serveur. Un répartiteur de charge distribue le trafic sur deux instances.

sequenceDiagram participant U as Navigateur participant LB as Répartiteur de charge participant I1 as Instance 1 participant I2 as Instance 2 participant DB as Magasin de données U->>LB: "Ajouter un article au panier" LB->>I1: "Requête 1" Note over I1: "Panier stocké en mémoire locale" U->>LB: "Afficher mon panier" LB->>I2: "Requête 2" I2->>DB: "Lire le panier de la session" DB-->>I2: "Aucune donnée" I2-->>U: "Votre panier est vide"

L'utilisateur a ajouté trois articles et voit un panier vide. Rien n'est en panne, aucune alerte ne se déclenche, les logs sont propres. Le bug n'existe qu'en production, parce qu'en développement il n'y avait qu'une seule instance.

La rustine classique consiste à activer l'affinité de session sur le répartiteur, pour renvoyer toujours le même utilisateur vers la même instance. Elle fonctionne, et elle annule une partie du bénéfice recherché : la charge se répartit mal, et si l'instance tombe, ses utilisateurs perdent tout. On a réparé le symptôme en supprimant la redondance.

Où l'état a le droit de vivre

La règle est plus simple qu'il n'y paraît. L'état ne vit qu'à deux endroits : dans le magasin de données côté serveur, ou dans l'interface utilisateur côté client. Jamais entre les deux.

Type d'état Où il doit vivre Le raccourci qu'on voit encore
Panier, formulaire en cours, brouillon Magasin de données, ou stockage du navigateur Session serveur en mémoire
Identité de l'utilisateur connecté Jeton signé porté par le client, ou magasin de sessions partagé Table des sessions locale au processus
Résultat intermédiaire d'un traitement long Magasin de données, avec un identifiant de tâche Variable d'instance, perdue au redéploiement
Compteur de quota ou de débit Magasin partagé, cache distribué Compteur local, divisé par le nombre d'instances

Le bénéfice est immédiat et double. Scalabilité : ajouter une instance devient une opération sans conséquence fonctionnelle, on peut le faire pendant les soldes et défaire le lendemain. Redondance : perdre une instance ne perd aucune donnée utilisateur, le répartiteur bascule le trafic et personne ne s'en aperçoit.

C'est aussi le sixième des douze facteurs, formulé autrement : exécuter l'application comme un ou plusieurs processus sans état, et traiter toute donnée persistante comme externe au processus.

Le test du stateless : tue une instance au hasard en pleine journée. Si un seul utilisateur perd quelque chose, ton état est au mauvais endroit.

Un dernier point souvent négligé : le stateless ne concerne pas que le code applicatif. Un cache local, un fichier temporaire écrit sur le disque de l'instance, une file interne en mémoire, sont autant d'états qui rendront le redéploiement risqué.


Cache : rapprocher la donnée de son consommateur

Sur Le Chantier, on ne renvoie pas un compagnon au dépôt central chercher trois vis. On tient un petit stock sur place, réapprovisionné une fois par jour. Le stock local n'est pas la vérité, c'est une copie proche et suffisante.

Le cache logiciel, c'est exactement ça : une copie d'une donnée maintenue plus près de son consommateur que son magasin d'origine. La différence de coût est d'un ordre de grandeur, parfois deux, entre une lecture en mémoire et une requête vers une base distante.

Ce qui mérite un cache

Deux critères se croisent, et il faut les deux. La donnée doit être lue souvent et modifiée rarement. Une donnée rarement lue ne rentabilise jamais son cache. Une donnée souvent modifiée passe son temps à être invalidée, ce qui coûte plus cher que de ne pas cacher du tout.

Donnée Lue Modifiée Cache pertinent
Catalogue produits, référentiels, tables de codes Très souvent Très rarement Oui, cas idéal
Profil et préférences d'un utilisateur connecté Souvent Rarement Oui, avec durée de vie courte
Stock disponible d'un article Souvent Souvent Prudence, cohérence critique
Solde d'un compte bancaire Souvent Souvent Non, on ne cache pas une donnée qui engage
Historique de commandes d'un client Rarement Rarement Inutile, aucun gain

Le compromis est toujours le même et il faut le poser explicitement : on échange de la fraîcheur contre de la performance. Une donnée en cache est, par construction, potentiellement périmée. La question n'est pas de savoir si elle le sera, mais combien de temps on accepte qu'elle le soit, et ce que ça casse.

Pour un libellé de catégorie, quelques minutes de retard n'ont aucune conséquence. Pour un prix affiché avant paiement, le retard se transforme en litige commercial.

Dans le processus, ou distribué

Le cache le plus simple est une structure en mémoire dans le processus du service. Aucune dépendance, aucune latence réseau, quelques lignes de code.

Il porte aussi le défaut du stateless mal fait. Avec trois instances derrière un répartiteur, on a trois caches indépendants qui divergent. Une mise à jour du catalogue invalide le cache de l'instance qui a reçu la requête, et laisse les deux autres servir l'ancienne valeur. L'utilisateur qui rafraîchit sa page voit le prix osciller entre l'ancien et le nouveau selon l'instance qui le sert. Ce bug est pénible à reproduire et pire encore à expliquer.

Le cache distribué résout le problème : un magasin en mémoire partagé, du type Redis ou Memcached, sur lequel toutes les instances lisent et écrivent. Une seule copie, une seule invalidation.

Critère Cache dans le processus Cache distribué
Latence d'accès Minimale, accès mémoire local Un aller-retour réseau, faible mais réel
Cohérence entre instances Aucune, chaque instance diverge Une seule vérité partagée
Invalidation À propager à toutes les instances Une opération suffit
Survie au redémarrage Perdu, avec un pic de charge sur la base Conservé, redémarrage transparent
Coût d'exploitation Nul Un service à déployer, superviser et redonder
Bon usage Données immuables, référentiels chargés au démarrage Sessions, données partagées et invalidables

Le choix se fait sur la nature de la donnée, pas sur la préférence de l'équipe. Une table de codes postaux chargée au démarrage n'a rien à faire dans un cache distribué. Un panier partagé entre instances n'a rien à faire dans un cache local.

Reste la partie difficile, l'invalidation. Deux stratégies coexistent : la durée de vie, où l'entrée expire seule au bout d'un délai fixé, simple et prévisible ; et l'invalidation explicite, où le service qui modifie la donnée supprime aussi l'entrée de cache, exact mais qui suppose que tous les chemins d'écriture y pensent. Le premier chemin d'écriture oublié devient un bug fantôme.

Une citation attribuée à Phil Karlton et relayée par Martin Fowler tient toujours : les deux choses difficiles en informatique sont l'invalidation de cache et le choix des noms.

Côté HTTP, une bonne partie du travail est déjà normalisée par les en-têtes de cache décrits dans la RFC 9111. Avant d'écrire un cache applicatif, il vaut la peine de vérifier ce que la couche HTTP sait déjà faire toute seule.


Scénario B : la panne S3 du 28 février 2017

Le 28 février 2017, le service de stockage Amazon S3 est devenu indisponible dans la région us-east-1 pendant plusieurs heures. AWS a publié un post-mortem détaillé, un texte court et précis sur le couplage caché.

Le déroulé, tel que décrit par AWS. Un membre autorisé de l'équipe S3 exécutait une procédure de diagnostic sur un ralentissement du système de facturation. La commande devait retirer du service un petit nombre de serveurs. Une saisie erronée a retiré un ensemble bien plus large, incluant des serveurs qui portaient deux autres sous-systèmes : l'index, qui contient les métadonnées et la localisation de tous les objets S3 de la région, et le placement, qui alloue le stockage pour les nouveaux objets.

Retirer une part significative de leur capacité imposait un redémarrage complet de ces deux sous-systèmes. Pendant ce redémarrage, S3 ne pouvait plus servir de requêtes dans la région.

Le redémarrage a pris plus de temps que prévu. AWS explique que ces sous-systèmes n'avaient pas été redémarrés intégralement depuis de nombreuses années, que S3 avait beaucoup grossi entre-temps, et que les contrôles d'intégrité sur les métadonnées ont donc duré plus longtemps qu'anticipé. Le placement dépendant de l'index, il fallait en outre rétablir l'index d'abord. La commande a été passée à 9h37, heure du Pacifique ; l'index a été pleinement rétabli à 13h18 et le placement à 13h54.

L'onde de choc a dépassé S3. D'autres services de la région qui s'appuient sur lui pour le stockage ont été affectés : la console S3, le lancement de nouvelles instances EC2, les volumes EBS lorsqu'une donnée devait être lue depuis un instantané S3, et Lambda.

Le détail le plus instructif est ailleurs. La console d'administration du tableau de bord d'état d'AWS, celui-là même qui sert à informer les clients d'une panne, dépendait de S3 dans us-east-1. Du début de l'incident jusqu'à 11h37, AWS n'a pas pu y mettre à jour l'état de chacun de ses services, et a communiqué par son compte Twitter et par le bandeau du tableau de bord.

Le couplage caché est celui qu'on découvre en panne. Personne n'avait décidé que l'affichage de l'état du service dépendrait du service lui-même. C'est arrivé, comme arrivent la plupart des dépendances, par accumulation de choix raisonnables pris séparément.

Les correctifs annoncés par AWS tiennent en trois points. L'outil a été modifié pour retirer la capacité plus lentement et pour refuser de descendre sous le seuil minimal d'un sous-système, ce qui limite la portée d'une erreur de saisie. Le découpage du sous-système d'index en cellules indépendantes, prévu pour plus tard dans l'année, a été avancé pour raccourcir les redémarrages. Et la console d'administration du tableau de bord d'état tourne désormais sur plusieurs régions, pour ne plus tomber en même temps que la région dont elle rapporte l'état.

Trois leçons transposables telles quelles : limiter le rayon de souffle d'une opération, découper les systèmes dont le temps de démarrage grandit avec les données, et vérifier que l'outil qui sert à gérer une panne ne dépend pas de ce qui tombe.


Points clés

  • Le couplage de plateforme et le couplage d'URL sont les deux soudures les plus fréquentes ; un contrat neutre, un annuaire de services et une passerelle les font sauter.
  • Un service sans état est la condition d'entrée de la scalabilité horizontale et de la redondance ; l'affinité de session est une rustine qui reprend d'une main ce qu'elle donne de l'autre.
  • L'état ne vit qu'à deux endroits : le magasin de données ou l'interface utilisateur, jamais dans la mémoire d'un processus entre deux requêtes.
  • On met en cache ce qui est lu souvent et modifié rarement, en acceptant explicitement un compromis entre fraîcheur et performance ; le cache local diverge entre instances, le cache distribué coûte un service à exploiter.
  • La panne S3 du 28 février 2017 montre le coût des dépendances non décidées, jusqu'au tableau de bord d'état qui dépendait du service en panne.

Dans la série

Palier 3 : Conduire le chantier. Domaine : Solutions.

Pour aller plus loin