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.
resources:
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:
| Manifest | What .NET does |
|---|---|
limits.memory: 512Mi | GC heap hard limit defaults to 75% of it (384 MiB); beyond that you get OutOfMemoryException rather than an OOM kill |
limits.cpu: 2 | Environment.ProcessorCount is 2 (rounded up), which sizes Server GC heaps and the thread pool |
| no CPU limit | ProcessorCount is the node's core count; override with DOTNET_PROCESSOR_COUNT |
requests | Scheduling 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
| Probe | Question | On failure | Should check |
|---|---|---|---|
| startup | Has it finished starting? | Restart after failureThreshold x periodSeconds | Warm-up done |
| liveness | Is the process wedged? | Restart the container | Nothing external |
| readiness | Can it take traffic now? | Removed from the Service, not restarted | Warm-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
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.
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: httpGC 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.
// 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.
# 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