Veröffentlicht am

.NET-Anwendungen auf Kubernetes containerisieren

Teilen:
Authors

TL;DR

.NET-8-Anwendungen lassen sich mit einem Multi-Stage Dockerfile schlank containerisieren. Nutze die Health-Check-Middleware von ASP.NET Core für Liveness- und Readiness-Probes. Konfiguriere Kestrel explizit für Container-Umgebungen und setze immer Resource Limits in deinen Kubernetes-Deployments.


.NET auf Kubernetes — vom Code zum laufenden Pod

ASP.NET Core ist für Container gebaut. Das Framework läuft cross-platform, bringt einen eigenen Webserver (Kestrel) mit und unterstützt Health Checks nativ. Trotzdem gibt es bei der Containerisierung einige Stolperfallen.

Multi-Stage Dockerfile

Ein Multi-Stage Build trennt Build-Umgebung und Runtime-Image. Das Ergebnis: ein schlankes Produktions-Image ohne SDK und Build-Artefakte.

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

# Dependencies zuerst kopieren (besseres Layer-Caching)
COPY *.csproj ./
RUN dotnet restore

# Dann den Rest
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore

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

# Non-Root User verwenden
RUN groupadd -r appgroup && useradd -r -g appgroup appuser
USER appuser

COPY --from=build /app/publish .

ENV ASPNETCORE_URLS=http://+:8080
ENV DOTNET_EnableDiagnostics=0

EXPOSE 8080
ENTRYPOINT ["dotnet", "MeineApp.dll"]

Dieses Dockerfile erzeugt ein Image von ca. 220 MB statt über 1 GB mit dem vollen SDK. Der Non-Root-User ist wichtig für Kubernetes-Cluster mit Pod Security Standards.

Bauen und testen:

# Image bauen
docker build -t meine-dotnet-app:v1.0 .

# Lokal testen
docker run -p 8080:8080 meine-dotnet-app:v1.0

# Image-Größe prüfen
docker images meine-dotnet-app

Health Checks mit ASP.NET Core

Kubernetes braucht Health-Check-Endpunkte, um zu entscheiden, ob ein Pod Traffic empfangen soll (Readiness) und ob er neu gestartet werden muss (Liveness). ASP.NET Core bringt dafür eine Middleware mit.

In Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Health Checks registrieren
builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy())
    .AddNpgSql(builder.Configuration.GetConnectionString("Default")!,
        name: "database",
        tags: new[] { "ready" })
    .AddRedis(builder.Configuration.GetConnectionString("Redis")!,
        name: "redis",
        tags: new[] { "ready" });

var app = builder.Build();

// Liveness: Nur Grundfunktion prüfen
app.MapHealthChecks("/healthz", new HealthCheckOptions
{
    Predicate = check => !check.Tags.Contains("ready")
});

// Readiness: Alle Abhängigkeiten prüfen
app.MapHealthChecks("/ready", new HealthCheckOptions
{
    Predicate = check => check.Tags.Contains("ready")
});

app.MapControllers();
app.Run();

Der /healthz-Endpunkt prüft nur, ob die App selbst läuft. Der /ready-Endpunkt prüft zusätzlich Datenbank und Redis — so bekommt der Pod erst Traffic, wenn alle Abhängigkeiten verfügbar sind.

Kestrel für Container konfigurieren

Kestrel ist der Standard-Webserver in ASP.NET Core. In Containern musst du einige Defaults anpassen.

{
  "Kestrel": {
    "Limits": {
      "MaxConcurrentConnections": 200,
      "MaxRequestBodySize": 10485760,
      "KeepAliveTimeout": "00:02:00",
      "RequestHeadersTimeout": "00:00:30"
    }
  },
  "Logging": {
    "LogLevel": {
      "Default": "Warning",
      "Microsoft.AspNetCore": "Warning"
    }
  }
}

Drei wichtige Punkte:

  • Port: Setze ASPNETCORE_URLS=http://+:8080 als Umgebungsvariable. Binde niemals auf localhost, sonst ist der Container von außen nicht erreichbar.
  • Logging: Reduziere das Log-Level in Production. ASP.NET Core loggt im Development-Modus extrem viel, was in Kubernetes Speicher kostet.
  • Graceful Shutdown: .NET reagiert auf SIGTERM korrekt und beendet laufende Requests. Setze terminationGracePeriodSeconds in Kubernetes passend.

Kubernetes Deployment und Service

Jetzt das komplette Kubernetes-Setup:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: dotnet-api
  namespace: production
  labels:
    app: dotnet-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: dotnet-api
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 1
  template:
    metadata:
      labels:
        app: dotnet-api
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: api
          image: mein-registry.azurecr.io/dotnet-api:v1.0
          ports:
            - containerPort: 8080
              name: http
          env:
            - name: ASPNETCORE_ENVIRONMENT
              value: "Production"
            - name: ASPNETCORE_URLS
              value: "http://+:8080"
            - name: ConnectionStrings__Default
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: connection-string
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              cpu: 500m
              memory: 512Mi
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 15
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3
          startupProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 12
---
apiVersion: v1
kind: Service
metadata:
  name: dotnet-api
  namespace: production
spec:
  selector:
    app: dotnet-api
  ports:
    - port: 80
      targetPort: 8080
      name: http
  type: ClusterIP
EinstellungWertWarum
requests.memory128Mi.NET-Baseline für eine kleine API
limits.memory512MiPuffer für GC-Spitzen und Caching
startupProbe60s max.NET braucht beim ersten Start Zeit für JIT
terminationGracePeriodSeconds30Genug Zeit für laufende Requests

Häufige Fehler vermeiden

GC und Memory Limits: .NET erkennt Container-Memory-Limits automatisch seit .NET 6. Der Garbage Collector passt sich an limits.memory an. Setze trotzdem immer Limits — ohne sie kennt die Runtime das Budget nicht und allokiert zu viel.

Image-Tag-Strategie: Verwende niemals latest als Tag. Nutze semantische Versionierung oder den Git-Commit-Hash:

# Image mit Git-SHA taggen
docker build -t mein-registry.azurecr.io/dotnet-api:$(git rev-parse --short HEAD) .

# Oder mit Versionsnummer
docker build -t mein-registry.azurecr.io/dotnet-api:v1.2.3 .

Culture und Timezone: .NET-Container nutzen standardmäßig die invariante Culture und UTC. Wenn deine App lokalisierte Formate braucht, setze die Umgebungsvariable DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false und installiere das icu-Paket im Dockerfile.

FAQ

Welches Base Image soll ich verwenden — aspnet, runtime oder nanoserver?

Für ASP.NET-Core-Apps nimm mcr.microsoft.com/dotnet/aspnet:8.0. Für Konsolen-Apps ohne Webserver reicht mcr.microsoft.com/dotnet/runtime:8.0. Nanoserver-Varianten sind kleiner, laufen aber nur auf Windows Nodes.

Wie debugge ich eine .NET-App im Kubernetes-Cluster?

Nutze kubectl port-forward für direkten Zugriff und kubectl logs für die Ausgabe. Für Remote-Debugging kannst du den .NET-Debugger vsdbg ins Container-Image einbauen — aber nur im Staging, niemals in Production.

Brauche ich ein Distroless Base Image?

Distroless Images von Google (gcr.io/distroless/dotnet) enthalten keine Shell und reduzieren die Angriffsfläche. Der Nachteil: Debugging im Container ist ohne Shell schwierig. Für Production-Workloads mit hohen Sicherheitsanforderungen sind sie empfehlenswert.

Wie setze ich CPU-Limits für .NET richtig?

.NET erkennt CPU-Limits und passt die Thread-Pool-Größe an. Setze requests.cpu auf den Normalverbrauch und limits.cpu auf das Doppelte bis Dreifache. Zu niedrige CPU-Limits führen zu Throttling und hohen Latenzen.

Soll ich AOT-Kompilierung für Container nutzen?

Native AOT (seit .NET 8) erzeugt ein einzelnes Binary ohne Runtime-Abhängigkeit. Startup-Zeit sinkt auf unter 50ms. Der Nachteil: kein Reflection, keine dynamische Code-Generierung. Für APIs mit minimalen Abhängigkeiten ist AOT ideal, für komplexe Anwendungen mit Entity Framework eher nicht.


Legacy zu Kubernetes migrieren?

Wir begleiten Ihre Migration von VMs zu Containern – ohne Produktionsausfall. Erfahrung aus 50+ Migrationsprojekten.

Kubernetes-Beratung gesucht?

Wir helfen deutschen Unternehmen bei der Kubernetes-Implementierung, Migration und Optimierung. DSGVO-konform und praxiserprobt.

📖 Verwandte Artikel

Weitere interessante Beiträge zu ähnlichen Themen