overview
1 | sequenceDiagram |
command
1 | # 1. Get the full container ID for a pod's container |
Here’s the end-to-end reference flow of how a Pod’s volume target path is created in Kubernetes with a CSI driver (using your LVM-on-host + containerd scenario), from PVC all the way to the containerd rootfs view.
There are two distinct “target” concepts people often conflate:
the kubelet target path (/var/lib/kubelet/pods/<pod-uid>/volumes/kubernetes.io~csi/<vol-name>/mount) created by the CSI node plugin
and
the containerd task rootfs (/run/containerd/io.containerd.runtime.v2.task/k8s.io/<container-id>/rootfs) created later by the runtime when the container starts.
The CSI driver never touches the containerd path directly — the kubelet hands the CSI target path to the CRI, and containerd/runc bind-mounts it into the container’s mount namespace.
Stage 1 — Provision and Bind
- You create a PVC referencing a StorageClass with
provisioner: <your-csi-driver>. - The external-provisioner sidecar watches PVCs and calls
CreateVolumeon the CSI controller plugin. For an LVM driver this typically creates the LV on the target host (or records intent for topology-aware late binding withvolumeBindingMode: WaitForFirstConsumer). - The provisioner creates the PV object; the PersistentVolume controller binds PV↔PVC.
- Pod is created referencing the PVC; scheduler picks a node (with
WaitForFirstConsumer, provisioning happens after scheduling so the LV lands on the right node).
Stage 2 — Attach (ControllerPublishVolume)
If the driver reports attach_required: true, the Attach/Detach controller creates a VolumeAttachment object, and the external-attacher calls ControllerPublishVolume to “attach” the volume to the node. For pure local LVM drivers this is usually a no-op or skipped entirely (CSIDriver.spec.attachRequired: false), since the LV is already local.
Stage 3 — NodeStageVolume (Staging Path)
Once the pod lands on the node, the kubelet’s Volume Manager (reconciler loop comparing desired vs. actual state of world) drives the mount:
- Kubelet’s in-tree CSI mounter calls
NodeStageVolumeover the driver’s UNIX socket. - Staging path:
/var/lib/kubelet/plugins/kubernetes.io/csi/pv/<pv-name>/globalmount(for block volumes:/var/lib/kubelet/plugins/kubernetes.io/csi/volumeDevices/staging/<pv-name>). - Your LVM driver here:
lvchange -ay vg/lv, format if the LV has no filesystem, thenmount /dev/vg/lv <staging-path>. - Staging happens once per PV per node, even if multiple pods use it.
Stage 4 — NodePublishVolume (Target Path)
This is where the per-pod target path is born:
- Kubelet calls
NodePublishVolumewithtarget_path = /var/lib/kubelet/pods/<pod-uid>/volumes/kubernetes.io~csi/<pvc-name>/mount. - The driver performs a bind mount:
mount --bind <staging-path> <target-path>. - The path is stable for the pod UID; kubelet’s reconciler will re-issue
NodePublishVolumeif the mount is lost, and drivers withRequiresRepublishget repeated calls.
So on the host you now have:
1 | /dev/mapper/vg-lv → /var/lib/kubelet/plugins/kubernetes.io/csi/pv/<pv>/globalmount |
Stage 5 — CRI → containerd → the rootfs Path
Now the runtime enters:
- Kubelet calls CRI
CreateContainer, passing Mounts:host_path = /var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~csi/<pvc>/mount,container_path = <volumeMounts.mountPath>. - Containerd’s CRI plugin builds the OCI spec (rootfs from the snapshotter + the mount list) and starts a runtime v2 shim task.
- runc creates the container’s mount namespace and bind-mounts the kubelet target path into the rootfs at
mountPathbefore pivot_root. - Containerd exposes the running container’s rootfs on the host at
/run/containerd/io.containerd.runtime.v2.task/k8s.io/<container-id>/rootfs— this is the path you see inconfig.json/runc specoutput.
Because that rootfs bind shares the same underlying directory the mounts were applied to, you can see the LV’s content from the host at:
1 | /run/containerd/io.containerd.runtime.v2.task/k8s.io/ca47433f.../rootfs/<mountPath> |
This rootfs path is also what vulnerability scanners and debug tooling walk when inspecting container filesystems.
Path Ownership Map
| Path | Created by | Lifetime |
|---|---|---|
/var/lib/kubelet/plugins/kubernetes.io/csi/pv/<pv>/globalmount |
CSI node plugin (NodeStageVolume) |
Per PV per node |
/var/lib/kubelet/pods/<uid>/volumes/kubernetes.io~csi/<pvc>/mount |
CSI node plugin (NodePublishVolume, bind mount) |
Per pod UID |
/run/containerd/io.containerd.runtime.v2.task/k8s.io/<cid>/rootfs |
containerd shim / runc | Per container task |
/var/log/pods/<ns>_<pod>_<uid>/<container>/*.log → symlinks into containerd log dir |
kubelet | Per pod |
Verify on the Host
1 | # find the CSI staging + target mounts for a pod |
Teardown is the exact reverse: NodeUnpublishVolume (unmount target) → container task destroyed (rootfs path disappears) → NodeUnstageVolume (unmount staging, optionally lvchange -an) → ControllerUnpublishVolume → DeleteVolume when the PV is released.
OverlayFS
1 | flowchart TD |
OverlayFS is the union filesystem that stitches the read-only container image and writable PVC-backed directory into a single coherent root filesystem for the container. It’s the “glue” that lets one container see a complete Linux filesystem while the actual data lives in two very different places.
| Component | host mount | Role |
|---|---|---|
lowerdir |
482/fs:481/fs:...:404/fs |
The container image layers — read-only, shared, immutable base content |
upperdir |
.../pvc-85dfd566.../mount/rootfs/fs |
The writable layer — every change the container makes lands here, persisted on your LVM volume |
workdir |
.../pvc-85dfd566.../mount/rootfs/work |
Kernel-internal scratch space for atomic copy-up operations; must be on the same filesystem as upperdir |
| merged mount | /run/containerd/.../52b9bde58b98.../rootfs |
The unified view — what the container actually sees as / |
- Reads: when the container opens a file, OverlayFS checks the upperdir first; if not found, it walks the lowerdirs top-to-bottom. Unmodified files are served straight from the image layers at native speed — after open, operations go directly to the underlying filesystem.
- New writes: files the container creates go directly into the upperdir — in your case, onto the XFS filesystem on the LVM LV. Nothing touches the image layers.
- Modifying an image file (copy-up): if the container writes to a file that only exists in a lowerdir, the kernel first copies the entire file into the upperdir, then applies the write to that copy. The original in the image layer stays untouched. The workdir is used to stage this so a crash mid-copy leaves the old version intact rather than a half-written file.
- Deletes: removing a lower-layer file doesn’t touch the image — OverlayFS drops a “whiteout” marker in the upperdir so the file simply disappears from the merged view.
So in one sentence: overlay is what lets the container believe it owns a normal writable root filesystem, while in reality the reads come from shared read-only image layers and every write is silently redirected into your LVM-backed PVC.
mount propagation
| Mode | Linux | Container sees new host mounts | Host sees mounts created in the container |
|---|---|---|---|
| None (default) | rprivate | No | No |
| HostToContainer | rslave | Yes | No |
| Bidirectional | rshared | Yes | Yes |