Skip to content

Commit 4e754b1

Browse files
authored
feat(disk, helm, docker): host-namespace disk inspection for containerized gpud (#1253)
## Summary Make the GPUd disk component report the **node's real disks** when GPUd runs inside a container (e.g. a privileged DaemonSet), and replace the prior overlay-root workaround with a proper, configurable fix. ## Problem Inside a container, the disk component's host tools run in the **container's** mount namespace, so they describe the container's overlay rootfs instead of the node: - `findmnt --target / --json --df` returns nothing (overlay is a pseudo filesystem that `--df` filters out) and **exits 1**, failing the disk check. - `lsblk` misses the node's data disks and reports empty fstypes. - `statfs(path)` falls through to the overlay — e.g. reporting **124G for a 2.8T** data disk. ## Change Add configurable command overrides so the disk component can run its host tools in the **host mount namespace** (via `nsenter`), mirroring the existing `--reboot-commands` pattern. **`gpud run` flags (new):** `--findmnt-commands`, `--lsblk-commands`, `--blockdev-usage-commands` Plumbed `cmd/gpud` → `pkg/config` → `pkg/server` → `GPUdInstance` → disk component → `pkg/disk`. **`pkg/disk`:** - `FindMntWithCommand` and an `lsblk` command override (run the configured invocation prefix; GPUd appends the flags it controls). - A `df`-based partitions/usage path (`df -T -B1 -P`) that replaces gopsutil enumeration + `statfs` when the override is set. **Helm chart:** - New `gpud.findmntCommands` / `gpud.lsblkCommands` / `gpud.blockdevUsageCommands` values, defaulted to `nsenter --target 1 --mount -- {findmnt,lsblk,df}` and wired into the DaemonSet startup (env + flags), gated like `rebootCommands`. - Document DaemonSet **reboot** and **disk inspection** configuration in the chart README; bump chart to **v0.12.2**; note the BYOK machine-id node label. **Docker:** keep a runtime-stage guard verifying `findmnt` is present in the image. ## Backward compatibility (strict invariant) When a flag is **unset** (the default), every code path falls through to the **exact legacy behavior** — locate `findmnt`/`lsblk` on `PATH` and run them directly, enumerate via gopsutil, and measure usage via `statfs`. Behavior on bare-metal / host GPUd is unchanged. Helm renders **no** disk env/flags when a value is empty. ## Revert of the `--all` band-aid This branch previously added `findmnt --df --all` to force the container overlay into `--df` output. With the host-namespace fix, `findmnt --target X --json --df` (matching `main`) works on real filesystems, and `--all` only masked the container-overlay case with **misleading data** (`overlay` instead of `/dev/sda1 ext4`). The default `findmnt` command is restored to `main`'s. ## Tests - `pkg/disk` unit tests: `df` output parser, partition filtering, option setters, command builders, and the **empty-flag == legacy-path** invariant. - Validated on a live BYOK (AKS) cluster: `nsenter`-wrapped `findmnt`/`lsblk`/`df` report the node's real ext4 root and 2.8T data disk; `df -T -B1 -P` column order matches the parser. - `go build` / `go vet` clean; touched-package tests pass; `helm lint` and `helm template` (default + empty-override) verified. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Gyuho Lee <gyuhol@nvidia.com>
1 parent d409362 commit 4e754b1

26 files changed

Lines changed: 1168 additions & 28 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,6 @@ custom-gcl
4242

4343
/gpud
4444

45+
46+
# Private helm values overlay (nvcr.io image + pull secret) -- internal only
47+
deployments/helm/gpud/values-nvcr.yaml

Dockerfile

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ RUN echo "# APT Package Sources" > /apt-sources/APT_SOURCES.txt && \
137137
FROM nvidia/cuda:${CUDA_VERSION}-runtime-${OS_NAME}${OS_VERSION}
138138
WORKDIR /
139139

140+
# OCI image metadata. Mirrors the Helm chart's Chart.yaml/README so registries
141+
# (e.g. nvcr.io) surface the same description, source, and license for the image.
142+
# ref. https://github.com/opencontainers/image-spec/blob/main/annotations.md
143+
LABEL org.opencontainers.image.title="gpud" \
144+
org.opencontainers.image.description="GPUd automates monitoring, diagnostics, and issue identification for GPUs" \
145+
org.opencontainers.image.source="https://github.com/leptonai/gpud" \
146+
org.opencontainers.image.url="https://github.com/leptonai/gpud" \
147+
org.opencontainers.image.documentation="https://github.com/leptonai/gpud" \
148+
org.opencontainers.image.vendor="leptonai" \
149+
org.opencontainers.image.licenses="Apache-2.0"
150+
140151
# Install required runtime dependencies not included in the NVIDIA CUDA runtime image.
141152
# NOTE: gnupg is installed temporarily for Docker GPG key verification, then purged
142153
# to address CVE-2025-68973 (out-of-bounds write in GnuPG armor_filter before 2.4.9)
@@ -161,6 +172,9 @@ RUN apt-get update && \
161172
# Remove gnupg and related packages to address CVE-2025-68973
162173
# These are only needed for GPG key verification during build, not at runtime
163174
apt-get purge -y --auto-remove gnupg gnupg-l10n gnupg-utils gpg gpg-agent gpg-wks-client gpg-wks-server gpgconf gpgsm dirmngr && \
175+
# util-linux provides findmnt, which the disk component shells out to.
176+
command -v findmnt >/dev/null && \
177+
findmnt --version >/dev/null && \
164178
rm -rf /var/lib/apt/lists/*
165179

166180
# Copy the gpud binary

cmd/gpud/command/command.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,26 @@ sudo rm /etc/systemd/system/gpud.service
204204
Usage: "bash script to run when the control plane sends a reboot request (leave empty to use the built-in sudo reboot behavior)",
205205
Value: "",
206206
},
207+
cli.StringFlag{
208+
Name: "findmnt-commands",
209+
Usage: "command prefix used to invoke findmnt for the disk component (e.g. 'nsenter --target 1 --mount -- findmnt' to read host mounts from inside a container); leave empty to locate and run findmnt directly",
210+
Value: "",
211+
},
212+
cli.StringFlag{
213+
Name: "lsblk-commands",
214+
Usage: "command prefix used to invoke lsblk for the disk component (e.g. 'nsenter --target 1 --mount -- lsblk' to read host block devices from inside a container); leave empty to locate and run lsblk directly",
215+
Value: "",
216+
},
217+
cli.StringFlag{
218+
Name: "blockdev-usage-commands",
219+
Usage: "command prefix used to collect block device usage for the disk component (e.g. 'nsenter --target 1 --mount -- df' to read host disk usage from inside a container); leave empty to use the built-in gopsutil/statfs behavior",
220+
Value: "",
221+
},
222+
cli.StringFlag{
223+
Name: "containerd-service-active-commands",
224+
Usage: "command used to check whether the containerd service is active (e.g. 'nsenter --target 1 --mount -- systemctl is-active containerd' to query the host service manager from inside a container; exit code 0 means active); leave empty to use the built-in systemd check",
225+
Value: "",
226+
},
207227
cli.StringFlag{
208228
Name: "version-file",
209229
Usage: "specifies the version file to use for auto update (leave empty to disable auto update)",

cmd/gpud/run/command.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ func Command(cliContext *cli.Context) error {
130130
enableAutoUpdate := cliContext.Bool("enable-auto-update")
131131
autoUpdateExitCode := cliContext.Int("auto-update-exit-code")
132132
rebootCommands := cliContext.String("reboot-commands")
133+
findmntCommands := cliContext.String("findmnt-commands")
134+
lsblkCommands := cliContext.String("lsblk-commands")
135+
blockdevUsageCommands := cliContext.String("blockdev-usage-commands")
136+
containerdServiceActiveCommands := cliContext.String("containerd-service-active-commands")
133137
versionFile := cliContext.String("version-file")
134138
versionFileSet := cliContext.IsSet("version-file")
135139
pluginSpecsFile := cliContext.String("plugin-specs-file")
@@ -339,6 +343,10 @@ func Command(cliContext *cli.Context) error {
339343
cfg.EnableAutoUpdate = enableAutoUpdate
340344
cfg.AutoUpdateExitCode = autoUpdateExitCode
341345
cfg.RebootCommands = rebootCommands
346+
cfg.FindmntCommands = findmntCommands
347+
cfg.LsblkCommands = lsblkCommands
348+
cfg.BlockdevUsageCommands = blockdevUsageCommands
349+
cfg.ContainerdServiceActiveCommands = containerdServiceActiveCommands
342350
if !versionFileSet {
343351
versionFile = config.VersionFilePath(cfg.DataDir)
344352
}

components/containerd/component.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,16 @@ func New(gpudInstance *components.GPUdInstance) (components.Component, error) {
109109
checkDependencyInstalledFunc: checkContainerdInstalled,
110110
checkSocketExistsFunc: checkSocketExistsFunc,
111111
checkContainerdRunningFunc: CheckContainerdRunning,
112-
checkServiceActiveFunc: func(_ context.Context) (bool, error) {
112+
// checkServiceActiveFunc defaults to the in-namespace systemd.IsActive
113+
// check. When ContainerdServiceActiveCommands is set (e.g. wrapping
114+
// "systemctl is-active containerd" with nsenter), the check runs against
115+
// the host's service manager instead -- required when gpud runs inside a
116+
// container, where the container's own systemd does not manage containerd.
117+
// Empty preserves the legacy behavior byte-for-byte.
118+
checkServiceActiveFunc: func(ctx context.Context) (bool, error) {
119+
if gpudInstance.ContainerdServiceActiveCommands != "" {
120+
return CheckServiceActiveWithCommand(ctx, gpudInstance.ContainerdServiceActiveCommands)
121+
}
113122
return systemd.IsActive("containerd")
114123
},
115124
getContainerdUptimeFunc: func() (*time.Duration, error) {

components/containerd/cri.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,27 @@ func CheckContainerdRunning(ctx context.Context) bool {
249249
return false
250250
}
251251

252+
// CheckServiceActiveWithCommand runs the given command and reports whether the
253+
// containerd service is active based on its exit code (0 = active). It overrides
254+
// the default in-namespace systemd.IsActive check, e.g. by wrapping
255+
// "systemctl is-active containerd" with nsenter so the check queries the host's
256+
// service manager from inside a container. A non-zero exit means "not active"
257+
// (not an error); only a failure to execute the command itself returns an error.
258+
func CheckServiceActiveWithCommand(ctx context.Context, command string) (bool, error) {
259+
// #nosec G204 -- command is an operator-provided override (same trust model as reboot-commands).
260+
out, err := exec.CommandContext(ctx, "bash", "-c", command).CombinedOutput()
261+
if err != nil {
262+
var exitErr *exec.ExitError
263+
if errors.As(err, &exitErr) {
264+
log.Logger.Debugw("containerd service-active command reported inactive",
265+
"command", command, "exitCode", exitErr.ExitCode(), "output", string(out))
266+
return false, nil
267+
}
268+
return false, fmt.Errorf("failed to run containerd service-active command %q: %w", command, err)
269+
}
270+
return true, nil
271+
}
272+
252273
// GetVersion gets the version of the containerd runtime.
253274
func GetVersion(ctx context.Context, endpoint string) (string, error) {
254275
conn, err := connect(ctx, endpoint)

components/containerd/cri_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -802,3 +802,22 @@ func TestIsErrUnimplemented(t *testing.T) {
802802
})
803803
}
804804
}
805+
806+
func TestCheckServiceActiveWithCommand(t *testing.T) {
807+
ctx := context.Background()
808+
809+
// exit code 0 => active
810+
active, err := CheckServiceActiveWithCommand(ctx, "true")
811+
require.NoError(t, err)
812+
assert.True(t, active, "expected active=true for exit-0 command")
813+
814+
// emulates "systemctl is-active containerd" returning active
815+
active, err = CheckServiceActiveWithCommand(ctx, "echo active")
816+
require.NoError(t, err)
817+
assert.True(t, active, "expected active=true for 'echo active'")
818+
819+
// non-zero exit => inactive, but NOT an error (mirrors systemctl exit 3)
820+
active, err = CheckServiceActiveWithCommand(ctx, "false")
821+
require.NoError(t, err, "non-zero exit should not be an error")
822+
assert.False(t, active, "expected active=false for exit-non-zero command")
823+
}

components/disk/component.go

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,26 @@ func newComponent(
141141
getExt4PartitionsFunc: func(ctx context.Context) (disk.Partitions, error) {
142142
timeoutCtx, cancel := context.WithTimeout(ctx, getPartitionsTimeout)
143143
defer cancel()
144-
return disk.GetPartitions(timeoutCtx, disk.WithFstype(disk.DefaultExt4FsTypeFunc), disk.WithMountPoint(disk.DefaultMountPointFunc))
144+
// WithBlockdevUsageCommand is empty by default (legacy gopsutil + statfs
145+
// path); when configured it runs the override (e.g. nsenter df) so usage
146+
// is read from the host mount namespace. The getPartitionsTimeout context
147+
// bounds the command, so a hung filesystem cannot block indefinitely.
148+
return disk.GetPartitions(timeoutCtx, disk.WithFstype(disk.DefaultExt4FsTypeFunc), disk.WithMountPoint(disk.DefaultMountPointFunc), disk.WithBlockdevUsageCommand(gpudInstance.BlockdevUsageCommands))
145149
},
146150
getNFSPartitionsFunc: func(ctx context.Context) (disk.Partitions, error) {
147151
// statfs on nfs can incur network I/O or impact disk I/O performance
148152
// do not track usage for nfs partitions
149153
timeoutCtx, cancel := context.WithTimeout(ctx, getPartitionsTimeout)
150154
defer cancel()
151-
return disk.GetPartitions(timeoutCtx, disk.WithFstype(disk.DefaultNFSFsTypeFunc), disk.WithMountPoint(disk.DefaultMountPointFunc))
155+
return disk.GetPartitions(timeoutCtx, disk.WithFstype(disk.DefaultNFSFsTypeFunc), disk.WithMountPoint(disk.DefaultMountPointFunc), disk.WithBlockdevUsageCommand(gpudInstance.BlockdevUsageCommands))
152156
},
153157

154-
findMntFunc: disk.FindMnt,
158+
// findMntFunc defaults to the legacy in-namespace findmnt when
159+
// FindmntCommands is empty (FindMntWithCommand(ctx, target, "") == FindMnt),
160+
// and runs the override (e.g. nsenter findmnt) when configured.
161+
findMntFunc: func(ctx context.Context, target string) (*disk.FindMntOutput, error) {
162+
return disk.FindMntWithCommand(ctx, target, gpudInstance.FindmntCommands)
163+
},
155164

156165
// Initialize file operation function field with real implementation
157166
statWithTimeoutFunc: pkgfile.StatWithTimeout,
@@ -167,11 +176,17 @@ func newComponent(
167176
c.getBlockDevicesFunc = func(ctx context.Context) (disk.BlockDevices, error) {
168177
timeoutCtx, cancel := context.WithTimeout(ctx, getBlockDevicesTimeout)
169178
defer cancel()
179+
// WithLsblkCommand/WithFindmntCommand are empty by default (legacy
180+
// in-namespace lsblk + findmnt back-fill); when configured they run the
181+
// overrides (e.g. nsenter lsblk/findmnt) so block devices and fstypes
182+
// are read from the host mount namespace.
170183
return disk.GetBlockDevicesWithLsblk(
171184
timeoutCtx,
172185
disk.WithFstype(disk.DefaultFsTypeFunc),
173186
disk.WithDeviceType(disk.DefaultDeviceTypeFunc),
174187
disk.WithMountPoint(disk.DefaultMountPointFunc),
188+
disk.WithLsblkCommand(gpudInstance.LsblkCommands),
189+
disk.WithFindmntCommand(gpudInstance.FindmntCommands),
175190
)
176191
}
177192
}
@@ -576,12 +591,17 @@ func (c *component) Check() components.CheckResult {
576591
// e.g.,
577592
// "unexpected end of JSON input"
578593
prevFailed := false
579-
for range 5 {
594+
findMntSucceeded := false
595+
for attempt := range 5 {
580596
cctx, ccancel := context.WithTimeout(c.ctx, time.Minute)
581597
mntOut, err := c.findMntFunc(cctx, target)
582598
ccancel()
583599
if err != nil {
584-
log.Logger.Errorw("failed to find mnt", "error", err)
600+
// findmnt is occasionally flaky and returns empty output, which
601+
// parses as "unexpected end of JSON input". This is transient, so
602+
// warn and retry; only a fully-exhausted retry budget (handled
603+
// after the loop) is a real error.
604+
log.Logger.Warnw("failed to find mnt, will retry", "target", target, "attempt", attempt+1, "error", err)
585605

586606
select {
587607
case <-c.ctx.Done():
@@ -600,10 +620,14 @@ func (c *component) Check() components.CheckResult {
600620
}
601621
cr.MountTargetUsages[target] = *mntOut
602622
if prevFailed {
603-
log.Logger.Infow("successfully ran findmnt after retries")
623+
log.Logger.Infow("successfully ran findmnt after retries", "target", target)
604624
}
625+
findMntSucceeded = true
605626
break
606627
}
628+
if !findMntSucceeded {
629+
log.Logger.Errorw("failed to find mnt after retries", "target", target)
630+
}
607631
}
608632

609633
if len(cr.BlockDevices) > 0 {

components/registry.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,33 @@ type GPUdInstance struct {
4343
MountPoints []string
4444
MountTargets []string
4545

46+
// FindmntCommands overrides how the disk component invokes "findmnt".
47+
// Empty (the default) preserves the legacy behavior of locating "findmnt" on
48+
// PATH and running it in the current namespace. When set (e.g.
49+
// "nsenter --target 1 --mount -- findmnt"), the command runs in the host
50+
// mount namespace so it reports the host's mounts instead of the container's.
51+
FindmntCommands string
52+
53+
// LsblkCommands overrides how the disk component invokes "lsblk".
54+
// Empty (the default) preserves the legacy behavior. When set (e.g.
55+
// "nsenter --target 1 --mount -- lsblk"), the command runs in the host mount
56+
// namespace.
57+
LsblkCommands string
58+
59+
// BlockdevUsageCommands overrides how the disk component collects partition
60+
// usage. Empty (the default) preserves the legacy behavior of enumerating
61+
// mounts via gopsutil and measuring usage via the statfs syscall. When set
62+
// (e.g. "nsenter --target 1 --mount -- df"), partitions and usage are read
63+
// from that command's output in the host mount namespace.
64+
BlockdevUsageCommands string
65+
66+
// ContainerdServiceActiveCommands overrides how the containerd component
67+
// checks whether the containerd service is active. Empty (the default)
68+
// preserves the legacy in-namespace systemd.IsActive behavior. When set (e.g.
69+
// "nsenter --target 1 --mount -- systemctl is-active containerd"), the command
70+
// runs against the host's service manager; exit code 0 means active.
71+
ContainerdServiceActiveCommands string
72+
4673
FailureInjector *FailureInjector
4774
}
4875

deployments/helm/gpud/.helmignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@
2121
.idea/
2222
*.tmproj
2323
.vscode/
24+
25+
# Private values overlay -- internal only
26+
values-nvcr.yaml

0 commit comments

Comments
 (0)