Skip to content
Imagini Docker pentru Node în 2026: multi-stage, distroless și imaginea de 1,1 GB care a ajuns la 140 MB
← ← Înapoi la Idei Development

Imagini Docker pentru Node în 2026: multi-stage, distroless și imaginea de 1,1 GB care a ajuns la 140 MB

Primul Dockerfile din majoritatea proiectelor Node are unsprezece linii, pleacă de la node:20, copiază repository-ul, rulează npm install și npm run build și livrează. Funcționează. Are și 1,1 GB, conține un compilator C, Python, git, toate dependențele de dev, directorul .git și sursa aplicației lângă output-ul de build. Asta am moștenit pe o platformă Next.js pe care o operăm pentru un client din SUA, și costa minute reale la fiecare deploy și bani reali în stocare ECR și în timpul de pull al App Runner.

Iată Dockerfile-ul pe care îl rulăm acum, la 140 MB, cu raționamentul fiecărei etape, cele două lucruri care s-au stricat când am trecut la distroless și nota sinceră despre ce renunți.

De unde veneau cei 1,1 GB

Layer Dimensiune De ce era acolo
Baza node:20 (Debian) 1.000 MB Debian complet cu toolchain de build, Python, git
node_modules inclusiv dependențe de dev 420 MB TypeScript, ESLint, Jest, Playwright, toate livrate în prod
Arborele sursă + .git 80 MB Copiat înainte să existe .dockerignore
Output-ul de build 60 MB Singura parte de care are nevoie producția

Patru din cele cinci rânduri sunt risipă în containerul care rulează. Doar imaginea de bază e de șapte ori cât aplicația. Și fiecare layer e re-împins în ECR și re-tras de fiecare instanță App Runner la scale-out, ceea ce adăuga douăzeci până la patruzeci de secunde la fiecare cold start.

Ce e în imagine înainte · 1,1 GB baza node:20 Debian · 1.000 MB dep. de dev · 420 MB după · 140 MB distroless 20 MB · dep. de prod 60 MB · output standalone 60 MB scară: 1 px ≈ 1,5 MB · aplicația în sine (verde) are aceeași dimensiune în ambele OS de bazănode_modulessursă + .gitoutput de buildruntime distroless Șapte optimi din vechea imagine nu rulau niciodată. Erau împinse, stocate și trase oricum.

Dockerfile-ul

# syntax=docker/dockerfile:1.7

# ---- 1. deps: instalare cu lockfile, cache pe store ----
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: compilare, output standalone ----
FROM deps AS build
COPY . .
ARG RELEASE_SHA
ENV NEXT_TELEMETRY_DISABLED=1 RELEASE_SHA=$RELEASE_SHA
RUN pnpm turbo build --filter=web
# curățare la dependențele de producție, pentru pachetele de care are nevoie output-ul standalone
RUN pnpm --filter=web deploy --prod /out

# ---- 3. runtime: nimic în afară de node și 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"]

Și .dockerignore-ul care face mai multă treabă decât orice linie din Dockerfile:

.git
node_modules
**/node_modules
**/.next
**/dist
**/coverage
.env*
*.md

Etapa 1, deps

Doar manifestele și lockfile-ul sunt copiate înainte de pnpm install, deci cheia de cache a acestui layer e lockfile-ul. Schimbi un fișier sursă și layer-ul de instalare e refolosit. Schimbi lockfile-ul și rulează din nou, ceea ce e corect. --mount=type=cache păstrează store-ul content-addressable al pnpm între build-uri pe același builder, deci chiar și o schimbare de lockfile lovește mai ales store-ul local în loc de registry. Pe CodeBuild cu cache-ul de layer-e din registry etapa asta e restaurată în vreo zece secunde.

node:22-bookworm-slim în loc de imaginea completă: 200 MB în loc de un gigabyte, are încă un shell și un package manager pentru puținele module native care trebuie compilate. Nu avem nevoie de Python sau gcc pentru aplicația asta; dacă tu ai, instalează-le doar în etapa asta.

Etapa 2, build

Copiază sursa peste etapa deps și rulează build-ul. output: 'standalone' din next.config.js e setarea-cheie: urmărește ce fișiere din node_modules importă efectiv serverul și copiază doar pe acelea în .next/standalone, cu un server.js minimal. Asta face ca 420 MB de node_modules să devină 60 MB.

pnpm deploy --prod e plasă de siguranță: pentru orice ratează tracer-ul standalone (am avut un require dinamic într-o bibliotecă de PDF), produce o instalare curată, doar de producție, a dependențelor aplicației.

Etapa 3, runtime

Distroless. Fără shell, fără package manager, fără apt, fără curl, fără sh. Doar binarul Node, bibliotecile lui de runtime, certificatele CA și fișierele pe care le copiem noi. Rulează implicit ca utilizatorul nonroot. Toată baza are 20 MB.

Asta e etapa care contează pentru scanerele de securitate: baza Debian căra vreo 180 de CVE-uri în pachete pe care aplicația nu le apela niciodată, majoritatea în unelte ca perl și git. Distroless cară o mână, toate în Node însuși, pe care le rezolvi ridicând tag-ul.

Cele două lucruri care s-au stricat

1. Health check-ul folosea curl. Vechea definiție de task avea un health check de container curl -f http://localhost:3000/api/health. Nu există curl în distroless. Nu există nici shell, deci și CMD-SHELL eșuează. Două soluții, și am folosit-o pe a doua: fie folosești health check-ul HTTP al platformei în loc de unul de container (App Runner și target group-urile ALB fac HTTP nativ, și ruta ar trebui oricum proiectată pentru asta), fie livrezi un mic script de health în Node și îl rulezi cu binarul Node:

HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD ["/nodejs/bin/node", "apps/web/healthcheck.js"]

2. Debugging-ul în container a încetat să funcționeze. Fără shell înseamnă fără docker exec -it app sh. Prima dată când cineva a avut nevoie să inspecteze un container care rula în staging, n-a putut. Răspunsul care a rămas: docker debug (Docker Desktop) sau un sidecar cu shell care împarte namespace-ul de procese, pentru ocazia rară. În practică, ce voiau oamenii de la exec era să citească un config sau să verifice o variabilă de mediu, și ambele primesc un răspuns mai bun de la modul deep al rutei de health și de la logurile structurate. N-am avut nevoie de un shell într-un container de producție de cinci luni.

1 · deps · node:22-slimcopiază doar lockfile + manifestepnpm install --frozen-lockfile--mount=type=cache pentru storecheie de cache: lockfile-ulrestaurat în ~10 s din cache-ul de registry 2 · buildcopiază sursanext build · output: standalonepnpm deploy --prod /outtracer-ul păstrează doar modulele importate420 MB → 60 MB de node_modules 3 · runtime · distrolessCOPY --from=build standaloneCOPY static + publicCMD ["apps/web/server.js"]fără shell · fără apt · nonroot · bază 20 MB~5 CVE-uri în loc de ~180 Doar ultima etapă se livrează. Primele două există ca să fie puse în cache.

Cifrele

Înainte După
Dimensiunea imaginii 1,1 GB 140 MB
Stocare ECR, 30 de tag-uri păstrate 33 GB, 3,30 $/lună 4 GB, 0,40 $/lună
Timp de push din CodeBuild 70 s 9 s
Pull rece App Runner la scale-out 25–40 s 4–6 s
CVE-uri raportate de scaner ~180 5
Timp de build, cache cald, o pagină schimbată 4 min 1 min 40 s

Cifra de pull rece e cea pe care au simțit-o utilizatorii. Scale-out-ul la un vârf de trafic însemna jumătate de minut înainte ca instanța nouă să poată servi; acum e sub zece secunde, adică diferența dintre un vârf absorbit și un vârf care produce o pagină de 503.

Ce renunți

  • Fără shell în producție. Acoperit mai sus. E o calitate până în ziua în care îl vrei, și atunci vrei un sidecar.
  • Modulele native trebuie precompilate. Orice are un pas node-gyp se compilează în etapa 1 pe glibc-ul Debian, pe care îl folosește și distroless, deci funcționează. Dacă mergi mai departe spre o bază Alpine sau musl, nu va funcționa. Am rămas pe distroless bazat pe Debian din motivul ăsta.
  • sharp și prietenii au nevoie de bibliotecile lor partajate. Tracer-ul standalone copiază binarul .node, dar nu și libvips. sharp și-l aduce pe al lui de la versiunea 0.33; versiunile mai vechi au nevoie de biblioteci copiate din etapa de build. Verifică prima cerere care atinge imagini după schimbare.
  • Utilizatorul nonroot nu poate lega porturi sub 1024. Folosește 3000 și lasă platforma să-l mapeze. Dacă ceva insistă pe 80, e treaba platformei, nu a containerului.

Versiunea scurtă

Bază slim pentru build, distroless pentru rulare, output: 'standalone' ca tracer-ul să facă curățarea, un .dockerignore care oprește sursa și .git să intre vreodată în contextul de build, și un layer de instalare cu cheia pe lockfile pe care cache-ul de registry îl restaurează. De la 1,1 GB la 140 MB, de la 40 de secunde de pull rece la 5, de la 180 de CVE-uri la 5.

Dacă imaginea ta de Node are peste 500 MB, o putem aduce sub 200 într-o zi, și ziua se plătește de obicei singură în prima lună de timp de pull.