GPU 推理自动伸缩:从并发指标到缩容到零
推理的弹性不是“加副本”三个字:指标选错会抖动,缩容到零要重新算一遍冷启动的账。
vLLM 生产化部署立起了多副本,但副本数是 values.yaml 里写死的。真实的推理流量有明显的潮汐(白天高峰、夜间几乎为零),GPU 又是按小时计价的最贵资源。本章解决两个问题:用什么指标驱动伸缩,以及缩容到零时冷启动怎么算账。
三种伸缩机制,三种指标语义
| 机制 | 驱动指标 | 缩容到零 | 典型用法 |
|---|---|---|---|
| Knative KPA(KServe 默认) | 每副本目标并发请求数 | 原生支持 | KServe InferenceService 开箱即用 |
| HPA | CPU/内存/自定义指标 | 不支持(最少 1 副本) | 传统无状态服务 |
| KEDA | 事件源:队列长度、Kafka 积压、Prometheus 指标 | 原生支持(0 到 N) | 队列缓冲型推理、批处理 |
KServe 的 GPU 样例展示了最短路径:一个带 nvidia.com/gpu 资源请求的 InferenceService,加一行注解就能获得并发驱动的伸缩:
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
name: flowers-sample-gpu
annotations:
autoscaling.knative.dev/target: "1" # 每副本目标并发数
spec:
predictor:
# ...模型与运行时配置...
resources:
limits:
nvidia.com/gpu: 1为什么 GPU 推理的伸缩指标要用“排队”而不是“利用率”
HPA 的默认直觉是 CPU 利用率,但这对 GPU 推理几乎必然失真,原因在本书前文已经埋好:解码型负载受带宽屋顶线约束,算力占用低是常态(见Roofline 模型);GPU 利用率只表示“有 kernel 在跑”,与请求排队无关(见GPU 指标语义)。按它伸缩,要么纹丝不动,要么追着噪声抖。
推理负载的真实压力信号是排队:请求在引擎的等待队列里积压,SLO(TTFT/尾延迟)开始恶化。所以正确的指标链是“队列深度或并发数驱动伸缩,尾延迟做验收”,这与vLLM一章“交付 SLO 下的 Goodput 而不是峰值吞吐”的结论一脉相承。KEDA 的价值在这里:它把“队列长度”这类事件源做成一等公民,并且支持从零副本起步。
缩容到零:四层冷启动账
缩容到零省的是真金白银(空闲时 GPU 节点可以整个还给云),代价是把冷启动从“镜像拉取”放大成一整条链路。以社区项目 gpu-autoscale-inference 的实测分解为例(Qwen2.5-1.5B,T4 节点,GKE):
| 层 | 机制 | 触发条件 | 典型耗时 |
|---|---|---|---|
| Pod | KEDA ScaledObject 驱动 HPA | Redis 队列深度超过阈值 | 约 30 秒(轮询周期) |
| 节点 | Cluster Autoscaler | 出现带 nvidia.com/gpu 请求的 Pending Pod | 约 2 分钟(GPU 虚机启动) |
| 镜像 | GKE Secondary Boot Disk 等 | 节点启动即挂载预缓存盘 | 接近 0 |
| 模型 | 权重常驻 PVC | Pod 启动加载权重入显存 | 约 128 秒(1.5B 模型) |
四层加起来,一次完全冷的请求要等约三四分钟。这个项目给出的工程答案值得原样学习:
- 队列缓冲:请求先进 Redis 队列再由 worker 消费,冷启动期间队列吸收突发,客户端无感、无重试;
- 权重常驻:模型权重放 PVC,Pod 反复重建不重复下载;
- 镜像预热:容器层预缓存到节点启动盘;
- 全链路遥测:DCGM(GPU 利用率/功耗/显存)、vLLM 指标(TTFT、KV cache)、kube-state-metrics(副本与节点生命周期)进同一块 Grafana 面板,伸缩行为本身可验收。
一句话总结:缩容到零不是省掉待机费的黑魔法,而是用队列缓冲吸收冷启动的延迟,前提是你算清了每一层的账。
动手实验:零成本跑通 KEDA 的 0 → 1 → 0
实验目标
在一台无 GPU 的 minikube 上验证 KEDA 的核心机制:队列深度驱动 Deployment 在 0 与 1 副本之间自动伸缩。机制验证不需要 GPU,GPU 场景只是把同样的 ScaledObject 换个目标。
前置条件与成本预估
- minikube 集群(上手篇的集群删掉 GPU 参数重建即可,或任何 2C/4G 集群)。
- 成本:零(本地集群)。
操作步骤
# 步骤 1:安装 KEDA
helm repo add kedacore https://kedacore.github.io/charts
helm install keda kedacore/keda --namespace keda --create-namespace
# 步骤 2:部署一个 Redis 作为队列,一个待伸缩的 worker(睡眠占位即可)
kubectl create deployment queue --image=redis:7 --port=6379
kubectl expose deployment queue --port=6379
kubectl create deployment worker --image=redis:7 -- sleep infinity
# 步骤 3:声明伸缩规则:队列长度超过 5 则扩到 1,空闲则缩回 0
cat <<'EOF' | kubectl apply -f -
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: worker-scaler
spec:
scaleTargetRef:
name: worker
minReplicaCount: 0
maxReplicaCount: 1
cooldownPeriod: 60
triggers:
- type: redis
metadata:
address: queue:6379
listName: inference_queue
listLength: "5"
EOF
# 步骤 4:观察周期一:空闲时副本被缩到 0
kubectl get deployment worker -w # 一分钟后 REPLICAS 变为 0
# 步骤 5:制造负载:向队列压入 6 个任务,观察 0 → 1
kubectl exec deployment/queue -- redis-cli LPUSH inference_queue a b c d e f
kubectl get deployment worker -w # 副本变为 1
# 步骤 6:清空队列,观察冷却后 1 → 0
kubectl exec deployment/queue -- redis-cli DEL inference_queue验证清单
- 步骤 4:空闲状态下 worker 副本数归零(缩容到零生效)
- 步骤 5:队列超过阈值后约 30 秒内副本从 0 变 1(事件驱动激活)
- 步骤 6:清空队列后经过 cooldownPeriod 副本回到 0
-
kubectl get hpa能看到 KEDA 托管的 HPA 对象及其当前指标 - 把“约 30 秒的激活延迟”记下来:这就是四层冷启动账的第一层,GPU 场景还要再叠加节点与模型加载
原理速览
KEDA 的架构是“激活器 + 标准组件”:它周期性轮询事件源(这里是 Redis 的 LLEN),队列为空时把 Deployment 缩到 0 并停止 HPA 评估;一旦超过阈值,先把副本拉到 1,再把控制权交给底层 HPA 按指标继续扩。所以你既得到了缩容到零,又保留了 HPA 生态的全部语义。GPU 推理场景里,把 worker 换成带 nvidia.com/gpu 请求的 vLLM Deployment、把队列换成真实的请求缓冲,就是社区项目的完整形态。
清理与止损
kubectl delete scaledobject worker-scaler
kubectl delete deployment worker queue
helm uninstall keda -n keda && kubectl delete ns keda
minikube delete节点层伸缩与治理的衔接
副本伸缩解决 Pod 层,节点层由 Cluster Autoscaler 接管:Pending 的 GPU Pod 会触发 GPU 节点池扩容,空闲后节点回收,这就是“空闲时零成本”的完整闭环。两件事需要与治理体系对齐:
- 冷启动预算要进 SLO:缩容到零的服务,P99 延迟必须包含激活时间,或者用队列把同步等待变成异步。
- 在线弹性与离线队列互补:KEDA 管在线推理的潮汐,Kueue管离线训练的排队,两者共享同一个 GPU 池时,配额与优先级的划分要在控制面统一设计,避免弹性推理把训练饿死(或反之)。
总结
GPU 推理的自动伸缩,选指标先于选工具:并发与队列深度才是推理负载的真实压力信号,CPU 利用率会把人带偏;缩容到零的价值与代价都来自冷启动,四层账(激活、节点、镜像、模型)算清了才能设计缓冲与 SLO。至此推理侧从物理层、引擎、生产栈到弹性全部闭环,下一章PyTorch转回训练侧:另一套完全不同的调度语义。