Zum Hauptinhalt springen
Docker in der Praxis – Production-Ready Templates für Node.js, .NET, Java & Rust
#Docker #Node.js #.NET Core #Java #Rust

Docker in der Praxis – Production-Ready Templates für Node.js, .NET, Java & Rust

Von Development bis Production: Optimierte Dockerfiles, Multi-Stage-Builds, Datenbankintegration und Container-Kommunikation für moderne Tech-Stacks

SerieContainer & Orchestrierung
Teil 2 von 3

Im ersten Teil der Serie haben wir die Grundlagen von Docker kennengelernt – was Container sind, wie sie funktionieren und warum sie das “Auf meinem Rechner läuft’s”-Problem lösen. Die Minecraft-Server-Beispiele haben die Konzepte greifbar gemacht.

Jetzt wird es konkret: Production-ready Dockerfiles für echte Technologie-Stacks. Node.js-APIs, .NET Core-Services, Java Spring Boot-Anwendungen, Rust-Microservices – jeder Stack hat seine Eigenheiten, Best Practices und Optimierungsmöglichkeiten.

Wir schauen uns an, wie Container miteinander kommunizieren, wie Datenbanken integriert werden, wie Multi-Stage-Builds Images schlank halten und welche Sicherheits-Aspekte in Production relevant sind.


Multi-Stage Builds – Schlanke Production-Images

Eines der wichtigsten Docker-Features für Production ist der Multi-Stage Build. Die Idee: Build-Dependencies gehören nicht ins finale Image.

Das Problem ohne Multi-Stage

Ein naives Node.js-Dockerfile könnte so aussehen:

FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]

Das funktioniert, aber das finale Image enthält:

  • Alle devDependencies (ESLint, TypeScript, Jest, etc.)
  • Node.js Build-Tools
  • Source-Code in TypeScript (obwohl nur JavaScript gebraucht wird)
  • Größe: ~1.2 GB

Die Multi-Stage-Lösung

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production=false
COPY . .
RUN npm run build

# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Das finale Image enthält:

  • Nur Production-Dependencies
  • Nur kompiliertes JavaScript
  • Minimale Node.js-Runtime (Alpine-based)
  • Größe: ~150 MB

Das ist eine Reduktion um 88% – schnellere Deployments, weniger Speicherplatz, kleinere Angriffsfläche.


Node.js – Production-Ready Template

Node.js-Anwendungen sind in Containern weit verbreitet. Hier ein vollständiges Production-Template mit allen Best Practices:

# Syntax-Version festlegen für BuildKit-Features
# syntax=docker/dockerfile:1

# Build Stage
FROM node:20-alpine AS builder

# Metadaten
LABEL stage=builder

# Build-Dependencies installieren
RUN apk add --no-cache python3 make g++

WORKDIR /app

# Dependency-Installation (gecached wenn package.json unverändert)
COPY package*.json ./
RUN npm ci --ignore-scripts

# Source-Code kopieren und bauen
COPY . .
RUN npm run build && \
    npm prune --production

# Production Stage
FROM node:20-alpine

# Security: Non-root User
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

WORKDIR /app

# Nur Production-Artefakte kopieren
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./

# Health-Check definieren
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"

# Non-root ausführen
USER nodejs

EXPOSE 3000

# Graceful Shutdown unterstützen
STOPSIGNAL SIGTERM

CMD ["node", "dist/index.js"]

Die Optimierungen im Detail

Alpine Linux: Basis-Image mit nur 5 MB statt 100+ MB bei Debian-basierten Images.

Layer-Caching: package.json wird vor dem Source-Code kopiert. Wenn sich nur Code ändert, wird die Dependency-Installation aus dem Cache geholt.

npm ci statt npm install: npm ci ist deterministisch, nutzt package-lock.json exakt und ist schneller in CI/CD-Umgebungen.

Production-Pruning: npm prune --production entfernt devDependencies nach dem Build.

Non-Root User: Container laufen als User nodejs (UID 1001), nicht als Root. Security-Best-Practice.

Health-Check: Docker kann automatisch prüfen, ob die Anwendung gesund ist. Wichtig für Orchestrierung.

Graceful Shutdown: STOPSIGNAL SIGTERM ermöglicht der Anwendung, sauber herunterzufahren (Connections schließen, etc.).

.dockerignore – Was nicht ins Image gehört

node_modules
npm-debug.log
dist
.git
.env
.vscode
*.md
.github
coverage
.nyc_output

Ohne .dockerignore werden alle Dateien kopiert – inklusive node_modules, was Build-Zeiten massiv verlängert.


.NET Core – Optimiert für C# und F#

.NET hat exzellente Docker-Integration. Microsoft stellt offizielle Images bereit, die optimal abgestimmt sind:

# Build Stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Projekt-Dateien kopieren und Restore (Layer-Caching)
COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"

# Source kopieren und bauen
COPY . .
WORKDIR "/src/MyApi"
RUN dotnet build "MyApi.csproj" -c Release -o /app/build

# Publish Stage
FROM build AS publish
RUN dotnet publish "MyApi.csproj" -c Release -o /app/publish \
    --no-restore \
    --runtime linux-x64 \
    --self-contained false

# Runtime Stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS runtime
WORKDIR /app

# Non-root User
RUN addgroup -g 1001 -S dotnet && \
    adduser -S dotnet -u 1001 -G dotnet

# Artefakte kopieren
COPY --from=publish --chown=dotnet:dotnet /app/publish .

# ASP.NET Core auf Port 8080 konfigurieren (non-privileged)
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production

USER dotnet
EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

ENTRYPOINT ["dotnet", "MyApi.dll"]

.NET-spezifische Optimierungen

SDK vs. ASP.NET Runtime: Das SDK-Image (1.2 GB) ist nur für den Build. Das Runtime-Image (Alpine-based, ~100 MB) enthält nur die .NET Runtime.

Layer-Caching für Restore: .csproj-Dateien werden vor dem Source-Code kopiert. NuGet-Packages werden nur neu geholt, wenn sich Dependencies ändern.

Self-Contained vs. Framework-Dependent: Hier --self-contained false – die Runtime ist im Base-Image. Für Deployments ohne .NET-Runtime-Abhängigkeit würde man true nutzen (aber größere Images).

Ready-to-Run (R2R): Für weitere Performance-Optimierung kann PublishReadyToRun aktiviert werden:

<PropertyGroup>
  <PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>

Das kompiliert IL zu nativem Code, verbessert Startup-Performance um 20-30%.

Trimming: Für noch kleinere Images kann man ungenutzten Code trimmen:

<PropertyGroup>
  <PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>

Reduziert die App-Größe erheblich, funktioniert aber nur mit Trimming-kompatiblen Libraries.


Java Spring Boot – Enterprise-Ready

Java-Anwendungen haben oft hohe Memory-Anforderungen. Optimierung ist hier besonders wichtig:

# Build Stage
FROM eclipse-temurin:21-jdk-jammy AS build
WORKDIR /app

# Gradle Wrapper und Dependencies (Layer-Caching)
COPY gradlew .
COPY gradle gradle
COPY build.gradle settings.gradle ./
RUN ./gradlew dependencies --no-daemon

# Source kopieren und bauen
COPY src src
RUN ./gradlew bootJar --no-daemon && \
    java -Djarmode=layertools -jar build/libs/*.jar extract

# Runtime Stage
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app

# Non-root User
RUN addgroup -g 1001 spring && \
    adduser -S spring -u 1001 -G spring

# Layered JAR kopieren (Spring Boot Feature)
COPY --from=build --chown=spring:spring /app/dependencies/ ./
COPY --from=build --chown=spring:spring /app/spring-boot-loader/ ./
COPY --from=build --chown=spring:spring /app/snapshot-dependencies/ ./
COPY --from=build --chown=spring:spring /app/application/ ./

USER spring

# JVM-Tuning für Container
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=60s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

Java-spezifische Optimierungen

Layered JARs: Spring Boot 2.3+ unterstützt Layer-Extraktion. Dependencies ändern sich selten, Application-Code oft. Durch Layer-Trennung werden nur geänderte Layer neu gepullt.

JDK vs. JRE: Build benötigt JDK (Compiler), Runtime nur JRE. Das spart ~200 MB.

Container-Aware JVM: -XX:+UseContainerSupport stellt sicher, dass die JVM cgroup-Limits respektiert. Sonst könnte die JVM annehmen, das gesamte Host-Memory steht zur Verfügung.

MaxRAMPercentage: Begrenzt Heap-Größe auf Prozentsatz des Container-Memories. Bei einem Container mit 2 GB RAM nutzt die JVM max. 1.5 GB für Heap.

G1GC: Garbage Collector optimiert für Container-Umgebungen mit begrenztem Memory.

Startup-Optimierung mit CDS (Class Data Sharing):

Für noch schnelleren Start kann man CDS nutzen:

RUN java -Xshare:dump

Das reduziert Startup-Zeit um 20-40%, besonders bei großen Anwendungen.


Rust – Maximale Effizienz

Rust-Anwendungen sind perfekt für Container: Statisch kompiliert, keine Runtime-Dependencies, winzige Binaries.

# Build Stage
FROM rust:1.75-alpine AS builder

# Build-Dependencies
RUN apk add --no-cache musl-dev openssl-dev

WORKDIR /app

# Dependency-Caching: Erst nur Cargo.toml kopieren
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && \
    echo "fn main() {}" > src/main.rs && \
    cargo build --release && \
    rm -rf src

# Echter Source-Code
COPY src src
RUN touch src/main.rs && \
    cargo build --release

# Runtime Stage: Distroless für minimale Größe
FROM gcr.io/distroless/static-debian12
WORKDIR /app

# Nur Binary kopieren
COPY --from=builder /app/target/release/myapp /app/myapp

# Non-root
USER nonroot:nonroot

EXPOSE 8080

CMD ["/app/myapp"]

Rust-spezifische Optimierungen

musl statt glibc: Alpine nutzt musl-libc. Rust kann damit statisch kompilieren – null Runtime-Dependencies.

Dependency-Caching: Der Trick mit dem Dummy-main.rs cached Dependencies. Änderungen am Source triggern kein Rebuild aller Crates.

Distroless Base-Image: Enthält nur die Binary, sonst nichts. Kein Shell, keine Utilities, kein Package-Manager. Angriffsfläche minimal. Image-Größe: ~5-15 MB (je nach Binary-Größe).

Strip Symbols für kleinere Binaries:

[profile.release]
strip = true
lto = true
codegen-units = 1

Das reduziert Binary-Größe um 30-50% und verbessert Performance durch Link-Time Optimization (LTO).

Cross-Compilation für noch kleinere Images:

Für maximale Optimierung kann man auch komplett ohne OS bauen:

FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
CMD ["/myapp"]

Image-Größe: Binary-Größe + 0 Bytes. Das ist das absolute Minimum.


Datenbank-Integration – PostgreSQL als Beispiel

Containerisierte Anwendungen brauchen Datenbanken. Schauen wir uns eine realistische Setup mit Docker Compose an:

version: '3.8'

services:
  # PostgreSQL Datenbank
  postgres:
    image: postgres:16-alpine
    container_name: postgres_db
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: myapp
    volumes:
      # Persistente Daten
      - postgres_data:/var/lib/postgresql/data
      # Init-Scripts
      - ./init-scripts:/docker-entrypoint-initdb.d
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - app_network
    restart: unless-stopped

  # Node.js API
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
    container_name: nodejs_api
    environment:
      DATABASE_URL: postgresql://appuser:${DB_PASSWORD}@postgres:5432/myapp
      NODE_ENV: production
      PORT: 3000
    ports:
      - "3000:3000"
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - app_network
    restart: unless-stopped

  # .NET Worker Service
  worker:
    build:
      context: ./worker
      dockerfile: Dockerfile
    container_name: dotnet_worker
    environment:
      ConnectionStrings__DefaultConnection: "Host=postgres;Database=myapp;Username=appuser;Password=${DB_PASSWORD}"
    depends_on:
      postgres:
        condition: service_healthy
    networks:
      - app_network
    restart: unless-stopped

volumes:
  postgres_data:

networks:
  app_network:
    driver: bridge

Die Integration im Detail

Netzwerk-Isolation: Alle Services laufen im app_network. Container können sich per Service-Name erreichen: Die API verbindet zu postgres:5432, nicht zu localhost oder einer IP.

Health-Checks: Die Datenbank hat einen Health-Check. depends_on mit condition: service_healthy stellt sicher, dass die API erst startet, wenn PostgreSQL bereit ist.

Secrets Management: Passwörter gehören nicht in docker-compose.yml. Die .env-Datei:

DB_PASSWORD=your_secure_password_here

Für Production würde man Docker Secrets oder Vault nutzen.

Persistente Daten: Das Volume postgres_data überlebt Container-Restarts und -Löschungen. Daten bleiben erhalten.

Init-Scripts: SQL-Dateien in ./init-scripts/ werden beim ersten Start automatisch ausgeführt:

-- init-scripts/01-schema.sql
CREATE TABLE IF NOT EXISTS users (
  id SERIAL PRIMARY KEY,
  email VARCHAR(255) UNIQUE NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);

Container-Kommunikation – Services verbinden

Container kommunizieren über verschiedene Mechanismen:

1. Service-Discovery über DNS

Docker Compose registriert jeden Service automatisch im internen DNS. Ein Container namens api erreicht postgres einfach per Hostname:

// Node.js Connection
const pool = new Pool({
  host: 'postgres',  // Service-Name aus docker-compose.yml
  port: 5432,
  database: 'myapp',
  user: 'appuser',
  password: process.env.DB_PASSWORD
});

2. Inter-Service Communication über HTTP

Microservices kommunizieren oft per HTTP:

services:
  auth-service:
    build: ./auth
    networks:
      - app_network

  user-service:
    build: ./users
    environment:
      AUTH_SERVICE_URL: http://auth-service:8080
    networks:
      - app_network

Der user-service kann den auth-service per http://auth-service:8080/validate erreichen.

3. Message Queues für asynchrone Kommunikation

Für Event-Driven Architecture nutzt man Message Brokers:

services:
  rabbitmq:
    image: rabbitmq:3-management-alpine
    ports:
      - "5672:5672"
      - "15672:15672"
    networks:
      - app_network

  publisher:
    build: ./publisher
    environment:
      RABBITMQ_URL: amqp://rabbitmq:5672
    depends_on:
      - rabbitmq
    networks:
      - app_network

  consumer:
    build: ./consumer
    environment:
      RABBITMQ_URL: amqp://rabbitmq:5672
    depends_on:
      - rabbitmq
    networks:
      - app_network

4. Shared Volumes für File-basierte Kommunikation

Für manche Use Cases (z.B. File-Processing) können Container Volumes teilen:

services:
  uploader:
    build: ./uploader
    volumes:
      - shared_files:/app/uploads

  processor:
    build: ./processor
    volumes:
      - shared_files:/app/input

volumes:
  shared_files:

Sicherheits-Best-Practices für Production

1. Immer Non-Root User

Niemals Container als Root laufen lassen:

RUN addgroup -g 1001 appgroup && \
    adduser -S appuser -u 1001 -G appgroup
USER appuser

2. Read-Only Filesystem

Wo möglich, Filesystem read-only machen:

services:
  api:
    build: ./api
    read_only: true
    tmpfs:
      - /tmp

Die Anwendung kann nichts ins Filesystem schreiben (außer /tmp), was viele Exploits verhindert.

3. Ressourcen-Limits setzen

Verhindere, dass ein Container alle Ressourcen frisst:

services:
  api:
    build: ./api
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 2G
        reservations:
          cpus: '1.0'
          memory: 1G

4. Capabilities droppen

Linux Capabilities sind Privilegien. Container brauchen meist nur minimale:

services:
  api:
    build: ./api
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE  # Nur wenn Port < 1024

5. Security Scanning

Images regelmäßig auf Schwachstellen scannen:

# Mit Trivy
docker run aquasec/trivy image myapp:latest

# Mit Docker Scout
docker scout cves myapp:latest

6. Secrets nicht in Environment Variables

Secrets über Files mounten, nicht via ENV:

services:
  api:
    build: ./api
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

Im Container:

const password = fs.readFileSync('/run/secrets/db_password', 'utf8');

Performance-Optimierungen

1. BuildKit aktivieren

BuildKit ist Dockers neues Build-System – schneller, effizienter:

export DOCKER_BUILDKIT=1
docker build -t myapp .

Oder in docker-compose.yml:

services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile
      args:
        BUILDKIT_INLINE_CACHE: 1

2. Cache-Mounts für Package-Manager

Wiederverwendung von Package-Caches zwischen Builds:

# Node.js
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production

# Rust
RUN --mount=type=cache,target=/usr/local/cargo/registry \
    cargo build --release

3. Layer-Reihenfolge optimieren

Seltene Änderungen zuerst, häufige zuletzt:

# 1. Base-Image (ändert sich fast nie)
FROM node:20-alpine

# 2. System-Dependencies (selten)
RUN apk add --no-cache python3

# 3. App-Dependencies (gelegentlich)
COPY package*.json ./
RUN npm ci

# 4. Source-Code (häufig)
COPY . .

4. Multi-Platform Images für ARM

Apple Silicon, AWS Graviton, Raspberry Pi nutzen ARM:

docker buildx build --platform linux/amd64,linux/arm64 -t myapp:latest .

Ein Image für beide Architekturen.


Debugging containerisierter Anwendungen

Logs ansehen

# Live-Logs
docker compose logs -f api

# Letzte 100 Zeilen
docker compose logs --tail=100 api

In laufenden Container einsteigen

docker exec -it nodejs_api sh

Port-Forwarding für lokales Debugging

services:
  api:
    build: ./api
    ports:
      - "9229:9229"  # Node.js Debugger-Port
    command: node --inspect=0.0.0.0:9229 dist/index.js

Visual Studio Code kann sich dann remote connecten.

Resource-Nutzung überwachen

docker stats

Zeigt CPU, Memory, Network I/O in Echtzeit.


Von Docker Compose zu Kubernetes

Docker Compose funktioniert perfekt für:

  • Entwicklungsumgebungen
  • Kleine bis mittlere Produktions-Deployments auf einem Host
  • CI/CD-Pipelines
  • Proof-of-Concepts

Aber was wenn du skalieren musst?

  • 50+ Services über 20 Server verteilt
  • Automatisches Failover bei Node-Ausfällen
  • Load Balancing zwischen Service-Instanzen
  • Rolling Updates ohne Downtime
  • Auto-Scaling basierend auf Last

Hier erreicht Docker Compose seine Grenzen. Container-Orchestrierung wird notwendig – und das führt uns direkt zu Kubernetes, dem Thema des dritten und letzten Teils dieser Serie.


Erkenntnisse aus der Praxis

Die Dockerfiles und Compose-Setups, die wir hier gesehen haben, sind Production-erprobt. Sie kombinieren:

  • Sicherheit: Non-root User, minimale Images, Health-Checks
  • Performance: Multi-Stage Builds, Layer-Caching, optimierte Runtimes
  • Wartbarkeit: Klare Struktur, Secrets-Management, Logging

Der Unterschied zwischen einem “funktionierenden” und einem “Production-Ready” Docker-Setup liegt in den Details: Health-Checks, Graceful Shutdown, Resource-Limits, Security-Hardening.

Jeder Tech-Stack hat seine Eigenheiten, aber die Prinzipien bleiben gleich: Klein, sicher, effizient, wartbar.


Weiterführende Links: