recherche

Git à toutes les échelles

VM
Vicent Martí30 min de lecture

Héberger des dépôts Git à grande échelle est un cauchemar. Lorsque Linus Torvalds a conçu la première version du gestionnaire d’informations infernal (c’est en réalité le slogan de Git, allez vérifier), il avait un cas d’usage très précis en tête : le sien. Il voulait remplacer BitKeeper, le système de contrôle de version distribué utilisé pour développer le noyau Linux. Bien sûr, son remplaçant devait lui aussi être distribué. Le noyau est un projet logiciel atypique : il est extrêmement décentralisé, avec de nombreux responsables de maintenance pour ses nombreux sous-systèmes. Un système de contrôle de version distribué est naturellement adapté à ce workflow.

Vingt ans plus tard, Git est devenu une norme du secteur, mais en réalité, sa nature distribuée est davantage un handicap qu’un avantage. Un projet open source typique ne repose pas sur un workflow décentralisé. Encore moins une entreprise typique. Ils tirent parti des nombreux avantages du modèle distribué (comme la possibilité de travailler hors ligne, de différer les pushes, etc.), mais dépendent fortement d’un hébergeur centralisé. Or, héberger un dépôt Git s’avère incroyablement difficile.

Qu’est-ce qui est difficile avec Git ?

La difficulté d’héberger des dépôts Git à grande échelle est inhérente à la conception même de Git : un système de contrôle de version distribué signifie que toutes les instances d’un dépôt sont identiques. Rien ne distingue le dépôt hébergé sur un serveur Git de celui présent sur l’ordinateur portable d’un développeur. Si cela peut sembler simplifier l’hébergement de dépôts Git à première vue (il suffit de placer un serveur HTTP devant une copie d’un dépôt sur disque et vous disposez d’un serveur Git opérationnel !), les défis de scalabilité et de fiabilité sont en réalité nombreux et complexes.

Dans un dépôt Git classique, votre code et vos métadonnées (fichiers, commits, arbres) sont compressés et stockés dans des packfiles — un format de sérialisation binaire simple, pratique à manipuler sur une machine locale, mais peu adapté à une gestion à grande échelle sur un serveur. Les packfiles sont les éléments fondamentaux du stockage Git et des échanges réseau de Git. Lorsque vous envoyez ou récupérez des données depuis un dépôt, elles sont transférées sous forme de packfile.

Git fonctionne ainsi par conception, mais on pourrait légitimement penser qu’il n’est pas nécessaire qu’il en soit ainsi. Après tout, vous ne contrôlez pas le client Git (du moins pas sans agacer vos utilisateurs et créer beaucoup de friction), mais sur votre propre serveur, vous pouvez faire tout ce que vous voulez. Rien ne vous oblige à utiliser des packfiles — Linus ne viendra pas vérifier. La seule contrainte est que vous devez recevoir et envoyer des packfiles sur le réseau pour toutes les opérations Git.

Au fil des ans, les entreprises qui ont tenté d’héberger des dépôts Git à grande échelle ont constaté que cette conception basée sur les packfiles constituait une limitation majeure en matière de disponibilité comme de scalabilité. Les packfiles sont de gros fichiers binaires qui doivent être présents sur un système de fichiers pour que Git puisse y accéder. La solution simple consistant à placer un serveur HTTP devant un dépôt sur disque atteint vite ses limites. Idéalement, vous voudriez que le dépôt soit présent sur de nombreux disques et sur de nombreuses machines (ce qui permet d’exécuter de nombreuses opérations Git en parallèle et de maintenir votre dépôt disponible lorsqu’un serveur plante). Mais comment y parvenir ?

Il existe globalement trois approches possibles, par ordre croissant de complexité : distribuer le système de fichiers, distribuer les packfiles ou distribuer Git lui-même.

Git sans packfiles

Git est un magasin de données adressable par contenu. Tous les objets d’un dépôt Git (blobs, arbres, commits, etc.) sont identifiés par le SHA-1 de leur contenu. Ce modèle se prête intuitivement très bien à un magasin clé-valeur distribué (la clé est le SHA-1 ; la valeur est l’objet lui-même) et pourrait permettre de faire évoluer proprement le stockage d’un dépôt. Pourtant, cela ne fonctionne pas en pratique.

Voici le problème : la structure réelle d’un dépôt Git est un graphe orienté acyclique (DAG, pour faire court). Vous pouvez rechercher n’importe quel objet à partir de son SHA, mais pour effectuer la moindre opération dans le dépôt, vous devez parcourir le DAG étape par étape.

COMMIT DAGTREE /main → c8f3?commit · c8f3NETWORK↓ OLDER COMMITSobjects 0/54 · round-trips 0key/value storeaa42a112c8f3e816f0214b70e8c487ab19b4d5c277b22dc83f7dc43040c22d6e21aa729a0db50f627cf191fe3e81b19092d080a5f31134e06c811f6e7a196e42e147b9086a70ce192ee45d83f7a9b02ec2a70c49b8e25ac074b1a93d47dd9b519d2a8c14d431dc31ef09e205

Si vous souhaitez effectuer une opération telle que l’affichage des modifications récentes d’un dépôt, vous devez traiter ses commits. Lorsque vous traitez un commit, vous obtenez un pointeur vers la racine de son arbre. Cet arbre fournit des pointeurs vers chaque fichier et chaque sous-arbre. Le commit d’origine fournit également un pointeur vers son parent (celui qui le précède dans l’historique). Surtout, à chaque étape de ce parcours, vous ne connaissez la valeur du pointeur suivant qu’après avoir récupéré celle du précédent. Si chaque récupération nécessite un aller-retour vers un magasin distribué, les coûts explosent très vite.

Cette approche consistant à distribuer Git au niveau des objets a déjà été tentée à de nombreuses reprises et échoue souvent lorsqu’il faut passer à l’échelle. L’implémentation la plus prometteuse a été tentée par mon ancien mentor, Shawn Pearce, lorsqu’il travaillait dans l’équipe des systèmes de contrôle de version chez Google. Son approche consistait à stocker les objets dans une table de hachage distribuée. Cela n’était possible que grâce à JGit, une implémentation personnalisée de Git en Java. Comme toute bonne vieille bibliothèque Java, JGit fournit suffisamment d’interfaces, de fabriques et de fabriques d’interfaces pour abstraire tous les détails d’un dépôt Git classique, notamment en remplaçant ses packfiles sur disque par une DHT. Bien que le système fonctionnait et que les résultats étaient suffisamment bons pour les opérations Git courantes, les limitations du protocole Git (qui, là encore, impose l’envoi de packfiles sur le réseau, quelle que soit la façon dont les données sont stockées sur le serveur) dégradaient suffisamment les performances de git clone pour que cette conception soit finalement abandonnée.

GitHub et les systèmes de fichiers

Quelques années après que Git a commencé à sortir de sa bulle du noyau Linux, une startup pleine de ressources est née à San Francisco. GitHub a été fondé en 2008 en tant que plateforme de développement collaboratif, avec un slogan très visionnaire : « Hébergement de dépôts Git : fini la galère. » Et je ne plaisante pas : voyez par vous-même. Dès 2008, un large consensus estimait que, malgré (ou peut-être à cause de) la conception distribuée de Git, il fallait en pratique un moyen centralisé d’héberger les dépôts Git pour les rendre faciles à utiliser, et que cela était très pénible. GitHub était déterminé à changer cela.

Sa plateforme était au départ un monolithe Rails (et l’est encore en grande partie). Les toutes premières versions tournaient sur une seule machine, certes puissante, avec un serveur Ruby et des copies des dépôts sur le disque local. Faire évoluer une application Rails est facile : il suffit d’en déployer davantage d’instances. Mais dans ce cas précis, puisqu’il s’agit de Git, ils se sont rapidement heurtés à la question récurrente que nous cherchons à résoudre ici : si l’application Rails doit accéder aux dépôts Git sur disque, comment déployer davantage de copies de ces dépôts ?

Bande de marginaux économes, les premiers ingénieurs systèmes de GitHub ont tenté l’approche la plus simple susceptible de résoudre leurs problèmes de scalabilité. L’idée était qu’en se concentrant sur la distribution du système de fichiers (plutôt que des packfiles ou de Git lui-même), ils pourraient laisser l’application Rails inchangée et consacrer leur temps à livrer davantage de fonctionnalités à une base d’utilisateurs en constante croissance, plutôt qu’à faire des choses étranges avec Git. Très pragmatique. Cela n’a pas fonctionné.

L’équipe a essayé de nombreuses approches pour distribuer le système de fichiers des données Git : la plus évidente, qui consistait à utiliser NFS pour stocker tous les dépôts sur un serveur centralisé, a été rapidement écartée. L’implémentation par défaut de Git repose sur de nombreuses hypothèses concernant la sémantique du système de fichiers (verrouillage, déchirure, lecture, synchronisation...) qui garantissent des performances correctes sur le système de fichiers local d’un ordinateur portable de développeur peu puissant, mais ne tiennent pas compte de leur comportement sur un système de fichiers réseau. C’était lent et bogué.

D’autres tentatives ont été menées avec des technologies — franchement horribles, avec le recul — qui répliquaient le système de fichiers au niveau des blocs. Un déploiement de courte durée avec GFS. Un autre, plus durable, basé sur DRBD. Toutes ont atteint leurs limites. Elles étaient terribles à exploiter au quotidien et ne compensaient pas cela par de bonnes performances. Tout se résume à la conception des packfiles sur disque.

Nous avons déjà vu comment les structures de données en graphe de Git rendent les allers-retours excessivement coûteux. Malheureusement, un principe très similaire s’applique également aux données sous-jacentes sur disque. Il n’existe aucune corrélation entre la disposition des objets dans le DAG et leur placement dans un packfile. L’heuristique principale utilisée lors de la génération des packfiles consiste à en minimiser la taille ; les objets sont répartis aléatoirement dans le pack, compressés et, surtout, rarement stockés intégralement. La plupart sont stockés sous forme de delta par rapport à un autre objet du même packfile. Lire un objet individuel, après avoir suivi les nombreux sauts logiques dans la structure de données en graphe, implique aussi de suivre des sauts physiques dans le format sur disque.

COMMIT DAGTREE /HEAD·mergecommit · c8f3root /tree · f021server.tsblob · f7a9pack.tsblob · a112README.mdblob · c430Cargo.tomlblob · ef09main~1commit · 9d2aroot /tree · e8c4server.tsblob · d431pack.tsblob · b8e2README.mdblob · 21aaCargo.tomlblob · 92d0featurecommit · 74b1root /tree · 7a19server.tsblob · b02epack.tsblob · 7cf1README.mdblob · 5d83Cargo.tomlblob · e147merge basecommit · 5ac0root /tree · 2d6eserver.tsblob · 8c14pack.tsblob · 0f62README.mdblob · a93dCargo.tomlblob · 3e81refactorcommit · 3f7droot /tree · 6c81server.tsblob · e205pack.tsblob · 19b4README.mdblob · 6a70Cargo.tomlblob · d5c2parsercommit · aa42root /tree · 91feserver.tsblob · 4b70pack.tsblob · f311README.mdblob · 2dc8Cargo.tomlblob · 80a5docscommit · ce19root /tree · 0db5server.tsblob · 729apack.tsblob · 6e42README.mdblob · b190Cargo.tomlblob · 47ddbootstrapcommit · 87abroot /tree · 40c2server.tsblob · 1f6epack.tsblob · c2a7README.mdblob · 9b51Cargo.tomlblob · 34e0initialcommit · 2ee4root /tree · b908server.tsblob · dc31pack.tsblob · 77b2README.mdblob · e816Cargo.tomlblob · 0c49↓ OLDER COMMITSpack-7d9a.pack00005041434B00000002000000369667706B001087CB98F791BBB1A90DFB95AEA39C9096002008C2A7180F97ACF200DBBC9098D5BBD10030B5A94CB2E61004EC96B098E17C9BE5B700404A19E5C49FA44FE62B3225C446E744410050C5EB8990E84A607C5C5A3D9996324D5F0060633898968A2B4DF3066395013955B4960070A15A6898EA976BE046C3ACDE98A86F5E00803BE84A97E68450D47936985069F5B25F00902FBDE5D4C28ECFE63DB350B871E7F33A00A0BAF109D0E8F7FF556E74DAB7E6E2E71E00B058DD984BFA29344429F2E52F415382E600C0C71AD04782E703FECCE51940E862FA0600D004826A0596C94054F246984BD218C3AC00E04B2C9506ED4A02967A5071F17D9793A000F01B73670B98B79D58807A0B14E6DC5E5301002FEF9814BBB9A6E8617EE5D4B43D5AE601108478CF63F0E7F3D8BA541492E8E8FCF701205929E078E6629739693698F03127F80B0130295BE516B79BE8E68A9D1BCD23E744210140B1A5543CE88D7CD27941A14CE6C614EB0150F0E1982CE621266DD61CE5CECCF7DEE60160212997678DE7FD3C7B66EFF2E85F579E0170C2D51BAEE51554918799BF9B3F89732901808F7ED201FA87C2FD

Ce type de parcours aléatoire à travers des gigaoctets de données, nécessaire pour chaque opération Git effectuée sur un dépôt, ne s’accommode tout simplement pas d’un système de fichiers réseau (qu’il réplique au niveau des fichiers ou des blocs). La seule façon de le faire fonctionner sans le ralentir jusqu’à l’extrême est de pouvoir mettre tout le fichier en cache localement. Mais avec des centaines de milliers de dépôts dans le même système de fichiers, la mise en cache n’est pas une option.

Finalement, les ingénieurs systèmes de GitHub ont pris leur courage à deux mains et renoncé à distribuer le système de fichiers. Ils ont commencé à développer un système RPC afin que les dépôts puissent résider sur des serveurs de fichiers dédiés, puis ont mis à jour l’application Rails pour effectuer toutes les opérations à distance. Cela a apporté une bonne dose de scalabilité horizontale, mais n’a pas résolu leurs problèmes de disponibilité, ni les problèmes de performances des dépôts les plus sollicités. Après tout, chaque dépôt restait stocké sur une seule machine.

Spokes et cohérence

Spokes a été développé à l’origine chez GitHub vers 2013 et est depuis devenu une norme du secteur. La plupart des services d’hébergement Git utilisent dans leur architecture une variante de l’approche Spokes (réplication des dépôts Git au niveau de l’application). La principale raison pour laquelle Spokes a si bien fonctionné pendant de nombreuses années est qu’il a fait trois choix fondamentaux qui, avec le temps, se sont révélés optimaux :

  1. Il ne distribue pas Git lui-même ; il fonctionne au niveau du packfile.
  2. Il stocke toutes les données sous forme de véritables dépôts Git sur des disques NVMe locaux.
  3. Il réplique les données Git, tout en maintenant toutes les copies synchronisées de manière cohérente.

En raison des schémas de lecture aléatoire dans les packfiles dont nous venons de parler, stocker des dépôts Git bruts sur des disques NVMe est pratiquement indispensable pour garantir la rapidité de toutes les opérations Git de base. Cela permet également de garder les clones efficaces, car vous n’avez pas à transformer les données dans le format attendu par le client Git. Vous pouvez aussi vous concentrer sur la création d’un produit basé sur Git, plutôt que de maintenir vous-même un fork de Git capable de fonctionner avec vos dépôts inhabituels.

Il est également essentiel de maintenir toutes les copies des données synchronisées de manière cohérente. C’est le genre de chose que l’on apprend à ses dépens, mais le client Git ne s’accommode vraiment pas de la cohérence éventuelle. Si votre client Git local pousse un commit puis ne parvient pas à le lire immédiatement après un fetch, c’est une mauvaise nouvelle. Git trouve cela très déroutant. Si vous exécutez votre pipeline CI sur une centaine de runners et que trois d’entre eux ne trouvent pas le commit qu’ils sont censés tester après avoir cloné votre dépôt, c’est une mauvaise nouvelle. C’est aussi une très mauvaise expérience utilisateur.

Travailler avec une vue à cohérence éventuelle d’un dépôt Git comporte de nombreux pièges, que ce soit côté client ou dans le backend. Spokes accepte donc un coût de complexité très élevé pour garantir que le système est toujours entièrement cohérent. Voyons précisément ce que cela signifie.

Spokes est un système distribué fondé sur le consensus. Il fonctionne en stockant plusieurs copies de votre dépôt Git sur différents serveurs. Chaque fois que vous poussez de nouvelles données, un orchestrateur distribue votre push afin que chaque instance de votre dépôt en reçoive une copie. Cette « distribution » est synchronisée au moyen d’un algorithme de consensus classique appelé 3PC (commit en trois phases), de sorte qu’un push n’est accepté que si une majorité des nœuds l’approuve.

QUORUM · 4/5tx #42RESTORE ALLCOORDINATORcollecting votesPARTICIPANT 1waitingPARTICIPANT 2waitingPARTICIPANT 3offlinePARTICIPANT 4waitingPARTICIPANT 5waiting
1 · VOTING2 · PRE-COMMIT3 · DO COMMIT

Le commit en trois phases n’est pas du tout lié à Git. Il s’agit d’un algorithme de consensus qui garantit que tous les nœuds d’un système s’accordent pour valider ou annuler une transaction ; il y parvient en trois allers-retours. Il est très similaire au commit en deux phases, mais introduit une phase supplémentaire de « pré-validation » afin que le système puisse se rétablir si le coordinateur se déconnecte au milieu d’une transaction.

Avant de pouvoir parler davantage de la manière dont Spokes utilise 3PC, nous devons comprendre comment fonctionne un push Git. Un push Git comporte deux composants : un packfile et une transaction de référence. Le packfile, dont nous avons déjà parlé, contient les objets que vous poussez vers le dépôt (blobs, arbres et commits contenant vos modifications). La transaction publie réellement vos modifications dans le dépôt en mettant à jour une ou plusieurs références (par exemple, la branche sur laquelle vous travaillez) afin qu’elles pointent vers les commits que vous venez de pousser.

Cette séparation est très pratique ici, car un commit poussé n’est pas visible (« atteignable » dans la terminologie Git) tant que la référence qui pointe vers lui n’a pas été mise à jour. Cela signifie que nous pouvons implémenter le consensus pour nos push en distribuant simultanément les packfiles vers tous les hôtes (nous n’avons pas besoin de synchroniser cette étape), puis en effectuant un commit en trois phases avec la transaction de référence, qui est bien plus petite et rapide à synchroniser que le packfile. Git lui-même prend en charge la préparation des transactions de référence : il peut acquérir un verrou sur la référence, vérifier que la valeur existante est celle attendue, puis conserver le verrou jusqu’à ce qu’il reçoive une commande de validation ou d’annulation pour la transaction.

Playback speed0.010x
Replicas5
One-way latency20ms
elapsed 0msSPOKEScoordinatorneeds 5 / 5 acks

Spokes distribue les packs, puis effectue un commit en trois phases pour la transaction de chaque push. Vous pouvez augmenter le nombre de répliques et la latence dans ce simulateur afin de voir comment cela affecte le débit des commits.

Avec cette conception, nous garantissons que chaque push est entièrement synchronisé sur toutes les répliques. Les lectures (fetches, clones) peuvent alors être routées en toute sécurité vers n’importe quelle réplique, car chaque réplique est toujours à jour.

C’est essentiellement ainsi que fonctionne Spokes, et il fonctionne plutôt bien depuis 13 ans. Bien sûr, Spokes n’est pas parfait — aucun système ne l’est. En 2026, la manière dont les gens utilisent les dépôts Git a radicalement changé, et nous avons tiré de nombreuses leçons importantes sur la création de systèmes distribués au fil du temps. Le temps et l’expérience ont montré lesquels des choix de Spokes se sont avérés optimaux, et lesquels ne l’ont pas été.

Un défaut qui s'est révélé critique est la scalabilité horizontale limitée de 3PC. Lors du lancement de Spokes, trois répliques par dépôt représentaient le compromis idéal. Vous pouviez gérer un dépôt moyen à partir de trois copies, avec de la capacité disponible et suffisamment de redondance pour continuer à accepter les pushes même si une machine tombait en panne.

En 2026, la situation est très différente. Le dépôt moyen d'une entreprise est désormais un monorepo massif. Trois répliques ne suffisent pas à gérer le trafic de tels dépôts, en particulier pour la CI. Bien sûr, rien n'empêche Spokes de fonctionner avec plus de trois répliques, si ce n'est le redoutable problème de la longue traîne à grande échelle. Le commit en trois phases correspond très élégamment au modèle transactionnel de Git, mais en tant qu'algorithme de consensus, il présente des limites fondamentales : la latence de chaque étape est déterminée par le serveur le plus lent du cluster. Plus vous ajoutez de répliques à un cluster, plus le débit des pushes se dégrade.

Cette contrainte de scalabilité s'applique également dans l'autre sens. Lorsque des agents travaillent avec des dépôts Git à grande échelle, ils opèrent souvent en dehors d'un monorepo, en créant un très grand nombre de petits dépôts, dont beaucoup sont éphémères et la plupart à peine utilisés. Spokes rencontre des difficultés dans ce cas, car il nécessite toujours trois répliques pour chacun de ces dépôts. Trois répliques généralement inactives, qui ne peuvent pas être réduites, car le système ne serait alors plus entièrement cohérent et une perte de données deviendrait possible. Avec le commit en trois phases, le plancher est toujours trop haut et le plafond trop bas.

Un autre défaut, impossible à anticiper mais douloureusement évident après l'avoir subi, est que Spokes peut être pénible à exploiter à grande échelle. Comme les dépôts sur disque constituent toujours la source de vérité pour le consensus, chaque copie de chaque dépôt est très importante. Vous devez traiter les dépôts comme des animaux de compagnie, et non du bétail.

Cela signifie, pour commencer, que vous devez savoir exactement où se trouve chaque dépôt. Cela ajoute une dépendance envers une base de données externe — et un risque potentiel pour la disponibilité — qui doit conserver une très grande table de routage associant chaque dépôt à chaque machine sur laquelle il est répliqué. Chaque dépôt doit également faire l'objet d'une somme de contrôle, et ces sommes doivent être constamment mises à jour dans cette table afin de garantir que le dépôt reste valide sur disque. Dès qu'un problème survient avec un dépôt (et croyez-moi, cela arrive tout le temps — Git peut être très capricieux en pratique), vous devez le détecter et planifier une tâche de réparation pour le ramener à un état sain. Et vous devez le faire très rapidement ! Car, encore une fois, les dépôts sur disque sont la source de vérité. Une copie corrompue est aussi grave qu'une copie manquante. Si deux des trois copies sont corrompues, le système ne peut plus accepter de pushes : il n'y a pas de quorum.

Continuity

Continuity (Cnt en abrégé) est le système de stockage Git que nous avons développé chez Cursor, avec une approche très claire : tirer les leçons de tout ce que Spokes faisait bien et corriger ce qui, après de nombreuses années, s'est révélé problématique.

Cnt est un système simple (un système ne peut pas être facile à exploiter s'il n'est pas simple). Son composant primitif central est un journal de préécriture, que nous stockons dans un stockage d'objets compatible avec S3. En production, nous l'exécutons directement sur S3, mais nous l'avons conçu pour pouvoir être déployé sur n'importe quel cloud.

Lorsqu'un dépôt reçoit un push, nous stockons ce push sous forme d'entrée WAL dans S3. Nous ne confirmons jamais un push tant qu'il n'a pas été entièrement persisté. Chaque push est stocké sous la forme d'un objet distinct ; nous écrivons le packfile envoyé sur disque tout en le téléversant vers S3. Toutefois, téléverser une entrée WAL ne la publie pas. Un push n'est visible qu'une fois sa transaction de référence préparée avec succès sur une copie locale du dépôt et un pointeur vers l'entrée WAL enregistré dans le fichier d'index WAL, qui constitue lui-même un objet du stockage. Cela garantit la linéarisabilité de tous les push.

S3 · OBJECT STORE#1.wal#2.wal#19e37.wal#23c6e.walgitwal.pbetag e2GIT CLIENTgit pushWALGITreceivingBARE REPOidle

Nous cherchons à ne pas effectuer une seule écriture S3 par push, car, dans les dépôts très actifs, cela plafonne le débit des push en fonction de la latence de l'opération PUT S3. Grâce à une implémentation de traitement par lots soigneusement optimisée, et parce qu'il suffit de synchroniser la transaction de référence avec un seul dépôt local plutôt qu'avec un quorum de répliques, notre système peut ingérer les push aussi vite que le permet notre disque.

La copie locale du dépôt est, bien sûr, un dépôt Git standard stocké sur un disque NVMe très rapide. Nous faisons la même chose que Spokes, car je pense que Spokes a parfaitement réussi cet aspect. Cela nous permet de réutiliser tout l'excellent travail open source de la communauté Git, y compris le client Git amont et ses nombreuses optimisations de performances. Nous pouvons ainsi nous concentrer sur la livraison de nouvelles fonctionnalités, plutôt que de faire des choses étranges avec Git.

Consensus

Nous avons vu que l’une des difficultés d’exploitation d’un cluster Spokes est qu’il est essentiel de suivre l’emplacement de chaque dépôt sur chaque serveur. Cnt fonctionne très différemment. Où se trouve chaque dépôt ? La réponse : « n’importe où ». Peu importe ! Nous traitons les dépôts comme un cache disque éphémère, mais la source de vérité reste toujours le journal de préécriture dans S3. Le système est sans état et ne nécessite ni tables de routage (ni base de données relationnelle à gérer — hashtag béni). Si un dépôt est absent du disque local lors d’un accès sur un hôte, nous le matérialisons simplement à partir du WAL. Nous pouvons le faire très efficacement, mais nous ne voulons évidemment pas le faire en permanence, car ce serait du gaspillage. En production, nous utilisons le hachage de rendez-vous pour associer un ID de dépôt à la liste des nœuds où il devrait se trouver. Tout l’état nécessaire pour router les dépôts se limite à l’ID du dépôt et à l’ensemble actuel des nœuds sains d’un cluster. Mais si cet état se désynchronise (par exemple, si un nœud devient indisponible), ce n’est pas un problème non plus. Nous matérialisons simplement le dépôt sur le nœud suivant, quel qu’il soit.

Et le consensus ? Les élections ? Quel serveur est le principal d’un dépôt donné ? Là encore, peu importe ! Il n’y a ni état ni consensus ici. N’importe quel serveur peut être le principal. Toutes les mises à jour du journal de préécriture sont synchronisées avec une opération atomique de comparaison et d’échange (CAS) sur S3. Il est donc toujours sûr que n’importe quelle instance d’un dépôt reçoive un push. Là encore, comme pour le routage, laisser un serveur arbitraire agir comme principal n’est pas le plus efficace (cela entraîne des tentatives CAS supplémentaires, qui peuvent retarder les pushs). En pratique, nous choisissons donc toujours le même serveur comme principal : le premier de la liste classée par hachage de rendez-vous. Mais dans les cas limites — lors d’un déploiement, d’un basculement ou d’une brève interruption réseau — nous ne nous préoccupons tout simplement pas de savoir précisément quel serveur est le principal. Le système est conçu pour rester correct lorsqu’il est dégradé et rapide lorsqu’il est sain.

S3 · OBJECT STORE4b1e#414b1e.walgitwal.pbetag e0WALGIT Aupload pack#414b1e.wal#429f4d.walgitwal.pbWALGIT Bupload pack#414b1e.wal#42b7e3.walgitwal.pb

Réplication

Disposer d’un journal de préécriture dans S3 ouvre un monde de possibilités en matière de passage à l’échelle. Nous pouvons avoir littéralement autant de répliques que nous le voulons, car la scalabilité de S3 est inégalée et toutes les répliques se mettent directement à jour depuis S3. Nous effectuons une réplication optimiste en envoyant des paquets UDP de gossip dans notre cluster. Les paquets contiennent toutes les métadonnées requises pour que chaque réplique puisse se mettre à jour directement depuis S3 après chaque push. « C’est insensé », vous entends-je marmonner derrière votre écran, à travers le temps et l’espace. « UDP n’est pas un transport fiable. » Bien sûr que non. Rien n’est fiable dans un système distribué ! Le réseau n’est pas fiable, le routage n’est pas fiable, et la topologie ne l’est pas non plus. Mais ce n’est pas grave : cela n’a aucune importance. Chaque réplique connaît l’ETag de la dernière version de l’index WAL qu’elle a rattrapée. Lorsque vous effectuez une opération de lecture sur une réplique, nous envoyons une requête GET conditionnelle à S3 avec l’ETag attendu. Une réponse 304 sans corps (une opération presque instantanée — moins de 10 ms en moyenne, puisqu’il s’agit d’une opération S3 portant uniquement sur les métadonnées) signifie que nous sommes à jour et pouvons servir immédiatement le fetch ou le clone. Une réponse 200 contient la version la plus récente de l’index WAL, que nous utilisons pour nous mettre à jour avant de servir la lecture.

S3 · OBJECT STORE#40.wal#41.wal#40b8ab.wal#4156e2.walgitwal.pbetag e1GIT CLIENTgit pushGIT CLIENTgit fetchWALGIT · PRIMARYreceive-packWALGIT · REPLICAcurrent · e1#40b8ab.wal#4156e2.walgitwal.pbetag e1

Peu importe si le paquet UDP de réplication est perdu ou s’il arrive sur le mauvais serveur parce que la topologie a changé. Toutes les lectures sur toutes les répliques sont entièrement cohérentes, car elles sont vérifiées par rapport à la source de vérité, qui est S3. Le système est conçu pour rester correct lorsqu’il est dégradé et rapide lorsqu’il est sain.

Les implications sont doubles. Premièrement, puisque le système est toujours cohérent, il est trivial de créer une infrastructure par-dessus. Nous (nos agents, notre interface web, nos clients) avons toujours une vue globalement cohérente du dépôt. Et comme le système passe à l’échelle dans les deux directions, chaque dépôt dispose exactement du bon nombre de répliques. Un grand monorepo peut être déployé sur des centaines de répliques afin de prendre en charge toute la charge générée par ses tâches de CI. Des millions de petits dépôts créés par des agents peuvent être servis par une seule réplique chacun ; nous n’avons pas besoin de plus d’une réplique pour garantir la disponibilité, car S3 est la source de vérité. En fait, un dépôt inactif n’en a même pas besoin : lorsqu’une réplique n’a pas reçu de trafic depuis un certain temps, nous la supprimons du disque du nœud et la matérialisons de nouveau à partir du WAL lorsqu’une requête fetch arrive.

S3 · OBJECT STORE

Compactage

Les journaux de préécriture nécessitent un compactage périodique. Vous ne pouvez pas laisser le journal croître indéfiniment : lors d’une restauration complète, chaque entrée est rejouée ; plus il y a d’entrées, plus l’opération coûte cher.

Un dépôt Git classique nécessite lui aussi un compactage périodique, bien que Git ne repose pas sur un WAL. Nous avons vu que l’unité de stockage fondamentale d’un dépôt Git est le packfile. Chaque fois que vous effectuez un push vers une copie distante d’un dépôt ou un fetch dans votre copie locale, vous créez un nouveau packfile. Cela ne passe pas indéfiniment à l’échelle : chaque packfile possède son propre index associé, qui permet à Git de rechercher efficacement les objets qu’il contient, mais cette recherche n’est efficace qu’à l’échelle d’un packfile. Si vous recherchez un objet précis et que votre dépôt contient 100 packfiles, vous devrez ouvrir l’index de chacun d’eux et y rechercher l’objet jusqu’à le trouver dans l’un des packfiles. Une opération efficace cesse de l’être lorsqu’elle doit être exécutée des centaines ou des milliers de fois.

Les versions modernes de Git sont devenues très efficaces pour contourner ce problème : elles prennent désormais en charge les index multi-pack et le compactage géométrique incrémentiel. Mais il faut bien finir par repack le dépôt Git sur disque. Historiquement, cela a constamment posé des problèmes de disponibilité pour des systèmes comme Spokes, car le repack est une opération très gourmande en CPU, même lorsqu’il est effectué de manière incrémentielle, et il doit être réalisé sur toutes les répliques du système. Déclencher accidentellement une opération de maintenance sur au moins deux nœuds Spokes pour le même dépôt provoquera facilement le basculement du dépôt.

Ici, nous amortissons le coût du compactage. Seul le nœud principal effectue les compactages, et le résultat s’applique à la fois au dépôt sur disque et au WAL. Comme toutes les répliques suivent le WAL, elles suivent également les événements de compactage. Les répliques n’effectuent pas de repack ; elles téléchargent simplement les packs déjà compactés depuis S3, troquant la bande passante contre du CPU.

WALGIT · LOCAL PACKSgeometric compaction0ec8f61e8f28979dbe0e6db3COMPACTION FRONTIERPACKS ARRIVES3 · SOURCE OF TRUTH5 packs#126db3.wal#40be0e.wal#970ec8.wal#98f61e.wal#998f28.walgitwal.pb

Passage à l’échelle

La réplication et le compactage sont les deux facteurs clés qui déterminent le comportement d’un système de stockage Git sous charge. Comme nous venons de le voir, ils sont intrinsèquement liés : plus un dépôt reçoit de pushs par seconde, plus les performances de lecture se dégradent, car les packfiles de chaque push doivent être compactés pour que les opérations Git restent efficaces. Si vous répliquez ces pushs, le compactage doit lui aussi être répliqué ou effectué indépendamment sur chaque réplique.

L’architecture de Continuity, centrée sur le WAL, offre une scalabilité horizontale entièrement cohérente : vous pouvez déployer un nombre arbitraire de répliques, et le débit des opérations Git en lecture seule augmente linéairement avec leur nombre. Comme toutes les répliques du cluster sont entièrement cohérentes, nous pouvons ainsi passer à l’échelle pour le protocole Git (clones, fetches) et pour toutes les opérations RPC qu’Origin effectue sur les dépôts (interactions dans l’interface web, API REST, toutes nos interfaces agentiques, etc.).

Nous avons effectué des tests de charge synthétiques avec jusqu’à 100 répliques et constaté une scalabilité linéaire et constante des lectures, sans aucune régression du débit des pushs.

Le débit des pushs d’un cluster dépend de la latence avec laquelle nous pouvons mettre à jour notre WAL sur S3. Avec S3 Standard, nous pouvons maintenir jusqu’à 120 pushs/s tout en compactant et en répliquant les données compactées sur tous les autres nœuds. Nous avons également déployé des clusters haute performance sur S3 Express One Zone, qui offre une latence bien plus faible pour les opérations PUT. Dans ce cas, nous pouvons ingérer plus de 300 pushs/s, et le facteur limitant est de fait la vitesse à laquelle Git peut compacter les données sur disque. Nous travaillons sur des méthodes innovantes d’organisation de ces données sur disque afin de réduire l’impact du compactage : notre objectif est de continuer à optimiser la vitesse à laquelle un dépôt Git peut ingérer du code sans assouplir nos strictes garanties de durabilité et de cohérence.

  • S3 Standard
  • S3 Express One Zone

Débit de push/clone pour everysphere, le monorepo de Cursor.
Tous les pushs sont linéarisables et enregistrés dans un stockage externe avant confirmation.
Tous les clones sont entièrement cohérents.

Le WAL comme source de vérité

S3 est une technologie remarquable. Tout le concept de stockage d’objets volumineux, dont l’API S3 a été pionnière, s’est révélé être une brique extrêmement puissante pour les systèmes de stockage de grandes quantités de données, et cela vaut tout autant pour l’hébergement de dépôts Git. La conception présentée ici est novatrice à bien des égards, mais ce n’est pas la première à stocker des packfiles sous forme de blobs. Azure DevOps (le concurrent de GitHub de Microsoft) dispose d’un système de stockage Git très performant qui stocke les packfiles dans un stockage de blobs et leurs références dans une base de données relationnelle (MS SQL Server). Un tel système implique de nombreux compromis. Une base de données relationnelle passe bien à l’échelle pour les transactions de référence volumineuses. Mais il faut alors exploiter une base de données relationnelle. Nous sommes fermement convaincus que la cohérence des données Git prime sur toute autre considération. C’est ce qui nous a finalement amenés à concevoir un système fondé sur un WAL, sans dépendance à des bases de données externes.

De nombreuses choses peuvent mal tourner avec un dépôt Git en production : corruption des données au repos, bugs lors du repack, conditions de concurrence lors des pushes. C’est tout un ensemble de cas limites. La plupart ont été éliminés dans Git en amont, mais pas tous. Aucun système n’est exempt de bugs, pas même les systèmes OSS largement déployés. Notre modèle de cohérence garantit le suivi de chaque opération fondamentale effectuée sur un dépôt. Nous ne confirmons jamais un push tant qu’il n’a pas été entièrement persisté dans le WAL. Nous linéarisons tous les pushes. Chaque vue de chaque dépôt auquel nous accédons est toujours entièrement cohérente. Puisque chaque push figure dans le WAL, nous pouvons examiner tous les états par lesquels un dépôt est passé. Nous disposons de données de provenance complètes pour tous les pushes, ainsi que pour tous les repacks. Nous pouvons rembobiner et avancer rapidement chaque réplique. Nous n’avons aucun état à synchroniser avec une base de données externe, qu’elle ne stocke que des références ou l’ensemble des données d’objets. Lorsque, et non pas si, nous rencontrons un bug dans Git, nous pouvons déterminer précisément ce qui s’est passé et l’annuler. Et au-delà des bugs déjà présents dans Git, nous en introduisons très peu de nouveaux, car, tout au long du processus, toutes les opérations Git sont effectuées sur un dépôt Git standard sur disque, à l’aide d’outils standard.

Origin

Nous sommes parfaitement conscients de l’importance que revêt l’hébergement du code source d’autrui. Je pense que toutes les personnes qui lisent et comprennent cet article de blog le sont tout autant. Une entreprise peut être paralysée si ses développeurs ne peuvent ni pousser ni récupérer du code depuis ses dépôts Git. Le coût en productivité de cinq minutes d’indisponibilité de votre système CI est difficile à chiffrer en dollars, mais il est, à tous égards, colossal.

Les agents ont profondément changé notre façon de travailler avec les logiciels et, à bien des égards, ont aggravé cette situation. Plus de code, plus de PR, plus d’exécutions CI. Le contrôle de version est au cœur de tout cela, et c’est peut-être l’élément le plus difficile à changer du jour au lendemain.

Nous sommes confrontés à ces difficultés en interne chez Cursor depuis de nombreux mois, et nous avons consacré beaucoup de réflexion et de soin à créer une plateforme qui les résout pour nous et qui, nous l’espérons, pourra aussi les résoudre pour nos clients. Notre priorité actuelle est d’offrir la transition la plus fluide possible vers davantage de fiabilité, de performances et de capacité à passer à l’échelle, tout en rendant la migration aussi simple que possible.

Origin n’est pas une expérimentation ; c’est le fruit de nombreuses décennies d’expérience dans la conception de ces systèmes, acquise par des personnes qui comprennent parfaitement l’ampleur des défis en jeu. Nous avons une philosophie d’ingénierie et d’exploitation dont l’efficacité a fait ses preuves, ainsi qu’un fort engagement à la faire évoluer continuellement à mesure que le paysage du contrôle de version évolue.

Nous espérons que vous nous accorderez votre confiance et celle de notre plateforme.

Classé dans : recherche

Auteur: Vicent Martí