nvml-mock Helm Chart¶
Mock GPU infrastructure for Kubernetes testing. Turns any cluster into a multi-GPU environment using a CGo-based mock NVML library — no physical NVIDIA hardware required.
What It Does¶
Deploys a DaemonSet that creates on every node:
- Mock libnvidia-ml.so shared library at /var/lib/nvml-mock/driver/usr/lib64/
- Mock device nodes at /var/lib/nvml-mock/driver/dev/nvidia{N,ctl,-uvm,-uvm-tools} (consumers see them at /dev/nvidia* via CDI bind-mount)
- GPU configuration at /var/lib/nvml-mock/driver/config/config.yaml
- An NFD feature file at
/etc/kubernetes/node-feature-discovery/features.d/nvml-mock.features, which
NFD turns into the node label
feature.node.kubernetes.io/pci-10de.present=true (see Node Labels)
- A fake InfiniBand sysfs tree at /var/lib/nvml-mock/ib/sys/class/infiniband/...
paired with libibmocksys.so (LD_PRELOAD) so real ibstat, ibstatus,
iblinkinfo, ... read mock HCAs
- A fake PCI sysfs tree at /var/lib/nvml-mock/sys/bus/pci/devices/... (symlinks
into /var/lib/nvml-mock/sys/devices/pciDDDD:BB/...) so consumers resolve the
PCIe root complex via a standard readlink(). lspci and anything else
reaching it through libc read it via libmockfs.so; containers served the
nvidia.com/gpu CDI spec get it bind-mounted at the kernel paths, which is
what Go consumers need — see PCI sysfs in containers
- A fake kernel-module surface at /var/lib/nvml-mock/proc/modules and
/var/lib/nvml-mock/sys/module/..., so lsmod lists nvidia and nvidia_uvm
and /sys/module/nvidia/refcnt exists. The node's own modules are mirrored
beside them. libmockfs.so redirects both paths for libc consumers. Both CDI
specs bind-mount the tree for Go consumers. A state reconcile refreshes the
mirror, so it can lag a module load or unload
Consumers (DRA driver, device plugin) point at /var/lib/nvml-mock/driver
as the NVIDIA driver root and discover GPUs through standard NVML APIs.
When nri.enabled=true (opt-in; default false), the chart also deploys
nvml-mock-nri, a node-local containerd NRI plugin. It mounts the host overlay
into newly created containers at /opt/nvml-mock and injects the mock
environment at runtime, so plain pods can run nvidia-smi without GPU resource
requests or pod-spec mutation. The overlay and environment are injected ambiently into
containers in non-excluded namespaces, while host device nodes (/dev/nvidia*) remain opt-in
(via nvidia.com/gpu requests or the nvml-mock.nvidia.com/devices: "true" annotation).
Unannotated pods without GPU requests will still report GPUs if nvidia-smi is run inside them.
Test suites that rely on non-GPU pods seeing zero GPUs should either keep NRI disabled (nri.enabled=false)
or run within an excluded namespace (nri.excludedNamespaces). Because it injects cluster-wide, it is off by
default. Kind clusters must have containerd NRI enabled; see
docs/guides/node-wide-injection.
Install it into its own namespace, and pass -n:
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
-n mokka --create-namespace \
--set nri.enabled=true
The plugin always excludes its own release namespace, so that the main
nvml-mock DaemonSet is never self-injected. Install without -n and the
release namespace is default — the plugin then renders
--excluded-namespaces=default,kube-system and skips every pod a first-time
user runs. Nothing reports this: the DaemonSet is Ready, /readyz returns 200
because the plugin is registered, and skipped containers produce no log line
at any level. The pods simply start with no mock GPU.
Prerequisites¶
| Tool | Version | Required For |
|---|---|---|
| Docker | 20.10+ | Building the image |
| Kind | 0.20+ | Local cluster (or use your own) |
| kubectl | 1.31+ | Cluster access |
| Helm | 3.x | Chart installation |
| Go | 1.25+ | Building from source |
| jq | any | DRA verification only |
Published image: The nvml-mock container image is published at
ghcr.io/nvidia/nvml-mock:latest and is built automatically on pushes to
main. If the image is not yet available (e.g., before the first release),
use "Option B: Build from source" in the quick start sections below.
Cluster requirements:
- Privileged pods must be allowed (nvml-mock DaemonSet uses privileged: true for mknod)
- For DRA: Kubernetes 1.32+ with DynamicResourceAllocation feature gate enabled
Walkthroughs¶
Each of these installs the chart against a real consumer, end to end:
| Guide | What it stands up |
|---|---|
| NVIDIA Device Plugin | Mock GPUs advertised as nvidia.com/gpu, and a workload scheduled against them |
| NVIDIA DRA Driver | Mock GPUs published as ResourceSlices, and a pod scheduled through a ResourceClaim |
| NVIDIA GPU Operator | The full operand stack — device plugin, GFD, DCGM and the validator |
The rest of this page is the reference: what the chart installs, every value it takes, and the behaviours worth knowing before you change one.
Integration: fake-gpu-operator¶
fake-gpu-operator by Run:ai simulates GPUs at the Kubernetes API level for scale testing. nvml-mock can provide driver-level fidelity (real NVML API) on real nodes while fake-gpu-operator handles KWOK virtual nodes.
Enable Profile Discovery¶
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
--set integrations.fakeGpuOperator.enabled=true
This creates per-profile ConfigMaps in the shape fake-gpu-operator's loader reads:
NAME DATA AGE
gpu-profile-a100 1 10s
gpu-profile-h100 1 10s
gpu-profile-b200 1 10s
gpu-profile-gb200 1 10s
gpu-profile-gb300 1 10s
gpu-profile-l40s 1 10s
gpu-profile-t4 1 10s
FGO loads these by name from its own namespace, so set integrations.fakeGpuOperator.targetNamespace to FGO's release namespace for them to be found. That requires FGO's builtinProfiles.enabled=false, because their builtin set uses the same seven names. See the fake-gpu-operator guide.
Custom Labels¶
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
--set integrations.fakeGpuOperator.enabled=true \
--set 'integrations.fakeGpuOperator.profileLabels.my-org/gpu-profile=true'
InfiniBand mocking¶
Each profile carries an infiniband: block alongside the GPU config. When the
DaemonSet starts, mock-ib reads it and writes a fake sysfs tree at
/var/lib/nvml-mock/ib/sys/class/infiniband/.... Inside the container, three
LD_PRELOAD shims cooperate (preload order
libibmockumad.so:libibmockverbs.so:libibmocksys.so):
libibmocksys.sorewrites every access to/sys/class/infiniband*,/sys/class/infiniband_mad/,/sys/class/infiniband_verbs/and/dev/infinibandso sysfs-driven tools read from the rendered tree.libibmockumad.soproxieslibibumad'sumad_send/umad_recvto the in-podmock-ibdaemon (Unix socket) which handles SA path queries, ibping echoes, and SMP synthesis foriblinkinfo.libibmockverbs.soproxies open/read/write on/dev/infiniband/uverbsNsolibibverbsconsumers can enumerate HCAs.
POD=$(kubectl get pods -l app.kubernetes.io/name=nvml-mock -o jsonpath='{.items[0].metadata.name}')
# sysfs / libibumad (always works):
kubectl exec "$POD" -- ibstat
kubectl exec "$POD" -- ibstatus
# libibverbs enumeration (modalias matches libmlx5's match table):
kubectl exec "$POD" -- ibv_devinfo -l
kubectl exec "$POD" -- ibv_devices
# Subnet management direct-route walk (cross-node fabric scan):
kubectl exec "$POD" -- iblinkinfo
Full per-device ibv_devinfo (without -l) intentionally is not supported:
after libibverbs claims the device, libmlx5's verbs_open_device issues
real uverbs ioctl()s that a userspace LD_PRELOAD shim cannot fake. The
same port-level information (state, phys state, GID, LID, rate, link layer)
is available through ibstatus, which reads it from the rendered sysfs
tree.
In NRI-injected pods¶
With nri.enabled=true the same tools are staged into the node overlay and
reachable from any injected workload at
/opt/nvml-mock/driver/usr/bin/<tool>. They carry their shared libraries
(libibmad, libibumad, libibverbs, libnl) alongside them in
driver/usr/lib64 and an RPATH of $ORIGIN/../lib64, so they run from an
image that ships no InfiniBand stack of its own — a distroless or scratch
workload, not just a full distro image.
Two limits apply there, both independent of the staging:
ibstatusis a/bin/shscript rather than an ELF binary, so it needs an image with a shell.ibv_devinfo -lreports0 HCAs foundin an injected pod. Enumeration needs libibverbs to match the device to a provider driver (libmlx5), and the provider ships in the nvml-mock image rather than in the workload. Useibstat -l, which reads the rendered sysfs throughlibibmocksys.soand lists every mock HCA.
The tools are glibc binaries. On a musl image (Alpine) they fail to exec at
all, because PT_INTERP names /lib/ld-linux-*.so.* by absolute path and no
RPATH can redirect that.
Defaults per profile¶
| Profile | Enabled | HCA | Speed | HCAs per GPU |
|---|---|---|---|---|
a100 |
yes | ConnectX-6 (MT4123) |
HDR 200 Gb/s | 1 |
h100 |
yes | ConnectX-7 (MT4129) |
NDR 400 Gb/s | 1 |
b200 |
yes | ConnectX-7 (MT4129) |
NDR 400 Gb/s | 1 |
gb200 |
yes | ConnectX-7 (MT4129) |
NDR 400 Gb/s | 1 |
gb300 |
yes | ConnectX-7 (MT4129) |
NDR 400 Gb/s | 1 |
l40s |
no | — | — | — |
t4 |
no | — | — | — |
infiniband: block schema¶
| Field | Default | Notes |
|---|---|---|
enabled |
false |
Must be true to render any tree |
hca_type |
MT4129 |
Shows up as CA type in ibstat output |
fw_version |
28.39.2048 |
Firmware version |
hw_rev |
0x0 |
Hardware revision |
board_id |
MT_0000000838 |
Mellanox board ID |
link_layer |
InfiniBand |
InfiniBand or Ethernet |
rate_gbps |
400 |
One of 100 (EDR), 200 (HDR), 400 (NDR), 800 (XDR) |
port_state |
ACTIVE |
DOWN, INIT, ARMED, ACTIVE, ACTIVE_DEFER |
phys_state |
LinkUp |
Disabled, Polling, Training, LinkUp, ... |
hcas_per_gpu |
1 |
Total HCAs = gpu.count * hcas_per_gpu |
hca_count |
0 |
If non-zero, used instead of gpu.count * hcas_per_gpu |
guid_prefix |
a088c20300ab |
Hex prefix for node/port GUIDs. The renderer keeps the first 8 hex digits fixed and uses the lower 32 bits for node/HCA identity |
node_desc_template |
{node_name} mlx5_{idx} |
{node_name} and {idx} are interpolated |
Disable IB on a profile¶
Two options, depending on intent:
- Turn the in-pod mock IB off at runtime (no sysfs render, no
mock-ibdaemon, shims become no-ops) without editing the profile:
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
--set gpu.profile=h100 \
--set infiniband.mockTier=off
This disables ibstat / ibping / iblinkinfo mocking in the pod. The
chart still renders the IB Service and NetworkPolicy because those track
the profile's infiniband.enabled, not the tier — use the next option to
drop them too.
- Set
infiniband.enabled: falsein the profile via a custom profile file (preferred for full control). Note thatgpu.customConfigreplaces the entire profile config rather than merging, so an inline--set-string 'gpu.customConfig=infiniband: { enabled: false }'would throw away all the GPU settings — pass a complete config file instead:
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
--set-file gpu.customConfig=my-h100-no-ib.yaml
PCIe topology mocking¶
Each profile carries a pcie_topology: block describing the host's PCI
root-complex layout. The node daemon's pcibus simulator reads it and writes a
fake sysfs tree at /var/lib/nvml-mock/sys/... matching what real Linux kernels
expose. Topology-aware consumers (NVIDIA DRA driver, device plugins computing
NUMA hints) resolve "which PCIe root complex a GPU lives on" via a standard
readlink() + path parse against the rendered tree:
$ readlink /var/lib/nvml-mock/sys/bus/pci/devices/0000:07:00.0
../../../devices/pci0000:00/0000:07:00.0
$ cat /var/lib/nvml-mock/sys/devices/pci0000:00/0000:07:00.0/numa_node
0
Defaults per profile¶
| Profile | Root complexes | NUMA nodes | Devices per root |
|---|---|---|---|
a100 |
2 (pci0000:00, pci0000:80) |
2 (dual EPYC) | 4 |
h100 |
2 (pci0000:00, pci0000:80) |
2 (dual socket) | 4 |
b200 |
2 (pci0000:00, pci0000:80) |
2 (dual socket) | 4 |
gb200 |
2 (pci0000:00, pci0000:40) |
2 (one per Grace CPU) | 2 |
gb300 |
2 (pci0000:00, pci0000:40) |
2 (one per Grace CPU) | 2 |
l40s |
2 (pci0000:00, pci0000:80) |
2 (dual socket) | 4 |
t4 |
1 (pci0000:00) |
1 | 4 |
pcie_topology: block schema¶
pcie_topology:
root_complexes:
- id: "pci0000:00" # sysfs root-complex dir, format "pciDDDD:BB"
numa_node: 0 # numa_node value for every child device
devices:
- "0000:07:00.0" # canonical 4-digit-domain BDF
- "0000:0F:00.0"
- id: "pci0000:80"
numa_node: 1
devices:
- "0000:87:00.0"
- "0000:90:00.0"
Nothing validates the block, so a typo is not reported anywhere — not through
the daemon's /healthz. What the daemon renders is the block reconciled against
the devices NVML reports, which silently absorbs most mistakes:
- A
bus_idthat is not an address in the kernel'sDDDD:BB:DD.Fform is dropped, and the device is left out of the tree. This includes the 8-digit domain NVML reports throughnvmlPciInfo_t.busId;bus_idcarries the 4-digit form, asbusIdLegacydoes. A value that is not an address is one no consumer can look up, and it would otherwise become a directory name. - A BDF listed under a root complex that no entry in
devices[]claims is dropped, along with any root complex it leaves empty. This is also howgpu.countworks: capping the device list leaves the layout untouched, and the uncapped BDFs disappear from the tree rather than rendering as GPUs NVML denies exist. - A BDF listed under two root complexes stays under the first and is dropped from the second.
- A device in
devices[]whose BDF no root complex lists is still rendered, under the root its own address implies (pciDDDD:BB) and reportingnuma_node-1. A GPU missing from the tree is one no consumer can resolve.
If a profile omits pcie_topology: entirely the renderer falls back to
a flat single-root layout (every device under pci0000:00, NUMA 0). A profile
whose devices declare no bus_id at all renders nothing, and the simulator
empties any tree a previous profile left behind.
PCI sysfs in containers¶
Reaching the tree through MOCK_PCI_ROOT requires the libmockfs.so
LD_PRELOAD shim, which only works for libc consumers: Go's os package issues
openat directly, so the shim never sees the open and the process reads the
node's real /sys, where the mock GPUs do not exist. GPU Feature Discovery and
the NVIDIA DRA driver are both Go.
So the nvidia.com/gpu CDI spec the node daemon writes bind-mounts the tree
read-only at the kernel paths:
| Host | Container |
|---|---|
/var/lib/nvml-mock/sys/devices |
/sys/devices |
/var/lib/nvml-mock/sys/bus/pci/devices |
/sys/bus/pci/devices |
Both, always together. The entries under sys/bus/pci/devices are relative
symlinks into ../../../devices/pciDDDD:BB, so mounting that directory alone
yields entries that list but whose every attribute read fails with ENOENT.
Trade-off: /sys/devices is mounted whole, which hides the host's other
device classes — CPU topology among them — from served containers. Narrowing it
to the profile's root complexes is not possible: the runtime would have to create
the mountpoint, and sysfs is read-only in a container, so container creation
fails outright rather than degrading. Profiles routinely declare root complexes
the node does not have. #689
tracks removing the trade-off.
A workload that needs the node's real device tree must not request
nvidia.com/gpu, since the mount rides the CDI spec the container toolkit
resolves for that resource. nri.excludedNamespaces is not an escape: it only
reaches the NRI plugin, whose own nvml-mock.nvidia.com/gpu spec carries the
module tree but no PCI sysfs mounts.
Machine type (nvidia.com/gpu.machine)¶
GFD derives the label from --machine-type-file, which defaults to
/sys/class/dmi/id/product_name — a path no mock can own under kind: the node
image writes kind there and re-binds it into every container after the
container's own mounts are set up, and on hosts without DMI (Docker Desktop) it
does not exist at all.
The daemon therefore writes the machine type to driver/config/machine-type. The
NRI plugin points GFD_MACHINE_TYPE_FILE at it, so with nri.enabled the label
needs nothing from the operator's own configuration. A value authored on the
container wins, for a cluster pinning a file of its own.
Without NRI the file is still served, at /etc/nvml-mock/machine-type by the
CDI mount that carries config.yaml, but the value has to be set by hand — the
toolkit resolving nvidia.com/gpu applies the spec's mounts and drops its env
(#747), so the plugin's
channel is the only automatic one:
The value is the profile's GPU product name, so gpu.machine matches
gpu.product (NVIDIA-GB300-NVL) rather than the NVIDIA-GB300-NVL72 a real
tray reports; the profiles carry no platform name to use instead.
Cross-node ibping¶
Sysfs mocking alone lets ibstat / iblinkinfo work, but real ibping
needs UMAD I/O. For IB-enabled profiles, the chart preloads
libibmockumad.so alongside libibmocksys.so, starts mock-ib in each pod,
and exposes a headless Service on port 18515 for TCP fabric relay between
nvml-mock pods.
The fabric listener binds 0.0.0.0 with no authentication, so the chart
also ships a NetworkPolicy (infiniband.ping.networkPolicy.enabled,
default true) that allows inbound fabric traffic only from peer
nvml-mock pods. NetworkPolicy is enforced only by CNIs that implement it;
Kind's default kindnet ignores it, so it is a no-op in the typical Kind
fixture but limits exposure on Calico/Cilium-backed clusters. mock-ib is a
test fixture — don't deploy it to a shared or production cluster.
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
--set gpu.profile=a100 \
--set gpu.count=2 \
--wait --timeout 120s
On a multi-node cluster, pick two nvml-mock pods on different nodes. Read the server LID from sysfs and ping that LID from the client:
SERVER_POD=$(kubectl get pods -l app.kubernetes.io/name=nvml-mock \
-o jsonpath='{.items[0].metadata.name}')
CLIENT_POD=$(kubectl get pods -l app.kubernetes.io/name=nvml-mock \
-o jsonpath='{.items[1].metadata.name}')
LID=$(kubectl exec "$SERVER_POD" -- sh -c \
"tr -d '[:space:]' < /var/lib/nvml-mock/ib/sys/class/infiniband/mlx5_0/ports/1/lid")
kubectl exec "$CLIENT_POD" -- ibping -c 3 "$LID"
For automated cross-node validation (including peer restart and retries), run
the ibping and ibfabric specs of the
Go e2e suite.
LID-based ping is the supported path; cross-node ibping -G <port_guid> is
supported (use 0x hex without colons). The same specs cover the companion
fabric tools:
iblinkinfoandibnetdiscover— the directed-route walk reaches peer GUIDs without duplicate-port errors.ibv_devinfo -lclaims every rendered HCA via libmlx5, andibstatusconfirms ACTIVE / LinkUp port state.sminforeports a master subnet manager, and every pod names the same one.
The mock reads MOCK_IB, MOCK_IB_PING_FABRIC, MOCK_IB_ROOT and
MOCK_IB_PEERS. The chart derives the first three from the
infiniband values.
Device injection mode¶
Applies only when nri.enabled=true, and only to the nri.deviceAnnotation
opt-in path.
nri.deviceInjectionMode selects how the plugin delivers mock GPU device nodes
to a container that carries nvml-mock.nvidia.com/devices: "true":
| Mode | Mechanism | Needs |
|---|---|---|
raw (default) |
The plugin stages the /dev/nvidia* nodes itself, in the NRI adjustment. |
Nothing. |
cdi |
The plugin emits the CDI device nvml-mock.nvidia.com/gpu=all and the runtime resolves it from the spec the cdi simulator stages at <cdiSpecDir>/nvml-mock-nri.yaml. |
A runtime with CDI on. |
Both modes deliver the same GPUs, and both inject libmockfs.so, so a libc
reader finds the simulated modules either way. Only cdi bind-mounts
/sys/module, so a Go reader finds them only there. cdi also sets
NVML_MOCK_DEVICE_SOURCE=cdi, the only way to tell which mechanism ran.
CDI needs no container toolkit on the node. containerd 2.x enables CDI by
default (enable_cdi = true, spec dirs /etc/cdi and /var/run/cdi), which
includes the stock kindest/node image. containerd 1.x gates it behind
enable_cdi, so raw stays the default.
If cdi is selected and no spec is staged, the plugin logs a warning and falls
back to raw. It does not fail the pod: an unresolvable CDI device makes
containerd reject container creation outright.
Neither mode changes whether a container is served. A container the NVIDIA device plugin already served keeps exactly its allocation in both modes, per MEP-0002.
NRI plugin failure modes¶
Applies only when nri.enabled=true.
The NRI plugin injects the mock GPU stack at container-creation time, which is what lets ordinary pods see mock GPUs without a pod-spec change. It also means the injection is written into the container's OCI spec once, at creation. A pod that is already running keeps everything it was given, whatever happens to the plugin afterwards. Only pods created after a failure are affected, and they are affected silently.
That is the property that makes this worth hardening: a test suite that creates its pods early and asserts against them later keeps passing on a node where injection stopped hours ago.
The two modes¶
| Fail-closed | Fail-open | |
|---|---|---|
| What the runtime does | Refuses to create the container | Creates the container without the plugin's adjustment |
| What you see | Pods stuck in ContainerCreating / CreateContainerError |
Pods start normally |
| What the workload gets | Nothing — it never runs | A container with no mock GPU stack |
| Risk | Test runs stop | Test runs continue and report results that no longer mean what they claim |
Fail-closed is loud and self-announcing. On a dedicated test cluster it is arguably the preferable posture: a broken mock stops the run instead of corrupting it.
Fail-open is the dangerous one, and it is the mode this chart is built to survive. It cannot be prevented from the plugin side — the decision belongs to containerd, not to the plugin — so the chart's posture is: assume fail-open can happen, and make it impossible for it to happen quietly.
Posture this chart targets¶
Detectable fail-open. Both probes exist to convert a silent window into a visible one, not to prevent it:
- Readiness (
/readyz) reports serving only while the plugin is registered with the runtime and its handler is answering. Any window in which the node is not injecting shows up as a NotReady pod and a short DaemonSet count. Readiness restarts nothing; it is purely the detection surface. - Liveness (
/healthz) fails only when a container-creation request has been in flight past the wedge threshold, and restarts the container into a fresh registration.
Neither probe reduces to "is the process alive", because the process stays alive in every mode that matters:
| State | Process | Connection | /readyz |
/healthz |
|---|---|---|---|---|
| Registered and serving | up | up | 200 | 200 |
| Started, not yet registered | up | — | 503 | 200 |
| Unregistered by the runtime | up | dropped | 503 | 200 |
| Handler wedged | up | up | 503 | 503 → restart |
A wedged handler is the case that defeats every simpler check: pgrep
nri-plugin finds the process, and a plain TCP check finds the socket bound,
in exactly the state where nothing is being injected.
Losing the connection is deliberately not a liveness failure. The NRI stub's
Run returns when the connection drops and the plugin exits on its own, so the
kubelet already restarts it; failing liveness on "not registered" as well would
only add restart loops whenever containerd is slow to come up.
containerd plugin_request_timeout¶
The wedge threshold is not a constant in the chart. The plugin derives it from the request timeout containerd itself reports at registration, and trips at twice that value. Past one whole timeout the runtime has already abandoned the request, so the container it belonged to was created without injection whatever happens next; the second is tolerance, so a single slow-but-completing request cannot restart the plugin.
NRI's defaults, from containerd/nri/pkg/api:
| Setting | Default |
|---|---|
plugin_request_timeout |
2s (wedge threshold 4s) |
plugin_registration_timeout |
5s |
Both are set on the runtime, not in this chart:
[plugins."io.containerd.nri.v1.nri"]
disable = false
socket_path = "/var/run/nri/nri.sock"
# Raise only if the plugin legitimately needs longer than 2s to answer.
plugin_request_timeout = "2s"
Guidance:
- Leave it at the default unless you have evidence. The plugin's
CreateContainerpath does astatof the topology document, a directory read of the device directory, and onestatper device node — all against the hostPath-mounted overlay, and nothing else. On a healthy node that is well under 2s. A raised timeout does not make injection more reliable; it widens the window in which each container creation blocks on a plugin that may already be wedged. - Those filesystem calls are the realistic wedge. They are the only blocking operations in the handler, so an overlay backed by a hung mount is how this plugin stops answering while staying alive and connected.
- Raising it widens the wedge threshold automatically. No chart change is
needed, and none should be made —
nri.livenessProbetuning andplugin_request_timeoutare not independent knobs. - Do not raise it to paper over a wedge. A plugin that needs more than 2s is the failure this hardening detects, not a tuning problem.
Checking a node by hand¶
# Which nodes are actually injecting right now
kubectl get pods -n mokka -l app.kubernetes.io/name=nvml-mock-nri -o wide
# Why a given node is not
kubectl describe pod -n mokka <nvml-mock-nri-pod>
Both probe endpoints answer with the reason in the body, so a readiness failure
in kubectl describe reads as not registered with the container runtime; new
containers are not being injected rather than a bare status code.
The port is not reachable from the node: this DaemonSet does not set
hostNetwork, so nri.healthPort is bound only inside the pod's own network
namespace, on the pod IP where the kubelet reaches it.
Configuration¶
Values¶
| Parameter | Default | Description |
|---|---|---|
gpu.profile |
gb300 |
GPU profile: a100, h100, b200, gb200, gb300, l40s, or t4 |
gpu.count |
"" |
Number of mock GPUs per node. Empty derives it from the profile's devices: list (8 for the baseboard profiles, 4 for t4, gb200 and gb300); a larger value is capped to that list at runtime |
gpu.customConfig |
"" |
Inline YAML to override profile config entirely |
gpu.dynamicMetrics.enabled |
false |
Make the mock return time-varying temperature / power / utilization readings instead of the static profile values. See Dynamic Metrics below. |
gpu.dynamicMetrics.seed |
0 (baseline) |
RNG seed; 0 uses a time-based seed, non-zero produces reproducible sequences. |
gpu.dynamicMetrics.temperature.* |
baseline (base_c: 55, …) |
base_c, variance_c, ramp_c, ramp_period_sec for the GPU temperature generator. |
gpu.dynamicMetrics.power.* |
profile default, else baseline 250000/25000 |
base_mw, variance_mw for the power generator (clamped to the profile's min/max_limit_mw). Resolved baseline < profile default < user override; profiles outside the 250W baseline set their own (t4 ~65W, b200/gb200 ~600W, gb300 ~800W). See Dynamic Metrics. |
gpu.dynamicMetrics.utilization.* |
baseline (pattern: burst, …) |
pattern (idle | busy | burst | steady), gpu_min/max, memory_min/max, burst_period_sec. |
gpu.failureInjection.enabled |
false |
Enable simulated GPU failures (lost / fallen off bus / uncorrectable ECC). See Failure Injection below. |
gpu.failureInjection.mode |
healthy |
Failure mode: healthy (default, no-op), lost, fallen_off_bus, or ecc_uncorrectable. With the inert default, enabled: true alone produces a healthy device — you must set mode explicitly to engage failures. |
gpu.failureInjection.probability |
0.0 |
Per-call probability [0, 1] for stochastic failure activation. |
gpu.failureInjection.after_calls |
0 |
Activate failure deterministically after N guarded NVML calls (0 = disabled). |
gpu.failureInjection.seed |
0 |
RNG seed for probability rolls; 0 uses a time-based seed. |
gpu.failureInjection.xid.code |
0 |
Xid error code delivered via the NVML event set (NVML_EVENT_TYPE_XID_CRITICAL_ERROR) once tripped. 0 = no Xid. |
image.repository |
ghcr.io/nvidia/nvml-mock |
Container image repository |
image.tag |
latest |
Container image tag |
image.pullPolicy |
IfNotPresent |
Image pull policy |
driverVersion |
"" (auto) |
NVIDIA driver version to mock. When empty, read from system.driver_version of the resolved GPU config (the selected gpu.profile file, or gpu.customConfig if set), so the profile is the single source of truth (e.g. GB200 → 580.65.06, B200 → 560.35.03, GB300 → 570.124.06, others → 550.163.01). Set explicitly only to override the profile. |
nodeSelector |
{} |
Node selector for DaemonSet |
tolerations |
[{operator: Exists}] |
Pod tolerations (default: tolerate all) |
nodeLabels.featuresDir |
/etc/kubernetes/node-feature-discovery/features.d |
Host directory NFD's local source reads feature files from. Override only if NFD runs with a non-default featureFilesDir |
integrations.fakeGpuOperator.enabled |
false |
Create per-profile ConfigMaps named gpu-profile-<profile>, keyed profile.yaml, in the shape fake-gpu-operator's loader reads |
integrations.fakeGpuOperator.targetNamespace |
"" (release namespace) |
Namespace for the profile ConfigMaps. Set to FGO's release namespace for FGO to find them; requires FGO's builtinProfiles.enabled=false to avoid a Helm ownership collision on the same seven names |
integrations.fakeGpuOperator.profileLabels |
{"run.ai/gpu-profile": "true"} |
Extra labels on profile ConfigMaps. The contract labels fake-gpu-operator/gpu-profile and nvml-mock/profile-name are always emitted and cannot be removed here |
infiniband.mockTier |
"" (auto) |
MOCK_IB tier: off, sysfs, or full. Empty auto-derives full for IB-enabled profiles and sysfs otherwise (keeps the libibmocksys redirect active so any real host IB is masked). off makes every shim a no-op and skips the daemon. An invalid value fails helm template |
infiniband.ping.port |
18515 |
TCP port for fabric relay between nvml-mock pods (mock-ib / ibping always enabled) |
infiniband.ping.networkPolicy.enabled |
true |
Restrict inbound access to the fabric port to peer nvml-mock pods. No-op on CNIs that don't enforce NetworkPolicy (e.g. Kind's kindnet) |
nri.enabled |
false |
Deploy the nvml-mock-nri containerd NRI plugin DaemonSet. Injects mock overlay and environment cluster-wide into non-excluded namespaces. Always install into a dedicated namespace (-n mokka) to avoid excluding default. Device node injection remains opt-in (nvidia.com/gpu request or nvml-mock.nvidia.com/devices: "true" annotation). |
nri.socketPath |
/var/run/nri/nri.sock |
NRI socket on the host. Its directory is hostPath-mounted into the plugin |
nri.pluginName / nri.pluginIndex |
nvml-mock / "10" |
NRI registration identity. The index orders this plugin against others |
nri.overlay.hostPath / nri.overlay.mountPath |
/var/lib/nvml-mock / /opt/nvml-mock |
Host overlay staged by the main DaemonSet, and the path it is injected at inside workloads |
nri.optOutAnnotation |
nvml-mock.nvidia.com/inject |
Pod annotation; value false disables injection for that pod |
nri.deviceAnnotation |
nvml-mock.nvidia.com/devices |
Pod annotation; value true adds mock /dev/nvidia* device nodes. Pod-authored, so treat it as part of the demo trust boundary |
nri.deviceInjectionMode |
raw |
How nri.deviceAnnotation delivers GPUs: raw stages the device nodes directly, cdi emits a CDI device reference the runtime resolves. See Device injection mode |
nri.cdiSpecDir |
/var/run/cdi |
Host directory holding CDI specs, mounted read-only into the plugin. Must be one of the runtime's configured cdi_spec_dirs |
nri.imexChannelAnnotation |
nvml-mock.nvidia.com/imex-channels |
Pod annotation; value true adds the mock /dev/nvidia-caps-imex-channels/channelN nodes staged by imex.mockChannels. A no-op when that is disabled. Same trust boundary as nri.deviceAnnotation |
nri.excludedNamespaces |
[] |
Extra namespaces to skip. The release namespace and kube-system are always excluded |
nri.healthPort |
8080 |
Port serving /healthz and /readyz. Bound only in the pod's network namespace — this DaemonSet does not use hostNetwork, so nothing is exposed on the node |
nri.readinessProbe |
/readyz, periodSeconds: 10, failureThreshold: 2 |
Detects that the node has stopped injecting. Set to null to drop. See NRI plugin failure modes |
nri.livenessProbe |
/healthz, periodSeconds: 10, failureThreshold: 3 |
Restarts a wedged plugin. Threshold follows containerd's plugin_request_timeout; do not tune the two independently. Set to null to drop |
nri.resources |
{} |
Resource requests/limits for the plugin container |
Node Labels¶
nvml-mock writes no node labels itself. It writes a feature file NFD turns into one label, and NFD retires that label on its next cycle once the daemon deletes the file at shutdown:
| Label | Written by | Removed by |
|---|---|---|
feature.node.kubernetes.io/pci-10de.present=true |
NFD, from a feature file the node daemon writes | NFD, once the node daemon deletes the file |
Labels under nvidia.com/ — gpu.present, gpu.count, gpu.product — belong
to NFD and GFD exactly as on real hardware, and are absent unless those are
deployed. Workloads that need to land on a mock node select the GPU node-pool
label their cluster gives those nodes, as they would on real hardware.
The label above is produced by Node Feature Discovery. nvml-mock only supplies
the input: internal/agent/pcibus writes pci-10de.present=true into
nodeLabels.featuresDir, which NFD's local source reads and turns into the
namespaced label. With no NFD on the cluster the file is inert and the label
does not exist — which is the honest state, and is what the e2e in
tests/e2e/go/scenario_nfd_test.go asserts.
NFD's PCI source still cannot see mock GPUs as deployed: it reads a
host-prefixed sysfs path fixed at link time (/host-sys/bus/pci/devices, from
HOSTMOUNT_PREFIX), while nvml-mock's rendered PCI tree lives under
/var/lib/nvml-mock/sys and is reachable at the canonical /sys path only
through an LD_PRELOAD sysfs shim — and nfd-worker ships statically linked,
so LD_PRELOAD is inert in it. Either fact alone is enough; both hold (verified
against NFD v0.19.0, the version pinned in go.mod).
That is a limit of how NFD is deployed, not of the rendered tree. Pointed at
/var/lib/nvml-mock/sys, NFD v0.19.0 enumerates every mock GPU and — with the
sources.pci.deviceLabelFields: [vendor] that GPU Operator configures — derives
exactly pci-10de.present on its own. The rendered devices already carry all
five attributes its PCI source treats as mandatory. Only visibility is missing,
and nothing nvml-mock can do supplies it without editing a third party's
DaemonSet, which is why the local source is the route the chart uses.
Writing a feature file rather than the label is why the DaemonSet needs no cluster RBAC at all: nothing in the pod calls the Kubernetes API.
GPU Profiles¶
Built-in profiles provide realistic hardware specs for common data center GPUs.
Select a profile with --set gpu.profile=<name>:
# Deploy as an 8-GPU H100 node
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
--set image.repository=nvml-mock \
--set image.tag=local \
--set gpu.profile=h100
# Deploy as a 4-GPU B200 node
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
--set image.repository=nvml-mock \
--set image.tag=local \
--set gpu.profile=b200 \
--set gpu.count=4
Profile Comparison¶
| A100 | H100 | B200 | GB200 | GB300 | L40S | T4 | |
|---|---|---|---|---|---|---|---|
| Profile name | a100 |
h100 |
b200 |
gb200 |
gb300 |
l40s |
t4 |
| Full name | A100-SXM4-40GB | H100 80GB HBM3 | B200 | GB200 NVL | GB300 NVL | L40S | Tesla T4 |
| Architecture | Ampere | Hopper | Blackwell | Blackwell | Blackwell Ultra | Ada Lovelace | Turing |
| Compute capability | 8.0 | 9.0 | 10.0 | 10.0 | 10.0 | 8.9 | 7.5 |
| CUDA cores | 6,912 | 16,896 | 18,432 | 18,432 | 21,632 | 18,176 | 2,560 |
| Memory | 40 GiB HBM2e | 80 GiB HBM3 | 192 GiB HBM3e | 192 GiB HBM3e | 288 GiB HBM3e | 48 GiB GDDR6 | 16 GiB GDDR6 |
| NVLink | v3, 12 links | v4, 18 links | v5, 18 links | v5, 18 links | v5, 18 links | — | — |
| NVLink BW | 600 GB/s | 900 GB/s | 1.8 TB/s | 1.8 TB/s | 1.8 TB/s | — | — |
| TDP | 400W | 700W | 1,000W | 1,000W | 1,400W | 350W | 70W |
| PCIe | Gen4 | Gen5 | Gen6 | Gen6 | Gen6 | Gen4 | Gen3 |
| MIG instances | 7 | 7 | 7 | 7 | 7 | 0 | 0 |
| Grace CPU | — | — | — | Yes (NVLink-C2C) | Yes (NVLink-C2C) | — | — |
| FP8 | — | Yes | Yes | Yes | Yes | Yes | — |
| FP4 | — | — | Yes | Yes | Yes | — | — |
| FP6 | — | — | — | — | Yes | — | — |
| Driver version | 550.163.01 | 550.163.01 | 560.35.03 | 560.35.03 | 570.124.06 | 550.163.01 | 550.163.01 |
When to Use Each Profile¶
a100— broadest compatibility. Most NVIDIA software assumes A100 in docs and examples. Use it when a test targets Ampere or trips over newer architectures.h100— testing Hopper-specific features: FP8, Transformer Engine, PCIe Gen5, or NVLink v4 topology.b200— testing next-gen Blackwell features: FP4, NVLink v5, PCIe Gen6. Standalone GPU (no Grace CPU).gb200— testing Grace-Blackwell Superchip: NVLink-C2C to Grace CPU, unified memory, and Blackwell features.gb300(default) — testing Grace-Blackwell Ultra Superchip: 288 GiB HBM3e per GPU, 1.4 kW TDP, FP6 in addition to FP4/FP8, and Blackwell Ultra driver line (570.124.06).l40s— testing Ada Lovelace inference workloads: FP8, PCIe Gen4, no NVLink (PCIe-only topology).t4— testing Turing inference GPUs: low power (70W), small memory (16 GiB), 4 GPUs per node.
Custom Configuration¶
For GPU types not covered by built-in profiles, provide your own config YAML.
Option A: File-based (recommended)¶
Create a YAML file following the profile format, then pass it at install time:
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
--set image.repository=nvml-mock \
--set image.tag=local \
--set-file gpu.customConfig=my-custom-gpus.yaml
Option B: Inline values¶
For small overrides, embed the config directly in a values file:
# custom-values.yaml
gpu:
count: 4
customConfig: |
version: "1.0"
system:
driver_version: "550.163.01"
nvml_version: "12.550.163.01"
cuda_version: "12.4"
cuda_version_major: 12
cuda_version_minor: 4
device_defaults:
name: "NVIDIA L40S"
architecture: "ada_lovelace"
compute_capability:
major: 8
minor: 9
num_gpu_cores: 18176
memory:
total_bytes: 48318382080
reserved_bytes: 536870912
free_bytes: 47781511168
used_bytes: 0
devices:
- index: 0
uuid: "GPU-14050000-0000-0000-0000-000000000000"
minor_number: 0
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
--set image.repository=nvml-mock \
--set image.tag=local \
-f custom-values.yaml
Writing a Custom Profile¶
Use an existing profile as your starting point:
Key fields to change:
| Field | What to set |
|---|---|
device_defaults.name |
GPU name shown in nvidia-smi |
device_defaults.architecture |
Architecture string (ampere, hopper, blackwell, etc.) |
device_defaults.compute_capability |
major / minor version |
device_defaults.num_gpu_cores |
CUDA core count |
device_defaults.memory.total_bytes |
Total GPU memory in bytes |
devices |
One entry per GPU (match gpu.count) with unique UUIDs |
nvlink |
NVLink version and links (or omit for PCIe-only GPUs) |
The full YAML schema matches the fields exposed by nvidia-smi -x -q. See the built-in
profiles in deployments/nvml-mock/helm/nvml-mock/profiles/ for complete examples.
Dynamic Metrics¶
Real GPUs report metrics that change over time — temperature rises under
load, utilization fluctuates, power draw ramps. By default the mock is
fully static: whatever values are set in a profile's thermal, power,
and utilization sections are returned unchanged on every call.
Set gpu.dynamicMetrics.enabled=true to have the rendered ConfigMap
inject a device_defaults.dynamic_metrics block. The mock then returns
fluctuating values from GetTemperature, GetPowerUsage, and
GetUtilizationRates. Each sub-section (temperature, power,
utilization) can be tuned independently; the overlay works with any
built-in profile and with gpu.customConfig.
Each field resolves in three layers, highest priority last:
The profile layer matters for power: one global base_mw can't fit every
profile's [min_limit_mw, max_limit_mw] envelope, so profiles outside the 250W
baseline declare their own base via a Helm-only dynamic_metrics_defaults key
(t4 ~65W, b200/gb200 ~600W, gb300 ~800W). The engine ignores that key;
it only takes effect once enabled: true folds it into dynamic_metrics.
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
--set image.repository=nvml-mock \
--set image.tag=local \
--set gpu.profile=h100 \
--set gpu.dynamicMetrics.enabled=true \
--set gpu.dynamicMetrics.utilization.pattern=burst
Or via a values file:
gpu:
profile: h100
dynamicMetrics:
enabled: true
seed: 0 # set non-zero for reproducibility
temperature:
base_c: 60
variance_c: 3
ramp_c: 15
ramp_period_sec: 120
power:
base_mw: 500000
variance_mw: 50000
utilization:
pattern: burst # idle | busy | burst | steady
gpu_min: 0
gpu_max: 100
memory_min: 0
memory_max: 100
burst_period_sec: 30
Utilization pattern semantics (values are always clamped to 0..100):
| pattern | sampled from |
|---|---|
idle |
bottom quarter of [gpu_min, gpu_max] |
busy |
top quarter of [gpu_min, gpu_max] |
burst |
alternates idle / busy every burst_period_sec |
steady |
full [gpu_min, gpu_max] range (default if omitted) |
See pkg/gpu/mocknvml/README.md
for the full engine-side reference.
Failure Injection¶
Real GPUs occasionally fall off the bus, accumulate uncorrectable ECC errors,
or surface Xid events. By default the mock reports healthy hardware. Set
gpu.failureInjection.enabled=true to have the rendered ConfigMap inject a
device_defaults.failure block; the mock will then trip the device into the
configured failure mode based on the trigger you choose:
# Deterministic: device goes "lost" after the 200th NVML call
helm install nvml-mock oci://ghcr.io/nvidia/k8s-test-infra/chart/nvml-mock \
--set gpu.profile=h100 \
--set gpu.failureInjection.enabled=true \
--set gpu.failureInjection.mode=lost \
--set gpu.failureInjection.after_calls=200
# Stochastic + Xid: 1% chance per call to surface ECC double-bit (Xid 64),
# bounded to trip within 10k calls so CI does not hang.
gpu:
profile: h100
failureInjection:
enabled: true
mode: ecc_uncorrectable
probability: 0.01
after_calls: 10000
seed: 12345
xid:
code: 64
Per-mode behaviour:
| mode | guarded API calls return | handle lookup returns | identity getters | ECC counters | event set |
|---|---|---|---|---|---|
healthy (default) |
normal values | normal handle | normal values | zero | empty |
lost |
ERROR_GPU_IS_LOST |
ERROR_GPU_IS_LOST |
ERROR_GPU_IS_LOST |
error | ERROR_GPU_IS_LOST (Xid once first if xid: set and getter-tripped) |
fallen_off_bus |
ERROR_GPU_IS_LOST |
ERROR_GPU_IS_LOST |
ERROR_GPU_IS_LOST |
error | ERROR_GPU_IS_LOST (Xid once first if xid: set and getter-tripped) |
ecc_uncorrectable |
normal values | normal handle | normal values | strictly-increasing | one XID_CRITICAL_ERROR if xid |
A configured Xid is delivered once per trip, through either
nvmlEventSetWait_v1 or nvmlEventSetWait_v2. Subsequent waits then
match the rest of the mode: lost and fallen_off_bus return
NVML_ERROR_GPU_IS_LOST immediately, as real NVML does after Xid 79;
ecc_uncorrectable reports NVML_ERROR_TIMEOUT (no event). With no
event pending and no lost device the wait blocks for the caller's
timeout, like real NVML — clients such as the device-plugin health
monitor and dcgm-exporter loop on the wait with no sleep of their own,
so an immediate TIMEOUT would spin a CPU core. ERROR_GPU_IS_LOST is
the exception: real NVML returns it promptly, and those same clients
already back off on it.
A device the config declares immediately lost — a bare mode: lost /
fallen_off_bus block with no after_calls or probability gate — fails the
wait with NVML_ERROR_GPU_IS_LOST on its own, so a client that only loops on
nvmlEventSetWait (the DRA driver's health monitor) sees a lost GPU the way
real NVML surfaces one that fell off the bus, without calling a getter first.
Everything else still needs a trip: the injector trips on a guarded device
call (GetTemperature, GetEccErrors, …), never on the wait itself, so
delivering the configured Xid, the ecc_uncorrectable event, and any
after_calls / probability gate only advance once something drives a device
getter (nvidia-smi -q, a dcgm-exporter scrape). nvml-mock-ctl only writes
the override file — it configures the failure, it does not trip it.
Values rendered into the ConfigMap are validated against
values.schema.json at install / upgrade time:
typos like mode: healhty or out-of-range values like probability: 1.5
are rejected by Helm before the chart renders, so misconfigurations
surface as actionable schema errors instead of silent runtime
coercion.
Failure injection composes with gpu.dynamicMetrics: with both enabled the
device returns dynamic readings while healthy and switches to the configured
failure mode once the trigger fires. Once tripped a device stays tripped for
the lifetime of the pod, matching real hardware that needs a reboot to
recover.
Verifying with nvidia-smi¶
Each nvidia-smi invocation is a fresh process whose call counter starts at
0, so a narrow query like --query-gpu=ecc.errors.uncorrected.aggregate.total
will only ever issue one guarded call per GPU per invocation. (Within a
single process the counter, like the rest of the device state, also survives
nvmlShutdown() followed by nvmlInit().) To see the
failure surface from a single short command set after_calls: 1, or use a
richer query that issues several guarded calls per GPU (e.g. nvidia-smi -q)
so the trigger fires within one process.
# mode: lost / fallen_off_bus ─ handle lookup itself fails once tripped.
# nvidia-smi prints "Unable to determine the device handle for GPU ..."
# and exits non-zero.
kubectl exec ds/nvml-mock -- nvidia-smi -L
kubectl exec ds/nvml-mock -- nvidia-smi --query-gpu=name,uuid --format=csv
kubectl exec ds/nvml-mock -- nvidia-smi -q # "GPU is lost"
# mode: ecc_uncorrectable ─ device stays addressable; counters grow and
# nvmlEventSetWait_v1/_v2 delivers the configured Xid once per trip.
kubectl exec ds/nvml-mock -- nvidia-smi -q -d ECC
kubectl exec ds/nvml-mock -- nvidia-smi \
--query-gpu=ecc.errors.uncorrected.aggregate.total --format=csv
kubectl exec ds/nvml-mock -- nvidia-smi \
--query-gpu=ecc.errors.uncorrected.aggregate.dram --format=csv
# Any mode ─ watch the engine trip in real time.
kubectl exec ds/nvml-mock -- env MOCK_NVML_DEBUG=1 \
nvidia-smi -q -d ECC 2>&1 | grep -E 'failure|GPU_IS_LOST|Xid'
# One long-running process so the per-process call counter accumulates
# (useful when after_calls > 1 and you want to see a deterministic trip
# without restarting the daemonset).
kubectl exec ds/nvml-mock -- nvidia-smi \
--query-gpu=ecc.errors.uncorrected.aggregate.total --format=csv -l 1
See pkg/gpu/mocknvml/README.md
for the full engine-side reference, including how the modes interact with
specific NVML calls.
How It Works¶
The chart deploys:
- DaemonSet — runs a privileged container on each node that:
- Copies
libnvidia-ml.so.{version}to the host at/var/lib/nvml-mock/driver/usr/lib64/ - Creates symlinks (
libnvidia-ml.so.1→libnvidia-ml.so.{version}) - Creates mock device nodes at
/var/lib/nvml-mock/driver/dev/nvidia{N,ctl,-uvm,-uvm-tools}(CDI bind-mounts them to/dev/nvidia*in consumer containers) - Writes GPU config YAML at
/var/lib/nvml-mock/driver/config/config.yaml - Writes the NFD feature file that makes
feature.node.kubernetes.io/pci-10de.present=trueappear — see Node Labels - ConfigMap — GPU configuration from the selected profile
- ServiceAccount — no cluster RBAC; nothing in the pod calls the API
Consumer components (DRA driver, device plugin) mount /var/lib/nvml-mock
and use --nvidia-driver-root=/var/lib/nvml-mock/driver to discover GPUs
through standard NVML tryResolveLibrary paths.
Known Limitations¶
The mock NVML library covers the NVML C API surface used by consumers for GPU discovery and monitoring. Some host-level subsystems are not mocked:
| What's Missing | Affected Consumer | Impact |
|---|---|---|
/sys/bus/pci/devices/{busID} sysfs entries in a container the mock does not serve |
Any Go consumer | Go's os package issues raw syscalls that the LD_PRELOAD shim cannot intercept, so a Go binary reads the host's real sysfs. Containers served the nvidia.com/gpu CDI spec get the tree bind-mounted at the kernel paths instead; ones reached only by the NRI plugin still see the host's — see PCI sysfs in containers |
| The host's other device classes in a container the mock does serve | Anything reading /sys/devices |
/sys/devices is replaced wholesale, so CPU topology and the container's namespaced sysfs are not visible there. Tracked in #689 |
/sys/bus/pci/devices/{busID}/numa_node in a container the mock does not serve |
Device plugin | NUMA-aware topology hints unavailable there; scheduling works but NUMA affinity not enforced. A served device plugin does get the hints — the renderer writes numa_node for every device |
/sys/bus/pci/devices/*/vendor,device,class as NFD reads them (/host-sys/…, fixed at link time) |
NFD (Node Feature Discovery) | PCI feature labels not auto-detected. pci-10de.present is created by NFD from a feature file nvml-mock drops in nodeLabels.featuresDir — see Node Labels |
PCIe Root Complex (DRA driver)¶
When using the DRA driver with nvml-mock, you will see warnings like:
W0319 11:41:21.314205 1 nvlib.go:491] error getting PCIe root for device 0,
continuing without attribute: failed to resolve PCIe Root Complex for PCI Bus ID
0000:07:00.0: failed to read symlink for PCI Bus ID /sys/bus/pci/devices/0000:07:00.0:
readlink /sys/bus/pci/devices/0000:07:00.0: no such file or directory
The driver resolves PCIe root complex topology by readlink()-ing
/sys/bus/pci/devices/{busID}, and it is a Go binary, so libmockfs.so cannot
redirect that read to the rendered tree. A container served the nvidia.com/gpu
CDI spec gets the tree at that path and resolves the root; one the mock does not
serve reads the node's real sysfs and logs the warning above. GPUs are fully
allocatable either way — only the dra.k8s.io/pcieRoot attribute is affected.
What its absence blocks: DRA topology-aware scheduling that uses pcieRoot
to align devices on the same PCIe root complex — for example, co-scheduling a GPU
with an SR-IOV virtual function (VF) from the same root for optimal data path
locality. Without pcieRoot, ResourceClaims that express cross-device topology
constraints cannot be validated.
Confirming the attribute end-to-end against the pinned driver version is tracked
in #265. Note the upstream
constraint the shape of the tree has to satisfy: deviceattribute rejects a
symlink whose resolved target does not start with devices/pci, so the mock's
entries have to stay canonical relative symlinks and be served at the kernel
path rather than redirected elsewhere.
Troubleshooting¶
ImagePullBackOff: Verify the image is accessible. The published image is at ghcr.io/nvidia/nvml-mock:latest. For local builds, ensure the image is loaded into your cluster (see Quick Start).
DaemonSet not ready: Check pod logs: kubectl logs -l app.kubernetes.io/name=nvml-mock
GPU Operator operands stuck on toolkit-validation: Six operand DaemonSets
block until /run/nvidia/validations/toolkit-ready exists. nvml-mock
deliberately does not write it — the marker belongs to GPU Operator's own
nvidia-validator, which deletes it, runs nvidia-smi against the mock driver,
and recreates it only on success. Writing it here would let operands clear the
gate before any check had run. If operands block on a marker that never returns
while the validator is in CrashLoopBackOff, check CLEANUP_ALL: it makes the
validator RemoveAll its output dir, which fails EBUSY on the bind mount and
exits before recreating anything. Nothing sets it by default.
Device plugin shows 0 GPUs: Verify mock files exist on the node:
NODE_CONTAINER=$(docker ps --filter name=control-plane -q)
docker exec "$NODE_CONTAINER" ls /var/lib/nvml-mock/driver/usr/lib64/libnvidia-ml.so.*
docker exec "$NODE_CONTAINER" cat /var/lib/nvml-mock/driver/config/config.yaml
DRA driver pods not ready: Check DRA logs:
PCIe root warnings from DRA driver: See Known Limitations.
Privileged pods blocked: Your cluster may have PodSecurity or OPA/Gatekeeper
policies blocking privileged: true. KIND allows this by default. For managed
clusters, you may need to create a PodSecurity exception for the nvml-mock
release namespace.