AI 推理服务有个让运维抓狂的特质:它吃 GPU,但 Kubernetes 原生的 HPA 只看 CPU 和内存。结果就是——流量打进来,GPU 已经 99% 占满,HPA 却因为 CPU 才 30% 而纹丝不动,请求在队列里越积越多,P99 从 300ms 飙到 30 秒。
这篇文章不聊概念,直接给你一套能落地的组合拳:DCGM Exporter 采 GPU 指标 → Prometheus Adapter 转成 Custom Metrics → HPA v2 按 GPU 利用率伸缩 → 预热池化解模型冷启动 → 限流 + 降级兜底。每一段配置都能直接抄。
一、为什么 CPU HPA 在 AI 服务上失灵
先说清楚一个根因,不然后面的配置你会「知其然不知其所以然」。
传统 Web 服务是CPU-bound:请求来了,吃 CPU,CPU 高了扩,CPU 低了缩,很自然。但 AI 推理服务是GPU-bound:一次 forward 推理,CPU 只是打打杂(预处理、tokenize),真正干活的是 GPU 的 CUDA Core 和显存。
结果就是三种「指标错配」:
| 场景 | CPU | GPU | 原生 HPA 反应 |
|---|---|---|---|
| 流量小、单请求大 | 低(15%) | 高(95%) | 不扩,请求排队 |
| 流量大、模型小 | 高(80%) | 低(40%) | 乱扩,浪费 GPU |
| 显存被打满 | 低 | 显存 100% | 不扩,直接 OOM |
结论:AI 服务必须用 GPU 指标做伸缩,CPU 最多当辅助。下面按「采集 → 暴露 → 伸缩」三步走。
二、GPU 指标采集:DCGM Exporter + Prometheus Adapter
K8s 自带的 metrics-server 只提供 CPU/Memory,压根不认识 GPU。要拿到 GPU 利用率、显存占用、温度,业界标准做法是 NVIDIA 的DCGM(Data Center GPU Manager)+DCGM Exporter。
2.1 部署 DCGM Exporter(DaemonSet)
DCGM Exporter 以 DaemonSet 跑在每个 GPU 节点上,通过 NVIDIA 驱动提供的 NVML 库读取 GPU 状态,暴露成 Prometheus 格式的指标。
# dcgm-exporter.yaml —— 每个 GPU 节点跑一个,采集该节点所有 GPU 指标 apiVersion: apps/v1 kind: DaemonSet metadata: name: dcgm-exporter namespace: monitoring spec: selector: matchLabels: app: dcgm-exporter template: metadata: labels: app: dcgm-exporter spec: # 关键:需要 hostPID 才能访问 NVML,且挂载设备 hostPID: true hostNetwork: true tolerations: - operator: Exists # 容忍 GPU 节点的污点,保证能调度上去 containers: - name: dcgm-exporter image: nvcr.io/nvidia/k8s/dcgm-exporter:3.3.5-3.4.0-ubuntu22.04 env: - name: NVIDIA_VISIBLE_DEVICES value: "all" ports: - name: metrics containerPort: 9400 volumeMounts: - name: pod-gpu-resources mountPath: /var/lib/kubelet/pod-resources readOnly: true volumes: - name: pod-gpu-resources hostPath: path: /var/lib/kubelet/pod-resources --- # 对应的 Service + ServiceMonitor,让 Prometheus 自动抓取 apiVersion: v1 kind: Service metadata: name: dcgm-exporter namespace: monitoring spec: selector: app: dcgm-exporter ports: - name: metrics port: 9400 targetPort: 9400部署完,访问http://<节点IP>:9400/metrics,你会看到DCGM_FI_DEV_GPU_UTIL(GPU 利用率)、DCGM_FI_DEV_FB_USED(显存已用)这类指标。DCGM_FI_DEV_GPU_UTIL就是我们伸缩的核心指标。
小提示:阿里云 ACK 上如果不想自己搭这套,可以直接装「云原生 AI 套件 ack-ai-dev-console」,它内置了 GPU 指标采集和 Dashboard,开箱即用。但自建 DCGM Exporter 的好处是:任何云/自建机房行为一致,不锁厂商。
2.2 Prometheus Adapter 把 GPU 指标变成 Custom Metrics
Prometheus 里有指标还不够,HPA 只认Custom Metrics API。这一步由Prometheus Adapter负责:它把 PromQL 查询结果包装成custom.metrics.k8s.io的 API,HPA 就能直接引用。
# prometheus-adapter 的自定义规则:把 GPU 指标翻译成 HPA 能懂的 custom metrics # 关键:seriesQuery 从 Prometheus 找原始指标,metricsQuery 定义最终暴露的名字
rules: default: false custom: - seriesQuery: 'DCGM_FI_DEV_GPU_UTIL{exported_namespace!="", exported_pod!=""}' resources: overrides: exported_namespace: { resource: "namespace" } exported_pod: { resource: "pod" } name: matches: "DCGM_FI_DEV_GPU_UTIL" as: "gpu_utilization" # 对同一个 Pod 的多卡取平均,得到「这个 Pod 的平均 GPU 利用率」 metricsQuery: 'avg by (<<.GroupBy>>) (avg_over_time(<<.Series>>{<<.LabelMatchers>>}[1m]))'配置完重启 Prometheus Adapter,kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 | grep gpu能看到pods/gpu_utilization这个指标,就说明通了。
三、HPA v2:按 GPU 利用率伸缩
指标就位,HPA 配置反而最简单——就是标准 HPA v2 语法,把 metrics 从resource换成pods类型的自定义指标。
# hpa.yaml —— 按 Pod 平均 GPU 利用率伸缩,目标 70% apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: ai-inference-hpa namespace: ai spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: llm-inference minReplicas: 2 maxReplicas: 20 metrics: # 核心:GPU 利用率,目标 70%(留 30% 缓冲应对突发) - type: Pods pods: metric: name: gpu_utilization target: type: AverageValue averageValue: "70" # 辅助:显存占用,防止利用率不高但显存先爆的情况 - type: Pods pods: metric: name: gpu_memory_utilization target: type: AverageValue averageValue: "80" behavior: scaleUp: stabilizationWindowSeconds: 60 # 扩容:60 秒窗口,GPU 紧张要快扩 policies: - type: Percent value: 100 # 每次最多翻一倍,防止误判打爆 periodSeconds: 60 - type: Pods value: 4 # 或每次最多加 4 个 periodSeconds: 60 selectPolicy: Max scaleDown: stabilizationWindowSeconds: 300 # 缩容:5 分钟窗口,模型冷启动贵,别急着缩 policies: - type: Pods value: 1 # 每次只缩 1 个,慢缩 periodSeconds: 120 selectPolicy: Min这里有两个老兵经验值得记住:
目标值别设太满。GPU 利用率 70% 不是拍脑袋——留 30% 缓冲,是因为 GPU 负载是「突发式」的,一个 batch 突然变大,如果没有缓冲,HPA 扩容的那几分钟里队列已经爆了。
扩容快、缩容慢。这是 AI 服务的铁律。扩容慢 = 用户等;缩容快 = 频繁冷启动(几十秒的模型加载),反而更伤。所以上面
scaleUp用 60 秒窗口、scaleDown用 300 秒窗口。
四、模型冷启动:预热 + 预热池
GPU 伸缩有个 Web 服务没有的「天坑」:模型冷启动慢。一个 7B 的模型,从 Pod 起来到把权重从磁盘/对象存储 load 进显存,少则几秒,多则几十秒。这几十秒里 Pod 是「Ready 但不 Serve」,流量打进来全是超时。
解决思路两条:预热(warm-up)和预热池(warm pool)。
4.1 预热:启动即加载,加载完才报 Ready
用startupProbe+ 一个「模型加载完成」的健康端点,把「进程起来了」和「模型能用了」区分开:
// Spring Boot AI 推理服务:暴露模型加载状态,配合 startupProbe @RestController public class ModelReadyController { // 原子变量标记模型是否已加载进显存 private static final AtomicBoolean MODEL_LOADED = new AtomicBoolean(false); @PostConstruct public void warmUp() { // 启动后立即加载模型(权重 → 显存),加载完才置 true Thread.ofVirtual().start(() -> { try { // 触发模型加载 + 跑一次 dummy 推理,让 CUDA 完成初始化 llmService.loadModel("qwen2.5-7b-instruct"); llmService.predict("ping"); // 预热一次,把 kernel 编译缓存也热了 MODEL_LOADED.set(true); } catch (Exception e) { log.error("模型预热失败", e); } }); } // startupProbe 会反复访问这个端点,直到返回 200 才算启动成功 @GetMapping("/actuator/ready") public ResponseEntity<String> ready() { return MODEL_LOADED.get() ? ResponseEntity.ok("ready") : ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body("loading"); } }配合 Deployment 里的探针:
# deployment.yaml 片段 —— 三探针各司其职 spec: template: spec: containers: - name: llm-inference image: myrepo/llm-inference:1.0 resources: limits: nvidia.com/gpu: 1 # 关键:申请 1 张 GPU,调度器才会把它放到 GPU 节点 startupProbe: # 启动探针:给足模型加载时间,最多等 5 分钟 httpGet: path: /actuator/ready port: 8080 failureThreshold: 30 # 30 * 10s = 最多等 5 分钟 periodSeconds: 10 readinessProbe: # 就绪探针:加载完成后,才把流量引进来 httpGet: path: /actuator/ready port: 8080 periodSeconds: 5 livenessProbe: # 存活探针:进程还活着就行,别用 ready 当 live httpGet: path: /actuator/health/liveness port: 8080 periodSeconds: 10划重点:startupProbe 和 readinessProbe 都指向
/actuator/ready,但含义不同。startupProbe 给模型加载「兜底时间」,readinessProbe 决定「流量什么时候进来」。如果只配 liveness/readiness,模型还没加载完,readiness 失败次数多了 Pod 会被直接干掉重启,陷入「加载 → 被杀 → 再加载」的死循环。
4.2 预热池:预启动 N 个副本,温热待命
预热解决了「单个 Pod 冷启动」的问题,但没解决「扩容时新 Pod 依然要冷启动」的问题。预热池的思路是:永远保持比实际需求多 1~2 个「温热副本」,一旦 HPA 要扩容,直接从池子里拿,而不是从零加载。
最简单的做法是把minReplicas设成 2,并配合「定时预热」:业务低谷期定时给所有副本发一个 dummy 请求,保持模型常驻显存(长时间无请求,驱动可能把权重换出显存)。
// 定时预热任务:每隔一段时间发个空请求,防止模型被换出显存
@Component public class ModelKeepWarmTask { @Scheduled(fixedDelay = 4 * 60 * 1000) // 每 4 分钟一次 public void keepWarm() { // 发一个最小代价的请求,让 GPU 保持活跃、权重常驻显存 String resp = llmService.predict("ping"); log.debug("keep-warm: {}", resp); } }生产上更完整的做法是KEDA + 预热:KEDA 支持基于「队列长度」的伸缩(比 GPU 利用率更贴近业务),配合一个常驻的预热副本,就能做到「流量一到,池子顶上,同时后台悄悄加载新副本」。
五、降级 + 限流:GPU 打满时的最后防线
弹性伸缩再好,也有一段时间差(扩容快也要 1~2 分钟)。在这段「GPU 已经打满、新副本还没起来」的空窗期,光靠 HPA 是不够的,必须上两道保险:限流(挡住过量请求)和降级(GPU 不可用时切到备选路径)。
5.1 限流:保护 GPU 集群不被冲垮
AI 服务限流不能用简单的 QPS——因为「1 个短请求」和「1 个长推理请求」资源消耗差百倍。更好的维度是并发数(in-flight 请求数)或Token 配额。
// 基于 Semaphore 的并发限流:同时只允许 N 个推理请求进入 GPU @Component public class GpuConcurrencyLimiter { // GPU 显存能同时容纳的最大并发推理数,压测出来的,不是拍脑袋 private final Semaphore semaphore = new Semaphore(16); public String predictWithLimit(String prompt) { // tryAcquire 拿不到就立刻拒绝,别排队——排队只会让后面的请求更慢 if (!semaphore.tryAcquire()) { throw new GpuBusyException("GPU 繁忙,请稍后重试"); } try { return llmService.predict(prompt); } finally { semaphore.release(); } } }在网关层(Spring Cloud Gateway 或 Sentinel)也配一道「预热期熔断」:当检测到多个 Pod 处于 loading 状态时,直接熔断,返回兜底话术「服务繁忙,请稍候」,而不是让请求无意义地等。
5.2 降级:GPU 不可用时切 CPU 或小模型
这是很多团队忽略的「性价比之王」:不是所有请求都值得占 GPU。简单问题用 CPU 推理或小模型,只有复杂问题才走大模型 GPU。
// 模型路由 + 降级链:复杂问题走 GPU 大模型,简单问题走 CPU 小模型 @Service public class ModelRouterWithFallback { public String route(String prompt) { // 第一步:意图分类(轻量,CPU 就能跑) boolean complex = intentClassifier.isComplex(prompt); try { if (complex) { return gpuBigModel.predict(prompt); // GPU:qwen2.5-14b } else { return cpuSmallModel.predict(prompt); // CPU:qwen2.5-0.5b,延迟高但免费 } } catch (GpuBusyException | TimeoutException e) { // 第二步:降级——GPU 打满时,复杂问题也先甩给 CPU 小模型兜底 log.warn("GPU 不可用,降级到 CPU 小模型: {}", e.getMessage()); return cpuSmallModel.predict(prompt); } } }这套「大模型 GPU + 小模型 CPU 兜底」的组合,实测能把 GPU 峰值压力削掉 30%~40%,因为大量「打招呼、简单问答」根本不需要 14B 的大模型。
实战建议(照抄版)
先把 GPU 指标接进来,再谈伸缩。别急着写 HPA。先确认
kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 | grep gpu能查到指标,这一步不通,后面全是空中楼阁。自建就用 DCGM Exporter + Prometheus Adapter,托管就用 ACK 云原生 AI 套件,二选一别混着用。HPA 目标值用压测定,别用经验值。70% 是我给的经验值,但你的模型、你的 batch size、你的显卡型号都不一样。正确姿势是:压测出「GPU 利用率到多少时 P99 开始劣化」,这个临界值减 10% 就是你的 target。没有压测数据的伸缩阈值,都是玄学。
冷启动和伸缩是同一个问题,必须一起设计。只做 HPA 不做预热池,等于「修了油门忘了打火」。最省事的组合是:
startupProbe 兜底加载时间 + minReplicas=2 保底温热 + 定时 keep-warm 任务,三件套上齐,冷启动对用户的伤害能降到最低。别在「GPU 要不要留缓冲」上抠,留的 30% 缓冲省下来的,是无数个深夜的告警。
CPU 时代的伸缩是「水涨船高」,GPU 时代的伸缩是「量体裁衣」——指标选错,扩得越多,死得越快。
下篇预告:第 9 周「云原生与 AI 工程化」收官,从下一篇 Day 64 起我们正式杀进大模型的内核——《后端工程师的大模型第一课:Transformer 通俗理解》。不需懂高数,我会用「图书馆检索」的直觉把 Self-Attention 讲明白,让你彻底搞懂这些天一直在调的大模型到底是怎么「看懂」一句话的。
往期回顾:
Day 62 - AI云服务企业级集成:百炼/混元/方舟平台接入对比。
Day 61 | Docker部署AI推理服务:Ollama + Open WebUI生产实践。
Day60-Serverless:函数计算改写传统Spring Boot。
Day59-阿里云ACK实战:生产级K8s集群部署与运维。
90篇JAVA高级深度分析与实践文章:做AI的主人,代码直接可用,附完整目录