Skip to content
Un monorepo avec trois applications : comment nous ne construisons que ce qui a changé, et avons fait passer le pipeline de huit minutes à trois
← ← Retour aux Réflexions Development

Un monorepo avec trois applications : comment nous ne construisons que ce qui a changé, et avons fait passer le pipeline de huit minutes à trois

Trois applications Next.js, quatre paquets partagés, un seul dépôt. C'est la forme de la plateforme que nous exploitons pour un client américain, et c'est la bonne forme : des types partagés, un seul lockfile, une seule pull request pour un changement qui touche l'API et les deux front-ends qui la consomment. La mauvaise partie, c'était le pipeline, qui reconstruisait les trois applications à chaque commit, y compris un commit qui changeait un README.

Huit minutes par push. Trois jobs CodeBuild, trois builds Docker, trois envois d'images, que quelque chose ait changé dans ces applications ou non. Voici comment nous l'avons ramené à trois minutes sur un commit typique et à moins d'une sur un commit de documentation seule, avec l'outillage qui l'a permis, le piège dans lequel nous sommes tombés, et le seul cas où tout reconstruire est correct.

L'organisation

.
├── apps/
│   ├── web/          # application Next.js publique
│   ├── admin/        # application Next.js interne
│   └── api/          # route handlers Next.js, déployés comme service à part
├── packages/
│   ├── db/           # schéma, migrations, helpers de requêtes
│   ├── ui/           # composants partagés
│   ├── config/       # presets eslint, tsconfig, tailwind
│   └── types/        # types TypeScript partagés, client d'API généré
├── infra/            # CDK
├── pnpm-workspace.yaml
├── turbo.json
└── pnpm-lock.yaml

pnpm workspaces pour le graphe de paquets, Turborepo pour le graphe de tâches. Chaque application dépend d'un sous-ensemble des paquets ; web et admin dépendent de ui, les trois dépendent de db et types, tout dépend de config. Ce graphe de dépendances est toute l'entrée de « qu'est-ce qui a changé ».

apps/web apps/admin apps/api packages/ui packages/db packages/types packages/config changer packages/ui → construire web + admin · changer packages/db → construire les trois · changer README → ne rien construire

Étape 1 : laisser Turborepo décider ce qui est affecté

Turborepo connaît déjà le graphe. La syntaxe --filter avec une plage git lui demande quels espaces de travail ont changé, directement ou via une dépendance, depuis un commit :

# quelles applications ont besoin d'un build pour ce push ?
pnpm turbo ls --affected --filter='./apps/*' --output=json | jq -r '.packages.items[].name'

--affected compare l'arbre de travail à origin/main par défaut (configurable avec TURBO_SCM_BASE), parcourt le graphe de dépendances et renvoie les applications qui dépendent transitivement de quelque chose qui a changé. Un changement dans packages/ui renvoie web et admin. Un changement dans apps/api/app/orders/route.ts renvoie api. Un changement dans README.md ne renvoie rien.

Le script de déploiement lit cette liste et démarre un job CodeBuild par application qu'elle contient, en parallèle, au lieu d'en démarrer toujours trois. C'est tout le changement du flux de contrôle du pipeline : dix lignes de shell qui remplacent une liste codée en dur de trois.

Deux choses que Turborepo compte, à raison, comme « affecte tout » : le lockfile et la configuration racine. Une montée de dépendance dans pnpm-lock.yaml reconstruit les trois applications, parce que n'importe laquelle a pu récupérer la nouvelle version. De même pour un changement dans turbo.json ou le package.json racine. Nous avons essayé une fois d'exclure le lockfile pour accélérer une montée de routine et avons livré une application avec une dépendance périmée. Ne le faites pas.

Étape 2 : mettre en cache les sorties de build, à distance

À l'intérieur du build de chaque application, Turborepo met en cache les sorties de tâches indexées par un hash des entrées : fichiers sources, dépendances, variables d'environnement que vous déclarez. Un second build avec les mêmes entrées restaure la sortie depuis le cache au lieu d'exécuter next build. En local, c'est automatique. En CI, chaque job CodeBuild part d'un disque vide, donc le cache doit vivre quelque part de partagé.

Nous faisons tourner un petit cache distant auto-hébergé, une implémentation open source de l'API de cache distant de Turborepo, sur une t4g.nano qui écrit dans S3. Chaque job CodeBuild et chaque machine de développeur pointe dessus :

// .turbo/config.json, ou via env en CI
{ "teamid": "team_platform", "apiurl": "https://turbo-cache.internal" }
# dans le buildspec
export TURBO_TOKEN=$TURBO_CACHE_TOKEN TURBO_TEAM=team_platform TURBO_API=https://turbo-cache.internal
pnpm turbo build --filter=web

L'effet : packages/types et packages/db sont construits une fois, par le job qui y arrive en premier, et tous les autres jobs les restaurent en une seconde. Un build de web qui a changé une page restaure les paquets partagés depuis le cache et n'exécute next build que pour web lui-même. Le cache distant coûte 3 $ par mois pour l'instance et quelques centimes pour S3.

Étape 3 : mettre aussi en cache les couches Docker

Le build Docker est l'autre moitié du temps. Une image Node multi-étapes a une couche pnpm install coûteuse qui ne change que lorsque le lockfile change, et une couche next build qui change à chaque commit. BuildKit peut exporter le cache de couches vers un registre et l'importer au build suivant :

docker buildx build \
  --cache-from type=registry,ref=$ECR/web:buildcache \
  --cache-to   type=registry,ref=$ECR/web:buildcache,mode=max \
  --build-arg RELEASE_SHA=$SHA \
  -t $ECR/web:$SHA --push .

Avec le cache chaud, la couche pnpm install est restaurée en dix secondes au lieu de tourner quatre-vingt-dix. La couche next build s'exécute toujours, mais le cache Turborepo à l'intérieur fait qu'elle est surtout restaurée aussi. mode=max met en cache les étapes intermédiaires, ce qui rend l'étape de builder réutilisable ; l'image de cache vit dans ECR sous un tag fixe et coûte quelques centaines de mégaoctets de stockage.

Les chiffres

Type de commit Avant Après Ce qui s'exécute
Documentation, infra, config CI seulement 8 min 40 s Turborepo dit que rien n'est affecté ; le script de déploiement saute tous les builds ; diff CDK seulement
Une page dans apps/web 8 min 2 min 50 s Un job CodeBuild ; paquets partagés depuis le cache distant ; couche Docker d'installation depuis le cache de registre
Changement dans packages/ui 8 min 3 min 20 s Deux jobs en parallèle, web et admin ; api intact
Changement dans packages/db 8 min 3 min 40 s Trois jobs en parallèle ; les caches fonctionnent encore pour les couches d'installation
Montée de lockfile 8 min 6 min Trois jobs ; couche d'installation reconstruite partout ; cache Turborepo invalidé ; correct

Le commit médian est la deuxième ligne. De huit minutes à moins de trois, et, parce que les jobs qui s'exécutent sont en parallèle, le maximum est maintenant le cas du lockfile à six, pas les anciennes huit à chaque commit.

Durée du pipeline par type de commit docs seulement8:00 → 0:40 une application8:00 → 2:50 packages/ui8:00 → 3:20 packages/db8:00 → 3:40 montée de lockfile8:00 → 6:00 · correct gris : avant, chaque commit · vert : après · jaune : le cas qui doit tout reconstruire, et le fait

Le piège : une application affectée qui n'a pas été construite

Deux mois plus tard, un déploiement a livré admin sans un changement dont admin avait besoin. Le changement était dans packages/types, un client d'API généré, et admin dépend de types, donc Turborepo aurait dû le signaler. Il ne l'a pas fait, parce que le client généré était produit par un script qui tournait en dehors du graphe de tâches Turborepo, écrivant des fichiers dans packages/types/generated/, qui était dans .gitignore. Turborepo hache les entrées suivies. La sortie générée n'était pas suivie, donc de son point de vue types n'avait pas changé.

Le correctif a été de faire de la génération une tâche Turborepo avec des sorties déclarées, pour que le hash inclue les entrées du générateur (la spécification OpenAPI) et que la sortie soit mise en cache et restaurée comme n'importe quelle autre. La leçon se généralise : tout ce qui produit des entrées de build doit être à l'intérieur du graphe, sinon le graphe vous ment. Nous avons audité chaque script de package.json à la recherche du même schéma et en avons trouvé un de plus.

Quand tout reconstruire quand même

  • Le lockfile ou la configuration racine a changé. Turborepo le fait tout seul ; ne l'outrepassez pas.
  • Une montée d'image de base dans les Dockerfiles. Le Dockerfile est une entrée du cache Docker, pas de Turborepo, donc nous greppons le diff à la recherche de Dockerfile et forçons les trois.
  • Un tag de release. Chaque release taguée reconstruit les trois depuis zéro, sans caches, pour que l'artefact d'une version soit reproductible depuis les sources et non depuis ce qui se trouvait dans le cache ce jour-là. Ça prend huit minutes, une fois par semaine, et c'est le build que nous voudrions s'il fallait un jour expliquer exactement ce qui a été livré.

La version courte

Laissez l'outil qui connaît déjà le graphe de dépendances décider ce qu'il faut construire. Mettez le cache de tâches quelque part où chaque builder peut l'atteindre. Mettez en cache la couche Docker coûteuse dans le registre. Gardez chaque générateur à l'intérieur du graphe. Reconstruisez tout sur les changements de lockfile et sur les tags de release, volontairement. De huit minutes à trois, pour une journée de mise en place et 3 $ par mois.

Si votre monorepo construit tout à chaque push, nous pouvons câbler ça en une journée ; la partie Turborepo prend une heure, le cache Docker les sept autres.