Docker dla Node.js: Konfiguracja produkcyjna, o której nikt nie mówi
Multi-stage buildy, użytkownicy non-root, health checki, zarządzanie sekretami i optymalizacja rozmiaru obrazów. Wzorce Docker, których używam przy każdym produkcyjnym wdrożeniu Node.js.
Większość Dockerfile'ów Node.js na produkcji jest zła. Nie "trochę suboptymalna" zła. Mam na myśli działanie jako root, dostarczanie 600MB obrazów z wkompilowanymi devDependencies, brak health checków i sekrety zahardkodowane w zmiennych środowiskowych, które każdy z docker inspect może odczytać.
Wiem, bo sam pisałem takie Dockerfile'e. Przez lata. Działały, więc nigdy ich nie kwestionowałem. Aż pewnego dnia audyt bezpieczeństwa oznaczył nasz kontener działający jako PID 1 root z prawami zapisu do całego systemu plików i zdałem sobie sprawę, że "działa" i "gotowy na produkcję" to bardzo różne poprzeczki.
To jest konfiguracja Docker, której teraz używam dla każdego projektu Node.js. To nie teoria. Napędza usługi za tą stroną i kilkoma innymi, które utrzymuję. Każdy wzorzec tutaj istnieje, bo albo sam się sparzyłem na alternatywie, albo patrzyłem, jak ktoś inny się parzy.
Dlaczego twój obecny Dockerfile jest prawdopodobnie zły#
Pozwól, że zgadnę, jak wygląda twój Dockerfile:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]To "hello world" Dockerfile'ów. Działa. Ma też co najmniej pięć problemów, które bolą cię na produkcji.
Działanie jako Root#
Domyślnie obraz Docker node działa jako root. To oznacza, że proces twojej aplikacji ma uprawnienia roota wewnątrz kontenera. Jeśli ktoś wykorzysta lukę w twojej aplikacji — błąd path traversal, SSRF, zależność z backdoorem — ma dostęp roota do systemu plików kontenera, może modyfikować binaria, instalować pakiety i potencjalnie eskalować dalej w zależności od konfiguracji container runtime.
"Ale kontenery są izolowane!" Częściowo. Container escapes są realne. CVE-2024-21626, CVE-2019-5736 — to rzeczywiste wyłamania z kontenerów. Działanie jako non-root to środek defense-in-depth. Kosztuje nic i zamyka całą klasę ataków.
Instalacja devDependencies na produkcji#
npm install bez flag instaluje wszystko. Twoje test runnery, lintery, narzędzia budujące, checkery typów — wszystko siedzi w twoim produkcyjnym obrazie. To rozdmuchuje obraz o setki megabajtów i zwiększa twoją powierzchnię ataku. Każdy dodatkowy pakiet to kolejna potencjalna luka, którą Trivy lub Snyk oznaczą.
COPY wszystkiego#
COPY . . kopiuje cały katalog projektu do obrazu. To obejmuje .git (który może być ogromny), pliki .env (które zawierają sekrety), node_modules (które i tak zaraz przeinstalujesz), pliki testów, dokumentację, konfiguracje CI — wszystko.
Brak Health Checków#
Bez instrukcji HEALTHCHECK Docker nie ma pojęcia, czy twoja aplikacja faktycznie obsługuje ruch. Proces może działać, ale być zawieszony, bez pamięci lub utknął w nieskończonej pętli. Docker raportuje kontener jako "running", bo proces się nie zakończył. Twój load balancer dalej wysyła ruch do zombie kontenera.
Brak strategii Layer Caching#
Kopiowanie wszystkiego przed instalacją zależności oznacza, że zmiana jednej linii kodu źródłowego unieważnia cache npm install. Każdy build instaluje wszystkie zależności od zera. W projekcie z ciężkimi zależnościami to 2-3 minuty zmarnowanego czasu na build.
Naprawmy to wszystko.
Multi-Stage Buildy: Największa pojedyncza wygrana#
Multi-stage buildy to najważniejsza zmiana, jaką możesz wprowadzić w Dockerfile Node.js. Koncept jest prosty: użyj jednego etapu do budowania aplikacji, potem skopiuj tylko artefakty, których potrzebujesz, do czystego, minimalnego finalnego obrazu.
Oto różnica w praktyce:
# Single stage: ~600MB
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "server.js"]
# Multi-stage: ~150MB
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["node", "dist/server.js"]Etap builder ma wszystko: pełny Node.js, npm, narzędzia budujące, kod źródłowy, devDependencies. Etap runner ma tylko to, co potrzebne w runtime. Etap builder jest całkowicie odrzucany — nie trafia do finalnego obrazu.
Rzeczywiste porównania rozmiarów#
Zmierzyłem to na prawdziwym projekcie Express.js API z około 40 zależnościami:
| Podejście | Rozmiar obrazu |
|---|---|
node:20 + npm install | 1,1 GB |
node:20-slim + npm install | 420 MB |
node:20-alpine + npm ci | 280 MB |
| Multi-stage + alpine + tylko zależności produkcyjne | 150 MB |
| Multi-stage + alpine + przycięte zależności | 95 MB |
To 10-krotna redukcja od naiwnego podejścia. Mniejsze obrazy oznaczają szybsze pulle, szybsze wdrożenia i mniejszą powierzchnię ataku.
Dlaczego Alpine?#
Alpine Linux używa musl libc zamiast glibc i nie zawiera cache menedżera pakietów, dokumentacji ani większości narzędzi, które znajdziesz w standardowej dystrybucji Linux. Bazowy obraz node:20-alpine to około 50MB w porównaniu do 350MB dla node:20-slim i ponad 1GB dla pełnego node:20.
Kompromis polega na tym, że niektóre pakiety npm z natywnymi bindingami (jak bcrypt, sharp, canvas) muszą być skompilowane pod musl. W większości przypadków to po prostu działa — npm pobierze prawidłowy prebuilt binary. Jeśli napotkasz problemy, możesz zainstalować zależności budujące w etapie builder:
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... rest of buildTe narzędzia budujące istnieją tylko w etapie builder. Nie ma ich w twoim finalnym obrazie.
Kompletny produkcyjny Dockerfile#
Oto Dockerfile, którego używam jako punkt wyjścia dla każdego projektu Node.js. Każda linia jest celowa.
# ============================================
# Stage 1: Install dependencies
# ============================================
FROM node:20-alpine AS deps
# Security: create a working directory before anything else
WORKDIR /app
# Install dependencies based on lockfile
# Copy ONLY package files first — this is critical for layer caching
COPY package.json package-lock.json ./
# ci is better than install: it's faster, stricter, and reproducible
# --omit=dev excludes devDependencies from this stage
RUN npm ci --omit=dev
# ============================================
# Stage 2: Build the application
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files and install ALL dependencies (including dev)
COPY package.json package-lock.json ./
RUN npm ci
# NOW copy source code — changes here don't invalidate the npm ci cache
COPY . .
# Build the application (TypeScript compile, Next.js build, etc.)
RUN npm run build
# ============================================
# Stage 3: Production runner
# ============================================
FROM node:20-alpine AS runner
# Add labels for image metadata
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourorg/yourrepo"
# Security: install dumb-init for proper PID 1 signal handling
RUN apk add --no-cache dumb-init
# Security: set NODE_ENV before anything else
ENV NODE_ENV=production
# Security: use non-root user
# The node image already includes a 'node' user (uid 1000)
USER node
# Create app directory owned by node user
WORKDIR /app
# Copy production dependencies from deps stage
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# Copy built application from builder stage
COPY --from=deps --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/dist ./dist
# Expose the port (documentation only — doesn't publish it)
EXPOSE 3000
# Health check: curl isn't available in alpine, use node
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
# Use dumb-init as PID 1 to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Start the application
CMD ["node", "dist/server.js"]Pozwól, że wyjaśnię części, które nie są oczywiste.
Dlaczego trzy etapy zamiast dwóch?#
Etap deps instaluje tylko zależności produkcyjne. Etap builder instaluje wszystko (w tym devDependencies) i buduje aplikację. Etap runner kopiuje zależności produkcyjne z deps i zbudowany kod z builder.
Dlaczego nie zainstalować zależności produkcyjnych w etapie builder? Ponieważ etap builder ma wymieszane devDependencies. Musiałbyś uruchomić npm prune --production po buildzie, co jest wolniejsze i mniej niezawodne niż czysta instalacja zależności produkcyjnych.
Dlaczego dumb-init?#
Gdy uruchomisz node server.js w kontenerze, Node.js staje się PID 1. PID 1 ma specjalne zachowanie w Linuxie: nie otrzymuje domyślnych handlerów sygnałów. Jeśli wyślesz SIGTERM do kontenera (co robi docker stop), Node.js jako PID 1 domyślnie go nie obsłuży. Docker czeka 10 sekund, potem wysyła SIGKILL, który natychmiast kończy proces bez żadnego czyszczenia — brak graceful shutdown, brak zamykania połączeń z bazą danych, brak kończenia in-flight żądań.
dumb-init działa jako PID 1 i prawidłowo przekazuje sygnały do twojej aplikacji. Twój proces Node.js otrzymuje SIGTERM zgodnie z oczekiwaniami i może się zamknąć gracefully:
// server.js
const server = app.listen(3000);
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('HTTP server closed');
// Close database connections, flush logs, etc.
process.exit(0);
});
});Alternatywą jest flaga --init w docker run, ale wbudowanie tego w obraz oznacza, że działa niezależnie od tego, jak kontener jest uruchamiany.
Plik .dockerignore#
To jest tak samo ważne jak sam Dockerfile. Bez niego COPY . . wysyła wszystko do demona Docker:
# .dockerignore
node_modules
npm-debug.log*
.git
.gitignore
.env
.env.*
!.env.example
Dockerfile
docker-compose*.yml
.dockerignore
README.md
LICENSE
.github
.vscode
.idea
coverage
.nyc_output
*.test.ts
*.test.js
*.spec.ts
*.spec.js
__tests__
test
tests
docs
.husky
.eslintrc*
.prettierrc*
tsconfig.json
jest.config.*
vitest.config.*
Każdy plik w .dockerignore to plik, który nie zostanie wysłany do kontekstu builda, nie trafi do twojego obrazu i nie unieważni cache warstw po zmianie.
Layer Caching: Przestań czekać 3 minuty na build#
Docker buduje obrazy warstwami. Każda instrukcja tworzy warstwę. Jeśli warstwa się nie zmieniła, Docker używa zcachowanej wersji. Ale oto kluczowy szczegół: jeśli warstwa się zmieni, wszystkie kolejne warstwy są unieważniane.
Dlatego kolejność instrukcji ma ogromne znaczenie.
Zła kolejność#
COPY . .
RUN npm ciZa każdym razem, gdy zmienisz jakikolwiek plik — pojedynczą linię w pojedynczym pliku źródłowym — Docker widzi, że warstwa COPY . . się zmieniła. Unieważnia tę warstwę i wszystko po niej, w tym npm ci. Reinstalacja wszystkich zależności przy każdej zmianie kodu.
Właściwa kolejność#
COPY package.json package-lock.json ./
RUN npm ci
COPY . .Teraz npm ci uruchamia się tylko, gdy package.json lub package-lock.json się zmienia. Jeśli zmieniłeś tylko kod źródłowy, Docker ponownie używa zcachowanej warstwy npm ci. W projekcie z 500+ zależnościami to oszczędza 60-120 sekund na build.
Cache Mount dla npm#
Docker BuildKit wspiera cache mounty, które zachowują cache npm między buildami:
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=devTo zachowuje cache pobierania npm między buildami. Jeśli zależność została już pobrana w poprzednim buildzie, npm używa zcachowanej wersji zamiast pobierać ją ponownie. To jest szczególnie przydatne w CI, gdzie budujesz często.
Żeby użyć BuildKit, ustaw zmienną środowiskową:
DOCKER_BUILDKIT=1 docker build -t myapp .Albo dodaj do konfiguracji demona Docker:
{
"features": {
"buildkit": true
}
}Używanie ARG do Cache Busting#
Czasami musisz wymusić przebudowanie warstwy. Na przykład, jeśli pullisz tag latest z registry i chcesz mieć pewność, że dostajesz najnowszą wersję:
ARG CACHE_BUST=1
RUN npm ciBuduj z unikalną wartością, żeby zbić cache:
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .Używaj tego oszczędnie. Cały sens cachowania to szybkość — zbijaj cache tylko wtedy, gdy masz ku temu powód.
Zarządzanie sekretami: Przestań wkładać sekrety do Dockerfile#
To jeden z najczęstszych i najniebezpieczniejszych błędów. Widzę to ciągle:
# NEVER DO THIS
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456Zmienne środowiskowe ustawione za pomocą ENV w Dockerfile są wkompilowane w obraz. Każdy, kto pullnie obraz, może je zobaczyć za pomocą docker inspect lub docker history. Są też widoczne w każdej warstwie po ich ustawieniu. Nawet jeśli je potem unset, istnieją w historii warstw.
Trzy poziomy sekretów#
1. Sekrety build-time (Docker BuildKit)
Jeśli potrzebujesz sekretów podczas builda (jak token prywatnego rejestru npm), użyj flagi --secret BuildKit:
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Mount the secret at build time — it's never stored in the image
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
npm ci
COPY . .
RUN npm run buildBuduj z:
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .Plik .npmrc jest dostępny podczas komendy RUN, ale nigdy nie jest commitowany do żadnej warstwy obrazu. Nie pojawia się w docker history ani docker inspect.
2. Sekrety runtime przez zmienne środowiskowe
Dla sekretów, których twoja aplikacja potrzebuje w runtime, przekazuj je przy uruchamianiu kontenera:
docker run -d \
-e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
-e API_KEY="sk-live-abc123" \
myappAlbo z plikiem env:
docker run -d --env-file .env.production myappSą widoczne przez docker inspect na działającym kontenerze, ale nie są wkompilowane w obraz. Każdy, kto pullnie obraz, nie dostanie sekretów.
3. Docker secrets (Swarm / Kubernetes)
Dla właściwego zarządzania sekretami w środowiskach orkiestrowanych:
# docker-compose.yml (Swarm mode)
version: "3.8"
services:
api:
image: myapp:latest
secrets:
- db_password
- api_key
secrets:
db_password:
external: true
api_key:
external: trueDocker montuje sekrety jako pliki w /run/secrets/<secret_name>. Twoja aplikacja czyta je z systemu plików:
import { readFileSync } from "fs";
function getSecret(name) {
try {
return readFileSync(`/run/secrets/${name}`, "utf8").trim();
} catch {
// Fall back to environment variable for local development
return process.env[name.toUpperCase()];
}
}
const dbPassword = getSecret("db_password");To najbezpieczniejsze podejście, ponieważ sekrety nigdy nie pojawiają się w zmiennych środowiskowych, listach procesów ani wynikach inspekcji kontenera.
Pliki .env i Docker#
Nigdy nie umieszczaj plików .env w swoim obrazie Docker. Twój .dockerignore powinien je wykluczać (dlatego wcześniej wymieniliśmy .env i .env.*). Dla lokalnego developmentu z docker-compose, montuj je w runtime:
services:
api:
env_file:
- .env.localHealth Checki: Poinformuj Dockera, że twoja aplikacja naprawdę działa#
Health check mówi Dockerowi, czy twoja aplikacja funkcjonuje poprawnie. Bez niego Docker wie tylko, czy proces działa — nie czy faktycznie jest w stanie obsługiwać żądania.
Instrukcja HEALTHCHECK#
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"Rozbijmy parametry:
--interval=30s: Sprawdzaj co 30 sekund--timeout=10s: Jeśli sprawdzenie trwa dłużej niż 10 sekund, uznaj je za nieudane--start-period=40s: Daj aplikacji 40 sekund na start przed liczeniem awarii--retries=3: Oznacz jako unhealthy po 3 kolejnych awariach
Dlaczego nie curl?#
Alpine domyślnie nie zawiera curl. Mógłbyś go zainstalować (apk add --no-cache curl), ale to dodaje kolejny binary do twojego minimalnego obrazu. Użycie bezpośrednio Node.js oznacza zero dodatkowych zależności.
Dla jeszcze lżejszych health checków możesz użyć dedykowanego skryptu:
// healthcheck.js
const http = require("http");
const options = {
hostname: "localhost",
port: 3000,
path: "/health",
timeout: 5000,
};
const req = http.request(options, (res) => {
process.exit(res.statusCode === 200 ? 0 : 1);
});
req.on("error", () => process.exit(1));
req.on("timeout", () => {
req.destroy();
process.exit(1);
});
req.end();COPY --chown=node:node healthcheck.js ./
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD ["node", "healthcheck.js"]Endpoint Health#
Twoja aplikacja potrzebuje endpointu /health, do którego check trafia. Nie zwracaj po prostu 200 — faktycznie zweryfikuj, czy twoja aplikacja jest zdrowa:
app.get("/health", async (req, res) => {
const checks = {
uptime: process.uptime(),
timestamp: Date.now(),
status: "ok",
};
try {
// Check database connection
await db.query("SELECT 1");
checks.database = "connected";
} catch (err) {
checks.database = "disconnected";
checks.status = "degraded";
}
try {
// Check Redis connection
await redis.ping();
checks.redis = "connected";
} catch (err) {
checks.redis = "disconnected";
checks.status = "degraded";
}
const statusCode = checks.status === "ok" ? 200 : 503;
res.status(statusCode).json(checks);
});Status "degraded" z 503 mówi orkiestratorowi, żeby przestał kierować ruch do tej instancji, podczas gdy się odzyskuje, ale niekoniecznie wyzwala restart.
Dlaczego Health Checki mają znaczenie dla orkiestratorów#
Docker Swarm, Kubernetes, a nawet zwykły docker-compose z restart: always używają health checków do podejmowania decyzji:
- Load balancery przestają wysyłać ruch do niezdrowych kontenerów
- Rolling updates czekają, aż nowy kontener będzie zdrowy, zanim zatrzymają stary
- Orkiestratory mogą restartować kontenery, które staną się niezdrowe
- Pipeline'y wdrożeniowe mogą zweryfikować, że wdrożenie się powiodło
Bez health checków rolling deployment może zabić stary kontener, zanim nowy będzie gotowy, powodując downtime.
docker-compose do developmentu#
Twoje środowisko deweloperskie powinno być jak najbliższe produkcji, ale z wygodą hot reload, debuggerów i natychmiastowej informacji zwrotnej. Oto konfiguracja docker-compose, której używam do developmentu:
# docker-compose.dev.yml
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
args:
NODE_VERSION: "20"
ports:
- "3000:3000"
- "9229:9229" # Node.js debugger
volumes:
# Mount source code for hot reload
- .:/app
# Anonymous volume to preserve node_modules from the image
# This prevents the host's node_modules from overriding the container's
- /app/node_modules
environment:
- NODE_ENV=development
- DATABASE_URL=postgres://postgres:devpassword@db:5432/myapp_dev
- REDIS_URL=redis://redis:6379
env_file:
- .env.local
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
command: npm run dev
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_DB: myapp_dev
POSTGRES_USER: postgres
POSTGRES_PASSWORD: devpassword
volumes:
# Named volume for persistent data across container restarts
- pgdata:/var/lib/postgresql/data
# Initialization scripts
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redisdata:/data
command: redis-server --appendonly yes
# Optional: database admin UI
adminer:
image: adminer:latest
ports:
- "8080:8080"
depends_on:
- db
volumes:
pgdata:
redisdata:Kluczowe wzorce deweloperskie#
Volume mounty do hot reload: Volume mount .:/app mapuje twój lokalny kod źródłowy do kontenera. Gdy zapiszesz plik, zmiana jest natychmiast widoczna wewnątrz kontenera. W połączeniu z dev serverem obserwującym zmiany (jak nodemon lub tsx --watch) dostajesz natychmiastową informację zwrotną.
Sztuczka z node_modules: Anonimowy volume - /app/node_modules zapewnia, że kontener używa swoich własnych node_modules (zainstalowanych podczas builda obrazu) zamiast node_modules twojego hosta. To kluczowe, ponieważ natywne moduły skompilowane na macOS nie będą działać wewnątrz kontenera Linux.
Zależności serwisów: depends_on z condition: service_healthy zapewnia, że baza danych jest faktycznie gotowa, zanim twoja aplikacja spróbuje się połączyć. Bez warunku health check depends_on czeka tylko na start kontenera — nie na gotowość serwisu wewnątrz niego.
Named volumes: pgdata i redisdata utrzymują się między restartami kontenerów. Bez named volumes traciłbyś swoją bazę danych za każdym razem, gdy uruchomisz docker-compose down.
Deweloperski Dockerfile#
Twój deweloperski Dockerfile jest prostszy niż produkcyjny:
# Dockerfile.dev
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
WORKDIR /app
# Install all dependencies (including devDependencies)
COPY package*.json ./
RUN npm ci
# Source code is mounted via volume, not copied
# But we still need it for the initial build
COPY . .
EXPOSE 3000 9229
CMD ["npm", "run", "dev"]Bez multi-stage builda, bez optymalizacji produkcyjnych. Celem jest szybka iteracja, nie małe obrazy.
Produkcyjny Docker Compose#
Produkcyjny docker-compose to inna bestia. Oto, czego używam:
# docker-compose.prod.yml
services:
app:
image: ghcr.io/yourorg/myapp:${TAG:-latest}
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
env_file:
- .env.production
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
cpus: "0.25"
memory: 128M
replicas: 2
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
start_period: 40s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
- internal
- web
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: postgres:16-alpine
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
deploy:
resources:
limits:
cpus: "1.0"
memory: 1G
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
redis-server
--appendonly yes
--maxmemory 256mb
--maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
networks:
- internal
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "3"
volumes:
pgdata:
driver: local
redisdata:
driver: local
networks:
internal:
driver: bridge
web:
external: trueCo się różni od developmentu#
Polityka restartu: unless-stopped automatycznie restartuje kontener, jeśli się crashnie, chyba że go wyraźnie zatrzymałeś. To obsługuje scenariusz "crash o 3 w nocy". Alternatywa always restartowałaby też kontenery, które celowo zatrzymałeś, co zazwyczaj nie jest tym, czego chcesz.
Limity zasobów: Bez limitów wyciek pamięci w twojej aplikacji Node.js zużyje cały dostępny RAM na hoście, potencjalnie zabijając inne kontenery lub sam host. Ustaw limity na podstawie faktycznego użycia twojej aplikacji plus trochę zapasu:
# Monitor actual usage to set appropriate limits
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"Konfiguracja logowania: Bez max-size i max-file logi Dockera rosną bez ograniczeń. Widziałem produkcyjne serwery, którym skończyło się miejsce na dysku z powodu logów Dockera. json-file z rotacją to najprostsze rozwiązanie. Dla zcentralizowanego logowania zamień na driver fluentd lub gelf:
logging:
driver: "fluentd"
options:
fluentd-address: "localhost:24224"
tag: "myapp.{{.Name}}"Izolacja sieciowa: Sieć internal jest dostępna tylko dla serwisów w tym stosie compose. Baza danych i Redis nie są wyeksponowane na host ani inne kontenery. Tylko serwis app jest połączony z siecią web, której twój reverse proxy (Nginx, Traefik) używa do routowania ruchu.
Brak mapowania portów dla baz danych: Zauważ, że db i redis nie mają ports w konfiguracji produkcyjnej. Są dostępne tylko przez wewnętrzną sieć Docker. W developmencie eksponujemy je, żebyśmy mogli używać lokalnych narzędzi (pgAdmin, Redis Insight). Na produkcji nie ma powodu, żeby były dostępne spoza sieci Docker.
Specyficzne dla Next.js: Standalone Output#
Next.js ma wbudowaną optymalizację Docker, o której wielu ludzi nie wie: tryb standalone output. Śledzi importy twojej aplikacji i kopiuje tylko pliki potrzebne do uruchomienia — bez konieczności node_modules (zależności są bundlowane).
Włącz to w next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;To dramatycznie zmienia wynik builda. Zamiast potrzebować całego katalogu node_modules, Next.js produkuje samowystarczalny server.js w .next/standalone/, który zawiera tylko zależności, których faktycznie używa.
Produkcyjny Dockerfile Next.js#
Oto Dockerfile, którego używam dla projektów Next.js, bazujący na oficjalnym przykładzie Vercel, ale z utwardzonym bezpieczeństwem:
# ============================================
# Stage 1: Install dependencies
# ============================================
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ============================================
# Stage 2: Build the application
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Disable Next.js telemetry during build
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ============================================
# Stage 3: Production runner
# ============================================
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy public assets
COPY --from=builder /app/public ./public
# Set up the standalone output directory
# Automatically leverages output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Copy the standalone server and static files
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]Porównanie rozmiarów dla Next.js#
| Podejście | Rozmiar obrazu |
|---|---|
node:20 + pełne node_modules + .next | 1,4 GB |
node:20-alpine + pełne node_modules + .next | 600 MB |
node:20-alpine + standalone output | 120 MB |
Standalone output jest transformacyjny. Obraz 1,4 GB staje się 120 MB. Deploye, które zajmowały 90 sekund na pull, teraz zajmują 10 sekund.
Obsługa plików statycznych#
Tryb standalone Next.js nie zawiera folderu public ani zasobów statycznych z .next/static. Musisz je skopiować jawnie (co robimy w powyższym Dockerfile). Na produkcji zazwyczaj chcesz CDN przed nimi:
// next.config.ts
const nextConfig: NextConfig = {
output: "standalone",
assetPrefix: process.env.CDN_URL || undefined,
};Jeśli nie używasz CDN, Next.js serwuje pliki statyczne bezpośrednio. Standalone server radzi sobie z tym dobrze — musisz tylko upewnić się, że pliki są we właściwym miejscu (co nasz Dockerfile zapewnia).
Sharp do optymalizacji obrazów#
Next.js używa sharp do optymalizacji obrazów. W produkcyjnym obrazie opartym na Alpine musisz upewnić się, że właściwy binary jest dostępny:
# In the runner stage, before switching to non-root user
RUN apk add --no-cache --virtual .sharp-deps vips-devAlbo lepiej, zainstaluj go jako zależność produkcyjną i pozwól npm obsłużyć binary specyficzny dla platformy:
npm install sharpObraz node:20-alpine działa z prebuilt binarym linux-x64-musl sharp. W większości przypadków nie potrzeba specjalnej konfiguracji.
Skanowanie obrazów i bezpieczeństwo#
Budowanie małego obrazu z użytkownikiem non-root to dobry początek, ale nie wystarcza dla poważnych obciążeń produkcyjnych. Oto jak pójść dalej.
Trivy: Skanuj swoje obrazy#
Trivy to kompleksowy skaner luk w obrazach kontenerów. Uruchamiaj go w swoim pipeline CI:
# Install trivy
brew install aquasecurity/trivy/trivy # macOS
# or
apt-get install trivy # Debian/Ubuntu
# Scan your image
trivy image myapp:latestPrzykładowy wynik:
myapp:latest (alpine 3.19.1)
=============================
Total: 0 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
Node.js (node_modules/package-lock.json)
=========================================
Total: 2 (UNKNOWN: 0, LOW: 0, MEDIUM: 1, HIGH: 1, CRITICAL: 0)
┌──────────────┬────────────────┬──────────┬────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Fixed Version │
├──────────────┼────────────────┼──────────┼────────┼───────────────┤
│ semver │ CVE-2022-25883 │ HIGH │ fixed │ 7.5.4 │
│ word-wrap │ CVE-2023-26115 │ MEDIUM │ fixed │ 1.2.4 │
└──────────────┴────────────────┴──────────┴────────┴───────────────┘
Zintegruj go w CI, żeby failować buildy przy krytycznych lukach:
# .github/workflows/docker.yml
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: trueSystem plików tylko do odczytu#
Możesz uruchamiać kontenery z systemem plików root tylko do odczytu. To zapobiega modyfikowaniu binariów, instalowaniu narzędzi lub pisaniu złośliwych skryptów przez atakującego:
docker run --read-only \
--tmpfs /tmp \
--tmpfs /app/.next/cache \
myapp:latestMounty --tmpfs zapewniają zapisywalne katalogi tymczasowe, gdzie twoja aplikacja słusznie musi pisać (pliki tymczasowe, cache). Wszystko inne jest tylko do odczytu.
W docker-compose:
services:
app:
image: myapp:latest
read_only: true
tmpfs:
- /tmp
- /app/.next/cachePorzuć wszystkie capabilities#
Linux capabilities to szczegółowe uprawnienia, które zastępują model wszystko-albo-nic roota. Domyślnie kontenery Docker dostają podzbiór capabilities. Możesz porzucić wszystkie:
docker run --cap-drop=ALL myapp:latestJeśli twoja aplikacja musi bindować port poniżej 1024, potrzebowałbyś NET_BIND_SERVICE. Ale ponieważ używamy portu 3000 z użytkownikiem non-root, nie potrzebujemy żadnych capabilities:
services:
app:
image: myapp:latest
cap_drop:
- ALL
security_opt:
- no-new-privileges:trueno-new-privileges zapobiega zdobywaniu dodatkowych uprawnień przez proces przez binaria setuid/setgid. To środek defense-in-depth, który kosztuje nic.
Przypnij digest bazowego obrazu#
Zamiast używać node:20-alpine (który jest ruchomym celem), przypnij do konkretnego digest:
FROM node:20-alpine@sha256:abcdef123456...Zdobądź digest za pomocą:
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpineTo zapewnia 100% reprodukowalność buildów. Kompromis jest taki, że nie dostajesz automatycznie poprawek bezpieczeństwa do bazowego obrazu. Użyj Dependabota lub Renovate do automatyzacji aktualizacji digest:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: docker
directory: "/"
schedule:
interval: weeklyIntegracja CI/CD: Składamy wszystko razem#
Oto kompletny workflow GitHub Actions, który buduje, skanuje i pushuje obraz Docker:
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true
- name: Upload Trivy results
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarifCache BuildKit w CI#
Linie cache-from: type=gha i cache-to: type=gha,mode=max używają cache GitHub Actions jako cache warstw Docker. To oznacza, że twoje buildy CI korzystają z cachowania warstw między uruchomieniami. Pierwszy build zajmuje 5 minut; kolejne buildy z tylko zmianami kodu zajmują 30 sekund.
Typowe pułapki i jak ich unikać#
Konflikt node_modules wewnątrz obrazu vs na hoście#
Jeśli montujesz katalog projektu do kontenera (-v .:/app), node_modules hosta nadpisuje te kontenera. Natywne moduły skompilowane na macOS nie będą działać w Linuxie. Zawsze używaj sztuczki z anonimowym volume:
volumes:
- .:/app
- /app/node_modules # preserves container's node_modulesObsługa SIGTERM w projektach TypeScript#
Jeśli uruchamiasz TypeScript z tsx lub ts-node w developmencie, obsługa sygnałów działa normalnie. Ale na produkcji, jeśli używasz skompilowanego JavaScript z node, upewnij się, że skompilowany wynik zachowuje handlery sygnałów. Niektóre narzędzia budujące optymalizują "nieużywany" kod.
Limity pamięci i Node.js#
Node.js automatycznie nie respektuje limitów pamięci Dockera. Jeśli twój kontener ma limit 512MB pamięci, Node.js nadal będzie próbował użyć domyślnego rozmiaru heap (około 1,5 GB na systemach 64-bitowych). Ustaw max old space size:
CMD ["node", "--max-old-space-size=384", "dist/server.js"]Zostaw około 25% zapasu między limitem heap Node.js a limitem pamięci kontenera na pamięć poza heap (bufory, natywny kod itp.).
Albo użyj flagi automatycznego wykrywania:
ENV NODE_OPTIONS="--max-old-space-size=384"Problemy ze strefą czasową#
Alpine domyślnie używa UTC. Jeśli twoja aplikacja zależy od konkretnej strefy czasowej:
RUN apk add --no-cache tzdata
ENV TZ=America/New_YorkAle lepiej: pisz kod niezależny od strefy czasowej. Przechowuj wszystko w UTC. Konwertuj na czas lokalny tylko w warstwie prezentacji.
Build Arguments vs zmienne środowiskowe#
ARGjest dostępny tylko podczas builda. Nie utrzymuje się w finalnym obrazie (chyba że skopiujesz go doENV).ENVutrzymuje się w obrazie i jest dostępny w runtime.
# Build-time configuration
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
# Runtime configuration
ENV PORT=3000
# WRONG: This makes the secret visible in the image
ARG API_KEY
ENV API_KEY=${API_KEY}
# RIGHT: Pass secrets at runtime
# docker run -e API_KEY=secret myappMonitoring na produkcji#
Twoja konfiguracja Docker nie jest kompletna bez obserwowalności. Oto minimalny, ale efektywny stos monitoringu:
# docker-compose.monitoring.yml
services:
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
ports:
- "9090:9090"
networks:
- internal
grafana:
image: grafana/grafana:latest
volumes:
- grafana_data:/var/lib/grafana
ports:
- "3001:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
networks:
- internal
volumes:
prometheus_data:
grafana_data:Eksponuj metryki ze swojej aplikacji Node.js za pomocą prom-client:
import { collectDefaultMetrics, Registry, Histogram } from "prom-client";
const register = new Registry();
collectDefaultMetrics({ register });
const httpRequestDuration = new Histogram({
name: "http_request_duration_seconds",
help: "Duration of HTTP requests in seconds",
labelNames: ["method", "route", "status_code"],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
registers: [register],
});
// Middleware
app.use((req, res, next) => {
const end = httpRequestDuration.startTimer();
res.on("finish", () => {
end({ method: req.method, route: req.route?.path || req.path, status_code: res.statusCode });
});
next();
});
// Metrics endpoint
app.get("/metrics", async (req, res) => {
res.set("Content-Type", register.contentType);
res.end(await register.metrics());
});Checklista#
Zanim wyślesz skonteneryzowaną aplikację Node.js na produkcję, zweryfikuj:
- Użytkownik non-root — Kontener działa jako użytkownik non-root
- Multi-stage build — devDependencies i narzędzia budujące nie są w finalnym obrazie
- Baza Alpine — Używasz minimalnego bazowego obrazu
- .dockerignore —
.git,.env,node_modules, testy wykluczone - Layer caching —
package.jsonskopiowany przed kodem źródłowym - Health check — Instrukcja HEALTHCHECK w Dockerfile
- Obsługa sygnałów —
dumb-initlub--initdo właściwej obsługi SIGTERM - Brak sekretów w obrazie — Brak
ENVz wrażliwymi wartościami w Dockerfile - Limity zasobów — Limity pamięci i CPU ustawione w compose/orkiestratorze
- Rotacja logów — Driver logowania skonfigurowany z max size
- Skanowanie obrazów — Trivy lub odpowiednik w pipeline CI
- Przypięte wersje — Wersje bazowego obrazu i zależności przypięte
- Limity pamięci —
--max-old-space-sizeustawiony dla heap Node.js
Większość z nich to jednorazowa konfiguracja. Zrób to raz, stwórz szablon i każdy nowy projekt zaczyna z kontenerem gotowym na produkcję od pierwszego dnia.
Docker nie jest skomplikowany. Ale przepaść między "działającym" Dockerfile a gotowym na produkcję jest szersza, niż większość ludzi myśli. Wzorce w tym poradniku zamykają tę przepaść. Używaj ich, adaptuj je i przestań wdrażać kontenery root z obrazami 1GB i bez health checków. Twoja przyszła wersja — ta, do której dzwonią o 3 w nocy — będzie ci wdzięczna.