Docker 基础体系 · 第 49/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker GPU 容器:NVIDIA Runtime、设备、驱动、资源和可观测

GPU 容器不是“把一块显卡映射进容器”这么简单。一个可运行的 NVIDIA GPU 容器,至少需要同时满足以下条件:

  1. 宿主机存在可用的 NVIDIA GPU 和内核驱动
  2. Docker 能在创建容器时请求 GPU 设备
  3. NVIDIA Container Toolkit 能把设备节点、用户态驱动库和相关能力注入容器
  4. 容器内的 CUDA、深度学习框架与宿主机驱动版本兼容
  5. 资源限制、设备可见性和监控方式符合实际部署目标

本文讨论 Linux 容器边界内、现代 Docker Engine、BuildKit 和 Compose 规范下的 NVIDIA GPU 容器。Windows 容器、AMD ROCm、Intel GPU 和 Kubernetes 调度不属于本文的运行时范围,但其中部分设备隔离原理是相通的。


一、先区分四个容易混淆的对象

1. NVIDIA 驱动

NVIDIA 驱动主要运行在宿主机上,包括:

  • 内核模块;
  • /dev/nvidia* 设备节点;
  • 用户态的 NVML、CUDA Driver API 等库;
  • 负责与 GPU 交互的服务或内核接口。

驱动决定了宿主机是否能识别 GPU,也决定了宿主机能够支持的 CUDA Driver API 能力。

容器通常不携带一套独立的内核 GPU 驱动。容器中的 CUDA 用户态库通过 NVIDIA Container Toolkit 注入或使用镜像中的兼容库,但最终仍然通过宿主机内核驱动访问真实 GPU。

因此,下面这个关系成立:

容器中的 CUDA 应用容器用户态 CUDA 库宿主机 NVIDIA 驱动GPU\text{容器中的 CUDA 应用} \rightarrow \text{容器用户态 CUDA 库} \rightarrow \text{宿主机 NVIDIA 驱动} \rightarrow \text{GPU}

容器隔离了进程、文件系统和部分设备视图,但没有把物理 GPU 的内核驱动复制成一份容器私有驱动。

2. CUDA Toolkit

CUDA Toolkit 是开发和运行 CUDA 程序的一组用户态组件,例如:

  • nvcc 编译器;
  • CUDA Runtime;
  • CUDA 数学库;
  • cuBLAS、cuDNN、TensorRT 等上层库;
  • CUDA Driver API 的用户态接口。

“镜像带 CUDA”不等于“镜像带宿主机驱动”。常见的 nvidia/cuda 镜像包含 CUDA 用户态工具和库,但仍要求宿主机安装兼容的 NVIDIA 驱动。

3. GPU 设备

Linux 中 GPU 会通过设备节点暴露给用户态程序,例如:

/dev/nvidia0
/dev/nvidiactl
/dev/nvidia-uvm
/dev/nvidia-uvm-tools

具体节点取决于驱动版本、功能和设备类型。容器必须能够:

  • 看到所需的设备节点;
  • 通过 devices cgroup 访问这些节点;
  • 拥有相应的 NVIDIA 驱动能力;
  • 找到匹配的用户态库。

只把 /dev/nvidia0--device 映射进去,通常并不完整,因为 CUDA 程序还可能需要 nvidiactl、UVM 设备和多个用户态库。

4. NVIDIA Container Toolkit 与 Runtime

NVIDIA Container Toolkit 是连接 Docker 和宿主机 NVIDIA 驱动的组件。它根据 Docker 的 GPU 请求,生成或修改 OCI 容器配置,将以下内容注入容器:

  • NVIDIA 设备节点;
  • 设备访问规则;
  • 驱动相关库;
  • 用户态工具;
  • NVIDIA_VISIBLE_DEVICESNVIDIA_DRIVER_CAPABILITIES 等环境设置;
  • 必要的 OCI hook 或 runtime 配置。

这里的 NVIDIA Runtime 不应简单理解为“另一个独立的容器引擎”。它通常是 Docker 创建 OCI 容器时使用的 NVIDIA 感知运行时组件。现代 Docker 的常见入口是:

docker run --gpus all ...

而不是必须显式写:

docker run --runtime=nvidia ...

--runtime=nvidia 是较早或较底层的配置方式;--gpus 是现代 Docker Engine 的 GPU 请求接口。


二、完整数据流:从 docker run --gpus 到 CUDA 程序

一个 GPU 容器的创建过程可以抽象为:

flowchart LR
    A[用户执行 docker run --gpus] --> B[Docker CLI]
    B --> C[Docker Engine]
    C --> D[OCI 容器配置]
    D --> E[NVIDIA Container Toolkit]
    E --> F[设备节点与 devices cgroup]
    E --> G[驱动库与环境变量]
    F --> H[容器进程]
    G --> H
    H --> I[CUDA Runtime / NVML]
    I --> J[宿主机 NVIDIA 驱动]
    J --> K[物理 GPU]

关键路径如下:

  1. Docker CLI 将 --gpus 转换为 Docker Engine 的 GPU 请求;
  2. Docker Engine 在创建容器时把 GPU 请求传给运行时;
  3. NVIDIA Container Toolkit 解析设备选择和 capability;
  4. Toolkit 生成设备节点挂载、库挂载和 cgroup 设备规则;
  5. 容器中的 CUDA 程序加载用户态库;
  6. 用户态库通过宿主机 NVIDIA 驱动访问 GPU。

因此,GPU 容器失败时,问题不一定在 CUDA 程序本身。失败可能发生在:

  • Docker 根本没有识别 GPU 请求;
  • Toolkit 没有安装或未配置;
  • 设备节点没有注入;
  • 驱动库没有注入;
  • 宿主机驱动版本不兼容;
  • CUDA 程序需要的能力没有暴露;
  • 容器内应用没有权限或找不到库。

三、配置 NVIDIA Container Toolkit

3.1 宿主机先验证驱动

在安装 Docker GPU 集成前,先在宿主机直接运行:

nvidia-smi

正常时会显示:

  • GPU 型号;
  • Driver Version;
  • CUDA Version;
  • 当前进程;
  • 显存使用情况。

这里的 CUDA Version 表示驱动声明支持的最高 CUDA 兼容版本范围,不代表宿主机安装了完整 CUDA Toolkit,也不代表容器一定使用这个版本。

如果宿主机上的 nvidia-smi 已失败,继续配置 Docker 没有意义。常见原因包括:

  • NVIDIA 内核模块未加载;
  • 驱动未正确安装;
  • GPU 被系统禁用;
  • 服务器处于虚拟化或直通配置错误状态;
  • 驱动与当前内核不匹配;
  • 权限或设备节点异常。

可以进一步检查:

ls -l /dev/nvidia*
lsmod | grep nvidia
docker version
docker info

ls -l /dev/nvidia* 用于确认设备节点存在;lsmod 用于确认内核模块已加载。设备节点存在并不等于驱动可用,但设备节点完全不存在时,容器通常不可能获得正常 GPU 访问。

3.2 配置 Docker 运行时

不同发行版的安装包名称和仓库配置有所不同,应以 NVIDIA Container Toolkit 官方文档对应发行版的安装步骤为准。安装 Toolkit 后,常见的 Docker 配置命令是:

sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

这个命令通常会修改 Docker 的 daemon 配置,使 Docker 能够使用 NVIDIA 相关运行时集成。配置完成后检查:

docker info

不要仅凭 docker info 中是否出现某个固定字符串判断所有功能。不同 Toolkit 和 Docker 版本的展示方式可能不同,最终验证应使用实际 GPU 容器。

3.3 使用最小运行测试

下面的命令直接启动一个 CUDA 基础镜像:

docker run --rm --gpus all \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi

各部分含义是:

  • --rm:进程退出后删除临时容器;
  • --gpus all:请求所有可用 GPU;
  • nvidia/cuda:...:提供 CUDA 用户态环境;
  • nvidia-smi:在容器内查询 GPU。

成功时,容器内的 nvidia-smi 应能看到宿主机 GPU。输出中的驱动版本通常来自宿主机,而 CUDA 工具和库来自镜像或 Toolkit 注入环境。

这个测试同时验证了:

Docker GPU 请求Toolkit 配置设备注入驱动库可用宿主机驱动正常\text{Docker GPU 请求} \land \text{Toolkit 配置} \land \text{设备注入} \land \text{驱动库可用} \land \text{宿主机驱动正常}

它不能验证你的模型、训练框架或 CUDA kernel 一定正确,但能排除大量基础运行时问题。


四、--gpus 如何选择设备

4.1 使用所有 GPU

docker run --rm --gpus all \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi -L

nvidia-smi -L 通常列出 GPU UUID 和型号。使用 all 只表示容器可见设备集合包含所有 GPU,不表示 Docker 会自动为多个容器做显存调度或独占分配。

4.2 按 GPU 索引选择

docker run --rm \
  --gpus '"device=0"' \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi -L

选择多个设备时:

docker run --rm \
  --gpus '"device=0,2"' \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi -L

这里出现嵌套引号,是因为:

  • 外层 shell 需要把整个参数作为一个参数传给 Docker;
  • 参数内部还包含 device=0,2 这样的 GPU 请求表达式。

更稳定的生产标识通常是 GPU UUID,而不是索引。索引可能因宿主机枚举顺序、热插拔或环境变化而改变。可以先查看 UUID:

nvidia-smi --query-gpu=index,uuid,name --format=csv

然后按 UUID 选择:

docker run --rm \
  --gpus '"device=GPU-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"' \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi -L

示例中的 UUID 必须替换为真实值。

4.3 GPU 可见性不是 GPU 使用权

容器内经常能看到:

echo "$NVIDIA_VISIBLE_DEVICES"

这个变量表示 Toolkit 为容器配置的可见设备集合。但它主要影响“程序能看到哪些 GPU”,不是一个完整的安全边界,也不是显存配额。

例如,两个容器都执行:

docker run --rm --gpus '"device=0"' ...
docker run --rm --gpus '"device=0"' ...

它们都可能访问同一块物理 GPU。Docker 不会因此自动把 GPU 显存切成两份,也不会自动阻止两个进程同时提交 CUDA 工作。


五、设备、Runtime 与 Linux 隔离机制的关系

Docker 的隔离来自多个 Linux 机制共同作用:

  • namespaces:隔离进程、挂载点、网络、主机名等视图;
  • cgroups:限制和统计 CPU、内存、进程数、I/O 等资源;
  • Mount:构造容器文件系统,并注入设备节点和驱动库;
  • devices cgroup:控制进程能够访问哪些设备类型;
  • OCI runtime:依据容器配置创建最终进程。

GPU 容器中的设备注入与普通的 bind mount 不同。普通文件挂载只解决“路径是否存在”,而设备访问还涉及:

  1. 设备节点的 major/minor 编号;
  2. 设备文件的权限;
  3. cgroup devices 访问规则;
  4. NVIDIA 驱动所需的辅助设备;
  5. 用户态库和动态链接器路径。

因此,下面这种手工方式通常不完整:

docker run --rm \
  --device=/dev/nvidia0 \
  ubuntu:22.04 \
  ...

它只尝试映射一个设备节点,不能自动完成 CUDA 所需的其他设备、库和能力注入。除非你明确知道应用只需要某个特殊设备接口,否则应优先使用:

docker run --rm --gpus all ...

5.1 --runtime=nvidia--gpus

历史上常见的写法是:

docker run --rm \
  --runtime=nvidia \
  -e NVIDIA_VISIBLE_DEVICES=0 \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi

现代 Docker 中,更推荐:

docker run --rm \
  --gpus '"device=0"' \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi

两者都可能依赖 NVIDIA Toolkit,但抽象层次不同:

  • --gpus 表达“这个容器需要哪些 GPU”;
  • --runtime=nvidia 表达“使用哪个运行时创建容器”;
  • NVIDIA_VISIBLE_DEVICES 是 Toolkit 使用的环境变量接口。

不要把三者随意叠加来“增加权限”。重复配置可能造成选择结果难以推断,尤其是在旧版 Toolkit、旧版 Docker 或自定义 daemon 配置中。

5.2 CDI 设备声明

较新的 NVIDIA Container Toolkit 可以生成 CDI(Container Device Interface)规格,使设备以标准化名称暴露。例如,环境支持时可以使用类似:

docker run --rm \
  --device=nvidia.com/gpu=all \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi

CDI 的准确可用性取决于 Toolkit、Docker Engine 和生成的 CDI 规格文件。它不是所有旧环境的默认接口。排查时应确认:

ls -l /var/run/cdi

以及 Toolkit 文档规定的 CDI 生成和刷新方式。不能因为系统安装了 NVIDIA 驱动,就假定 CDI 设备名已经存在。


六、NVIDIA Driver Capabilities:为什么 nvidia-smi 能用而应用仍失败

Toolkit 不仅选择 GPU,还会根据 capabilities 决定注入哪些驱动功能。常见能力包括:

  • compute:CUDA 计算;
  • utility:NVML 和 nvidia-smi 等管理功能;
  • graphics:OpenGL 等图形能力;
  • video:视频编解码相关能力;
  • display:显示相关能力;
  • compat32:32 位兼容库。

例如,一个只需要 CUDA 计算和 NVML 的容器可以显式设置:

docker run --rm --gpus all \
  -e NVIDIA_DRIVER_CAPABILITIES=compute,utility \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi

若只设置了 compute,某些依赖 NVML 的监控程序可能无法工作;若图形程序需要 OpenGL,却只暴露 compute,utility,则可能出现库缺失或初始化失败。

需要区分两个事实:

  • nvidia-smi 成功说明管理路径和至少一部分驱动能力正常;
  • nvidia-smi 成功不等于 CUDA kernel、cuDNN、TensorRT、OpenGL 或视频编解码路径都正常。

常见失败表现

找不到 GPU

NVIDIA-SMI has failed because it couldn't communicate with the NVIDIA driver.

通常首先检查宿主机 nvidia-smi,然后检查 Toolkit 和容器运行参数。

找不到 NVML

Failed to initialize NVML: Driver/library version mismatch

这类错误表示用户态 NVML 库与内核驱动不匹配,或者容器中错误地优先加载了不兼容的库。容器并不是把任意版本的 NVIDIA 用户态库放进去都能运行,驱动兼容关系仍然成立。

找不到 libcuda.so

libcuda.so.1: cannot open shared object file

常见原因是:

  • 没有通过 --gpus 请求 GPU;
  • Toolkit 未正确注入驱动库;
  • capability 不包含 compute
  • 镜像中的动态链接器路径或库缓存异常;
  • 容器使用了不匹配的手工库覆盖。

可以在容器中检查:

ldconfig -p | grep -E 'libcuda|libnvidia-ml'
echo "$NVIDIA_DRIVER_CAPABILITIES"
echo "$NVIDIA_VISIBLE_DEVICES"

七、宿主机驱动与容器 CUDA 的兼容关系

CUDA 应用通常依赖两类版本:

  1. 容器内的 CUDA 用户态组件版本;
  2. 宿主机 NVIDIA 驱动支持的 CUDA Driver API 版本。

简化表示为:

DhostCcontainerD_{\text{host}} \succeq C_{\text{container}}

其中:

  • DhostD_{\text{host}} 表示宿主机驱动提供的兼容能力;
  • CcontainerC_{\text{container}} 表示容器用户态 CUDA 组件所需的能力;
  • \succeq 不是简单的字符串大小比较,而是 NVIDIA 规定的兼容关系。

因此,“宿主机装了 CUDA 12.4”并不是严格的判断条件;真正重要的是宿主机驱动版本是否支持容器中 CUDA 版本的运行要求。

一个常见反例是:

宿主机驱动较旧
容器使用较新的 CUDA 镜像
nvidia-smi 可以运行
模型启动时报 CUDA driver version is insufficient

nvidia-smi 可能只使用了管理接口,而模型会调用更新的 CUDA Driver API 或特定库功能,因此两者结果不矛盾。

排查时应记录:

# 宿主机
nvidia-smi

# 容器
docker run --rm --gpus all \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  bash -lc 'nvidia-smi; ldconfig -p | grep -E "libcuda|libnvidia-ml"'

镜像 CUDA 版本、宿主机驱动版本和框架官方兼容矩阵应一起检查,不能只看其中一个数字。


八、Compose 中声明 GPU

Compose 需要表达“服务需要哪些设备”,而不是把 GPU 选择写成普通环境变量。常见写法是:

services:
  trainer:
    image: nvidia/cuda:12.4.1-base-ubuntu22.04
    command: ["nvidia-smi"]
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]

启动:

docker compose up --abort-on-container-exit

这里的关键字段是:

  • driver: nvidia:请求 NVIDIA 设备驱动;
  • count: 1:请求一块 GPU;
  • capabilities: [gpu]:声明设备能力。

也可以按设备 ID 选择:

services:
  trainer:
    image: nvidia/cuda:12.4.1-base-ubuntu22.04
    command: ["nvidia-smi", "-L"]
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              device_ids: ["0"]
              capabilities: [gpu]

countdevice_ids 表示两种不同的选择方式,不能同时指定。capabilities 是必需的,否则 Compose 无法明确设备用途。

需要注意 Compose 的版本差异:

  • 现代 Docker Compose 支持 Compose Specification 中的设备预留语法;
  • 旧版 Compose 对 deploy 的支持范围不同;
  • 某些环境支持更直接的 gpus 字段,但其版本要求和行为应以当前 Compose 文档为准;
  • Compose 只负责向 Docker Engine 提交设备请求,不替代宿主机驱动和 NVIDIA Toolkit。

验证生成的容器配置:

docker compose config
docker inspect <container-name>

重点查看容器是否真的创建了 GPU 请求、设备和相关环境,而不是只检查 YAML 能否解析。


九、GPU 资源与 cgroups:Docker 能限制什么,不能限制什么

9.1 CPU、内存和进程数仍然由 cgroups 管理

GPU 容器并不特殊到可以绕过普通资源治理。例如:

docker run --rm --gpus all \
  --cpus=4 \
  --memory=16g \
  --pids-limit=512 \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi

这些限制分别作用于:

  • --cpus=4:限制容器可使用的 CPU 配额;
  • --memory=16g:限制容器进程使用的主机内存;
  • --pids-limit=512:限制 PID 数量;
  • --gpus all:选择可见 GPU。

它们作用于不同资源维度,不存在“加了 GPU 就自动获得更多 CPU 或内存”的关系。

9.2 主机内存不等于 GPU 显存

下面是最常见的错误理解:

docker run --gpus all --memory=8g ...

这并不表示容器最多使用 8 GiB GPU 显存。--memory=8g 限制的是容器 cgroup 的主机内存,通常包括:

  • 应用堆内存;
  • Python 对象;
  • CPU tensor;
  • 文件缓存;
  • 部分 pinned memory;
  • 其他用户态内存。

GPU 显存由 GPU 驱动和 CUDA 分配器管理,通常不会作为普通 Docker memory cgroup 配额被精确限制。

因此可能出现:

容器主机内存没有达到 --memory 上限
但 CUDA 报 out of memory

这意味着 GPU 显存耗尽,而不是容器 cgroup 的主机内存耗尽。

反过来也成立:

GPU 还有大量空闲显存
容器却因 --memory 限制触发 OOM

因为应用可能消耗了过多主机内存、数据集缓存或 pinned memory。

9.3 GPU 计算时间通常不是 Docker 原生 cgroup 配额

Docker 的 CPU cgroup 能表达 CPU 配额,例如:

QCPU=4 个 CPU 核的周期预算Q_{\text{CPU}} = 4 \text{ 个 CPU 核的周期预算}

但普通 Docker GPU 请求一般表达的是:

SGPU={容器可见的 GPU 集合}S_{\text{GPU}} = \{\text{容器可见的 GPU 集合}\}

而不是:

QGPU=每秒 GPU 计算时间配额Q_{\text{GPU}} = \text{每秒 GPU 计算时间配额}

也就是说,--gpus device=0 选择了 GPU 0,但通常没有表达“最多使用 GPU 0 的 30% 计算时间”或“最多使用 4 GiB 显存”。

多个容器共享同一 GPU 时,实际竞争可能包括:

  • CUDA kernel 执行时间;
  • GPU 显存;
  • copy engine;
  • NVDEC/NVENC 视频引擎;
  • PCIe 和主机内存带宽;
  • GPU 上下文资源。

Docker 默认不会为这些资源建立完整公平调度。

9.4 MIG 是硬件分区,不是普通 Docker 限制参数

支持 MIG 的 NVIDIA GPU 可以把物理 GPU 切分成多个硬件实例。MIG 实例通常具有较明确的计算和显存边界,容器可以选择某个 MIG 设备,而不是访问整块卡。

但必须区分:

  • --gpus device=0:可能选择整块 GPU;
  • 选择 MIG UUID:选择某个 MIG 实例;
  • --memory=8g:限制主机内存,不创建 MIG 分区。

MIG 的创建、配置和生命周期由宿主机 NVIDIA 工具链与硬件能力决定,不是 Docker --memory--cpus 的替代品。生产环境中还要考虑重启后的 MIG 配置持久化、设备 UUID 变化和监控维度。

9.5 MPS、应用级限流和调度器

NVIDIA MPS、框架级 batch 限制、应用内部显存分配器和外部调度系统都可能影响 GPU 共享行为,但它们不等于 Docker Runtime:

  • Docker 负责容器创建和设备可见性;
  • 驱动负责 GPU 设备访问;
  • MIG 负责部分硬件分区;
  • MPS 负责特定 CUDA 进程的共享路径;
  • 应用和调度系统负责更高层的工作负载调度。

把这些层次混成一个“GPU 配额”概念,会导致错误的容量规划。


十、容器中的 GPU 可观测性

10.1 docker stats 不显示完整 GPU 指标

docker stats

主要显示容器的:

  • CPU 使用率;
  • 主机内存;
  • 网络 I/O;
  • 块设备 I/O;
  • PIDs。

它通常不提供 NVIDIA GPU 利用率、显存使用、编码器利用率或温度等 GPU 专属指标。

因此,GPU 容器监控至少要分成两条路径:

容器 cgroup 指标+NVIDIA GPU/NVML 指标\text{容器 cgroup 指标} + \text{NVIDIA GPU/NVML 指标}

10.2 使用 nvidia-smi 做即时检查

宿主机:

nvidia-smi

容器:

docker exec -it <container-name> nvidia-smi

按秒刷新:

watch -n 1 nvidia-smi

查询结构化指标:

nvidia-smi \
  --query-gpu=index,uuid,name,temperature.gpu,utilization.gpu,memory.used,memory.total \
  --format=csv

查询进程:

nvidia-smi pmon -c 1

这些命令适合故障现场检查,但不适合直接作为长期监控系统。原因包括:

  • 文本格式不稳定;
  • 查询频率过高可能增加管理开销;
  • 容器名、业务名和 GPU 进程之间缺乏稳定关联;
  • 多实例 MIG 的指标维度需要额外处理。

10.3 NVML、DCGM 和 Prometheus

NVIDIA Management Library(NVML)提供程序化的 GPU 管理和指标访问。nvidia-smi 本身也依赖相关管理接口。

长期监控通常使用 NVIDIA DCGM 及其 exporter,将 GPU 指标导出给 Prometheus。常见指标类别包括:

  • GPU 利用率;
  • 显存已用和总量;
  • 功耗;
  • 温度;
  • ECC 错误;
  • PCIe 状态;
  • 编码器和解码器利用率;
  • MIG 实例指标。

这条路径的因果关系是:

GPU 驱动/NVML
    ↓
DCGM
    ↓
Exporter
    ↓
Prometheus
    ↓
Grafana / 告警系统

监控 DCGM exporter 自身是否拥有 GPU 访问权限同样重要。一个 exporter 容器启动成功,不代表它已经收集到有效 GPU 指标;必须检查其日志、指标内容以及容器的 GPU 设备配置。

10.4 把 GPU 指标关联到容器

GPU 驱动通常能看到 GPU 上运行的进程 PID,但这个 PID 可能属于宿主机 PID namespace,而容器内看到的是容器 namespace 中的 PID。两者不一定相同。

因此排查一个异常进程时,可以同时执行:

docker top <container-name>
nvidia-smi

必要时在宿主机记录:

ps -fp <host-pid>
cat /proc/<host-pid>/cgroup

/proc/<pid>/cgroup 可帮助判断进程属于哪个容器 cgroup。生产监控系统通常需要结合:

  • 宿主机 PID;
  • 容器 ID;
  • cgroup 路径;
  • GPU UUID 或 MIG UUID;
  • 业务标签。

只记录“GPU 0 利用率 100%”而不记录容器和作业身份,通常无法完成故障归因。


十一、一个端到端诊断流程

下面假设容器启动后报告 CUDA 错误。应按层次从底向上排查,而不是直接修改模型代码。

第一步:验证宿主机驱动

nvidia-smi

如果失败,先修复宿主机驱动、内核模块和设备节点。

第二步:验证 Docker 基础 GPU 通路

docker run --rm --gpus all \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi

如果失败,检查:

docker info
docker version
nvidia-ctk --version

并查看 Docker daemon 日志:

journalctl -u docker --since "10 minutes ago"

第三步:验证设备选择

docker run --rm \
  --gpus '"device=0"' \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi -L

如果 all 能运行而指定设备失败,重点检查索引、UUID、MIG 状态和设备表达式的 shell 引号。

第四步:验证库和能力

docker run --rm --gpus all \
  -e NVIDIA_DRIVER_CAPABILITIES=compute,utility \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  bash -lc '
    echo "VISIBLE=$NVIDIA_VISIBLE_DEVICES"
    echo "CAPS=$NVIDIA_DRIVER_CAPABILITIES"
    ldconfig -p | grep -E "libcuda|libnvidia-ml" || true
    nvidia-smi
  '

如果设备可见但库不存在,检查 Toolkit 注入和镜像内动态链接环境。

第五步:验证 CUDA 应用

基础测试通过后,再使用实际框架镜像或应用镜像。此时如果失败,重点检查:

  • CUDA 与驱动兼容关系;
  • PyTorch、TensorFlow、JAX 或 TensorRT 的构建版本;
  • cuDNN、NCCL 等库;
  • 容器内 Python 环境;
  • 应用是否选择了错误的 GPU;
  • 显存是否已经被其他进程占用。

第六步:区分主机内存 OOM 和 GPU OOM

主机内存 cgroup 事件可通过:

docker inspect <container-name> --format '{{.HostConfig.Memory}}'
docker stats <container-name>

在使用 cgroup v2 的 Linux 系统上,也可以检查对应 cgroup 的:

memory.current
memory.max
memory.events

GPU 显存则通过:

nvidia-smi --query-compute-apps=pid,used_memory --format=csv

或者在支持的环境中使用 DCGM 指标检查。两者必须分别判断,不能看到 CUDA out of memory 就直接增加 Docker --memory


十二、启动成功但实际不可用的边界

12.1 容器启动不等于 GPU 初始化成功

容器的 PID 1 可能只是一个 shell、Web 服务或等待进程。即使容器成功创建,也可能直到业务代码第一次调用 CUDA 时才报错。

因此健康检查应验证实际能力,而不是只检查进程是否存活。例如:

HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD nvidia-smi >/dev/null || exit 1

这个检查只能验证 NVIDIA 管理路径。对于训练服务,还应增加轻量 CUDA 或框架初始化测试,否则 nvidia-smi 成功仍可能掩盖 cuDNN、NCCL 或显存分配问题。

12.2 Rootless Docker 和权限边界

Rootless Docker 的容器进程不以宿主机 root 身份运行,设备访问和 runtime 配置会受到额外限制。GPU 场景中还要考虑:

  • rootless daemon 是否能使用 NVIDIA Toolkit;
  • /dev/nvidia* 的属主和权限;
  • cgroup v2 delegation;
  • CDI 设备是否可被 rootless 环境解析;
  • 宿主机安全策略是否阻止设备访问。

因此不能简单把 rootful Docker 的配置文件复制到 rootless Docker。必须在实际运行用户下执行 GPU 测试。

12.3 容器用户身份与设备权限

即使设备节点成功注入,容器内非 root 用户也可能因设备文件权限、组权限或安全策略而无法访问 GPU。可以检查:

docker run --rm --user 1000:1000 --gpus all \
  nvidia/cuda:12.4.1-base-ubuntu22.04 \
  nvidia-smi

如果 root 可以运行、普通用户失败,需要检查容器用户的组、设备节点权限以及宿主机的访问控制策略,而不是重复安装 CUDA。

12.4 安全边界不能被高估

GPU 设备访问意味着容器进程能够调用宿主机 GPU 驱动接口。--gpus 主要解决设备可见性和运行功能,不等于把 GPU 变成一个完全隔离的虚拟设备。

生产环境仍应谨慎处理:

  • 不可信代码;
  • 共享 GPU;
  • 容器逃逸风险;
  • 驱动漏洞;
  • 容器内调试接口;
  • 是否需要 --privileged

通常不应为了“让 GPU 能用”直接添加:

--privileged

--privileged 会显著扩大容器权限,可能暴露远超 GPU 所需的主机能力。若普通 --gpus 不能工作,应先定位 Toolkit、设备规则、权限和驱动问题。


十三、BuildKit 与 GPU 构建边界

GPU 运行时和 GPU 镜像构建是两个不同问题。

普通 Dockerfile 构建步骤:

FROM nvidia/cuda:12.4.1-base-ubuntu22.04
RUN nvidia-smi

不能因为基础镜像包含 CUDA,就假定 docker buildRUN 阶段自动拥有宿主机 GPU。构建器通常运行在独立的 BuildKit worker 中,构建容器不会自动继承 docker run --gpus 的设备请求。

GPU 训练、推理和测试应优先作为运行阶段执行:

docker buildx build -t my-cuda-app:dev .
docker run --rm --gpus all my-cuda-app:dev python /app/smoke_test.py

这个流程把两个问题分开:

  1. buildx build 负责构建可分发的镜像;
  2. docker run --gpus 负责在有 GPU 的运行节点上执行测试。

某些较新的 BuildKit 版本和实验性能力支持在构建步骤中声明设备,但这依赖构建器版本、设备提供方式和安全 entitlement,不能当作普通 Dockerfile 的普遍保证。若构建确实需要 GPU,应明确验证:

  • BuildKit worker 运行在哪里;
  • worker 是否安装 NVIDIA 驱动和 Toolkit;
  • builder 是否支持设备挂载;
  • 是否启用了所需 entitlement;
  • 构建缓存是否会掩盖实际执行。

不要在没有验证的情况下把“构建阶段能访问 GPU”写进跨环境 CI 方案。


十四、生产中的状态与恢复路径

GPU 容器生命周期可以分为几个状态:

stateDiagram-v2
    [*] --> HostDriverReady: 宿主机驱动正常
    HostDriverReady --> RuntimeConfigured: Toolkit 配置完成
    RuntimeConfigured --> ContainerCreated: Docker 接受 GPU 请求
    ContainerCreated --> DeviceInjected: 注入设备与驱动库
    DeviceInjected --> AppInitialized: CUDA/NVML 初始化
    AppInitialized --> Running: 业务运行
    AppInitialized --> Failed: 版本、权限或库错误
    Running --> GPUOOM: 显存耗尽
    Running --> HostOOM: 主机内存 cgroup OOM
    Running --> DriverFault: 驱动或硬件故障
    GPUOOM --> Restarted: 释放上下文或重启应用
    HostOOM --> Restarted: 调整内存或降低负载
    DriverFault --> HostRepaired: 修复驱动或重置 GPU
    HostRepaired --> HostDriverReady
    Failed --> RuntimeRepaired: 修复配置或镜像
    RuntimeRepaired --> ContainerCreated

每个状态的恢复动作不同:

  • HostDriverReady 之前失败:修复宿主机驱动、内核模块和设备节点;
  • RuntimeConfigured 之前失败:修复 Docker daemon 与 Toolkit 集成;
  • DeviceInjected 之前失败:检查 --gpus、Compose 设备声明和 CDI;
  • AppInitialized 失败:检查库、capability、驱动兼容性和用户权限;
  • GPUOOM:降低 batch size、减少并发、释放 CUDA context、使用 MIG 或重新调度;
  • HostOOM:调整 cgroup 内存、减少 CPU 侧缓存和 pinned memory;
  • DriverFault:查看 dmesg、GPU 错误和硬件状态;必要时进行节点隔离和维护。

“重启容器”对 GPU OOM 有时有效,因为 CUDA context 会释放;但如果宿主机上仍有其他进程占用显存,重启当前容器不会解决资源竞争。如果驱动已经进入错误状态,单纯重启应用也可能无效。


十五、资源治理的一个完整算例

假设一台主机有四块 GPU,业务希望:

  • 服务 A 使用 GPU 0;
  • 服务 B 使用 GPU 1 和 GPU 2;
  • 服务 C 使用 GPU 0,但只作为共享推理服务;
  • 每个服务限制主机 CPU 和内存;
  • 不要求 Docker 原生限制显存。

可以配置:

services:
  service-a:
    image: my-inference:latest
    command: ["python", "serve.py"]
    deploy:
      resources:
        limits:
          cpus: "4"
          memory: 16G
          pids: 512
        reservations:
          devices:
            - driver: nvidia
              device_ids: ["0"]
              capabilities: [gpu]

  service-b:
    image: my-trainer:latest
    command: ["python", "train.py"]
    deploy:
      resources:
        limits:
          cpus: "16"
          memory: 64G
          pids: 2048
        reservations:
          devices:
            - driver: nvidia
              device_ids: ["1", "2"]
              capabilities: [gpu]

  service-c:
    image: my-inference:latest
    command: ["python", "serve.py"]
    deploy:
      resources:
        limits:
          cpus: "4"
          memory: 16G
          pids: 512
        reservations:
          devices:
            - driver: nvidia
              device_ids: ["0"]
              capabilities: [gpu]

从配置可以推导出:

Visible(A)={GPU0}\text{Visible}(A)=\{GPU_0\}

Visible(B)={GPU1,GPU2}\text{Visible}(B)=\{GPU_1,GPU_2\}

Visible(C)={GPU0}\text{Visible}(C)=\{GPU_0\}

但不能推导出:

VRAM(A)=12VRAM(GPU0)\text{VRAM}(A)=\frac{1}{2}\text{VRAM}(GPU_0)

因为 A 和 C 仍然共享同一块 GPU 0,Docker 没有自动进行显存分区。若 A 和 C 必须互不影响,应使用不同物理 GPU、MIG 实例,或在更高层实施调度和限流。


十六、常见误解与反例

误解一:CUDA 镜像自带驱动,所以任何宿主机都能运行

反例:

容器:CUDA 12.x
宿主机:过旧 NVIDIA 驱动
结果:镜像能拉取,nvidia-smi 可能部分工作,但应用初始化失败

镜像分发的是用户态环境,不会替代宿主机内核驱动。

误解二:--gpus 1 表示使用 1 GiB 显存

实际含义通常是请求一块 GPU,而不是显存大小。它表达的是设备集合选择:

Visible GPU Set=1|\text{Visible GPU Set}| = 1

而不是:

Visible VRAM=1 GiB\text{Visible VRAM} = 1\text{ GiB}

误解三:--memory=16g 可以防止 CUDA OOM

两者属于不同资源系统。Docker memory cgroup 管理主机内存;CUDA 分配器管理 GPU 显存。一个限制不会自动转换成另一个限制。

误解四:宿主机 nvidia-smi 正常,容器就一定正常

还可能缺少:

  • Docker GPU 请求;
  • Toolkit 配置;
  • 设备节点;
  • capability;
  • 容器用户权限;
  • 兼容的用户态库;
  • 正确的 Compose 声明。

宿主机测试只验证驱动层,不验证容器集成层。

误解五:容器看到 GPU 就拥有独占权

GPU 可见性只是访问入口。多个容器可以看到同一个 GPU,显存和计算时间仍可能互相竞争。


十七、选择接口时的取舍

在大多数现代 Docker Engine 环境中,可以按以下层次选择接口:

普通 Docker 命令

docker run --gpus all ...

适合单机运行和脚本化任务,语义直接,最容易验证。

Compose 设备声明

deploy:
  resources:
    reservations:
      devices:
        - driver: nvidia
          count: 1
          capabilities: [gpu]

适合多服务本地开发、测试和单机部署,但必须确认当前 Compose 版本对设备预留的支持。

CDI

docker run --device=nvidia.com/gpu=all ...

适合希望使用标准化设备名称、减少特定 runtime 耦合的环境,但依赖 Toolkit 生成 CDI 规格和 Docker 对 CDI 的支持。

手工 --device 和库挂载

只适用于已经明确掌握驱动设备、库和权限要求的特殊场景。它容易遗漏辅助设备和 ABI 兼容关系,不应作为普通 CUDA 应用的默认方案。


结语

NVIDIA GPU 容器的核心不是某一个参数,而是一条跨越多个层次的运行链路:

宿主机驱动NVIDIA Container ToolkitDocker GPU 请求OCI 设备与库注入容器 CUDA 应用\text{宿主机驱动} \rightarrow \text{NVIDIA Container Toolkit} \rightarrow \text{Docker GPU 请求} \rightarrow \text{OCI 设备与库注入} \rightarrow \text{容器 CUDA 应用}

其中:

  • Runtime 负责把 GPU 请求转化为 OCI 容器配置;
  • 设备决定容器能够访问哪些 GPU 接口;
  • 驱动仍然由宿主机提供,并决定兼容能力;
  • 资源治理可以继续限制 CPU、内存、PID 和 I/O,但普通 Docker GPU 请求通常不提供显存或 GPU 时间配额;
  • 可观测性必须同时观察容器 cgroup 和 NVIDIA GPU/NVML/DCGM 指标。

只要把“设备可见性”“驱动兼容性”“资源配额”和“监控归因”分别建模,GPU 容器出现问题时就能沿着明确的数据流定位,而不会把所有故障都归因于 CUDA 或 Docker。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。