AI 时代,你的 HPA 可能已经失灵了

keda-ai-autoscaling.jpg

不知道你们有没有发现,进入 AI 时代之后,传统微服务那套 HPA 好像突然不好使了。

CPU 20%,内存 30%,监控面板一片岁月静好,但你的 AI 推理服务已经在排队了。HPA 看了一眼指标,嗯,一切正常,不用扩。

这不是 HPA 的 bug,是它背后那套「资源使用率等于负载压力」的逻辑,在 AI 推理场景下从根上就不成立。问题不在调参,在于观测信号本身就是失真的。

今天就来聊聊这件事的来龙去脉,以及目前我们认为最合理的解法,KEDA。

1. HPA 在 AI 场景下到底错在哪

先说清楚 HPA 的底层逻辑。

HPA(Horizontal Pod Autoscaler)的设计假设是,资源使用率跟负载压力成正比。CPU 高了说明忙,内存高了说明扛不住,扩就完事了。对传统 Web 服务来说,这个假设基本成立,请求来了 CPU 就涨,请求走了 CPU 就降,很线性。

但 AI 推理服务把这个假设的每一个环节都打破了。以图片生成服务为例。

hpa-metrics-failure

第一个问题,GPU 显存是常驻的

图片生成服务启动的时候,第一件事就是把模型权重加载到 GPU 显存里。比如 Stable Diffusion SDXL 这种级别的模型,光权重就要好几个 GB,加载完之后,这些显存就一直占着,不管你有没有请求,它都是满的。

所以你如果用 GPU 显存使用率做 HPA 指标,服务刚启动就已经「满」了。HPA 会觉得你一直需要扩容,即使压根没有任何请求在处理。

第二个问题,GPU 计算使用率跟请求量不成正比

生成一张图片,一般要迭代几十上百步,整个过程可能就要几秒到几十秒。在这个过程中,GPU 使用率直接飙到 90% 甚至 100%。

问题在于,一个请求就能把 GPU 跑满。1 个请求和 10 个请求,在 GPU 使用率上看起来差不多,都是满载。 HPA 那套「采样窗口取平均值再做判断」的逻辑,放到这里完全没意义,因为不管采多少秒,看到的都是满载。

更离谱的是,你按 GPU 使用率做 HPA,一个请求过来就触发扩容,扩出来的 Pod 加载模型要 30 秒到 2 分钟,等它准备好,原来那个请求早就处理完了。

你可能会想,那 CPU 呢?内存呢?

第三个问题,CPU 和内存指标跟 GPU 负载无关

推理服务的 CPU 主要做些预处理和调度,内存主要是模型权重的 CPU 侧缓存。0 个请求和 10 个请求的 CPU/内存使用,差别可能就是几个百分点。

所以,三个问题,三条路,全堵死了。HPA 最依赖的两个指标,在 AI 推理场景下,全部失真。

指标传统 Web 服务AI 推理服务
GPU 显存随负载动态变化模型加载后常驻满载
GPU 计算使用率与请求量正相关一个请求就能拉满,无法反映积压程度
CPU 使用率与请求量正相关仅做预处理,变化极小
内存使用率与请求量正相关主要是模型缓存,基本不变
HPA 扩容效果准确失真

这就是为什么 HPA 在 AI 场景下不是扩错了,就是扩晚了,要么干脆就不扩。它的设计假设被 AI 服务或者 GPU 的工作模式彻底打碎了。

那怎么办?

2. 别看机器忙不忙,看活多不多

HPA 之所以在 AI 场景下失灵,是因为它在看「机器忙不忙」。但 AI 推理的真正压力不在机器指标上,在「有多少活等着干」。

那「活」在哪?

  • 可能是消息队列里积压的消息
  • 可能是数据库里状态为 pending 的任务记录
  • 可能是对象存储里等着处理的文件
  • 也可能是外部系统通过 webhook 推过来的请求

形式不同本质一样,都是「有多少事还没干」。

对 AI 推理来说,最常见的载体是消息队列。图片生成、视频生成这些请求动辄几秒、几十秒甚至几分钟的,一般都会走异步处理。用户请求先进 MQ 排队,Worker 从队列里消费。队列里积压了多少消息,就是有多少待处理的推理请求。

这就是最真实的扩容信号。不根据指标看 GPU 忙不忙,直接看系统里有多少活没干完

demand-side-signal

KEDA 干的就是这件事。

KEDA 全称 Kubernetes Event-driven Autoscaling,核心思路特别简单,根据外部事件源的状态来决定扩缩容,而不是看 Pod 的资源使用率。消息队列里有积压就扩,没消息就缩,就是这么直接。

用一张表对比一下 HPA 和 KEDA 的核心差异。

对比维度HPAKEDA
扩容信号来源Pod 资源使用率(CPU/内存/自定义指标)外部事件源(队列深度、消费延迟等)
信号本质机器侧,看「忙不忙」需求侧,看「活多不多」
Scale to Zero不支持,minReplicaCount 最低为 1原生支持
外部指标接入复杂度需要 Prometheus Adapter + ServiceMonitor + 自定义指标 + HPA 规则一个 ScaledObject CRD 搞定
适合场景传统 Web 服务,资源使用率与负载线性相关异步处理,负载压力体现在队列积压

KEDA 支持 60 多种 Scaler,包括 Kafka、RabbitMQ、Redis、Prometheus、Cron 等,每个 Scaler 对接一种外部事件源,直接读取事件源的指标来决定扩缩容。

其中几个关键差异值得说一下。

可以缩到零。 没有请求的时候直接把 Pod 数量缩到 0,有请求来了再拉起来。对 GPU 这种昂贵资源来说,不用的时候就不占是最优的。

扩容信号直接来自需求侧。 不再绕一圈去猜 GPU 忙不忙,直接看队列深度、消费延迟这些业务强相关指标。

配置更简单。 用 HPA 接外部指标,你得部署 Prometheus Adapter、写 ServiceMonitor、配自定义指标、再写 HPA 规则,一整套流水线。KEDA 就一个 ScaledObject CRD,声明一下触发器类型和参数就搞定了。

下面用一个 demo 把整个流程跑一遍,让大家有一个大致的印象。

3. 实战,用 RabbitMQ 驱动 KEDA 扩缩容

我们搞了一个 demo 项目,模拟一个典型的 AI 图片生成服务架构,演示完整的流程。 整体架构如下。

  • 1)用户提交「生成一张猫的图片」这样的请求
  • 2)API 接收后把任务写入 DB 做状态追踪,同时把 task ID 发到 RabbitMQ
  • 3)Worker 从队列消费任务,没有真正跑模型服务,而是 sleep 一段模拟 Stable Diffusion 那种几秒到几十秒的推理延迟,然后把任务标记为完成。
  • 4)KEDA 监控 RabbitMQ 队列深度,队列有积压就自动扩 Worker,没消息就缩到零。

keda-architecture


为什么选图片生成? 因为图片/视频生成是 KEDA 扩容最典型的 AI 场景。这类请求处理时间长,没法像 ChatGPT 那样实时返回,必须走异步架构。用户提交请求后拿到一个 task ID,过几秒再来查结果。这个架构和真实 Stable Diffusion API、ComfyUI 工作流、甚至 Sora 类视频生成服务的后端,逻辑是一样的。

为什么加了 DB? 因为真实的图片生成服务,用户提交请求后需要能查到任务状态,从 queued 到 processing 到 completed,这个生命周期需要持久化存储。MQ 只负责通知 Worker 有新任务,不负责存状态。

真实的 AI 推理当然不会用 sleep,这里用 sleep 只是为了模拟推理耗时,让你不用 GPU 也能跑通完整的 KEDA 扩缩容流程。架构、队列、扩缩容逻辑,跟生产环境一模一样。

代码和全部部署文件在 GitHub,keda-ai-queue-demo,下面逐个讲关键部分。

3.1 准备工作

需要一个跑着 Kubernetes 的集群,并且装好 KEDA。

1
2
3
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace

装完确认一下。

1
kubectl get pods -n keda

应该能看到 keda-operator 和 keda-operator-metrics-apiserver 两个 Pod 在运行。

3.2 部署

克隆项目,直接 apply 即可,镜像已推送到 Docker Hub。

1
2
git clone https://github.com/lixueduan/keda-ai-queue-demo.git
cd keda-ai-queue-demo

一行搞定部署,PostgreSQL、RabbitMQ、API、Worker、KEDA ScaledObject 全部拉起来。

1
kubectl apply -k deploy/

确认 Pod 状态。

1
kubectl get pods -n keda-ai-demo

应该看到 PostgreSQL、RabbitMQ、API 三个 Pod 在运行,Worker 的 replicas 是 0,因为队列里还没消息,KEDA 不会给它扩容。

3.3 核心配置,KEDA ScaledObject

整个事件驱动扩缩容的配置就这一个 YAML。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: worker-scaler
  namespace: keda-ai-demo
spec:
  scaleTargetRef:
    name: worker
  pollingInterval: 5
  cooldownPeriod: 60
  minReplicaCount: 0
  maxReplicaCount: 10
  triggers:
    - type: rabbitmq
      metadata:
        queueName: inference-requests
        mode: QueueLength
        value: "5"

逐个解释一下。

scaleTargetRef: worker,KEDA 只管 Worker 这个 Deployment,API 和 PostgreSQL 跟 KEDA 无关。

pollingInterval: 5,每 5 秒检查一次队列状态。AI 推理服务对响应时间敏感,轮询间隔可以设短一点,默认是 30 秒。

cooldownPeriod: 60,队列清空后 60 秒才缩容。demo 里设短一点方便观察,实际使用建议设 300 秒以上,避免模型反复加载。

minReplicaCount: 0,启用 scale to zero,没请求的时候不占资源。

mode: QueueLengthvalue: "5"每个 Pod 处理 5 条消息。队列里有 23 条消息,KEDA 就算出需要 ceil(23/5) = 5 个 Pod。

至于 KEDA 怎么连接 RabbitMQ,它通过 TriggerAuthentication 从 Kubernetes Secret 里读取连接信息,不在 ScaledObject 里硬编码。具体可以看 demo 仓库的 deploy/keda.yaml

3.4 制造队列积压,观察扩容

端口转发 API。

1
kubectl port-forward -n keda-ai-demo svc/api 8080:8080

创建一批任务。

1
2
3
4
5
6
for i in $(seq 1 20); do
  curl -s -X POST http://127.0.0.1:8080/tasks \
    -H 'Content-Type: application/json' \
    -d "{\"input\":\"task $i\"}"
  echo
done

查一下任务状态。

1
curl -s http://127.0.0.1:8080/tasks/<task-id>

状态流转是 queued -> processing -> completed,整个过程都有时间戳可以看。

打开另一个终端,观察 Worker Pod 数量变化。

1
kubectl get pods -n keda-ai-demo -l app=worker -w

你会看到 Worker 从 0 个 Pod 开始,KEDA 检测到队列有积压后,根据我们的配置把 Worker 扩到 4 个 Pod 并发消费。队列清空、冷却期结束后,Worker 又缩回 0。

KEDA 的操作日志也能看到完整过程。

1
kubectl logs -n keda deploy/keda-operator -f | grep -E "Scale|scale"
1
2
3
Successfully updated ScaleTarget: Original Replicas Count: 0, New Replicas Count: 1
Successfully updated ScaleTarget: Original Replicas Count: 1, New Replicas Count: 4
Successfully set ScaleTarget replicas count to ScaledObject minReplicaCount: Original Replicas Count: 4, New Replicas Count: 0

0 -> 1 -> 4 -> 0,这就是 KEDA 事件驱动扩缩容的完整过程。

scaled-object-vs-job

3.5 整个流程

  1. 用户 POST 一个任务,API 写入 PostgreSQL,同时把 task ID 发到 RabbitMQ
  2. KEDA 每 5 秒检查 RabbitMQ 队列深度
  3. 队列有消息,触发器变为 active,KEDA 把 Worker 从 0 扩到计算出的副本数
  4. 扩出来的 Worker Pod 消费消息,标记任务为 processing,sleep 模拟推理,标记认为为 completed
  5. 队列消费完,冷却期结束,KEDA 把 Worker 缩回 0

清理环境。

1
kubectl delete namespace keda-ai-demo

4. 实际使用中需要注意的几个坑

配置写起来简单,但真正使用起来还是有一些需要注意的地方:

冷启动问题。 从 0 扩到 N 意味着 Pod 要从零开始启动,包括加载模型到 GPU。大型图片生成模型的加载可能要 30 秒到 2 分钟,这段时间请求只能排队。如果业务对延迟敏感,最好把 minReplicaCount 设为 1,始终保留一个热 Pod。

cooldownPeriod 别设太短。 缩容后又来请求,重新扩容 + 模型加载,这个周期可能要一两分钟。频繁缩容再扩容的代价很大,Pod 反复启停,GPU 资源白白浪费在模型加载上。建议至少设 300 秒,让流量真正稳定下来再缩。

HPA 会被 KEDA 接管。 KEDA 会在底层创建一个 HPA,所以同一个 Deployment 不能再手动创建 HPA,会冲突

轮询间隔要权衡。 pollingInterval 设得短,响应快,但对外部系统的 API 调用量也大。如果你有几百个 ScaledObject,每个 5 秒轮询一次,对消息队列的压力不小。根据实际场景在响应速度和资源消耗之间找个平衡点。

聊了这么多,最后收一下。

5. 小结

HPA 在 AI 推理场景下失灵,说到底是它的设计假设,资源使用率跟负载成正比,被 GPU 工作模式打破了。GPU 显存常驻、使用率脉冲、CPU/内存不跟负载走,HPA 看到的信号全是失真的。

KEDA 的思路是绕过资源指标,直接看需求侧的信号。消息队列里有多少消息在排队,就是最真实的扩容依据。

这个思路其实不只适用于 AI 推理,所有异步处理场景都有类似的扩缩容需求。只不过 AI 推理把这个问题放大了,因为 GPU 太贵了,扩错一台就是真金白银的浪费。

几个要点:

  • 别看机器忙不忙,看活多不多,队列深度就是 AI 推理服务最真实的扩容信号
  • KEDA 支持 scale to zero,GPU 不用的时候不占资源
  • 注意冷启动、cooldownPeriod、轮询间隔这几个关键参数
  • 没有银弹,KEDA 只是让扩容信号回归准确,模型加载的延迟还得靠预热或其他手段解决

如果你的 AI 推理服务还在用 HPA,建议重新审视一下扩容策略,可能它已经在「失灵」了,只是你还没注意到。

0%