Skip to main content
RunBook Academy

← All break/fix scenarios in Kubernetes

advancedkubernetes-csi~35 min

CSI mount failure

Reported symptoms

  • A StatefulSet Pod rescheduled after a routine node drain has been in ContainerCreating for 23 minutes
  • The nightly backup CronJob has produced no completed Pod for two nights and nobody noticed until the report was missing
  • A second team reports a Deployment rollout stuck at 3 of 5, with the three that started sitting on three particular nodes
  • Every volume in the storage backend console is attached and healthy, and the storage team says nothing is wrong
  • Deleting a stuck Pod fixes it perhaps half the time, which has convinced two engineers that certain nodes are flaky
  • Every CSI node plugin Pod is Running and Ready, the CSI controller is healthy, and new PVCs bind immediately

Evidence

  • · kubectl describe pod on a stuck Pod shows FailedMount and a timed out waiting for the condition message, with no backend error
  • · kubectl get volumeattachment shows ATTACHED true for the volume, so the controller plugin and the backend both did their work
  • · kubectl get pvc shows Bound, so provisioning is not involved either
  • · kubectl get pods -n kube-system -l app=ebs-csi-node -o wide sorted by creation time splits cleanly into Pods a few hours old and Pods weeks old
  • · Every stuck workload is on a node whose node plugin Pod is one of the young ones; every workload that mounted fine is on one of the old ones
  • · On a failing node the host mount namespace shows no CSI mount for the volume, while the same volume is mounted inside the node plugin container
  • · kubectl rollout history daemonset/ebs-csi-node shows revision 2 rolled out this morning
  • · The revision 2 pod template has no mountPropagation on the volumeMount for /var/lib/kubelet
Diagnosis and resolutionclick to reveal

Root cause

The CSI driver upgrade that rolled this morning lost one line. The node plugin mounts the host `/var/lib/kubelet` directory into its own container, and that volumeMount must carry `mountPropagation: Bidirectional`; without it the mount defaults to `None`, meaning private. The plugin therefore does its job correctly and invisibly: `NodeStageVolume` formats and mounts the device, `NodePublishVolume` bind-mounts it to the target path, both return success, and every one of those mounts exists only inside the plugin container mount namespace. The host never sees them, so neither does the kubelet, and neither does the container runtime that has to bind the path into the workload container. The kubelet waits, retries, and eventually reports that it timed out waiting for the condition — a message that says nothing about mount propagation and points at no component in particular. Because a DaemonSet rolls one node at a time, the fault spread across the fleet over several hours in the order the DaemonSet chose, which is why it looks like a handful of unrelated workloads on a handful of flaky nodes rather than one change. The attach path is untouched throughout, which is why the volumes are all attached and healthy and the storage team is right that nothing is wrong.

Remediation

Roll the DaemonSet back before anything else, with `kubectl rollout undo daemonset/ebs-csi-node -n kube-system`: revision 1 carries the propagation setting and the same rolling mechanism that spread the fault will heal it node by node. Restarting the node plugin is safe on the nodes that are still working, because a mount that reached the host namespace does not depend on the plugin process afterwards; on the broken nodes the mounts that vanish with the container were never usable from outside it. Cordon the already broken nodes while the rollback proceeds so that nothing new schedules onto them, and uncordon each as its plugin Pod comes back on the good revision. Do not force-delete the stuck Pods and do not force-detach the volumes: the kubelet retries the mount on its own backoff and will succeed once the plugin under it is correct, whereas a force-detach removes the attachment without the node confirming an unmount and a force-deleted StatefulSet Pod can leave two Pods with the same identity writing the same volume. Fix the source manifest afterwards, not during, and treat "hold at revision 1 until the upgrade is re-reviewed" as the expected outcome of the incident rather than a failure to complete it.

Verification

The check is per node and it is a real mount, not a Pod status. Confirm the DaemonSet reports desired, current, ready and up-to-date all equal to the node count on the rolled-back revision. Then, on a node that was broken, confirm the volume now appears in the host mount namespace rather than only inside the plugin container — that comparison is what identified the fault and it is what proves the fix. Confirm the stuck Pods reached Running without anyone deleting them, because a Pod that only recovered after deletion tells you nothing about the node it left. Schedule a short-lived Pod with a fresh PVC onto each node that was in the broken set, one at a time, and confirm it mounts and writes; testing a random node proves nothing when the fault followed a rollout order. Finally, confirm the backup CronJob produced a completed Pod on its next scheduled run.

Prevention

A driver upgrade needs a test that mounts something. Every check that ran during this upgrade asked whether the plugin Pods were Ready, and they were Ready throughout, on every node, while being unable to serve a single mount. Make the post-upgrade gate a short-lived workload with a fresh PVC on every node, which is the only test that exercises the path that broke. Diff the rendered manifest against the running one before applying a driver upgrade and read the volumeMounts stanza specifically, since the field that vanished is one line inside a block that otherwise looked unchanged. Add a policy check that fails any CSI node plugin whose `/var/lib/kubelet` mount is not `Bidirectional`, because that setting is not a tuning choice and there is no legitimate reason for it to be absent. Alert on Pods stuck in ContainerCreating beyond a few minutes and group the alert by node, because the grouping is the entire diagnosis. And retire "that node is flaky" as an explanation: a node that fails intermittently for several workloads is a hypothesis to be tested, not a conclusion.

Reported symptoms

Four things are wrong this morning and none of them look related.

  • Platform. postgres-2, rescheduled last night after a routine node drain, has been in ContainerCreating for twenty-three minutes.
  • Data. The nightly backup CronJob has produced no completed Pod for two nights. Nobody noticed until the report failed to arrive.
  • Search. A Deployment rollout is stuck at 3 of 5. The three Pods that started are on node-02, node-08 and node-14.
  • Storage. Asked to look at the backend, the storage team reports every volume attached, healthy, and serving IO to the workloads that already have it.

Two engineers have independently concluded that particular nodes are flaky, because deleting a stuck Pod fixes it about half the time. One of them has started draining node-19.

Meanwhile the CSI driver is, by every check the platform runs, in perfect health: forty node plugin Pods Running and Ready, the controller plugin healthy, and PVCs binding within a second of being created.

The questions worth asking at this point:

  • If the volumes are attached, which part of the storage path is left?
  • Why does deleting a Pod fix it sometimes?
  • What happened at 06:40 this morning?

Evidence provided

Read-only / Safea timeout, not a backend error - nothing here names a component
$ kubectl describe pod postgres-2 -n platform | sed -n '/Events/,$p'
Events:
Type     Reason       Age                    From               Message
----     ------       ----                   ----               -------
Normal   Scheduled    24m                    default-scheduler  Successfully assigned platform/postgres-2 to node-19
Warning  FailedMount  2m17s (x9 over 22m)    kubelet            Unable to attach or mount volumes: unmounted volumes=[data], unattached volumes=[], timed out waiting for the condition

Illustrative output

Read-only / Safeattached true - the controller plugin and the backend both did their job
$ kubectl get volumeattachment -o custom-columns=NAME:.metadata.name,PV:.spec.source.persistentVolumeName,NODE:.spec.nodeName,ATTACHED:.status.attached | grep node-19
csi-3f81a4c9  pvc-a17c5b2e-9d44-4c1a-b6f2-71e0c8d3a952  node-19  true

Illustrative output

That single line eliminates a great deal. The PVC is Bound, so provisioning worked. The VolumeAttachment is attached, so ControllerPublishVolume worked and the device is present on the node. Everything left is on the node, between the kubelet and the node plugin.

Read-only / Safeevery Pod Ready, and the ages fall into two groups
$ kubectl get pods -n kube-system -l app=ebs-csi-node -o wide --sort-by=.metadata.creationTimestamp | awk 'NR==1 || NR%6==2'
NAME                  READY   STATUS    RESTARTS   AGE   NODE
ebs-csi-node-x4k2p    3/3     Running   0          26d   node-02
ebs-csi-node-r7wqm    3/3     Running   0          26d   node-14
ebs-csi-node-9tzvb    3/3     Running   0          3h1m  node-19
ebs-csi-node-c5mdh    3/3     Running   0          2h48m node-27
ebs-csi-node-k8plf    3/3     Running   0          2h11m node-33
ebs-csi-node-j2xrn    3/3     Running   0          97m   node-41

Illustrative output

Read-only / Saferevision 2 started rolling at 06:40 and is still working through the fleet
$ kubectl rollout history daemonset/ebs-csi-node -n kube-system
daemonset.apps/ebs-csi-node
REVISION  CHANGE-CAUSE
1         <none>
2         driver upgrade v1.30.0 -> v1.31.0

Illustrative output

Read-only / Safeinside the plugin container on node-19: the mounts exist
$ kubectl exec -n kube-system ebs-csi-node-9tzvb -c ebs-plugin -- grep -c 'ebs.csi.aws.com' /proc/mounts
3

Illustrative output

Read-only / Safeon the host itself: none of them
$ kubectl debug node/node-19 -it --image=busybox -- grep -c 'ebs.csi.aws.com' /host/proc/1/mountinfo
0

Illustrative output

Work the evidence before reading on

Nothing here has failed. Every component reports success, and every one of those reports is honest.

  1. Line up the list of stuck workloads against the list of node plugin Pod ages. The correlation is the diagnosis; write it down before you form an opinion about nodes.
  2. The volume is mounted inside the plugin container and not on the host. Which of the two processes that need to see that mount is inside the container?
  3. Deleting a stuck Pod helps about half the time. Given the rollout is still in progress, what determines which half?

Before continuing: the engineer draining node-19 is trying to move those workloads somewhere healthy. What does the drain actually do to this incident?

Root cause

1. The plugin mounted the volume into its own namespace

A container has its own mount namespace. That is the point of a container.

The CSI node plugin therefore has a problem no other workload has: the mounts it creates have to be visible to somebody else — to the kubelet, which asked for them, and to the container runtime, which has to bind the path into the workload container. Both are on the host.

The mechanism that solves this is mountPropagation: Bidirectional on the volumeMount for the host /var/lib/kubelet directory. It is the setting that lets a mount made inside the container travel outward to the host.

Revision 2 of the DaemonSet does not have it:

        volumeMounts:
        - name: kubelet-dir
          mountPath: /var/lib/kubelet
          # mountPropagation: Bidirectional   <- present in revision 1

Absent, the field defaults to None, which means private. So NodeStageVolume formats and mounts the device, NodePublishVolume bind-mounts it to the target path, both return success to the kubelet, and all of it happens in a namespace nobody else can see. The plugin is not broken. It is working perfectly, in a room with the door shut.

2. The kubelet has no vocabulary for this

The kubelet asked for a mount and got a success. It then looks at the target path, finds nothing mounted, and does the only thing it can: waits, retries, and eventually reports timed out waiting for the condition.

That message is accurate and useless. It names no component, carries no backend error, and reads exactly like a slow storage array. It is the reason three teams filed three tickets against three different subsystems.

3. A DaemonSet rolls one node at a time, which disguises the change

ebs-csi-node updates with a rolling strategy, so at 09:15 the fleet is split: nodes the DaemonSet has reached are broken for new mounts, nodes it has not reached are fine, and the boundary moves during the incident.

Every confusing observation follows from that split.

  • Deleting a stuck Pod works when the scheduler happens to place it on a node the rollout has not reached yet, and does nothing when it does not. That is the “half the time”.
  • The three Search Pods that started are on node-02, node-08 and node-14, which are simply three of the nodes still on revision 1.
  • Workloads already running are unaffected, because their mounts were made before the roll and live on the host where they always did.

None of this is a property of any node. It is a property of the rollout order, and it is why “these nodes are flaky” survived as an explanation for three hours.

Resolution

  1. Stop the drain. It is converting working nodes into broken ones, and it is the most urgent thing in the incident.
  2. Write the correlation down: stuck workloads against node plugin Pod age. One table, two columns, and the three tickets become one.
  3. Roll the DaemonSet back with kubectl rollout undo daemonset/ebs-csi-node -n kube-system. Revision 1 carries the propagation setting, and the mechanism that spread the fault heals it in the same order.
  4. Cordon the nodes still on revision 2 while the rollback works through them, and uncordon each one as its plugin Pod returns to revision 1. This bounds the incident instead of chasing it.
  5. Leave the stuck Pods alone. The kubelet retries the mount on its own backoff and will succeed once the plugin under it is correct; a Pod you delete tells you nothing about whether the node recovered.
  6. Verify one formerly broken node properly before declaring the fleet fixed: the mount has to appear in the host namespace, not merely in the plugin container.
  7. Fix the upstream manifest afterwards, not during. The upgrade stays held at revision 1 until somebody has read the volumeMounts stanza of revision 2 and explained how the line was lost.
  8. Check what the two missed backup nights cost before closing. The CronJob failed silently and that is a second finding, not a footnote.

Verification

  1. The DaemonSet reports desired, current, ready and up-to-date all equal to the node count, on revision 1.
  2. On a node that was in the broken set, the CSI mount for a test volume appears in the host mount namespace. This is the check that can fail and it is the one that matters.
  3. The Pods that were stuck reached Running without being deleted. Note which ones, because any Pod that was deleted during the incident has to be re-tested.
  4. A short-lived Pod with a fresh PVC mounts and writes on each node that was broken, tested one node at a time. A random node proves nothing when the fault followed a rollout order.
  5. The backup CronJob produced a completed Pod on its next scheduled run, and the resulting artefact is the right size.
  6. No VolumeAttachment was force-removed and no StatefulSet Pod was force-deleted during the incident. If either happened, the affected volume needs a filesystem check before it is trusted.
  7. The drained node is back in service and carrying workloads again.

Prevention

  • Make the upgrade gate mount something. Every check that ran during this upgrade asked whether the plugin Pods were Ready. They were Ready on all forty nodes, throughout, while serving no mounts at all. A post-upgrade gate that schedules a short-lived Pod with a fresh PVC onto every node is the only test that exercises the path that broke.
  • Read the volumeMounts stanza on every driver upgrade. Diff the rendered manifest against the running one rather than trusting the chart version. The field that disappeared is one line in a block that otherwise looked identical.
  • Enforce it as policy. A CSI node plugin whose /var/lib/kubelet mount is not Bidirectional is misconfigured in every case; there is no tuning argument on the other side. That makes it a good candidate for an admission policy or a CI check rather than a review convention.
  • Alert on ContainerCreating, grouped by node. A Pod stuck in ContainerCreating past a few minutes is worth an alert on its own; the grouping by node is what turns four tickets into one diagnosis, and it is free.
  • Alert on a CronJob that produces no completed Pod. Two nights of missing backups went unnoticed because the job never ran, and a monitor that watches job failures does not see a job that failed to start.
  • Retire “that node is flaky”. It is a hypothesis with an obvious test — does the fault follow the node or the workload — and it was accepted here for three hours without anyone running it.