把一个 LLM 推理服务从「能在服务器上跑」推进到「能在 Kubernetes 上稳定跑并且按需伸缩」,踩的坑和普通 Web 服务完全不是一个量级:GPU 是稀缺且不可共享的资源、模型加载要一到两分钟、CPU 利用率永远是低的、扩容要等权重从镜像或对象存储里拉下来。这些特性决定了传统的 HPA 与探针配置在这里几乎全部失效。本文给出一套可直接落地的 GPU 推理服务部署与弹性伸缩方案。
为什么 GPU 服务不能用普通 Web 的套路
普通 Web 服务的伸缩模型很简单:CPU 或 QPS 升高就加副本,副本启动几百毫秒,随时可以杀掉。LLM 推理服务在几乎每个环节都与它不同:
| 维度 | 普通 Web 服务 | LLM 推理服务 |
|---|---|---|
| 核心资源 | CPU / 内存 | GPU(不可超卖,默认独占) |
| 启动耗时 | 0.1~2 秒 | 45~120 秒(加载权重) |
| CPU 利用率 | 与负载正相关 | 恒低(10%~25%,瓶颈在 GPU) |
| 扩容粒度 | 1 个 Pod | 1 张 GPU(可能是整台机器) |
| 缩容风险 | 低(无状态) | 高(打断正在生成的请求) |
| 关键指标 | CPU / QPS | 排队请求数 / KV Cache 使用率 |
结论:不能照搬 CPU 驱动的 HPA,也不能用默认的探针参数,更不能随意缩容。下面逐层拆解。
GPU 调度基础
nvidia device plugin
Kubernetes 原生不认识 GPU,需要 NVIDIA device plugin 把节点上的 GPU 注册成扩展资源 nvidia.com/gpu。它以一个 DaemonSet 的形式运行在每台 GPU 节点上:
kubectl apply -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.16.2/deployments/static/nvidia-device-plugin.yml
kubectl get nodes -o json | jq '.items[] | {name: .metadata.name, gpu: .status.allocatable["nvidia.com/gpu"]}'
注册成功后,Pod 只要在 resources 里声明 nvidia.com/gpu: 1 就能获得一张 GPU。注意这里的关键限制:GPU 是整数资源,不能像 CPU 那样声明 0.5。一张卡要么整张分给一个 Pod,要么通过 MIG 或 time-slicing 显式切分。这条限制直接导致了「伸缩粒度粗」的问题——一个只需要 8 GB 显存的小模型,也会独占一张 80 GB 的卡。
nodeSelector、taints 与 tolerations
生产集群通常把 GPU 节点打上 taint,避免非 GPU 负载调度上去:
kubectl taint nodes gpu-node-01 nvidia.com/gpu=present:NoSchedule
kubectl label nodes gpu-node-01 accelerator=nvidia-a100
推理 Pod 则通过 tolerations 容忍这个 taint,并用 nodeSelector 或 nodeAffinity 精确选择卡型:
spec:
nodeSelector:
accelerator: nvidia-a100
tolerations:
- key: nvidia.com/gpu
operator: Equal
value: present
effect: NoSchedule
按卡型打标签(A100 / H100 / L40S)非常重要:不同卡型的显存和算力不同,同一个 Deployment 混跑会带来不可预测的吞吐。如果业务有多种卡,建议为每种卡型建一个独立的 Deployment 与独立的 KEDA 伸缩策略。GPU 节点与工作负载的调度细节可参考 Kubernetes 上的 AI/ML 与 GPU 。
MIG 与 time-slicing
当单卡显存远大于模型需求时,可以用两种方式切分:
- MIG(Multi-Instance GPU):把一张 A100/H100 物理切分成多个独立实例,每个实例有独立的显存和算力,硬件级隔离。例如 A100 80G 可切成 7 个 1g.10gb 实例,或 2 个 3g.40gb 实例。切分后资源名变成
nvidia.com/mig-1g.10gb。 - time-slicing:在时间维度上让多个 Pod 共享同一张 GPU,没有显存隔离,靠时分复用。配置在 device plugin 的 ConfigMap 里指定
replicas(如 4),即一张卡最多分给 4 个 Pod。
两者对比:
| 方式 | 隔离级别 | 显存隔离 | 算力隔离 | 适用场景 | 资源名示例 |
|---|---|---|---|---|---|
| 整卡独占 | 最强 | 是 | 是 | 大模型、生产核心 | nvidia.com/gpu |
| MIG | 硬件级 | 是 | 是 | 中小模型、多租户 | nvidia.com/mig-1g.10gb |
| time-slicing | 软件级 | 否 | 否 | 开发测试、低负载 | nvidia.com/gpu(共享) |
MIG 的隔离更彻底但切分粒度固定、不灵活;time-slicing 灵活但一个 Pod 的显存溢出会拖垮同卡的其他 Pod。生产环境对大模型推荐整卡独占,对中小模型推荐 MIG。更完整的共享与调度策略见 GPU 共享与调度 。
Deployment 还是 StatefulSet
这是部署 LLM 服务时第一个要回答的问题:
| 维度 | Deployment | StatefulSet |
|---|---|---|
| 身份 | 随机 Pod 名 | 稳定序号(-0、-1) |
| 存储 | 共享或无 | 每副本独立 PVC |
| 适用 | 无状态单卡推理 | 张量并行 / 依赖本地模型缓存 |
| 滚动更新 | 支持 | 支持(有序) |
| 典型场景 | vLLM 单卡副本 | 70B 多卡张量并行、MoE |
选择原则:
- 绝大多数单卡推理服务用 Deployment 就够了。vLLM 副本本身是无状态的,请求可以在任意副本间分发。
- 需要张量并行(TP)把一个模型切成多卡时,用 StatefulSet:多卡之间的通信(NCCL)需要稳定的网络标识与确定的 Pod 数量,一个「逻辑副本」由固定数量的 Pod 组成,扩缩必须以组为单位。
- 模型权重放在本地 NVMe 上加速加载时,用 StatefulSet 配 per-pod PVC 或 hostPath,保证每个 Pod 复用自己节点上的缓存。
张量并行与多机推理的调度细节可参考分布式推理的部署方案,参见 分布式推理与 GPU 集群 。
探针设计
探针是 GPU 推理服务最容易配错的地方。默认的 initialDelaySeconds: 0 加 3 次失败就会让 Pod 在模型还没加载完时被反复重启,陷入 CrashLoopBackOff。
startupProbe 应对长加载
模型加载需要 45~120 秒(取决于模型大小与存储介质),必须用 startupProbe 给足预算:
startupProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 30 # 10 + 30×10 = 310 秒预算
这里的数学是:总预算 = initialDelaySeconds + periodSeconds × failureThreshold。310 秒足以覆盖 120 秒加载加镜像层解压。startupProbe 成功后,livenessProbe 和 readinessProbe 才会接管。
readinessProbe 与 livenessProbe
readinessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 5
要点:
- readiness 探针的
/health必须在模型加载完成前返回非 200,加载完成后返回 200,这样 Service 在 Pod 就绪前不会把流量导进来。 - liveness 探针不要太激进。GPU 推理偶尔会出现一次长请求(如 32K 上下文生成),如果
/health处理被 GIL 或事件循环阻塞超过 timeout,会被误判为死亡并重启,反而制造故障。建议给较宽松的 timeoutSeconds 和 failureThreshold。 - vLLM 的
/health在服务可用时返回 200,是一个合适的探针端点。
优雅终止
缩容或滚动更新时,必须让正在生成的请求跑完,否则用户体验是「回答到一半突然断了」:
terminationGracePeriodSeconds: 120
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
preStop 里的 sleep 是为了等 kube-proxy 把该 Pod 从 Service 端点里摘除,避免摘除还没生效时新请求继续打进来。
为什么 HPA 用 CPU 会失效
HPA(Horizontal Pod Autoscaler)最常用的指标是 CPU 利用率。在 LLM 推理服务上,这个指标几乎完全无效:
- GPU 是瓶颈,CPU 只做 tokenize、调度和 HTTP 处理,利用率长期停留在 10%~25%。
- 当请求暴涨、GPU 已经过载时,CPU 可能只从 12% 升到 18%,远达不到 HPA 的 70% 阈值,副本数纹丝不动。
- 反过来,当 CPU 因 tokenize 压力短暂升高时,HPA 可能误判需要扩容,而此时 GPU 可能还有余量。
实测数据(Qwen2.5-7B,A100,单副本):
| 并发 | CPU 利用率 | GPU 利用率 | 排队请求数 | 单副本 QPS |
|---|---|---|---|---|
| 8 | 11% | 45% | 0 | 6.2 |
| 32 | 14% | 78% | 0 | 11.8 |
| 64 | 18% | 96% | 6 | 13.1 |
| 128 | 21% | 100% | 41 | 13.4 |
可以看到:并发从 8 涨到 128 的过程中,CPU 只从 11% 动到 21%,而 GPU 早已打满、排队请求从 0 涨到 41。用 CPU 做伸缩信号,等于闭着眼睛开车。
正确的信号是「服务自己感知到的压力」,也就是队列深度。
KEDA 加 Prometheus 自定义指标
KEDA(Kubernetes Event-driven Autoscaling)支持从 Prometheus 拉取任意指标来驱动伸缩,非常适合用 vLLM 的队列深度做扩缩容。
指标选择
vLLM 暴露的 Prometheus 指标里,与伸缩最相关的是:
| 指标 | 含义 | 是否适合驱动伸缩 |
|---|---|---|
vllm:num_requests_waiting | 排队等待调度的请求数 | 最合适,直接反映过载 |
vllm:num_requests_running | 正在执行的请求数 | 适合,反映负载水平 |
vllm:gpu_cache_usage_perc | KV Cache 使用率 | 适合,反映显存压力 |
vllm:time_to_first_token_seconds | TTFT 直方图 | 适合,反映用户体验 |
vllm:request_success_total | 累计成功请求数 | 适合算 QPS |
首选 vllm:num_requests_waiting:它是「有多少请求在等」的直接度量,目标值设为「每个副本允许的平均排队数」。例如希望每副本平均排队不超过 2 个,阈值就设为 2。
部署 KEDA 与 ScaledObject
先安装 KEDA:
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace --version 2.16.0
再给推理 Deployment 配一个 ScaledObject:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: vllm-qwen25-7b
namespace: llm-serving
spec:
scaleTargetRef:
name: vllm-qwen25-7b
minReplicaCount: 1
maxReplicaCount: 8
pollingInterval: 15
cooldownPeriod: 300
advanced:
horizontalPodAutoscalerConfig:
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 2
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 120
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring.svc:9090
metricName: vllm_num_requests_waiting
query: |
sum(vllm:num_requests_waiting{namespace="llm-serving"})
threshold: "2"
activationThreshold: "1"
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring.svc:9090
metricName: vllm_gpu_cache_usage
query: |
avg(vllm:gpu_cache_usage_perc{namespace="llm-serving"})
threshold: "0.85"
activationThreshold: "0.5"
几个关键参数的含义:
pollingInterval: 15:每 15 秒拉一次指标,太短会增加 Prometheus 压力,太长会错过突发流量。cooldownPeriod: 300:最后一次触发后等待 300 秒再缩到 minReplicaCount。scaleUp.stabilizationWindowSeconds: 30:扩容的观察窗口,避免对瞬时尖峰过度反应,但也不能太长,否则冷启动太慢。scaleDown.stabilizationWindowSeconds: 600:缩容窗口设得很长(10 分钟),这是刻意为之——缩容比扩容危险得多,宁可多留一会儿。activationThreshold:低于该值时不做任何伸缩动作,避免 0 和 1 之间反复抖动。
两个触发器是「或」的关系:只要排队数超阈值或 KV 使用率超阈值,就扩容。用多个互补指标可以避免单一指标的盲区。
配套的 Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-qwen25-7b
namespace: llm-serving
spec:
replicas: 1
selector:
matchLabels:
app: vllm-qwen25-7b
template:
metadata:
labels:
app: vllm-qwen25-7b
spec:
nodeSelector:
accelerator: nvidia-a100
tolerations:
- key: nvidia.com/gpu
operator: Equal
value: present
effect: NoSchedule
terminationGracePeriodSeconds: 120
containers:
- name: vllm
image: vllm/vllm-openai:v0.6.3
args:
- "--model"
- "Qwen/Qwen2.5-7B-Instruct-AWQ"
- "--quantization"
- "awq"
- "--kv-cache-dtype"
- "fp8"
- "--max-model-len"
- "8192"
- "--gpu-memory-utilization"
- "0.92"
- "--port"
- "8000"
ports:
- containerPort: 8000
name: http
resources:
limits:
nvidia.com/gpu: 1
memory: 32Gi
cpu: "8"
requests:
nvidia.com/gpu: 1
memory: 16Gi
cpu: "4"
startupProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 30
readinessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8000
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 5
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
volumeMounts:
- name: model-cache
mountPath: /root/.cache/huggingface
volumes:
- name: model-cache
hostPath:
path: /mnt/nvme/hf-cache
type: DirectoryOrCreate
注意 resources.requests 与 limits 里的 GPU 必须相等(GPU 不支持 requests/limits 不等),而 CPU 与内存的 requests 可以低于 limits 以提升装箱率。
冷启动与扩容抖动
冷启动是 GPU 推理伸缩最痛的点。一次扩容的时间构成:
| 阶段 | 耗时 | 优化手段 |
|---|---|---|
| 调度 + 拉镜像 | 120~300 秒(首次) | 预拉镜像、用 P2P 分发 |
| 容器启动 + 加载权重 | 45~120 秒 | 本地 NVMe 缓存、对象存储加速 |
| 预热(首次前向) | 5~15 秒 | 启动后自动发一次探针请求 |
| 合计 | 170~435 秒 | — |
也就是说,当流量突增触发扩容时,用户要等 3 到 7 分钟才能用上新副本。这段窗口里,现有副本的排队会持续恶化。几种缓解思路:
- 保持较高的 minReplicaCount。对延迟敏感的服务,宁可多留一个热副本(成本换体验)。
- 预拉镜像到所有 GPU 节点(DaemonSet 或 initContainer),把 2~5 分钟的镜像拉取从关键路径上拿掉。
- 权重放本地 NVMe 并预热,把 120 秒的加载压到 45 秒以内。
- 扩容策略用「快扩慢缩」:
scaleUp一次加 2 个、窗口 30 秒,scaleDown一次减 1 个、窗口 600 秒。 - 配合网关做排队与降级,在扩容完成前先限流或返回排队提示,避免雪崩。多模型路由与流量分发的设计可参考多模型网关的思路。
缩容为什么容易误杀
缩容是比扩容更危险的操作,常见问题有三个:
- 缩容打断了正在生成的请求。LLM 请求的生成时间可能是几十秒,如果缩容直接发 SIGTERM 而没有优雅终止,请求会中断。必须配
terminationGracePeriodSeconds和preStop。 - 指标抖动导致反复伸缩。如果排队数在阈值附近震荡,副本数会一会儿加一会儿减,GPU 被反复申请和释放。用
stabilizationWindowSeconds和activationThreshold抑制。 - 缩到 0 后冷启动更慢。KEDA 支持缩到 0(
minReplicaCount: 0),但对 LLM 服务通常不划算——从 0 启动要 3~7 分钟,只适合流量有明显波谷且对延迟不敏感的场景。
模型更新与灰度发布
模型更新与代码更新有本质区别:权重变了,输出分布就变了,回归风险远高于普通服务。不能简单地滚动重启,需要灰度。
两种主流形态:
- 蓝绿:同时起两套 Deployment(旧版本与 新版本),用 Service 的 selector 或网关权重把流量整体切过去。优点是切换干净、可秒级回滚;缺点是峰值要占用 2 倍 GPU。
- 金丝雀:新旧版本并存,先给新版本 5% 流量,观察 TTFT、错误率、输出质量指标后再逐步放量。GPU 成本同样翻倍,但风险最低。
用两个 Deployment 加一个带权重的网关是最常见的做法:
apiVersion: v1
kind: Service
metadata:
name: vllm-qwen25-7b-canary
namespace: llm-serving
spec:
selector:
app: vllm-qwen25-7b
version: v2
ports:
- port: 8000
targetPort: 8000
---
apiVersion: v1
kind: Service
metadata:
name: vllm-qwen25-7b-stable
namespace: llm-serving
spec:
selector:
app: vllm-qwen25-7b
version: v1
ports:
- port: 8000
targetPort: 8000
网关按权重把 5% 的流量发给 canary,其余发给 stable。观察期内的关键指标:
| 指标 | 基线 | 金丝雀告警阈值 | 说明 |
|---|---|---|---|
| TTFT P95 | 130 ms | > 200 ms | 首 token 延迟劣化 |
| 错误率 | 0.1% | > 1% | 加载或推理异常 |
| 排队请求数 | 2 | > 8 | 新版本吞吐下降 |
| 输出长度分布 | 均值 180 | 偏移 > 30% | 质量异常信号 |
灰度期间要注意:金丝雀副本同样需要 GPU,如果 GPU 资源紧张,可以先用 maxReplicaCount: 1 起一个金丝雀副本,验证通过后再整体替换。模型路由与权重分发的完整设计可参考多模型网关方案。
常见坑清单
- 用 CPU 做 HPA 指标。GPU 服务 CPU 恒低,HPA 永远不触发,必须改用队列深度等自定义指标。
- GPU 不可共享导致伸缩粒度粗。声明
nvidia.com/gpu: 1就是整张卡,小模型也占满一张卡,成本被浪费。用 MIG 或 time-slicing 切分。 - 探针默认值导致 CrashLoopBackOff。模型加载 60~120 秒,没有 startupProbe 的 Pod 会在加载完成前被 liveness 重启。
- liveness 探针过于激进。长请求阻塞事件循环被误判为死亡,引发无谓重启,要放宽 timeoutSeconds。
- 缩容误杀正在生成的请求。缺少优雅终止配置,用户看到回答中断。
- 镜像拉取慢拖垮冷启动。15 GB 的推理镜像冷拉要 3~5 分钟,必须预拉或本地缓存。
- 多卡张量并行用 Deployment。TP 需要稳定的 Pod 身份与固定数量,必须用 StatefulSet,否则 NCCL 初始化失败。
- 忽略 KV Cache 对并发的影响。同一个副本在不同上下文长度下的并发能力差好几倍,伸缩阈值必须结合上下文长度设定。
- 忘记给 GPU 节点打 taint。非 GPU 负载(如日志采集之外的业务 Pod)抢占 GPU 节点资源,影响推理稳定性。
- 缩到 0 当成省钱手段。LLM 冷启动动辄数分钟,缩到 0 的体验代价远大于省下的钱。
小结
Kubernetes 上跑 LLM 推理,核心是把「Web 服务思维」换成「GPU 作业思维」:调度上用 device plugin 加 nodeSelector、taints 把 GPU 节点隔离出来,并按模型规模选择整卡、MIG 或 time-slicing;编排上单卡用 Deployment、多卡张量并行用 StatefulSet;探针上用 startupProbe 给足 120 秒以上的加载预算并配优雅终止;伸缩上放弃 CPU 驱动的 HPA,改用 KEDA 加 Prometheus 的 vllm:num_requests_waiting 队列深度,配合「快扩慢缩」的 behavior 策略。冷启动是这个架构里最难消除的成本,靠预拉镜像、本地权重缓存和适当的 minReplicaCount 来对冲。把这些做对,推理集群才能在成本与延迟之间找到可持续的平衡点。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。