Service Readiness with Probes — Readiness, Liveness, and Startup

Lesson 21 of 39 14 min read Updated

In the first two articles you learned that a Service claims every Pod whose labels match its selector, and forwards traffic to them. That rule has an uncomfortable consequence: the moment a Pod exists with the right labels, the Service starts sending it real user requests.

Nobody asked the application whether it was ready. A container can be running while its process is still loading configuration, warming a cache, or waiting on a database. During those seconds, a share of your users get errors, and nothing in the cluster looks wrong.

Probes close that gap. They are configured on the Pod, not on the Service, but they are what decides whether a Pod is allowed to appear behind a Service at all. This is the third and final article on Services.

We will cover the following topics:

  1. The three probes and the distinct question each one answers
  2. The four probe mechanisms: HTTP, command, TCP, and gRPC
  3. Every timing parameter, and what happens when you get them wrong
  4. Watching a Pod leave and rejoin a Service without ever restarting
  5. Watching a Pod get restarted by a failing liveness probe

Technical Requirements

  1. A working Kubernetes cluster and kubectl.
  2. Docker and the .NET 10 SDK if you want to build the sample image.

Core Concepts

Three Probes, Three Questions

The names sound similar and the YAML looks almost identical, which is why they get confused. The way to keep them straight is to remember that each answers a different question and each has a different consequence when it fails.

ProbeQuestionOn failure
startupProbeHas the application finished starting?The container is restarted, but only after the whole allowance is used up.
readinessProbeCan this Pod serve requests right now?The Pod is removed from Service endpoints. It is not restarted.
livenessProbeIs this container broken beyond recovery?The container is killed and restarted.

The distinction that matters most is between the last two. Readiness is reversible and gentle: a Pod that fails it stops receiving traffic but keeps running, and it rejoins the moment it recovers. Liveness is a death sentence: failing it restarts the container and throws away whatever state it held.

Get this backwards and you cause outages. A liveness probe that checks a database connection will restart every one of your Pods when the database has a hiccup, turning a brief dependency blip into a full restart storm. That check belongs in the readiness probe, where the Pods quietly leave the load balancer and come back when the database returns.

One more thing about liveness: it cannot repair anything. It only detects that a container is broken and orders its termination, hoping a fresh start behaves better.

How startupProbe Protects the Other Two

Legacy applications often need a long time on first start. That creates a dilemma: liveness probes should react fast to catch deadlocks, but a fast liveness probe will kill a slow starter before it ever finishes booting.

You can paper over it with a large initialDelaySeconds, but that delay applies to every check cycle decision, so you lose fast detection forever in exchange for a slow start once.

startupProbe is the proper answer. While it is running, the readiness and liveness probes are suspended entirely. Give it a generous failureThreshold * periodSeconds budget, and the application gets all the time it needs. The instant it succeeds, the startup probe never runs again and the other two take over with tight, fast settings.

The Four Probe Mechanisms

All three probes support the same set of mechanisms. Pick the one that matches your workload.

MechanismSucceeds whenGood for
httpGetThe response code is at least 200 and below 400Web services and APIs. The most common choice.
execThe command exits with code 0Anything with a CLI health check, such as a database client.
tcpSocketA TCP connection can be openedNon-HTTP protocols such as LDAP or a message broker.
grpcThe gRPC Health Checking Protocol reports servinggRPC services that implement the standard health service.

The HTTP range catches people out. A 404 is a failure, so probing a path that does not exist means the Pod is never healthy. That is a real and common mistake: pointing a probe at /healthcheck on a stock nginx image gives a 404 forever.

HTTP probes also accept host, scheme, path, httpHeaders, and port. A custom header is worth adding, because it lets you filter probe traffic out of your access logs.

What Makes a Good Readiness Endpoint

A readiness endpoint that returns 200 unconditionally is decoration. It proves the web server is listening and nothing else.

Make it actually test what the application needs to serve a request: open a connection to the database, check that a required cache is reachable, verify that configuration finished loading. If you write the application, add a dedicated endpoint that touches every backend and reports the result. That is the endpoint worth probing.

Keep it cheap, though. It runs every few seconds on every replica forever, so it should not do heavy work or cascade into other services.

The Timing Parameters

All three probes share the same tuning fields.

FieldMeaningDefault
initialDelaySecondsWait before the first check0
periodSecondsInterval between checks10
timeoutSecondsHow long a single check may take before counting as a failure1
successThresholdConsecutive successes needed to be considered healthy again1
failureThresholdConsecutive failures needed to act3

The default timeoutSeconds: 1 is the field that most often causes mysterious flapping. A busy application that occasionally takes 1.2 seconds to answer will be marked unhealthy for no good reason. Raise it.

Note that successThreshold must be 1 for liveness and startup probes; Kubernetes rejects any other value. Only readiness allows a higher number.

The time before a probe acts is failureThreshold × periodSeconds, plus the initial delay. With the defaults, a dead container takes about thirty seconds to be restarted. Decide deliberately whether that is too slow or too aggressive for your workload.

Hands-On: Kubernetes Commands

See which Pods are actually receiving traffic

The ready condition on each endpoint is what the Service uses.

kubectl get endpointslice -n fintech -l kubernetes.io/service-name=wallet-api \
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{" ready="}{.conditions.ready}{"\n"}{end}'

Check readiness and restart count together

These two columns separate a readiness problem from a liveness problem at a glance.

kubectl get pods -n fintech -o custom-columns=\
'NAME:.metadata.name,READY:.status.containerStatuses[0].ready,RESTARTS:.status.containerStatuses[0].restartCount'

Read the probe configuration on a running Pod

Confirms which paths, ports, and thresholds are actually in effect.

kubectl describe pod -n fintech -l app=wallet-api | grep -A3 -E 'Liveness|Readiness|Startup'

Find out why a Pod is being restarted

The kubelet records both the failure and its decision.

kubectl describe pod <pod-name> -n fintech | grep -iE 'probe failed|Killing'

Watch readiness change in real time

The READY column flips without the restart count moving.

kubectl get pods -n fintech -l app=wallet-api -w

Step-by-Step Example

The Scenario

A wallet API with three replicas behind a ClusterIP Service. The application deliberately takes twenty seconds to start, and exposes two administrative switches that let us break readiness or liveness on demand. That turns two behaviours that are normally hard to observe into experiments you can run in a minute.

Step 1 — Build the Application

dotnet new web -n WalletApi
cd WalletApi

Replace Program.cs. Notice that each probe endpoint answers a different question, matching the table above:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

string podName = Environment.GetEnvironmentVariable("POD_NAME") ?? Environment.MachineName;

// How long the application pretends to need before it has finished starting up.
int startupSeconds = int.Parse(Environment.GetEnvironmentVariable("STARTUP_DELAY_SECONDS") ?? "20");
DateTimeOffset startedAt = DateTimeOffset.UtcNow;

// Two switches an operator can flip at runtime, so probe behaviour can be demonstrated
// without rebuilding the image or editing the manifest.
bool readyOverride = true;
bool aliveOverride = true;

bool StartupComplete() => DateTimeOffset.UtcNow >= startedAt.AddSeconds(startupSeconds);

// startupProbe target: succeeds only once the slow initialisation has finished.
app.MapGet("/healthz/startup", () => StartupComplete()
? Results.Ok(new { pod = podName, startupComplete = true })
: Results.StatusCode(503));

// livenessProbe target: fails only when the process is genuinely broken.
app.MapGet("/healthz/live", () => aliveOverride
? Results.Ok(new { pod = podName, alive = true })
: Results.StatusCode(500));

// readinessProbe target: fails when the Pod should be removed from the Service endpoints
// while the process keeps running, for example during a dependency outage.
app.MapGet("/healthz/ready", () => readyOverride && StartupComplete()
? Results.Ok(new { pod = podName, ready = true })
: Results.StatusCode(503));

app.MapGet("/", () => Results.Ok(new
{
servedByPod = podName,
ready = readyOverride && StartupComplete(),
alive = aliveOverride,
startupComplete = StartupComplete(),
uptimeSeconds = (int)(DateTimeOffset.UtcNow - startedAt).TotalSeconds
}));

// Flip readiness off: the Pod keeps running but leaves the Service endpoint list.
app.MapPost("/admin/ready/{state:bool}", (bool state) =>
{
readyOverride = state;
return Results.Ok(new { pod = podName, ready = state });
});

// Flip liveness off: the kubelet will restart the container.
app.MapPost("/admin/alive/{state:bool}", (bool state) =>
{
aliveOverride = state;
return Results.Ok(new { pod = podName, alive = state });
});

app.Run();

Add a Dockerfile and build:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish WalletApi.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "WalletApi.dll"]
docker build -t wallet-api:1.0 .

Step 2 — Deploy With All Three Probes

apiVersion: v1
kind: Namespace
metadata:
name: fintech
kubectl apply -f 1-fintech-namespace.yaml

Read the three probe blocks together. The startup probe is generous, the readiness probe is quick to react, and the liveness probe is deliberately slower so a brief hiccup does not cause a restart.

apiVersion: apps/v1
kind: Deployment
metadata:
name: wallet-api
namespace: fintech
labels:
app: wallet-api
spec:
replicas: 3
selector:
matchLabels:
app: wallet-api
template:
metadata:
labels:
app: wallet-api
spec:
containers:
- name: wallet-api
image: wallet-api:1.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
# The application pretends to need 20 seconds of initialisation, which is
# what the startupProbe below is there to cover.
- name: STARTUP_DELAY_SECONDS
value: "20"
resources:
requests:
cpu: "50m"
memory: "96Mi"
limits:
cpu: "300m"
memory: "256Mi"
# startupProbe runs first. Until it succeeds the other two probes are suspended,
# so a slow start cannot be mistaken for a crash.
startupProbe:
httpGet:
path: /healthz/startup
port: http
periodSeconds: 5
failureThreshold: 12 # allows up to 60 seconds to start
timeoutSeconds: 3
# readinessProbe decides whether this Pod appears in the Service endpoints.
# Failing it removes the Pod from traffic without restarting it.
readinessProbe:
httpGet:
path: /healthz/ready
port: http
periodSeconds: 5
timeoutSeconds: 3
successThreshold: 1
failureThreshold: 2
# livenessProbe decides whether the container is restarted.
livenessProbe:
httpGet:
path: /healthz/live
port: http
httpHeaders:
- name: X-Probe-Source
value: kubelet-liveness
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
kubectl apply -f 2-wallet-api-deployment.yaml

Add the Service and a toolbox Pod for testing from inside the cluster:

apiVersion: v1
kind: Service
metadata:
name: wallet-api
namespace: fintech
labels:
app: wallet-api
spec:
type: ClusterIP
selector:
app: wallet-api
ports:
- name: http
port: 80
targetPort: http
protocol: TCP
apiVersion: v1
kind: Pod
metadata:
name: probe-toolbox
namespace: fintech
labels:
app: probe-toolbox
spec:
containers:
- name: toolbox
image: nicolaka/netshoot:latest
imagePullPolicy: IfNotPresent
command: ["sleep", "infinity"]
resources:
requests:
cpu: "25m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
restartPolicy: Always
kubectl apply -f 3-wallet-api-service.yaml
kubectl apply -f 4-wallet-probe-toolbox.yaml

Step 3 — Watch the Startup Probe Hold Traffic Back

Watch the Pods come up:

kubectl get pods -n fintech -l app=wallet-api -w

Sampled every five seconds, the picture is unambiguous:

t+05s wallet-api-...-dxkgt(0/1) wallet-api-...-ms5g9(0/1) wallet-api-...-qsql4(0/1)
t+15s wallet-api-...-dxkgt(0/1) wallet-api-...-ms5g9(0/1) wallet-api-...-qsql4(0/1)
t+25s wallet-api-...-dxkgt(0/1) wallet-api-...-ms5g9(0/1) wallet-api-...-qsql4(0/1)
t+30s wallet-api-...-dxkgt(1/1) wallet-api-...-ms5g9(1/1) wallet-api-...-qsql4(1/1)

For half a minute every Pod is Running but 0/1. The containers started immediately; the applications had not finished initialising, so the startup probe kept failing and the Pods stayed out of the Service.

This is exactly the window in which, without probes, your users would have received errors. Note also that nothing was restarted: the liveness probe was suspended for the whole period, which is the entire point of having a startup probe.

Step 4 — The Key Experiment: Readiness Removes Traffic Without Restarting

Confirm all three Pods are receiving traffic:

kubectl get endpointslice -n fintech -l kubernetes.io/service-name=wallet-api \
-o jsonpath='{range .items[*].endpoints[*]}{.addresses[0]}{" ready="}{.conditions.ready}{"\n"}{end}'
10.244.1.78 ready=true
10.244.2.84 ready=true
10.244.1.77 ready=true

Now break readiness on one of them, addressing the Pod directly so we bypass the Service:

kubectl exec probe-toolbox -n fintech -- curl -s -X POST http://10.244.1.78:8080/admin/ready/false
{"pod":"wallet-api-67dc89d6d-76fkr","ready":false}

Wait about fifteen seconds, then look at the Pod:

kubectl get pod wallet-api-67dc89d6d-76fkr -n fintech -o custom-columns=\
'NAME:.metadata.name,READY:.status.containerStatuses[0].ready,STATUS:.status.phase,RESTARTS:.status.containerStatuses[0].restartCount'
wallet-api-67dc89d6d-76fkr false Running 0

Read that line carefully. READY is false, STATUS is still Running, and RESTARTS is still 0. The container was not touched. And the endpoint changed:

10.244.1.78 ready=false
10.244.2.84 ready=true
10.244.1.77 ready=true

Send fifteen requests through the Service and count who answers:

kubectl exec probe-toolbox -n fintech -- sh -c 'for i in $(seq 1 15); do curl -s http://wallet-api/; echo; done'
8 x wallet-api-67dc89d6d-x8x4q
7 x wallet-api-67dc89d6d-vmmms

Only two Pods answered. The third is alive and healthy from the kubelet's point of view, but the Service is not sending it anything. Turn readiness back on:

kubectl exec probe-toolbox -n fintech -- curl -s -X POST http://10.244.1.78:8080/admin/ready/true

Within a few seconds it rejoins:

10.244.1.78 ready=true
10.244.2.84 ready=true
10.244.1.77 ready=true

That round trip, out of rotation and back, with no restart and no lost state, is what makes readiness the right place for dependency checks.

Step 5 — Liveness Restarts the Container

Now break liveness on a different Pod and watch the opposite happen:

kubectl exec probe-toolbox -n fintech -- curl -s -X POST http://10.244.1.77:8080/admin/alive/false

Sample the Pod every five seconds:

t+05s true 0 Running
t+15s true 0 Running
t+25s true 0 Running
t+30s false 1 Running

Nothing happened for twenty-five seconds, then the restart count went to 1. That delay is failureThreshold: 3 multiplied by periodSeconds: 10. The kubelet waited for three consecutive failures before acting, exactly as configured.

The events say so explicitly:

kubectl describe pod wallet-api-67dc89d6d-vmmms -n fintech | grep -iE 'probe failed|Killing'
Warning Unhealthy 1s (x3 over 21s) kubelet Liveness probe failed: HTTP probe failed with statuscode: 500
Normal Killing 1s kubelet Container wallet-api failed liveness probe, will be restarted

Compare this with Step 4. Same application, same kind of probe, same HTTP mechanism. The only difference is which probe was failing, and the outcomes could hardly be further apart: one Pod quietly left the load balancer, the other had its container killed.

Note also that the restarted container starts from zero, which means it goes through the twenty-second startup delay again before serving traffic. A liveness restart is not free.

Step 6 — Clean Up

kubectl delete namespace fintech

Common Mistakes

Four errors account for most probe-related incidents.

  1. Checking dependencies in the liveness probe. When the database blips, every replica restarts at once and a small problem becomes an outage. Dependency checks belong in readiness.
  2. Probing a path that returns 404. Anything outside 200-399 is a failure, so a missing endpoint means the Pod is never healthy. Verify the path exists in the image you are actually running.
  3. Leaving timeoutSeconds at its default of 1. A Pod that occasionally answers in 1.2 seconds will flap for no reason.
  4. Using a long initialDelaySeconds instead of a startup probe. You pay that delay in slow failure detection forever, rather than once at startup.

Summary

  1. Probes are configured on the Pod, but they decide which Pods a Service will use.
  2. readinessProbe removes a Pod from Service endpoints without restarting it. Reversible and gentle.
  3. livenessProbe kills and restarts the container. It cannot repair anything, only detect and terminate.
  4. startupProbe suspends the other two until the application has finished initialising, so a slow start is never mistaken for a crash.
  5. Four mechanisms: httpGet, exec, tcpSocket, and grpc. HTTP succeeds on 200-399 only.
  6. Time to act is failureThreshold × periodSeconds. With the defaults, about thirty seconds.
  7. successThreshold must be 1 for liveness and startup probes.
  8. A good readiness endpoint tests real dependencies, and stays cheap enough to run every few seconds forever.
  9. Put dependency checks in readiness, never in liveness.

That completes this series on Services. You can now expose Pods inside the cluster, expose them to the outside world, and control precisely when each Pod is allowed to receive traffic.