Kubernetes GPU 调度与管理的完整机制------从节点上架到 Pod 拿到 GPU
GPU 云平台系列 · 第 2 篇
第 1 篇讲了 GPU 云平台的六大挑战。这一篇聚焦其中的调度层------一个 Pod 从 kubectl apply 到拿到 GPU 并开始计算,中间经过多少层机制?每层在做什么、配置在哪里、出了问题怎么排查。
完整链路概览
scss
kubectl apply -f pod.yaml
│
▼
┌─────────────────────────────────────────────┐
│ 第一层:准入控制 (Admission) │
│ GPU 请求是否合法?配额是否够? │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 第二层:kube-scheduler 调度 │
│ Filter(过滤节点)→ Score(打分)→ Reserve(预留)│
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 第三层:kubelet + Device Plugin │
│ 分配具体 GPU 设备 → 注入环境变量 │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ 第四层:容器运行时 (nvidia-container-runtime) │
│ 挂载 GPU 设备 → 设置 cgroup → 启动容器 │
└─────────────────────────────────────────────┘
│
▼
容器内可以调用 CUDA → nvidia-smi 看到 GPU
第一层:准入控制------Pod 还没调度,先被"验身"
Pod 进入 API Server 后,在持久化之前,经过一系列 Admission Plugin 和 Webhook:
ResourceQuota:租户配额检查
yaml
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-ml-gpu
namespace: team-ml
spec:
hard:
requests.nvidia.com/gpu: "16"
limits.nvidia.com/gpu: "16"
Pod 请求的 GPU 数 + 该 Namespace 已分配的 GPU 数 > 16 → API Server 直接拒绝,Pod 不会进入调度队列。
LimitRange:强制 GPU 声明规范
yaml
apiVersion: v1
kind: LimitRange
metadata:
name: gpu-limits
namespace: team-ml
spec:
limits:
- type: Container
min:
nvidia.com/gpu: "1" # 必须至少声明 GPU
max:
nvidia.com/gpu: "8" # 单 Pod 最多 8 张
防止有人忘记声明 GPU 请求或声明一个离谱的数字("我要 1000 张 GPU")。
自定义 Admission Webhook(GPU 平台常用的增强)
准入层可以做 GPU 平台特有的校验:
markdown
1. 检查 Pod 是否既声明了 GPU 又写了 nodeName(试图绕过调度器直接指定节点)
2. 根据 GPU 型号自动注入 toleration(减轻用户心智负担)
3. 强制要求 GPU Pod 必须声明 limit=request(GPU 不支持超分)
4. 检查所选 GPU 型号在集群中是否有可用容量
第二层:调度------如何选中一个节点
2.1 Device Plugin 如何让调度器看见 GPU
GPU 不是 K8s 的内置资源。kube-scheduler 不会自己扫描 PCIe 总线。它依赖 Device Plugin 上报:
yaml
┌──────────────────────────────────────────┐
│ NVIDIA GPU Operator │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ nvidia-device-plugin (DaemonSet) │ │
│ │ │ │
│ │ 1. 启动时调用 NVML 库枚举 GPU │ │
│ │ 发现 8 张 GPU │ │
│ │ │ │
│ │ 2. 通过 Unix Socket 向 kubelet │ │
│ │ 注册扩展资源: │ │
│ │ nvidia.com/gpu: 8 │ │
│ │ │ │
│ │ 3. 持续监控,GPU 数量变化时重新上报 │ │
│ └─────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ gpu-feature-discovery (DaemonSet) │ │
│ │ 自动给节点打 label: │ │
│ │ nvidia.com/gpu.product: A100-SXM4 │ │
│ │ nvidia.com/gpu.memory: 81920 │ │
│ │ nvidia.com/gpu.nvlink: connected │ │
│ │ nvidia.com/cuda.driver: 535.xx │ │
│ └─────────────────────────────────────┘ │
└──────────────────────────────────────────┘
kubelet 收到 Device Plugin 的注册后,更新 Node 对象的 status.capacity 和 status.allocatable:
bash
$ kubectl get node gpu-node-1 -o yaml
status:
capacity:
cpu: 64
memory: 512Gi
nvidia.com/gpu: 8 # Device Plugin 上报
allocatable:
nvidia.com/gpu: 8 # 可分配数 = capacity - 系统预留
kube-scheduler 的 NodeInfo 缓存会定时同步这些数据,调度决策基于缓存的资源可用量。
2.2 Filter 阶段:哪些节点"能用"
调度器拿到一个 GPU Pod 后,逐个节点过 Filter(过滤),任何一条不通过,节点被排除:
ini
Filter 检查清单(针对 GPU Pod):
1. nvidia.com/gpu 资源够吗?
Node.allocatable[nvidia.com/gpu] - Node.requested[nvidia.com/gpu] ≥ Pod.request
2. Taint 匹配吗?
GPU 节点通常有 taint: nvidia.com/gpu=true:NoSchedule
Pod 必须有对应的 toleration,否则被挡
3. NodeSelector / NodeAffinity 匹配吗?
Pod 要求 nvidia.com/gpu.product=A100
节点 label 必须精确匹配
4. CPU/内存/临时存储够吗?
同普通 Pod,没有特殊逻辑
5. PodAffinity / PodAntiAffinity 满足吗?
分布式训练 Pod 通常要求 PodAntiAffinity 分散到不同节点
推理 Pod 可能要求 PodAffinity 跟模型缓存在同一节点
其中第 5 点在分布式训练场景中特别关键。一个 4 节点 × 8 GPU 的训练任务,4 个 Pod 需要分散到 4 个不同节点(不能两个训练 Pod 落到同一个节点上抢同一组 NVLink):
yaml
apiVersion: v1
kind: Pod
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
training-job: llama-70b-epoch3
topologyKey: kubernetes.io/hostname # 同节点只能有一个
2.3 Score 阶段:哪个节点"最好"
通过 Filter 的节点进入 Score 阶段,调度器给每个节点打分(0-100),分最高的被选中。K8s 默认注册的评分插件中,跟 GPU 最相关的是 NodeResourcesFit:
ini
LeastAllocated 策略(默认):
Score = 100 × (allocatable - requested) / allocatable
→ 资源用得越少的节点分越高 → Pod 被"散开"调度
MostAllocated 策略(Bin Packing):
Score = 100 × requested / allocatable
→ 资源用得越满的节点分越高 → Pod 被"堆叠"到已有 Pod 的节点
GPU 场景默认用 **Bin Packing(MostAllocated)**更合理------优先把 GPU Pod 堆到已经有 GPU Pod 的节点上:
markdown
为什么 GPU 场景要 Bin Packing(紧凑)而不是 Spread(散开)?
Spread(CPU 集群推荐):
把 Pod 散开 → 每个节点负载均衡 → 容错性好
但对 GPU:散开 = 每个节点都有几块 GPU 被占 → 碎片化严重
新来的多卡任务找不到连续的完整 GPU → Pending
Bin Packing(GPU 集群推荐):
把 Pod 尽量堆到少数节点 → 释放出完全空闲的节点
新来的多卡任务可以拿到一整台空闲机器的全部 GPU
通过 KubeSchedulerConfiguration 配置:
yaml
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: gpu-scheduler
plugins:
score:
enabled:
- name: NodeResourcesFit
weight: 10
pluginConfig:
- name: NodeResourcesFit
args:
scoringStrategy:
type: MostAllocated # 对 GPU 资源用 Bin Packing
resources:
- name: nvidia.com/gpu
weight: 10 # GPU 权重最高
- name: cpu
weight: 1
- name: memory
weight: 1
这样配置后,调度器对 nvidia.com/gpu 使用 MostAllocated(堆叠 GPU Pod),对 CPU/内存可以继续用 LeastAllocated。
2.4 Reserve 阶段:预留 GPU"名额"
Score 最高的节点被选中后,调度器进入 Reserve 阶段------它还没真正分配 GPU,只是在调度器的内部状态里"占位":
yaml
调度器缓存(Reserve 后):
Node gpu-a100-01:
nvidia.com/gpu: 8 allocatable, 6 requested(含当前 Pod 占的 2 张)
实际节点上:GPU 还是 8 张全空闲(kubelet 还没收到指令)
这个预留动作是乐观锁 ------调度器认为这个节点能用,但最终分配由 kubelet 决定。如果 kubelet 分配时发现 GPU 不可用了(Device Plugin 在调度间隙更新了状态),Pod 会进入 UnexpectedAdmissionError 状态,触发重新调度。
第三层:kubelet + Device Plugin ------ 把"调度决策"变成"具体 GPU 设备号"
3.1 kubelet 调 Device Plugin 的 Allocate 接口
调度器把 Pod 绑定到节点后,kubelet 开始创建 Pod。对于 GPU 资源请求,kubelet 调用 Device Plugin 的 Allocate() gRPC 接口:
javascript
kubelet:
"DevicePlugin,这个 Pod 请求了 nvidia.com/gpu: 2,
给我分配 2 张可用的 GPU"
nvidia-device-plugin:
1. 查看本节点哪些 GPU 还没被分配
2. 选出 2 张 GPU(如 GPU-0 和 GPU-3)
3. 返回:
- 设备路径: /dev/nvidia0, /dev/nvidia3
- 环境变量: NVIDIA_VISIBLE_DEVICES=GPU-xxx,GPU-yyy
- 挂载点: /usr/local/nvidia 等驱动文件路径
这些信息被 kubelet 写入容器的 OCI spec,传给容器运行时。
3.2 GPU 分配策略
nvidia-device-plugin 支持多种分配策略,通过 ConfigMap 配置插件行为:
yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: device-plugin-config
data:
config.yaml: |
version: v1
flags:
migStrategy: mixed
sharing:
timeSlicing:
resources:
- name: nvidia.com/gpu
replicas: 8
这份 ConfigMap 管两件事(示例把两个开关都开了,实际按需配一个即可):
flags.migStrategy:MIG 上报策略开关------none(忽略 MIG,整卡上报)/single(所有实例同规格,统一上报为nvidia.com/gpu)/mixed(混合规格,逐个上报为nvidia.com/mig-*)sharing.timeSlicing.replicas: 8:把一张卡逻辑切成 8 份时间片,轮流分给 Pod 用
策略一览("Pod 侧声明"列 = Pod 的 resources.requests 里要写的资源名):
| 分配策略 | Pod 侧声明 | 含义 | 隔离级别 |
|---|---|---|---|
| 默认(整卡) | nvidia.com/gpu: 1 |
整张卡 | 物理独占 |
| MIG | nvidia.com/mig-1g.10gb: 1 |
一个 MIG 实例:1/7 算力 + 10GB 显存 | 硬件隔离 |
| Time-Slicing | nvidia.com/gpu: 1 |
逻辑 1/8 张卡(8 来自上面配置的 replicas) |
无隔离 |
| MPS | nvidia.com/gpu: 1 |
共享 GPU 算力 | 显存不隔离 |
注意:MIG 行的资源名 nvidia.com/mig-1g.10gb 不是这份 ConfigMap 写出来的 。插件只是被 migStrategy 告知"用哪种方式上报",具体叫什么名字,取决于 GPU 硬件上实际切了哪些 MIG 实例------链路见下。
MIG 是什么?配置分三层
MIG(Multi-Instance GPU,多实例 GPU)把一张物理 GPU 按硬件边界切成多个互相隔离的"小 GPU",每个实例有独立的显存和计算核心,故障也互不影响------和 Time-Slicing 的"轮流用"是本质区别:切完就是真隔离。前提是型号支持(A100/A30/H100 这类支持;V100、T4 不支持,只能走 MPS/Time-Slicing),且 MIG 设备透传进容器依赖 nvidia-container-toolkit 运行时(第 4 层的主角之一)。
MIG 要用起来,要打通三层,缺一不可:
| 层 | 在哪配 | 做什么 |
|---|---|---|
| ① 硬件层 | GPU 节点上 nvidia-smi(GPU Operator 环境由 mig-manager 按配置自动执行) |
开 MIG 模式、建实例------资源名真正的"来源"在这 |
| ② 插件层 | 本节的 ConfigMap migStrategy |
只决定插件怎么上报,不产生资源名 |
| ③ 调度层 | Pod 的 resources.requests |
按资源名请求,与 ① 建出来的实例对得上才能调度 |
硬件层手动做是这样(A100-80GB 为例,切 2 个 1g.10gb 实例):
bash
nvidia-smi -mig-mode 1 # 开启 MIG 模式(需重启 GPU/驱动后生效)
nvidia-smi mig -cgi 1g.10gb,1g.10gb -C # 创建实例
之后插件(mixed 模式)扫 NVML,发现"这张卡上有 2 个 1g.10gb 实例",就向 kubelet 上报 nvidia.com/mig-1g.10gb: 2;你切的是别的规格,上报的就是对应的名字------插件不造名字,只翻译硬件现状 。所以完整的资源名先出现在 nvidia-smi mig 的命令行里,再出现在 Pod 的 requests 里,ConfigMap 里反而找不到它。写 Pod 前先 nvidia-smi mig -lgi 看一眼这张卡上实际有什么实例,别凭记忆写。
为什么 MIG 的 1g 是 1/7,Time-Slicing 却是 1/8?
两个分母来源完全不同:
- MIG 的 7 是硬件写死的 :A100/H100 的计算部分按物理单元组边界切,上限就是 7 份,命名
1g~7g表示"7 份里的第几份"。所以哪怕只建了 2 个实例,1g 实例依然是 1/7 算力,分母不变。 - Time-Slicing 的 8 是你在 ConfigMap 里写的 :
replicas: 8想改 4、改 16 都行,纯软件分时,改配置即可。
显存是另一套独立的切片(A100 40GB 版每档 5GB、80GB 版每档 10GB),所以名字里 g(算力份数)和 gb(显存大小)各算各的------同样是 1g 算力,40GB 卡上叫 1g.5gb,80GB 卡上叫 1g.10gb,两段拼在一起才是完整资源名。
3.3 分配失败回退
bash
# kubelet 日志中分配失败的典型输出:
"Failed to allocate GPU: no available GPUs meeting request:
requested 2 GPUs with label nvidia.com/gpu.product=A100,
found 1 available"
原因通常是其他 Pod 在调度窗口内先一步抢到了 GPU(调度器刚说有 2 张,但 kubelet 调 Allocate 时只剩 1 张)。Pod 状态变为 UnexpectedAdmissionError,scheduler 重新调度。
第四层:nvidia-container-runtime ------ 把 GPU 设备挂进容器
4.1 容器运行时的 GPU 挂载链
K8s 默认用 containerd 做容器运行时。containerd 通过 runc 创建容器。GPU 挂载的关键是 nvidia-container-runtime------它是 runc 的一个包装:
java
kubelet
→ CRI Plugin (containerd)
→ containerd-shim
→ nvidia-container-runtime ← 拦截容器创建
→ nvidia-container-runtime-hook ← OCI prestart hook
→ 调用 libnvidia-container ← 挂载 GPU 设备 + 驱动库
→ runc 启动容器
nvidia-container-runtime-hook 在容器启动前注入 GPU 相关的挂载和环境变量:
bash
容器内被自动注入:
/dev/nvidia0 ← GPU 设备文件
/dev/nvidiactl ← NVML 控制设备
/dev/nvidia-modeset ← 显示模式内核驱动
/dev/nvidia-uvm ← 统一虚拟内存
/usr/lib/x86_64-linux-gnu/libcuda.so ← CUDA 驱动库
/usr/lib/x86_64-linux-gnu/libnvidia-ml.so ← NVML 库
NVIDIA_VISIBLE_DEVICES=GPU-xxx,GPU-yyy ← 容器内 nvidia-smi 只看得到分配的 GPU
NVIDIA_DRIVER_CAPABILITIES=compute,utility ← 开放的能力
4.2 一个关键的安全设计:NVIDIA_VISIBLE_DEVICES
nvidia-smi 在容器内只显示被分配到的 GPU,不是节点的全部 GPU:
bash
# 节点上有 8 张 GPU
$ kubectl exec gpu-pod -- nvidia-smi -L
GPU 0: NVIDIA A100-SXM4-80GB (UUID: GPU-xxx) # 只看到分配的 2 张
GPU 1: NVIDIA A100-SXM4-80GB (UUID: GPU-yyy)
# 其余 6 张不可见
这个过滤是在 nvidia-container-runtime 中实现的------挂载 /dev 时只暴露被分配的 GPU 设备文件,CUDA 库会读取 NVIDIA_VISIBLE_DEVICES 来限制可见设备。
第五层:GPU Operator ------ 把上面四层全部自动化
手动部署 Device Plugin、DCGM、Feature Discovery、Driver Container 等组件很繁琐。NVIDIA GPU Operator 用一个 Helm Chart 搞定整个 GPU 节点的生命周期管理:
bash
helm install gpu-operator nvidia/gpu-operator \
--namespace gpu-operator --create-namespace \
--set driver.enabled=true \ # 自动安装 GPU 驱动(DaemonSet)
--set mig.strategy=mixed \ # 启用 MIG
--set devicePlugin.config.name=device-plugin-config
GPU Operator 部署的组件一览:
| 组件 | 类型 | 作用 |
|---|---|---|
| nvidia-driver-daemonset | DaemonSet | 在 GPU 节点上安装/更新 NVIDIA 驱动 |
| nvidia-device-plugin | DaemonSet | 向 kubelet 注册 GPU 扩展资源 |
| gpu-feature-discovery | DaemonSet | 自动检测 GPU 型号并打 Node Label |
| nvidia-dcgm-exporter | DaemonSet | GPU 指标导出(温度/功率/ECC 错误等 Prometheus 指标) |
| nvidia-mig-manager | DaemonSet | MIG 分区策略管理(定期按配置重新切分 MIG) |
| nvidia-operator-validator | Job | 部署后验证 GPU 环境是否可用 |
| sandbox-validator | DaemonSet | 持续运行时验证(GPU 健康检查) |
驱动管理:不用登录每台机器跑 apt install
ini
传统方式:
新 GPU 节点上架 → SSH 登录 → apt install nvidia-driver-535
→ 版本不对 → 卸载重装 → 重启 → 90% 的节点上架时间花在驱动问题上
GPU Operator 方式:
新 GPU 节点上架 → 打 label: nvidia.com/gpu.present=true
→ GPU Operator 自动派 Driver DaemonSet Pod 到该节点
→ Pod 内安装/编译驱动 → 自动加载内核模块 → Done
不需要 SSH,不需要重启(大部分情况下)
GPU 节点全生命周期
markdown
节点上架:
1. 服务器到货,装好 OS 和 K8s
2. 打 label: nvidia.com/gpu.present=true
3. GPU Operator 自动部署驱动 + Device Plugin + DCGM
4. nvidia-operator-validator 跑验证
5. 验证通过 → node 自动变为 Ready + nvidia.com/gpu 资源可分配
节点运行中:
1. DCGM Exporter 持续采集指标
2. Node Problem Detector 监听 DCGM 指标 → 异常自动打 taint
3. MIG Manager 监控 MIG 配置(如有人手动改了 → 自动恢复)
节点下架:
1. kubectl drain gpu-node-1 → Pod 迁移到其他 GPU 节点
2. 打完 drain → 节点可以安全关机/送修
常见调度故障排查
故障一:Pod 一直 Pending,Events 为空
bash
$ kubectl describe pod training-pod-0
Events: <none> # 调度器根本没考虑这个 Pod
原因: nvidia.com/gpu 资源没有被任何节点上报。
排查:
bash
# 检查 Device Plugin 是否正常运行
kubectl get pods -n gpu-operator -l app=nvidia-device-plugin
# 检查节点上是否有 nvidia.com/gpu
kubectl get nodes -o custom-columns=NAME:.metadata.name,GPU:.status.capacity.'nvidia\.com/gpu'
# 如果没有 → 检查 Device Plugin 日志
kubectl logs -n gpu-operator nvidia-device-plugin-xxx
# "NVML initialized. Number of devices: 0" → 驱动问题,检查驱动是否加载
故障二:Pod Pending,Events 显示 "0/10 nodes are available"
bash
Events:
Type Reason Age Message
---- ------ ---- -------
Warning FailedScheduling 2m 0/10 nodes are available:
3 node(s) had untolerated taint {nvidia.com/gpu: true}
4 node(s) didn't match Pod's node affinity/selector
3 Insufficient nvidia.com/gpu
每个节点不通过的原因都列出来了:
- 3 个节点被 taint 挡住 → Pod 缺少 toleration
- 4 个节点 label 不匹配 → Pod 的 nodeSelector 或 GPU 型号要求不对
- 3 个节点 GPU 不够 → 空闲 GPU 数 < Pod 请求数
故障三:Pod 调度成功但启动失败,"Failed to allocate GPU"
bash
Events:
Type Reason Age Message
---- ------ ---- -------
Warning UnexpectedAdmissionError 5s failed to allocate GPU devices
原因: 调度器和 kubelet 之间的状态不一致。调度器算的时候有 2 张空闲 GPU,但 kubelet 调 Allocate 时已经被其他 Pod 先占了。
解决: 不需要手动干预。kube-scheduler 会自动重新调度这个 Pod(Backoff 递增延迟)。
一句话总结
Kubernetes 的 GPU 调度不是单一机制------是五层协同:准入控制做合法性和配额校验,Device Plugin 上报 GPU 资源到调度器,调度器通过 Filter(资源/Taint/Label)+ Score(Bin Packing)选中节点,kubelet 调 Device Plugin 分配具体 GPU 设备号,nvidia-container-runtime 在容器启动时挂载设备文件和驱动库。GPU Operator 把驱动安装、Device Plugin 部署、MIG 管理、健康监控全打包进 Helm Chart,节点上架打一个 label 即可自动完成全流程配置。