Skip to main content
RunBook Academy

← All break/fix scenarios in Kubernetes

advancedkubernetes-pod-lifecycle~35 min

Readiness probe failure

Reported symptoms

  • checkout-api returned 503 to every request for roughly six minutes on Tuesday evening, then recovered without anyone changing anything
  • Every Pod was Running throughout, with a restart count of zero; no container ever crashed, so every restart-based alert stayed silent
  • The application logs show ordinary 200s right up to the second traffic stops arriving, then nothing, then ordinary 200s again — no errors of any kind
  • Running curl against the health path from inside any Pod returns 200 in about twenty milliseconds, during the incident and after it
  • It happened during a routine node drain, so the drain was blamed; it then happened again on Wednesday at the lunchtime peak with no drain, no deploy and no infrastructure change
  • The database, CoreDNS and the ingress controller were all healthy, and a Pod-to-Pod curl straight to a Pod IP succeeded while the Service was returning 503

Evidence

  • · kubectl get pods shows nine Pods Running, READY 0/1, RESTARTS 0, during the window
  • · The kubelet event reads: Readiness probe failed: Get http://10.244.7.31:8080/healthz: context deadline exceeded (Client.Timeout exceeded while awaiting headers) — a timeout, not an HTTP status
  • · The probe line from describe pod reads: http-get http://:8080/healthz delay=5s timeout=1s period=5s #success=1 #failure=2
  • · The EndpointSlice for the Service held zero ready addresses for about ninety seconds, twice, roughly five minutes apart
  • · The container CPU limit is 500m against a request of 250m, and the CFS throttled-periods ratio for the surviving Pods reached 0.41 during the window
  • · The Tuesday incident began about forty seconds after a drain removed three of twelve replicas; the Wednesday incident began about forty seconds after the lunchtime traffic step
  • · Ready count over the window does not decay — it collapses to zero, returns to full within one probe period, and collapses again
Diagnosis and resolutionclick to reveal

Root cause

The readiness probe was measuring saturation and responding to it by removing capacity. Its timeout is one second and the health handler runs inside the container, sharing the same CFS quota as the request path, so under load the handler is subject to the same throttling as everything else the process is doing. When the drain removed three of twelve replicas, per-replica load on the remaining nine rose past the point where the handler could reliably answer within a second. Two consecutive misses — failureThreshold 2 at a five-second period, so ten seconds — took a Pod out of the EndpointSlice, its share of traffic moved to the Pods still in the slice, and their handlers began missing the same deadline. That is positive feedback: the response to overload is to withdraw a server, which increases the overload on every server that remains. Roughly forty seconds after the first miss the Service had no ready addresses at all. What happens next is the part that made the incident unreadable. With zero endpoints no Pod receives any traffic, every handler answers in twenty milliseconds, and successThreshold 1 at a five-second period returns the entire fleet to Ready simultaneously — after which full production traffic lands on all of them at once and the collapse repeats. The delay between a readiness change and kube-proxy acting on it gives the loop its phase lag, and feedback plus lag is an oscillator rather than a failure. Nothing was broken. The probe did exactly what it was configured to do, and the configuration says that a slow reply is grounds for removing a working server from a tier that is already short of servers.

Remediation

Scale out first and reason afterwards, because the fleet is sitting in the region where the loop runs and every minute spent diagnosing is a minute it can re-enter. Adding replicas lowers per-replica load below the throttle ceiling and stops the oscillation without touching any configuration; give it an owner and an end time so the temporary replica count does not quietly become the architecture. Then fix the probe so it stops reporting load as ill-health: raise timeoutSeconds above the p99 of the health path measured under the worst load the tier is meant to survive rather than under an idle one, and raise failureThreshold so a single bad window cannot deschedule a Pod. Make the handler cheap and local — it should answer whether this process can accept and serve a request, and it should do no work whose cost grows with traffic. Raise or remove the CPU limit so the probe is not competing for a capped quota; the Pod remains Burstable and keeps its request, and the throttling that is amplifying every latency spike goes away. What must not happen is deleting the probe. Without it the Service routes to Pods that are genuinely unable to serve, and every future rollout loses the only signal that distinguishes a working image from a broken one. The probe is not the problem; a one-second deadline on a path that shares a throttled CPU quota with production traffic is the problem.

Verification

Reproduce the loop before believing it is gone. In staging, drive load until the p99 of the health path approaches the old one-second deadline, then remove a quarter of the replicas and require the fleet to stay Ready throughout. A probe that has only ever been exercised on an idle Pod is untested, and idle is the one condition under which the old configuration also passed. Over a full production peak, the count of Unhealthy events carrying context deadline exceeded must be zero. Instrument ready endpoints as a fraction of desired replicas per Service and require it never to fall below the floor you chose, through a drain and through a peak; that ratio is the only signal that moved during this incident, since restarts stayed at zero from beginning to end. Then prove the probe can still fail: stop the listener in one canary Pod and require it to leave the EndpointSlice within the window the new timing implies, because a probe relaxed far enough to stop producing false negatives can easily be relaxed far enough to stop producing true ones. Finally confirm the throttled-periods ratio at peak sits under whatever threshold you set, since that is the mechanism that made a twenty-millisecond handler miss a one-second deadline.

Prevention

A readiness probe must not share fate with the thing it gates. When the probe path competes for the same throttled CPU as the request path, the probe is a saturation detector wired to a capacity switch, and saturation is precisely the condition under which capacity must not be withdrawn. Treat timeoutSeconds as an SLO statement: strictly greater than the p99 of the probe path under the worst load the tier is intended to survive, not under the load it happens to be carrying when the value is chosen. Alert on the ratio of ready endpoints to desired replicas for every Service, because this entire failure class produces no restarts and no error logs and is invisible to alerting built on either. Review a probe change as a traffic-routing change, since that is what it is. Load-test the probe rather than only the application. Distinguish the two failure messages in triage and teach the distinction: an HTTP status in the Unhealthy event means the application answered and declined, while a context deadline exceeded means it never answered at all, and the two lead to completely different places. And do not wire a dependency check into a readiness probe on a horizontally-scaled tier — it is the same class of mistake in a different disguise, because every replica then fails simultaneously and a partially degraded dependency becomes a total outage.

Reported symptoms

checkout-api runs twelve replicas behind a ClusterIP Service and an Ingress. On Tuesday at 21:04 it began returning 503 to every request. At 21:10 it stopped, without anyone having changed anything.

The post-incident notes read like four different problems:

  • Nothing crashed. Every Pod was Running for the whole window with a restart count of zero. Every alert the team owns is built on restarts, CrashLoopBackOff or container exit codes, and not one of them fired.
  • Nothing logged. The application log shows ordinary 200s until 21:04, then silence, then ordinary 200s from 21:10. No exception, no timeout, no connection error.
  • The health endpoint is fine. kubectl exec into any Pod and curl the readiness path: 200, about twenty milliseconds. During the incident. Afterwards. Every time anyone has tried it.
  • The dependencies are fine. The database team confirms nothing happened. CoreDNS is healthy. The ingress controller is healthy. A curl from another Pod straight to a checkout-api Pod IP succeeded at 21:06, while the Service itself was returning 503.

The one thing that looked like a lead was timing: the incident started roughly forty seconds after a routine node drain removed three of the twelve replicas. The drain was written up as the cause and the maintenance procedure was changed.

Then it happened again on Wednesday at 12:41. No drain. No deploy. No infrastructure change. Just lunchtime.

Evidence provided

Read-only / Safecaptured at 21:06 — nine of nine not ready, none restarted
$ kubectl -n shop get pods -l app=checkout-api
NAME                            READY   STATUS    RESTARTS   AGE
checkout-api-7f9c4d5b8-2xqjw    0/1     Running   0          6d
checkout-api-7f9c4d5b8-4hn7z    0/1     Running   0          6d
checkout-api-7f9c4d5b8-8bkcm    0/1     Running   0          6d
checkout-api-7f9c4d5b8-9wrtd    0/1     Running   0          6d
checkout-api-7f9c4d5b8-dq4vs    0/1     Running   0          6d
checkout-api-7f9c4d5b8-jm2pl    0/1     Running   0          6d
checkout-api-7f9c4d5b8-n6zxh    0/1     Running   0          6d
checkout-api-7f9c4d5b8-rk8fw    0/1     Running   0          6d
checkout-api-7f9c4d5b8-t3vqn    0/1     Running   0          6d

Illustrative output

Read-only / Saferead the failure text carefully — it is not a status code
$ kubectl -n shop describe pod checkout-api-7f9c4d5b8-2xqjw | grep -E 'Readiness|Unhealthy'
    Readiness:  http-get http://:8080/healthz delay=5s timeout=1s period=5s #success=1 #failure=2
Warning  Unhealthy  21s (x14 over 4m)  kubelet  Readiness probe failed: Get "http://10.244.7.31:8080/healthz": context deadline exceeded (Client.Timeout exceeded while awaiting headers)

Illustrative output

Read-only / Safenine addresses in the slice, none of them ready
$ kubectl -n shop get endpointslice -l kubernetes.io/service-name=checkout-api -o jsonpath='{.items[*].endpoints[*].conditions.ready}'
false false false false false false false false false

Illustrative output

Read-only / Safea capped CPU quota shared by the request path and the probe path
$ kubectl -n shop get deploy checkout-api -o jsonpath='{.spec.template.spec.containers[0].resources}'
{"limits":{"cpu":"500m","memory":"512Mi"},"requests":{"cpu":"250m","memory":"256Mi"}}

Illustrative output

The metrics for the window, plotted at ten-second resolution:

time      ready_endpoints   req_per_replica   cfs_throttled_ratio
21:03:50       12                 310               0.06
21:04:10        9                 413               0.19
21:04:50        9                 413               0.38
21:05:10        4                 930               0.41
21:05:20        0                   0               0.00
21:05:35        9                   0               0.00
21:05:50        9                 413               0.36
21:06:10        3                1240               0.44
21:06:20        0                   0               0.00

Work the evidence before reading on

Two things in the evidence are doing all the work, and both are easy to read past.

  1. The kubelet event says context deadline exceeded, not HTTP probe failed with statuscode: 503. Those are two different failures. What does each one tell you about whether the application answered?
  2. Work the probe arithmetic from the Readiness: line. How many seconds of consecutive failure remove a Pod from the EndpointSlice, and how many seconds of success put it back? Are those two numbers the same?
  3. Look at the ready_endpoints column. It does not decay towards zero and settle. It collapses, returns to full, and collapses again. What kind of system produces that shape, and what does it need besides a feedback path to produce it?
  4. Look at req_per_replica at 21:05:10 and at 21:05:20. Explain why the exec-and-curl test returns 200 in twenty milliseconds every single time anyone runs it.

Before continuing: the drain was blamed on Tuesday and there was no drain on Wednesday. Was the drain a cause or a trigger, and what distinguishes the two?

Root cause

1. A timeout is not a status code

The kubelet’s HTTP probe opens a connection to the Pod IP and waits timeoutSeconds for response headers. If the application answers with a 5xx, the event records the status code. If it does not answer in time, the event records context deadline exceeded.

The first says the application was asked and declined. The second says the application was never heard from. They lead to completely different places, and this incident produced the second one throughout while being investigated as though it were the first — which is why so much time went into the database, the dependency graph and the application logs, all of which describe a process that was perfectly willing to answer.

2. The probe shares the container’s CPU quota

The handler behind /healthz runs inside the container. The container has limits.cpu: 500m, which the kernel enforces as a CFS quota, so the process is descheduled once it exhausts its slice within each accounting period.

At 21:04 the drain took nine replicas’ worth of traffic and gave it to nine replicas — 413 requests per replica per second where there had been 310. The throttled-periods ratio went from 0.06 to 0.38. A handler that returns in twenty milliseconds on an idle Pod is being interrupted, repeatedly, on a Pod that is at its quota.

The probe is therefore not measuring health. It is measuring how much CPU the container has left, which is a measurement of load.

3. Removing a server from an overloaded pool is positive feedback

timeout=1s period=5s #failure=2 means two consecutive misses, ten seconds apart at worst, and the Pod’s address is marked not-ready in the EndpointSlice. kube-proxy stops sending it traffic.

That traffic does not disappear. It goes to the Pods still in the slice, whose per-replica load rises, whose handlers now miss the same deadline.

StepReady endpointsRequests per replica
Before the drain12310
After the drain9413
First Pods drop out4930
Cascade completes00

The system’s response to overload is to withdraw servers, which is the one action guaranteed to increase overload on everything that remains. Forty seconds after the first missed probe, the Service had no ready addresses at all.

4. Why it recovered by itself, and why that was the worst part

#success=1 at period=5s means one successful probe returns a Pod to the slice, and with zero traffic every probe succeeds immediately. So the entire fleet came back Ready within one period — all nine at once — and full production traffic landed on all of them simultaneously.

Then it collapsed again.

This is why the incident is an oscillation rather than an outage. It also explains the single most misleading piece of evidence in the whole case: any Pod you can reach with kubectl exec during the collapse is a Pod that has been removed from service and therefore has no traffic. Measuring it tells you what it does when idle. The measurement is only possible in the state that guarantees the answer is 200.

Resolution

  1. Scale out first. Adding replicas lowers per-replica load below the point where the handler misses its deadline and takes the fleet out of the oscillating region without changing any configuration. Give it a named owner and an end time, so the temporary replica count does not silently become the architecture.
  2. Measure the p99 of the health path under load, not at idle. Every number in the new probe configuration has to be justified against that measurement; a timeout chosen for looking generous is the same mistake with a larger constant.
  3. Raise timeoutSeconds above that p99 and raise failureThreshold so one bad window cannot deschedule a Pod. Record why each value was chosen next to the value, because the next person to tune it will otherwise only see that it is unusually large.
  4. Make the handler cheap and local. It should report whether this process can accept and serve a request, and it should do no work whose cost grows with traffic. Anything that queries a dependency belongs on a separate endpoint that dashboards read and the kubelet does not.
  5. Raise or remove the CPU limit so the probe is not competing for a capped quota. The Pod stays Burstable and keeps its request; what goes away is the throttling that turns a twenty-millisecond handler into a one-second one.
  6. Re-examine the drain procedure that was changed on Tuesday. The drain was a trigger, not a cause, and a procedure changed to avoid a trigger will not survive the next lunchtime peak.
  7. Add the ratio of ready endpoints to desired replicas as a first-class alert for every Service in the namespace. This is the only signal that moved during the incident; restarts, exit codes and error logs were all flat.
  8. Write the two failure messages into the triage runbook side by side — a status code means the application declined, a context deadline means it never answered — because that one distinction is worth about an hour of an incident.

Verification

  1. The loop reproduces on the old configuration in staging. Drive load until the p99 of the health path approaches one second, remove a quarter of the replicas, and watch the ready count collapse. A fix for a failure you have not reproduced is a hypothesis.
  2. The same test passes on the new configuration, with the fleet staying Ready throughout. Both halves matter: a test that only ever passes proves the test is weak, not that the fix is strong.
  3. Zero Unhealthy events carrying context deadline exceeded across a full production peak. Count them rather than eyeballing them, and count them over a peak rather than over a quiet afternoon.
  4. Ready endpoints as a fraction of desired replicas never falls below the agreed floor, through a drain and through a peak. Restarts stayed at zero for this entire incident, so any verification built on restarts verifies nothing here.
  5. The probe can still fail. Stop the listener in a canary Pod and require it to leave the EndpointSlice inside the window the new timing implies. A probe relaxed enough to stop producing false negatives is easily relaxed enough to stop producing true ones.
  6. The throttled-periods ratio at peak sits below the threshold you set. That is the mechanism that made a twenty-millisecond handler miss a one-second deadline, and it is the number that will tell you when the tier is drifting back towards the same condition.
  7. A rollout still stalls on a genuinely broken image. Deploy a deliberately broken build to a staging replica set and confirm the Deployment refuses to progress. This checks that relaxing the probe did not disarm the gate it exists to provide.

Prevention

  • A readiness probe must not share fate with the thing it gates. If the probe path competes for the same throttled CPU as the request path, the probe is a saturation detector wired to a capacity switch — and saturation is exactly when capacity must not be withdrawn.
  • timeoutSeconds is an SLO statement. It must exceed the p99 of the probe path under the worst load the tier is meant to survive, not under the load it happens to be carrying when someone picks the number.
  • Alert on ready endpoints over desired replicas, per Service. This whole failure class produces no restarts and no error logs, and is therefore invisible to alerting built on either.
  • Review a probe change as a traffic-routing change, because that is what it is. It belongs in the same review category as a Service selector or an Ingress rule.
  • Load-test the probe, not only the application. The old configuration passed every test that had ever been run against it, because every one of them ran against an idle Pod.
  • Never wire a dependency check into a readiness probe on a horizontally-scaled tier. It is this same mistake wearing different clothes: every replica fails at the same instant, and a partially degraded dependency becomes a total outage.
  • Teach the two messages. statuscode: 503 and context deadline exceeded look equally like “the probe failed” and lead to entirely different investigations.