Probes, Restarts and Self-Healing
Liveness, readiness and startup probes: how the cluster decides your app is alive, ready for traffic, or beyond saving — and what a badly written probe costs.
Liveness, readiness and startup probes: how the cluster decides your app is alive, ready for traffic, or beyond saving — and what a badly written probe costs.
A process that has crashed is easy to handle: it exits, the kubelet
restarts it. The hard case is the process that is still running and no
longer working — deadlocked, out of database connections, stuck on a
request that will never complete. Its port is open, kubectl get pods
says Running, and every request routed to it fails.
Probes are how you tell the cluster the difference. By the end of this lesson you will know the three kinds, which one removes a pod from traffic and which one kills it, and why a badly written probe is considerably worse than none at all.
Without a probe, "healthy" means one thing: the container's main process has not exited. That is a low bar.
An application can hold its port open while its connection pool is exhausted, its event loop is blocked, a dependency it needs is unreachable, or it is still loading a 4 GB model into memory. In every case the process is alive and the answers are wrong or absent.
Kubernetes cannot infer any of this. Only your application knows whether it can do its job, so it has to be asked — and probes are the asking.
The names are similar and the consequences are not, which is why getting them straight matters more than the syntax.
Can it serve traffic now?
Failing drops it from the Service endpoints and nothing else. The pod keeps running, and traffic returns when it passes again.
Reversible, gentle, and where dependency checks belong.
Is it beyond saving?
Failing kills the container. Use it only for states a restart genuinely fixes.
Has it finished starting?
While it is failing, the other two are not evaluated at all — so a long initialisation cannot be mistaken for a hang.
Three mechanisms are available for any probe: an HTTP request, a TCP connection, or a command run inside the container.
Any HTTP status from 200 to 399 is a pass. The port can be named,
which is worth doing — port: http follows the container port
wherever it moves.
The timing fields, and sensible starting values:
| Field | Default | Meaning |
|---|---|---|
initialDelaySeconds | 0 | Wait before the first check |
periodSeconds | 10 | How often |
timeoutSeconds | 1 | How long to wait for an answer |
failureThreshold | 3 | Consecutive failures before acting |
successThreshold | 1 | Consecutive passes to recover |
timeoutSeconds: 1 is the default most worth changing. One second is
tight for an endpoint that touches a database, and a probe that times
out counts as a failure — which for a liveness probe means a restart
caused by nothing but a slow answer.
The two probes should not hit the same endpoint, because they are asking different questions. This is the whole art of it.
Readiness should check dependencies. Can I reach the database? Is my cache available? Is my configuration loaded? If any answer is no, there is no point sending me requests — but do not restart me, because the problem is not mine.
Liveness should check only itself. Is my event loop turning? Am I past a deadlock? A liveness endpoint should not touch a database, because a database outage would then restart every pod you have — which is the mistake below.
Bad — liveness depends on something outside the container:
Good — liveness checks the process, readiness checks the dependencies:
The bad version turns a thirty-second database blip into an outage, and it does so in five steps.
The database is briefly unavailable
Thirty seconds. Nothing is wrong with your application.
Every pod fails the probe at once
They all check the same database, so they all fail simultaneously. This is not a partial outage any more.
Every container is killed and restarted
Because it was a liveness probe. Restarting was never going to help — the problem was never in the container.
They restart into the same unavailable database
Fail again, get killed again. Now the whole Deployment is in
CrashLoopBackOff.
Backoff extends the outage past the cause
Ten seconds, twenty, forty, up to five minutes. The database came back three minutes ago.
An application that takes ninety seconds to start presents a real dilemma with only two probes. A liveness probe with a short period kills it before it finishes; one lenient enough to allow ninety seconds is also ninety seconds slow to notice a genuine hang later.
initialDelaySeconds is the old workaround, and it is a bad one — you
are guessing a fixed number, and a slow day breaks it.
The startup probe removes the trade-off:
Until the startup probe passes, liveness and readiness are not evaluated. The moment it passes, it is never run again and the strict liveness probe takes over. Generous at the start, strict afterwards.
Probes handle the start. The end has its own race, and it is worth understanding because it is the last source of errors during an otherwise clean rollout.
When a pod is deleted, two things happen in parallel: it is removed
from the Service endpoints, and its container receives SIGTERM. Those
are not synchronised. Endpoint removal has to propagate to kube-proxy
on every node, and for a moment traffic still arrives at a pod that has
already begun shutting down.
The standard fix is a small deliberate pause before shutting down:
Five seconds is enough for endpoint removal to propagate; the
application then handles SIGTERM, finishes its in-flight requests, and
exits — all inside the thirty-second grace period, after which
SIGKILL arrives regardless.
That only works if your process actually receives SIGTERM, which
means the bracket-form CMD from the Docker course. A shell between
Kubernetes and your program swallows the signal, and the pod is killed
hard thirty seconds later.
Probe failures appear in the pod's events, in the cluster's own words:
Three things to check in order when a probe misbehaves:
That last command is the decisive one. If the endpoint answers from inside the container, the probe's configuration is wrong — wrong port, wrong path, timeout too short. If it does not answer, the probe is correct and your application is genuinely not ready.
A pod with rising RESTARTS and no crash in its logs is a liveness
probe doing its job on an application that is not actually broken.
kubectl logs --previous shows what the killed container was saying.
The cluster can now tell working from running, which is the last piece
the scheduler needs before it can make good decisions — and those
decisions depend on knowing how much CPU and memory each pod wants.
That is the next lesson, along with why a pod sits Pending forever
and why another keeps getting OOMKilled.
Before that, add a readiness probe to something you are running and
watch kubectl get pods during a rollout. Seeing READY 0/1 for a few
seconds before a new pod takes traffic is the mechanism working.
Events:
Warning Unhealthy 30s (x3 over 50s) Readiness probe failed:
HTTP probe failed with statuscode: 503
Warning Unhealthy 10s Liveness probe failed:
Get "http://10.244.1.7:8000/health": context
deadline exceeded
Normal Killing 10s Container api failed
liveness probe, will be restartedspec:
containers:
- name: api
image: notes-api:0.1.0
ports:
- name: http
containerPort: 8000
readinessProbe:
httpGet:
path: /ready
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
startupProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
failureThreshold: 30 # up to 150s to start# The other two mechanisms
readinessProbe:
tcpSocket:
port: 5432 # a connection opens = pass
livenessProbe:
exec:
command: ['pg_isready', '-U', 'postgres'] # exit 0 = pass@app.get("/health") # liveness: am I fundamentally alive
def health():
return {"status": "ok"} # deliberately trivial
@app.get("/ready") # readiness: can I serve a request
def ready():
with psycopg.connect(DATABASE_URL, connect_timeout=2) as conn:
conn.execute("SELECT 1")
cache.ping()
return {"status": "ready"}livenessProbe:
httpGet:
path: /ready # this endpoint queries the database
port: http
periodSeconds: 10
failureThreshold: 3livenessProbe:
httpGet:
path: /health # trivial: is the process responsive
port: http
readinessProbe:
httpGet:
path: /ready # queries the database
port: httpstartupProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
failureThreshold: 30 # 5 x 30 = up to 150 seconds
livenessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
failureThreshold: 3 # tight, once it is runningspec:
terminationGracePeriodSeconds: 30
containers:
- name: api
lifecycle:
preStop:
exec:
command: ['sh', '-c', 'sleep 5']kubectl describe pod api-6c9f7d4b58-mn4pqkubectl get pods # READY 0/1 with Running = readiness
kubectl describe pod <name> # the probe's own error message
kubectl exec <name> -- curl -s localhost:8000/ready # try it yourselfspec:
terminationGracePeriodSeconds: 30
containers:
- name: api
ports:
- name: http
containerPort: 8000
# Can I serve traffic? Fail -> removed from endpoints.
# Check dependencies here.
readinessProbe:
httpGet: { path: /ready, port: http }
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
# Am I beyond saving? Fail -> container KILLED.
# Check only the process. Never a database.
livenessProbe:
httpGet: { path: /health, port: http }
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 3
# Have I finished starting? Holds the others off.
startupProbe:
httpGet: { path: /health, port: http }
periodSeconds: 5
failureThreshold: 30 # up to 150s
# Let endpoint removal propagate before shutting down
lifecycle:
preStop:
exec: { command: ['sh', '-c', 'sleep 5'] }# The three mechanisms
httpGet: { path: /ready, port: http } # 200-399 passes
tcpSocket: { port: 5432 } # a connection passes
exec: { command: ['pg_isready', '-U', 'postgres'] } # exit 0 passes# Diagnosing
kubectl get pods # Running but READY 0/1 = readiness
kubectl describe pod <name> # the probe's error, under Events
kubectl exec <name> -- curl -s localhost:8000/ready # test it directly
kubectl logs <name> --previous # what the killed container said
# The rule for choosing
# would a restart fix this? yes -> liveness. no -> readiness.
# Defaults worth overriding
# timeoutSeconds: 1 too tight for anything touching a database
# periodSeconds: 10 fine for liveness, slow for readiness