Skip to content

Commit 9269f07

Browse files
committed
feat: force-unmount broken staging paths for self-healing
When a block device completely disappears (NAS reboot, NVMe controller gone), kubelet doesn't re-stage because it thinks the volume is still staged. Pods crash-loop indefinitely. The health monitor now detects staging mounts whose source device no longer exists (or is inaccessible) and force-unmounts them with umount -l. This makes kubelet's mount check fail, triggering the full NodeUnstageVolume → NodeStageVolume → NodePublishVolume flow which re-establishes the connection from scratch. This is the same approach used by democratic-csi for handling backend storage failures.
1 parent 8731a98 commit 9269f07

1 file changed

Lines changed: 81 additions & 0 deletions

File tree

pkg/driver/node_recovery.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,16 +60,97 @@ func (s *NodeService) healthMonitorLoop() {
6060
}
6161

6262
// runHealthCheck performs a single pass of health checking and recovery.
63+
// It first tries to recover storage sessions (iSCSI re-login, NVMe controller reset).
64+
// If devices are completely gone and unrecoverable, it force-unmounts the staging
65+
// paths so kubelet detects the broken state and triggers re-staging.
6366
func (s *NodeService) runHealthCheck() {
6467
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
6568
defer cancel()
6669

70+
// Phase 1: Try to recover transport-level sessions
6771
iscsiRecovered := s.recoverISCSISessions(ctx)
6872
nvmeRecovered := s.recoverNVMeOFConnections(ctx)
6973

7074
if iscsiRecovered > 0 || nvmeRecovered > 0 {
7175
klog.Infof("Health monitor recovered: iSCSI=%d, NVMe-oF=%d", iscsiRecovered, nvmeRecovered)
7276
}
77+
78+
// Phase 2: Force-unmount staging paths for volumes whose devices are completely gone.
79+
// This allows kubelet to detect the broken mount and trigger NodeUnstageVolume +
80+
// NodeStageVolume, which will establish a fresh connection.
81+
s.unmountBrokenStagingPaths(ctx)
82+
}
83+
84+
// unmountBrokenStagingPaths finds staging mounts where the source block device
85+
// no longer exists and force-unmounts them so kubelet can re-stage.
86+
func (s *NodeService) unmountBrokenStagingPaths(ctx context.Context) {
87+
// Use findmnt to discover all mounts under our CSI staging directory
88+
findmntCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
89+
defer cancel()
90+
91+
cmd := exec.CommandContext(findmntCtx, "findmnt", "-n", "-o", "SOURCE,TARGET", "-t", "ext4,xfs", "--raw")
92+
output, err := cmd.CombinedOutput()
93+
if err != nil {
94+
klog.V(4).Infof("findmnt for staging path check: %v", err)
95+
return
96+
}
97+
98+
// Filter for our CSI driver's staging paths
99+
const stagingPathMarker = "/plugins/kubernetes.io/csi/nasty.csi.io/"
100+
for _, line := range strings.Split(string(output), "\n") {
101+
if !strings.Contains(line, stagingPathMarker) {
102+
continue
103+
}
104+
105+
fields := strings.Fields(line)
106+
if len(fields) < 2 {
107+
continue
108+
}
109+
110+
source := fields[0]
111+
target := fields[1]
112+
113+
// Check if the source device still exists
114+
if _, statErr := os.Stat(source); statErr == nil {
115+
// Device exists — check if it's actually accessible
116+
if isDeviceAccessible(ctx, source) {
117+
continue // Healthy
118+
}
119+
klog.Warningf("Device %s exists but is inaccessible (staging: %s)", source, target)
120+
} else {
121+
klog.Warningf("Device %s no longer exists (staging: %s)", source, target)
122+
}
123+
124+
// Device is gone or inaccessible — force lazy unmount the staging path.
125+
// This makes kubelet's mount check fail, triggering the full re-stage flow.
126+
klog.Infof("Force-unmounting broken staging path %s (source device: %s)", target, source)
127+
umountCtx, umountCancel := context.WithTimeout(ctx, 15*time.Second)
128+
umountCmd := exec.CommandContext(umountCtx, "umount", "-l", target)
129+
if umountOutput, umountErr := umountCmd.CombinedOutput(); umountErr != nil {
130+
klog.Errorf("Failed to unmount broken staging path %s: %v (%s)", target, umountErr, strings.TrimSpace(string(umountOutput)))
131+
} else {
132+
klog.Infof("Successfully unmounted broken staging path %s — kubelet will re-stage", target)
133+
}
134+
umountCancel()
135+
}
136+
}
137+
138+
// isDeviceAccessible checks if a block device can be opened (not just stat'd).
139+
// A device that exists in /dev but has a dead transport will fail to open.
140+
func isDeviceAccessible(ctx context.Context, devicePath string) bool {
141+
// Use blockdev --getsize64 as a quick accessibility check
142+
checkCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
143+
defer cancel()
144+
145+
cmd := exec.CommandContext(checkCtx, "blockdev", "--getsize64", devicePath)
146+
output, err := cmd.CombinedOutput()
147+
if err != nil {
148+
return false
149+
}
150+
151+
// If size is 0, device is likely broken
152+
size := strings.TrimSpace(string(output))
153+
return size != "" && size != "0"
73154
}
74155

75156
// recoverVolumes is called asynchronously after the WebSocket connection to

0 commit comments

Comments
 (0)