AI时代的Kubernetes教程36招-第20招-Kubernetes v1.37怎么用Device Plugin和DRA调度GPU与AI设备?

把AI硬件纳入Kubernetes调度:从Device Plugin到DRA

调度器看见的是一串数字,还是一张显卡?

[读者] 数字人/推理平台的后端与平台工程师
[痛点] GPU节点看得见、分不准,MIG与整卡混部时总在抢
[现在读] 卡点已经从能不能用变成能不能被准确描述
[读完] 能画出Device Plugin到DRA的平滑演进路径
[旧方案] nvidia.com/gpu 计数的 Extended Resource
    |
    v
[新需求] MIG/vGPU切片、设备属性、健康状态、拓扑亲和
    |
    v
[冲突] 整数计数器表达不了设备语义
    |
    v
[后果] 调度成功不等于运行成功,OOM与Xid错误成为常态

我是老李,负责一个数字人平台的基础设施。集群里二十多台 GPU 机器,主力是 8 卡 A10 节点,跑三类负载:数字人实时驱动、语音克隆推理,以及周期性的模型微调。

最早的做法很朴素,所有 Pod 都写 nvidia.com/gpu: 1,把请求整卡当作默认姿势。节点上装了 NVIDIA 的设备插件,kubectl describe node 里能看到一个漂亮的整数,运维同事扫一眼就知道还剩几张卡。

变的不是负载,是成本模型。业务希望一张 A10 切成四份跑推理,微调任务继续吃整卡,两种实例在同一天节点上共存。任务是让这套混部在调度层面可描述,而不是靠人肉打标签。

冲突就在这一刻出现:调度器只认识一个整数,而运维嘴里说的是“1g.10gb 的 MIG、要和同一张父卡、别给我挑那张正在报 Xid 的卡”。

同一批设备,为什么调度器认为够用,而运行时空闲?

是继续往上叠命名约定,还是把设备语义整体交给 DRA?

如果选错,代价是集群规模越大越难回头,还是只是多写几行 YAML?

01、故事

数字人这条链路对 GPU 的要求是碎而密:一次播报请求会触发 TTS、口型驱动、画面合成三个模型,每个模型都不大,但都要独占一点显存和一点 SM。一张 A10 跑整卡推理,利用率长期在低位徘徊。

第一次尝试是横向堆 Pod,一张卡塞多个进程。结果是显存互相踩,Xid 报错隔三差五来一次,谁也说不清是谁打死了谁。

第二次尝试才是 MIG。把 A10 切成 1g.10gb 四份,每份给一个租户。设备插件以混合策略启动后,节点上多出了 nvidia.com/mig-1g.10gb 这样的资源名,调度看起来能用了。

真正的问题是:这个资源名只说了一个数字。它不说这张切片属于哪张父卡,不说它的拓扑和整卡是不是一路,更不说这块设备当前是不是健康的。所以调度成功之后,我们仍然要在节点上二次确认:这次到底落在了哪块物理设备上。

一卡切四片
片片有归属
调度只认得数
只报一个整数

02、问题

旧方案失效的第一个表现是粒度冲突。MIG 的不同 profile 必须注册成不同资源名,节点上的资源键迅速膨胀,而它们本质上描述的是同一批物理卡。

第二个表现是属性不可见。型号、拓扑、MIG profile、NUMA 亲和,调度器一概不知;它只做数值比较,不知道两个 Pod 会不会落在同一块硅片上。

第三个表现是健康不可见。一张卡开始报错,只要插件还在上报数量,调度器就继续往上放 Pod,直到运行时报错才暴露。

第四个表现是共享是假共享。time-slicing 把一张卡放大成 N 份副本,但显存与故障域没有隔离,一个进程的显存溢出会带走邻居。

业务影响很具体:数字人播报的尾延迟抖动、夜间微调任务被驱逐、故障排查从看日志变成猜设备。技术表现同样具体:Pod 一直 Pending,事件里只有 Insufficient nvidia.com/gpu;或者 Pod 跑起来了,容器里 nvidia-smi 显示的却是另一块卡。

要把它写成可验证的完成标准:给定一组带属性的需求,重复调度多次落点一致;一块设备被标记为不健康后,新 Pod 不再落到它上面;需要共同分配的一组设备不会被打散。

看得见数量
看不见属性
更看不见的
设备是否健康

03、原理

先看 Device Plugin 的最小闭环。kubelet 在 /var/lib/kubelet/device-plugins/kubelet.sock 上提供 gRPC 服务,插件启动后主动 Register,声明自己的资源名(必须是 vendor-domain/resource 形式,例如 nvidia.com/gpu)与 socket 路径。随后 kubelet 周期性调用 ListAndWatch 拿设备列表,把它累加进 Node 的 Capacity 与 Allocatable;Pod 调度到节点后,kubelet 调 Allocate 取回设备 ID,交给容器运行时注入容器。

关键在于:这套机制里没有设备这个概念,只有计数。资源名是 vendor 与 kubelet 之间的一条字符串约定,kubelet 对它是不透明的,调度器对它也只有数值语义。

反直觉判断:扩展资源用得越多,调度器对设备的理解反而越少。因为每新增一种 profile,你不是在描述设备,而是在给同一批物理设备起新名字。

再看 DRA。结构化参数模式下有三个关键对象:ResourceSlice 由 DRA 驱动发布,声明这个节点上有哪些设备、每个设备有哪些属性;DeviceClass 是集群级分类,带 CEL 选择器,决定什么样的设备算这一类;ResourceClaim 与 ResourceClaimTemplate 表达工作负载的请求,Pod 通过 resourceClaims 引用,容器通过 resources.claims 消费。

分配动作发生在调度阶段:调度器求值选择器、选出设备、把结果写进 ResourceClaim 的状态;kubelet 再据此调用驱动的 NodePrepareResources 完成设备准备。

反直觉判断:DRA 并没有让调度变简单,它只是把复杂度从运行时排查前移到了调度时描述。你必须先能把需求写清楚,调度器才可能做对。

还有一个必须牢记的边界:MIG 与 time-slicing 是两种不同性质的东西。MIG 是硬件分区,有独立显存与独立故障域;time-slicing 是时间片复用,只有看起来更多的副本数。把它们混进同一套调度语义,是绝大多数 GPU 混部事故的起点。

名字越堆叠
语义越稀薄
描述越精确
决策越笃定

04、架构

[输入] Pod 的设备需求(数量 / 属性 / 兼容组)
    |
    v
[模块] Scheduler + kubelet + DRA Driver
    |
    v
[数据/状态] ResourceSlice / DeviceClass / ResourceClaim 状态
    |
    v
[处理] 选择器求值 → 设备分配 → 设备准备与健康反馈
    |
    v
[输出] 容器拿到可见设备与隔离的算力切片

边界划分要清楚:DRA 驱动只负责如实描述设备,不负责决定给谁;调度器负责决定给谁,产出分配结果;kubelet 负责把结果落到容器里。三者职责不能混,混了就会出现“驱动替调度做了决定”的隐式行为。

收益有三个:设备属性首次进入调度决策;健康状态可以参与过滤,把不可用设备挡在调度之前;一组需要共同分配的设备不会被拆散。

代价同样有三个:节点上必须常驻驱动,运维面变宽;DeviceClass 与选择器成为需要版本管理的集群资产;老负载的迁移不是无痛的,资源名与声明式请求会在集群里长期并存。

适用条件:设备类型多、属性强、需要健康感知或成组分配时收益最大;如果只有一种整卡、单一租户,继续用扩展资源更省事。

谁能描述清
谁就能调度
描述不清时
只能靠人猜

05、实战一次

目标很克制:先在 V1 上把 GPU 正式接入调度体系,用最小可运行集合跑通,再谈 DRA。

环境与本篇相关的版本(具体版本与 API 需按当前官方文档核验):Kubernetes 1.3x 系列;NVIDIA 驱动与 nvidia-container-toolkit;容器运行时为 containerd 并配置了 nvidia runtime。依赖为 nvidia-device-plugin 组件,也可以由 GPU Operator 统一托管。

配置用一个 ConfigMap,声明混合 MIG 策略与一份共享副本:

apiVersion: v1
kind: ConfigMap
metadata:
  name: nvidia-device-plugin-cm
  namespace: kube-system
data:
  config.yaml: |
    version: v1
    flags:
      migStrategy: mixed
    sharing:
      timeSlicing:
        resources:
          - name: nvidia.com/gpu
            replicas: 2

核心实现的 DaemonSet 关键片段:

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: nvidia-device-plugin
  namespace: kube-system
spec:
  selector:
    matchLabels:
      name: nvidia-device-plugin
  template:
    metadata:
      labels:
        name: nvidia-device-plugin
    spec:
      nodeSelector:
        nvidia.com/gpu.present: 'true'
      tolerations:
        - key: nvidia.com/gpu
          operator: Exists
          effect: NoSchedule
      containers:
        - name: nvidia-device-plugin
          image: nvcr.io/nvidia/k8s-device-plugin:latest
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop:
                - ALL
          volumeMounts:
            - name: device-plugin
              mountPath: /var/lib/kubelet/device-plugins
            - name: config
              mountPath: /etc/device-plugin
      volumes:
        - name: device-plugin
          hostPath:
            path: /var/lib/kubelet/device-plugins
        - name: config
          configMap:
            name: nvidia-device-plugin-cm

启动与验证命令:

kubectl apply -f nvidia-device-plugin.yaml
kubectl -n kube-system rollout status ds/nvidia-device-plugin
kubectl get node <gpu-node> -o jsonpath='{.status.allocatable}'
kubectl -n kube-system logs ds/nvidia-device-plugin --tail=50

再用一个最小 Pod 请求切片:

apiVersion: v1
kind: Pod
metadata:
  name: gpu-smoke
spec:
  containers:
    - name: cuda
      image: nvcr.io/nvidia/cuda:12.4.0-base-ubuntu22.04
      command:
        - nvidia-smi
        - -L
      resources:
        limits:
          nvidia.com/mig-1g.10gb: 1

未在当前环境实测,以下为预期结果(示例输出):

$ kubectl get pod gpu-smoke -o wide
NAME        READY   STATUS    NODE
gpu-smoke   1/1     Running   gpu-node-03

$ kubectl logs gpu-smoke
GPU 0: NVIDIA A10 (UUID: GPU-xxxx)
  MIG 1g.10gb Device 0: (UUID: MIG-xxxx)

跑通之后要清楚它留下了什么问题:这个 Pod 只声明了要一个 1g 切片,没有声明要哪张父卡上的、要健康的那一个。这正是第 06 章要查的方向。

先跑通最小
再谈最优解
跑不通的图
都是纸上的

06、排查

诊断链一:MIG 切片明明空闲,Pod 依然 Pending。

现象是节点上 nvidia-smi 显示四个 1g 实例都空着,而 kubectl get pod 显示 Pending,事件里只有 0/20 nodes are available: Insufficient nvidia.com/gpu。

怀疑是调度器计数与节点实际状态不一致。检查动作包括查看节点的 Capacity 与 Allocatable、查看设备插件日志、查看 /var/lib/kubelet/device-plugins/ 目录下的 socket。

证据很清楚:节点的 Allocatable 里根本没有 nvidia.com/mig-1g.10gb 这个键,只有 nvidia.com/gpu;插件日志里列出的注册资源名同样没有 MIG profile 名。

根因是插件没有按混合策略上报 MIG 资源,或者 ConfigMap 更新后 DaemonSet 没有重启,插件进程仍在用旧配置注册。修复方式是修正 migStrategy 配置并滚动重启 DaemonSet,确认资源名出现在 Allocatable 中。

错误尝试: 先删掉 Pending 的 Pod 重建,再把节点上的 kubelet 重启一次。之所以错,是因为设备注册的结果由插件进程与 kubelet 的注册状态共同决定,只要插件仍以旧配置注册,重启 kubelet 只会重放同一份资源清单;而重启 kubelet 会打断节点上所有正在跑的 GPU 负载,代价与收益完全不成比例。

诊断链二:Pod 起来了,但跑着跑着报显存不足或 Xid。

现象是两个数字人渲染 Pod 落在同一张卡上,nvidia-smi 里显存占用互相顶,容器日志出现 CUDA out of memory,dmesg 里有 Xid。

怀疑是调度器的设备选择与容器实际拿到的设备不是同一个。检查动作是看容器内的设备可见性变量、看容器内 nvidia-smi -L、看 kubectl describe pod 里分配到的资源名、再比对插件 Allocate 的日志。

证据显示两个 Pod 拿到的设备 UUID 完全相同,资源名是 nvidia.com/gpu,而这张卡被 time-slicing 放大成了两份副本。

根因是扩展资源只能表达数量,无法表达必须是独立的硬件分区;time-slicing 提供的是复用,不是隔离。修复方向有两条:改用 MIG 实例资源名走硬件分区,或者把设备属性与健康纳入调度决策,也就是第 07 章的方案。

错误尝试: 给 Pod 加显存限制,认为限制了显存就不会互相踩。之所以错,是因为显存不是 Kubernetes 的一等资源,容器运行时不会因为 limits 去约束 CUDA 的内存分配;没有硬件分区或应用自身的内存管理,显存隔离根本不会发生。

先看分配链
再看设备号
猜一百次因
不如一条证

07、优化

V2 的目标很具体:让调度器在调度时就知道三件事——这是哪一块设备、它健不健康、它要不要和别的设备一起分配。

改动一,让驱动以 ResourceSlice 的形式描述设备与属性(下面的 API 组与字段需按当前官方文档核验):

apiVersion: resource.k8s.io/v1beta1
kind: ResourceSlice
metadata:
  name: gpu-node-03-mig-0
spec:
  driver: gpu.example.com
  nodeName: gpu-node-03
  devices:
    - name: mig-1g-0
      attributes:
        nvidia.com/migProfile:
          string: 1g.10gb
        nvidia.com/parentUUID:
          string: GPU-xxxx
        nvidia.com/healthy:
          bool: true

改动二,用 DeviceClass 承载什么算合格设备的判定:

apiVersion: resource.k8s.io/v1beta1
kind: DeviceClass
metadata:
  name: digital-human.mig.1g
spec:
  selectors:
    - cel:
        expression: >-
          device.attributes['nvidia.com'].migProfile == '1g.10gb' &&
          device.attributes['nvidia.com'].healthy == true

改动三,工作负载用 ResourceClaimTemplate 表达需求,Pod 只引用名字:

apiVersion: resource.k8s.io/v1beta1
kind: ResourceClaimTemplate
metadata:
  name: mig-1g
spec:
  spec:
    devices:
      requests:
        - name: slice
          deviceClassName: digital-human.mig.1g
          count: 1
---
apiVersion: v1
kind: Pod
metadata:
  name: digital-human-render
spec:
  resourceClaims:
    - name: slice
      resourceClaimTemplateName: mig-1g
  containers:
    - name: render
      image: registry.example.com/digital-human/render:1.0
      resources:
        claims:
          - name: slice

新行为是:不健康的设备不进入候选集合;需要共同分配的设备由兼容组表达,调度器要么整组给出、要么整组不给;Pod 的设备在调度阶段就已确定,运行时不再有惊喜。

验证方法(这里是方法,不是结果):把某个 ResourceSlice 里的健康属性置为 false,观察新 Pod 是否绕开该设备;重复调度多次,检查分配结果的落点是否稳定;再制造一次组内设备缺失,确认兼容组不会被拆分。

要注意,DRA 的设备兼容组、设备 Taint/Toleration 与资源健康状态等能力在不同版本间的成熟度不同,Kubernetes 1.37 上的具体形态与默认开关需按当前官方文档核验。

把判断前移
把失败提前
让调度器懂
它在分什么

08、演进

[同一输入:一个需要 1g 切片的数字人渲染 Pod]
    |
    +--[V1] 资源名计数 / 代价:属性与健康不可见
    |
    +--[V2] 选择器求值后分配 / 代价:驱动与设备模型成为集群资产
    |
[Trade-off]
得到:可描述、可过滤、可整组分配
失去:部署简单性与一部分兼容性
边界:设备种类单一、租户单一的场景,收益不足以覆盖成本

正确性:V1 只能保证数量对得上,V2 能保证设备符合声明,后者才是可验证的正确性。

稳定性:V1 的故障表现为运行时互相踩踏;V2 把不可用设备挡在调度之前,故障从运行期挪到了声明期。

复杂度:V1 的复杂度藏在运维的命名约定里,看不见但一直存在;V2 的复杂度显式落在驱动、DeviceClass 与选择器上,可评审、可版本化。

成本:V2 需要驱动常驻与设备模型维护,换来的是排障路径变短、混部事故变少。

适用范围:多 profile、多租户、需要健康感知的 GPU 池适合 V2;单纯整卡独占的小集群可以长期停留在 V1。

遗留问题:V2 并不解决共享隔离本身,time-slicing 的性质不会因为换了 API 而改变;跨节点拓扑与跨机架亲和仍然是开放问题。

旧路走得快
新路走得准
选哪一条路
看你要什么

09、洞见

9.1 资源名不是设备模型

扩展资源的本质是字符串约定,它能表达数量,不能表达身份。反直觉判断:设备接入的难点从来不是让调度器看见 GPU,而是让它理解 GPU。数量对得上,只是把问题推迟到了运行时。

9.2 调度成功不等于运行成功

V1 的时间线是调度成功、运行失败、人肉定位;DRA 的时间线是描述清楚、调度决策、运行按约定。反直觉判断:把分配决策前移到调度器,表面上让调度变重了,实际上是把最贵的那部分成本——线上排障——换成了廉价的声明式表达。

9.3 兼容组、Taint 与健康,解决的是组的问题

单个设备好选,难的是要么一起给、要么都不给。设备兼容组表达共生关系,设备 Taint/Toleration 表达排斥关系,资源健康状态表达可用性。三者拼在一起,才能描述真实机房里的设备约束。这些能力在 Kubernetes 1.37 上的具体形态与成熟度,需按当前官方文档核验。

9.4 隔离与复用是两件事,别用一套 API 混着讲

MIG 是分区,改变的是故障域;time-slicing 是复用,改变的只是排队方式。把它们当成同一种切片,是绝大多数 GPU 混部事故的起点。真正需要隔离的负载,不该被一份看起来很美的副本数说服。

名不载其实
数不见其形
先说清设备
再谈调度谁

10、系统落地

原来有什么:一个只会数卡数的 GPU 池,靠 MIG 资源名硬撑混部,出问题靠 nvidia-smi 和 dmesg 定位。

本篇新增什么:设备属性进入声明面,选择规则沉淀为集群资产,工作负载用声明式请求表达需求。

现在能做什么:把要一块 1g 的、健康的、与父卡一致的切片写成可执行的选择器,让调度器替你判断,而不是让运维在节点上碰运气。

还缺什么:驱动自身的运维(升级、故障恢复、版本对齐)、选择器的版本管理、以及共享型负载的隔离策略。

下一步演进:把设备模型与业务画像对齐,例如按业务等级划分 DeviceClass,高优先级负载绑定独占分区,低优先级负载绑定复用分区,让成本与稳定性各归其位。

先有形之器
再有名之法
器法皆备后
才谈调度智

11、小结

Q1 → 因为扩展资源只表达数量,设备属性与健康从未进入调度器视野
Q2 → 小集群继续用 Device Plugin,多 profile 与健康感知场景迁到 DRA
Q3 → 代价不是多写几行 YAML,而是设备模型成为需要长期维护的集群资产
状态 → 可按设备属性与健康状态分配 GPU 切片的最小可运行方案

数尽则见形
形明则见性
性显则见智
智定则见稳

12、作业

12.1 理解题:为什么扩展资源只能表达数量,不能表达设备身份?

参考答案:因为 kubelet 对扩展资源名不透明,它只把 ListAndWatch 上报的设备累加为 Capacity 与 Allocatable 的整数值,调度器也只做数值比较;型号、拓扑、MIG profile、健康这些语义没有任何字段可以承载,只能塞进资源名的字符串里,于是每多一种设备属性就多一个资源键。

12.2 实战题:一个 8 卡节点,要求其中 4 张以 1g 切片对外提供、4 张保持整卡,说明让调度可见的关键配置与验证命令。

参考答案:以混合 MIG 策略启动设备插件(ConfigMap 中的策略设为 mixed),滚动重启 DaemonSet 使注册生效;用 kubectl get node <node> -o jsonpath='{.status.allocatable}' 确认同时出现整卡资源名与对应的 MIG 资源名;再用一个只请求 MIG 资源的最小 Pod 验证容器内 nvidia-smi -L 只能看到对应实例,看不到整卡。

12.3 排障题:Pod 反复 Pending,事件显示 Insufficient nvidia.com/gpu,但节点上 nvidia-smi 显示有空闲设备,给出排查顺序。

参考答案:先看节点 Allocatable 里是否存在该资源键及其数值;再看插件进程是否完成注册,检查 device-plugins 目录下的 socket 与插件日志;再看插件配置是否与预期策略一致、DaemonSet 是否已按新配置重启;最后看是否存在未被释放的旧分配。只有确认资源键与数值都对得上,才去怀疑调度器侧的其他约束,例如污点与亲和性。

12.4 架构判断题:团队为了省事,把 time-slicing 的副本数从 2 调到 8,声称一张卡当八张用,请判断这个决策。

参考答案:不合理。time-slicing 只改变时间片复用与逻辑副本数,不改变显存容量、算力总量与故障域;副本数提升会让更多进程共享同一块显存池,显存溢出与相互影响的概率上升,而调度器看到的更多资源只是假象。合理做法是把需要隔离的负载放到独立硬件分区,把可容忍抖动的负载放到复用池,并明确两类负载的边界。

题干可惑人
证据不说谎
一行行日志
一条条证据链

13、思考

回到最初那个冲突:调度器看见的应该是一串数字,还是一块可描述的设备。V1 的答案是数字,它能让你很快把 GPU 接进集群;V2 的答案是设备,它要求你先想清楚需要什么样的算力,再让调度器替你判断。

可复用的工程判断是:当设备种类少于两种、租户只有一个时,用扩展资源是最划算的选择;一旦出现多 profile、多租户、健康感知或成组分配的需求,就该把设备语义从资源名里搬出来,交给能承载它的对象。

设备不会因为你给了它一个好听的名字就变得可调度,它只会在你把约束写清楚之后,才真正进入调度体系。

一念辨边界
一证定根因
一改知取舍
一役见真章

posted @ 2026-10-12 10:12  李福春  阅读(3)  评论(0)    收藏  举报