.NET & C# Development · Lesson 161 of 229
Running .NET in Kubernetes — Health Probes, Graceful Shutdown, and Configuration
Running .NET in Kubernetes — Health Probes, Graceful Shutdown, and Configuration
Kubernetes manages your pods, but it needs to know when they are ready and when to restart them. This guide covers everything .NET needs to run correctly in a K8s cluster.
Health Probes — What Kubernetes Checks
Liveness probe: Is the pod alive? Failing = restart the pod
Readiness probe: Is the pod ready to receive traffic? Failing = remove from load balancer
Startup probe: Has the pod finished initialising? Prevents liveness from killing slow starters
Sequence:
1. Startup probe runs until it succeeds (app is ready to accept liveness checks)
2. Readiness probe runs — pod added to service endpoints when healthy
3. Liveness probe runs continuously — pod restarted if it fails N timesStep 1: Add Health Checks in ASP.NET Core
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.*" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.*" />
<PackageReference Include="AspNetCore.HealthChecks.RabbitMQ" Version="8.*" />// Program.cs
builder.Services.AddHealthChecks()
// Database connectivity
.AddDbContextCheck<AppDbContext>(
name: "database",
tags: ["ready"])
// Redis connectivity
.AddRedis(
builder.Configuration.GetConnectionString("Redis")!,
name: "redis",
tags: ["ready"])
// Custom business check (e.g., can we reach a critical dependency?)
.AddCheck<ExternalPaymentGatewayCheck>(
name: "payment-gateway",
tags: ["ready"])
// Liveness — just "is the process responsive?"
.AddCheck("live", () => HealthCheckResult.Healthy(), tags: ["live"]);
// Map health endpoints with tag filters
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("live"),
ResponseWriter = WriteHealthResponse,
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"),
ResponseWriter = WriteHealthResponse,
});
// Human-readable JSON response
static Task WriteHealthResponse(HttpContext ctx, HealthReport report)
{
ctx.Response.ContentType = "application/json";
return ctx.Response.WriteAsync(JsonSerializer.Serialize(new
{
status = report.Status.ToString(),
checks = report.Entries.Select(e => new
{
name = e.Key,
status = e.Value.Status.ToString(),
duration = e.Value.Duration.TotalMilliseconds,
error = e.Value.Exception?.Message,
}),
}));
}// Custom health check — implement IHealthCheck
public class ExternalPaymentGatewayCheck(IHttpClientFactory httpFactory) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken ct = default)
{
try
{
var client = httpFactory.CreateClient("payment-gateway");
var response = await client.GetAsync("/ping", ct);
return response.IsSuccessStatusCode
? HealthCheckResult.Healthy()
: HealthCheckResult.Degraded($"HTTP {(int)response.StatusCode}");
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("Payment gateway unreachable", ex);
}
}
}Step 2: Kubernetes Probe Configuration
containers:
- name: api
image: myapp-api:1.0.0
ports:
- containerPort: 8080
# Startup probe: give the app up to 60s to start before liveness kicks in
startupProbe:
httpGet:
path: /health/live
port: 8080
failureThreshold: 12 # 12 attempts × 5s = 60s max startup time
periodSeconds: 5
# Liveness: restart if unresponsive for 30s
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 0 # startup probe handles the delay
periodSeconds: 10
failureThreshold: 3 # restart after 3 consecutive failures
# Readiness: remove from load balancer if dependencies are down
readinessProbe:
httpGet:
path: /health/ready
port: 8080
periodSeconds: 10
failureThreshold: 3
successThreshold: 1Step 3: Graceful Shutdown
When Kubernetes stops a pod (rolling update, scale-down, node drain), it sends SIGTERM. Your app has a grace period to finish in-flight requests before SIGKILL.
// ASP.NET Core handles SIGTERM automatically — but configure the timeout
builder.Services.Configure<HostOptions>(opts =>
{
opts.ShutdownTimeout = TimeSpan.FromSeconds(30); // default is 5s — too short
});
// The shutdown sequence:
// 1. Kubernetes sends SIGTERM
// 2. ASP.NET Core stops accepting new requests
// 3. In-flight requests have ShutdownTimeout to complete
// 4. IHostedService.StopAsync is called with a CancellationToken
// 5. Kubernetes sends SIGKILL after terminationGracePeriodSeconds// BackgroundService: respect cancellation for graceful shutdown
public class OrderWorker(IMessageBus bus, IMediator mediator, ILogger<OrderWorker> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// stoppingToken is cancelled when SIGTERM is received
await foreach (var message in bus.ConsumeAsync<PlaceOrderMessage>(stoppingToken))
{
try
{
await mediator.Send(message.Command, stoppingToken);
await bus.AcknowledgeAsync(message);
}
catch (OperationCanceledException)
{
// Shutdown requested — nack the message so another pod picks it up
await bus.NegativeAcknowledgeAsync(message, requeue: true);
break;
}
}
logger.LogInformation("OrderWorker stopped gracefully");
}
}# Match terminationGracePeriodSeconds to ShutdownTimeout + buffer
spec:
terminationGracePeriodSeconds: 40 # ShutdownTimeout(30) + SIGKILL buffer(10)
containers:
- name: api
lifecycle:
preStop:
exec:
# Sleep briefly so K8s removes the pod from endpoints before draining starts
command: ["/bin/sh", "-c", "sleep 5"]Step 4: Configuration from Environment and Secrets
// ASP.NET Core reads environment variables as configuration automatically
// K8s env vars map to nested config: ConnectionStrings__Default → ConnectionStrings:Default
// appsettings.json (defaults, non-secret)
{
"Logging": { "LogLevel": { "Default": "Information" } },
"AllowedHosts": "*"
}
// appsettings.Production.json (non-secret production overrides)
{
"Logging": { "LogLevel": { "Default": "Warning" } }
}# Kubernetes Deployment — inject secrets as env vars
env:
- name: ConnectionStrings__Default
valueFrom:
secretKeyRef:
name: db-secret
key: connection-string
- name: Redis__ConnectionString
valueFrom:
secretKeyRef:
name: redis-secret
key: connection-string
- name: ASPNETCORE_ENVIRONMENT
value: Production# Better: mount secrets as files — ASP.NET Core reads them via file provider
volumes:
- name: secrets
secret:
secretName: app-secrets # contains appsettings.Secret.json
volumeMounts:
- name: secrets
mountPath: /app/secrets
readOnly: true// Program.cs — add the mounted secrets file to configuration
builder.Configuration.AddJsonFile(
"/app/secrets/appsettings.Secret.json",
optional: true, // won't crash if file missing (local dev)
reloadOnChange: false);Step 5: Resource Limits
resources:
requests:
memory: "256Mi" # K8s uses this for scheduling decisions
cpu: "250m" # 250 millicores = 0.25 CPU
limits:
memory: "512Mi" # OOMKill if exceeded
cpu: "1000m" # throttled (not killed) if exceeded// .NET respects container memory limits automatically in .NET 7+
// Set GC memory limit to match container limit
// DOTNET_GCHeapHardLimit or percentage:
// env: DOTNET_GCHeapHardLimitPercent=75 → GC uses max 75% of container memoryDockerfile for Production
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["src/Api/Api.csproj", "src/Api/"]
COPY ["src/Application/Application.csproj", "src/Application/"]
COPY ["src/Infrastructure/Infrastructure.csproj", "src/Infrastructure/"]
RUN dotnet restore "src/Api/Api.csproj"
COPY . .
RUN dotnet publish "src/Api/Api.csproj" -c Release -o /app/publish \
--no-restore \
/p:UseAppHost=false
FROM base AS final
# Run as non-root for security
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
USER appuser
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "Api.dll"]Interview Answer
"A .NET service in Kubernetes needs three health endpoints: /health/live (liveness — is the process responsive?), /health/ready (readiness — can it serve requests?), and handled by a startup probe that gives slow-starting apps time to initialise before liveness kicks in. ASP.NET Core's AddHealthChecks configures checks with tags to split them across endpoints. Graceful shutdown: K8s sends SIGTERM, ASP.NET Core stops accepting requests, in-flight requests finish within ShutdownTimeout (configure to 30s, not the 5s default), and terminationGracePeriodSeconds must be larger than ShutdownTimeout. Background services receive a stoppingToken — on cancellation, nack in-flight queue messages so another pod picks them up. Configuration: K8s env vars (ConnectionStrings__Default) override appsettings.json automatically; secrets should be mounted as files or injected as env vars from K8s Secrets, never baked into images. Always set resource requests and limits — .NET 7+ respects container memory limits for GC automatically."