3.1 NVIDIA Container Runtime 的调用链剖析 读者读完这一节,应该能一句话说出他学到了什么:你敲下 时,真正把 GPU「塞」进容器的不是 docker 本身,而是 NVIDIA Container Toolkit 在运行时替换了底层容器运行时(runc),于容器启动前把宿主的 设备和驱动库挂载了进去。 这是整本教程里最像「源码级原理」的一节。把它读通,你以后看到 或者「容器里 报错」时,脑子里会立刻浮出一条调用链,而不是茫然重启容器。 一、先立住一个最关键的心智模型 在拆调用链之前,必须先纠正一个普遍误解。很多人以为「容器里也能跑 CUDA,说明容器里装了 GPU 驱动」。不对,而且这个误解会害你以后排错全错方向。
读者读完这一节,应该能一句话说出他学到了什么:你敲下 docker run --gpus all 时,真正把 GPU「塞」进容器的不是 docker 本身,而是 NVIDIA Container Toolkit 在运行时替换了底层容器运行时(runc),于容器启动前把宿主的 /dev/nvidia* 设备和驱动库挂载了进去。
这是整本教程里最像「源码级原理」的一节。把它读通,你以后看到 CUDA driver version is insufficient 或者「容器里 nvidia-smi 报错」时,脑子里会立刻浮出一条调用链,而不是茫然重启容器。
在拆调用链之前,必须先纠正一个普遍误解。很多人以为「容器里也能跑 CUDA,说明容器里装了 GPU 驱动」。不对,而且这个误解会害你以后排错全错方向。
真实分工是这样的:
libcuda.so、libnvidia-ml.so):同样来自宿主,由 NVIDIA Container Toolkit 在容器启动时挂载进去。libcudart.so、cuDNN、PyTorch 自带的 CUDA 运行时):这些建在你的镜像里,随镜像分发。所以准确的说法是:内核驱动和驱动用户态库在宿主,CUDA 工具链在容器。容器镜像提供「用 CUDA 的能力」,宿主提供「驱动 GPU 的本体」。两者在容器启动那一刻被拼接起来。
这就引出了一个几乎必踩的版本约束:容器内 CUDA 工具链的大版本,不能高于宿主驱动所支持的最高 CUDA 版本。比如宿主驱动最高支持 CUDA 12.4,你硬塞一个要求 CUDA 12.6 的镜像,就会报 CUDA driver version is insufficient for CUDA runtime version。这条规则不是 docker 定的,是 NVIDIA 的「驱动—运行时」前向兼容边界定的。镜像选定后跨机器跑,先 nvidia-smi 看宿主驱动支持到哪个 CUDA 版本,再决定能不能用,是工程常识。
蓝框是宿主给的,绿框是镜像给的,黄框是二者拼接的结果。缺任何一块都跑不起来。
docker run --gpus all 走一遍现在我们从你敲下命令的那一刻,逐层往下拆。下面这条链路是理解一切的基础。
逐段解释:
--gpus all 被 docker 守护进程接收。docker 自己不懂 GPU,它只是把「要哪些 GPU」这个信息写进了将要交给运行时(runtime)的 OCI 配置里。runc 改成 nvidia-container-runtime(或在其前插一层)。containerd 按配置,把「真正启动容器」这件事交给这个 NVIDIA 运行时。nvidia-container-runtime 本质上是一个包在 runc 外面的壳。它先调用 nvidia-container-cli,根据 NVIDIA_VISIBLE_DEVICES(由 --gpus 演化而来)决定要把哪些 /dev/nvidia* 设备、哪些驱动库挂进容器。/dev/nvidia0、/dev/nvidiactl、/dev/nvidia-uvm 等)和驱动用户态库(从宿主对应路径)**绑定挂载(bind mount)**进容器的命名空间。nvidia-container-runtime 把这份「已经被注入 GPU 的 OCI 配置」交给真正的 runc 去启动容器。所以 runc 看到的,是一个「本来就有 GPU 设备和库」的容器。一句话总结这条链:docker 负责传话,containerd 负责调度,nvidia-container-runtime 负责在 runc 动手前偷偷把 GPU 塞进去。
NVIDIA_VISIBLE_DEVICES 到底是什么你在容器里常见一个环境变量 NVIDIA_VISIBLE_DEVICES=all 或一串 GPU 序号。它就是从 --gpus 来的「设备选择指令」,被 nvidia-container-cli 读取,用来决定挂载哪些 /dev/nvidiaN 设备。
这里有个容易混淆的点:它控制的是「容器能看到哪些卡」,不控制「每张卡用多少显存」。它切的是「设备视图」,不是「资源额度」。很多人误以为设了 NVIDIA_VISIBLE_DEVICES=0 就能限制显存,结果两个容器都只看见 0 号卡,却共享同一张卡的显存,互相挤爆。显存/算力的真正隔离是 3.2 的议题,这里先记住边界。
实战提示:如果一个容器里
torch.cuda.device_count()返回的数,和你期望的不一致,第一反应该去查NVIDIA_VISIBLE_DEVICES,而不是去改 docker 命令的别的参数。
NVIDIA_VISIBLE_DEVICES 到 CDI早期 NVIDIA 靠 nvidia-container-cli 直接把设备节点和库挂载进容器,这套机制简单有效,至今仍是很多环境的主力。但它在一些新型运行时(如某些 CRI 实现、rootless 场景、或被限制直接操作宿主文件系统的环境)下不够通用。
于是 NVIDIA 引入了 CDI(Container Device Interface,容器设备接口)。思路转变是:不再由 NVIDIA 组件「硬挂载」,而是预先生成一份描述「本机有哪些 NVIDIA 设备、各自对应哪些设备节点和库」的 JSON 规格文件,容器运行时按这份规格去发现并注入设备。这把「设备发现」和「运行时注入」解耦,让它能在更多运行时上工作。
至于你的环境走哪条路径,取决于 NVIDIA Container Toolkit 的版本与配置。文中只讲机制主干;具体该用哪条、开关在哪,建议结合你所用版本的官方说明核实,我不替你编造默认值。
理解了调用链,下面三类报错你就知道从哪查起:
故障一:docker: Error response from daemon: could not select device driver
含义是 docker 找不到 NVIDIA 运行时。几乎总是宿主没装或没配好 NVIDIA Container Toolkit,dockerd 的 runtimes 里没有 nvidia。容器侧的命令再对也没用,根因在宿主。
故障二:容器能起来,但 nvidia-smi 报 NVIDIA-SMI has failed
通常设备节点没挂进容器,或宿主本身 nvidia-smi 就失败了(驱动没装好、或内核模块没加载)。先去宿主 nvidia-smi 验证,宿主都不行就别怪容器。
故障三:容器能跑,但 PyTorch 报 CUDA driver version is insufficient
这就是第一节说的版本天花板:镜像里的 CUDA 工具链要的版本,高于宿主驱动支持的最高版本。解决办法是降镜像 CUDA 版本,或升级宿主驱动——具体哪个更划算,看你能否动生产机的驱动。
这三类,根因分别落在「运行时配置」「宿主驱动」「版本匹配」三个不同层面。调用链的价值,就是让你一眼把它归到正确的层。
不要凭「能 import torch」就万事大吉。三个递进的验证动作:
nvidia-smi,确认驱动健康、能看到所有卡。这是地基。ls /dev/nvidia*,确认设备节点确实被挂进来了。这一步直接验证 nvidia-container-cli 的挂载动作。python -c "import torch; print(torch.cuda.is_available(), torch.cuda.device_count())"。前两步过了,这一步基本稳;它验证的是「CUDA 工具链 + 挂载的驱动」拼接成功。只有当这三步逐级通过,你才真正拥有了一个「GPU 透传正确」的沙箱。任何一步卡住,按调用链往回推:第三步挂 → 第二步查挂载 → 第一步查宿主驱动。
第二章(2.3)你其实已经装好了 NVIDIA Container Toolkit 并配好了 runtime,--gpus 也能用了。那时我们追求的是「先把沙箱跑起来」。而本节把它拆开,是为了让你从「会用」升级到「懂原理」。
这种顺序是有意的:先让你拿到一个能用的沙箱,建立信心;再回过头讲清楚它为什么能工作。否则一上来就拆调用链,你很容易在「宿主驱动 vs 镜像 CUDA」的版本关系里迷失,反而迟迟跑不出第一个容器。先有果,再究因,是这本教程的叙事纪律。
nvidia-container-cli 看清楚「到底会注入什么」调用链讲完,光靠脑内推演还不够。NVIDIA 提供了一个诊断利器:nvidia-container-cli。它有两个特别实用的子命令,能在你真正跑容器之前,就看清 Toolkit 准备注入什么。
nvidia-container-cli info:打印宿主上 NVIDIA 设备、驱动库、以及当前 runtime 配置的整体信息。你可以用它确认 Toolkit 是否识别到了所有卡、驱动库路径是否齐全。nvidia-container-cli list:列出「如果现在启动一个容器,会被挂载进去的设备和库清单」。这是排错神器——当你怀疑「某张卡没透传进去」时,先看这个清单里有没有它,比盲目改 --gpus 参数高效得多。这两个命令的价值在于把「黑盒注入」变成「白盒可查」。我强烈建议你在装好 Toolkit 后第一件事就跑一遍 nvidia-container-cli info,把宿主的真实设备盘点记下来。以后任何容器看不到卡的故障,先回看这份基线,能省下至少一半的排查时间。
具体子命令参数、输出格式以你所用 NVIDIA Container Toolkit 版本的官方说明为准,我不替你编造输出样例。机制主干(它用来预演注入内容)是稳定的。
前面说 nvidia-container-runtime「在 runc 前注入设备与库」。落地的具体动作,是它修改了传给 runc 的 OCI runtime 配置(config.json)。原始的 docker/containerd 配置里,容器只有普通 linux 设备;nvidia-container-runtime 在交给 runc 之前,往这份配置里追加了两样东西:
linux.devices 里的 NVIDIA 设备节点:把 /dev/nvidia0、/dev/nvidiactl、/dev/nvidia-uvm 等以设备条目加进去,让容器内出现这些设备文件。mounts 里的驱动库绑定挂载:把宿主驱动的用户态库(如 libcuda.so 所在路径)以 bind mount 方式挂进容器对应目录,使得容器内进程能 dlopen 到它们。理解这一点很重要:所谓「GPU 透传」本质上就是在容器启动前,往它的 OCI 配置里塞设备条目和库挂载。runc 并不懂 CUDA,它只是忠实地按这份被改过的配置,创建了带 NVIDIA 设备和库的容器命名空间。这也是为什么——只要 Toolkot 正常工作——runc 这一层你完全不需要碰。
把八、九两节和前面的调用链拼起来,你就有了完整的排错地图:宿主驱动(地基)→ Toolkit 配置(能否生成 runtime)→ nvidia-container-cli 预演(注入清单对不对)→ OCI 配置(实际改了什么)→ runc 落地(容器里有没有设备)。任何一步都能精确定位。
回到第一节那个最痛的报错:CUDA driver version is insufficient。真撞上了,你面临一个二选一:降镜像 CUDA 版本,还是升宿主驱动?我的决策框架是这样的:
FROM 的 CUDA 标签,其余不变。这再次印证本章开头的话:版本天花板是 NVIDIA「驱动—运行时」前向兼容边界定的,不是 docker 的错,也不是你写错命令。理解它,决策就从「瞎试」变成「按能否动驱动来选路径」。
本节你拿到了一条完整调用链:docker 传话 → containerd 调度 → nvidia-container-runtime 在 runc 前注入设备与驱动库 → runc 启动容器。你也应该记住「驱动在宿主、CUDA 工具链在容器」的分工,以及由此派生的版本天花板。
但注意:调用链解决的是「能不能看到卡」,不解决「多人共用时怎么互不踩」。容器 A 看见 0 号卡、容器 B 也看见 0 号卡,它们共享的是同一张卡的显存与算力——这才是生产环境真正的痛点。下一节 3.2,我们专门讲显存、算力与设备视图的隔离边界。