mirror of
https://github.com/hanzoai/s3-csi.git
synced 2026-09-30 23:52:32 +00:00
Fork of seaweedfs/seaweedfs-csi-driver, renamed seaweedfs -> s3 throughout and ported off protobuf entirely. WHY a fork was unavoidable: hanzoai/s3 serves HanzoFiler over the native ZAP transport on the port still named "grpc" (18888) — s3/command/filer.go binds it with transport.ListenStream and says it "replaces the legacy gRPC HanzoFiler server: the whole filer (28 unary + 5 streaming RPCs) now answers over ZAP, no gRPC". RegisterHanzoFilerServer exists nowhere in that tree. So the upstream driver's gRPC client cannot connect at all; it fails as `Unimplemented: unknown service filer_pb.SeaweedFiler`, which LOOKS like a service-name mismatch but is a gRPC client hitting a non-gRPC port. Renaming the service in the client would not have helped. pkg/filer is the only place this driver touches storage, and it speaks ZAP: Mkdir -> CreateEntry (CreateVolume) Remove -> DeleteEntry (DeleteVolume) Exists -> LookupDirectoryEntry (ValidateVolumeCapabilities) One shared, concurrency-safe client multiplexes over a single ZAP connection, dials lazily so controller start does not depend on filer readiness, and redials exactly once on a transport fault (covers a filer restart closing an idle conn without becoming a retry storm against a struggling backend). pkg/driver/volume.go Quota() likewise moves to s3/wire/mount — the fork generates no mount_pb at all. Dead gRPC plumbing (WithFilerClient, AdjustedUrl, grpcDialOption) is deleted rather than left inert. The kubelet-facing side stays CSI gRPC: that is the Kubernetes contract between kubelet and any CSI driver, not a Hanzo choice. Zero protobuf call sites remain. Whole module compiles clean. Co-authored-by: Hanzo Dev <[email protected]>
392 lines
14 KiB
Go
392 lines
14 KiB
Go
package driver
|
|
|
|
import (
|
|
"runtime/debug"
|
|
"time"
|
|
|
|
"github.com/hanzoai/s3/s3/glog"
|
|
"k8s.io/mount-utils"
|
|
)
|
|
|
|
const (
|
|
defaultHealthCheckInterval = 30 * time.Second
|
|
// defaultHealthCheckTimeout bounds any single isHealthyFn call so a
|
|
// frozen FUSE daemon (os.ReadDir blocked in the kernel) cannot stall
|
|
// the health monitor goroutine indefinitely.
|
|
defaultHealthCheckTimeout = 5 * time.Second
|
|
)
|
|
|
|
func (ns *NodeServer) startHealthMonitor(interval time.Duration) {
|
|
go func() {
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
glog.Infof("health monitor started with interval %v", interval)
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
ns.runHealthCheckTick()
|
|
case <-ns.stopCh:
|
|
glog.Infof("health monitor stopped")
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// runHealthCheckTick runs one health check pass with panic recovery so
|
|
// that an unexpected crash inside checkAndRecoverVolumes or recoverVolume
|
|
// does not silently disable self-healing for the lifetime of the pod.
|
|
func (ns *NodeServer) runHealthCheckTick() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
glog.Errorf("health monitor: recovered from panic: %v\n%s", r, debug.Stack())
|
|
}
|
|
}()
|
|
ns.checkAndRecoverVolumes()
|
|
}
|
|
|
|
// checkHealth runs isHealthyFn with a timeout so a hung FUSE daemon
|
|
// cannot stall the monitor sweep. On timeout the path is considered
|
|
// unhealthy, which either triggers recovery (if it really is dead) or
|
|
// is harmlessly retried on the next tick (if it was just slow). The
|
|
// background goroutine is allowed to leak on timeout — it will exit
|
|
// whenever the underlying filesystem call eventually returns.
|
|
//
|
|
// The inner goroutine has its own panic recovery so a crashing
|
|
// isHealthyFn cannot take down the whole driver process.
|
|
func (ns *NodeServer) checkHealth(path string) bool {
|
|
done := make(chan bool, 1)
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
glog.Errorf("health monitor: health check for %s panicked: %v\n%s", path, r, debug.Stack())
|
|
// Treat a panic as unhealthy and unblock the caller
|
|
// so it does not have to wait for the timeout. The
|
|
// channel is buffered (size 1) and no prior send has
|
|
// happened on this path, so this non-blocking send
|
|
// is always safe.
|
|
select {
|
|
case done <- false:
|
|
default:
|
|
}
|
|
}
|
|
}()
|
|
done <- ns.isHealthyFn(path)
|
|
}()
|
|
select {
|
|
case result := <-done:
|
|
return result
|
|
case <-time.After(defaultHealthCheckTimeout):
|
|
glog.Warningf("health monitor: health check for %s timed out after %v, treating as unhealthy", path, defaultHealthCheckTimeout)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// checkAndRecoverVolumes iterates the volume map and launches one
|
|
// background goroutine per volume to perform its health check and any
|
|
// needed recovery work. The sweep loop itself only touches the
|
|
// in-flight set — it does not block on health checks, so a single hung
|
|
// FUSE can never stall inspection of other volumes.
|
|
//
|
|
// ns.activeRecoveries deduplicates in-flight work: if a recovery for a
|
|
// given volumeID is already running (e.g. because the previous sweep's
|
|
// goroutine is blocked on a slow unmount), the new sweep skips it
|
|
// instead of spawning a second goroutine that would just pile up on
|
|
// the same mutex. This prevents goroutine exhaustion when a volume is
|
|
// stuck.
|
|
func (ns *NodeServer) checkAndRecoverVolumes() {
|
|
ns.volumes.Range(func(key, value interface{}) bool {
|
|
volumeID := key.(string)
|
|
vol := value.(*Volume)
|
|
|
|
if vol.StagedPath == "" {
|
|
return true
|
|
}
|
|
ns.launchVolumeHealthCheck(volumeID)
|
|
return true
|
|
})
|
|
}
|
|
|
|
// launchVolumeHealthCheck spawns the per-volume health check goroutine
|
|
// if one is not already running for this volumeID. The goroutine is the
|
|
// place where slow work (checkHealth, unmount, re-mount, re-bind) runs.
|
|
// Tests synchronize via ns.recoveryWg.
|
|
func (ns *NodeServer) launchVolumeHealthCheck(volumeID string) {
|
|
if _, loaded := ns.activeRecoveries.LoadOrStore(volumeID, struct{}{}); loaded {
|
|
glog.V(4).Infof("health monitor: health check already in progress for volume %s, skipping", volumeID)
|
|
return
|
|
}
|
|
|
|
ns.recoveryWg.Add(1)
|
|
go func() {
|
|
defer ns.recoveryWg.Done()
|
|
defer ns.activeRecoveries.Delete(volumeID)
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
glog.Errorf("health monitor: health check for volume %s panicked: %v\n%s", volumeID, r, debug.Stack())
|
|
}
|
|
}()
|
|
ns.performVolumeHealthCheck(volumeID)
|
|
}()
|
|
}
|
|
|
|
// performVolumeHealthCheck runs inside the per-volume goroutine. It
|
|
// does the potentially-slow health checks off the sweep thread and
|
|
// dispatches to full recovery or publish-retry as needed.
|
|
func (ns *NodeServer) performVolumeHealthCheck(volumeID string) {
|
|
val, ok := ns.volumes.Load(volumeID)
|
|
if !ok {
|
|
return
|
|
}
|
|
vol := val.(*Volume)
|
|
if vol.StagedPath == "" {
|
|
return
|
|
}
|
|
|
|
if !ns.checkHealth(vol.StagedPath) {
|
|
glog.Warningf("health monitor: detected unhealthy staging mount for volume %s at %s", volumeID, vol.StagedPath)
|
|
ns.recoverVolume(volumeID)
|
|
return
|
|
}
|
|
|
|
// Staging is alive; check whether any publish bind mounts have
|
|
// been dropped (e.g. from a previous partial recovery) and need
|
|
// to be re-bound without tearing down the FUSE mount.
|
|
if ns.hasUnhealthyPublishPath(vol) {
|
|
glog.Warningf("health monitor: detected unhealthy publish mount for volume %s", volumeID)
|
|
ns.retryPublishPaths(volumeID)
|
|
}
|
|
}
|
|
|
|
// hasUnhealthyPublishPath returns true if any of the Volume's tracked
|
|
// publish paths is not currently a live, readable mount point. It uses
|
|
// the same isHealthyFn as staging so behavior stays consistent across
|
|
// both mount levels. This runs inside the per-volume goroutine so a
|
|
// slow check for one volume does not affect others.
|
|
func (ns *NodeServer) hasUnhealthyPublishPath(vol *Volume) bool {
|
|
unhealthy := false
|
|
vol.publishPaths.Range(func(k, _ interface{}) bool {
|
|
if !ns.checkHealth(k.(string)) {
|
|
unhealthy = true
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
return unhealthy
|
|
}
|
|
|
|
// tearDownStalePublishBind unmounts a publish bind, falling back to
|
|
// mount.CleanupMountPoint if the regular unmount fails. Returns true
|
|
// only if the path is no longer a mount point — callers must skip
|
|
// re-publishing when false, since Volume.Publish short-circuits on
|
|
// any pre-existing mount and would falsely claim success against the
|
|
// dead FUSE.
|
|
func (ns *NodeServer) tearDownStalePublishBind(path, volumeID string) bool {
|
|
if err := ns.unmountFn(path); err == nil {
|
|
return true
|
|
} else {
|
|
glog.Warningf("health monitor: unmount publish path %s for volume %s failed: %v, trying force cleanup", path, volumeID, err)
|
|
}
|
|
if cleanupErr := mount.CleanupMountPoint(path, mountutil, true); cleanupErr != nil {
|
|
glog.Errorf("health monitor: force cleanup of publish path %s for volume %s also failed: %v; skipping re-publish to avoid Publish() falsely satisfying the stale mount", path, volumeID, cleanupErr)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// retryPublishPaths re-binds publish paths whose bind mount has gone
|
|
// missing while the underlying staging FUSE mount is still alive. This
|
|
// is the second-chance path for publish failures that happened during a
|
|
// previous recoverVolume sweep.
|
|
func (ns *NodeServer) retryPublishPaths(volumeID string) {
|
|
volumeMutex := ns.getVolumeMutex(volumeID)
|
|
volumeMutex.Lock()
|
|
defer volumeMutex.Unlock()
|
|
|
|
val, ok := ns.volumes.Load(volumeID)
|
|
if !ok {
|
|
return
|
|
}
|
|
vol := val.(*Volume)
|
|
|
|
// If staging has died since the sweep, let the next tick's
|
|
// full-recovery path handle it instead of fighting the race here.
|
|
if !ns.checkHealth(vol.StagedPath) {
|
|
glog.Infof("health monitor: staging for volume %s became unhealthy before publish retry; deferring to full recovery", volumeID)
|
|
return
|
|
}
|
|
|
|
vol.publishPaths.Range(func(k, v interface{}) bool {
|
|
path := k.(string)
|
|
readOnly := v.(bool)
|
|
if ns.checkHealth(path) {
|
|
return true
|
|
}
|
|
glog.Warningf("health monitor: re-binding publish path %s for volume %s", path, volumeID)
|
|
// Volume.Publish short-circuits on any pre-existing mount; if we
|
|
// cannot tear the stale bind down, calling it would falsely
|
|
// claim success against the dead FUSE. Defer to the next sweep.
|
|
if !ns.tearDownStalePublishBind(path, volumeID) {
|
|
return true
|
|
}
|
|
if err := vol.Publish(vol.StagedPath, path, readOnly); err != nil {
|
|
glog.Errorf("health monitor: failed to re-bind publish path %s for volume %s: %v", path, volumeID, err)
|
|
return true
|
|
}
|
|
glog.Infof("health monitor: successfully re-bound publish path %s for volume %s", path, volumeID)
|
|
|
|
remountStaleFuseInContainers(path, vol.StagedPath, readOnly)
|
|
return true
|
|
})
|
|
}
|
|
|
|
func (ns *NodeServer) recoverVolume(volumeID string) {
|
|
volumeMutex := ns.getVolumeMutex(volumeID)
|
|
volumeMutex.Lock()
|
|
defer volumeMutex.Unlock()
|
|
|
|
// Re-load from map after acquiring lock (another goroutine may have replaced it)
|
|
val, ok := ns.volumes.Load(volumeID)
|
|
if !ok {
|
|
glog.Infof("health monitor: volume %s no longer exists, skipping recovery", volumeID)
|
|
return
|
|
}
|
|
vol := val.(*Volume)
|
|
|
|
// Re-check health after acquiring lock
|
|
if ns.checkHealth(vol.StagedPath) {
|
|
glog.Infof("health monitor: volume %s is now healthy, skipping recovery", volumeID)
|
|
return
|
|
}
|
|
|
|
if vol.volContext == nil {
|
|
glog.Warningf("health monitor: cannot recover volume %s - no volume context available (volume was rebuilt from existing mount after CSI driver restart)", volumeID)
|
|
return
|
|
}
|
|
|
|
stagingPath := vol.StagedPath
|
|
|
|
// Capture the old FUSE mount's device identifier before cleanup.
|
|
// After recovery the staging path will have a new device, so this
|
|
// is the only chance to learn which device the containers' stale
|
|
// bind mounts still reference.
|
|
oldDevice, _ := getMountDevice(stagingPath)
|
|
|
|
// Collect publish paths before recovery
|
|
type publishInfo struct {
|
|
path string
|
|
readOnly bool
|
|
}
|
|
var publishes []publishInfo
|
|
vol.publishPaths.Range(func(k, v interface{}) bool {
|
|
publishes = append(publishes, publishInfo{k.(string), v.(bool)})
|
|
return true
|
|
})
|
|
|
|
// If staging is already unmounted, derive the device from any
|
|
// publish bind — they target the same FUSE. Device-keyed remount
|
|
// avoids replacing a different volume's mount when a pod has
|
|
// multiple CSI volumes.
|
|
if oldDevice == "" {
|
|
for _, p := range publishes {
|
|
if d, err := getMountDevice(p.path); err == nil && d != "" {
|
|
oldDevice = d
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
glog.Infof("health monitor: recovering volume %s (%d publish paths)", volumeID, len(publishes))
|
|
|
|
// Re-stage before touching publish binds: if re-stage fails, the
|
|
// (broken) binds stay in place rather than leaving kubelet seeing
|
|
// empty publish paths.
|
|
|
|
// Step 1: Manager-level unmount. cleanupStagingFn only does a host
|
|
// unmount + RemoveAll, leaving the manager thinking the volume is
|
|
// still mounted, so the follow-up Mount would no-op onto a dead path.
|
|
if vol.unmounter != nil {
|
|
if err := vol.unmounter.Unmount(); err != nil {
|
|
// Aborting is safer than continuing: RemoveAll on a path the
|
|
// manager still considers mounted risks deleting user data
|
|
// through a live FUSE.
|
|
glog.Errorf("health monitor: unmount via mount manager failed for volume %s, aborting recovery: %v", volumeID, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// RemoveAll on a still-mounted FUSE would delete remote data via
|
|
// gRPC. FUSE can still be alive here if vol.unmounter was nil
|
|
// (rebuilt volume) or wait()'s kubeMounter.Unmount silently failed.
|
|
if notMnt, err := mountutil.IsLikelyNotMountPoint(stagingPath); err == nil && !notMnt {
|
|
glog.Errorf("health monitor: refusing to clean up staging path %s for volume %s — still a mount point; aborting recovery to avoid data deletion", stagingPath, volumeID)
|
|
return
|
|
}
|
|
|
|
// Step 2: Clean up stale staging path
|
|
if err := ns.cleanupStagingFn(stagingPath); err != nil {
|
|
glog.Errorf("health monitor: failed to cleanup stale staging for volume %s: %v", volumeID, err)
|
|
return
|
|
}
|
|
|
|
// Step 3: Re-stage with a fresh FUSE mount.
|
|
newVol, err := ns.stageNewVolume(volumeID, stagingPath, vol.volContext, vol.readOnly)
|
|
if err != nil {
|
|
glog.Errorf("health monitor: failed to re-stage volume %s: %v", volumeID, err)
|
|
return
|
|
}
|
|
|
|
// Step 4: Tear down stale publish binds. Volume.Publish short-circuits
|
|
// on any pre-existing mount, so track which were actually unmounted —
|
|
// re-publishing onto a stale bind would falsely claim success against
|
|
// the dead FUSE.
|
|
unmounted := make(map[string]bool, len(publishes))
|
|
for _, p := range publishes {
|
|
glog.Infof("health monitor: unmounting stale publish path %s for volume %s", p.path, volumeID)
|
|
unmounted[p.path] = ns.tearDownStalePublishBind(p.path, volumeID)
|
|
}
|
|
|
|
// Step 5: Re-bind publish paths. Track failures and successes
|
|
// separately so container-side mounts still get fixed for paths
|
|
// that did recover.
|
|
var failed, recovered []publishInfo
|
|
for _, p := range publishes {
|
|
newVol.AddPublishPath(p.path, p.readOnly)
|
|
if !unmounted[p.path] {
|
|
failed = append(failed, p)
|
|
continue
|
|
}
|
|
glog.Infof("health monitor: re-publishing %s for volume %s", p.path, volumeID)
|
|
if err := newVol.Publish(stagingPath, p.path, p.readOnly); err != nil {
|
|
glog.Errorf("health monitor: failed to re-publish %s for volume %s: %v", p.path, volumeID, err)
|
|
failed = append(failed, p)
|
|
} else {
|
|
recovered = append(recovered, p)
|
|
}
|
|
}
|
|
|
|
// Step 6: Replace the volume in the map
|
|
ns.volumes.Store(volumeID, newVol)
|
|
|
|
// Step 7: Replace stale FUSE mounts inside pod containers (rprivate
|
|
// propagation blocks host-side changes from reaching them).
|
|
// Device-keyed so we never touch another volume's mount in the
|
|
// same pod. Without oldDevice there is no safe identifier and no
|
|
// automatic retry: hasUnhealthyPublishPath only sees the (now
|
|
// healthy) host bind, so containers stay broken until pod restart.
|
|
if oldDevice == "" {
|
|
glog.Errorf("health monitor: container-side remount for volume %s could not run — no device captured for the old FUSE mount; affected pods will need to be restarted manually because hasUnhealthyPublishPath only sees the (now healthy) host bind", volumeID)
|
|
} else {
|
|
for _, p := range recovered {
|
|
remountInContainers(p.path, stagingPath, oldDevice, p.readOnly)
|
|
}
|
|
}
|
|
|
|
if len(failed) > 0 {
|
|
glog.Warningf("health monitor: volume %s recovered with %d publish path failure(s); retryPublishPaths will retry on the next sweep", volumeID, len(failed))
|
|
return
|
|
}
|
|
|
|
glog.Infof("health monitor: volume %s successfully recovered", volumeID)
|
|
}
|