27 Containerisierung einer NestJS-Anwendung

Die Containerisierung hat sich als einer der wichtigsten Bausteine moderner Softwareentwicklung etabliert. Für NestJS-Anwendungen bietet die Containerisierung nicht nur konsistente Ausführungsumgebungen, sondern auch die Grundlage für skalierbare und wartbare Deployments in produktiven Umgebungen.

27.1 Einführung in die Containerisierung

Containerisierung löst ein fundamentales Problem der Softwareentwicklung: “Es funktioniert auf meinem Computer, aber nicht in der Produktion.” Container kapseln Ihre NestJS-Anwendung zusammen mit allen Abhängigkeiten, Laufzeitumgebungen und Konfigurationen in ein portables Paket ein. Dies bedeutet, dass Ihre Anwendung identisch funktioniert, egal ob sie auf Ihrem Entwicklungsrechner, in der Staging-Umgebung oder in der produktiven Cloud läuft.

Stellen Sie sich Container wie standardisierte Frachtcontainer vor: Genau wie ein Schiff verschiedene Container transportieren kann, ohne sich um deren Inhalt kümmern zu müssen, kann eine Container-Runtime verschiedene Anwendungen ausführen, ohne deren interne Struktur verstehen zu müssen. Diese Abstraktion ermöglicht es Entwicklungsteams, sich auf die Anwendungslogik zu konzentrieren, während Infrastruktur-Teams sich um die Ausführungsumgebung kümmern können.

Für NestJS-Anwendungen bringt die Containerisierung zusätzliche Vorteile mit sich. Da NestJS bereits eine modulare Architektur fördert, lassen sich einzelne Module oder Microservices natürlich in separate Container aufteilen. Die TypeScript-Compilation kann optimiert werden, und die resultierende JavaScript-Anwendung läuft schlank und effizient im Container.

27.2 Containerisierung mit Docker

Docker hat sich als De-facto-Standard für die Containerisierung etabliert. Die Containerisierung einer NestJS-Anwendung mit Docker erfordert durchdachte Entscheidungen bezüglich Performance, Sicherheit und Wartbarkeit.

27.2.1 Vorbereitung der NestJS-Anwendung

Bevor Sie Ihre NestJS-Anwendung containerisieren, sollten Sie sicherstellen, dass sie für den Container-Betrieb optimiert ist. Der erste Schritt besteht darin, eine .dockerignore-Datei zu erstellen, die verhindert, dass unnötige Dateien in den Docker-Build-Kontext kopiert werden:

# Abhängigkeiten
node_modules
npm-debug.log*

# Build-Artefakte
dist
build

# Development-Dateien
.env.local
.env.development
coverage

# Git und IDE-Dateien
.git
.gitignore
.vscode
.idea

# Dokumentation
README.md
docs/

# Test-Dateien (außer in Testumgebungen)
**/*.spec.ts
**/*.test.ts
test/

Diese Konfiguration reduziert die Build-Zeit erheblich und verhindert, dass sensible Entwicklungsdateien versehentlich in produktive Container gelangen.

27.2.2 Multi-Stage Dockerfile erstellen

Ein Multi-Stage Dockerfile ist essentiell für produktionstaugliche NestJS-Container. Dieser Ansatz trennt die Build-Umgebung von der Laufzeitumgebung und resultiert in deutlich kleineren und sichereren Images:

# Build-Stage: Hier wird die Anwendung kompiliert
FROM node:18-alpine AS builder

# Arbeitsverzeichnis festlegen
WORKDIR /app

# Package-Dateien kopieren (für besseres Caching)
COPY package*.json ./
COPY yarn.lock* ./

# Abhängigkeiten installieren (inklusive DevDependencies für Build)
RUN npm ci --only=production=false

# Quellcode kopieren
COPY . .

# TypeScript kompilieren und Anwendung bauen
RUN npm run build

# Produktions-Dependencies installieren (ohne DevDependencies)
RUN npm ci --only=production && npm cache clean --force

# Produktions-Stage: Hier läuft die Anwendung
FROM node:18-alpine AS production

# Sicherheits-User erstellen (nicht als root ausführen)
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nestjs -u 1001

# Arbeitsverzeichnis festlegen
WORKDIR /app

# Produktions-Dependencies aus Build-Stage kopieren
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/package*.json ./

# User wechseln (wichtig für Sicherheit)
USER nestjs

# Port exposieren (standardmäßig 3000 für NestJS)
EXPOSE 3000

# Health Check definieren
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node healthcheck.js

# Anwendung starten
CMD ["node", "dist/main.js"]

Dieser Multi-Stage-Ansatz bietet mehrere entscheidende Vorteile. Die Build-Stage enthält alle Entwicklungstools und DevDependencies, die für die TypeScript-Compilation benötigt werden. Die Produktions-Stage hingegen enthält nur die kompilierte Anwendung und die für die Laufzeit notwendigen Dependencies. Dies reduziert die Image-Größe oft um 60-80% und minimiert gleichzeitig die Angriffsfläche.

27.2.3 Environment-spezifische Builds

Verschiedene Deployment-Umgebungen erfordern oft leicht unterschiedliche Konfigurationen. Docker BuildArgs ermöglichen es, zur Build-Zeit Parameter zu übergeben:

# Am Anfang des Dockerfile
ARG NODE_ENV=production
ARG BUILD_VERSION
ARG API_VERSION=v1

# Environment-Variablen setzen
ENV NODE_ENV=${NODE_ENV}
ENV BUILD_VERSION=${BUILD_VERSION}
ENV API_VERSION=${API_VERSION}

# Bedingte Installation basierend auf Environment
RUN if [ "$NODE_ENV" = "development" ] ; then npm install ; else npm ci --only=production ; fi

Diese Build-Args können beim Docker-Build übergeben werden:

# Development Build
docker build \
  --build-arg NODE_ENV=development \
  --build-arg BUILD_VERSION=$(git rev-parse --short HEAD) \
  -t nestjs-app:dev .

# Production Build  
docker build \
  --build-arg NODE_ENV=production \
  --build-arg BUILD_VERSION=${CI_COMMIT_SHA} \
  --build-arg API_VERSION=v2 \
  -t nestjs-app:prod .

27.2.4 Docker-Image bauen und testen

Das Bauen und Testen von Docker-Images sollte ein automatisierter und wiederholbarer Prozess sein. Ein typisches Build-Script könnte folgendermaßen aussehen:

#!/bin/bash

# Variablen definieren
IMAGE_NAME="nestjs-app"
VERSION=$(git rev-parse --short HEAD)
REGISTRY="your-registry.com"

# Image bauen
echo "Building Docker image..."
docker build -t ${IMAGE_NAME}:${VERSION} -t ${IMAGE_NAME}:latest .

# Sicherheits-Scan durchführen
echo "Running security scan..."
docker scan ${IMAGE_NAME}:${VERSION}

# Container-Test: Anwendung starten und Health Check ausführen
echo "Testing container..."
CONTAINER_ID=$(docker run -d -p 3000:3000 ${IMAGE_NAME}:${VERSION})

# Warten bis Container bereit ist
sleep 10

# Health Check
if curl -f http://localhost:3000/health; then
    echo "Container test passed"
    docker stop ${CONTAINER_ID}
    docker rm ${CONTAINER_ID}
else
    echo "Container test failed"
    docker logs ${CONTAINER_ID}
    docker stop ${CONTAINER_ID}
    docker rm ${CONTAINER_ID}
    exit 1
fi

# Image taggen und pushen (wenn Tests erfolgreich)
docker tag ${IMAGE_NAME}:${VERSION} ${REGISTRY}/${IMAGE_NAME}:${VERSION}
docker push ${REGISTRY}/${IMAGE_NAME}:${VERSION}

27.2.5 Sicherheitsüberlegungen

Sicherheit beginnt bereits beim Dockerfile-Design. Die wichtigsten Sicherheitsprinzipien für NestJS-Container umfassen:

Minimale Base Images verwenden: Alpine Linux-basierte Images sind deutlich kleiner und haben weniger potentielle Schwachstellen als vollständige Linux-Distributionen. Das node:18-alpine Image ist oft eine gute Wahl für NestJS-Anwendungen.

Nicht-Root-User verwenden: Container sollten niemals als Root-User laufen. Der in unserem Dockerfile erstellte nestjs-User hat minimale Berechtigungen und kann die Auswirkungen eines möglichen Container-Compromises begrenzen.

Secrets-Management: Niemals Geheimnisse wie API-Keys oder Passwörter direkt im Dockerfile hardcoden. Nutzen Sie stattdessen Docker Secrets oder externe Secret-Management-Systeme:

# Falsch: Secrets im Dockerfile
ENV DATABASE_PASSWORD=super-secret-password

# Richtig: Secrets zur Laufzeit laden
# (Passwort wird über Docker Secrets oder Kubernetes Secrets bereitgestellt)

Layer-Optimierung für Sicherheit: Jeder Layer in einem Docker Image kann potentielle Schwachstellen enthalten. Durch geschickte Gruppierung von Befehlen können Sie die Anzahl der Layer reduzieren:

# Optimiert: Mehrere Befehle in einem Layer
RUN apt-get update && \
    apt-get install -y --no-install-recommends curl && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

Regelmäßige Sicherheits-Scans: Integrieren Sie Tools wie docker scan, Snyk oder Trivy in Ihre CI/CD-Pipeline, um automatisch nach bekannten Schwachstellen zu suchen.

27.3 Kubernetes-Deployment

Kubernetes hat sich als führende Container-Orchestrierungsplattform etabliert. Für NestJS-Anwendungen bietet Kubernetes nicht nur Skalierung und Hochverfügbarkeit, sondern auch sophisticated Service Discovery und Load Balancing.

27.3.1 Kubernetes-Manifeste

Eine produktionstaugliche NestJS-Anwendung in Kubernetes besteht aus mehreren koordinierten Ressourcen. Das Deployment-Manifest definiert, wie Ihre Container ausgeführt werden sollen:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nestjs-app
  labels:
    app: nestjs-app
    version: v1
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nestjs-app
  template:
    metadata:
      labels:
        app: nestjs-app
        version: v1
    spec:
      containers:
      - name: nestjs-app
        image: your-registry.com/nestjs-app:latest
        ports:
        - containerPort: 3000
          protocol: TCP
        env:
        - name: NODE_ENV
          value: "production"
        - name: PORT
          value: "3000"
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: database-secret
              key: url
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 30
          periodSeconds: 10
          timeoutSeconds: 5
          failureThreshold: 3
        readinessProbe:
          httpGet:
            path: /health/ready
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 5
          timeoutSeconds: 3
          failureThreshold: 3
      securityContext:
        runAsNonRoot: true
        runAsUser: 1001
        fsGroup: 1001

Dieses Deployment definiert drei Replica-Pods Ihrer NestJS-Anwendung. Die Resource-Limits verhindern, dass einzelne Pods zu viele Cluster-Ressourcen verbrauchen, während die Probes sicherstellen, dass nur gesunde Pods Traffic erhalten.

27.3.2 ConfigMaps und Secrets

Kubernetes unterscheidet zwischen öffentlichen Konfigurationsdaten (ConfigMaps) und sensiblen Daten (Secrets). Für eine NestJS-Anwendung könnte eine typische Aufteilung folgendermaßen aussehen:

# ConfigMap für öffentliche Konfiguration
apiVersion: v1
kind: ConfigMap
metadata:
  name: nestjs-config
data:
  LOG_LEVEL: "info"
  API_VERSION: "v1"
  CORS_ORIGINS: "https://app.example.com,https://admin.example.com"
  RATE_LIMIT_MAX: "100"
  CACHE_TTL: "300"
---
# Secret für sensible Daten
apiVersion: v1
kind: Secret
metadata:
  name: nestjs-secrets
type: Opaque
data:
  DATABASE_URL: cG9zdGdyZXNxbDovL3VzZXI6cGFzc3dvcmRAZGI6NTQzMi9kYXRhYmFzZQ==
  JWT_SECRET: c3VwZXItc2VjcmV0LWp3dC1rZXk=
  REDIS_PASSWORD: cmVkaXMtcGFzc3dvcmQ=

Diese Konfigurationsdaten werden dann in Ihr Deployment eingebunden:

spec:
  containers:
  - name: nestjs-app
    envFrom:
    - configMapRef:
        name: nestjs-config
    - secretRef:
        name: nestjs-secrets

27.3.3 Service Discovery

Kubernetes Services ermöglichen es anderen Anwendungen, Ihre NestJS-App zu finden, ohne Hard-codierte IP-Adressen verwenden zu müssen:

apiVersion: v1
kind: Service
metadata:
  name: nestjs-service
  labels:
    app: nestjs-app
spec:
  selector:
    app: nestjs-app
  ports:
  - port: 80
    targetPort: 3000
    protocol: TCP
    name: http
  type: ClusterIP

Andere Services im Cluster können Ihre NestJS-Anwendung nun über den DNS-Namen nestjs-service erreichen. Dies ist besonders wertvoll in Microservice-Architekturen, wo verschiedene Services miteinander kommunizieren müssen.

27.3.4 Load Balancing

Für externe Zugriffe benötigen Sie einen Ingress Controller, der als intelligenter Load Balancer fungiert:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nestjs-ingress
  annotations:
    kubernetes.io/ingress.class: nginx
    cert-manager.io/cluster-issuer: letsencrypt-prod
    nginx.ingress.kubernetes.io/rate-limit: "100"
    nginx.ingress.kubernetes.io/rate-limit-window: "1m"
spec:
  tls:
  - hosts:
    - api.example.com
    secretName: nestjs-tls
  rules:
  - host: api.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: nestjs-service
            port:
              number: 80

Dieser Ingress terminiert SSL, implementiert Rate Limiting und verteilt Traffic auf alle verfügbaren Pods Ihrer NestJS-Anwendung.

27.3.5 Health Checks und Readiness Probes

Kubernetes unterscheidet zwischen Liveness- und Readiness-Probes, was für NestJS-Anwendungen besonders wichtig ist. Ihre NestJS-Anwendung sollte entsprechende Health Check-Endpunkte bereitstellen:

// health.controller.ts
import { Controller, Get } from '@nestjs/common';
import { HealthCheckService, HttpHealthIndicator, TypeOrmHealthIndicator } from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private http: HttpHealthIndicator,
    private db: TypeOrmHealthIndicator,
  ) {}

  @Get()
  check() {
    // Liveness Probe: Grundlegende Funktionsfähigkeit
    return this.health.check([
      () => this.http.pingCheck('api', 'http://localhost:3000/'),
    ]);
  }

  @Get('ready')
  ready() {
    // Readiness Probe: Bereit für Traffic
    return this.health.check([
      () => this.db.pingCheck('database'),
      () => this.http.pingCheck('external-service', 'https://api.external.com/health'),
    ]);
  }
}

Die Unterscheidung ist wichtig: Die Liveness Probe prüft, ob der Container noch lebt und neu gestartet werden sollte, falls er hängt. Die Readiness Probe prüft, ob der Container bereit ist, Traffic zu empfangen. Ein Container, der seine Readiness Probe nicht besteht, wird aus dem Load Balancer entfernt, aber nicht neu gestartet.

27.4 Helm Charts für NestJS

Helm vereinfacht das Deployment komplexer Kubernetes-Anwendungen erheblich. Ein Helm Chart für NestJS-Anwendungen sollte flexibel konfigurierbar sein und verschiedene Deployment-Szenarien unterstützen.

# Chart.yaml
apiVersion: v2
name: nestjs-app
description: A Helm chart for NestJS applications
type: application
version: 0.1.0
appVersion: "1.0.0"

Die values.yaml definiert konfigurierbare Parameter:

# values.yaml
replicaCount: 3

image:
  repository: your-registry.com/nestjs-app
  pullPolicy: IfNotPresent
  tag: "latest"

service:
  type: ClusterIP
  port: 80
  targetPort: 3000

ingress:
  enabled: true
  className: nginx
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - host: api.example.com
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: nestjs-tls
      hosts:
        - api.example.com

resources:
  limits:
    cpu: 500m
    memory: 512Mi
  requests:
    cpu: 250m
    memory: 256Mi

autoscaling:
  enabled: true
  minReplicas: 3
  maxReplicas: 10
  targetCPUUtilizationPercentage: 80

config:
  nodeEnv: production
  logLevel: info
  corsOrigins: "https://app.example.com"

secrets:
  databaseUrl: ""
  jwtSecret: ""
  redisPassword: ""

Ein Template für das Deployment würde die Values verwenden:

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: \\{{ include "nestjs-app.fullname" . }}
  labels:
    {{- include "nestjs-app.labels" . | nindent 4 }}
spec:
  {{- if not .Values.autoscaling.enabled }}
  replicas: {{ .Values.replicaCount }}
  {{- end }}
  selector:
    matchLabels:
      {{- include "nestjs-app.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "nestjs-app.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: {{ .Values.service.targetPort }}
              protocol: TCP
          env:
            - name: NODE_ENV
              value: {{ .Values.config.nodeEnv }}
            - name: LOG_LEVEL
              value: {{ .Values.config.logLevel }}
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: {{ include "nestjs-app.fullname" . }}-secrets
                  key: databaseUrl
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

27.5 Konfiguration für Containerumgebungen

Container-Umgebungen erfordern eine andere Herangehensweise an die Konfiguration als traditionelle Deployments. Die Twelve-Factor App Methodology empfiehlt, Konfiguration über Umgebungsvariablen zu verwalten.

Für NestJS bedeutet dies, dass Ihr Configuration Service container-freundlich gestaltet werden sollte:

// config.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AppConfigService {
  constructor(private configService: ConfigService) {}

  get port(): number {
    return this.configService.get<number>('PORT', 3000);
  }

  get databaseUrl(): string {
    const url = this.configService.get<string>('DATABASE_URL');
    if (!url) {
      throw new Error('DATABASE_URL environment variable is required');
    }
    return url;
  }

  get jwtSecret(): string {
    const secret = this.configService.get<string>('JWT_SECRET');
    if (!secret) {
      throw new Error('JWT_SECRET environment variable is required');
    }
    return secret;
  }

  get logLevel(): string {
    return this.configService.get<string>('LOG_LEVEL', 'info');
  }

  // Container-spezifische Konfiguration
  get gracefulShutdownTimeout(): number {
    return this.configService.get<number>('GRACEFUL_SHUTDOWN_TIMEOUT', 10000);
  }

  get healthCheckInterval(): number {
    return this.configService.get<number>('HEALTH_CHECK_INTERVAL', 30000);
  }
}

Graceful Shutdown ist in Container-Umgebungen besonders wichtig, da Container häufig gestoppt und gestartet werden:

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // Graceful Shutdown konfigurieren
  app.enableShutdownHooks();
  
  // Signal Handler für SIGTERM (Container Stop)
  process.on('SIGTERM', async () => {
    console.log('SIGTERM received, shutting down gracefully');
    await app.close();
    process.exit(0);
  });

  await app.listen(process.env.PORT || 3000, '0.0.0.0');
}
bootstrap();

27.6 Überwachung und Logging in Containern

Container-Logging unterscheidet sich grundlegend von traditionellem Logging. Container sollten ihre Logs an stdout/stderr ausgeben, wo sie von der Container-Runtime erfasst und an zentrale Logging-Systeme weitergeleitet werden können.

// logger.service.ts
import { Injectable, LoggerService } from '@nestjs/common';
import * as winston from 'winston';

@Injectable()
export class ContainerLoggerService implements LoggerService {
  private logger: winston.Logger;

  constructor() {
    this.logger = winston.createLogger({
      level: process.env.LOG_LEVEL || 'info',
      format: winston.format.combine(
        winston.format.timestamp(),
        winston.format.errors({ stack: true }),
        winston.format.json() // JSON-Format für bessere Parsbarkeit
      ),
      transports: [
        // Nur Console-Output in Containern
        new winston.transports.Console({
          handleExceptions: true,
          handleRejections: true
        })
      ]
    });
  }

  log(message: string, context?: string) {
    this.logger.info(message, { context });
  }

  error(message: string, trace?: string, context?: string) {
    this.logger.error(message, { trace, context });
  }

  warn(message: string, context?: string) {
    this.logger.warn(message, { context });
  }

  debug(message: string, context?: string) {
    this.logger.debug(message, { context });
  }

  verbose(message: string, context?: string) {
    this.logger.verbose(message, { context });
  }
}

Für das Monitoring in Container-Umgebungen sollten Sie Prometheus-Metriken bereitstellen:

// metrics.service.ts
import { Injectable } from '@nestjs/common';
import { register, Counter, Histogram, Gauge } from 'prom-client';

@Injectable()
export class MetricsService {
  private httpRequestsTotal = new Counter({
    name: 'http_requests_total',
    help: 'Total number of HTTP requests',
    labelNames: ['method', 'route', 'status']
  });

  private httpRequestDuration = new Histogram({
    name: 'http_request_duration_seconds',
    help: 'HTTP request duration in seconds',
    labelNames: ['method', 'route']
  });

  private activeConnections = new Gauge({
    name: 'active_connections',
    help: 'Number of active connections'
  });

  constructor() {
    // Registriere Metriken
    register.registerMetric(this.httpRequestsTotal);
    register.registerMetric(this.httpRequestDuration);
    register.registerMetric(this.activeConnections);
  }

  recordHttpRequest(method: string, route: string, status: number, duration: number) {
    this.httpRequestsTotal.inc({ method, route, status: status.toString() });
    this.httpRequestDuration.observe({ method, route }, duration);
  }

  setActiveConnections(count: number) {
    this.activeConnections.set(count);
  }

  getMetrics() {
    return register.metrics();
  }
}

Diese umfassende Herangehensweise an die Containerisierung stellt sicher, dass Ihre NestJS-Anwendung nicht nur in Container-Umgebungen läuft, sondern auch alle Vorteile moderner Container-Orchestrierung nutzen kann. Von der sicheren Image-Erstellung über sophisticated Kubernetes-Deployments bis hin zu professionellem Monitoring - jeder Aspekt trägt dazu bei, dass Ihre Anwendung zuverlässig, skalierbar und wartbar in produktiven Umgebungen operiert.