Zum Inhalt springen

Docker-Entwicklungsworkflows, die wirklich funktionieren

Hör auf, im Alltag mit Docker zu kämpfen: Multi-Stage-Builds, Bind Mounts, Compose-Overrides und die Muster, die Container nativ wirken lassen.

3 Min. Lesezeit
Docker-Compose-Konfiguration mit Multi-Stage-Build und Entwicklungs-Overrides

Docker soll deine Umgebung reproduzierbar machen. In der Praxis landen viele Teams bei Dockerfiles, die in CI laufen, lokal kaputtgehen und ein Ritual aus Tribal Knowledge erfordern, um sie zum Laufen zu bringen. Das Tooling ist mächtig — die Lücke liegt in der Art und Weise, wie es genutzt wird.

Multi-Stage-Builds sind nicht verhandelbar

Ein Single-Stage-Dockerfile, das alles ins Image kopiert, ist die Hauptursache für die meisten Docker-Frustrationen. Es macht Images groß, langsam beim Neubauen und unmöglich sowohl für Entwicklung als auch Produktion zu optimieren.

dockerfiledockerfile
# ❌ Single-stage — ships dev dependencies, source maps, everything
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
 
# ✅ Multi-stage — lean production image, proper layer caching
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
 
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
USER node
CMD ["node", "dist/index.js"]

Die deps-Stage wird separat gecacht. Wenn sich package.json nicht ändert, wiederverwendet Docker den Layer — dein npm ci läuft nur, wenn sich die Abhängigkeiten tatsächlich ändern.

Docker-Compose-Overrides für lokale Entwicklung

Dieselbe docker-compose.yml in Entwicklung und Produktion zu verwenden, zwingt zu schmerzhaften Kompromissen. Nutze stattdessen eine Basisdatei plus umgebungsspezifische Overrides.

ymlyaml
# docker-compose.yml (base — shared config)
services:
  api:
    build:
      context: .
      target: deps # stop at deps stage for dev
    environment:
      DATABASE_URL: postgres://postgres:password@db:5432/app
    depends_on:
      db:
        condition: service_healthy
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
ymlyaml
# docker-compose.override.yml (dev — auto-merged by Compose)
services:
  api:
    build:
      target: deps
    command: npm run dev # hot reload in dev
    volumes:
      - .:/app # bind mount source
      - /app/node_modules # keep container's node_modules
    environment:
      NODE_ENV: development
    ports:
      - "3000:3000"
      - "9229:9229" # Node debugger
ymlyaml
# docker-compose.prod.yml (production — explicit override)
services:
  api:
    build:
      target: runner
    restart: unless-stopped
    environment:
      NODE_ENV: production

Lokal docker compose up ausführen (merged den Override automatisch), in Produktion docker compose -f docker-compose.yml -f docker-compose.prod.yml up.

Bind Mounts und der node_modules-Trick

Der häufigste Schmerzpunkt in der lokalen Entwicklung: Du mountest deinen Quellcode per Bind Mount in den Container, aber jetzt werden die node_modules des Containers (für Linux gebaut) von den node_modules deines Macs (für macOS/Windows gebaut) überschrieben.

Die Lösung ist ein anonymer Volume, der den Bind Mount bei node_modules maskiert:

ymlyaml
volumes:
  - .:/app # sync source code
  - /app/node_modules # hide host's node_modules, keep container's

Das leere Volume-Ziel /app/node_modules hat für diesen spezifischen Pfad Vorrang vor dem übergeordneten Bind Mount. Dein Quellcode wird synchronisiert; deine plattformspezifischen Binärdateien bleiben intakt.

Layer-Caching in CI

Build-Zeiten summieren sich. Ein schlecht sortiertes Dockerfile lädt bei jedem Code-Change alle Abhängigkeiten neu herunter. Die Regel: sortiere Layer von selten geändert zu oft geändert.

dockerfiledockerfile
FROM node:22-alpine AS builder
WORKDIR /app
 
# 1. Copy only the manifest — cached until deps change
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
 
# 2. Copy generated/config files that rarely change
COPY tsconfig.json ./
COPY prisma ./prisma
RUN npx prisma generate
 
# 3. Copy source — changes on every commit
COPY src ./src
RUN npm run build

In GitHub Actions nutzt du cache-from und cache-to mit dem Registry-Driver, um den Layer-Cache über Runs hinweg zu persistieren:

ymlyaml
- name: Build image
  uses: docker/build-push-action@v6
  with:
    context: .
    target: runner
    cache-from: type=registry,ref=ghcr.io/org/app:cache
    cache-to: type=registry,ref=ghcr.io/org/app:cache,mode=max
    tags: ghcr.io/org/app:${{ github.sha }}

Health Checks und Graceful Shutdown

Services, die Health Checks deklarieren, erlauben es Compose (und Kubernetes), zu warten, bis sie bereit sind, bevor abhängige Services starten. Sie zu überspringen verursacht Race Conditions, die sich als inkonsistente Testfehler äußern.

dockerfiledockerfile
# In your Dockerfile
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

Graceful Shutdown ist in Containern wichtig, weil docker stop SIGTERM sendet. Wenn dein Prozess das nicht behandelt, killt Docker nach dem Timeout hart und verwirft laufende Requests.

tstypescript
// Handle SIGTERM for graceful shutdown
const server = app.listen(3000);
 
async function shutdown() {
  console.log("SIGTERM received, shutting down gracefully");
  server.close(async () => {
    await db.end(); // close DB connections
    process.exit(0);
  });
  // Force exit if graceful shutdown takes too long
  setTimeout(() => process.exit(1), 10_000);
}
 
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

Wichtige Erkenntnisse

  1. Multi-Stage-Builds sind die Grundlage — trenne die Stufen Dependency-Installation, Build und Runtime für kleinere Images und besseres Caching
  2. Nutze Compose-Overrides, nicht Umgebungsvariablen, für Dev/Prod-Unterschiede — das Override-Pattern hält beide Umgebungen explizit und reviewbar
  3. Der anonyme node_modules-Volume-Trick verhindert Kollisionen plattformspezifischer Binärdateien bei Bind Mounts
  4. Sortiere Dockerfile-Layer von selten geändert zu oft geändert — die Manifeste vor dem Quellcode zu kopieren ist die Cache-Optimierung mit dem höchsten ROI
  5. Health Checks und Graceful Shutdown sind Produktionsanforderungen — depends_on mit Health-Bedingungen verhindert Race Conditions; SIGTERM-Handling verhindert verworfene Requests
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX