.NET Microservices, Performance & Docker

.NET Microservices, Performance & Docker

Explore .net microservices, performance & docker code snippets and tutorials

.NET Microservices, Performance & Docker

Graceful shutdown of ASP.NET Core pods in Kubernetes: SIGTERM and grace period (C#)

The pod is removed from the Service endpoints and, at the same time, the container receives SIGTERM.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ======================================================================
// Q: What happens when Kubernetes stops a pod? How do you shut down gracefully?
//
// - The pod is removed from the Service endpoints and, at the same time, the
//   container receives SIGTERM.
// - The .NET host triggers ApplicationStopping. Kestrel stops accepting new
//   requests and completes the in-flight ones. BackgroundServices get
//   cancellation through the stoppingToken.
// - If it doesn't finish within terminationGracePeriodSeconds (default 30s),
//   SIGKILL follows.
// - Workers: honor the CancellationToken. A message that was not finished is not
//   completed, so it gets redelivered (Q30), which is why the consumer must be
//   idempotent (Q31).
// - Memory: .NET reads the container's memory limit (cgroups) and sizes the GC
//   heap accordingly (default ~75% of the limit). If the pod gets OOMKilled,
//   look for a leak first (Q44) and only then raise the limit.
// - CPU limits cause throttling, which looks like random latency. We often set
//   only CPU requests, without limits.
// ======================================================================

// The .NET timeout must be shorter than the Kubernetes grace period
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(25));
.NET Microservices, Performance & Docker

Kubernetes core objects and a production-ready Deployment for ASP.NET Core (C# health checks)

Pod: the smallest unit: one or more containers sharing a network.

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ======================================================================
// Q: Explain the core Kubernetes objects and show a production-ready Deployment.
//
// - Pod: the smallest unit: one or more containers sharing a network. It is
//   ephemeral.
// - Deployment: keeps N replicas of a Pod, performs rolling updates and
//   rollbacks.
// - Service: a stable DNS name and load balancing to the Pods (Pods change IPs).
// - Ingress / Gateway API: HTTP routing from outside (host, path, TLS) to the
//   Services.
// - ConfigMap / Secret: configuration and sensitive data, as env vars or files.
// - Job / CronJob: tasks that run to completion: migrations (Q8), nightly
//   imports.
// - HPA: automatic scaling of the number of replicas.
// The liveness probe does not check the database or the ERP. If the database
// goes down, all pods would be restarted at the same time for no reason (restart
// storm). Dependencies are checked only in readiness.
// ======================================================================

// Program.cs: separate endpoints for liveness and readiness
builder.Services.AddHealthChecks()
    .AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
    .AddDbContextCheck<AppDbContext>(tags: ["ready"]);

app.MapHealthChecks("/health/live",  new() { Predicate = r => r.Tags.Contains("live") }).AllowAnonymous();
app.MapHealthChecks("/health/ready", new() { Predicate = r => r.Tags.Contains("ready") }).AllowAnonymous();
// AllowAnonymous: otherwise the fallback policy (Q36) returns 401
// and Kubernetes "thinks" the pod is dead
.NET Microservices, Performance & Docker

Kubernetes core objects and a production-ready Deployment for ASP.NET Core (Kubernetes YAML)

Pod: the smallest unit: one or more containers sharing a network.

yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# ======================================================================
# Q: Explain the core Kubernetes objects and show a production-ready Deployment.
#
# - Pod: the smallest unit: one or more containers sharing a network. It is
#   ephemeral.
# - Deployment: keeps N replicas of a Pod, performs rolling updates and
#   rollbacks.
# - Service: a stable DNS name and load balancing to the Pods (Pods change IPs).
# - Ingress / Gateway API: HTTP routing from outside (host, path, TLS) to the
#   Services.
# - ConfigMap / Secret: configuration and sensitive data, as env vars or files.
# - Job / CronJob: tasks that run to completion: migrations (Q8), nightly
#   imports.
# - HPA: automatic scaling of the number of replicas.
# The liveness probe does not check the database or the ERP. If the database
# goes down, all pods would be restarted at the same time for no reason (restart
# storm). Dependencies are checked only in readiness.
# ======================================================================

# Deployment and Service for the API
apiVersion: apps/v1
kind: Deployment
metadata: { name: shop-api }
spec:
  replicas: 3
  selector: { matchLabels: { app: shop-api } }
  strategy:
    rollingUpdate: { maxUnavailable: 0, maxSurge: 1 }        # zero-downtime deploy
  template:
    metadata: { labels: { app: shop-api } }
    spec:
      containers:
        - name: api
          image: myregistry.azurecr.io/shop-api:3f2a9c1        # commit SHA
          ports: [{ containerPort: 8080 }]
          envFrom:
            - configMapRef: { name: shop-api-config }
          resources:
            requests: { cpu: 250m, memory: 256Mi }             # used for scheduling
            limits:   { memory: 512Mi }                        # above this: OOMKilled
          startupProbe:                                        # gives the app time to start
            httpGet: { path: /health/live, port: 8080 }
            periodSeconds: 2
            failureThreshold: 30
          readinessProbe:                                      # on failure: receives no traffic
            httpGet: { path: /health/ready, port: 8080 }
            periodSeconds: 5
          livenessProbe:                                       # on failure: container restart
            httpGet: { path: /health/live, port: 8080 }
            periodSeconds: 10
          securityContext:
            runAsNonRoot: true
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
          volumeMounts: [{ name: tmp, mountPath: /tmp }]       # .NET needs a writable /tmp
      volumes: [{ name: tmp, emptyDir: {} }]
---
apiVersion: v1
kind: Service
metadata: { name: shop-api }
spec:
  selector: { app: shop-api }
  ports: [{ port: 80, targetPort: 8080 }]
.NET Microservices, Performance & Docker

Running a .NET API with PostgreSQL and Redis locally using Docker Compose

With docker compose: the API, PostgreSQL and Redis start with a single command, the same for the whole team.

yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# ======================================================================
# Q: How do you run the service and its dependencies locally?
#
# With docker compose: the API, PostgreSQL and Redis start with a single
# command, the same for the whole team.
# - __ (double underscore) in environment variables maps to : in configuration:
#   ConnectionStrings__Default = ConnectionStrings:Default.
# - Inside a container, localhost is the container itself. Other services are
#   reached by their name (db, redis).
# - Alternatively .NET Aspire (Q33): the same idea, but in C#, with a dashboard
#   for logs and traces.
# ======================================================================

# docker-compose.yml: API, PostgreSQL and Redis
services:
  api:
    build: .
    ports: ["8080:8080"]
    environment:
      ASPNETCORE_ENVIRONMENT: Development
      ConnectionStrings__Default: "Host=db;Database=shop;Username=shop;Password=dev-only"
      ConnectionStrings__Redis: "redis:6379"
    depends_on:
      db:    { condition: service_healthy }   # waits until the database is ready
      redis: { condition: service_started }

  db:
    image: postgres:16-alpine
    environment: { POSTGRES_DB: shop, POSTGRES_USER: shop, POSTGRES_PASSWORD: dev-only }
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shop"]
      interval: 5s
      retries: 10

  redis:
    image: redis:7-alpine

volumes:
  pgdata:
.NET Microservices, Performance & Docker

Writing a good Dockerfile for a .NET service: multi-stage build, chiseled image, non-root user

An image is the immutable package (application, runtime, OS layers).

docker
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# ======================================================================
# Q: What makes a good Dockerfile for a .NET service?
#
# An image is the immutable package (application, runtime, OS layers). A
# container is a running instance of the image.
# - Multi-stage build: the SDK (~800 MB) stays in the build stage. The final
#   image contains only the runtime.
# - Chiseled images: no shell and no package manager, so a much smaller attack
#   surface and fewer CVEs.
# - A .dockerignore with bin/, obj/, .git, *.user, so unrelated files or secrets
#   don't end up in the build context.
# - No secrets in the image: not in ENV, not in appsettings.json. The layer
#   history can be read by anyone who has the image.
# - Tag = commit SHA, never :latest. Build once, and deploy the same image to
#   every environment (Q14).
# - Without a Dockerfile: dotnet publish -t:PublishContainer (.NET 8+) builds an
#   image directly from the SDK.
# ======================================================================

# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

# 1. Project files first: the restore layer stays cached as long as the packages don't change
COPY ["Shop.sln", "Directory.Packages.props", "./"]
COPY ["src/Shop.Api/Shop.Api.csproj", "src/Shop.Api/"]
COPY ["src/Shop.Domain/Shop.Domain.csproj", "src/Shop.Domain/"]
COPY ["src/Shop.Infrastructure/Shop.Infrastructure.csproj", "src/Shop.Infrastructure/"]
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet restore src/Shop.Api/Shop.Api.csproj

# 2. Then the code
COPY . .
RUN --mount=type=cache,target=/root/.nuget/packages \
    dotnet publish src/Shop.Api/Shop.Api.csproj -c Release -o /app --no-restore

# 3. Runtime image: small, no SDK, no shell (chiseled)
FROM mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
USER $APP_UID                    # non-root
EXPOSE 8080                      # since .NET 8 the default port is 8080, not 80
ENTRYPOINT ["dotnet", "Shop.Api.dll"]