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

相关推荐
池以遇1 小时前
云原生——k8s中的微服务
微服务·云原生·kubernetes
^酸酸2 小时前
Kubernetes 运维实战:临时容器、端口转发与资源管理详解
运维·容器·kubernetes
奇特認2 小时前
kubernetes 微服务
微服务·容器·kubernetes
专注仿真3 小时前
Go操作Kubernetes API
贪心算法·golang·kubernetes
UseLessQQ3 小时前
云原生 kubernetes 中的service
云原生·容器·kubernetes
程序员-Benothing3 小时前
MySQL 中的 Log Buffer 是什么?它有什么作用?
数据库·mysql·面试
大大大大晴天️3 小时前
把湖仓一体放进 K8s:Iceberg、Hudi、Paimon 如何重塑云原生大数据架构
大数据·云原生·kubernetes
Phil32317 小时前
开源一个 Windows 实时面试助手:听译、置顶提词、按需生成英文稿
面试·github
汉堡大王952719 小时前
面试官:讲讲归并排序 —— 为什么它能在面试里反复出现?
前端·javascript·面试