TT Lab
Blog

한국어 English 日本語 中文

Get started
Blog

Blog

Reading NVIDIA GPU Operator: How a single ClusterPolicy prepares GPU nodes and how dcgm-exporter finds the owner of a GPU

Status: Draft (2026-10-17), partially measured. Reference versions: NVIDIA/gpu-operator v26.3.3 (commit b0a49c0e7b2e), NVIDIA/k8s-device-plugin v0.19.3 (570e776d6813), NVIDIA/dcgm-exporter 4.5.3-4.8.2 (691c92762eb5). Since a small cluster is running these exact versions, measurements were obtained on 2026-10-17 from that cluster by read-only methods (reading ClusterPolicy, DaemonSets, node labels, pod configurations, reading /metrics via API server proxy, and reading files inside running pods). Records measuring the same phenomenon at greater length on 2026-10-16 are also cited. Writing rules: Measured data is the default; unmeasured items are marked as "Unmeasured," and unverified items are marked as "Unverified."

Table of Contents

1. One-line summary: "Installation technician + Checklist" for GPU nodes

To use GPUs in Kubernetes, multiple layers must align on each node: kernel drivers, a container toolkit that allows containers to see GPU devices, a device plugin that reports nvidia.com/gpu resources to kubelet, GPU Feature Discovery (GFD) which attaches GPU specifications as node labels, and dcgm-exporter which exports metrics. If even one thing is out of order, pods will get stuck in Init or fail to find the GPU.

The GPU Operator installs these layers sequentially like an installation technician, checks whether the previous step is complete like a checklist, and then moves on to the next step. Users only need to interact with a single CR called ClusterPolicy .

Components What it does Where it runs
gpu-operator Observes ClusterPolicy and sequentially applies the status list 1 Deployment
NFD (node-feature-discovery) Detects PCI devices and applies labels such as feature.node.kubernetes.io/pci-10de.present labels Master Deployment + Worker DaemonSet (all nodes)
driver Install kernel modules (container driver). Disabled if the driver is already present on the host GPU Node DaemonSet
container-toolkit Configure runtime, generate CDI spec(management.nvidia.com) GPU Node DaemonSet
operator-validator Verify that the driver, toolkit, CUDA, and plugins work properly, and leave a "readiness file" GPU Node DaemonSet
device plugin nvidia.com/gpu Advertising and allocation, time-slicing replication, CDI spec(k8s.device-plugin.nvidia.com) GPU Node DaemonSet
GFD Expose GPU count, replica count, sharing method, etc., as node labels GPU Node DaemonSet
dcgm-exporter Expose DCGM metrics in Prometheus format with pod labels attached GPU Node DaemonSet

2. Architecture Diagram: State List and Readiness Chain within Nodes

ClusterPolicy 하나가 상태 목록을 차례로 적용한다 ClusterPolicy 클러스터에 하나 Reconcile → step() 반복 안 된 상태가 있으면 5초 뒤 다시 상태 = assets/ 폴더 하나(매니페스트 묶음) → 변환 → 적용 1 pre-requisites 2 operator-metrics 3 driver 4 container-toolkit 5 operator-validation 6 device-plugin 7 mps-control 8 dcgm 9 dcgm-exporter 10 gpu-feature-disc. 11 mig-manager 12 node-status-exp. 13–20 vGPU·샌드박스·kata·cc 계열(샌드박스 워크로드용) 켜짐 꺼짐(작은 클러스터 설정 — 호스트 드라이버 사용 등) GPU 노드 안: 파일 하나씩으로 잇는 준비 사슬 operator-validator 파드의 initContainer 순서 driver 검증 driver-ready toolkit 검증 toolkit-ready cuda 검증 cuda-ready plugin 검증 plugin-ready 파일은 노드의 /run/nvidia/validations 에 남는다 device-plugin·dcgm-exporter 는 toolkit-ready 를 기다린 뒤 뜬다
Figure: ClusterPolicy applies the state list in sequence, and inside the node, readiness order is chained via validation files

How to read the diagram:

3. Code Map

Looking at the three repositories together, focusing on the most important top-level locations.

Path What File to read first
gpu-operator api/ Go types for ClusterPolicy and NVIDIADriver CRDs api/nvidia/v1/clusterpolicy_types.go
gpu-operator controllers/ Reconcile loop, state management, object-specific application and conversion clusterpolicy_controller.go, state_manager.go
gpu-operator assets/ Manifest bundles per state(state-device-plugin/0500_daemonset.yaml , etc.) state-operator-validation/0500_daemonset.yaml
gpu-operator cmd/nvidia-validator/ In-node validator (driver, toolkit, CUDA, plugin) main.go
k8s-device-plugin internal/plugin/ kubelet device plugin gRPC server server.go
k8s-device-plugin internal/rm/ Building device list, time-slicing replication (::N) device_map.go, devices.go
k8s-device-plugin internal/cdi/ Generate and save CDI spec cdi.go
dcgm-exporter internal/pkg/transformation/ Attach pod and namespace labels to metrics kubernetes.go
dcgm-exporter internal/pkg/server/ /metrics Process, apply transformations server.go

controllers/object_controls.go is over 5,000 lines long. Rather than reading it from the beginning, it is better to look up the respective function when its name appears in the paths below.

4. Following the Core Path

4.1 The Reconcile Loop: Taking "One Step" at a Time

  1. When the operator receives a ClusterPolicy, controllers/clusterpolicy_controller.go:93 Reconcile runs. Since only one ClusterPolicy is allowed per cluster (subsequent ones are Ignored), if it is the first one, it calls init .
  2. controllers/state_manager.go:854 init finds GPU nodes, labels them (state_manager.go:521 labelGPUNodes), and registers the state list. Whether a node is a GPU node is determined by the NFD label feature.node.kubernetes.io/pci-10de.present=true(where 10de is NVIDIA's PCI vendor ID), and such nodes are labeled with nvidia.com/gpu.present=true and nvidia.com/gpu.deploy.<구성요소>=true . The nodeSelector for each DaemonSet corresponds to these labels.
  3. Registration is performed as state_manager.go:175 addState loads controllers/resource_manager.go:102 addResourcesControls for each folder path, looks at the kind in the YAML, and builds a list of "functions that apply this object." One state equals one function list.
  4. Reconcile repeats state_manager.go:1051 step up to the final state. step runs all functions for that state, and if even one is not ready, marks the state as NotReady and moves on to the next state. After running through to the end, if any state remains unready, it runs again after 5 seconds.
for _, fs := range n.controls[n.idx] {
    stat, err := fs(*n)
    if err != nil {
        return stat, err
    }
    // successfully deployed resource, now check if its ready
    if stat != gpuv1.Ready {
        result = stat
    }
}
// move to next state
n.idx++

Source: Part of NVIDIA/gpu-operator v26.3.3 controllers/state_manager.go(step ), Apache-2.0.

A design choice is visible here. It does not block waiting for the preceding state to be ready. The DaemonSets are created upfront, and the actual sequencing is handled by the readiness files inside the nodes (4.2). If the operator forced each node to wait, a delay in a single node would halt the application across the entire cluster.

  1. The function that applies DaemonSets is controllers/object_controls.go:4621 DaemonSet . If a state is disabled, it deletes the object and returns Disabled . If enabled, object_controls.go:728 preProcessDaemonSet selects a transformation functionbased on the DaemonSet name (nvidia-device-plugin-daemonset → TransformDevicePlugin, nvidia-dcgm-exporter → TransformDCGMExporter ...). These transformation functions are where the images, environment variables, and configurations of the ClusterPolicy are injected into the YAML. Readiness is checked by object_controls.go:3989 isDaemonSetReady .
  2. When a new GPU node joins, clusterpolicy_controller.go:246 addWatchNewGPUNode 's node watcher wakes up reconcile again.

4.2 Node-level Readiness Chain: Validators and the "-ready" File

Validators are located in a single file: cmd/nvidia-validator/main.go . The directory where state files are kept is /run/nvidia/validations(line 146 defaultStatusPath), and the filenames are gathered as constants on lines 161–189 (driver-ready, toolkit-ready, cuda-ready, plugin-ready …).

The file-based chaining approach is simple yet highly advantageous. When a node reboots, /run is cleared, so validation naturally reruns from the beginning, and an administrator can see exactly where it stopped with a single ls /run/nvidia/validations command on the node.

4.3 Device Plugin: Time-Slicing Replication and CDI Specs

The device plugin is a gRPC server implementing kubelet's device plugin API. The flow can be summarized in three functions.

  1. internal/plugin/server.go:229 Register — Registers to the kubelet socket stating "nvidia.com/gpu is managed by me."
  2. server.go:267 ListAndWatch — Streams the device list (including health status) to the kubelet.
  3. server.go:306 Allocate — Returns the environment variables, devices, and CDI names to inject into the container when allocating devices to a pod.

Time-slicing occurs in step 2. If replicas: 5 is present in the configuration, internal/rm/device_map.go:286 updateDeviceMapWithReplicas scales up a single GPU to be advertised as GPU-<uuid>::0 … ::4 five "devices." Attaching and detaching ::N is handled by a small type around internal/rm/devices.go:243 NewAnnotatedID . Kubelet distributes these to five pods one by one, unaware that they are the same physical GPU. There is no memory isolation. This ::N connects to the issues discussed in Section 5.

CDI spec is a file telling the container runtime, "To provide this device, include these device files and libraries." internal/cdi/cdi.go:187 CreateSpecFile saves the spec to /var/run/cdi(line 41 cdiRoot), and this function is called once when the plugin starts (cmd/nvidia-device-plugin/plugin-manager.go:84). The spec includes library filenames including the driver version . Therefore, if you update only the host driver without restarting the plugin, the library version pointed to by the spec and the actually loaded kernel module version may mismatch. Since the plugin restarts upon receiving kubelet restarts, configuration changes, or signals (main.go start restart loop in ), a node reboot or plugin pod restart is practically necessary after a driver update.

4.4 dcgm-exporter: "Whose GPU Is This?"

dcgm-exporter 는 스크랩마다 「이 GPU 는 누구 것」 을 묻는다 DCGM 값 GPU UUID 별 한 줄 PodMapper.Process() kubelet pod-resources List() List() 응답 — 시분할로 한 GPU 를 세 파드가 나눔 파드 P GPU-uuid::0 파드 Q GPU-uuid::1 파드 R GPU-uuid::2 순서는 kubelet 의 Go 맵 순회 → 부를 때마다 다르다 toDeviceToPod ::N 을 떼고 map[UUID] = 파드 → 마지막 파드가 이긴다 실측: 10초 간격 스크랩 6번의 pod 라벨 1회 P 2회 Q 3회 P 4회 R 5회 Q 6회 R 같은 GPU 의 시계열 하나가 세 파드 사이를 오간다 --kubernetes-virtual-gpus 를 켜면 toDeviceToSharingPods → 파드마다 한 줄씩 복사(일부 필드는 프로세스별 값)
Figure: dcgm-exporter queries kubelet for pods on every scrape, and under time-sliced GPUs, the last pod wins

dcgm-exporter reads per-GPU metrics from DCGM and /metrics exports them. DCGM_EXPORTER_KUBERNETES=true If so, each line is pod·namespace·container labeled, and that information comes not from the Kubernetes API, but from kubelet's pod-resources gRPC(default /var/lib/kubelet/pod-resources/kubelet.sock). This is because kubelet is the one that knows 「which device ID this container received」.

  1. /metrics When a request arrives, internal/pkg/server/server.go:249 Metrics → render runsserver.go:309 transformation.Process). per scrape the conversion list.
  2. internal/pkg/transformation/kubernetes.go:296 getMappings connects to the socket and calls kubernetes.go:533 listPods via List() .
  3. In the default(--kubernetes-virtual-gpus disabled) mode, kubernetes.go:712 toDeviceToPod builds a map of 「device ID → single pod」. Time-sliced device IDs GPU-<uuid>::N use :: the part before it, using the GPU UUID as the key.
} else if strings.Contains(deviceID, "::") {
    gpuInstanceID := strings.Split(deviceID, "::")[0]
    // (디버그 로그 생략)
    deviceToPodMap[gpuInstanceID] = podInfo
}

Source: NVIDIA/dcgm-exporter 4.5.3-4.8.2 internal/pkg/transformation/kubernetes.go lines 887–899 summary, Apache-2.0.

If three pods share the same GPU, the three pods take turns overwriting the same key(kubernetes.go:898). Ultimately, the last pod in the response wins. And the order of that response changes on every call because kubelet builds the pod list by popping it from a Go map (based on kubelet v1.34 pkg/kubelet/pod/pod_manager.go map iteration source). When the 「last one wins」 behavior of a single line of code meets the randomness of response order, labels change on every scrape.

--kubernetes-virtual-gpus(Enabling the environment variable KUBERNETES_VIRTUAL_GPUS) makes kubernetes.go:662 toDeviceToSharingPods group a list of podsper GPU and duplicate device metrics one line per pod (with some fields split into per-process values). The GPU Operator default deployment does not enable this value.

5. Author's Contributions and Where to Follow Up

6. Measurements: Readings from a Small Cluster

2026-10-17, all read commands (kubectl get, inside running pod ls·grep, API server proxy kubectl get --raw …/proxy/metrics). Nodes are masked by role, and pods are masked as P, Q, and R.

ClusterPolicy and DaemonSets.

Item Read Value
ClusterPolicy Status state: ready, condition Reconciled(since 2026-10-14)
driver.enabled false — Using host-installed driver (state 3 driver off)
dcgm.enabled / dcgmExporter.enabled false / true — Using the engine inside the exporter without a separate DCGM
cdi.enabled true
migManager.enabled, sandboxWorkloads.enabled Both false
devicePlugin.config default no-sharing, profile name time-slicing-config
DaemonSet Desired / Ready nodeSelector
NFD worker 8 / 8 None (all nodes)
container-toolkit 5 / 5 nvidia.com/gpu.deploy.container-toolkit=true
operator-validator 5 / 5 …deploy.operator-validator=true
device-plugin 5 / 5 …deploy.device-plugin=true
mps-control-daemon 0 / 0 Above + nvidia.com/mps.capable=true(No matching node)
dcgm-exporter 5 / 5 …deploy.dcgm-exporter=true
gpu-feature-discovery 5 / 5 …deploy.gpu-feature-discovery=true

Out of eight nodes, five are GPU nodes, and all five had nvidia.com/gpu.present=true and gpu.deploy.* eight labels attached (Step 2 in 4.1).

Validation chain. operator-validator pod's initContainer was in the driver-validation → toolkit-validation → cuda-validation → plugin-validation order, and the device plugin pod was toolkit-validation → config-manager-init, and the dcgm-exporter pod was toolkit-validation just one. Exactly like the chain at the bottom of the diagram.

Time-slicing. Only one GPU node (GPU Node B) nvidia.com/device-plugin.config=shared-5 was using a different configuration chosen via labels. That configuration was replicas: 5, failRequestsGreaterThanOne: true(preventing a single pod from taking two or more slots), renameByDefault: false and the result is as follows.

GPU node gpu.sharing-strategy gpu.replicas allocatable nvidia.com/gpu
B time-slicing 5 5
remaining four none 1 1

One GPU (24GB class) appears as five to kubelet. The label gpu.replicas·gpu.sharing-strategy was attached by GFD.

CDI spec. Inside the device plugin pod, looking at /var/run/cdi , there were two files: the k8s.device-plugin.nvidia.com-gpu.json created by the plugin and the management.nvidia.com-gpu.yaml created by the toolkit. The modification time of the plugin spec across all five nodes was the time the plugin pod started(as mentioned in section 4.3, "Once at startup"), and within the spec, the version of libnvidia-ml.so.<판> and the kernel module version of /proc/driver/nvidia/version were identical across all five nodes today. Since the spec dates on each node were scattered from early September to mid-October, we can see that nodes where the driver was updated also had their plugins restarted afterward.

pod labels for dcgm-exporter. The exporter on GPU Node B /metrics was scraped six times at 10-second intervals. In each scrape, 15 metrics (configuration file dcp-metrics-included.csv) were output as one line per GPU, and there were three running pods requesting GPUs on that node.

scrape 1 2 3 4 5 6
DCGM_FI_DEV_GPU_UTIL 's pod P Q P R Q R

changed all five times over the course of five scrapes. When scraping the same node 30 times at 2-second intervals on 2026-10-16, the three pods appeared 19, 8, and 3 times (19, 10, and 1 in the second run; changes between two consecutive runs occurred 12 times), and one hour's worth of data accumulated in Prometheus (at 15-second intervals) split a single GPU into three time series with 129, 76, and 35 samples, and over 24 hours, the number of distinct exported_pod values was 7. This is why lines appear sporadically broken when grouped by pod on the dashboard.

7. Where to start reading

  1. controllers/clusterpolicy_controller.go:93 Reconcile — The entire flow is visible within 150 lines.
  2. controllers/state_manager.go:854 init and 1051 lines of step — State list and a step.
  3. assets/state-device-plugin/ folder — Visually check what kind of YAML bundle a "single state" is.
  4. object_controls.go:1520 TransformDevicePlugin — An actual example of how ClusterPolicy values go into YAML.
  5. internal/plugin/server.go 's Register·ListAndWatch·Allocate — Three functions of the device plugin API.
  6. internal/pkg/transformation/kubernetes.go:296 to Process — The path of pods attaching to metrics.

8. Good First Contribution Areas

). External PRs face long wait times for NVIDIA-side CI validation.

How pods fail when the CDI spec is actually misaligned was not reproduced this time (only recorded in Part 2 Incident Notes). The working pods spawned by the validator's CUDA check and the MPS path were not covered.

Titi's Word.

Five people take turns using a single ping-pong table, and the scorekeeper's nameplate always shows the name of "the person who stood in line last." Since the queue order changes every time, the owner changes every time, too. Organizing them just alphabetically would quiet things down!

Sign in to like

Comments

No comments yet.

Sign in to leave a comment