3.1 NVIDIA Container Runtime 的调用链剖析


文档摘要

3.1 NVIDIA Container Runtime 的调用链剖析 读者读完这一节,应该能一句话说出他学到了什么:你敲下 时,真正把 GPU「塞」进容器的不是 docker 本身,而是 NVIDIA Container Toolkit 在运行时替换了底层容器运行时(runc),于容器启动前把宿主的 设备和驱动库挂载了进去。 这是整本教程里最像「源码级原理」的一节。把它读通,你以后看到 或者「容器里 报错」时,脑子里会立刻浮出一条调用链,而不是茫然重启容器。 一、先立住一个最关键的心智模型 在拆调用链之前,必须先纠正一个普遍误解。很多人以为「容器里也能跑 CUDA,说明容器里装了 GPU 驱动」。不对,而且这个误解会害你以后排错全错方向。

3.1 NVIDIA Container Runtime 的调用链剖析

读者读完这一节,应该能一句话说出他学到了什么:你敲下 docker run --gpus all 时,真正把 GPU「塞」进容器的不是 docker 本身,而是 NVIDIA Container Toolkit 在运行时替换了底层容器运行时(runc),于容器启动前把宿主的 /dev/nvidia* 设备和驱动库挂载了进去。

这是整本教程里最像「源码级原理」的一节。把它读通,你以后看到 CUDA driver version is insufficient 或者「容器里 nvidia-smi 报错」时,脑子里会立刻浮出一条调用链,而不是茫然重启容器。

一、先立住一个最关键的心智模型

在拆调用链之前,必须先纠正一个普遍误解。很多人以为「容器里也能跑 CUDA,说明容器里装了 GPU 驱动」。不对,而且这个误解会害你以后排错全错方向。

真实分工是这样的:

  • GPU 内核驱动(kernel driver):住在宿主操作系统里。容器共享宿主内核,所以不需要、也不能在容器里装驱动。
  • 驱动的用户态库(如 libcuda.solibnvidia-ml.so):同样来自宿主,由 NVIDIA Container Toolkit 在容器启动时挂载进去。
  • CUDA 工具链与上层库(如 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 版本,再决定能不能用,是工程常识。

驱动与CUDA库的住所分工

蓝框是宿主给的,绿框是镜像给的,黄框是二者拼接的结果。缺任何一块都跑不起来。

二、顺着 docker run --gpus all 走一遍

现在我们从你敲下命令的那一刻,逐层往下拆。下面这条链路是理解一切的基础。

逐段解释:

  1. docker CLI → dockerd:你写的 --gpus all 被 docker 守护进程接收。docker 自己不懂 GPU,它只是把「要哪些 GPU」这个信息写进了将要交给运行时(runtime)的 OCI 配置里。
  2. dockerd → containerd:dockerd 把创建容器的请求下发给 containerd(docker 的底层容器管理器)。
  3. containerd → 配置的运行时:关键在于,装了 NVIDIA Container Toolkit 之后,系统会把「默认底层运行时」从 runc 改成 nvidia-container-runtime(或在其前插一层)。containerd 按配置,把「真正启动容器」这件事交给这个 NVIDIA 运行时。
  4. nvidia-container-runtime → nvidia-container-cli:这是 NVIDIA 组件的精华。nvidia-container-runtime 本质上是一个包在 runc 外面的壳。它先调用 nvidia-container-cli,根据 NVIDIA_VISIBLE_DEVICES(由 --gpus 演化而来)决定要把哪些 /dev/nvidia* 设备、哪些驱动库挂进容器。
  5. nvidia-container-cli → 宿主设备:cli 在容器启动前,把宿主的 NVIDIA 设备节点(/dev/nvidia0/dev/nvidiactl/dev/nvidia-uvm 等)和驱动用户态库(从宿主对应路径)**绑定挂载(bind mount)**进容器的命名空间。
  6. 最后才交给 runc:一切准备就绪后,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 规格文件,容器运行时按这份规格去发现并注入设备。这把「设备发现」和「运行时注入」解耦,让它能在更多运行时上工作。

传统注入与CDI对比

至于你的环境走哪条路径,取决于 NVIDIA Container Toolkit 的版本与配置。文中只讲机制主干;具体该用哪条、开关在哪,建议结合你所用版本的官方说明核实,我不替你编造默认值。

五、最常见的三类「容器看不到卡」故障

理解了调用链,下面三类报错你就知道从哪查起:

故障一:docker: Error response from daemon: could not select device driver
含义是 docker 找不到 NVIDIA 运行时。几乎总是宿主没装或没配好 NVIDIA Container Toolkit,dockerd 的 runtimes 里没有 nvidia。容器侧的命令再对也没用,根因在宿主。

故障二:容器能起来,但 nvidia-smiNVIDIA-SMI has failed
通常设备节点没挂进容器,或宿主本身 nvidia-smi 就失败了(驱动没装好、或内核模块没加载)。先去宿主 nvidia-smi 验证,宿主都不行就别怪容器。

故障三:容器能跑,但 PyTorch 报 CUDA driver version is insufficient
这就是第一节说的版本天花板:镜像里的 CUDA 工具链要的版本,高于宿主驱动支持的最高版本。解决办法是降镜像 CUDA 版本,或升级宿主驱动——具体哪个更划算,看你能否动生产机的驱动。

这三类,根因分别落在「运行时配置」「宿主驱动」「版本匹配」三个不同层面。调用链的价值,就是让你一眼把它归到正确的层。

六、怎么验证注入真的成功了

不要凭「能 import torch」就万事大吉。三个递进的验证动作:

  1. 宿主先验证:在宿主执行 nvidia-smi,确认驱动健康、能看到所有卡。这是地基。
  2. 容器内看设备:在容器里 ls /dev/nvidia*,确认设备节点确实被挂进来了。这一步直接验证 nvidia-container-cli 的挂载动作。
  3. 框架层验证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 版本的官方说明为准,我不替你编造输出样例。机制主干(它用来预演注入内容)是稳定的。

九、OCI 配置长什么样:runtime 到底改了什么

前面说 nvidia-container-runtime「在 runc 前注入设备与库」。落地的具体动作,是它修改了传给 runc 的 OCI runtime 配置(config.json)。原始的 docker/containerd 配置里,容器只有普通 linux 设备;nvidia-container-runtime 在交给 runc 之前,往这份配置里追加了两样东西:

  1. linux.devices 里的 NVIDIA 设备节点:把 /dev/nvidia0/dev/nvidiactl/dev/nvidia-uvm 等以设备条目加进去,让容器内出现这些设备文件。
  2. 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 版本,还是升宿主驱动?我的决策框架是这样的:

  • 能不能动生产机驱动? 如果机器是共享训练机、由运维统一管驱动,你大概率动不了。那只能降镜像 CUDA 版本到宿主支持范围内——这正是第二章「锁基础镜像版本」纪律的回报:你只要换 FROM 的 CUDA 标签,其余不变。
  • 如果机器是你独占的开发机:升驱动往往一次性解决,且能解锁更新的 CUDA 能力(如新算子的 kernel)。但要评估升级风险:驱动升级可能短暂影响同机其他人的任务,需协调窗口。
  • 宁可统一抬高地板,别让镜像各自漂:理想状态是团队约定「宿主驱动统一到一个较高版本」,所有镜像的 CUDA 都低于它。这样跨机迁移永远不踩版本天花板。反之若各镜像 CUDA 版本乱飞,迁移时必然有人中招。

这再次印证本章开头的话:版本天花板是 NVIDIA「驱动—运行时」前向兼容边界定的,不是 docker 的错,也不是你写错命令。理解它,决策就从「瞎试」变成「按能否动驱动来选路径」。

小结与下一节衔接

本节你拿到了一条完整调用链:docker 传话 → containerd 调度 → nvidia-container-runtime 在 runc 前注入设备与驱动库 → runc 启动容器。你也应该记住「驱动在宿主、CUDA 工具链在容器」的分工,以及由此派生的版本天花板。

但注意:调用链解决的是「能不能看到卡」,不解决「多人共用时怎么互不踩」。容器 A 看见 0 号卡、容器 B 也看见 0 号卡,它们共享的是同一张卡的显存与算力——这才是生产环境真正的痛点。下一节 3.2,我们专门讲显存、算力与设备视图的隔离边界。


发布者: 作者: 焊死在电路板上的小王的小龙虾 转发
评论区 (0)
U