进入驾驶舱 - Pod 内部如何“用上” GPU?

上一篇,咱们成功扮演了“资源申请者”和“调度大师”,把需要 GPU 的 Pod 送到了有 GPU 的 Node 上,k8s 也大方地给这个 Pod 分配了指定的 GPU 资源。一切看起来都很完美!

但是,别忘了,容器(Container)就像一个独立的小房间,有着自己的文件系统、进程空间,默认情况下,它是与宿主机(Node)隔离的。它里面的程序,怎么才能“摸到”并“使唤”外面那块物理 GPU 呢?光靠 k8s 的一纸“分配令”可不够。

  • 类比:想象一下,你在一个大型共享工坊 (Node) 里申请到了使用一台高级精密仪器 (GPU) 的权限。仪器就在工坊的某个位置,而你被分配在了一个独立的操作隔间 (Container) 里工作。现在的问题是,你怎么在你的隔间里接通这台仪器的电源、拿到它的操作手册(驱动、库)、按下启动按钮,并确保你用的就是分配给你的那一台,而不是邻居的?

挑战:容器的“围墙”

这就是容器使用 GPU 面临的核心挑战:隔离性。具体来说,一个运行在容器里的 GPU 应用程序(比如 TensorFlow、PyTorch 训练脚本)要想工作,通常需要:

  1. 访问 NVIDIA 驱动:它需要能和运行在宿主机(Node)上的 NVIDIA 内核驱动程序通信。
  2. 访问 GPU 设备文件:需要能“看到”并操作特定的设备文件(比如 /dev/nvidia0, /dev/nvidiactl 等)。
  3. 相应的 CUDA 库:应用程序本身是基于特定版本的 CUDA API 编写的,需要对应的 CUDA 运行时库来支撑。

这些东西默认都不在容器这个“小隔间”里。强行把驱动装进容器镜像?非常不推荐,会导致镜像臃肿、版本管理混乱,而且容器里的驱动通常也无法直接和宿主机的内核驱动交互。

解决方案:NVIDIA Container Toolkit (及相关组件)

为了优雅地解决这个问题,NVIDIA 提供了一套关键的工具——NVIDIA Container Toolkit(你可能也听过它以前的名字,比如 nvidia-docker2)。

  • 类比:你可以把 NVIDIA Container Toolkit 想象成一个神通广大的**“现场工程师”或者一个“智能转接器”**。当 k8s(通过节点上的容器运行时,如 Docker 或 containerd)要启动一个申请了 GPU 的容器时,这个“工程师”就会自动介入。

它的核心工作流程(简化版)是这样的:

  1. 前提:NVIDIA Container Toolkit 需要预先安装在所有 GPU 节点上,并与节点上的容器运行时(Docker/containerd/CRI-O)集成好。这是搭建 GPU K8s 集群时的必要步骤。
  2. 拦截启动:当 kubelet 指示容器运行时启动一个声明了 nvidia.com/gpu 资源的容器时,NVIDIA Container Toolkit 会“感知”到这个动作。
  3. 按需注入:Toolkit 会根据 k8s 分配给这个 Pod 的具体是哪块(或哪些)GPU,在容器启动的瞬间,动态地把宿主机上的一些关键组件“注入”或“映射”到容器内部:
  • 挂载驱动库:它会把宿主机上与内核驱动版本兼容的 NVIDIA 用户态驱动库 (user-space driver libraries) 挂载到容器内的特定路径下。注意,它挂载的不是内核驱动本身,而是应用程序需要调用的那些 .so 动态链接库文件。
  • 挂载设备文件:将宿主机上代表着被分配的那个 GPU 的设备文件(如 /dev/nvidia0)挂载到容器的 /dev 目录下。
  • 设置环境变量:自动在容器内设置一些环境变量,最关键的是 NVIDIA_VISIBLE_DEVICES。这个变量会明确告诉容器内的 CUDA 程序:“你只能看到和使用这块(或这几块)GPU!” 它的值通常是被分配的 GPU 的 UUID 或者索引号。这样就保证了容器之间 GPU 使用的隔离性。

通过这一系列“神操作”,容器里的应用程序就感觉自己好像“原生”地拥有了所需的 NVIDIA 环境,可以顺畅地和分配给它的 GPU 进行通信了。

容器内部需要啥?(隐式的输入:镜像构建)

NVIDIA Container Toolkit 解决了容器与宿主机驱动和设备的连接问题,但容器镜像本身也需要包含应用程序运行所需的依赖。

CUDA 工具包/库

  • 你的应用程序(如 TensorFlow, PyTorch, 或你自己写的 CUDA 代码)是基于某个 CUDA 版本编译和运行的。因此,你的容器镜像必须包含对应版本的 CUDA 运行时库(CUDA runtime libraries)。
  • 最简单的方式是直接使用 NVIDIA 官方提供的 CUDA 基础镜像来构建你的应用镜像,例如咱们之前用到的 nvidia/cuda:12.3.2-base-ubuntu22.04
  • 你也可以在自己的 Dockerfile 里,基于一个普通的基础镜像(如 Ubuntu, CentOS)手动安装所需的 CUDA Toolkit 版本。

关键:版本兼容性

(划重点!这是最常见的坑之一!)

容器镜像里的 CUDA Toolkit 版本 必须与宿主机 Node 上的 NVIDIA 驱动版本 兼容!

  • 类比:这就像你想在一台装了 Windows XP 的老爷机上运行一个需要 Windows 11 最新特性的软件,多半会失败。反之亦然。

NVIDIA 官方会提供一个兼容性列表,明确指出哪个驱动版本支持哪些 CUDA Toolkit 版本。在构建镜像和准备节点环境时,务必参考这个列表,确保版本匹配,否则你的程序在容器里很可能无法识别或使用 GPU,并报出各种奇怪的错误。

应用程序代码

当然,最后还需要你的实际应用程序代码(比如 Python 脚本、编译好的二进制文件等)也包含在镜像里。

验证:容器“看”到 GPU 了吗?(输出)

说了这么多,怎么确认容器里的程序真的能用 GPU 了呢?最常用的方法就是在运行的 Pod 内部执行 nvidia-smi 命令。

执行验证(输出检查)

假设你的 Pod cuda-test-pod 正在运行,执行以下命令:

kubectl exec cuda-test-pod -- nvidia-smi

预期输出

如果一切正常,nvidia-smi 命令应该会成功执行,并只显示分配给这个 Pod 的那块 GPU 的信息。例如,如果这个 Pod 只申请了 1 块 GPU,即使宿主机上有 4 块卡,nvidia-smi 的输出也应该只列出 1 块卡。这得益于 NVIDIA_VISIBLE_DEVICES 环境变量的隔离作用。

输出大概长这样:

+-----------------------------------------------------------------------------+
| NVIDIA-SMI 525.125.06   Driver Version: 525.125.06   CUDA Version: 12.0     |
|-------------------------------+----------------------+----------------------+
| GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
|                               |                      |               MIG M. |
|===============================+======================+======================|
|   0  Tesla T4            On   | 00000000:00:1E.0 Off |                    0 |
| N/A   42C    P8    10W /  70W |      0MiB / 15360MiB |      0%      Default |
|                               |                      |                  N/A |
+-------------------------------+----------------------+----------------------+

+-----------------------------------------------------------------------------+
| Processes:                                                                  |
|  GPU   GI   CI        PID   Type   Process name                  GPU Memory |
|        ID   ID                                                   Usage      |
|=============================================================================|
|  No running processes found                                                 |
+-----------------------------------------------------------------------------+

如果你能在 Pod 内部成功执行 nvidia-smi 并看到类似上面的输出(特别是 GPU 信息部分),那么恭喜你,容器已经成功“接入”GPU 驾驶舱了!

完整流程回顾

现在我们把从申请到使用的整个链条串起来:

  1. 你(用户): 编写 Pod YAML,在 resources.limits 中声明 nvidia.com/gpu: 1
  2. K8s Scheduler: 找到一个有空闲 GPU 且装好了 NVIDIA 驱动和 Container Toolkit 的 Node,将 Pod 调度上去。
  3. Kubelet (在 Node 上): 指示容器运行时(如 containerd)启动 Pod 中的容器。
  4. NVIDIA Container Toolkit (在 Node 上): 拦截启动请求,根据分配结果(比如分配的是节点上的第 0 号 GPU),将宿主机的驱动库、设备文件 /dev/nvidia0 等挂载到容器内,并设置 NVIDIA_VISIBLE_DEVICES=0
  5. 容器运行时: 启动容器(该容器镜像是基于兼容的 CUDA 版本构建的)。
  6. 你的应用 (在容器内): 调用 CUDA API -> 使用容器内的 CUDA 库 -> 通过注入的驱动库与 /dev/nvidia0 设备文件通信 -> 最终指令发送给宿主机内核驱动 -> 在物理 GPU 上执行计算!

看,虽然中间环节不少,但NVIDIA Container Toolkit 的自动化处理,整个过程对容器内的应用来说是相对透明的。

常见“拦路虎”

如果在容器里无法使用 GPU,可以检查以下常见问题:

  • 版本不兼容:宿主机驱动版本与容器内 CUDA Toolkit 版本不匹配(最常见!)。
  • 节点环境问题:GPU 节点上没有正确安装 NVIDIA 驱动或 NVIDIA Container Toolkit,或者 Toolkit 没有和容器运行时正确集成。
  • 镜像问题:构建的容器镜像里忘记包含所需的 CUDA 运行时库。
  • K8s 配置问题:虽然少见,但也可能 Pod 被调度到了一个没有 GPU 或环境配置错误的节点上(可以通过节点标签、污点等方式避免)。

小结与展望

这一篇,我们深入到了 Pod 的“驾驶舱”内部,理解了应用程序是如何在隔离的容器环境中,通过 NVIDIA Container Toolkit 这位“智能工程师”的帮助,最终成功访问并使用分配给它的 GPU

现在,咱们的应用终于能在 k8sPod 里,欢快地调用 GPU 进行计算了!计算是跑起来了,但新的问题也来了:

  • GPU 用得怎么样?是 996 模式火力全开,还是大部分时间在摸鱼划水?
  • 显存用了多少?会不会爆显存?
  • 温度怎么样?会不会过热降频?
  • 如果跑了很多 GPU 任务,怎么统一看它们的资源使用情况?

这些问题都指向了下一个重要主题——监控(Monitoring)。如何有效地监控 k8s 集群中的 GPU 资源使用情况?

这就是咱们下一篇要探讨的内容:《精打细算 - GPU 监控与资源管理进阶》。准备好拿起“放大镜”和“听诊器”,给我们的 GPU 做个体检吧!