Przejdź do treści
·23 min czytania

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.

Udostępnij:X / TwitterLinkedIn

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:

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:

dockerfile
# 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ścieRozmiar obrazu
node:20 + npm install1,1 GB
node:20-slim + npm install420 MB
node:20-alpine + npm ci280 MB
Multi-stage + alpine + tylko zależności produkcyjne150 MB
Multi-stage + alpine + przycięte zależności95 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:

dockerfile
FROM node:20-alpine AS builder
RUN apk add --no-cache python3 make g++
# ... rest of build

Te 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.

dockerfile
# ============================================
# 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:

javascript
// 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ść#

dockerfile
COPY . .
RUN npm ci

Za 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ść#

dockerfile
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:

dockerfile
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev

To 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ą:

bash
DOCKER_BUILDKIT=1 docker build -t myapp .

Albo dodaj do konfiguracji demona Docker:

json
{
  "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ę:

dockerfile
ARG CACHE_BUST=1
RUN npm ci

Buduj z unikalną wartością, żeby zbić cache:

bash
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:

dockerfile
# NEVER DO THIS
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456

Zmienne ś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:

dockerfile
# 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 build

Buduj z:

bash
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:

bash
docker run -d \
  -e DATABASE_URL="postgres://user:pass@db:5432/myapp" \
  -e API_KEY="sk-live-abc123" \
  myapp

Albo z plikiem env:

bash
docker run -d --env-file .env.production myapp

Są 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:

yaml
# 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: true

Docker montuje sekrety jako pliki w /run/secrets/<secret_name>. Twoja aplikacja czyta je z systemu plików:

javascript
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:

yaml
services:
  api:
    env_file:
      - .env.local

Health 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#

dockerfile
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:

javascript
// 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();
dockerfile
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:

javascript
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:

yaml
# 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
# 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:

yaml
# 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: true

Co 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:

bash
# 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:

yaml
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:

typescript
// 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:

dockerfile
# ============================================
# 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ścieRozmiar obrazu
node:20 + pełne node_modules + .next1,4 GB
node:20-alpine + pełne node_modules + .next600 MB
node:20-alpine + standalone output120 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:

typescript
// 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:

dockerfile
# In the runner stage, before switching to non-root user
RUN apk add --no-cache --virtual .sharp-deps vips-dev

Albo lepiej, zainstaluj go jako zależność produkcyjną i pozwól npm obsłużyć binary specyficzny dla platformy:

bash
npm install sharp

Obraz 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:

bash
# Install trivy
brew install aquasecurity/trivy/trivy  # macOS
# or
apt-get install trivy  # Debian/Ubuntu
 
# Scan your image
trivy image myapp:latest

Przykł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:

yaml
# .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: true

System 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:

bash
docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /app/.next/cache \
  myapp:latest

Mounty --tmpfs zapewniają zapisywalne katalogi tymczasowe, gdzie twoja aplikacja słusznie musi pisać (pliki tymczasowe, cache). Wszystko inne jest tylko do odczytu.

W docker-compose:

yaml
services:
  app:
    image: myapp:latest
    read_only: true
    tmpfs:
      - /tmp
      - /app/.next/cache

Porzuć 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:

bash
docker run --cap-drop=ALL myapp:latest

Jeś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:

yaml
services:
  app:
    image: myapp:latest
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

no-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:

dockerfile
FROM node:20-alpine@sha256:abcdef123456...

Zdobądź digest za pomocą:

bash
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpine

To 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:

yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: docker
    directory: "/"
    schedule:
      interval: weekly

Integracja CI/CD: Składamy wszystko razem#

Oto kompletny workflow GitHub Actions, który buduje, skanuje i pushuje obraz Docker:

yaml
# .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.sarif

Cache 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:

yaml
volumes:
  - .:/app
  - /app/node_modules  # preserves container's node_modules

Obsł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:

dockerfile
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:

dockerfile
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:

dockerfile
RUN apk add --no-cache tzdata
ENV TZ=America/New_York

Ale 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#

  • ARG jest dostępny tylko podczas builda. Nie utrzymuje się w finalnym obrazie (chyba że skopiujesz go do ENV).
  • ENV utrzymuje się w obrazie i jest dostępny w runtime.
dockerfile
# 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 myapp

Monitoring na produkcji#

Twoja konfiguracja Docker nie jest kompletna bez obserwowalności. Oto minimalny, ale efektywny stos monitoringu:

yaml
# 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:

javascript
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 cachingpackage.json skopiowany przed kodem źródłowym
  • Health check — Instrukcja HEALTHCHECK w Dockerfile
  • Obsługa sygnałówdumb-init lub --init do właściwej obsługi SIGTERM
  • Brak sekretów w obrazie — Brak ENV z 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-size ustawiony 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.

Powiązane wpisy