Přeskočit na obsah
·23 min čtení

Docker pro Node.js: Produkční setup, o kterém se nemluví

Multi-stage buildy, non-root uživatelé, health checky, správa secrets a optimalizace velikosti image. Docker patterny, které používám pro každý produkční Node.js deployment.

Sdílet:X / TwitterLinkedIn

Většina Node.js Dockerfilů v produkci je špatná. Ne "mírně suboptimální" špatná. Mám na mysli běh jako root, odesílání 600MB imagí s devDependencies zapečenými uvnitř, žádné health checky a secrets natvrdo zakódované v proměnných prostředí, které si kdokoliv s docker inspect může přečíst.

Vím to, protože jsem takhle ty Dockerfily psal. Celé roky. Fungovaly, takže jsem je nikdy nezpochybňoval. Pak jednoho dne bezpečnostní audit označil náš kontejner běžící jako PID 1 root s přístupem pro zápis do celého filesystému a já si uvědomil, že "funguje" a "připravené na produkci" jsou velmi odlišné laťky.

Toto je Docker setup, který nyní používám pro každý Node.js projekt. Není to teorie. Běží na něm služby za tímto webem a několika dalšími, které spravuji. Každý pattern zde existuje, protože jsem se buď spálil na alternativě, nebo jsem sledoval, jak se na ní spálil někdo jiný.

Proč je váš současný Dockerfile pravděpodobně špatně#

Hádám, jak vypadá váš Dockerfile:

dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]

Tohle je "hello world" Dockerfilů. Funguje to. Ale má to minimálně pět problémů, které vás v produkci budou bolet.

Běh jako Root#

Ve výchozím nastavení Docker image node běží jako root. To znamená, že proces vaší aplikace má uvnitř kontejneru root oprávnění. Pokud někdo zneužije zranitelnost ve vaší aplikaci — path traversal bug, SSRF, závislost se zadními vrátky — má root přístup k filesystému kontejneru, může měnit binárky, instalovat balíčky a potenciálně eskalovat dále v závislosti na konfiguraci runtime kontejneru.

"Ale kontejnery jsou izolované!" Částečně. Úniky z kontejnerů jsou reálné. CVE-2024-21626, CVE-2019-5736 — to jsou reálné container breakouty. Běh jako non-root je opatření defense-in-depth. Nic to nestojí a uzavře to celou třídu útoků.

Instalace devDependencies v produkci#

npm install bez příznaků nainstaluje všechno. Vaše test runnery, lintery, build nástroje, type checkery — to vše sedí ve vašem produkčním image. Tím se váš image nafukuje o stovky megabajtů a zvětšuje se váš útočný povrch. Každý další balíček je další potenciální zranitelnost, kterou Trivy nebo Snyk označí.

COPY všeho#

COPY . . zkopíruje celý adresář projektu do image. To zahrnuje .git (který může být obrovský), .env soubory (které obsahují secrets), node_modules (které stejně budete přeinstalovávat), testovací soubory, dokumentaci, CI konfigurace — úplně všechno.

Žádné Health Checky#

Bez instrukce HEALTHCHECK nemá Docker žádnou představu, zda vaše aplikace skutečně obsluhuje provoz. Proces může běžet, ale být v deadlocku, bez paměti nebo zaseknutý v nekonečné smyčce. Docker bude hlásit kontejner jako "running", protože proces neskončil. Váš load balancer pokračuje v odesílání provozu na zombie kontejner.

Žádná strategie Layer Cachingu#

Kopírování všeho před instalací závislostí znamená, že změna jediného řádku zdrojového kódu invaliduje npm install cache. Každý build přeinstaluje všechny závislosti od nuly. Na projektu s těžkými závislostmi to jsou 2-3 minuty zbytečně stráveného času na build.

Pojďme to všechno opravit.

Multi-Stage Buildy: Jediná největší výhra#

Multi-stage buildy jsou nejdůležitější změna, kterou můžete v Node.js Dockerfilu udělat. Koncept je jednoduchý: použijte jednu stage k sestavení vaší aplikace, pak zkopírujte pouze artefakty, které potřebujete, do čistého, minimálního finálního image.

Zde je rozdíl v praxi:

dockerfile
# Jedna 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"]

Builder stage má všechno: plný Node.js, npm, build nástroje, zdrojový kód, devDependencies. Runner stage má pouze to, co je potřeba za běhu. Builder stage je kompletně zahozena — neskončí ve finálním image.

Reálné porovnání velikostí#

Toto jsem měřil na skutečném Express.js API projektu s přibližně 40 závislostmi:

PřístupVelikost Image
node:20 + npm install1,1 GB
node:20-slim + npm install420 MB
node:20-alpine + npm ci280 MB
Multi-stage + alpine + pouze produkční deps150 MB
Multi-stage + alpine + prořezané deps95 MB

To je 10x zmenšení oproti naivnímu přístupu. Menší image znamenají rychlejší pull, rychlejší deploymenty a menší útočný povrch.

Proč Alpine?#

Alpine Linux používá musl libc místo glibc a neobsahuje cache správce balíčků, dokumentaci ani většinu utilit, které byste našli ve standardní Linux distribuci. Základní node:20-alpine image má asi 50MB ve srovnání s 350MB pro node:20-slim a přes 1GB pro plný node:20.

Kompromis spočívá v tom, že některé npm balíčky s nativními bindingy (jako bcrypt, sharp, canvas) musí být zkompilovány proti musl. Ve většině případů to prostě funguje — npm stáhne správnou předkompilovanou binárku. Pokud narazíte na problémy, můžete nainstalovat build závislosti v builder stage:

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

Tyto build nástroje existují pouze v builder stage. Nejsou ve vašem finálním image.

Kompletní produkční Dockerfile#

Zde je Dockerfile, který používám jako výchozí bod pro každý Node.js projekt. Každý řádek je záměrný.

dockerfile
# ============================================
# Stage 1: Instalace závislostí
# ============================================
FROM node:20-alpine AS deps
 
# Bezpečnost: vytvoření pracovního adresáře před čímkoliv jiným
WORKDIR /app
 
# Instalace závislostí na základě lockfile
# Nejprve zkopírujte POUZE package soubory — to je kritické pro layer caching
COPY package.json package-lock.json ./
 
# ci je lepší než install: je rychlejší, přísnější a reprodukovatelný
# --omit=dev vylučuje devDependencies z této stage
RUN npm ci --omit=dev
 
# ============================================
# Stage 2: Sestavení aplikace
# ============================================
FROM node:20-alpine AS builder
 
WORKDIR /app
 
# Zkopírujte package soubory a nainstalujte VŠECHNY závislosti (včetně dev)
COPY package.json package-lock.json ./
RUN npm ci
 
# TEĎ zkopírujte zdrojový kód — změny zde neinvalidují npm ci cache
COPY . .
 
# Sestavte aplikaci (TypeScript kompilace, Next.js build, atd.)
RUN npm run build
 
# ============================================
# Stage 3: Produkční runner
# ============================================
FROM node:20-alpine AS runner
 
# Přidejte labely pro metadata image
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourorg/yourrepo"
 
# Bezpečnost: nainstalujte dumb-init pro správné zpracování signálů PID 1
RUN apk add --no-cache dumb-init
 
# Bezpečnost: nastavte NODE_ENV před čímkoliv jiným
ENV NODE_ENV=production
 
# Bezpečnost: použijte non-root uživatele
# Node image již obsahuje uživatele 'node' (uid 1000)
USER node
 
# Vytvořte adresář aplikace vlastněný uživatelem node
WORKDIR /app
 
# Zkopírujte produkční závislosti z deps stage
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
 
# Zkopírujte sestavenou aplikaci z builder stage
COPY --from=deps --chown=node:node /app/package.json ./
COPY --from=builder --chown=node:node /app/dist ./dist
 
# Exponujte port (pouze dokumentace — nepublikuje ho)
EXPOSE 3000
 
# Health check: curl není v alpine k dispozici, použijte 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) })"
 
# Použijte dumb-init jako PID 1 pro správné zpracování signálů
ENTRYPOINT ["dumb-init", "--"]
 
# Spusťte aplikaci
CMD ["node", "dist/server.js"]

Dovolte mi vysvětlit části, které nejsou zřejmé.

Proč tři stage místo dvou?#

deps stage instaluje pouze produkční závislosti. builder stage instaluje všechno (včetně devDependencies) a sestaví aplikaci. runner stage kopíruje produkční deps z deps a sestavený kód z builder.

Proč neinstalovat produkční deps v builder stage? Protože builder stage má devDependencies smíchané uvnitř. Museli byste spustit npm prune --production po buildu, což je pomalejší a méně spolehlivé než čistá instalace produkčních závislostí.

Proč dumb-init?#

Když spustíte node server.js v kontejneru, Node.js se stane PID 1. PID 1 má v Linuxu speciální chování: nedostává výchozí signal handlery. Pokud pošlete SIGTERM kontejneru (což je to, co dělá docker stop), Node.js jako PID 1 ho ve výchozím nastavení nezpracuje. Docker čeká 10 sekund, pak pošle SIGKILL, který okamžitě ukončí proces bez jakéhokoliv úklidu — žádné graceful shutdown, žádné uzavření databázových spojení, žádné dokončení rozpracovaných požadavků.

dumb-init funguje jako PID 1 a správně přeposílá signály vaší aplikaci. Váš Node.js proces obdrží SIGTERM podle očekávání a může se korektně vypnout:

javascript
// server.js
const server = app.listen(3000);
 
process.on('SIGTERM', () => {
  console.log('SIGTERM přijat, korektní vypínání');
  server.close(() => {
    console.log('HTTP server uzavřen');
    // Uzavřete databázová spojení, vyprázdněte logy, atd.
    process.exit(0);
  });
});

Alternativou je příznak --init v docker run, ale zapečení do image znamená, že to funguje bez ohledu na to, jak je kontejner spuštěn.

Soubor .dockerignore#

Ten je stejně důležitý jako samotný Dockerfile. Bez něj COPY . . odešle všechno Docker démonu:

# .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ždý soubor v .dockerignore je soubor, který nebude odeslán do build kontextu, neskončí ve vašem image a neinvaliduje váš layer cache, když se změní.

Layer Caching: Přestaňte čekat 3 minuty na každý build#

Docker sestavuje image po vrstvách. Každá instrukce vytvoří vrstvu. Pokud se vrstva nezměnila, Docker použije kešovanou verzi. Ale zde je kritický detail: pokud se vrstva změní, všechny následující vrstvy jsou invalidovány.

Proto záleží na pořadí instrukcí enormně.

Špatné pořadí#

dockerfile
COPY . .
RUN npm ci

Pokaždé, když změníte jakýkoliv soubor — jediný řádek v jediném zdrojovém souboru — Docker vidí, že se vrstva COPY . . změnila. Invaliduje tuto vrstvu a všechno po ní, včetně npm ci. Přeinstalujete všechny závislosti při každé změně kódu.

Správné pořadí#

dockerfile
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

Nyní se npm ci spustí pouze když se změní package.json nebo package-lock.json. Pokud jste změnili pouze zdrojový kód, Docker znovu použije kešovanou vrstvu npm ci. Na projektu s 500+ závislostmi to ušetří 60-120 sekund na build.

Cache Mount pro npm#

Docker BuildKit podporuje cache mounty, které uchovávají npm cache mezi buildy:

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

Toto uchovává npm download cache napříč buildy. Pokud byla závislost již stažena v předchozím buildu, npm použije kešovanou verzi místo opětovného stahování. To je obzvláště užitečné v CI, kde buildíte často.

Pro použití BuildKit nastavte proměnnou prostředí:

bash
DOCKER_BUILDKIT=1 docker build -t myapp .

Nebo přidejte do konfigurace Docker démona:

json
{
  "features": {
    "buildkit": true
  }
}

Použití ARG pro Cache Busting#

Někdy potřebujete vynutit rebuild vrstvy. Například pokud stahujete latest tag z registru a chcete zajistit, že získáte nejnovější verzi:

dockerfile
ARG CACHE_BUST=1
RUN npm ci

Buildujte s unikátní hodnotou pro proražení cache:

bash
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .

Používejte toto střídmě. Celý smysl cachování je rychlost — prorazit cache pouze když máte důvod.

Správa Secrets: Přestaňte dávat secrets do Dockerfilu#

Toto je jedna z nejčastějších a nejnebezpečnějších chyb. Vidím to neustále:

dockerfile
# NIKDY TOTO NEDĚLEJTE
ENV DATABASE_URL=postgres://user:password@db:5432/myapp
ENV API_KEY=sk-live-abc123def456

Proměnné prostředí nastavené pomocí ENV v Dockerfilu jsou zapečeny do image. Kdokoliv, kdo stáhne image, je může vidět pomocí docker inspect nebo docker history. Jsou také viditelné v každé vrstvě poté, co jsou nastaveny. I když je později odstraníte pomocí unset, existují v historii vrstev.

Tři úrovně Secrets#

1. Build-time secrets (Docker BuildKit)

Pokud potřebujete secrets během buildu (jako token soukromého npm registru), použijte příznak --secret BuildKitu:

dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
 
# Připojte secret v době buildu — nikdy se neuloží do image
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
    npm ci
 
COPY . .
RUN npm run build

Buildujte s:

bash
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

Soubor .npmrc je dostupný během příkazu RUN, ale nikdy není commitnut do žádné vrstvy image. Neobjeví se v docker history ani docker inspect.

2. Runtime secrets přes proměnné prostředí

Pro secrets, které vaše aplikace potřebuje za běhu, předejte je při spuštění kontejneru:

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

Nebo s env souborem:

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

Tyto jsou viditelné přes docker inspect na běžícím kontejneru, ale nejsou zapečeny do image. Kdokoliv, kdo stáhne image, nedostane secrets.

3. Docker secrets (Swarm / Kubernetes)

Pro řádnou správu secrets v orchestrovaných prostředích:

yaml
# docker-compose.yml (Swarm mód)
version: "3.8"
services:
  api:
    image: myapp:latest
    secrets:
      - db_password
      - api_key
 
secrets:
  db_password:
    external: true
  api_key:
    external: true

Docker připojí secrets jako soubory na /run/secrets/<jméno_secretu>. Vaše aplikace je čte z filesystému:

javascript
import { readFileSync } from "fs";
 
function getSecret(name) {
  try {
    return readFileSync(`/run/secrets/${name}`, "utf8").trim();
  } catch {
    // Záloha na proměnnou prostředí pro lokální vývoj
    return process.env[name.toUpperCase()];
  }
}
 
const dbPassword = getSecret("db_password");

Toto je nejbezpečnější přístup, protože secrets se nikdy neobjeví v proměnných prostředí, výpisech procesů ani ve výstupu inspekce kontejneru.

Soubory .env a Docker#

Nikdy nezahrnujte .env soubory do svého Docker image. Váš .dockerignore by je měl vylučovat (proto jsme dříve uvedli .env a .env.*). Pro lokální vývoj s docker-compose je připojte za běhu:

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

Health Checky: Dejte Dockeru vědět, že vaše aplikace skutečně funguje#

Health check říká Dockeru, zda vaše aplikace správně funguje. Bez něj Docker ví pouze to, zda proces běží — ne zda je skutečně schopen zpracovávat požadavky.

Instrukce 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) })"

Dovolte mi rozebrat parametry:

  • --interval=30s: Kontrola každých 30 sekund
  • --timeout=10s: Pokud kontrola trvá déle než 10 sekund, považujte ji za neúspěšnou
  • --start-period=40s: Dejte aplikaci 40 sekund na spuštění než začnete počítat selhání
  • --retries=3: Označte jako nezdravou po 3 po sobě jdoucích selháních

Proč nepoužít curl?#

Alpine ve výchozím nastavení neobsahuje curl. Mohli byste ho nainstalovat (apk add --no-cache curl), ale to přidá další binárku do vašeho minimálního image. Použití Node.js přímo znamená nulové další závislosti.

Pro ještě lehčí health checky můžete použít dedikovaný skript:

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"]

Health Endpoint#

Vaše aplikace potřebuje endpoint /health, na který se kontrola zavolá. Nevracejte jen 200 — skutečně ověřte, že je vaše aplikace zdravá:

javascript
app.get("/health", async (req, res) => {
  const checks = {
    uptime: process.uptime(),
    timestamp: Date.now(),
    status: "ok",
  };
 
  try {
    // Kontrola databázového připojení
    await db.query("SELECT 1");
    checks.database = "connected";
  } catch (err) {
    checks.database = "disconnected";
    checks.status = "degraded";
  }
 
  try {
    // Kontrola Redis připojení
    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" s kódem 503 říká orchestrátoru, aby přestal směrovat provoz na tuto instanci, zatímco se zotavuje, ale nemusí nutně vyvolat restart.

Proč záleží na Health Checkech pro orchestrátory#

Docker Swarm, Kubernetes a dokonce i prostý docker-compose s restart: always používají health checky k rozhodování:

  • Load balancery přestanou posílat provoz na nezdravé kontejnery
  • Rolling updaty čekají, až bude nový kontejner zdravý, než zastaví starý
  • Orchestrátory mohou restartovat kontejnery, které se stanou nezdravými
  • Deployment pipelines mohou ověřit, že deployment uspěl

Bez health checků může rolling deployment zabít starý kontejner před tím, než je nový připraven, což způsobí výpadek.

docker-compose pro vývoj#

Vaše vývojové prostředí by mělo být co nejblíže produkci, ale s pohodlím hot reload, debuggerů a okamžité zpětné vazby. Zde je docker-compose setup, který používám pro vývoj:

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:
      # Připojení zdrojového kódu pro hot reload
      - .:/app
      # Anonymní volume pro zachování node_modules z image
      # Zabraňuje tomu, aby host node_modules přepsal kontejnerový
      - /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 pro perzistentní data mezi restarty kontejneru
      - pgdata:/var/lib/postgresql/data
      # Inicializační skripty
      - ./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
 
  # Volitelné: administrační UI databáze
  adminer:
    image: adminer:latest
    ports:
      - "8080:8080"
    depends_on:
      - db
 
volumes:
  pgdata:
  redisdata:

Klíčové vývojové patterny#

Volume mounty pro hot reload: Volume mount .:/app mapuje váš lokální zdrojový kód do kontejneru. Když uložíte soubor, změna je okamžitě viditelná uvnitř kontejneru. V kombinaci s dev serverem, který sleduje změny (jako nodemon nebo tsx --watch), získáte okamžitou zpětnou vazbu.

Trik s node_modules: Anonymní volume - /app/node_modules zajistí, že kontejner používá své vlastní node_modules (nainstalované během buildu image) místo hostových node_modules. To je kritické, protože nativní moduly zkompilované na macOS nebudou fungovat uvnitř Linux kontejneru.

Závislosti služeb: depends_on s condition: service_healthy zajistí, že databáze je skutečně připravena dříve, než se vaše aplikace pokusí připojit. Bez podmínky health check depends_on čeká pouze na spuštění kontejneru — ne na připravenost služby uvnitř.

Named volumes: pgdata a redisdata přetrvávají mezi restarty kontejneru. Bez named volumes byste přišli o databázi pokaždé, když spustíte docker-compose down.

Vývojový Dockerfile#

Váš vývojový Dockerfile je jednodušší než produkční:

dockerfile
# Dockerfile.dev
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
 
WORKDIR /app
 
# Nainstalujte všechny závislosti (včetně devDependencies)
COPY package*.json ./
RUN npm ci
 
# Zdrojový kód je připojen přes volume, ne kopírován
# Ale stále ho potřebujeme pro úvodní build
COPY . .
 
EXPOSE 3000 9229
 
CMD ["npm", "run", "dev"]

Žádný multi-stage build, žádná produkční optimalizace. Cílem je rychlá iterace, ne malé image.

Produkční Docker Compose#

Produkční docker-compose je jiná kategorie. Zde je, co používám:

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 je jinak oproti vývoji#

Restart politika: unless-stopped automaticky restartuje kontejner, pokud spadne, pokud jste ho explicitně nezastavili. To řeší scénář "pád ve 3 ráno". Alternativa always by také restartovala kontejnery, které jste záměrně zastavili, což obvykle nechcete.

Limity zdrojů: Bez limitů bude memory leak ve vaší Node.js aplikaci konzumovat veškerou dostupnou RAM na hostovi, potenciálně zabíjející ostatní kontejnery nebo samotný host. Nastavte limity na základě skutečného využití vaší aplikace plus nějaká rezerva:

bash
# Monitorujte skutečné využití pro nastavení odpovídajících limitů
docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}"

Konfigurace logování: Bez max-size a max-file Docker logy rostou neomezeně. Viděl jsem produkční servery, kterým došlo místo na disku kvůli Docker logům. json-file s rotací je nejjednodušší řešení. Pro centralizované logování přepněte na fluentd nebo gelf driver:

yaml
logging:
  driver: "fluentd"
  options:
    fluentd-address: "localhost:24224"
    tag: "myapp.{{.Name}}"

Izolace sítě: Síť internal je přístupná pouze pro služby v tomto compose stacku. Databáze a Redis nejsou vystaveny hostu ani jiným kontejnerům. Pouze služba app je připojena k síti web, kterou váš reverzní proxy (Nginx, Traefik) používá k směrování provozu.

Žádné mapování portů pro databáze: Všimněte si, že db a redis nemají ports v produkční konfiguraci. Jsou přístupné pouze přes interní Docker síť. Ve vývoji je vystavujeme, abychom mohli používat lokální nástroje (pgAdmin, Redis Insight). V produkci není důvod, aby byly přístupné zvnějšku Docker sítě.

Next.js specifické: Standalone výstup#

Next.js má vestavěnou Docker optimalizaci, o které mnoho lidí neví: standalone výstupní režim. Trasuje importy vaší aplikace a kopíruje pouze soubory potřebné ke spuštění — žádné node_modules nejsou potřeba (závislosti jsou bundlovány).

Povolte ho v next.config.ts:

typescript
// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  output: "standalone",
};
 
export default nextConfig;

Toto dramaticky mění výstup buildu. Místo potřeby celého adresáře node_modules produkuje Next.js samostatný server.js v .next/standalone/, který obsahuje pouze závislosti, které skutečně používá.

Produkční Dockerfile pro Next.js#

Toto je Dockerfile, který používám pro Next.js projekty, založený na oficiálním příkladu Vercelu, ale s bezpečnostním zpevněním:

dockerfile
# ============================================
# Stage 1: Instalace závislostí
# ============================================
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: Sestavení aplikace
# ============================================
FROM node:20-alpine AS builder
WORKDIR /app
 
COPY --from=deps /app/node_modules ./node_modules
COPY . .
 
# Vypněte telemetrii Next.js během buildu
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN npm run build
 
# ============================================
# Stage 3: Produkční 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 uživatel
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
# Zkopírujte veřejné assety
COPY --from=builder /app/public ./public
 
# Nastavte adresář standalone výstupu
# Automaticky využívá output traces ke snížení velikosti image
# https://nextjs.org/docs/advanced-features/output-file-tracing
RUN mkdir .next
RUN chown nextjs:nodejs .next
 
# Zkopírujte standalone server a statické soubory
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"]

Porovnání velikostí pro Next.js#

PřístupVelikost Image
node:20 + plné node_modules + .next1,4 GB
node:20-alpine + plné node_modules + .next600 MB
node:20-alpine + standalone výstup120 MB

Standalone výstup je transformativní. Image o velikosti 1,4 GB se stává 120 MB. Deploymenty, které trvaly 90 sekund na stažení, nyní trvají 10 sekund.

Zpracování statických souborů#

Standalone režim Next.js nezahrnuje složku public ani statické assety z .next/static. Musíte je zkopírovat explicitně (což děláme v Dockerfilu výše). V produkci obvykle chcete CDN před nimi:

typescript
// next.config.ts
const nextConfig: NextConfig = {
  output: "standalone",
  assetPrefix: process.env.CDN_URL || undefined,
};

Pokud nepoužíváte CDN, Next.js obsluhuje statické soubory přímo. Standalone server to zvládne v pořádku — jen se musíte ujistit, že soubory jsou na správném místě (což náš Dockerfile zajišťuje).

Sharp pro optimalizaci obrázků#

Next.js používá sharp pro optimalizaci obrázků. V produkčním image založeném na Alpine se musíte ujistit, že je k dispozici správná binárka:

dockerfile
# V runner stage, před přepnutím na non-root uživatele
RUN apk add --no-cache --virtual .sharp-deps vips-dev

Nebo lépe, nainstalujte ho jako produkční závislost a nechte npm zpracovat platformově specifickou binárku:

bash
npm install sharp

Image node:20-alpine funguje s předkompilovanou binárkou sharp linux-x64-musl. Ve většině případů není potřeba žádná speciální konfigurace.

Skenování image a bezpečnost#

Vytvoření malého image s non-root uživatelem je dobrý začátek, ale nestačí to pro seriózní produkční workloady. Zde je, jak jít dál.

Trivy: Skenujte vaše image#

Trivy je komplexní skener zranitelností pro kontejnerové image. Spouštějte ho ve své CI pipeline:

bash
# Instalace trivy
brew install aquasecurity/trivy/trivy  # macOS
# nebo
apt-get install trivy  # Debian/Ubuntu
 
# Skenujte váš image
trivy image myapp:latest

Ukázkový výstup:

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         │
└──────────────┴────────────────┴──────────┴────────┴───────────────┘

Integrujte ho v CI pro selhání buildů na kritických zranitelnostech:

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

Read-Only Filesystem#

Kontejnery můžete spouštět s read-only root filesystémem. To brání útočníkovi v modifikaci binárek, instalaci nástrojů nebo psaní škodlivých skriptů:

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

--tmpfs mounty poskytují zapisovatelné dočasné adresáře, kam vaše aplikace legitimně potřebuje zapisovat (dočasné soubory, cache). Vše ostatní je read-only.

V docker-compose:

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

Odebrání všech Capabilities#

Linux capabilities jsou jemnozrnná oprávnění, která nahrazují model vše-nebo-nic root. Ve výchozím nastavení Docker kontejnery dostávají podmnožinu capabilities. Můžete je všechny odebrat:

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

Pokud vaše aplikace potřebuje bindovat na port nižší než 1024, potřebovali byste NET_BIND_SERVICE. Ale protože používáme port 3000 s non-root uživatelem, nepotřebujeme žádné capabilities:

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

no-new-privileges brání procesu v získání dalších oprávnění přes setuid/setgid binárky. To je opatření defense-in-depth, které nic nestojí.

Připnutí digestu základního image#

Místo použití node:20-alpine (což je pohyblivý cíl), připněte na konkrétní digest:

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

Získejte digest pomocí:

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

Toto zajistí, že vaše buildy jsou 100% reprodukovatelné. Kompromis je, že automaticky nedostáváte bezpečnostní záplaty základního image. Použijte Dependabot nebo Renovate k automatizaci aktualizací digestů:

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

CI/CD integrace: Sestavení celku#

Zde je kompletní GitHub Actions workflow, který buildí, skenuje a pushuje Docker image:

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

BuildKit cache v CI#

Řádky cache-from: type=gha a cache-to: type=gha,mode=max používají GitHub Actions cache jako Docker layer cache. To znamená, že vaše CI buildy těží z layer cachingu napříč spuštěními. První build trvá 5 minut; následné buildy pouze se změnami kódu trvají 30 sekund.

Časté problémy a jak se jim vyhnout#

Konflikt node_modules uvnitř image vs host#

Pokud volume-mountujete adresář projektu do kontejneru (-v .:/app), hostové node_modules přepíšou kontejnerové. Nativní moduly zkompilované na macOS nebudou fungovat v Linuxu. Vždy používejte trik s anonymním volume:

yaml
volumes:
  - .:/app
  - /app/node_modules  # zachovává kontejnerové node_modules

Zpracování SIGTERM v TypeScript projektech#

Pokud spouštíte TypeScript s tsx nebo ts-node ve vývoji, zpracování signálů funguje normálně. Ale v produkci, pokud používáte zkompilovaný JavaScript s node, ujistěte se, že váš zkompilovaný výstup zachovává signal handlery. Některé build nástroje optimalizují pryč "nepoužívaný" kód.

Paměťové limity a Node.js#

Node.js automaticky nerespektuje Docker paměťové limity. Pokud má váš kontejner paměťový limit 512MB, Node.js bude stále zkoušet použít svou výchozí velikost haldy (kolem 1,5 GB na 64-bitových systémech). Nastavte maximální velikost old space:

dockerfile
CMD ["node", "--max-old-space-size=384", "dist/server.js"]

Nechte asi 25% rezervu mezi limitem haldy Node.js a paměťovým limitem kontejneru pro non-heap paměť (buffery, nativní kód, atd.).

Nebo použijte příznak pro automatickou detekci:

dockerfile
ENV NODE_OPTIONS="--max-old-space-size=384"

Problémy s časovým pásmem#

Alpine ve výchozím nastavení používá UTC. Pokud vaše aplikace závisí na konkrétním časovém pásmu:

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

Ale lepší je: pište kód nezávislý na časovém pásmu. Ukládejte vše v UTC. Konvertujte na lokální čas pouze v prezentační vrstvě.

Build Arguments vs proměnné prostředí#

  • ARG je dostupný pouze během buildu. Nepřetrvává ve finálním image (pokud ho nezkopírujete do ENV).
  • ENV přetrvává v image a je dostupný za běhu.
dockerfile
# Konfigurace v době buildu
ARG NODE_VERSION=20
FROM node:${NODE_VERSION}-alpine
 
# Konfigurace za běhu
ENV PORT=3000
 
# ŠPATNĚ: Toto zpřístupní secret v image
ARG API_KEY
ENV API_KEY=${API_KEY}
 
# SPRÁVNĚ: Předejte secrets za běhu
# docker run -e API_KEY=secret myapp

Monitoring v produkci#

Váš Docker setup není kompletní bez observability. Zde je minimální, ale efektivní monitorovací stack:

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:

Exponujte metriky z vaší Node.js aplikace 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: "Doba trvání HTTP požadavků v sekundách",
  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();
});
 
// Endpoint pro metriky
app.get("/metrics", async (req, res) => {
  res.set("Content-Type", register.contentType);
  res.end(await register.metrics());
});

Kontrolní seznam#

Než odešlete kontejnerizovanou Node.js aplikaci do produkce, ověřte:

  • Non-root uživatel — Kontejner běží jako non-root uživatel
  • Multi-stage build — devDependencies a build nástroje nejsou ve finálním image
  • Alpine základ — Použití minimálního základního image
  • .dockerignore.git, .env, node_modules, testy vyloučeny
  • Layer cachingpackage.json kopírován před zdrojovým kódem
  • Health check — Instrukce HEALTHCHECK v Dockerfilu
  • Zpracování signálůdumb-init nebo --init pro správné zpracování SIGTERM
  • Žádné secrets v image — Žádné ENV s citlivými hodnotami v Dockerfilu
  • Limity zdrojů — Paměťové a CPU limity nastaveny v compose/orchestrátoru
  • Rotace logů — Logging driver nakonfigurován s max velikostí
  • Skenování image — Trivy nebo ekvivalent v CI pipeline
  • Připnuté verze — Verze základního image a závislostí připnuty
  • Paměťové limity--max-old-space-size nastaven pro haldu Node.js

Většina z toho je jednorázový setup. Udělejte to jednou, vytvořte šablonu a každý nový projekt začíná s kontejnerem připraveným na produkci od prvního dne.

Docker není složitý. Ale mezera mezi "fungujícím" Dockerfilem a produkčně připraveným je širší, než si většina lidí myslí. Patterny v tomto průvodci tu mezeru uzavírají. Používejte je, přizpůsobte si je a přestaňte nasazovat root kontejnery s 1GB image bez health checků. Vaše budoucí já — to, které bude probuzeno ve 3 ráno — vám poděkuje.

Související články