TT Lab
开始
学习 学习路径 课程

GPU Operator 与时间片

对齐驱动版本——为什么镜像标签里塞着内核版本

在 TT Lab 中继续学习

一句话总结

以容器形式部署的 GPU 驱动,实际做的事是把模块加载到主机内核中,所以内核一变,驱动也必须重新构建(因此预编译镜像的标签里包含内核版本);而要加载驱动,必须先腾空该节点上的 GPU 工作负载(因此 PodDisruptionBudget 会阻止升级)。

为什么这是个问题

普通的容器运行在隔离的用户空间里。无论主机上装了什么,容器都使用镜像里的库,所以“做成容器就与主机无关了”这种直觉是成立的。

驱动却不是这样。NVIDIA 文档这样解释驱动升级需要特别考虑的原因:驱动内核模块在每次驱动容器重新启动时,都必须先卸载再重新加载。 这个过程分为五步——停止所有使用驱动的客户端,卸载当前的内核模块,启动新的驱动 Pod,加载新的模块,再重新启动客户端。

由此得出两个性质。第一,驱动依赖于主机内核。 内核模块必须针对内核版本编译,所以内核每升一个版本,就需要另一个针对该内核的驱动。第二,更换驱动一定会影响该节点上的 GPU 工作负载。 因为要卸载模块,就必须没有任何进程在使用它。

工作原理——镜像标签就是契约

默认方式的驱动容器,会在节点上下载内核头文件和编译器,在启动时构建模块。 这需要联网,耗时,还占用 CPU。所以 NVIDIA 另外提供了预编译的驱动容器。文档指出,这一优势在联网受限的地方和资源紧张的地方尤其有价值。

预编译镜像的标签是这样的。

<driver-branch>-<linux-kernel-version>-<os-tag>

예: 525-5.15.0-69-generic-ubuntu22.04

三段信息被写进了一个标签,这就是这种方式的契约。 驱动分支相同而内核版本不同,就是不同的镜像;内核版本相同而操作系统不同,又是另一个镜像。所以在运维中,下面这些事全都成了“镜像工作”。

如果没有,会怎样暴露出来呢?只有那个节点上的驱动 Pod 因为拉取不到镜像而失败。 其他节点完好无损,所以集群层面的告警不会响,只是那个节点的 GPU 悄悄掉了。文档明确写道,受支持的组合是用表格规定的,如果使用列表中没有的内核变体,就必须自己构建镜像,并推送到自己的注册表。

所以成熟的团队会把“我们已有的驱动镜像列表”当作台账,定期与节点当前的组合对照。 一旦排定了内核升级计划,就先运行这个对照,提前构建缺失的组合。不遵守这个顺序,就会把节点腾空之后干等镜像。

CUDA 只向前兼容

驱动和 CUDA 运行时之间也有版本问题,而且方向只朝一边开放。旧的 CUDA 容器可以在新驱动上正常运行,反过来则不行。 因为驱动不认识比自己更晚发布的 CUDA 运行时。

在实际工作中,这意味着很简单的一件事。开始使用新的框架镜像时,首先要看集群中最低的驱动版本能否承载这个镜像。然后把这个要求写进 Pod 规约,而不是靠人的记忆——在 gpu-feature-discovery 添加的 nvidia.com/cuda.driver.major 标签上设置 nodeAffinity 条件,就会在调度阶段被拦住,而不是去了不匹配的节点、在运行中失败。把失败提前,正是这个条件的价值。

升级停住的地方

要加载驱动,必须撤下该节点上的 GPU 工作负载,撤下要经过 eviction API。而 eviction API 前面有 PodDisruptionBudget。当前存活的数量一旦降到预算以下,API 就会拒绝请求。

error when evicting pods/"trainer-..." (will retry after 5s):
Cannot evict pod as it would violate the pod's disruption budget.

kubectl drain 会先 cordon 节点,再逐个 evict Pod,所以即使被拦住,节点也已经处于不可调度状态。 如果在这里没有察觉,这个节点就会停在一个不上不下的状态:既不接收新 Pod,驱动也加载不上。不带 --timeout 运行的 drain 会永远重试,所以从屏幕上看只是“很慢”。

GPU Operator 的升级控制器在自动化这个过程的同时,把进度状态写在节点标签 nvidia.com/gpu-driver-upgrade-state 中。文档定义的状态如下。

状态 含义
upgrade-required 驱动 Pod 不是最新的
cordon-required 轮到把节点标记为不可调度
wait-for-jobs-required 等待指定的 Job 结束
pod-deletion-required 删除已分配 GPU 的 Pod
drain-required 仅删除 Pod 不够,需要 drain 节点
pod-restart-required 重启驱动 Pod,加载新版本
validation-required 验证新驱动
uncordon-required 让节点重新恢复为可调度
upgrade-done 已完成

这个标签的价值不在于状态名称本身,而在于能用一行看出停在了哪里。 停在 cordon-required 的节点和停在 pod-deletion-required 的节点,原因是不同的。把所有节点一次性提取出来看,就能立刻看出在哪个阶段堆积了多少。

文档特别强烈警告的是 drain.enable。默认值是关闭,是有原因的——drain 会把与 GPU 无关的工作负载也全部从这个节点上赶走。 文档写道,应先调整删除 GPU Pod 的设置,只有这样仍不够时才开启 drain,并用 podSelector 缩小范围。

在现场相遇的样子

第一,“只有一个节点看不到 GPU”。 可能是只有那个节点的内核升级了,也可能是只有那个节点是后来加入的,镜像不同。确认的办法,只要把该节点的内核标签和驱动 Pod 的镜像标签并排放在一起看就行了。

第二,“升级在一个节点上已经停了三个小时”。 无论怎么读驱动日志都找不到答案,原因在于问题并不出在驱动上。可能是 PDB 在拒绝 eviction,也可能是控制器被指定要等待的 Job 一直没有结束。先看升级状态标签,3 秒就能搞定。

第三,预装的驱动。 在把驱动预先装进镜像的团队里,Operator 并不管理驱动。文档也写道,Operator 不管理预先安装在主机上的驱动的生命周期。 这看起来很省事,但版本管理就整个变成了人的责任,所以哪一种更好,取决于团队的镜像流水线成熟度。

第四,本环境诚实的局限。 实验中并不会真的安装驱动。没有 GPU,没有内核模块,也没有 nvidia-smi。所以内核版本和驱动版本用节点标签来表示,升级则用修改这些标签来表示——在真实的集群中,调度器和 Operator 所看的,归根结底也是这些标签。而另一方面,节点添加、PodDisruptionBudget 的判定、eviction 被拒绝、drain 和 uncordon,都由真实的控制平面如实执行。

参考文档

下一项实验要做什么

以节点标签作为事实的来源,制作计算节点所需驱动镜像标签的工具,以及把它与公司内部镜像列表对照的检查器。增加一个节点,确认检查器会先告诉你只有这个节点缺少相应的组合;把 CUDA 要求写成 nodeAffinity,看到不匹配的要求在调度阶段被拦住。然后在经过 PodDisruptionBudget 真正拒绝 drain 的位置之后,从确保镜像到 uncordon,按顺序把升级流程完整走一遍。