Skip to main content
RunBook Academy

← All break/fix scenarios in Kubernetes

advancedkubernetes-cri-failure~30 min

Container runtime unavailable

Reported symptoms

  • `worker-07` has been NotReady for twenty minutes and the applications on it are still running and still writing to the central log pipeline
  • `kubectl exec` into any Pod on `worker-07` hangs and then fails, while `kubectl get pod` and `kubectl describe pod` answer normally for the same Pods
  • Eleven Pods scheduled to `worker-07` since 09:12 are stuck in `ContainerCreating` and have never produced a container
  • A Pod deleted at 09:20 has shown `Terminating` ever since
  • The Deployments that had Pods on the node show the right number of Ready replicas, because replacements came up elsewhere five minutes after the node went NotReady - while the originals are still running
  • The StatefulSet member on the node was never replaced at all
  • The kubelet on `worker-07` is alive: it is renewing its lease, reporting node conditions, and logging several lines a second

Evidence

  • · `kubectl get nodes` shows `worker-07` NotReady and every other node Ready
  • · `kubectl describe node worker-07` shows `Ready False` with reason `KubeletNotReady` and the `node.kubernetes.io/not-ready:NoExecute` taint applied
  • · `kubectl get pods -A --field-selector spec.nodeName=worker-07` shows the stuck `ContainerCreating` Pods clustered on this one node
  • · `systemctl status containerd` on the node reports the unit failed
  • · `journalctl -u containerd` shows repeated start attempts, each exiting while loading `/etc/containerd/config.toml`
  • · `journalctl -u kubelet` shows the kubelet failing to connect to the container runtime, repeating on its sync interval
  • · `crictl --runtime-endpoint unix:///run/containerd/containerd.sock ps` cannot reach the socket
  • · `stat -c %y /etc/containerd/config.toml` shows the file was written four days before the node condition changed
Diagnosis and resolutionclick to reveal

Root cause

containerd is not running on `worker-07`. It exited on its last start because `/etc/containerd/config.toml` no longer parses: a registry-mirror block appended to the end of the file re-declares a table that already appears earlier in it, which TOML forbids, so the daemon fails during configuration load and never reaches the point where it creates its socket. The edit was made four days before the incident. containerd reads that file only when it starts, so the broken configuration sat on disk doing nothing at all until the monthly package upgrade restarted the unit at 09:12 and the file finally took effect. That gap is what makes the incident hard to read: the change log for the day of the outage contains nothing relevant, and the change that caused it is four days upstream in a window that closed without incident. The symptom set follows from one property of the design. containerd supervises containers out of process, so its death does not kill them - the application processes keep running and keep serving, and traffic to a Pod IP never touches the runtime. Everything the kubelet needs the runtime for, however, is now impossible: it cannot create a sandbox, so new Pods stay in `ContainerCreating`; it cannot stop a container, so deleted Pods stay `Terminating`; it cannot proxy an exec, so `kubectl exec` fails; and it correctly reports itself not ready, which is why the node is NotReady while its workload is healthy.

Remediation

Recover the daemon; do not force the symptoms away. The first move is to decide nothing gets force-deleted on this node, because a force-delete removes the Pod object while the container keeps running, which converts a visible outage into an invisible duplicate. Cordon the node explicitly so it stays unschedulable through the recovery. Read the containerd journal for the parse failure and the file it names, then diff the file against a healthy node of the same pool and restore the known-good copy, keeping the broken one as evidence rather than overwriting it. Validate before restarting - `containerd config dump` reads the same file the daemon reads and fails the same way, so it answers the parse question without asking systemd to answer it in production. Then restart containerd, watch the journal through the start, and confirm the socket answers with `crictl info` before looking at Kubernetes at all. The kubelet reconnects on its own; the node returns Ready, the not-ready taint is removed, and the pending deletions the kubelet has been holding finally complete. If the configuration cannot be recovered in the window, holding is a legitimate option - the workload is still serving - but it is not free, and it needs an owner and an end time. If instead the node must be abandoned, power it off before force-deleting anything: powering it off is the only action that actually stops the containers.

Verification

Verify the runtime first and Kubernetes second, because the node condition lags the daemon by up to a sync interval and a green `kubectl get nodes` read too early proves nothing. `crictl info` must answer and `crictl ps` must list the sandboxes that were running before the incident, which confirms the socket is up and containerd still has its state. The kubelet journal must stop producing runtime connection errors. Then the Kubernetes side: the node reports Ready, the `node.kubernetes.io/not-ready` taint is gone, the eleven `ContainerCreating` Pods reach Running, and the Pod that had been `Terminating` for a quarter of an hour disappears. Prove the create path rather than the socket alone by scheduling one canary Pod to the node and watching it reach Running - a socket that answers `info` has not demonstrated `RunPodSandbox` and `StartContainer`. Prove the streaming path by running `kubectl exec` against a Pod on the node. Last, reconcile the duplicates: compare the running Pod count for each affected workload against its declared replicas, and confirm the StatefulSet member that was stuck has been recreated exactly once.

Prevention

The durable fix is to stop letting a configuration file be validated for the first time by a production restart. Whatever writes `/etc/containerd/config.toml` should parse it in the same run that writes it, and a node whose file does not parse should be reported before anything restarts the daemon. The cheap standing check that would have found this four days early is a comparison of the file mtime against the service start time: `stat -c %Y /etc/containerd/config.toml` newer than the `ActiveEnterTimestamp` from `systemctl show containerd` means a configuration is staged and has never been tested. Alerting needs the same correction. An alert on NotReady alone gets acknowledged and ignored, because on this failure the dashboards stay green; alert instead on the combination that is the runtime signature - a node reporting NotReady while its Pods are still producing logs, and Pods sitting in `ContainerCreating` for more than a few minutes clustered on a single node. Finally, write the force-delete rule down before you need it: force-deleting a Pod on a NotReady node requires evidence that the process is dead, and the only evidence that qualifies is a runtime that answers or a node that is powered off.

Reported symptoms

At 09:31 the on-call is paged for worker-07 NotReady in the production cluster. Twenty minutes later the node is still NotReady, and none of the usual conclusions fit.

The applications on worker-07 are still running. Their log lines are still arriving in the central pipeline, timestamped seconds ago, from Pods the control plane has given up on. Whatever is wrong with this node, it has not stopped the workload.

kubectl exec into a Pod on worker-07 hangs and then fails. The same command against a Pod on any other node returns instantly. kubectl get pod and kubectl describe pod answer normally for the very same Pods.

Every Pod the scheduler has placed on worker-07 since 09:12 is in ContainerCreating and has never left it. There are eleven, and they are all on this one node.

A Pod deleted at 09:20 is still Terminating.

The Deployments that had Pods here show the right number of Ready replicas: replacements appeared on other nodes about five minutes after the node went NotReady. The StatefulSet member that was on the node was never replaced, and its Pod has read Terminating for a quarter of an hour.

The kubelet on worker-07 is alive. It is renewing its lease, it is reporting node conditions, and its journal is producing several lines a second.

That last fact is what makes the page confusing. A dead kubelet explains a NotReady node. A live one does not.

Evidence provided

Read-only / Safeone node, and only one
$ kubectl get nodes
NAME        STATUS     ROLES           AGE    VERSION
worker-06   Ready      <none>          214d   v1.34.1
worker-07   NotReady   <none>          214d   v1.34.1
worker-08   Ready      <none>          214d   v1.34.1

Illustrative output

Read-only / Safethree states at once on one node
$ kubectl get pods -A --field-selector spec.nodeName=worker-07
NAMESPACE   NAME                        READY   STATUS              RESTARTS   AGE
prod        checkout-6f9c4d-2xk8w       0/1     ContainerCreating   0          19m
prod        checkout-6f9c4d-9tzqr       1/1     Running             0          31d
prod        ledger-0                    1/1     Terminating         0          17d
prod        reporting-58b7f9-h4mnp      0/1     ContainerCreating   0          18m

Illustrative output

Read-only / Safeon worker-07, over SSH
$ systemctl status containerd
● containerd.service - containerd container runtime
   Loaded: loaded (/usr/lib/systemd/system/containerd.service; enabled)
   Active: failed (Result: exit-code) since 09:12:41 UTC

Illustrative output

Read-only / Safea clean stop, then a start that never completes
$ journalctl -u containerd -n 20 --no-pager
systemd[1]: Stopping containerd container runtime...
systemd[1]: Starting containerd container runtime...
containerd[3141]: failed to load TOML from /etc/containerd/config.toml
systemd[1]: containerd.service: Main process exited, code=exited, status=1/FAILURE
systemd[1]: containerd.service: Failed with result 'exit-code'.

Illustrative output

The exact wording of a configuration-load failure varies between containerd releases. The load-bearing parts of this journal do not: the unit was stopped cleanly, the restart failed while reading the configuration file, and the process exited before it could create its socket.

Read-only / Safenothing is listening on the CRI socket
$ crictl --runtime-endpoint unix:///run/containerd/containerd.sock ps
FATA[0002] validate service connection: validate CRI v1 runtime API for endpoint
"unix:///run/containerd/containerd.sock": rpc error: code = Unavailable

Illustrative output

Read-only / Safethe file is four days old; the unit has no successful start to report
$ stat -c %y /etc/containerd/config.toml; systemctl show containerd -p ActiveEnterTimestamp
2026-08-14 02:41:18.402 +0000
ActiveEnterTimestamp=

Illustrative output

Work the evidence before reading on

Three questions, in this order. Each one is cheaper than the next.

  1. The kubelet is alive and the node is NotReady. Name every operation the kubelet performs that requires the runtime, and check each reported symptom against that list. Does anything remain unexplained?
  2. The applications are still serving. Which part of the request path for an already-running Pod involves containerd at all?
  3. The configuration file is dated 14 August. The unit was restarted on 18 August. What is the relationship between those two timestamps, and what would kubectl get events from the day of the outage show about the change that caused it?

Before continuing, answer the question that decides the first action: is it safe to force-delete the Pod that has been Terminating for fifteen minutes? Justify the answer from what a force-delete does and what it does not do.

Root cause

1. containerd is dead, and it died four days after it broke

/etc/containerd/config.toml was edited on 14 August to add a registry mirror. The block was appended to the end of the file, and it re-declares a table that already appears earlier in it. TOML forbids that, so the file no longer parses.

Nothing happened on 14 August. containerd reads its configuration when it starts, and it was already running with the previous file loaded in memory. The broken file sat on disk for four days, inert.

At 09:12 on 18 August the monthly package upgrade restarted the unit. That restart is where the four-day-old edit took effect, and containerd has not started since.

This gap is the whole reason the incident is hard to read. The change log for the day of the outage is empty of anything relevant. The change that caused it is four days upstream, in a window that closed green.

2. One dead daemon, four symptoms

Everything on the page follows from a single division of labour: the kubelet knows the cluster’s desired state, the runtime knows the local containers, and the CRI is the only bridge between them.

SymptomThe CRI call the kubelet cannot make
Pods stuck in ContainerCreatingRunPodSandbox, then CreateContainer
Pod stuck in TerminatingStopContainer, then RemovePodSandbox
kubectl exec failsExec on the RuntimeService
Node reports NotReadythe kubelet cannot manage containers and says so

And the symptom that is not on that list is the one that misleads everybody: the applications keep serving, because serving a request needs none of it.

3. The Deployment healed and the StatefulSet did not

Five minutes after the node’s Ready condition went False, the node controller’s node.kubernetes.io/not-ready:NoExecute taint reached the end of its default 300-second toleration and the Pods on the node were marked for deletion.

For the Deployment, that was enough: the Pods have deletion timestamps, so the ReplicaSet created replacements elsewhere. They are Ready. The Deployment looks healthy, and the original Pods are still running on worker-07, still holding their connections to everything downstream.

For the StatefulSet, the deletion never completed - completing it requires the runtime - so the Pod object still exists, and the controller will not create a second ledger-0 while the first one is unaccounted for. That is the guarantee working correctly. It is also why the member is simply missing rather than duplicated.

Both behaviours are correct. They point in opposite directions, which is why reading either one alone sends the investigation somewhere else.

Resolution

  1. Decide, and say out loud, that nothing on this node gets force-deleted until the runtime answers or the node is off. Everything below depends on that decision holding under time pressure.
  2. Cordon the node with kubectl cordon worker-07. The not-ready taint already stops scheduling, but it is removed the moment the node recovers, and the recovery is not the moment you want new work arriving.
  3. Read the containerd journal for the parse failure and the file it names. This is the step that converts a node incident into a configuration incident.
  4. Diff the file against a healthy node in the same pool: ssh worker-08 cat /etc/containerd/config.toml against the local copy. The difference is the change, and it carries its own date.
  5. Preserve the broken file. Copy it aside before restoring; it is the only evidence of what was written and when, and the restore overwrites it.
  6. Restore the known-good configuration, then validate it with containerd config dump before restarting anything. That command reads the same file the daemon reads and fails the same way, so it answers the parse question without asking systemd to answer it in production.
  7. Restart containerd and watch the journal through the start rather than checking the exit code afterwards. A unit that starts and then fails a second later reports success to an impatient reader.
  8. Confirm the runtime before looking at Kubernetes: crictl info must answer, and crictl ps must list the sandboxes that were running before the incident. If containerd came back with no state, the recovery is a different and much longer procedure.
  9. Let the kubelet reconnect on its own. It retries on its sync interval; there is no step here, and restarting the kubelet to hurry it along only adds a variable.
  10. Watch the pending work drain: the eleven ContainerCreating Pods start, and the deletions the kubelet has been holding since 09:20 finally complete.
  11. Reconcile the workloads. For each affected Deployment, compare running Pods against declared replicas and confirm the duplicates are gone; confirm ledger-0 was recreated exactly once.
  12. Uncordon the node only after a canary Pod has reached Running on it.

If the configuration cannot be recovered inside the window, holding is a real option and should be treated as one: the workload is serving, and a node that is NotReady with healthy Pods is not an emergency. It is not free either - the cluster is running with one node of lost capacity, the StatefulSet is a member short, and every Deployment on that node is carrying a duplicate. Give the hold an owner and an end time, and write down what happens at that time.

If the node must instead be abandoned, power it off first. Powering it off is the only action available that actually stops the containers, and it is what makes the subsequent force-deletes safe rather than merely fast.

Verification

  1. The runtime answers. crictl info returns, and crictl ps lists the pre-incident sandboxes. Check this before any kubectl command, because the node condition lags the daemon and a green kubectl get nodes read too early proves nothing.
  2. The kubelet journal has stopped reporting runtime connection failures. A single successful crictl call does not prove the kubelet reconnected.
  3. The node reports Ready and the node.kubernetes.io/not-ready taint is gone from kubectl describe node worker-07.
  4. The create path works, not just the socket. Schedule one canary Pod to the node and watch it reach Running - that exercises RunPodSandbox, CreateContainer and StartContainer, none of which crictl info demonstrates.
  5. The streaming path works. kubectl exec into a Pod on the node returns. This was one of the reported symptoms and is the only check that closes it.
  6. The stuck deletions completed. The Pod that had been Terminating since 09:20 is gone from kubectl get pods, and it left because the kubelet stopped it rather than because somebody forced it.
  7. No duplicates remain. For each affected workload, the running Pod count equals the declared replica count, and ledger-0 exists exactly once.
  8. The guard can fail. On a test node, write a file with a duplicated table and confirm the pre-restart validation refuses it. A validation step that has only ever passed has not been tested.

Prevention

  • Parse the configuration in the same run that writes it. A file whose only validator is a production restart is a file that is validated in production, four days late, by an outage.
  • Compare the file mtime against the service start time as a standing fleet check. stat -c %Y /etc/containerd/config.toml newer than the ActiveEnterTimestamp from systemctl show containerd means a configuration is staged and untested. One command per node, and it finds this entire class of fault - including the version where a human edited the file and forgot.
  • Alert on the combination, not the condition. NotReady alone is a page that gets acknowledged and ignored, because on this failure every dashboard stays green. NotReady while the node’s Pods are still producing logs is the runtime signature and it has no other cause worth guessing at.
  • Alert on ContainerCreating older than a few minutes, grouped by node. A cluster of stuck sandbox creations on one node is a runtime failure; the same count spread across the cluster is a registry or CNI problem. The grouping is what makes the alert diagnostic rather than noisy.
  • Write down the force-delete rule before you need it, and put it in the same runbook as the node-recovery procedure, because that is the page somebody will have open at 09:31.
  • Restart containerd deliberately and watch it. It is not a fire-and-forget command: the node condition lags, so an operator who restarts the daemon and moves on will be told nothing for a minute or more.