Das erste Dockerfile in den meisten Node-Projekten ist elf Zeilen lang, beginnt mit node:20, kopiert das Repository, führt npm install und npm run build aus und wird ausgeliefert. Es funktioniert. Es hat außerdem 1,1 GB, enthält einen C-Compiler, Python, git, jede Dev-Abhängigkeit, das .git-Verzeichnis und den Quellcode der App neben ihrem Build-Output. Das haben wir auf einer Next.js-Plattform geerbt, die wir für einen US-Kunden betreiben, und es kostete bei jedem Deploy echte Minuten und echtes Geld in ECR-Speicher und App-Runner-Pull-Zeit.
Hier ist das Dockerfile, das wir jetzt betreiben, mit 140 MB, mit der Begründung für jede Stufe, den zwei Dingen, die beim Wechsel auf Distroless kaputtgingen, und der ehrlichen Anmerkung dazu, was Sie aufgeben.
Woher die 1,1 GB kamen
| Layer | Größe | Warum er da war |
|---|---|---|
Basis node:20 (Debian) |
1.000 MB | Volles Debian mit Build-Toolchain, Python, git |
node_modules inkl. Dev-Abhängigkeiten |
420 MB | TypeScript, ESLint, Jest, Playwright, alles nach Prod ausgeliefert |
Quellbaum + .git |
80 MB | Kopiert, bevor es eine .dockerignore gab |
| Build-Output | 60 MB | Der einzige Teil, den die Produktion braucht |
Vier dieser fünf Zeilen sind Verschwendung im laufenden Container. Allein das Basis-Image ist siebenmal so groß wie die App. Und jeder Layer wird bei jedem Scale-out erneut in ECR gepusht und von jeder App-Runner-Instanz erneut gezogen, was jedem Kaltstart zwanzig bis vierzig Sekunden hinzufügte.
Das Dockerfile
# syntax=docker/dockerfile:1.7
# ---- 1. deps: mit dem Lockfile installieren, den Store cachen ----
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN corepack enable
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/
COPY packages/*/package.json packages/
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# ---- 2. build: kompilieren, den Standalone-Output erzeugen ----
FROM deps AS build
COPY . .
ARG RELEASE_SHA
ENV NEXT_TELEMETRY_DISABLED=1 RELEASE_SHA=$RELEASE_SHA
RUN pnpm turbo build --filter=web
# auf Produktionsabhängigkeiten reduzieren, für die Pakete, die der Standalone-Output braucht
RUN pnpm --filter=web deploy --prod /out
# ---- 3. runtime: nichts außer node und dem Output ----
FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
COPY --from=build --chown=nonroot:nonroot /app/apps/web/.next/standalone ./
COPY --from=build --chown=nonroot:nonroot /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build --chown=nonroot:nonroot /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["apps/web/server.js"]
Und die .dockerignore, die mehr Arbeit leistet als jede einzelne Zeile des Dockerfiles:
.git
node_modules
**/node_modules
**/.next
**/dist
**/coverage
.env*
*.md
Stufe 1, deps
Nur die Manifeste und das Lockfile werden vor pnpm install kopiert, also ist der Cache-Schlüssel dieses Layers das Lockfile. Ändern Sie eine Quelldatei, wird der Install-Layer wiederverwendet. Ändern Sie das Lockfile, läuft er erneut, was korrekt ist. Das --mount=type=cache behält pnpms inhaltsadressierten Store zwischen Builds auf demselben Builder, sodass selbst eine Lockfile-Änderung meist den lokalen Store statt der Registry trifft. Auf CodeBuild mit dem Registry-Layer-Cache wird diese Stufe in etwa zehn Sekunden wiederhergestellt.
node:22-bookworm-slim statt des vollen Images: 200 MB statt eines Gigabytes, hat noch eine Shell und einen Paketmanager für die wenigen nativen Module, die kompilieren müssen. Wir brauchen für diese App weder Python noch gcc; wenn Sie es tun, installieren Sie sie nur in dieser Stufe.
Stufe 2, build
Kopiert den Quellcode auf die deps-Stufe und führt den Build aus. output: 'standalone' in next.config.js ist die entscheidende Einstellung: Sie verfolgt, welche Dateien in node_modules der Server tatsächlich importiert, und kopiert nur diese nach .next/standalone, mit einer minimalen server.js. Das macht aus 420 MB node_modules 60 MB.
pnpm deploy --prod ist doppelter Boden: Für alles, was der Standalone-Tracer übersieht (wir hatten ein dynamisches require in einer PDF-Bibliothek), erzeugt es eine saubere, reine Produktionsinstallation der App-Abhängigkeiten.
Stufe 3, runtime
Distroless. Keine Shell, kein Paketmanager, kein apt, kein curl, kein sh. Nur das Node-Binary, seine Laufzeitbibliotheken, CA-Zertifikate und die Dateien, die wir hineinkopieren. Läuft standardmäßig als Benutzer nonroot. Die gesamte Basis hat 20 MB.
Das ist die Stufe, die für Sicherheitsscanner zählt: Die Debian-Basis schleppte rund 180 CVEs in Paketen mit, die die App nie aufrief, die meisten in Werkzeugen wie perl und git. Distroless trägt eine Handvoll, alle in Node selbst, die Sie durch Anheben des Tags beheben.
Die zwei Dinge, die kaputtgingen
1. Der Health Check benutzte curl. Die alte Task-Definition hatte einen Container-Health-Check curl -f http://localhost:3000/api/health. In Distroless gibt es kein curl. Es gibt auch keine Shell, also scheitert CMD-SHELL ebenfalls. Zwei Lösungen, und wir haben die zweite genommen: entweder den HTTP-Health-Check der Plattform statt eines Container-Checks verwenden (App Runner und ALB-Target-Groups können beide nativ HTTP, und die Route sollte ohnehin dafür entworfen sein), oder ein winziges Node-Health-Skript mitliefern und es mit dem Node-Binary ausführen:
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD ["/nodejs/bin/node", "apps/web/healthcheck.js"]
2. Debugging im Container funktionierte nicht mehr. Keine Shell heißt kein docker exec -it app sh. Beim ersten Mal, als jemand einen laufenden Container in Staging inspizieren musste, ging es nicht. Die Antwort, die sich durchgesetzt hat: docker debug (Docker Desktop) oder ein Sidecar mit Shell, der den Prozess-Namespace teilt, für den seltenen Fall. In der Praxis wollten die Leute von exec eine Konfiguration lesen oder eine Umgebungsvariable prüfen, und beides beantwortet der Deep-Modus der Health-Route und die strukturierten Logs besser. Wir haben seit fünf Monaten keine Shell in einem Produktionscontainer gebraucht.
Die Zahlen
| Vorher | Nachher | |
|---|---|---|
| Image-Größe | 1,1 GB | 140 MB |
| ECR-Speicher, 30 Tags behalten | 33 GB, 3,30 $/Monat | 4 GB, 0,40 $/Monat |
| Push-Zeit aus CodeBuild | 70 s | 9 s |
| Kalter App-Runner-Pull beim Scale-out | 25–40 s | 4–6 s |
| Vom Scanner gemeldete CVEs | ~180 | 5 |
| Build-Zeit, warmer Cache, eine Seite geändert | 4 Min | 1 Min 40 s |
Die Zahl für den kalten Pull ist die, die Nutzer gespürt haben. Scale-out bei einer Lastspitze bedeutete früher eine halbe Minute, bevor die neue Instanz bedienen konnte; jetzt sind es unter zehn Sekunden, und das ist der Unterschied zwischen einer abgefangenen Spitze und einer Spitze, die eine Seite voller 503 produziert.
Was Sie aufgeben
- Keine Shell in der Produktion. Oben behandelt. Es ist ein Vorteil bis zu dem Tag, an dem Sie eine wollen, und dann wollen Sie einen Sidecar.
- Native Module müssen vorgebaut sein. Alles mit einem
node-gyp-Schritt kompiliert in Stufe 1 gegen Debians glibc, das auch Distroless verwendet, also funktioniert es. Wenn Sie weiter zu einer Alpine- oder musl-Basis gehen, funktioniert es nicht. Aus diesem Grund sind wir bei Debian-basiertem Distroless geblieben. sharpund Verwandte brauchen ihre Shared Libraries. Der Standalone-Tracer kopiert das.node-Binary, aber nichtlibvips.sharpbringt seit Version 0.33 sein eigenes mit; ältere Versionen brauchen die Bibliotheken aus der Build-Stufe kopiert. Prüfen Sie nach dem Wechsel die erste Anfrage, die Bilder berührt.- Der Benutzer
nonrootkann keine Ports unter 1024 binden. Verwenden Sie 3000 und lassen Sie die Plattform mappen. Wenn etwas auf 80 besteht, ist das die Aufgabe der Plattform, nicht des Containers.
Die Kurzfassung
Schlanke Basis zum Bauen, Distroless zum Laufen, output: 'standalone', damit der Tracer das Ausdünnen übernimmt, eine .dockerignore, die Quellcode und .git nie in den Build-Kontext lässt, und ein Lockfile-geschlüsselter Install-Layer, den der Registry-Cache wiederherstellt. Von 1,1 GB auf 140 MB, von 40 Sekunden kaltem Pull auf 5, von 180 CVEs auf 5.
Wenn Ihr Node-Image über 500 MB hat, bringen wir es an einem Tag unter 200, und der Tag zahlt sich meist im ersten Monat Pull-Zeit aus.