Drei Next.js-Anwendungen, vier geteilte Pakete, ein Repository. Das ist die Form der Plattform, die wir für einen US-Kunden betreiben, und es ist die richtige Form: geteilte Typen, ein Lockfile, ein Pull Request für eine Änderung, die die API und die beiden Frontends berührt, die sie konsumieren. Der falsche Teil war die Pipeline, die bei jedem Commit alle drei Anwendungen neu baute, auch bei einem Commit, der eine README änderte.
Acht Minuten pro Push. Drei CodeBuild-Jobs, drei Docker-Builds, drei Image-Pushes, ob sich in diesen Anwendungen etwas geändert hatte oder nicht. So haben wir sie bei einem typischen Commit auf drei Minuten und bei einem reinen Doku-Commit auf unter eine gebracht, mit dem Tooling, das es geschafft hat, der Falle, in die wir getappt sind, und dem einen Fall, in dem alles neu zu bauen richtig ist.
Das Layout
.
├── apps/
│ ├── web/ # öffentliche Next.js-App
│ ├── admin/ # interne Next.js-App
│ └── api/ # Next.js-Route-Handler, als eigener Dienst deployt
├── packages/
│ ├── db/ # Schema, Migrationen, Query-Helfer
│ ├── ui/ # geteilte Komponenten
│ ├── config/ # eslint-, tsconfig-, tailwind-Presets
│ └── types/ # geteilte TypeScript-Typen, generierter API-Client
├── infra/ # CDK
├── pnpm-workspace.yaml
├── turbo.json
└── pnpm-lock.yaml
pnpm-Workspaces für den Paketgraphen, Turborepo für den Task-Graphen. Jede App hängt von einer Teilmenge der Pakete ab; web und admin hängen von ui ab, alle drei von db und types, alles von config. Dieser Abhängigkeitsgraph ist der gesamte Input für „was hat sich geändert“.
Schritt 1: Turborepo entscheiden lassen, was betroffen ist
Turborepo kennt den Graphen bereits. Die --filter-Syntax mit einem Git-Bereich fragt, welche Workspaces sich seit einem Commit geändert haben, direkt oder über eine Abhängigkeit:
# welche Apps brauchen für diesen Push einen Build?
pnpm turbo ls --affected --filter='./apps/*' --output=json | jq -r '.packages.items[].name'
--affected vergleicht den Arbeitsbaum standardmäßig mit origin/main (konfigurierbar über TURBO_SCM_BASE), läuft den Abhängigkeitsgraphen ab und liefert die Apps, die transitiv von etwas Geändertem abhängen. Eine Änderung an packages/ui liefert web und admin. Eine Änderung an apps/api/app/orders/route.ts liefert api. Eine Änderung an README.md liefert nichts.
Das Deploy-Skript liest diese Liste und startet pro App darin einen CodeBuild-Job, parallel, statt immer drei zu starten. Das ist die gesamte Änderung am Kontrollfluss der Pipeline: zehn Zeilen Shell, die eine hartkodierte Liste von dreien ersetzen.
Zwei Dinge zählt Turborepo, korrekt, als „betrifft alles“: das Lockfile und die Root-Konfiguration. Ein Dependency-Bump in pnpm-lock.yaml baut alle drei Apps neu, weil jede die neue Version aufgenommen haben könnte. Ebenso eine Änderung an turbo.json oder der Root-package.json. Wir haben einmal versucht, das Lockfile auszuschließen, um einen Routine-Bump zu beschleunigen, und eine App mit veralteter Abhängigkeit ausgeliefert. Nicht tun.
Schritt 2: die Build-Outputs cachen, remote
Innerhalb des Builds jeder App cacht Turborepo Task-Outputs, geschlüsselt nach einem Hash der Inputs: Quelldateien, Abhängigkeiten, deklarierte Umgebungsvariablen. Ein zweiter Build mit denselben Inputs stellt den Output aus dem Cache wieder her, statt next build auszuführen. Lokal passiert das automatisch. In CI startet jeder CodeBuild-Job von einer leeren Platte, also muss der Cache irgendwo Geteiltes leben.
Wir betreiben einen kleinen selbst gehosteten Remote-Cache, eine Open-Source-Implementierung der Remote-Cache-API von Turborepo, auf einer t4g.nano, die nach S3 schreibt. Jeder CodeBuild-Job und jeder Entwicklerrechner zeigt darauf:
// .turbo/config.json, oder per Env in CI
{ "teamid": "team_platform", "apiurl": "https://turbo-cache.internal" }
# in der Buildspec
export TURBO_TOKEN=$TURBO_CACHE_TOKEN TURBO_TEAM=team_platform TURBO_API=https://turbo-cache.internal
pnpm turbo build --filter=web
Der Effekt: packages/types und packages/db werden einmal gebaut, von dem Job, der zuerst dort ankommt, und jeder andere Job stellt sie in einer Sekunde wieder her. Ein web-Build, der eine Seite geändert hat, stellt die geteilten Pakete aus dem Cache wieder her und führt next build nur für web selbst aus. Der Remote-Cache kostet $3 im Monat für die Instanz und Cent-Beträge für S3.
Schritt 3: auch die Docker-Layer cachen
Der Docker-Build ist die andere Hälfte der Zeit. Ein mehrstufiges Node-Image hat eine teure pnpm install-Schicht, die sich nur ändert, wenn das Lockfile es tut, und eine next build-Schicht, die sich bei jedem Commit ändert. BuildKit kann den Layer-Cache in eine Registry exportieren und beim nächsten Build importieren:
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 .
Mit warmem Cache wird die pnpm install-Schicht in zehn Sekunden wiederhergestellt, statt neunzig zu laufen. Die next build-Schicht läuft weiterhin, aber Turborepos Cache darin bedeutet, dass auch sie größtenteils wiederhergestellt wird. mode=max cacht Zwischenstufen, was die Builder-Stage wiederverwendbar macht; das Cache-Image liegt in ECR unter einem festen Tag und kostet ein paar hundert Megabyte Speicher.
Die Zahlen
| Commit-Typ | Vorher | Nachher | Was läuft |
|---|---|---|---|
| Nur Doku, Infra, CI-Konfig | 8 Min | 40 s | Turborepo meldet nichts betroffen; Deploy-Skript überspringt alle Builds; nur CDK-Diff |
Eine Seite in apps/web |
8 Min | 2 Min 50 s | Ein CodeBuild-Job; geteilte Pakete aus dem Remote-Cache; Docker-Install-Schicht aus dem Registry-Cache |
Änderung an packages/ui |
8 Min | 3 Min 20 s | Zwei Jobs parallel, web und admin; api unberührt |
Änderung an packages/db |
8 Min | 3 Min 40 s | Drei Jobs parallel; Caches treffen weiter für Install-Schichten |
| Lockfile-Bump | 8 Min | 6 Min | Drei Jobs; Install-Schicht überall neu gebaut; Turborepo-Cache invalidiert; korrekt |
Der mittlere Commit ist die zweite Zeile. Acht Minuten auf unter drei, und weil die laufenden Jobs parallel sind, ist das Maximum jetzt der Lockfile-Fall mit sechs, nicht die alten acht bei jedem Commit.
Die Falle: eine betroffene App, die nicht gebaut wurde
Zwei Monate später lieferte ein Deploy admin ohne eine Änderung aus, die admin brauchte. Die Änderung lag in packages/types, einem generierten API-Client, und admin hängt von types ab, also hätte Turborepo sie markieren müssen. Tat es nicht, weil der generierte Client von einem Skript erzeugt wurde, das außerhalb des Turborepo-Task-Graphen lief und Dateien nach packages/types/generated/ schrieb, das in .gitignore stand. Turborepo hasht getrackte Inputs. Der generierte Output war nicht getrackt, also hatte sich types aus seiner Sicht nicht geändert.
Die Lösung war, die Generierung zu einem Turborepo-Task mit deklarierten Outputs zu machen, sodass der Hash die Inputs des Generators (die OpenAPI-Spezifikation) enthält und der Output wie jeder andere gecacht und wiederhergestellt wird. Die Lektion verallgemeinert sich: Alles, was Build-Inputs erzeugt, muss im Graphen sein, sonst lügt der Graph. Wir haben jedes Skript in package.json auf dasselbe Muster geprüft und noch eines gefunden.
Wann trotzdem alles neu gebaut wird
- Lockfile oder Root-Konfig geändert. Das macht Turborepo von selbst; nicht überschreiben.
- Ein Basis-Image-Bump in den Dockerfiles. Das Dockerfile ist ein Input für den Docker-Cache, nicht für Turborepo, also greppen wir den Diff nach
Dockerfileund erzwingen alle drei. - Ein Release-Tag. Jedes getaggte Release baut alle drei von Grund auf, ohne Caches, sodass das Artefakt einer Version aus dem Quellcode reproduzierbar ist und nicht aus dem, was an dem Tag zufällig im Cache lag. Das dauert acht Minuten, einmal die Woche, und es ist der Build, den wir wollen würden, falls wir je genau erklären müssten, was ausgeliefert wurde.
Die Kurzfassung
Das Werkzeug, das den Abhängigkeitsgraphen bereits kennt, entscheiden lassen, was gebaut wird. Den Task-Cache dorthin legen, wo jeder Builder ihn erreicht. Die teure Docker-Schicht in der Registry cachen. Jeden Generator im Graphen halten. Bei Lockfile-Änderungen und Release-Tags absichtlich alles neu bauen. Acht Minuten auf drei, für einen Tag Einrichtung und $3 im Monat.
Wenn Ihr Monorepo bei jedem Push alles baut, können wir das an einem Tag verdrahten; der Turborepo-Teil ist eine Stunde, der Docker-Cache die anderen sieben.