A single /health endpoint that returns 200 is one of the most common production traps in containerized .NET. It conflates three different questions that Kubernetes asks separately, and getting them confused leads to two classic failures: traffic routed to pods that are not ready, and healthy pods killed mid-warmup. The fix is to answer each question with its own endpoint.
Three probes, three questions
Kubernetes probes are not interchangeable:
- Liveness - is the process wedged and in need of a restart? If this fails, the pod is killed.
- Readiness - can the pod serve traffic right now? If this fails, it is pulled from the load balancer but not killed.
- Startup - has the app finished its potentially slow initialization? Until this passes, liveness and readiness are held off so a slow start is not mistaken for a hang.
Mapping all three to one check guarantees you get at least one of them wrong.
Tagging checks in ASP.NET Core
The ASP.NET Core health-check system lets you tag individual checks and expose endpoints filtered by tag. Register checks with the tags that describe which probe they belong to:
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
.AddNpgSql(connString, tags: ["ready"])
.AddCheck<WarmupHealthCheck>("warmup", tags: ["startup"]);
Then map three endpoints, each filtering by a tag predicate:
app.MapHealthChecks("/alive", new() { Predicate = r => r.Tags.Contains("live") });
app.MapHealthChecks("/health", new() { Predicate = r => r.Tags.Contains("ready") });
app.MapHealthChecks("/startup", new() { Predicate = r => r.Tags.Contains("startup") });
Now /alive stays cheap and dependency-free, /health checks the things you need to serve a request, and /startup reports initialization progress.
The warmup gate
The subtle part is startup. Migrations, cache priming, and bus connections take time, and during that window the app is running but not ready. A warmup gate - a hosted service that holds /startup unhealthy until initialization completes - solves this without Thread.Sleep or guesswork:
public sealed class StartupWarmupGate : IHostedService
{
private volatile bool _ready;
public bool IsReady => _ready;
public void MarkReady() => _ready = true;
public Task StartAsync(CancellationToken ct) => Task.CompletedTask;
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
The startup health check simply reports the gate's state, and your initialization code calls MarkReady() when migrations and warmup actually finish.
Note: Keep liveness dumb. A liveness check that touches the database will restart your pods during a database blip - turning a recoverable dependency hiccup into a cascading outage.
Wiring the probes
The deployment manifest points each probe at its endpoint, with the startup probe given a generous failure threshold so slow-but-healthy starts are tolerated:
startupProbe:
httpGet: { path: /startup, port: 8080 }
failureThreshold: 30
periodSeconds: 5
livenessProbe:
httpGet: { path: /alive, port: 8080 }
readinessProbe:
httpGet: { path: /health, port: 8080 }
Wrapping up
Three probes exist because they answer three different questions, and your app should answer them separately. Tag your health checks, expose /alive, /health, and /startup, and gate startup behind a real warmup signal. Do that and Kubernetes will route traffic only when you are genuinely ready and restart only when you are genuinely stuck - which is the entire point of health checks.
Want your services production-hardened from day one? Talk to us.
