Skip to content

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_lengthwaiting_requests直接反映积压,是扩容最敏感信号
延迟指标p95_latency_msttft_ms直接映射用户体验
并发指标active_requestsinflight_requests反映实例承载能力
LLM 专属kv_cache_usage_ratioprefill_tokensdecode_tokens_per_second比 GPU 利用率更贴近模型压力
资源辅助gpu_utilizationgpu_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 分离。

python
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。

yaml
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 推理服务:

yaml
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 推理弹性体系,可以按下面顺序推进:

  1. 先把 NVIDIA GPU Operator、DCGM Exporter、Prometheus 跑起来
  2. 在推理服务中补齐业务指标:队列、并发、TTFT、错误率、KV Cache
  3. 先做 HPA 或 KEDA 的 Pod 级扩缩,不急着一步到位
  4. 补上 Karpenter,让 Pending GPU Pod 能驱动节点补货
  5. 优化 readiness、warmup、镜像预拉取、模型缓存
  6. 再考虑 MIG/vGPU 提升密度
  7. 最后再做平台化治理:统一模型路由、灰度、配额、成本分摊