Kubernetes GPU 自动伸缩:Karpenter、KAI Scheduler 与 Gang Scheduling 本节摘要:GPU 自动伸缩不是一层,是三层。Karpenter 负责动态供给节点(1 分钟内,比 Cluster Autoscaler 快约 40%);KAI Scheduler 处理 gang 调度、拓扑感知与分层队列——它阻止「8 卡只来了 7 张、其余 7 张空转等最后一张」的局部分配陷阱;应用层自动伸缩器(NVIDIA Dynamo Planner、llm-d Workload Variant Autoscaler)依据推理专用信号(队列深度、KV 缓存利用率)做伸缩,而非 CPU 或 DCGM 占空比。
本节摘要:GPU 自动伸缩不是一层,是三层。Karpenter 负责动态供给节点(1 分钟内,比 Cluster Autoscaler 快约 40%);KAI Scheduler 处理 gang 调度、拓扑感知与分层队列——它阻止「8 卡只来了 7 张、其余 7 张空转等最后一张」的局部分配陷阱;应用层自动伸缩器(NVIDIA Dynamo Planner、llm-d Workload Variant Autoscaler)依据推理专用信号(队列深度、KV 缓存利用率)做伸缩,而非 CPU 或 DCGM 占空比。经典的 HPA 陷阱在于:
DCGM_FI_DEV_GPU_UTIL是占空比度量——100% 可能是 10 个请求也可能是 100 个;vLLM 又会预分配 KV 缓存内存,所以内存指标永远不触发缩容。本节教你把三层组合起来,并避开 Karpenter 默认WhenEmptyOrUnderutilized策略——它会中途干掉正在推理的 GPU 任务。
对应原课程:Phase 17 · Lesson 03 ·
03-gpu-autoscaling-kubernetes(原英文phases/17-infrastructure-and-production/03-gpu-autoscaling-kubernetes/docs/en.md)。
阅读完本节,你应当能够:
DCGM_FI_DEV_GPU_UTIL 是 vLLM 错误的 HPA 信号,并说出两个替代(队列深度、KV 缓存利用率)。WhenEmptyOrUnderutilized),并说出 2026 年的安全替代。你的团队在 Kubernetes 上线了一个 LLM 服务。你用 DCGM_FI_DEV_GPU_UTIL 做 HPA 信号。白天业务高峰,服务一直顶在 100% 利用率。HPA 却从不扩容——它以为你已经满了。你手动加副本,TTFT 降下来了。HPA 还是不动。信号在骗你。
与此同时,你用 Cluster Autoscaler 管节点。凌晨两点来了一个百万 token 的长提示,集群花 3 分钟供给一个节点,请求超时。
再来,你部署一个要 8 张 GPU、跨 2 个节点的 70B 模型。集群有 7 张空闲 GPU,还有 1 张散落在 3 个节点上。Cluster Autoscaler 为那 1 张缺失的 GPU 供给一个新节点。于是 7 个节点空等 4 分钟烧钱,等 Kubernetes 把最后那张 GPU 拉起来。
三层,三种失败模式。2026 年的 GPU 感知自动伸缩不是「打开 HPA」,而是把节点供给、gang 调度、应用信号伸缩组合起来。
Karpenter 盯着 pending 的 pod,约 4560 秒内供给节点(Cluster Autoscaler 对 GPU 节点通常要 90120 秒)。它按 NodePool 约束动态选实例类型——如果你的 pod 要 8 张 H100 而集群没有匹配节点,Karpenter 直接供给一个,而不是扩某个现有节点组。
合并陷阱:Karpenter 默认 consolidationPolicy: WhenEmptyOrUnderutilized 对 GPU 池很危险。它会终止正在运行的 GPU 节点,把 pod 迁到更便宜的合适实例。对推理工作负载,这意味着驱逐正在跑的请求、在新节点重载 70B 模型——损失是几分钟容量 + 请求失败。
GPU 池的安全设置:
disruption: consolidationPolicy: WhenEmpty # 只合并真正空的节点 consolidateAfter: 1h # 空了 1 小时才动手
这让 Karpenter 在节点真空闲一小时后合并,但绝不驱逐正在运行的任务。
KAI Scheduler(项目曾名 "Karp",后改名)处理默认 kube-scheduler 不做的事:
Gang 调度 —— 全有或全无。一个要 8 张 GPU 的分布式推理 pod,要么 8 张一起启动,要么都不启动。没这个,你就会掉进局部分配陷阱:8 个 pod 起了 7 个,无限等待,烧钱。
拓扑感知 —— 知道哪些 GPU 共享 NVLink、哪些在同一机柜、哪些之间有 InfiniBand,据此放置 pod。DeepSeek-V3 67B 的张量并行工作负载必须留在一个 NVLink 域内,KAI Scheduler 尊重这一点。
分层队列 —— 多个团队抢同一个 GPU 池,带优先级与配额。团队 A 的生产吃紧,只有在优先级规则允许时才会被团队 B 的训练任务抢占。
KAI 作为二级调度器与 kube-scheduler 并存;你给工作负载打注解指定用它。Ray 和 vLLM production-stack 都已集成。
HPA 陷阱:DCGM_FI_DEV_GPU_UTIL 是占空比指标——它度量的是每个采样间隔里 GPU「有没有在干活」。100% 利用率可能意味着 10 个并发请求,也可能 100 个——GPU 两种情况都在忙。按占空比伸缩等于闭眼伸缩。
更糟的是,vLLM 这类引擎会预分配 KV 缓存内存(直到 --gpu-memory-utilization)。于是哪怕只有 1 个请求,内存占用也接近 90%。基于内存的 HPA 永远不缩容。
2026 年的替代信号:
NVIDIA Dynamo Planner 与 llm-d Workload Variant Autoscaler 消费这些信号来伸缩副本,在 LLM 服务场景里完全取代 HPA。
| 伸缩决策 | 工具 |
|---|---|
| 加/减节点 | Karpenter |
| 调度多 GPU 任务 | KAI Scheduler |
| 加/减副本 | Dynamo Planner / llm-d WVA(或基于队列深度的自定义 HPA) |
| 选 GPU 型号 | Karpenter NodePool |
| 抢占低优先级 | KAI Scheduler 队列 |
如果你跑分离式 prefill/decode(第 17 节),你有两类 pod、不同的伸缩触发器:prefill pod 按队列深度伸缩,decode pod 按 KV 缓存压力伸缩。llm-d 把它们暴露成不同的 Service,各带按角色的 HPA。别在两者前面塞一个 HPA。
冷启动缓解(第 10 节)是节点供给时间变得用户可见的地方。Karpenter 的 4560 秒预热 + 20GB 模型加载 + 引擎初始化,意味着一个从零开始的请求要 25 分钟。对 SLO 关键路径保留暖池(min_workers=1),或在应用层用 Modal 式的 checkpoint 恢复。
DCGM_FI_DEV_GPU_UTIL 作 HPA 信号:坏的;改用队列深度或 KV 利用率。WhenEmptyOrUnderutilized:会终止运行中的 GPU 任务。推理用 WhenEmpty + consolidateAfter: 1h。原课程 code/main.py 在突发 GPU 工作负载上模拟三层伸缩器,对比朴素 HPA(占空比)、队列深度 HPA、KAI gang 调度伸缩,报告未满足请求数、空闲 GPU 分钟与综合分。下面给一个最小可读的队列深度伸缩器骨架。
def queue_depth_autoscaler(arrival_rate, service_rate_per_replica, target_queue_depth=4, min_replicas=1, max_replicas=16): """按队列深度决定副本数。 参数: arrival_rate: 请求/秒 service_rate_per_replica: 单副本请求/秒 target_queue_depth: 目标队列深度(超过就扩) min/max_replicas: 副本上下限 返回: (desired_replicas, est_queue_depth) """ load_ratio = arrival_rate / service_rate_per_replica needed_for_load = max(min_replicas, int(load_ratio) + 1) # 排队估算:Little 定律 L = λ * W,这里用 load_ratio 超出整数部分的余量近似 est_queue = max(0.0, (load_ratio - int(load_ratio)) * 10) scale_for_queue = 0 if est_queue > target_queue_depth: scale_for_queue = 1 desired = min(max_replicas, max(needed_for_load + scale_for_queue, min_replicas)) return desired, round(est_queue, 2) # 突发场景:平时 5 req/s,峰值 40 req/s,单副本 4 req/s for label, rate in [("平稳", 5), ("峰值", 40)]: n, q = queue_depth_autoscaler(rate, 4) print(f"{label}: 到达 {rate} req/s → 期望副本 {n}, 估算队列 {q}")
对比「占空比 HPA」:同一份数据,占空比在 5 req/s 和 40 req/s 时都可能显示 100%(GPU 都在忙),于是两者都报「不需要扩容」。这就是为什么队列深度比占空比更诚实地反映真实压力。
💡 HPA 替换不是「调阈值」,是「换信号」。先确认你的伸缩信号与用户体感(TTFT、排队)线性相关,再谈自动化——否则你只是在自动放大一个错误的判断。
| 层 | 工具 | 解决什么 | 经典陷阱 |
|---|---|---|---|
| 节点供给 | Karpenter | 45~60s 动态供节点,按 NodePool 选实例 | 默认 WhenEmptyOrUnderutilized 会杀运行中 GPU 任务 |
| (对比) | Cluster Autoscaler | 90~120s,基于节点组扩容 | 慢,且不适配 GPU 拓扑 |
| 调度 | KAI Scheduler | gang + 拓扑 + 分层队列 | 默认 kube-scheduler 无 gang,会局部分配 |
| 应用伸缩 | Dynamo Planner / llm-d WVA | 按队列深度/KV 利用率扩副本 | 用 DCGM_FI_DEV_GPU_UTIL 的 HPA 失真 |
组合心法:Karpenter 管节点生死、KAI 管多卡任务原子调度、应用层管副本数——三者职责不重叠,缺一不可。任何一层用默认值都可能让你在半夜被叫醒。
本节产出 outputs/skill-gpu-autoscaler-plan.md(原课程目录)。给定集群拓扑、工作负载形状、SLO,它会:
跑通模拟器:运行 code/main.py。突发工作负载下,朴素占空比 HPA 会丢多少请求?队列深度 HPA 能接住多少?差异从哪来?
设计 NodePool:为「Llama 3.3 70B FP8 on H100 SXM5」集群设计 Karpenter NodePool,写出 capacity-type、disruption.consolidationPolicy、consolidateAfter,以及一个把非 GPU 工作负载挡在这些节点外的污点。
诊断 Pending:团队报部署卡在 Pending,提示「GPU 可用但 pod 不调度」。是 Karpenter、kube-scheduler 还是 KAI Scheduler 的问题?哪些指标能确认?
分离式信号:为分离式 prefill pod 选一个伸缩信号,为 decode pod 选另一个,分别论证。
算损失:一个 7×24 生产服务平均每天 60 次「P99 TTFT > 10s 的掉请求事件」,源于 WhenEmptyOrUnderutilized 合并陷阱。按你估的每次事件损失(失败请求 × 单请求收入 + SLA 罚款)算 24 小时成本,看改 WhenEmpty + 1h 多久回本。
DCGM_FI_DEV_GPU_UTIL 是占空比:100% 可能是 10 或 100 请求,作 HPA 信号等于闭眼伸缩。WhenEmptyOrUnderutilized 会杀运行中 GPU 任务,推理改 WhenEmpty + consolidateAfter: 1h。下一节,我们钻进 vLLM 引擎内部——看 PagedAttention、连续批处理(continuous batching)、分块 prefill(chunked prefill)如何让一块 GPU 同时高效服务几十个请求。