Kubernetes Deployment for .NET

Write a Deployment and Service for an ASP.NET Core API with resource settings the GC understands and startup, liveness and readiness probes that fit.

Syntaxresources: requests: { cpu: 500m, memory: 512Mi } limits: { memory: 512Mi } readinessProbe: httpGet: { path: /health/ready, port: http }

A Deployment keeps N identical pods running and replaces them gradually on every change; a Service gives them one stable DNS name and load-balances only to pods whose readiness probe passes. For a .NET API the parts that need thought are resources, because the runtime sizes itself from them, and probes, because a wrong probe turns a small incident into an outage.

Requests, limits and the runtime

The .NET runtime reads the container's cgroup limits at start-up:

ManifestWhat .NET does
limits.memory: 512MiGC heap hard limit defaults to 75% of it (384 MiB); beyond that you get OutOfMemoryException rather than an OOM kill
limits.cpu: 2Environment.ProcessorCount is 2 (rounded up), which sizes Server GC heaps and the thread pool
no CPU limitProcessorCount is the node's core count; override with DOTNET_PROCESSOR_COUNT
requestsScheduling only; the runtime does not see them

ASP.NET Core apps use Server GC. In .NET 10 Server GC runs with DATAS (dynamic adaptation to application sizes) by default, which starts with one heap and grows the heap count under load, so a small pod no longer reserves a heap per core. Set memory request equal to limit: memory cannot be throttled, only killed, and a pod using more than its request is first in line for eviction. CPU limits throttle via CFS quotas and show up as p99 latency spikes; many teams set a CPU request, no CPU limit, and DOTNET_PROCESSOR_COUNT to the request. Native memory (sockets, TLS, compression buffers) lives outside the GC heap, so do not set the heap limit near 100%.

Three probes, three questions

ProbeQuestionOn failureShould check
startupHas it finished starting?Restart after failureThreshold x periodSecondsWarm-up done
livenessIs the process wedged?Restart the containerNothing external
readinessCan it take traffic now?Removed from the Service, not restartedWarm-up, draining, and the dependencies the pod cannot serve without (database, with a short timeout)

Liveness and readiness do not start until the startup probe has passed. Defaults are periodSeconds: 10, timeoutSeconds: 1 and failureThreshold: 3.

Putting the database in readiness is a trade-off, not a mistake: during an outage of a dependency every replica shares, all pods fail readiness together, the Service has no endpoints and the ingress answers 503 to every request, including ones a pod could have served from cache. When the API can still give useful responses without the dependency, report that check as Degraded (still 200) instead of Unhealthy. Give each dependency check a timeout below the probe's timeoutSeconds (a 2 s check, timeoutSeconds: 3), so a hung database is reported as Unhealthy instead of the probe itself timing out. Check registration, tags, predicates and anonymous access are covered in Health Checks.

Production failure modes

  • Liveness checks the database. A 30-second database failover fails liveness on every pod, Kubernetes restarts them all, and the cold restarts outlast the failover. Liveness must only prove the process can answer.
  • A one-second timeout under thread-pool starvation. Sync-over-async code starves the pool, the probe times out, and the pod is killed exactly when it is busiest. Fix the blocking; give liveness a few seconds of timeout.
  • Probes behind auth or rate limits. The kubelet sends no token. Map health endpoints anonymous, outside rate limiting, on the main port.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Ride out a 40-second Postgres failover without restarting all three orders-api replicas, because liveness checks only that the process answers.
  • Give a service that loads 200,000 product prices at start-up 60 seconds to warm up without loosening its liveness probe.
  • Stop p99 latency spikes from CPU throttling by removing the CPU limit and setting DOTNET_PROCESSOR_COUNT to the request.

More examples

Deployment and Service for the orders API

The startup probe allows up to 60 seconds (30 x 2 s) for warm-up without making liveness lenient for the rest of the pod's life. Memory request equals limit, there is no CPU limit to throttle, and <code>DOTNET_PROCESSOR_COUNT</code> sizes the GC and thread pool to the CPU actually requested instead of the node's 32 cores.

Example · yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: orders-api
  labels:
    app: orders-api
spec:
  replicas: 3
  revisionHistoryLimit: 5
  selector:
    matchLabels:
      app: orders-api
  template:
    metadata:
      labels:
        app: orders-api
    spec:
      containers:
        - name: orders-api
          image: registry.example.com/shop/orders-api:1.4.0
          ports:
            - name: http
              containerPort: 8080
          env:
            - name: ASPNETCORE_ENVIRONMENT
              value: Production
            - name: DOTNET_PROCESSOR_COUNT
              value: "1"
          resources:
            requests:
              cpu: 500m
              memory: 512Mi
            limits:
              memory: 512Mi
          startupProbe:
            httpGet:
              path: /health/startup
              port: http
            periodSeconds: 2
            failureThreshold: 30
          livenessProbe:
            httpGet:
              path: /health/live
              port: http
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /health/ready
              port: http
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 2
---
apiVersion: v1
kind: Service
metadata:
  name: orders-api
spec:
  selector:
    app: orders-api
  ports:
    - name: http
      port: 80
      targetPort: http

GC settings that survive the hex trap

Values in <code>runtimeconfig.template.json</code> are decimal. The same settings as environment variables are parsed as hexadecimal: <code>DOTNET_GCHeapHardLimitPercent=70</code> means 0x70, which is 112 percent, so write <code>0x46</code> or use the JSON file. Lowering the limit to 70% leaves room for native memory such as TLS and socket buffers.

Example · json
// runtimeconfig.template.json, next to the .csproj; merged into the app's runtimeconfig.json
{
  "configProperties": {
    "System.GC.Server": true,
    "System.GC.HeapHardLimitPercent": 70,
    "System.GC.RetainVM": false
  }
}

Diagnosing probe failures from the cluster

A readiness failure reported as <code>context deadline exceeded</code> means the probe timed out before the app answered: the dependency check's timeout is not below <code>timeoutSeconds</code>, or the thread pool is starved. A 503 means a check reported Unhealthy. <code>OOMKilled</code> in the last state means the memory limit, not liveness, restarted the container. How to register the checks behind these endpoints is in net-health-checks.

Example · bash
# Why is the pod not Ready? Probe failures appear as events with the HTTP status or timeout.
kubectl describe pod -l app=orders-api | grep -A3 -E "Readiness|Liveness|Startup"
kubectl get events --field-selector reason=Unhealthy --sort-by=.lastTimestamp

# Call the same endpoints the kubelet calls, through a port-forward to the pod.
kubectl port-forward deployment/orders-api 8080:http &
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:8080/health/ready
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://localhost:8080/health/live

# Restarts caused by liveness or OOM show up here.
kubectl get pods -l app=orders-api -o custom-columns=NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount,LAST:.status.containerStatuses[0].lastState.terminated.reason

Discussion

  • Be the first to comment on this lesson.