Reported symptoms
Tuesday’s maintenance window patched four worker nodes. Wednesday afternoon a
batch namespace misses its SLA: none of its jobs have started. Someone notices
they are all on worker-04.
By Wednesday evening the shape is clear and nobody can explain it:
- Every Pod scheduled onto
worker-04since the window sits inContainerCreatingindefinitely. No crashes. NoImagePullBackOff. Restart count zero. - Every Pod that was already running on
worker-04before the window is healthy and serving traffic. - The node is
Ready, has never been anything else, and reports plenty of free CPU and memory - so the scheduler keeps choosing it. - The stuck Pods have no IP address. That routes the incident to the network team, who spend an afternoon on the CNI and find nothing wrong with it.
kubectl logson a stuck Pod returns an error instead of output. The application team reads that as “the Pod has not been created yet” and escalates it as a scheduling problem.- containerd is running.
crictl psanswers instantly and lists the node’s running containers. The platform team concludes the runtime is healthy and hands the ticket back.
Three other nodes were patched in the same window with the same playbook, and none of them do this. The DaemonSet that is supposed to prove node health is reporting 4/4.
Evidence provided
$ kubectl describe pod batch-import-9k2mv -n batch | tail -8Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 21m default-scheduler Successfully assigned batch/batch-import-9k2mv to worker-04
Warning FailedCreatePodSandbox 19m (x9 over 21m) kubelet Failed to create pod sandbox: rpc error: code = Unknown desc = failed to get sandbox image "registry.k8s.io/pause:3.10": failed to pull image "registry.k8s.io/pause:3.10": context deadline exceededIllustrative output
$ kubectl get pods -n batch -o wide | head -4NAME READY STATUS RESTARTS AGE IP NODE
batch-import-9k2mv 0/1 ContainerCreating 0 21m <none> worker-04
batch-import-c4t8p 0/1 ContainerCreating 0 21m <none> worker-04
batch-export-tt61w 1/1 Running 0 6d 10.244.4.31 worker-04Illustrative output
# crictl images | grep -i pauseIMAGE TAG IMAGE ID SIZE
harbor.example.com/k8s/pause 3.9 e6f181688397 744kBIllustrative output
# crictl pull registry.k8s.io/pause:3.10; crictl pull harbor.example.com/batch/import:2.4.1FATA[0030] pulling image: rpc error: code = DeadlineExceeded desc = context deadline exceeded
Image is up to date for sha256:9d2f0e4b1c...Illustrative output
# grep -n sandbox_image /etc/containerd/config.toml /etc/containerd/config.toml.dpkg-old/etc/containerd/config.toml:61: sandbox_image = "registry.k8s.io/pause:3.10"
/etc/containerd/config.toml.dpkg-old:58: sandbox_image = "harbor.example.com/k8s/pause:3.9"Illustrative output
# journalctl -u kubelet --since '30 min ago' | grep -c RunPodSandbox214Illustrative output
Work the evidence before reading on
The kubelet is running. The runtime is running. The node is Ready. The scheduler placed the Pods correctly. And no Pod has started on this node for a day.
- Read the
FailedCreatePodSandboxmessage again and identify which image it names. Is that image anywhere in the Pod spec? - The old Pods on this node are fine and the new ones are not. What does a running Pod already have that a new one has to be given?
crictl psworks. What exactly does that prove, and what does it not prove?- The stuck Pods have no IP. Which step in Pod startup assigns one, and does it run before or after the step that is failing?
Before continuing: the node has run this workload for six days. What changed on Tuesday that a Pod created on Monday would never notice?
Root cause
1. The sandbox is a Pod’s first dependency, and it is not in the Pod spec
The kubelet’s first CRI call for a new Pod is RunPodSandbox. The sandbox is
the container that owns the Pod’s namespaces - the thing the Pod’s application
containers are later joined to. It is built from an image the runtime chooses,
configured on the node, and named nowhere in any manifest anyone writes.
Tuesday’s containerd upgrade replaced /etc/containerd/config.toml with the
package default. That default names the upstream sandbox image. The node’s
egress policy does not permit the upstream registry - which is the entire
reason the internal mirror was configured in the first place - so the pull
times out, the sandbox is never created, and the Pod stops there.
The old value was not lost. The package manager saved it beside the new file, where nobody looked, because nobody had a reason to suspect the runtime configuration of a workload problem.
2. Failing that early makes the Pod invisible to workload diagnostics
Everything an operator reaches for by reflex assumes a container exists.
kubectl logs needs a container to read from, so it errors. The Pod has no
application image pull to fail, so there is no ImagePullBackOff to see. There
is no restart count, because nothing has run and therefore nothing has
restarted. And the Pod has no IP, because the network is attached during
sandbox creation - so a failure at that step reads exactly like a CNI failure
to anyone who checks the IP first and the events second.
Four teams each looked at the layer they own, found it healthy, and were right.
The failure sits in the one layer no team was watching: the node’s own runtime
configuration, which is not in the API server and does not appear in any
kubectl output.
3. Ready is a claim about the kubelet, not about the node’s usefulness
The kubelet runs its status update loop independently of its Pod sync loop. The status loop reports node conditions and heartbeats on its own schedule; the sync loop reconciles Pods. Neither waits on the other.
So a kubelet that has failed to start every Pod for twenty-four hours reports a healthy node the entire time, and the scheduler - which reads that report - keeps sending it work. The node is not lying about anything it was asked. It was simply never asked the question that mattered.
Resolution
- Cordon the node before anything else. It is advertising capacity it cannot use, and every minute it stays schedulable it collects more Pods that will never start. This costs nothing and it stops the incident growing while you work.
- Drain the node so the running workload moves somewhere that works. The remaining steps restart the container runtime, and that is not a thing to do underneath live traffic when you have the option not to.
- Diff the saved configuration against the new package default. Take the local settings the node needs - the sandbox image, any registry mirrors - and leave the new defaults alone. Do not copy the old file back over the new one.
- Restart containerd and confirm it comes back cleanly in its own journal before looking at Kubernetes at all. A runtime that failed to parse its configuration is a worse position than the one you started from.
- Delete the stuck Pods so their controllers recreate them and the kubelet retries from a clean state. Some will have been waiting long enough that their owning controller has already given up.
- Start one throwaway Pod pinned to this node and watch it reach Running with an IP before uncordoning. Ready is not the gate; a Pod that started is the gate.
- Uncordon, then check the same setting on every node the maintenance window touched. Three other nodes ran the same upgrade and may be one scale-up away from the same fault - they have simply not been asked to start a Pod yet.
- Record what the package replaced and why configuration management did not put it back. That is the defect worth fixing; the setting itself is only the symptom that made it visible.
Verification
- A new Pod pinned to the node reaches Running. This is the whole verification, because it is the only check that exercises RunPodSandbox, the image fetch, the network attachment and container start in the order they actually occur.
- The test Pod has an IP. A sandbox that is created but not networked is a different fault presenting with the same symptom, and only the IP separates them.
- The sandbox image resolves from the node itself, not merely in the configuration file. Pull it by hand and confirm; a value that is correct and unreachable fails identically to a value that is wrong.
- The setting matches a healthy peer node. Compare rather than inspect - the fastest way to be confident a node is repaired is that it now looks like the nodes that never broke.
- No other node in the fleet carries the reverted value. Run the same check everywhere the upgrade ran, because a node that has not been asked to start a Pod since the window has not been tested.
- The previously stuck Pods have cleared and their controllers report the expected replica counts, rather than being assumed to have recovered on their own.
- The post-maintenance procedure now ends with a Pod-start test and the node is uncordoned on that result. A procedure change that is agreed but not written down has not happened.
Prevention
-
Make configuration management own the runtime configuration and run after the package upgrade. The setting was correct until a package replaced the file. Nothing put it back because nothing was watching. Convergence after the upgrade is the fix; hoping the file survives is not.
-
End every maintenance window with a Pod that starts. A node that has been patched and not asked to run a new Pod has not been tested, whatever its conditions say:
NODE=worker-04
kubectl run smoke-"$NODE" --image=harbor.example.com/ops/busybox:1.36 \
--restart=Never --overrides='{"spec":{"nodeName":"'"$NODE"'"}}' -- true
kubectl wait --for=condition=Ready pod/smoke-"$NODE" --timeout=120s
-
Uncordon on the strength of that test, not on the node reporting Ready. Ready is the kubelet’s opinion of its own health. It is not a statement that the node can do the job it is about to be given.
-
Alert on Pods in ContainerCreating beyond a few minutes, grouped by node. One signal, grouped that way, separates a node-local fault from a workload fault instantly - and it is the alert that would have turned a day-long four-team investigation into a five-minute one.
-
Distrust read-only health checks of write paths.
crictl psanswering is not evidence that the runtime can create anything. Where a component is checked by reading, know what the check omits. -
Treat node runtime configuration as cluster configuration. It decides whether Pods can start at all, and it lives outside the API server where no Kubernetes-native tool will show you drift. It deserves review and drift detection like anything else that can stop the cluster working.