Reported symptoms
A worker node failed at 02:07 with what the hardware team later called a
backplane fault. That part went to plan: the node went NotReady, the
node controller marked its Pods for deletion, and the schedulers placed
them elsewhere. The cluster is built for this and has done it before.
By 02:20 roughly a third of the rescheduled Pods had still not started, and the picture on the call was confusing.
The failures look unrelated to each other. The payments API came back inside ten seconds. The checkout API did not. The ingress controller did not. A Redis sidecar in three different namespaces did not, and the message broker did not. There is no pattern by team, by namespace, by priority class or by node.
The Pods do not agree on what is wrong. Some report a 429 from a
registry. Some sit in ImagePullBackOff with a message that says only
that the back-off is happening. Several have been in ContainerCreating
for eight minutes and have produced no event at all.
The registry is fine. The internal registry answers its /v2/
endpoint. Two engineers pull the failing images by hand from their
laptops while the call is running. Both succeed, immediately, every
time.
Nothing changed. No release went out. No manifest was edited. No registry maintenance was scheduled. The only event of the night was a node dying.
And there is one more thing, offered apologetically at 02:35: the first two Pods the on-call force-deleted came back fine, so the problem was called transient and the rest were force-deleted too. More of them failed after that than before.
Evidence collected
$ kubectl -n shop describe pod checkout-7d9f4c8b6-x2k9p | tail -8Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Pulling 9m kubelet Pulling image "docker.io/library/redis:7.2"
Warning Failed 9m kubelet Failed to pull image "docker.io/library/redis:7.2": 429 Too Many Requests - You have reached your pull rate limit
Warning Failed 9m kubelet Error: ErrImagePull
Normal BackOff 4m (x12 over 9m) kubelet Back-off pulling image "docker.io/library/redis:7.2"Illustrative output
$ kubectl get pods -A -o go-template='{{range .items}}{{range .spec.containers}}{{println .image}}{{end}}{{end}}' | cut -d/ -f1 | sort | uniq -c | sort -rn 412 registry.example.com
37 docker.io
6 quay.ioIllustrative output
$ kubectl get pods -A --field-selector=spec.nodeName=worker-11NAMESPACE NAME READY STATUS RESTARTS AGE
billing billing-cache-0 0/1 ImagePullBackOff 0 9m
ingress traefik-6b8f9c47d5-qz4mn 0/1 ImagePullBackOff 0 9m
search search-api-5c4d7f9b8-m8vqx 2/2 Running 0 9m
shop checkout-7d9f4c8b6-x2k9p 1/2 ContainerCreating 0 9mIllustrative output
$ kubectl get pods -A --field-selector=spec.nodeName=worker-11 -o custom-columns='NAME:.metadata.name,IMAGES:.spec.containers[*].image'NAME IMAGES
billing-cache-0 docker.io/library/redis:7.2
traefik-6b8f9c47d5-qz4mn docker.io/library/traefik:v3.1
search-api-5c4d7f9b8-m8vqx registry.example.com/search/api:4.8.1
checkout-7d9f4c8b6-x2k9p registry.example.com/shop/checkout:2.14.0,docker.io/library/redis:7.2Illustrative output
$ kubectl -n shop run probe --rm -it --restart=Never --image=registry.example.com/tools/curl:1 -- curl -sS -o /dev/null -w '%{http_code}' https://registry.example.com/v2/200Illustrative output
# grep -A2 'registry.mirrors' /etc/containerd/config.toml[plugins."io.containerd.grpc.v1.cri".registry.mirrors."registry.example.com"]
endpoint = ["https://harbor.internal.example.com/proxy-internal"]Illustrative output
Work the evidence before reading on
Three Pods on the replacement node stalled and one on that same node came up in seconds. That is the most useful pair of facts on the page.
- List the image references of the Pods that failed and the Pods that succeeded, side by side. Ignore everything else about them - the team, the namespace, the priority - and look only at the registry host.
- The surviving nodes are running these same images right now, quite happily. What is different about the node that cannot start them?
- Two engineers pulled the failing image successfully from their laptops. What does a laptop have that a cluster node does not, from the point of view of the thing counting requests?
- Read the containerd mirror configuration again. Which registry host is covered, and which one is not?
- The force-deletes made it worse. If the constraint were an outage, would retrying cost anything?
Before continuing: nothing was deployed and no registry went down. What changed at 02:07 that made a dependency visible which had been invisible for months?
Root cause
1. In steady state, nothing pulls
A running Pod does not talk to a registry. The image is unpacked in the node local content store, and every restart, every probe failure, every container crash-loop reuses what is already there. Whether the cluster can reach a registry at all is a question that simply does not come up while the cluster is healthy.
That is why 37 containers pulling from Docker Hub had never been noticed. The dependency is real and continuously present in the manifests, and it is exercised approximately never.
2. A node failure converts the whole dependency into one burst
At 02:07 every Pod on the dead node needed a new home, and a new home means a host that has probably never run that image. The kubelet on the replacement node pulled for all of them at once, and so did the kubelets on the other nodes that picked up the overflow.
Months of accumulated, unexercised external dependency arrived as a single burst of requests inside about ninety seconds.
3. Every one of those requests came from the same place
The cluster egresses through a single NAT address. To an external registry counting anonymous requests, the whole estate is one client. A quota that a trickle never approaches is exhausted immediately by a crowd, and once exhausted it stays exhausted for as long as the window lasts - which is exactly the window in which the cluster is trying to recover.
This is the piece that makes the laptop test misleading rather than merely useless. The laptop is a different source address with its own budget. Pulling the image successfully from it proves the image exists and is readable. It proves nothing whatsoever about the cluster, and it was the single most confidently offered piece of evidence on the call.
4. The scattered symptoms are one cause seen at different stages
Once you sort the Pods by registry host, the arbitrariness disappears.
Everything from registry.example.com started. Everything from
docker.io did not. search-api was running on the same replacement
node throughout, because its image comes from the internal registry, and
checkout sits at 1/2 because one of its two containers comes from
each.
The Pods with no events yet are not a different problem. The kubelet
pull timeout is generous and its retries are quiet, so a Pod can sit in
ContainerCreating for many minutes before anything is written down.
ImagePullBackOff is a later stage of the same story, not a worse one.
Resolution
- Stop causing pulls, explicitly and out loud. No force-deletes, no drains, no rollouts, no node replacements until the pull path is fixed. Name the owner of that hold and the time it will be reviewed so that stillness reads as a decision rather than as inaction.
- Sort the failing Pods by registry host. This takes one command and it converts an arbitrary-looking incident into a bounded list of affected workloads: everything from the external host, nothing else.
- Confirm the constraint is a quota rather than an outage by reading the actual error rather than the Pod status. A 429 is a refusal to serve you specifically; a timeout or a connection failure would point somewhere entirely different and would change the whole plan.
- Make the pulls authenticated as the fastest available relief. An imagePullSecret on the affected namespaces attributes the requests to an account instead of to a shared egress address. Check the provider current terms for what that actually buys rather than trusting a number in an old runbook.
- Let the backed-off Pods recover on their own. They will retry on their existing schedule and start succeeding; they need no intervention, and intervening is what caused the second wave.
- Add a mirror entry for the external registry host to the containerd registry configuration, pointing at the in-cluster pull-through cache, and roll it one node at a time. Verify on the first node with
crictl pullbefore touching the second. - Re-host the third-party images the cluster cannot start without in the internal registry, under your own names, and update the manifests. This is the change that makes the next node failure uneventful rather than survivable.
- Only once pulls are working again, bring the replacement node fully back into service and confirm it is carrying a normal share of the workload.
Verification
- The previously stuck Pods are Running, not merely no longer backing off. Check readiness and check that their containers have not restarted since starting.
- A pull is served by the mirror, and you have proof of it rather than an inference. Remove the image from one node local store, pull it again with
crictl pull, and find the request in the mirror own access log. - The dependency itself is gone, which is a stronger claim than the pull working. Block egress to the external registry host on one node, start a Pod there that references an external image, and require it to run.
- No direct external reference remains. Inventory every image in the cluster by registry host and require each one to resolve through the internal registry or through the mirror. One survivor is the next incident.
- The mirror configuration is present on every node pool, including any pool created after the change. A configuration applied to existing nodes by hand is a configuration the next pool will not have.
- Nothing is pinned to a pull policy that defeats the cache. Find workloads with
imagePullPolicy: Alwayson external images and decide, deliberately, whether each one is worth the loss of cache fallback. - The alert exists and fires. Cause a pull failure on a scratch workload and confirm an image-pull alert arrives as its own signal, distinct from a Pod-not-ready alert.
- The runbook records that force-deleting Pods during a pull failure prolongs it. This is the part of the incident that will be repeated by somebody who was not on the call.
Prevention
- Inventory external image dependencies and give each one an owner. The list took one command to produce during the incident and would have taken the same command a year earlier, when there was time to act on it.
- Mirror or re-host everything needed to recover. Recovery is exactly when an external dependency cannot be afforded, and it is the only time it will be exercised at scale.
- Put the mirror configuration in node provisioning, not on the nodes afterwards. A mirror that exists on the old pool and not the new one is this same outage with a different trigger, and it will look equally mysterious.
- Test the registry dependency deliberately. Block it on one node and confirm Pods still start. The exercise is short and it reliably finds something.
- Alert on image pull failures as a distinct signal. Arriving as “Pods not ready” mixes them with a dozen unrelated causes at the moment when the distinction matters most.
- Know what
imagePullPolicyis doing.Alwayson an external image means no cache fallback and a pull on every single start, including every start during an incident. - Write down that force-deleting a stuck Pod resets the backoff. It is the most natural action available and it is the wrong one, so it needs to be somewhere other than in the memory of the people who were awake.