Wie man ein pnpm-Monorepo ohne 30-Minuten-Build nach Docker ausliefert
Die Docker-Build-Strategie für ein pnpm-Monorepo, die Cold-Builds unter fünf Minuten und inkrementelle Builds unter dreißig Sekunden hält. Das Workspace-aware-Dockerfile, die Lockfile-gepinned-Installation und die Deploy-Falle, die uns an Tag eins gebissen hat.
Das erste Deployment eines pnpm-Monorepos in die Produktion ist, wenn das Team lernt, was eine „kurze Dockerfile“ tatsächlich bedeutet. Eine naive Dockerfile baut bei jedem CI-Lauf das gesamte Lockfile neu. Wir haben erlebt, wie ein 10-Minuten-Default-Timeout von appleboy/ssh-action von einem Cold-Build aufgefressen wurde. Hier ist das Muster, das wir gefunden haben, und die Falle, die uns an Tag eins gebissen hat.
Das Workspace-aware-Dockerfile
Zwei nicht verhandelbare Designregeln für ein Monorepo-Dockerfile:
- Context ist der Monorepo-Root. Der Build-Context muss der Workspace-Root sein, nicht
apps/backendoderapps/web. Die Pakete referenzieren sich über den Workspace hinweg; die Installation muss das gesamte Lockfile und die Workspace-Deklarationen sehen. - Das Lockfile ist der Vertrag. Dasselbe
pnpm-lock.yaml, das die Entwicklerin lokal ausgeführt hat, ist das, das die CI installiert. Wir pinnen in Docker und in CI deshalb auffrozen-lockfile=true.
Das Backend-Dockerfile-Muster:
- Eine Builder-Stage, die das volle
node:20-alpine-Image nutzt und einmalpnpm install --frozen-lockfileausführt. - Eine Runner-Stage, die nur
apps/backend/dist,node_modulesfür die Laufzeit undapps/backend/package.jsonkopiert. Kein Quellcode, keine Test-Fixtures, kein Build-Tooling.
Das Web-Dockerfile-Muster (separate Concerns, andere Stage):
- Eine Builder-Stage, die Next.js mit
output: „standalone“kompiliert, und dieser Standalone-Output enthält bereits einnode_modulesnur mit den Laufzeit-Abhängigkeiten. - Eine Runner-Stage, die den Standalone-Output und die Public-Assets kopiert und dann
node server.jsausführt.
Die Deploy-Falle, die uns an Tag eins gebissen hat
Unser erster Deploy-Versuch war der klassische Fehler: Jede App hat ihr eigenes Dockerfile, jede Compose-Datei nutzt den Build-Context für die jeweilige App, und die Produktions-Compose-Datei zeigt auf einen anderen Projektnamen als die Dev-Datei. Zwei Prozesse landeten am selben Host-Port.
Der Fix:
- Ein einziger Build-Context pro App. Der Dockerfile-Build-Context ist immer der Monorepo-Root. Die Apps nutzen
apps/web/Dockerfile- undapps/backend/Dockerfile-Dateien, aber der Context ist. - Expliziter Compose-Projektname. Die Produktions-Compose nutzt
-p 2runbe-monorepo, um die Container von anderen Apps abzugrenzen, die auf demselben Host sein könnten. Ohne das können zwei Compose-Dateien lautlos denselben Port beanspruchen. - Das externe Compose-Netzwerk. Die Produktion betreibt Backend und Web auf einem geteilten
fenix-shared-services-Netzwerk neben anderen Apps, die dieselben MongoDB- und Redis-Instanzen teilen. Lokale Dev betreibt ihre eigene Compose imapp-Netzwerk mit dem Profil--profile full, das ihre eigene MongoDB und Redis bootet.
Lockfile-Drift-Erkennung
Zwei Wege, wie Lockfile-Drift Sie erwischt:
- Eine Entwicklerin aktualisiert
pnpm-lock.yaml, die CI läuft gegen das alte Dockerfile, das es von einem festen Ort kopiert, und die Installation scheitert, weil das Lockfile neuer ist als der Abhängigkeitsbaum. - Eine zweite Entwicklerin führt lokal
pnpm installohne Frozen-Lockfile aus und bekommt ein leicht anderes Lockfile als die CI.
Wir sperren beide Enden:
- Pre-Commit-Hook führt
pnpm install --frozen-lockfileaus und verweigert den Commit, wenn es scheitert. - CI nutzt ebenfalls Frozen-Lockfile. Wenn die Entwicklerin den Hook umgangen hat, fängt die CI es vor dem Deploy ab.
Cold- vs. inkrementelle Builds
Mit dem Workspace-aware-Dockerfile und gepinntem Lockfile liegen die Cold-Build-Zeiten bei:
- Backend. ~3 Minuten auf einem 4-Kern-Runner. Die meiste Zeit entfällt auf
pnpm installüber den vollen Workspace. - Web. ~4 Minuten. Next.js-Builds dominieren, sobald
pnpm installgecacht ist.
Die inkrementellen Build-Zeiten (Cache-Hit auf pnpm install):
- Backend. ~25 Sekunden für ein einzelnes Abhängigkeits-Upgrade, ~40 Sekunden für eine NestJS-Quelländerung.
- Web. ~30 Sekunden für eine einzelne Quelländerung, ~90 Sekunden für ein Abhängigkeits-Upgrade.
Die CI-Cache-Strategie, die das funktioniert machen hat:
- Cache
pnpm store, geschlüsselt auf den vollen Lockfile-Hash. Eine Änderung an einem Paket hebt den Cache-Key, aber unabhängige Änderungen treffen den Cache. - Cache
node_modules, pro App geschlüsselt auf Lockfile plus Workspace-package.json. Zwei Apps mit demselben Lockfile teilen sich node_modules, wenn der Abhängigkeitsbaum es zulässt.
Das CI-Command-Timeout anheben
Das Default-Command-Timeout von appleboy/ssh-action beträgt 10 Minuten. Ein Backend-Cold-Build mit frischem Lockfile dauert auf einem langsamen Runner ~10 Minuten – direkt an der Fehlergrenze. Heben Sie das Timeout für jeden Deploy-Schritt, der einen Docker-Build enthält, auf 30 Minuten. Das Deploy-Skript, das um 9 Minuten 50 Sekunden abbricht, ist ein Deploy-Skript, das Sie um 23 Uhr debuggen müssen.
