Kubernetes GPU 推理服务弹性扩缩
在 Kubernetes 上部署 GPU 推理服务时,真正的挑战从来不是「把模型跑起来」,而是如何在延迟、吞吐、成本、稳定性之间找到可持续的平衡点。很多团队最初会把问题理解为「给 Deployment 配个 HPA 就够了」,但真正进入生产环境后很快会发现,GPU 推理服务的扩缩容远比 CPU Web 服务复杂。
GPU 推理服务存在几个本质特征使其区别于普通无状态服务:第一,GPU 是昂贵且离散的资源,无法像 CPU 一样平滑超卖,一个 Pod 要么拿到完整 GPU,要么根本调度不上去;第二,单请求成本差异极大,不同请求在 token 长度、batch size、上下文长度、采样参数上差异明显;第三,扩容存在显著滞后,完整链路通常包括 HPA 观察指标、Deployment 调整副本数、Pod 创建、调度器寻找可用 GPU 节点、节点不足时启动新节点、节点拉起、驱动初始化、镜像拉取、模型权重加载、服务预热等十几步,耗时可能达 60 秒到 300 秒。
因此,一个成熟的 GPU 推理弹性方案必须从「单点扩缩工具」升级为「多层控制系统」:服务层负责 Pod 水平扩缩容、资源层负责 GPU 节点供给与回收、调度层负责 GPU 型号与拓扑资源切分、应用层负责动态批处理与队列保护、观测层从基础 GPU 指标升级到业务 SLO 指标。
GPU 推理的本质特征
资源是离散且强约束的
一个 GPU Pod 往往直接声明 resources.limits.nvidia.com/gpu: 1,这意味着它要么拿到完整 GPU,要么根本调度不上去。和 CPU 可以按 millicore 平滑调度不同,GPU 是典型的「整卡资源」。如果节点上剩余 0.8 张 GPU,这个 Pod 仍然无法启动。
单请求成本差异极大
不同请求在 token 长度、batch size、上下文长度、采样参数上差异明显。一个 128 token 的短请求和一个 8K 上下文的长请求,对显存、算力、时延的压力完全不同。因此,QPS 不是唯一指标,GPU 利用率也不是唯一指标,「每秒请求数一样」不等于「负载一样」。
扩容存在显著滞后
完整扩容链路包括:HPA 观察到指标超阈值、Deployment 调整副本数、新 Pod 创建、调度器寻找可用 GPU 节点、若节点不足则 Karpenter 或 Cluster Autoscaler 启动新节点、节点拉起驱动初始化与 Device Plugin 注册、镜像拉取、模型权重加载到显存、服务完成 warmup 并 readiness 成功、Service 或 Ingress 开始转发流量。这条路径非常常见地耗时 60 秒到 300 秒,也就是说,扩缩系统是明显滞后的控制系统。
生产级目标定义
在架构设计前,建议先明确四类目标。
性能目标
- P95 延迟可控
- P99 延迟不出现雪崩
- 首 Token 时间(TTFT)在大模型场景保持稳定
- 队列等待时间不跨越业务红线
成本目标
- GPU 平均利用率尽可能高
- 低峰时自动回收节点
- 非关键流量可使用低价 Spot GPU
- 小模型、小批量任务尽可能通过 MIG/vGPU 提升密度
稳定性目标
- 扩容后新 Pod 必须在 ready 前完成模型预热
- 缩容不能中断推理中的会话或流式输出
- 单节点故障不能导致某类模型全部不可用
工程治理目标
- 扩缩逻辑标准化、可复用
- 指标可审计、阈值可解释
- 服务扩缩和节点扩缩解耦
- 平台侧能支持 Triton、vLLM、TensorRT-LLM、PyTorch 自研服务等多种推理框架
四层弹性闭环架构
一个成熟的 GPU 推理弹性体系,建议拆成四层。
应用层
核心职责是「承接流量并输出可用于扩缩的真实业务指标」,常见组件包括:
- API Gateway 或 Ingress:流量入口、认证、限流
- 推理服务本体:vLLM、Triton、TensorRT-LLM、Ray Serve、自研 FastAPI/gRPC 服务
- 模型管理:权重下载、版本路由、灰度切换
- 应用指标导出器:请求队列、TTFT、tokens/s、batch size、KV Cache 使用率
指标层
由 Prometheus 统一采集两类指标:
- 基础资源指标:GPU 利用率、显存、温度、功耗、PCIe/NVLink 利用率
- 业务指标:排队长度、并发请求、TTFT、生成吞吐、错误率
控制层
负责做出扩缩决策:
- HPA:适合持续性负载、基于标准和自定义指标的扩缩
- KEDA:适合突发流量、消息堆积、队列驱动场景,也支持 scale-to-zero
- 自定义控制器:适合复杂策略,例如多指标联合判定、分模型策略、预扩容
资源层
负责供给 GPU 节点:
- Karpenter:新一代节点供给器,响应更快,实例选择更灵活
- Cluster Autoscaler:传统节点扩缩容方案
- NVIDIA GPU Operator:统一管理驱动、Toolkit、Device Plugin、DCGM Exporter
指标体系:从资源指标到服务指标
很多失败案例都不是因为没装 HPA,而是因为选错了指标。
资源指标能看机器忙不忙,但不一定能看服务是否健康
最常见的 GPU 指标包括:
DCGM_FI_DEV_GPU_UTIL:GPU 核心利用率DCGM_FI_DEV_FB_USED:显存占用DCGM_FI_DEV_MEM_COPY_UTIL:显存拷贝利用率DCGM_FI_DEV_POWER_USAGE:功耗
这些指标适合做容量评估、节点画像、资源利用率分析、成本审计。但它们不一定适合直接驱动扩缩容,典型问题包括:
- LLM 在等待请求时 GPU Util 很低,但首包延迟已经变高
- 动态批处理生效后,GPU Util 很高,但服务依旧健康,不应该盲目扩容
- 预填充阶段显存和算力激增,解码阶段却不同,均值会掩盖真实波动
真正用于扩缩容的优先级应是业务饱和度指标
生产环境中,建议优先使用以下指标作为扩缩依据:
| 指标类别 | 推荐指标 | 作用 |
|---|---|---|
| 队列指标 | request_queue_length、waiting_requests | 直接反映积压,是扩容最敏感信号 |
| 延迟指标 | p95_latency_ms、ttft_ms | 直接映射用户体验 |
| 并发指标 | active_requests、inflight_requests | 反映实例承载能力 |
| LLM 专属 | kv_cache_usage_ratio、prefill_tokens、decode_tokens_per_second | 比 GPU 利用率更贴近模型压力 |
| 资源辅助 | gpu_utilization、gpu_memory_ratio | 用于兜底,防止资源长期过载 |
一个非常实用的原则是:队列长度决定「是否扩」,延迟决定「扩得够不够」,GPU 指标决定「是否已经接近硬件极限」。
多指标联合比单指标更可靠
建议把扩容判定设计成「主指标 + 护栏指标」:
- 主指标:等待队列长度
- 护栏指标:P95 延迟、GPU 显存占用率
例如,当 queue_length > 8 持续 60 秒,触发扩容。但如果 gpu_memory_ratio < 40% 且 p95 < 800ms,说明只是短暂抖动,可以暂不扩。当 kv_cache_usage_ratio > 0.85 时,即使 GPU Util 不高,也应尽快扩容。
HPA 与 KEDA 的选择
HPA:最稳、最通用
适合负载较平稳、不需要缩到 0、团队希望保持 Kubernetes 原生能力、扩缩指标来自 Prometheus Adapter 暴露的 Custom Metrics 的场景。
优点包括原生集成度高、运维成本低、与大多数托管 K8s 兼容。不足是对突发流量响应不如 KEDA 灵活、scale-to-zero 能力弱、复杂联合策略表达能力有限。
KEDA:更适合突发队列型流量
适合请求通过网关排队或消息队列削峰、高峰与低谷明显、需要 scale-to-zero、希望直接消费 Prometheus、Kafka、RabbitMQ、SQS 等信号的场景。
优点是触发丰富、与队列堆积天然契合、更适合突发性负载。不足是多一层控制逻辑、如果指标和冷启动链路未优化,scale-to-zero 可能导致首个请求体验很差。
选择建议
如果是单一模型或少量服务,优先使用 HPA + Prometheus Adapter 或 KEDA + Prometheus Trigger。如果是统一 AI 推理平台,优先使用 KServe + Karpenter + GPU Operator。如果是大模型在线推理,通常建议应用层限流加队列、KEDA 或 HPA 基于业务指标扩 Pod、Karpenter 补 GPU 节点、通过预热、副本保活、镜像和模型缓存优化冷启动。
节点弹性:Pod 扩出来没用,关键是 GPU Node 能不能及时补货
很多文章只讲 HPA,不讲 Node Autoscaling,这在 GPU 场景里是不完整的。
为什么节点扩缩容是成败关键
Pod 扩容之后,如果调度器发现没有可用 GPU,Pod 会一直 Pending。这时业务以为「已经扩容」,但用户看到的依然是超时和排队。
因此,必须把以下链路一起打通:Pod 变多、Pending Pod 出现、节点供给器识别缺口、拉起正确型号的 GPU 节点、节点注册并可调度、Pod 启动并预热完成。
为什么更推荐 Karpenter
相比传统 Cluster Autoscaler,Karpenter 在 GPU 场景里有几个明显优势:节点规格选择更灵活,可按 Pod 实际请求选最合适实例;启动速度更快;支持更细粒度的容量优化;更适合混合使用 On-Demand 与 Spot。
节点池设计建议
建议至少拆成三类节点池:
| 节点池 | 用途 | 建议 |
|---|---|---|
| 在线核心池 | 承接高优先级在线推理 | 使用稳定 GPU 机型,保留基础容量 |
| 弹性池 | 应对流量峰值 | 允许 Karpenter 快速补货 |
| 低价池 | 批处理或可降级流量 | 使用 Spot GPU,但必须配合驱逐容忍和熔断 |
在标签设计上建议明确:nodeSelector: workload-type: online-inference, accelerator: nvidia-l40s,同时通过 taints/tolerations 隔离 GPU 节点,避免普通业务误占。
冷启动是 GPU 弹性最大的敌人
真正拖慢扩容的通常不是 HPA,而是冷启动。
冷启动耗时拆解
一个 GPU 推理 Pod 从 0 到 Ready 的耗时通常由以下部分构成:新节点拉起 20s 到 120s、镜像拉取 10s 到 180s、模型权重下载 30s 到数分钟、模型加载到内存/显存 10s 到 120s、引擎初始化和 warmup 5s 到 60s。
常见优化手段
保留温实例:对于在线核心服务,建议 minReplicas >= 1,重要模型甚至 minReplicas >= 2,避免单实例升级或故障造成空窗。
镜像预拉取:通过 DaemonSet 预拉取推理镜像,减少首个 Pod 启动时延。
模型本地缓存:把模型缓存到节点本地 NVMe、共享高速文件系统、或专门的模型缓存系统,避免每次都从对象存储拉取大权重。
预热探针:readiness 通过前必须完成模型加载、CUDA 上下文初始化、一次 dummy inference,否则负载一打上来就会出现第一批请求全部超时。
生产级应用指标导出示例
下面给出一个可直接用于生产改造的 Python/FastAPI 示例,它体现了三个关键点:有限并发控制、排队指标暴露、readiness 与 warmup 分离。
import asyncio
import time
from contextlib import asynccontextmanager
from typing import AsyncIterator
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from prometheus_client import Counter, Gauge, Histogram, generate_latest
from starlette.responses import PlainTextResponse, JSONResponse
MAX_CONCURRENCY = 16
QUEUE_LIMIT = 256
request_waiting_gauge = Gauge(
"inference_requests_waiting",
"Number of requests waiting in queue",
)
request_inflight_gauge = Gauge(
"inference_requests_inflight",
"Number of requests currently executing",
)
ttft_histogram = Histogram(
"inference_ttft_ms",
"Time to first token in milliseconds",
buckets=(50, 100, 200, 400, 800, 1200, 2000, 5000),
)
request_total = Counter(
"inference_requests_total",
"Total inference requests",
["status"],
)
kv_cache_usage_gauge = Gauge(
"inference_kv_cache_usage_ratio",
"KV cache usage ratio",
)
class GenerateRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=32000)
max_tokens: int = Field(default=256, ge=1, le=4096)
class FakeModelEngine:
def __init__(self) -> None:
self._ready = False
async def load(self) -> None:
await asyncio.sleep(3)
self._ready = True
async def warmup(self) -> None:
if not self._ready:
raise RuntimeError("model not loaded")
await asyncio.sleep(1)
async def generate(self, prompt: str, max_tokens: int) -> str:
await asyncio.sleep(min(max_tokens / 400, 2.0))
return f"generated for: {prompt[:32]}"
engine = FakeModelEngine()
queue: asyncio.Queue[GenerateRequest] = asyncio.Queue(maxsize=QUEUE_LIMIT)
semaphore = asyncio.Semaphore(MAX_CONCURRENCY)
service_ready = False
async def model_worker() -> None:
while True:
req = await queue.get()
request_waiting_gauge.dec()
async with semaphore:
request_inflight_gauge.inc()
started = time.perf_counter()
try:
await engine.generate(req.prompt, req.max_tokens)
finally:
elapsed_ms = (time.perf_counter() - started) * 1000
ttft_histogram.observe(elapsed_ms)
request_inflight_gauge.dec()
queue.task_done()
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
global service_ready
await engine.load()
await engine.warmup()
service_ready = True
workers = [asyncio.create_task(model_worker()) for _ in range(MAX_CONCURRENCY)]
try:
yield
finally:
for worker in workers:
worker.cancel()
app = FastAPI(lifespan=lifespan)
@app.get("/healthz")
async def healthz() -> JSONResponse:
return JSONResponse({"status": "ok"})
@app.get("/readyz")
async def readyz() -> JSONResponse:
if not service_ready:
raise HTTPException(status_code=503, detail="warming up")
return JSONResponse({"status": "ready"})
@app.get("/metrics")
async def metrics() -> PlainTextResponse:
waiting = queue.qsize()
request_waiting_gauge.set(waiting)
kv_cache_usage_gauge.set(min(0.15 + waiting / QUEUE_LIMIT, 0.98))
return PlainTextResponse(generate_latest().decode("utf-8"))
@app.post("/generate")
async def generate(req: GenerateRequest) -> JSONResponse:
if not service_ready:
request_total.labels(status="not_ready").inc()
raise HTTPException(status_code=503, detail="service not ready")
if queue.full():
request_total.labels(status="rejected").inc()
raise HTTPException(status_code=429, detail="queue full")
request_waiting_gauge.inc()
await queue.put(req)
request_total.labels(status="accepted").inc()
return JSONResponse({"queued": True, "queue_size": queue.qsize()})这段代码在生产实践里的意义不在于「直接上线」,而在于它体现了 GPU 推理服务必须具备的几个设计原则:不让请求无限堆积,必须有显式队列上限;指标不能只靠节点采集,服务本身必须输出队列和并发指标;readiness 必须等模型 warmup 完成;通过可观测的等待队列,把扩容信号从「底层 GPU 忙不忙」升级为「用户请求有没有堆积」。
Prometheus Adapter 配置
如果采用 HPA,需要把 Prometheus 指标映射为 Kubernetes Custom Metrics。
apiVersion: v1
kind: ConfigMap
metadata:
name: adapter-config
namespace: monitoring
data:
config.yaml: |
rules:
- seriesQuery: 'inference_requests_waiting{namespace!="",pod!=""}'
resources:
overrides:
namespace:
resource: namespace
pod:
resource: pod
name:
matches: "inference_requests_waiting"
as: "inference_requests_waiting"
metricsQuery: 'avg(inference_requests_waiting{<<.LabelMatchers>>}) by (<<.GroupBy>>)'
- seriesQuery: 'inference_kv_cache_usage_ratio{namespace!="",pod!=""}'
resources:
overrides:
namespace:
resource: namespace
pod:
resource: pod
name:
matches: "inference_kv_cache_usage_ratio"
as: "inference_kv_cache_usage_ratio"
metricsQuery: 'avg(inference_kv_cache_usage_ratio{<<.LabelMatchers>>}) by (<<.GroupBy>>)'HPA 生产配置示例
下面这个 HPA 配置比单看 GPU Util 更适合 LLM 推理服务:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: llm-inference-hpa
namespace: inference
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: llm-inference
minReplicas: 2
maxReplicas: 20
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 4
periodSeconds: 60
- type: Percent
value: 100
periodSeconds: 60
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Percent
value: 20
periodSeconds: 60
selectPolicy: Min
metrics:
- type: Pods
pods:
metric:
name: inference_requests_waiting
target:
type: AverageValue
averageValue: "4"
- type: Pods
pods:
metric:
name: inference_kv_cache_usage_ratio
target:
type: AverageValue
averageValue: "0.75"这里有几个生产实践要点:minReplicas: 2 是为了规避单副本故障和升级空窗;scaleDown.stabilizationWindowSeconds: 600 是为了防止波谷短抖动导致来回缩放;扩容更激进,缩容更保守,这是在线推理的常见原则。
常见误区
只看 GPU 利用率做扩缩
这是最常见错误。很多时候 GPU Util 低,不代表服务没压力,尤其是在排队、限流、KV Cache 紧张时。
readiness 过早通过
如果模型尚未 warmup 就开始接流量,首批请求非常容易失败。
缩容太快
GPU 服务启动慢、预热慢,如果缩容和扩容都很激进,会导致系统持续抖动。
Node Autoscaler 与 Pod Autoscaler 没打通
Pod 扩了但节点没起来,是 GPU 推理服务最常见的「假扩容」问题。
忽略长请求和流式请求
长请求常常把显存和会话占满,如果没有优雅退出和连接摘除,缩容时很容易影响在线请求。
落地清单
如果你的团队正在从 0 到 1 建 GPU 推理弹性体系,可以按下面顺序推进:
- 先把 NVIDIA GPU Operator、DCGM Exporter、Prometheus 跑起来
- 在推理服务中补齐业务指标:队列、并发、TTFT、错误率、KV Cache
- 先做 HPA 或 KEDA 的 Pod 级扩缩,不急着一步到位
- 补上 Karpenter,让 Pending GPU Pod 能驱动节点补货
- 优化 readiness、warmup、镜像预拉取、模型缓存
- 再考虑 MIG/vGPU 提升密度
- 最后再做平台化治理:统一模型路由、灰度、配额、成本分摊
