Kubernetes GPU 调度与管理的完整机制——从节点上架到 Pod 拿到 GPU

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 即可自动完成全流程配置。

相关推荐
子林super8 小时前
MySQL三大日志(binlog、redo log和undo log)详解
面试
小楼昨夜又东风12610 小时前
一文理清K8s PVC挂载完整流程
容器·rpc·kubernetes
小猿姐11 小时前
KubeBlocks Enterprise v2.3 发布!
redis·kubernetes·aiops
东风微鸣14 小时前
如何发现 K8s 集群里的无用资源?
云原生·容器·kubernetes
救救孩子把14 小时前
AI面试八股文-03_3-RAG 系统面试题
人工智能·redis·面试·agent·rag
flytalei15 小时前
从YAML到Nginx服务访问:动手理解 Kubernetes Pod、Deployment 与 Service
运维·nginx·kubernetes
十年Java程序媛15 小时前
Java 内部类全解|成员 / 静态 / 匿名内部类,理清和 Lambda 的区别
java·spring boot·面试
用户6760811491116 小时前
个人图书室
面试
柒和远方16 小时前
企业级多模态知识库:从文件入库、跨模态检索到可追溯回答
面试