Penser en systèmes, pas en code
À l'heure où des machines écrivent le code à notre place, comprendre <em>ce qu'on construit</em> compte plus que jamais. On va le faire en bâtissant un clone de Google Drive, brique par brique.
L'ingénierie logicielle change à toute vitesse : des machines écrivent désormais du code pour nous, et ça marche étonnamment bien. Mais à mesure que la production accélère, les pannes en cascade sur les grandes plateformes se multiplient. C'est le bon moment pour ralentir et se reposer la question la plus importante : qu'est-ce qu'on construit, au juste ? Pas comment — quoi, et pourquoi.
L'ensemble des décisions structurantes qui rendent un système plus — ou moins — facile à faire évoluer. Une bonne architecture n'est pas une fin en soi : elle existe pour que le logiciel reste rapide et bon marché à étendre demain. Une mauvaise rend chaque nouvelle fonctionnalité plus lente et plus coûteuse à livrer.
Architecturer, ce n'est pas empiler des technologies à la mode. C'est penser en systèmes : voir les pièces, leurs responsabilités, et ce qui se passe quand l'une tombe ou quand la charge double. Pour rendre ça concret, on va construire ensemble un clone de Google Drive — uploader et télécharger des fichiers — en partant du plus simple possible, puis en le faisant grandir, décision après décision.
La règle du jeu : chaque étape ne s'ajoute que si elle résout un vrai problème de l'étape précédente. Pas de sur-ingénierie, pas de « au cas où ». On verra à chaque fois le pour et le contre, parce que la bonne architecture dépend toujours de l'endroit où en est ton projet.
Découpler le serveur de ses données
Tout commence par un serveur qui contient tout. Ça marche — jusqu'à la première panne, et jusqu'au jour où tu veux un deuxième serveur. Deux questions qui changent tout.
Au commencement, il y a un serveur. Tes utilisateurs lui envoient des requêtes, il leur répond, et toutes les données vivent à l'intérieur de lui — la liste des fichiers, leur emplacement, tout. C'est ainsi que démarrent la plupart des programmes, et honnêtement, ça fonctionne très bien… un temps.
Puis deux questions arrivent, et elles vont tout déterminer. Que se passe-t-il si le serveur meurt ? Comme les données sont dans le serveur, elles meurent avec lui. Et si tu veux un deuxième serveur ? Chacun aura sa propre copie des données, qui divergeront dès la première écriture. Un utilisateur écrit sur la machine A ; la machine B n'en sait rien.
La solution, tu la connais sans doute : ajouter une base de données. Mais l'important n'est pas « ajouter une base » — c'est découpler. Le serveur devient sans état (stateless) : il ne retient rien entre deux requêtes, il va chercher et ranger les données dans une base partagée. N'importe quelle instance peut alors servir n'importe quel utilisateur, puisque la vérité est ailleurs.
Le serveur se préoccupe de servir l'utilisateur ; la base se préoccupe de stocker les données. Si un serveur disparaît, la base s'en moque — elle ne le connaît même pas. C'est le tout premier principe d'architecture, et on le retrouvera à chaque étage, à des échelles de plus en plus grandes.
Ce stockage relationnel — tables utilisateurs, tables fichiers, relations entre elles — a ses propres profondeurs (transactions, isolation, index). Si tu veux creuser ce qui se passe sous le SELECT, le cours Transactions SQL & Isolation et Index B-tree & le problème N+1 sont là pour ça.
Le simulateur ci-dessous rejoue le problème. Écris sur le serveur 1, puis lis depuis le serveur 2 — d'abord avec les données couplées (désynchro), puis avec une base découplée (cohérent).
Bascule le mode, écris sur un serveur, lis depuis l'autre :
Encaisser la charge : plus de serveurs
L'appli cartonne, mais elle rame. La réponse tient en un mot — scaler — et en une nouvelle pièce posée devant tes serveurs : le répartiteur de charge.
L'application grimpe dans le store, le trafic afflue — et les retours tombent : « c'est lent ». Un seul serveur n'encaisse plus toutes les requêtes. Heureusement, on l'a rendu sans état à l'étape précédente : on peut donc en ajouter un deuxième sans rien casser. Mais dès qu'il y a plus d'un serveur, une question surgit : qui décide où va chaque requête ?
Sans arbitre, tous les utilisateurs pourraient se ruer sur la même machine pendant que l'autre se tourne les pouces. Il faut une pièce au milieu qui distribue le trafic vers une machine en bonne santé : c'est le répartiteur de charge (load balancer).
Round robin : on alterne séquentiellement, chaque serveur reçoit son tour. Least connections : on envoie vers le serveur qui a le moins de connexions actives. Sticky sessions : un même utilisateur revient toujours sur le même serveur (utile s'il y a un état local à conserver). Un bon load balancer ajoute des health checks pour éviter les machines malades, et peut même router selon le chemin de l'URL (
POST /upload → serveur de fichiers).
Le round robin est déjà très correct, mais il a une faille : tous les tours ne se valent pas. Si l'utilisateur A uploade un film de 20 Go pendant que l'utilisateur B télécharge une vignette, ce n'est pas la même charge du tout. Un load balancer plus malin, guidé par les health checks, enverra le gros travail là où il y a de la marge.
Deux façons de grandir
| Scaling horizontal | Scaling vertical | |
|---|---|---|
| Principe | Ajouter des machines | Grossir la machine (plus de RAM/CPU) |
| Limite | Coordination, état partagé | Plafond physique d'une seule machine |
| Résilience | Une tombe, les autres tiennent | Un seul point de défaillance |
En vrai, on combine les deux : des machines costaudes et plusieurs d'entre elles. Et comme chaque machine coûte de l'argent, on vise le minimum suffisant : l'auto-scaling démarre à deux instances, puis en provisionne davantage (jusqu'à dix, par exemple) quand le trafic l'exige, et les retire quand il retombe.
L'état : le vrai défi du scaling horizontal
Souviens-toi du principe de la section précédente : des données couplées à la machine empêchent de scaler. Dès qu'il y a plusieurs serveurs, ce principe se généralise à tout état local. Deux cas reviennent sans cesse, et il faut les traiter explicitement : les sessions et les fichiers.
Les sessions
À la connexion, on stocke souvent la session de l'utilisateur — état de connexion, panier, préférences — en mémoire du serveur. Avec une seule machine, impeccable. Mais derrière un load balancer, sa requête suivante peut tomber sur un autre serveur, qui n'a jamais entendu parler de lui : la session est perdue, l'utilisateur est déconnecté sans raison apparente.
| Solution | Principe | Limite |
|---|---|---|
| Sticky sessions | Le load balancer renvoie toujours l'utilisateur sur le même serveur | Contournement : si ce serveur tombe, la session disparaît ; la charge se répartit mal |
| Session store partagé | Les sessions sortent de la mémoire du serveur vers un magasin commun (Memcached, Redis) | La vraie solution : les serveurs redeviennent réellement sans état |
La deuxième approche est la bonne par défaut. En rangeant les sessions dans un magasin partagé, tous les serveurs lisent et écrivent au même endroit : n'importe lequel peut servir n'importe quel utilisateur, et la mort d'une machine ne perd aucune session. Les sticky sessions ne sont qu'un palliatif, utile quand on ne peut pas (encore) externaliser.
Memcached est un cache clé-valeur en RAM, pur et simple — parfait pour des sessions volatiles. Redis fait la même chose, mais ajoute des structures de données riches et une persistance optionnelle : les sessions peuvent alors survivre à un redémarrage du magasin. Ce sont les deux choix classiques pour externaliser les sessions.
Redis / Memcached
Les fichiers, et tout disque local
Même piège avec les fichiers. Si un serveur écrit un fichier sur son disque local, les autres serveurs ne le voient pas — c'est exactement la désynchronisation de la section précédente, version système de fichiers. Là encore, deux familles de réponses :
| Solution | Idée | Quand |
|---|---|---|
| Stockage réseau partagé (NFS…) | Un volume monté sur tous les serveurs, qui voient le même système de fichiers | Pratique pour du legacy ; reste un point central à gérer et à scaler |
| Object storage (S3, GCS) | Un service dédié aux fichiers statiques, pensé pour l'échelle | Le choix moderne — on y vient en détail à la section Object storage |
Le simulateur ci-dessous distribue des requêtes selon la stratégie que tu choisis. Change d'algorithme et envoie du trafic : observe comment la répartition change.
Choisis une stratégie et envoie des requêtes :
Scaler la base de données
Tu as multiplié les serveurs sans état — mais ils tapent tous sur une seule base. Tôt ou tard, c'est elle le goulot d'étranglement. Or on ne « scale » pas une base comme un serveur.
Voilà le paradoxe que la section précédente a laissé en suspens. Rendre les serveurs sans état permet d'en ajouter autant qu'on veut — mais ils lisent et écrivent tous dans la même base de données. Plus on ajoute de serveurs, plus on tape fort sur cette base unique. Elle devient le vrai goulot d'étranglement, et on ne peut pas la dupliquer naïvement : les données doivent rester cohérentes. Deux stratégies, complémentaires.
Réplicas de lecture : encaisser les lectures
Dans la plupart des applications, on lit bien plus qu'on n'écrit. L'idée : une base primaire reçoit toutes les écritures, et plusieurs réplicas en reçoivent une copie pour servir les lectures. La primaire diffuse ses changements aux réplicas en continu.
Une copie de la base primaire, alimentée par réplication. Les écritures vont à la primaire ; les lectures se répartissent sur les réplicas. Comme les lectures dominent, on absorbe ainsi une charge énorme en ajoutant simplement des réplicas.
Sharding : encaisser les écritures
Les réplicas ne soulagent que les lectures — toutes les écritures restent sur une seule primaire. Quand elle sature, il faut découper les données.
On répartit les données sur plusieurs bases selon une clé de sharding (par exemple
user_id % N, ou par région géographique). Chaque shard ne détient qu'une tranche des données et de la charge d'écriture. C'est l'équivalent, au niveau de la base, du scaling horizontal des serveurs.
| Réplicas de lecture | Sharding | |
|---|---|---|
| Scale… | les lectures | les écritures (et le volume) |
| Coût | Retard de réplication | Requêtes inter-shards difficiles, rééquilibrage délicat |
| Quand | Presque toujours utile | En dernier recours, quand une primaire ne suffit plus |
La cohérence, l'isolation et les transactions sous-jacentes sont l'objet du cours Transactions SQL & Isolation ; l'efficacité des requêtes et des index, celui d'Index B-tree & le problème N+1.
Le simulateur ci-dessous illustre la séparation lecture/écriture et son piège. Écris une valeur, puis lis aussitôt depuis un réplica : observe le retard de réplication.
Écris, puis lis depuis un réplica avant qu'il ait rattrapé :
Découper en services spécialisés
Tu embauches, certains endpoints rament. On ouvre alors la porte d'un monde puissant mais dangereux : des services spécialisés, et un portier unique pour les orchestrer.
L'application est rentable, les clients sont contents, et tu embauches. Mais certains endpoints rament — les uploads, notamment, ralentissent tout le monde parce que chaque serveur fait tout. C'est le moment d'ouvrir une porte puissante, et franchement dangereuse : les microservices.
Un service spécialisé qui sait faire une chose, et la fait très bien. Plutôt qu'un gros serveur qui gère fichiers, notifications, authentification et temps réel, on découpe en services indépendants — que des équipes distinctes peuvent développer et déployer chacune de leur côté.
Pour notre Google Drive, le découpage par domaine tombe sous le sens : un service Fichiers, un service Notifications, un service Auth, un service Temps réel (la synchro vers tes autres appareils). C'est la même séparation des responsabilités que serveur/base, mais à plus grande échelle.
Mais une fois les services séparés, comment l'extérieur leur parle-t-il ? Le load balancer seul ne suffit plus : il faut une pièce qui reçoit chaque requête, l'analyse et la route vers le bon service. C'est le API Gateway.
Le portier unique du système. Il route chaque requête vers le service concerné (
/api/auth/login → service Auth), peut agréger plusieurs réponses en une seule, et porte l'authentification. Surtout, il devient le seul point d'entrée : tous les services vivent dans un réseau privé (VPC), invisibles depuis l'extérieur.
Ce réseau privé répond à une vraie menace : sans lui, rien n'empêcherait un client d'appeler directement le service de fichiers en contournant l'authentification. En n'exposant que le gateway, tu reprends le contrôle de la porte. (Le gateway peut lui-même devenir un point de défaillance unique : on le scale donc lui aussi, derrière un load balancer.)
Le simulateur ci-dessous est ce gateway. Appelle un endpoint pour voir le routage — puis tente d'atteindre un service en direct.
Appelle un endpoint, ou tente un accès direct :
Qui es-tu, et qu'as-tu le droit de faire ?
Dès qu'on expose des services, une question s'impose : qui appelle, et de quel droit ? Le jeton vérifié à l'entrée évite un aller-retour réseau à chaque requête.
Dès qu'il y a un gateway et des services, l'authentification devient incontournable. Deux notions à ne pas confondre, d'abord :
L'authentification répond à « qui es-tu ? » (as-tu accès à l'application ?). L'autorisation répond à « qu'as-tu le droit de faire ? » (peux-tu uploader, supprimer ce fichier ?). Liées, mais distinctes : on peut authentifier au gateway, et autoriser plus finement dans chaque service.
Pour l'authentification, le schéma classique repose sur un jeton JWT : une chaîne signée qui prouve ton identité et porte une date d'expiration. À la connexion, l'utilisateur envoie ses identifiants ; le service Auth les vérifie, signe un jeton avec une clé privée, et le lui renvoie. Ensuite, chaque requête transporte ce jeton dans l'en-tête Authorization.
401 Unauthorized immédiatement, et le service métier n'est jamais sollicité. Une vérification au bord, c'est du réseau et du calcul économisés.
La génération du jeton, elle, passe bien par le service Auth (et souvent un service Utilisateurs pour vérifier que le compte existe). La distinction est nette : générer un jeton demande une clé privée et une vérification d'identité ; valider un jeton ne demande que la signature — c'est pour ça qu'on peut le faire à la volée, au bord.
Ces mécanismes — jetons, signatures, sessions, contrôle d'accès — ont leur cours dédié côté Symfony : Symfony Security avancé. Et comme tout ça ne vaut rien si le transport n'est pas chiffré, vois aussi TLS/HTTPS.
Le simulateur ci-dessous est la vérification au bord. Choisis l'état du jeton, puis envoie une requête.
Règle l'état du jeton, puis appelle un endpoint protégé :
Uploader des fichiers sans saturer tes serveurs
Notre métier, c'est le fichier. Et la pire idée serait de le faire transiter par ton serveur ou ta base. La bonne consiste à laisser le client parler directement au stockage.
Notre métier, c'est le fichier — et pourtant on a soigneusement évité jusqu'ici de dire comment on l'uploade. C'est qu'il y a un piège. La pire idée serait d'envoyer le fichier directement à ton serveur, qui le pousserait dans la base relationnelle. Un film de 20 Go dans une base SQL ? Elle n'est pas faite pour ça. Et faire transiter tout ça par ton serveur, c'est l'exposer aux timeouts et aux attaques par épuisement.
Un stockage dédié aux fichiers statiques (images, vidéos, archives) — un bucket S3, Google Cloud Storage… Il est conçu pour encaisser d'énormes volumes, là où une base relationnelle gère des lignes structurées. La règle d'or : les métadonnées en base, les octets dans le bucket.
Le flux malin tient en quelques temps. Le client annonce au gateway « je veux déposer ce fichier » avec ses métadonnées (nom, taille, type). Le service Fichiers crée une ligne en base (un id, le nom, la taille — pas les octets), puis demande au bucket une URL pré-signée : un lien temporaire et restreint qui autorise un upload direct.
Ce motif — déléguer le gros transfert au stockage, ne garder que les métadonnées — est au cœur des architectures serverless. On le retrouve dans les cours Raccourcisseur d'URL serverless et Chat temps réel serverless, où DynamoDB et les services managés AWS jouent ces rôles.
Le simulateur ci-dessous compare les deux chemins. Choisis une taille de fichier, et vois ce qui arrive selon que tu passes par le serveur ou par l'URL pré-signée.
Choisis une taille, puis compare les deux chemins d'upload :
Découpler les services par messages
Un fichier arrive : il faut une miniature, une notification, une synchro. Les appeler en direct, c'est tout faire tomber dès qu'un service flanche. Un intermédiaire change la donne.
Le fichier est dans le bucket. Mais l'upload doit déclencher des effets : générer une vignette, prévenir le service temps réel pour synchroniser tes autres appareils, peut-être envoyer une notification push. Comment ?
La tentation est d'appeler ces services directement, l'un après l'autre, dès l'upload terminé. C'est précisément le piège. Si le service de vignettes est lent, la requête expire. S'il est en panne, ton fichier reste sans vignette — et personne ne sait pourquoi. Et faire connaître tous tes services à la source de l'événement ne passe pas à l'échelle.
Un intermédiaire au milieu — Kafka, RabbitMQ, un système pub/sub. La source publie un seul événement (« fichier uploadé ») ; le broker se charge de le distribuer aux services intéressés. La source ne connaît que le broker, pas les consommateurs.
Ce découplage débloque trois propriétés décisives :
| Propriété | Ce que ça apporte |
|---|---|
| Durabilité | Le message survit même si le broker redémarre |
| Redélivraison | Pas d'accusé de réception ? On réessaie après un délai |
| Fan-out | Un événement, copié vers plusieurs consommateurs |
C'est, à nouveau, de la séparation des responsabilités — à l'échelle du système entier. Tout ce mécanisme (files, accusés, redélivraison, DLQ, ordre, idempotence) a ses cours dédiés : Files de messages pour les concepts, et Symfony Messenger avancé pour la mise en œuvre.
Le simulateur ci-dessous est ce broker. Mets un consommateur en panne, publie un événement, et observe la redélivraison puis la file de lettres mortes.
Coupe un consommateur, puis publie un événement :
Arrêter de recalculer la même chose
Cinq cents personnes ouvrent le même fichier chaque jour. Refaire tout le trajet à chaque fois, c'est gâcher des ressources. Le cache et le CDN rapprochent la donnée de l'utilisateur.
L'application grimpe à 10 000 utilisateurs actifs : le signe que ça marche, et le début d'une nouvelle classe de problèmes. On entre dans le territoire de l'optimisation — et un avertissement d'emblée : on le garde pour la fin justement parce qu'il ne faut pas optimiser prématurément. On optimise quand la douleur est réelle, mesurée.
La douleur, ici : 500 personnes ouvrent le même fichier chaque jour. À chaque fois, le même trajet complet — gateway, service fichiers, base relationnelle pour les métadonnées, object storage pour les octets, et retour. Du calcul gâché à refaire à l'identique.
Un stockage clé-valeur en RAM : l'accès mémoire est bien plus rapide que le disque. On y range les données petites et souvent lues — typiquement les métadonnées du fichier, qui ne changent presque jamais. La RAM est chère et rare : on n'y met pas les gros fichiers eux-mêmes.
Le motif à connaître par cœur, c'est le cache-aside :
1. La clé est-elle dans le cache ? Si oui → on retourne directement (hit).
2. Sinon (miss) → on va chercher en base, on écrit le résultat dans le cache, puis on retourne.
La requête suivante sur la même clé sera servie depuis la RAM.
Reste les octets du fichier — trop gros pour la RAM. Pour eux, on déporte le cache à la périphérie du réseau : le CDN.
Des centaines de petits points de présence répartis dans le monde, qui mettent en cache les fichiers statiques près des utilisateurs. Le premier visiteur de Lisbonne déclenche le trajet complet ; les 499 suivants reçoivent le fichier depuis le point de présence local — 20 ms au lieu d'une seconde. Plus besoin de traverser tout le système.
Cache et CDN ont chacun leur cours : Cache & éviction pour les stratégies (quoi garder, quoi jeter), Redis pour l'outil, et Reverse Proxy, CDN & Cache pour la mise en cache au bord.
Le simulateur ci-dessous applique le cache-aside. Demande un fichier plusieurs fois — le premier accès est un miss (lent), les suivants des hits (rapides).
Demande le même fichier plusieurs fois, puis vide le cache :
Protéger le système des abus
Tu passes à l'échelle, et avec elle viennent les abus. Un compteur par utilisateur, rangé dans le cache, suffit à fermer la porte avant que l'infrastructure ne souffre.
Dernière brique du voyage, et pas la moindre. Dès qu'un système passe à l'échelle, il faut le protéger des abus : sans garde-fou, un utilisateur malveillant peut épuiser tes ressources, gonfler ta facture et dégrader l'expérience de tout le monde — juste pour nuire.
La bonne nouvelle : on a déjà toutes les pièces. Le rate limiting s'appuie sur le cache mis en place à l'étape précédente.
Une couche — sur le gateway, ou un service dédié — qui identifie chaque appelant (par son IP ou son jeton) et compte ses requêtes dans le cache. Comme le cache est en RAM, ce comptage est quasi gratuit. Au-delà d'un seuil (« 10 requêtes par minute »), l'appelant reçoit un
429 Too Many Requests.
Il existe plusieurs algorithmes (fenêtre fixe, fenêtre glissante, token bucket…), chacun avec ses compromis sur les rafales. Le cours Rate Limiting les compare en détail.
Le simulateur ci-dessous compte tes requêtes dans une fenêtre. Clique vite pour franchir le seuil et déclencher le 429.
Envoie des requêtes — dépasse 10 dans la fenêtre :
Le voyage, et le mot « ça dépend »
D'un serveur unique à un système distribué, chaque étape était une décision avec ses compromis. Récapitulons — et rappelons quand il ne faut surtout pas en faire autant.
Regarde le chemin parcouru. On est parti d'un serveur unique avec ses données dedans, et on a abouti à un système distribué — sans jamais ajouter une brique « pour faire bien ». Chaque étape répondait à une douleur précise :
| Douleur | Brique introduite |
|---|---|
| Le serveur tombe, les données meurent ; impossible de dupliquer | Base découplée, serveur sans état |
| Un seul serveur ne suffit plus | Scaling + load balancer |
| Les uploads ralentissent tout, les équipes se marchent dessus | Microservices + API gateway |
| Qui appelle, et de quel droit ? | Authentification (JWT) au bord |
| Les gros fichiers saturent serveurs et base | Object storage + URL pré-signée |
| Les effets en chaîne tombent si un service flanche | Broker événementiel (+ DLQ) |
| On recalcule sans cesse la même chose | Cache (cache-aside) + CDN |
| Les abus épuisent les ressources | Rate limiting |
Architecturer, c'est penser en systèmes : identifier la douleur réelle, peser le pour et le contre de chaque réponse, et n'ajouter une brique que lorsqu'elle est justifiée. Ce n'est pas qu'une affaire d'infrastructure — le cache touchait aux algorithmes, l'object storage à des décisions produit, le découpage au domaine métier. Comprendre le problème vient toujours avant la solution.
Chacune de ces briques est un monde en soi, et le wiki a un cours dédié pour la plupart — scaling et résilience des données (Transactions SQL), files et événements (Files de messages, Messenger), cache et diffusion (Cache & éviction, Redis, Reverse Proxy & CDN), sécurité (Security, TLS/HTTPS), limites (Rate Limiting). Cette section était la carte ; à toi d'explorer les territoires.