J'ai conçu, construit et j'exploite seul un écosystème SaaS multi-tenant en production, avec de vrais clients payants. Cette page montre les chiffres mesurés, comment une mise en production se déroule et — ce qui compte vraiment — les incidents qui ont mis le système à terre et ce qui a changé après chacun.
Ce tableau existe pour vous éviter de lire toute la page. La colonne qui compte le plus est la dernière : mesurer est ce qui sépare exploiter de supposer. Et là où je n'ai que la formation, pas la pratique, c'est dit — cocher tout n'informe personne.
Ce n'est pas un laboratoire. Il y a des clients qui paient, et quand cela tombe quelqu'un cesse de vendre. Tout le reste de cette page existe à cause de cette phrase.
ARM64 en cloud public, 8 vCPU et 30 Go chacun, derrière un tunnel Cloudflare. Les deux servent du trafic : aucun nœud ne reste inactif à attendre la catastrophe.
Row-Level Security dans PostgreSQL, fail-closed : sans la portée du client dans la session, la requête renvoie zéro ligne. Le rôle d'exécution n'a pas BYPASSRLS.
Une couverture minimale qui fait échouer le build : 95 % sur deux services, 90,3 % sur un autre. Ce n'est pas un objectif affiché au mur — c'est une condition pour publier.
Un chiffre sans provenance est une opinion déguisée en donnée. Chaque ligne ci-dessous dit ce qui a été compté, comment, quand — et ce que la mesure ne prouve PAS. La dernière colonne est celle qui m'intéresse : une mesure honnête déclare sa propre limite.
docker ps sur les deux nœuds : 112 chacun. Sur ces 112, 59 sont des processus applicatifs (web, travailleur, publieur, consommateur, ordonnanceur) ; les 53 autres sont bus, données, observabilité, bordure et coffre
ce n'est pas une mesure de charge ni d'utilisation — c'est un comptage de processus« Montrez-moi le schéma et je me méfie. Montrez-moi ce qui se passe quand il tombe et je vous crois. »
ce que cette page tente de répondreLe dessin complet : par où le trafic entre, où chaque pièce tourne, comment on atteint la donnée — et ce qui se passe quand chaque partie tombe.
Deux vues qui précèdent le schéma de déploiement. La première répond à « qui utilise ceci et avec quel monde extérieur il parle ». La seconde répond à « quelles applications existent et comment elles se parlent » — sans aucune infrastructure, à dessein : mélanger les deux dans un seul schéma est l'erreur qui rend la plupart illisibles.
Les 224 conteneurs ne sont pas 224 copies de la même chose. Comptés par rôle, ils dessinent la plateforme — et chaque couche existe parce qu'une douleur l'a réclamée.
Aucun des deux nœuds n'expose de port public. Le trafic entre par un tunnel Cloudflare (connecteur sortant) et arrive sur Traefik, qui assure le routage par hôte, le TLS automatique et sert de point d'équilibrage. Drainer un nœud du tunnel est ce qui rend la mise en production canari possible : c'est ainsi qu'on retire un nœud de l'équilibrage sans rien interrompre.
PostgreSQL sur un hôte dédié, partagé par les verticales, avec HAProxy comme point d'accès unique et pgbouncer provisionné mais encore hors du chemin (mesuré : zéro client actif ; les applications vont directement à HAProxy) — il entrera quand le nombre de connexions l'exigera. Il y a deux instances : primaire et réplique chaude. L'isolation client repose sur Row-Level Security, pas sur des WHERE tenant_id éparpillés dans le code : le rôle d'exécution n'a pas BYPASSRLS, donc oublier la portée renvoie zéro ligne au lieu de fuiter.
Honnêteté sur la cible : il n'y a pas de bascule automatique de la base. La promotion est manuelle et documentée — et je préfère le dire plutôt que de dessiner un Patroni qui n'existe pas.
Les services ne s'appellent pas en chaîne : ils publient des faits. Le chemin est un outbox transactionnel (le fait est validé avec la donnée, ou aucun des deux) → relais → Redis Streams → inbox avec déduplication par groupe de consommation → DLQ pour ce qui a échoué. Il y a 14 instances Redis : le bus est par verticale et par nœud, et c'est précisément pourquoi la convergence se règle dans Postgres, pas dans un verrou distribué qui ne traverserait pas les nœuds.
.envLes identifiants vivent dans un coffre (OpenBao) avec un jeton de service par consommateur. Une alerte dédiée surveille le jeton du coffre qui approche de l'expiration — car un secret qui expire en silence fait tout tomber d'un coup, à la pire heure.
La règle est de publier un fait, pas d'appeler un service. Le chemin par défaut est asynchrone : outbox transactionnel (le fait n'existe que si la donnée existe), relais, file, inbox avec déduplication par groupe de consommation et DLQ pour ce qui a échoué. Un service en panne retarde donc l'autre — il ne l'entraîne pas dans sa chute.
Quand la réponse est nécessaire maintenant — « ce client a-t-il droit à cette fonctionnalité ? », « cette entreprise est-elle fille de celle-là ? » — l'appel est synchrone, mais ce n'est jamais du HTTP épars dans le code : il passe par un client typé du SDK unique, avec des opérations nommées et une authentification machine à machine par clé propre à chaque service. L'autorité sur le plan, la hiérarchie et le droit d'usage a un seul propriétaire ; les autres demandent, ils ne déduisent pas.
Quand une vente a lieu, le service de ventes n'avertit personne. Il publie le fait — et douze groupes de consommation indépendants le lisent à leur rythme. Le stock décrémente la pièce, la finance crée la créance, le fiscal prépare le document, la comptabilité enregistre. Aucun ne sait que les autres existent, et l'un d'eux en panne retarde sa propre file, pas la vente.
Par nœud : 13 travailleurs, 11 publieurs, 10 consommateurs et 10 ordonnanceurs. Chacun tourne avec une concurrence de 1, déclarée dans le fichier de composition. La valeur par défaut de l'outil est 8 — et c'est ainsi que la flotte est tombée une fois. Une limite qui n'est pas écrite n'existe pas.
Le travail d'IA, d'analyse et de routine ont leurs propres files. Un appel de modèle qui prend trente secondes ne peut pas passer devant la file qui confirme une vente. Séparer est ce qui empêche le lent de retarder l'urgent.
Le bus écrit sur disque. Si un nœud tombe avec du travail en file, ce travail ne s'évapore pas — il attend le retour du nœud et reprend. La différence entre « le client a attendu » et « la commande a disparu » tient à cette ligne de configuration.
Prometheus collectant 9 cibles (les deux nœuds, cAdvisor, l'exportateur PostgreSQL, Traefik, sondes HTTP), Grafana, Alertmanager, cAdvisor, node et postgres exporter, blackbox sondant les routes de l'extérieur. 12 règles d'alerte en service.
Ce que je considère comme la partie mature, ce n'est pas la quantité — c'est ce qu'elles surveillent. Trois d'entre elles ne demandent pas « est-ce que quelque chose a cassé ? », mais « est-ce que quelque chose a cessé de se produire ? » : sauvegarde hors site vide, sauvegarde en retard, sauvegarde sans la base à l'intérieur. Un coffre de sauvegarde qui existe mais reste vide passe pour sain dans n'importe quel contrôle de présence — et on ne le découvre que le jour de la restauration. Les autres couvrent la chute de PostgreSQL, le disque qui se remplit sur chaque nœud, un nœud hors service, un slot WAL qui s'accumule et un conteneur en boucle de redémarrage.
« Le meilleur outil est la planification. »
pourquoi chaque pièce est là où elle estChaque choix ici a un gain, un coût que je paie et une conséquence commerciale. Qui ne liste que le gain vend, il ne conçoit pas.
Tout le budget de calcul tient en deux nœuds de 8 vCPU et 30 Go, et ils font déjà tourner 224 conteneurs. Un plan de contrôle Kubernetes — serveur d'API, etcd, ordonnanceur, contrôleurs, réseau superposé, contrôleur d'entrée — consomme du CPU et de la mémoire sur ce même budget, de façon soutenue. Et la charge CPU soutenue est exactement ce qui m'a déjà coûté deux suspensions d'infrastructure.
En décision d'affaires : Kubernetes résout un problème d'échelle que je n'ai pas encore, en facturant aujourd'hui en complexité opérationnelle et en consommation de la ressource la plus rare dont je dispose. Tant que la charge tient sur deux nœuds, l'adopter reviendrait à payer d'avance une assurance contre un risque inexistant — et à en créer un nouveau, bien réel : une surface que je ne peux pas tenir seul en astreinte. Ce n'est pas rejeter l'outil ; c'est ne pas changer d'outil par mode. Le jour où le calcul changera — plus d'un opérateur, ou une charge qui ne tient plus sur deux nœuds — la migration est déjà dessinée, et elle est bon marché précisément parce que tout est déjà image de conteneur et configuration déclarative.
En décision d'affaires : un équilibreur géré coûte à l'heure et au volume, et me rend en prime une IP publique à défendre. Le tunnel ne coûte rien sur le forfait utilisé, supprime la surface exposée et apporte le filtrage applicatif avec. J'ai échangé un risque que je devrais exploiter (l'exposition) contre un risque que je surveille (la disponibilité d'un tiers) — et ce second-là a une sonde externe et une alerte.
En décision d'affaires : une bascule automatique sans couche de consensus ni isolement du nœud défaillant échange une panne rare et récupérable contre une corruption silencieuse et permanente. Pour qui conserve les données financières de tiers, cet échange est mauvais dans tous les cas. L'atténuation n'est pas d'automatiser la promotion : c'est que la réplique soit chaude et à jour, que la procédure soit écrite, et que la récupération à un instant donné existe — le tout mesuré.
En décision d'affaires : une réserve inactive est un coût qui ne sert que le pire jour — et qui échoue souvent précisément ce jour-là, parce que personne ne l'éprouve. Avec les deux actifs, le même argent achète de la capacité et de la résilience, et la preuve que cela marche, c'est le trafic d'hier. Honnêteté sur la limite : aujourd'hui les deux sont dans le même domaine de disponibilité, donc une défaillance corrélée du domaine me met encore à terre. C'est marqué dans le schéma parce que c'est la prochaine étape, pas parce que je ne le vois pas.
En décision d'affaires : la valeur de la barrière n'est pas où elle s'exécute, c'est ce qu'elle refuse. Un exécuteur hébergé lançant la suite contre une base sans isolation client donne une impression de couverture et laisse passer la classe de défaut la plus douloureuse dans un SaaS multi-tenant : la fuite entre clients. Pour compenser le coût que j'ai assumé : un test qui surveille la barrière elle-même et échoue bruyamment.
Les douze verticales n'ont pas d'adresse publique propre. Elles sont toutes montées par chemin sous une origine unique, et le serveur d'identité — OAuth2 et publication de clés — vit sur cette même origine. L'autorisation ne se limite pas à une porte en façade : elle voyage avec le service, via un SDK unique que toutes les verticales embarquent et qui refuse l'appel quand la portée est absente.
En décision d'affaires : le produit est multi-tenant et se vend à des entreprises qui doivent hériter d'une hiérarchie de clients, d'un plan souscrit et d'une séparation des tâches. Une identité externalisée facture par utilisateur actif — le coût croît avec la base au moment précis où la marge devrait s'améliorer — et pousse la règle métier hors du système qui en est propriétaire. En la gardant en interne, le coût est d'ingénierie (une fois) plutôt que d'abonnement (toujours), et la règle du qui-voit-quoi reste là où elle est vraie. Le coût est atténué côté exploitation : une alerte dédiée surveille clé et jeton qui expirent, et la barrière de publication vérifie que l'émetteur de clés répond avant de rendre le nœud au trafic.
Java avec Spring Boot pour l'identité, la comptabilité et le moteur de flux. Go pour les nouveaux services — un binaire unique, sans runtime, démarre vite et occupe peu de mémoire, la ressource rare ici. Rust à deux endroits précis : l'agent qui observe le cœur du système d'exploitation et le moteur de calcul fiscal. Python avec Django dans les verticales métier, où la vitesse de changement vaut plus que les microsecondes.
En décision d'affaires : réécrire ce qui fonctionne est la façon la plus coûteuse de ne rien livrer. L'existant productif continue de rapporter en Python pendant que ce qui naît naît en Go, et le contrat stable entre eux permet de changer pièce par pièce sans arrêter l'exploitation — sans le risque de la réécriture totale, qui historiquement fait tomber des entreprises.
Acquéreur de carte, bureau de crédit, API de gouvernement, open banking, signature électronique, marketplace, messagerie — tout entre et sort par un seul service. Il traduit chaque fournisseur en une capacité normalisée et renvoie le résultat à l'écosystème sous forme de fait sur le bus. Aucune verticale métier ne connaît le nom du fournisseur ; la règle métier reste chez le consommateur, pas dans l'adaptateur.
En décision d'affaires : l'intégration avec des tiers est l'endroit où vit le risque contractuel — identifiant fuité, facturation indue, donnée client qui sort. Concentrer signifie qu'auditer « qui parle au monde extérieur, et avec quel identifiant ? » revient à regarder un service, pas douze. Et c'est vérifiable, pas une promesse : j'ai mesuré les six verticales métier et aucune n'a de SDK tiers installé — ils n'existent que dans le service d'intégration. Une règle qui dépend de la discipline humaine échoue en silence ; celle-ci se mesure.
Exploiter seul avec de vrais clients impose une contrainte qu'une grande entreprise n'a pas : chaque service géré que je souscris est de l'argent tous les mois et une pièce que je ne contrôle pas. C'est pourquoi la conception évite les trois sources classiques d'une facture qui grossit sans prévenir : le trafic sortant facturé, un équilibreur facturé à l'heure et une identité facturée par utilisateur actif.
En décision d'affaires : dans un SaaS, le coût d'infrastructure est ce qui sépare la marge de la perte quand la base grandit. La question que je pose avant d'adopter un service est « ce coût croît-il avec le nombre de clients ? » — s'il croît linéairement avec le revenu, il mange l'économie d'échelle que le modèle devrait apporter. Le dimensionnement, les limites de CPU et de concurrence déclarées et le registre de publication existent autant pour le coût que pour la fiabilité : une ressource que personne ne mesure est une ressource que personne ne coupe.
Un plan de reprise jamais chronométré est une intention, pas un plan. Ces trois chiffres ont été réellement mesurés, non estimés.
« Rien n'est publié à la main — et rien ne compile sur le serveur de production. »
une règle née de deux suspensionsPublier est le moment où un système stable devient dangereux. Le chemin ci-dessous existe pour que changer cesse d'être un acte de courage.
Rien n'est publié à la main et rien ne compile sur le serveur de production. La contrainte est née d'une vraie douleur : l'infrastructure précédente a été suspendue deux fois pour charge CPU soutenue — compiler sur l'hôte était l'un des déclencheurs. L'architecture a changé à cause de cela.
L'image est construite sur le poste/CI et publiée dans le registre avec le tag du commit. Le serveur de production ne fait que pull — il ne compile jamais.
Le connecteur du tunnel est arrêté sur le nœud A. 100 % du trafic va vers le nœud B. La mise en production se fait sur un nœud qu'aucun utilisateur n'utilise.
Pas de crashloop, l'émetteur de clés répond, et zéro migration en attente. Ce dernier contrôle est né d'un incident : un processus debout ne signifie pas que la base a suivi.
Il revient au digest précédent sur le nœud même et s'interrompt — le nœud stable n'a jamais été touché. Et le connecteur du tunnel est restauré avec vérification : en cas d'échec, il crie sur stderr avec la commande de réparation manuelle.
Le nœud validé revient dans le tunnel et seulement ensuite le second nœud reçoit la même image, un service à la fois. Un verrou global sérialise toute la flotte : deux déploiements simultanés ont déjà mis l'infrastructure à terre.
Chaque service recréé est inscrit dans un registre sur le nœud avec la date, le digest de l'image et le commit. C'est ainsi qu'on répond à « qu'est-ce qui tourne exactement ? » sans deviner.
La chaîne est maison : il n'y a pas de runner hébergé qui vérifie la pull request. GitHub stocke le code et sert de registre d'images ; la barrière qualité tourne avant, sur le poste. Cela a une conséquence inconfortable que je préfère dire tout haut : il n'existe aucun filet de sécurité distant — si je ne lance pas la barrière, personne ne la lance.
Formatage et lint avec cliquet (le plancher ne fait que monter), contrôle d'intégrité du projet et la suite tournant sur PostgreSQL avec RLS armé — pas sur une base jouet. Une couverture sous le plancher fait échouer. Sur le service en Java, la même barrière ajoute tests d'architecture, intégration avec de vrais conteneurs et analyse statique.
Le travail se fait sur une branche, la publication passe par une pull request, et la ligne principale est protégée. push --force, reset --hard et la suppression de la principale sont bloqués par politique. Avant un gros remaniement, un repère de retour.
La cible de la barrière appelait l'interpréteur du système et mourait juste après que le lint ait affiché « tout va bien ». Qui lisait l'écran voyait du vert sur une barrière qui n'avait exécuté aucun test. Aujourd'hui un test surveille la barrière elle-même — et il échoue bruyamment.
« Un système qui n'a jamais cassé est un système que personne n'utilise. »
trois incidents réels, et la cicatrice de chacunLa partie que l'on ne publie normalement pas. Cause, ce qui a échoué dans la détection, ce que j'ai aggravé en essayant de réparer, et le garde-fou qui empêche la récidive.
Un système qui n'a jamais cassé est un système que personne n'utilise. Ce qui fait la différence n'est pas l'absence d'incident — c'est ce qui reste après. Ces trois-là sont réels, du même jour, et chacun a laissé un garde-fou dans le code.
Le garde-fou est né avec le vice même qu'il allait chasser : il ne comptait que les migrations en attente, et grep -c sur une sortie vide affiche zéro — identique à « aucune en attente ». Aujourd'hui il compte aussi le total : une liste vide n'est pas un schéma à jour, c'est un manque de preuve.
except large englobant le client entier : chaque écart était détecté puis jeté. Une transaction fantôme chez l'acquéreur, ou un paiement sans confirmation — de l'argent — devenait une ligne de journal.L'absence de mesure n'est pas l'absence du fait. Et une alerte qui crie toujours aveugle le gardien — ç'aurait été pire que le défaut d'origine.
En mesurant une intégration, ne vous arrêtez pas à « l'événement est publié ». Demandez qui écoute — et si ce qu'il transporte signifie quelque chose de l'autre côté.
Chaque incident ci-dessus est devenu un test qui fait échouer le build si la classe de défaut revient. Une convention écrite dans un document vieillit ; un garde-fou exécutable, non. Et chaque garde-fou est testé avec un mutant : si je réintroduis le défaut et qu'il reste vert, il ne sert à rien.
« Cet entrepôt compte-t-il comme stock vendable ? » était écrit, littéralement, en cinq endroits. Avec un second type d'entrepôt, chaque copie oubliée devient un trou silencieux. La règle a désormais un propriétaire unique — et un test refuse quiconque la réécrit à la main.
Un balayage par AST : tout type d'événement publié dans le code doit être déclaré. Il a trouvé deux cas vivants — l'un avalait les écarts de carte.
Un garde-fou structurel, pas comportemental : en test les deux rôles de base fusionnent, donc le défaut n'existe qu'en production. Un test comportemental resterait vert avec le système à terre.
Deux liens absolus pointant vers la machine de celui qui les a créés se sont retrouvés dans l'image de production — et ils étaient autoréférents : récursion infinie pour tout balayage qui les suit.
Le SRE, ici, n'est pas un titre : c'est un ensemble de règles qui m'ont déjà coûté cher à apprendre et qui sont aujourd'hui écrites dans des scripts, des tests et des politiques. En voici six :
Le récit du premier incident de cette page comporte un champ intitulé « J'ai aggravé » : ma tentative de réparation manuelle a empilé trois sessions bloquées avant que je comprenne le problème. Un post-mortem qui omet l'erreur de son auteur n'aide personne à apprendre — il protège l'auteur.
Face à une production à terre, la marche à suivre est de revenir au digest précédent et seulement ensuite d'enquêter. Le retour arrière est automatique et par digest — pas par « reconstruire l'image d'avant », ce qui n'est pas la même chose et m'a déjà trompé.
Une alerte déclenchée sur un total cumulé reste allumée pour toujours et finit ignorée — la nouvelle panne arrive donc sans que personne ne la voie. Une alerte doit mesurer le changement, et se taire quand le changement s'arrête.
L'infrastructure précédente a été suspendue deux fois pour charge CPU soutenue, et compiler sur le serveur était l'un des déclencheurs ; une troisième suspension aurait été définitive. Aujourd'hui « la production ne compile jamais » n'est pas une préférence de style — c'est une règle avec une revue qui bloque la pull request.
Deux déploiements simultanés ont déjà mis la flotte à terre. Il existe un verrou global : un service à la fois, un nœud à la fois, et un verrou orphelin est récupéré au lieu de bloquer la flotte pour toujours.
Chaque recréation de service est inscrite dans un registre sur le nœud : date, digest et commit. La question « qu'est-ce qui tourne exactement ? » se répond par une requête, pas par la mémoire de celui qui a déployé. C'est de là que vient la moyenne mesurée de 92 mises en production par jour.
J'ai déjà signalé « c'est une question de configuration de l'opérateur » à propos d'un champ impossible à configurer — le type ne pouvait pas contenir l'identifiant de l'autre service. Zéro enregistrement rempli sur douze dans toute la flotte, ce n'était pas de la négligence : c'était un défaut. Aujourd'hui je mesure avant de conclure.
Une validation qui n'a pas pu mesurer ne peut pas annoncer « tout va bien ». Cette confusion est apparue trois fois : dans le déploiement, dans la réconciliation des cartes et dans un garde-fou de mon cru — toujours avec l'air d'être au vert.
Une suite avec des milliers de tests verts cohabitait avec l'écran principal cassé. Un test avec une doublure ne mesure pas la règle qui n'existe qu'à la vraie frontière — et la vérification visuelle attrape ce qu'aucun test n'attrape.
« Ce que je n'ai pas encore testé vaut autant que ce que j'ai testé — à condition que je dise lequel est lequel. »
l'état honnête de ce qui manqueCe qui est fait, ce qui est partiel et ce qui n'existe pas encore. Séparer les trois est ce qui rend les deux premières colonnes crédibles.
Je peux démontrer une mise en production canari de bout en bout — y compris le retour arrière automatique quand la validation échoue.