推理服务的 Kubernetes 部署与弹性伸缩

本文面向 LLM 推理服务的生产部署,讲清 GPU 调度(device plugin、taints、MIG、time-slicing)的配置要点,比较 Deployment 与 StatefulSet 的边界,给出 startupProbe 应对模型加载的探针设计,说明 HPA 基于 CPU 失效的原因并改用 KEDA 加 Prometheus 队列深度指标,附可运行 YAML。

把一个 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 个 Pod1 张 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 服务时第一个要回答的问题:

维度DeploymentStatefulSet
身份随机 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
811%45%06.2
3214%78%011.8
6418%96%613.1
12821%100%4113.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_percKV Cache 使用率适合,反映显存压力
vllm:time_to_first_token_secondsTTFT 直方图适合,反映用户体验
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 P95130 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 来对冲。把这些做对,推理集群才能在成本与延迟之间找到可持续的平衡点。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「LLMOps」更多文章

  1. 语义缓存与 Prompt 缓存
  2. 结构化输出与函数调用
  3. 多智能体编排与工作流引擎