While setting up the Claude Science container, I learned about a new way of using NVIDIA GPUs inside containers, so I’m noting it down (most of it was written by AI, used as reference material for my own research).
To use an NVIDIA GPU in a container, the most common approach in the early days was to pull NVIDIA’s official CUDA container images (the nvidia/cuda series) and run them with the NVIDIA Container Toolkit. In the last two years, CDI (Container Device Interface) has gradually become the more “modern” approach, with Docker and various container technologies actively adding native support for it.
On the surface, both approaches let you “use a GPU in a container,” but the underlying technical implementation and day-to-day experience are actually very different.
The old way: official CUDA images + NVIDIA Container Runtime
Let’s start with the old way. NVIDIA maintains a bunch of nvidia/cuda images on Docker Hub, such as nvidia/cuda:12.4.1-devel-ubuntu22.04. These images bake the CUDA toolchain and runtime libraries (libcudart, libcublas, etc.) directly into the image, so you can compile and run CUDA programs right after pulling them.
But the image alone isn’t enough — the driver parts that aren’t in the image (libcuda.so, the device nodes /dev/nvidia*) need to be injected from the host. That requires the NVIDIA Container Toolkit (known as nvidia-docker2 in the old days), configured as a runtime for Docker:
1 | # install the toolkit, then configure it as a docker runtime |
How it works
The core of the old way is the hook mechanism. The flow goes roughly like this:
nvidia-container-runtimeis invoked as Docker’s runtime; it’s essentially a thin wrapper aroundrunC;- The runtime injects a
prestarthook into the OCI spec (config.json), corresponding to/usr/share/containers/oci/hooks.d/oci-nvidia-hook.json; - The hook calls
nvidia-container-cli, which decides which devices to inject based on theNVIDIA_VISIBLE_DEVICESenvironment variable; - The CLI mounts/copies the host’s device nodes, driver libraries (
libcuda.so, etc.), and tools likenvidia-smiinto the container.
So there are a few key points in this mode:
- Device selection relies on an environment variable:
NVIDIA_VISIBLE_DEVICEScan beall,0,1, a UUID, and so on; - CUDA lives in the image, the driver lives on the host: the image packages the CUDA user-space libraries, while the driver is injected from the host via the hook; the two are “stitched together” at runtime;
- Tight coupling to the runtime: this hook mechanism is NVIDIA-proprietary. Docker needs an
nvidiaruntime configured; Podman recognizes hooks by default, but every runtime has to be adapted separately.
Pain points of the old way
Over time you run into issues:
- Huge images: with the whole CUDA toolchain baked in, images can be several GB, which is a burden for pulling and storage;
- Version coupling: the CUDA version in the image and the host driver version need to match; after upgrading the driver, old images may stop working;
- Too tightly bound to the runtime: the hook mechanism is NVIDIA’s own invention; switching runtimes or to another vendor’s devices means starting over;
- Poor rootless support: the hook mechanism doesn’t play well with rootless containers.
The new way: CDI (Container Device Interface)
CDI is an open specification under CNCF (cncf-tags/container-device-interface) that tries to solve exactly the problems above. Its idea is to standardize “how a device is exposed to a container,” so that runtimes, container engines, and orchestrators can all describe devices in the same language.
Basic concepts
- Devices are identified by fully-qualified names in the form
vendor.com/class=unique_name; for NVIDIA that’s things likenvidia.com/gpu=0,nvidia.com/gpu=1:0(a MIG device), ornvidia.com/gpu=all; - Device information lives in spec files (
.yaml/.json) in the default directories/etc/cdi(static configuration) and/var/run/cdi(dynamically updated); - CDI only makes containers “device-aware”; resource management (scheduling, quotas) is the orchestrator’s job, not CDI’s.
A CDI spec file looks roughly like this (excerpt):
1 | cdiVersion: 0.6.0 |
As you can see, CDI turns “which device nodes to inject, which libraries to mount, which env vars to set” into declarative data. A container engine that sees a device name looks up the spec file and assembles the corresponding content into the OCI spec itself, without going through NVIDIA’s proprietary hook.
How NVIDIA generates CDI specs
The NVIDIA Container Toolkit has supported generating CDI specs since v1.12.0. Since v1.18.0, a systemd service called nvidia-cdi-refresh automatically generates and updates the spec at /var/run/cdi/nvidia.yaml on Toolkit install/upgrade, GPU driver install/upgrade, or system reboot:
1 | # list the currently available CDI devices |
One caveat: nvidia-cdi-refresh currently does not handle driver removal or MIG device reconfiguration automatically. In those scenarios you need to regenerate manually:
1 | sudo nvidia-ctk cdi generate --output=/var/run/cdi/nvidia.yaml |
How to use CDI
Docker has supported CDI since 25.0.0 and enables it by default since 28.2.0. Versions in between (25.0.0 to 28.1.1) require manually enabling it in /etc/docker/daemon.json:
1 | { |
At runtime, just run:
1 | docker run --rm \ |
Note that this runs a plain ubuntu image; the CUDA libraries are injected from the host via the CDI spec — which directly addresses the “huge image” pain point of the old way.
What if the runtime doesn’t natively support CDI
For runtimes that don’t natively support CDI (e.g. Docker with the CDI feature disabled), you can configure the NVIDIA Container Runtime in cdi mode. Device selection still uses the NVIDIA_VISIBLE_DEVICES environment variable, but the injection goes through CDI:
1 | sudo nvidia-ctk config --in-place --set nvidia-container-runtime.mode=cdi |
One limitation worth remembering: CDI injection and the NVIDIA Container Runtime hook mechanism are mutually exclusive. If /usr/share/containers/oci/hooks.d/oci-nvidia-hook.json exists, don’t use CDI injection at the same time, and don’t set NVIDIA_VISIBLE_DEVICES either — they’ll fight each other.
Comparing the two approaches
| Dimension | Old way: official CUDA images | New way: CDI |
|---|---|---|
| Device injection | NVIDIA-proprietary prestart hook + nvidia-container-cli |
Open-spec CDI files parsed by the runtime |
| Device selection | NVIDIA_VISIBLE_DEVICES env var |
--device nvidia.com/gpu=xxx or annotations/CRI fields |
| CUDA source | Baked into the image, huge images | Host driver + CDI injection, plain images work |
| Version coupling | Image CUDA must match driver; upgrade-sensitive | Spec auto-follows the driver via nvidia-cdi-refresh |
| Runtime compatibility | Depends on NVIDIA hook, adapted per runtime | Natively unified in Podman/Docker/containerd/CRI-O |
| Rootless | Poor support | Good support |
| Ecosystem openness | NVIDIA-proprietary | CNCF open spec; usable for other vendors’ devices too |
| Resource management | No standard | Explicitly left to the orchestrator (e.g. K8s device plugin) |
Summary
The old way is simple and direct. Many people (myself included) ran their first GPU container exactly like that, and it still works today — great for quick validation and personal use. But if your environment is heading toward Kubernetes, needs rootless support, or has multiple runtimes coexisting, CDI is clearly the more modern and less painful direction.
That said, CDI isn’t a silver bullet either — nvidia-cdi-refresh doesn’t cover driver removal or MIG reconfiguration, so the spec sometimes has to be refreshed manually; and it requires runtime-side support, so old Docker versions still need a manual feature flag. But the overall trend is clear: device description is moving from “vendor-proprietary hooks” toward “open, declarative specs.”