找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4417

积分

0

好友

576

主题
发表于 4 天前 | 查看: 24| 回复: 0

在 Kubernetes 上部署 GPU 推理服务,真正的难点从来不是“让模型跑起来”,而是如何在延迟、吞吐、成本和稳定性之间找到一个可持续的平衡点。

许多团队最初会简单地认为“给 Deployment 配个 HPA 就万事大吉了”,但一旦进入生产环境,很快就会意识到 GPU 推理服务的扩缩容远比传统的 CPU Web 服务复杂得多:

  • GPU 是昂贵且离散的资源,无法像 CPU 那样平滑地超卖。
  • 模型加载慢,容器启动慢,节点启动更慢,整个冷启动链路动辄需要几十秒到数分钟。
  • 只看 GPU 利用率(GPU Utilization)常常会误判真实负载,尤其是对于像 LLM、Triton、vLLM 这类采用异步批处理机制的推理服务。
  • Pod 扩出来了,不代表能立刻承接流量。节点没准备好、镜像没拉完、模型没预热,都会导致扩容“看起来成功,但用户请求依然超时”的尴尬局面。
  • 真正决定最终用户体验的,往往不是 GPU 使用率,而是请求队列长度、首 Token 延迟(TTFT)、P95/P99 延迟、KV Cache 水位以及活跃请求数这些业务层指标。

因此,一个成熟、可靠的弹性方案必须从单一的“扩缩工具”升级为一套多层协同的控制系统

  • 服务层:Pod 水平扩缩容
  • 资源层:GPU 节点的供给与回收
  • 调度层:GPU 型号、拓扑、MIG/vGPU 资源切分
  • 应用层:动态批处理、并发控制、模型预热、队列保护
  • 观测层:从基础 GPU 硬件指标升级到业务 SLO 指标

本文将完整拆解这条链路,并提供一套可直接用于生产环境的实现方法与最佳实践。

1. 核心挑战:为什么 GPU 推理服务如此难以扩缩?

传统的 Web 服务扩缩逻辑通常很简单:

  1. CPU 使用率高了,就扩容 Pod。
  2. 请求量减少了,就缩容 Pod。
  3. 节点资源不够了,就自动添加机器。

但 GPU 推理服务完全不是这个模式。其本质更像一个 “构建在异构资源上的排队系统” ,而非普通的无状态服务。

1.1 GPU 推理服务的三个本质特征

第一,资源是离散且强约束的

一个 GPU 推理 Pod 的资源配置通常直接声明整张卡:

resources:
  limits:
    nvidia.com/gpu: 1

这意味着 Pod 要么拿到一整张 GPU,要么根本调度不上去。这与 CPU 可以按毫核(millicore)平滑调度的方式截然不同。如果某个节点上只剩 0.8 张 GPU 的算力,这个需要 1 整卡的 Pod 依然无法启动。

第二,单次请求的成本差异极大

不同的推理请求在输入 token 长度、批处理大小(batch size)、上下文长度、采样参数上差异巨大。一个 128 token 的短查询和一个需要 8K 上下文的长文档总结,对显存、算力以及最终延迟造成的压力完全不同。

因此:

  • 单纯的 QPS(每秒查询率)不能作为唯一指标。
  • GPU 利用率也不能作为唯一指标。
  • “每秒请求数相同”绝不等于“系统负载相同”。

第三,扩容存在显著的滞后效应

一次完整的 GPU 服务扩容链路通常包含以下步骤:

  1. HPA 或 KEDA 控制器观测到指标超过阈值。
  2. Deployment 开始调整副本数。
  3. 调度器创建新的 Pod。
  4. 调度器为 Pod 寻找可用的 GPU 节点。
  5. 若没有可用节点,Karpenter 或 Cluster Autoscaler 启动新节点。
  6. 新节点启动,完成驱动初始化、NVIDIA Device Plugin 注册。
  7. 拉取容器镜像。
  8. 将模型权重加载到 GPU 显存。
  9. 服务完成预热(warmup),readiness 探针通过。
  10. Service 或 Ingress 开始向新 Pod 转发流量。

在生产环境中,这条“感知-决策-执行”的路径耗时 60 秒到 300 秒是常有的事。也就是说,整个扩缩系统是一个具有明显滞后的控制系统。如果控制策略设计得不稳定,就会出现:

  • 业务流量高峰已经过去,新的副本才姗姗来迟。
  • 刚扩容出来的副本,因为策略抖动又马上被缩回去。
  • 用户侧请求延迟暴涨,但监控面板上的 GPU 使用率看起来却并不高。

正因如此,GPU 推理服务的弹性治理必须围绕“预测 + 缓冲 + 稳定时间窗口”来设计,生搬硬套 CPU 服务的经验往往是行不通的。

2. 生产级目标:不止于“能自动扩”,更要“扩得准、扩得稳、缩得省”

在进行架构设计之前,建议先明确以下四类核心目标。

2.1 性能目标

  • P95 延迟保持可控。
  • P99 延迟不发生雪崩式劣化。
  • 在大模型场景下,首 Token 时间(TTFT)保持稳定。
  • 请求在队列中的等待时间不超过业务设定的红线。

2.2 成本目标

  • 尽可能提高 GPU 的平均利用率。
  • 在流量低谷期能自动回收闲置的 GPU 节点。
  • 非关键流量能够利用低价的 Spot 实例 GPU。
  • 对于小模型、小批量任务,尽可能通过 MIG 或 vGPU 技术提升单卡部署密度。

2.3 稳定性目标

  • 新扩容的 Pod 必须在就绪(ready)前完成模型预热,杜绝冷启动影响。
  • 缩容操作不能中断正在进行的推理会话或流式输出。
  • 单节点故障不能导致某一类模型全部不可用,具备一定的容灾能力。

2.4 工程治理目标

  • 扩缩逻辑标准化、可复用 across 不同服务。
  • 扩缩指标可审计、决策阈值可解释。
  • 服务层扩缩(Pod)与资源层扩缩(Node)解耦。
  • 平台侧有能力支持 Triton、vLLM、TensorRT-LLM 及自研 PyTorch 服务等多种推理框架。

3. 总体架构:构建四层弹性闭环

一个成熟的 GPU 推理弹性体系,建议拆解为以下四个逻辑层。

3.1 应用层

核心职责是“承接用户流量,并暴露可用于扩缩决策的真实业务指标”。常见组件包括:

  • API Gateway / Ingress:负责流量接入、认证、路由。
  • 推理服务本体:如 vLLM、Triton、TensorRT-LLM、Ray Serve,或自研的 FastAPI/gRPC 服务。
  • 模型管理:负责模型权重的下载、版本路由与灰度切换。
  • 应用指标导出器:暴露诸如请求队列长度、TTFT、每秒生成 token 数、批处理大小、KV Cache 使用率等指标。

3.2 指标层

Prometheus 统一采集两类指标:

  • 基础资源指标:通过 NVIDIA DCGM Exporter 获取 GPU 利用率、显存占用量、温度、功耗、PCIe/NVLink 带宽等。
  • 业务指标:从应用层采集的排队长度、并发请求数、TTFT、生成吞吐量、错误率等。

3.3 控制层

负责根据指标做出扩缩决策,核心组件三选一或组合使用:

  • HPA:Kubernetes 原生组件,适合负载较平稳、无需缩容至零的场景,基于标准或自定义指标进行扩缩。
  • KEDA:更擅长处理突发流量和消息队列堆积场景,天然支持 scale-to-zero,触发器丰富(Prometheus, Kafka等)。
  • 自定义控制器:当扩缩策略极其复杂,需要多指标联合判定、分模型差异化策略或预扩容时使用。

3.4 资源层

负责底层 GPU 计算资源的供给:

  • Karpenter:新一代节点供给器,响应速度快,实例选择策略灵活,特别适合云上动态环境。
  • Cluster Autoscaler:传统的节点扩缩容方案,生态成熟。
  • NVIDIA GPU Operator:一站式管理 Kubernetes 上的 GPU 资源,包括驱动、CUDA Toolkit、Device Plugin、DCGM Exporter 等组件的部署与生命周期管理。

4. 扩缩决策的关键:从“资源指标”升级到“服务指标”

许多弹性方案的失败,并非因为没有安装 HPA,而是因为选错了驱动扩缩的指标。

4.1 资源指标的局限性

最常用的 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 利用率可能很低,但此时用户感知的首包延迟已经开始升高。
  • 当动态批处理生效时,GPU 利用率会被拉得很高,但这恰恰是系统高效工作的表现,不应盲目触发扩容。
  • 大模型推理的“预填充”(Prefill)阶段和“解码”(Decode)阶段对资源的消耗模式不同,平均值会掩盖真实的压力波动。

4.2 应优先采用业务饱和度指标

在生产环境中,建议优先使用以下贴近业务真实状态的指标作为扩缩依据:

指标类别 推荐指标 核心作用
队列指标 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 资源指标则用来判断 “是否已经接近硬件性能极限”

4.3 多指标联合判定更可靠

建议将扩容判定设计为 “主指标 + 护栏指标” 的联合策略:

  • 主指标:等待队列长度(request_queue_length
  • 护栏指标:P95 延迟(p95_latency_ms)、GPU 显存占用率(gpu_memory_ratio

例如:

  • queue_length > 8 持续 60 秒时,触发扩容。
  • 但如果同时 gpu_memory_ratio < 40%p95 < 800ms,则可能只是短暂流量抖动,可以暂不扩容,继续观察。
  • kv_cache_usage_ratio > 0.85 时,即使 gpu_utilization 不高,也说明 KV Cache 即将耗尽,应尽快扩容以避免性能骤降。

5. 技术选型:HPA、KEDA、KServe 如何抉择?

5.1 HPA:最稳健、最通用的选择

适合场景

  • 负载相对平稳,没有剧烈的突发流量。
  • 不需要将副本数缩容至零(scale-to-zero)。
  • 团队希望最大限度利用 Kubernetes 原生能力,降低运维复杂度。
  • 扩缩指标来自 Prometheus Adapter 暴露的 Custom Metrics API。

优点

  • 与 Kubernetes 集成度最高,兼容性最好。
  • 运维成本低,被各大托管 K8s 服务广泛支持。

不足

  • 对突发流量的响应不如 KEDA 灵活。
  • scale-to-zero 能力较弱。
  • 表达复杂的多指标联合策略能力有限。

5.2 KEDA:更适合突发与队列驱动型场景

适合场景

  • 请求通过网关或消息队列(如 Kafka)进行削峰填谷。
  • 流量高峰与低谷区别明显。
  • 需要 scale-to-zero 以在闲时节省成本。
  • 希望直接基于 Prometheus、Kafka、RabbitMQ、AWS SQS 等外部系统的信号进行扩缩。

优点

  • 触发器丰富,与队列堆积的监控天然契合。
  • 对突发性负载响应更佳。
  • 原生支持 scale-to-zero。

不足

  • 在 Kubernetes 原生 HPA 之上又多了一层控制逻辑。
  • 如果冷启动链路(镜像、模型)未优化,scale-to-zero 后首个请求的体验会非常差。

5.3 KServe:适合平台化统一治理

适合场景

  • 需要构建统一的多模型、多团队共享的推理平台。
  • 需要平台层提供模型灰度发布、金丝雀发布、版本管理等高级能力。
  • 希望统一交付和管理 Triton、TorchServe、SKLearn、vLLM 等多种推理框架的后端。

优点

  • 提供完整的模型 Serving 平台能力,开箱即用。
  • 为不同框架的服务提供一致的交付和管理体验。
  • 非常适合中大型企业的 AI 中台建设。

不足

  • 学习和治理成本相对更高。
  • 在需要极度定制化扩缩策略的场景下,灵活性可能不如直接管理 Deployment。

5.4 选择建议

  • 如果是单一或少量模型服务:优先选择 HPA + Prometheus AdapterKEDA + Prometheus Trigger。简单直接,易于管理。
  • 如果是统一的 AI 推理平台:优先选择 KServe + Karpenter + GPU Operator 组合,实现平台化、标准化的治理。
  • 对于大模型在线推理场景:通用架构是 应用层限流排队 + KEDA/HPA 基于业务指标扩缩 Pod + Karpenter 动态补充 GPU 节点,并辅以预热、副本保活、镜像模型缓存等优化手段来对抗冷启动。

6. 节点弹性:Pod 能扩,GPU 节点更要能及时“补货”

许多文章只讲服务层(Pod)弹性,不讲资源层(Node)弹性,这在 GPU 场景下是不完整的方案。

6.1 节点扩缩容是成败关键

Pod 成功扩容后,如果调度器发现集群内没有任何节点拥有可用的 GPU 资源,新增的 Pod 将一直处于 Pending 状态。此时,业务监控可能显示“已扩容”,但用户端体验到的依然是请求超时和排队。

因此,必须打通以下完整链路:

  1. 业务指标触发 Pod 扩容。
  2. 新增 Pod 因资源不足进入 Pending
  3. 节点自动伸缩组件(如 Karpenter)识别到未满足的资源请求。
  4. 在云平台拉起指定型号的 GPU 节点。
  5. 新节点注册到 Kubernetes 集群并变为可调度状态。
  6. Pending 的 Pod 被调度到新节点,启动并完成预热。
  7. 服务开始处理流量。

6.2 为什么更推荐 Karpenter?

相比传统的 Cluster Autoscaler,Karpenter 在 GPU 场景下有几个显著优势:

  • 实例选择更灵活:它能根据 Pending Pod 的实际资源请求(如需要 1 张 A100),动态选择最合适(未必是配置池中预设的)的实例类型,成本更优。
  • 启动速度更快:架构更简洁,减少了决策链路,节点拉起速度通常更快。
  • 容量优化更细粒度:更好的装箱(bin packing)算法,提升节点资源利用率。
  • 混合实例策略更友好:更擅长混合使用按需(On-Demand)实例和抢占式(Spot)实例。

6.3 节点池设计建议

建议至少将 GPU 节点划分为三类节点池:

节点池 主要用途 配置建议
在线核心池 承载高优先级、对延迟敏感的在线推理服务。 使用稳定、高性能的 GPU 机型(如 A100/H100)。保持一定量的基础保留容量,确保日常服务稳定。
弹性缓冲池 应对不可预测的流量高峰。 允许 Karpenter 根据 Pending Pod 的需求,自动选择并拉起最合适的 GPU 实例,高峰过后自动回收。
低成本池 运行批处理任务、离线推理或可降级的在线流量。 大量使用 Spot 实例以降低成本,但必须为 Pod 配置相应的容忍(tolerations)和中断预算,并做好熔断保护。

在标签设计上要明确,例如在 Pod 的 nodeSelector 中指定:

nodeSelector:
  workload-type: online-inference
  accelerator: nvidia-l40s

同时,务必通过 taintstolerations 机制隔离 GPU 节点,避免普通的 CPU 业务 Pod 被误调度上来,抢占宝贵的 GPU 资源。

7. GPU 资源共享:MIG、vGPU 与时间片复用如何选型?

整卡独占最简单,但未必最经济。资源共享技术可以提升利用率,但会引入复杂度。

7.1 MIG(Multi-Instance GPU)

适用于 NVIDIA A100、H100 等支持硬件级切分的 GPU。

  • 优点:隔离性强(每个 MIG 实例拥有独立的流处理器、显存带宽),性能稳定可预测,非常适合多租户场景。
  • 缺点:切分粒度固定(如 A100 可切分为 1/2/3/4/7 个实例),不够灵活;对模型大小和部署形态有一定约束。

7.2 vGPU / 虚拟化切分

通常基于 SR-IOV 或类似技术,在驱动层或 Hypervisor 层实现单卡多实例。

  • 优点:可以更灵活地定义切分规格,理论上资源利用率更高,适合部署大量小模型或实现高密度多租户。
  • 缺点:隔离性和性能可预测性通常弱于 MIG;在工程实现和商业许可上复杂度更高。

7.3 时间片复用

通过 CUDA MPS 或类似技术,让多个进程分时共享同一张 GPU。

  • 适用场景:主要用于离线训练、批处理推理等对尾延迟不敏感的离线任务。
  • 在线推理警告:在线服务需极度谨慎,因为时间片切换可能显著放大尾延迟(P99 Latency),影响用户体验。

7.4 生产建议

  • 大模型在线推理:优先选择整卡独占MIG,保障性能隔离和延迟稳定。
  • 中小模型、高密度多租户:优先考虑 MIGvGPU,在可接受的隔离性下提升资源利用率。
  • 对尾延迟极其敏感的场景:避免使用激进的(如时间片复用)共享策略。

8. 冷启动:GPU 弹性最大的“敌人”

很多时候,拖慢扩容速度的不是 HPA 决策慢,而是漫长的冷启动过程。

8.1 冷启动耗时拆解

一个 GPU 推理 Pod 从 0 到 Ready 的耗时通常由以下几部分构成:

  • 新节点拉起:20秒 - 120秒(云厂商 API 调用、虚拟机初始化)。
  • 镜像拉取:10秒 - 180秒(取决于镜像大小和网络速度)。
  • 模型权重下载:30秒 - 数分钟(模型动辄数十 GB)。
  • 模型加载到显存:10秒 - 120秒(从磁盘或内存加载到 GPU)。
  • 推理引擎初始化与预热:5秒 - 60秒(构建 CUDA 上下文、加载内核、执行一次空推理)。

8.2 常见优化手段

保留“温”实例

对于核心在线服务,建议设置 minReplicas >= 1。对于特别重要的模型,甚至可以考虑 minReplicas >= 2,以避免单个实例故障或滚动更新时出现服务空窗期。

镜像预拉取

通过 DaemonSet 或 Init Container,在节点就绪后提前将常用的推理框架镜像拉取到本地,减少 Pod 启动时的镜像拉取时间。

模型本地缓存

避免每次启动都从远端对象存储拉取巨大的模型文件。可以将模型缓存至:

  • 节点本地 NVMe SSD(速度最快)。
  • 高性能共享文件系统(如 Lustre, WekaFS)。
  • 专门的模型缓存服务(如 Dragonfly)。

预热探针(Readiness Probe)

确保服务的 /readyz 端点只在模型完成加载、CUDA 上下文初始化、并成功执行一次示例推理(dummy inference)后才返回成功。否则,负载均衡器可能会将用户流量导到一个“看起来 Ready 但实际无法处理请求”的 Pod,导致首批请求全部失败。

9. 生产级实现:从监控、扩缩到交付的完整链路

下面给出一套可落地的实现方案与组件清单。

9.1 基础组件清单

建议部署以下核心组件:

  • Kubernetes 1.27+
  • NVIDIA GPU Operator:一站式管理 GPU 设备与相关组件。
  • Prometheus Operator:简化 Prometheus 生态的部署与管理。
  • DCGM Exporter:由 GPU Operator 部署,负责暴露 GPU 硬件指标。
  • Prometheus Adapter:将自定义的 Prometheus 指标转换为 Kubernetes Custom Metrics API,供 HPA 使用。
  • KEDA 或 HPA:根据实际场景选择扩缩控制器。
  • Karpenter:负责节点层的弹性供给。
  • 推理框架:根据业务选择 vLLM、Triton 或自研服务。

9.2 关键指标采集

  • GPU 硬件指标:由 DCGM Exporter 自动采集并暴露给 Prometheus。
  • 应用业务指标:需要在推理服务代码中主动暴露。例如,一个自研的推理服务应该暴露以下指标:
    • inference_requests_waiting (Gauge): 当前排队等待的请求数。
    • inference_requests_inflight (Gauge): 当前正在执行的请求数。
    • inference_ttft_ms_bucket (Histogram): 首 Token 延迟的分布。
    • inference_generation_tokens_total (Counter): 累计生成的 token 数。
    • inference_kv_cache_usage_ratio (Gauge): KV Cache 使用率。

10. 生产级应用代码示例:暴露核心业务指标

下面是一个可直接用于生产改造的 Python (FastAPI) 示例。这段代码的重点不在于直接上线,而在于阐释了生产级 GPU 推理服务必须具备的几个关键设计原则:

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()})

这段代码体现的核心设计原则

  1. 显式队列与并发控制:通过 asyncio.QueueSemaphore 控制请求处理流程,防止请求无限堆积压垮服务。
  2. 业务指标暴露:服务自身通过 prometheus_client 暴露 inference_requests_waitinginference_requests_inflightinference_ttft_ms 等关键指标。这是将扩容信号从“GPU忙不忙”升级为“用户请求有没有积压”的基础。
  3. 就绪探针与预热分离/readyz 端点只在模型完成 load()warmup() 后才返回成功,确保就绪的 Pod 能立刻处理请求。
  4. 队列保护:当队列满时(queue.full()),立即拒绝新请求(HTTP 429),避免服务雪崩。

11. Prometheus Adapter:桥接业务指标与 HPA

如果选择 HPA 作为控制器,需要配置 Prometheus Adapter,将 Prometheus 中的自定义指标转换为 HPA 能够消费的 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>>)'

12. HPA 生产配置示例:基于队列与 KV Cache 的联合扩缩

下面是一个比单纯依赖 GPU 利用率更适合 LLM 推理服务的 HPA v2 配置。

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:长达10分钟的缩容稳定窗口,有效防止因流量短暂波动导致的副本数来回抖动(Thrashing)。
  • 扩容激进,缩容保守:这是在线服务的常见原则。scaleUp 策略允许每分钟最多扩4个 Pod 或直接翻倍,而 scaleDown 则限制每分钟最多缩20%。

13. KEDA 生产配置示例:基于 Prometheus 队列指标驱动突发扩容

如果流量波峰波谷明显,KEDA 可能是更合适的选择。

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: llm-inference-scaledobject
  namespace: inference
spec:
  scaleTargetRef:
    name: llm-inference
  minReplicaCount: 1
  maxReplicaCount: 30
  pollingInterval: 15
  cooldownPeriod: 300
  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleUp:
          stabilizationWindowSeconds: 30
        scaleDown:
          stabilizationWindowSeconds: 600
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus-operated.monitoring.svc:9090
        metricName: inference_requests_waiting_total
        query: |
          sum(inference_requests_waiting{namespace="inference",app="llm-inference"})
        threshold: "8"

启用 scale-to-zero 的前提
如果设置 minReplicaCount: 0,必须确保:

  1. 业务允许冷启动延迟:从 0 到 1 的启动时间能被业务方接受。
  2. 网关有保护机制:在服务未就绪时,网关能妥善处理排队或返回优雅降级响应。
  3. 优化冷启动:已实施镜像预拉取、模型缓存等优化手段。
  4. 首次请求体验可控:明确告知业务方首个请求会较慢,或由系统自动发送预热请求。

否则,盲目追求 scale-to-zero 可能会将成本优化转化为线上事故。

14. Deployment 生产配置:探针、优雅终止与资源声明

一个生产就绪的 Deployment 配置需要面面俱到。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: llm-inference
  namespace: inference
spec:
  replicas: 2
  selector:
    matchLabels:
      app: llm-inference
  template:
    metadata:
      labels:
        app: llm-inference
    spec:
      terminationGracePeriodSeconds: 180
      nodeSelector:
        accelerator: nvidia-l40s
        workload-type: online-inference
      tolerations:
        - key: "nvidia.com/gpu"
          operator: "Exists"
          effect: "NoSchedule"
      containers:
        - name: server
          image: registry.example.com/llm-inference:1.0.0
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: "4"
              memory: "16Gi"
              nvidia.com/gpu: "1"
            limits:
              cpu: "8"
              memory: "24Gi"
              nvidia.com/gpu: "1"
          readinessProbe:
            httpGet:
              path: /readyz
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
            failureThreshold: 24
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          lifecycle:
            preStop:
              exec:
                command:
                  - /bin/sh
                  - -c
                  - "sleep 20"
          env:
            - name: NVIDIA_VISIBLE_DEVICES
              value: all

缩容时的优雅终止(Graceful Shutdown)
这一点在 GPU 推理中尤为重要,因为推理请求可能是长连接或流式输出。如果 Pod 被直接强制杀死,会导致用户会话中断。

  • terminationGracePeriodSeconds: 180:给予 Pod 足够长的优雅终止时间。
  • lifecycle.preStop:执行一个 sleep 20 命令,在收到终止信号后,先等待 20 秒。这期间,Kubernetes 会将 Pod 从 Service 的 Endpoints 列表中移除,停止向其发送新流量。同时,应用自身应停止接收新请求,并等待正在处理的(特别是流式)请求完成。
  • 建议结合使用:在应用层实现请求排空逻辑,并利用 Ingress 或 Service Mesh 的连接排空功能,实现更平滑的缩容。

15. Karpenter 节点供给示例:为 GPU 推理准备弹性资源池

下面是一个简化的 Karpenter NodePool 配置示例,其核心思想是:让因资源不足而 Pending 的 GPU Pod 驱动 Karpenter 去云平台拉取合适的节点。

apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: gpu-elastic
spec:
  template:
    metadata:
      labels:
        workload-type: online-inference
        accelerator: nvidia-l40s
    spec:
      taints:
        - key: nvidia.com/gpu
          effect: NoSchedule
      requirements:
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["g"] # GPU实例族
        - key: karpenter.k8s.aws/instance-gpu-count
          operator: In
          values: ["1", "4", "8"]
      expireAfter: 720h
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 300s
  limits:
    cpu: "2000"

生产环境细化建议

  • 多 NodePool 区分:为不同的 GPU 型号(A100, H100, L40S)或不同业务优先级(核心,弹性,批处理)创建不同的 NodePool。
  • 混合实例策略:在 NodePool 的 requirements 中配置,允许同时使用 On-Demand 和 Spot 实例,并为 Spot 实例配置更宽松的 expireAfter 和更主动的 consolidationPolicy
  • 精细化调度标签:通过更丰富的节点标签和 Pod 的 nodeSelector/affinity,实现大模型、小模型对不同规格 GPU 的精准调度。

16. 实战案例:日间高峰明显的 LLM 问答平台弹性设计

假设一个企业知识问答系统具有以下特征:

  • 请求量在工作时间(9:00-18:00)显著升高。
  • 请求以流式输出为主。
  • 主力模型为 14B 参数量的指令微调模型,单实例需要 1 张 L40S GPU。
  • 低峰期 2 个副本足够,高峰期需要扩容到 12-16 个副本。
  • 业务要求 P95 首 Token 延迟小于 1.2 秒。

16.1 架构设计

  • 推理层:使用 vLLM 部署为 Deployment。
  • 网关层:使用 API Gateway 进行认证、限流和超时控制。
  • 指标层:Prometheus + DCGM Exporter + vLLM 应用指标。
  • Pod 弹性:KEDA,基于 vllm_requests_waiting 队列指标进行扩容。
  • 节点弹性:Karpenter,动态补充 L40S GPU 节点。
  • 成本优化:夜间自动缩容至 2 副本,周末允许非核心服务降级运行。

16.2 关键策略

  1. 扩容看队列,不看 GPU:因为高峰期的瓶颈首先体现在请求排队上,而非 GPU 算力满载。以 request_queue_length 作为首要扩容信号。
  2. 缩容比扩容更保守:设置扩容稳定窗口 30 秒,缩容稳定窗口 600 秒。避免因白天流量的自然波动导致副本数频繁抖动。
  3. 保留基础容量:在高峰来临前,通过定时任务或预测性伸缩,提前将 minReplicas 从 2 提升到 4,确保始终有“温”实例备用。
  4. 节点预热:在预期的高峰时段前,通过脚本或控制器,主动扩展 GPU 节点池并执行镜像预拉取和模型缓存,将冷启动时间降至最低。

16.3 预期收益

实施上述方案后,通常可以获得:

  • 流量高峰期间的 P95 延迟更加稳定。
  • 尾延迟(P99)显著下降,用户体验提升。
  • GPU 资源的平均利用率得到提高(闲时回收,忙时高效利用)。
  • 夜间及周末的闲时 GPU 成本大幅下降。

17. 平台化演进:从单服务扩缩到多模型调度

当团队从管理几个模型发展到需要管理数十上百个模型时,仅针对单个 Deployment 进行扩缩已经不够,需要向平台级调度演进。

17.1 引入模型路由层

统一管理:

  • 模型名到后端实例池的映射
  • 热门模型与长尾模型的资源优先级调度
  • 模型版本的灰度发布与流量切换
  • 为大客户或关键业务提供专属的实例池

17.2 预填充与解码分离部署

针对大模型推理,可将计算模式不同的阶段拆分开:

  • Prefill 阶段:对算力和显存带宽极其敏感,适合使用高算力 GPU。
  • Decode 阶段:对持续的并发处理能力和显存容量更敏感,适合使用大显存 GPU。
    将两者分离部署并独立扩缩,通常能获得比单体服务更好的资源利用率和成本效益。

17.3 实现多层降级策略

生产环境不能只会“扩容”,还必须具备“降级”能力以保护核心 SLO:

  • 队列过深时:拒绝超长上下文(context length)的请求。
  • 高峰负载时:限制单次请求的最大生成 token 数(max_tokens)。
  • 资源极度紧张时:将部分非核心请求路由到更小、更快的模型(降级模型)。
  • 保障核心租户:对非核心或低优先级租户实施请求限流。

这些应用层的降级措施,有时比“紧急扩容更多 GPU”更快速、更有效。

18. 生产中最容易踩的“坑”

  1. 仅依赖 GPU 利用率扩缩:这是最常见的错误。GPU 使用率低可能意味着服务空闲,也可能意味着请求正在排队等待,需要结合业务队列指标判断。
  2. 就绪探针(Readiness Probe)过早通过:在模型完成加载和预热前,Pod 就进入了 Ready 状态,导致首批用户请求全部失败或超时。
  3. 缩容策略过于激进:GPU 服务启动慢,如果缩容窗口设置太短,容易造成系统在扩缩之间持续震荡,无法稳定。
  4. 节点自动伸缩与 Pod 自动伸缩未联动:只配置了 HPA,没配置 Karpenter/Cluster Autoscaler,导致 Pod 扩出来却无节点可调度,形成“假扩容”。
  5. 忽略长请求和流式请求的优雅终止:缩容时直接删除 Pod,中断了可能持续数十秒的流式响应,影响用户体验。

19. 一份可执行的落地检查清单

如果你的团队正从零开始构建 GPU 推理弹性体系,建议按以下顺序推进:

  1. 搭建监控基石:部署 NVIDIA GPU Operator、DCGM Exporter、Prometheus,确保 GPU 基础指标可观测。
  2. 补齐业务指标:在推理服务代码中暴露排队长度、并发数、TTFT、KV Cache 使用率等关键业务指标。
  3. 实现 Pod 级弹性:先采用 HPA 或 KEDA,基于上一步的业务指标实现 Pod 的自动扩缩,验证控制链路。
  4. 打通节点供给:部署 Karpenter 或 Cluster Autoscaler,确保 Pending 的 GPU Pod 能自动触发节点扩容。
  5. 优化冷启动:实施模型预热探针、镜像预拉取、模型本地缓存等优化,缩短扩容就绪时间。
  6. 提升资源密度:根据模型特性,评估并引入 MIG 或 vGPU 技术,提升单卡资源利用率。
  7. 平台化治理:最后考虑引入模型路由、统一配额管理、成本分摊等平台级能力。

20. 总结

在 Kubernetes 上实现 GPU 推理服务的弹性扩缩,其本质并非简单地“为 GPU Pod 配置一个 HPA”,而是构建一套完整的 异构资源排队与控制系统

真正能在生产环境生效的方案,需要协同多个层面:

  • 贴近业务的队列、延迟指标,而非单一的硬件资源指标来驱动扩缩决策。
  • HPA 或 KEDA 实现服务实例(Pod)层的快速弹性。
  • Karpenter 或 Cluster Autoscaler 保障底层 GPU 节点资源的及时供给。
  • 模型预热、缓存和保活实例 来克服漫长的冷启动延迟。
  • MIG、vGPU 及多节点池调度 来提升昂贵的 GPU 资源利用率。
  • 应用层的限流、排队和降级机制 作为最后防线,牢牢守住服务的 SLO。

将以上几个层面打通,GPU 推理服务才能真正从“能运行”升级为“运行得稳、成本可控、弹性高效”。

对大多数团队而言,最务实的路径并非一开始就追求大而全的复杂平台,而是优先建立这条最小可行闭环应用指标可观测 -> HPA/KEDA 可决策 -> Karpenter 可补货 -> 服务预热完成再接流量

当这条核心闭环稳定运行后,无论是后续接入 Triton、vLLM、TensorRT-LLM 等不同推理后端,还是进一步构建多模型管理平台、LLM 网关等高级能力,都将拥有坚实可靠的基础。欢迎在 云栈社区 分享你的实践经验或提出技术问题,与更多同行交流探讨。




上一篇:TTS高并发架构核心原理与实战:从缓存设计到音频分发全解析
下一篇:系统化排查Linux性能瓶颈:htop/iotop/vmstat实战指南
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-4-24 17:05 , Processed in 0.654781 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表