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


![keda-ai-autoscaling.jpg](https://img.lixueduan.com/kubernetes/cover/keda-ai-autoscaling.jpg)

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

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

<!--more-->

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

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



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


先说清楚 HPA 的底层逻辑。

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

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

![hpa-metrics-failure](https://img.lixueduan.com/kubernetes/keda/01-hpa-metrics-failure.jpg)

### 第一个问题，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](https://img.lixueduan.com/kubernetes/keda/02-demand-side-signal.jpg)

KEDA 干的就是这件事。

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

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

| 对比维度 | HPA | KEDA |
| --- | --- | --- |
| 扩容信号来源 | 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](https://img.lixueduan.com/kubernetes/keda/03-keda-architecture.jpg)

---

**为什么选图片生成？** 因为图片/视频生成是 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](https://github.com/lixueduan/keda-ai-queue-demo)，下面逐个讲关键部分。

### 3.1 准备工作

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

```bash
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda --namespace keda --create-namespace
```

装完确认一下。

```bash
kubectl get pods -n keda
```

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

### 3.2 部署

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

```bash
git clone https://github.com/lixueduan/keda-ai-queue-demo.git
cd keda-ai-queue-demo
```

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

```bash
kubectl apply -k deploy/
```

确认 Pod 状态。

```bash
kubectl get pods -n keda-ai-demo
```

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

### 3.3 核心配置，KEDA ScaledObject

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

```yaml
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: QueueLength`，`value: "5"`，**每个 Pod 处理 5 条消息**。队列里有 23 条消息，KEDA 就算出需要 ceil(23/5) = 5 个 Pod。

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

### 3.4 制造队列积压，观察扩容

端口转发 API。

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

创建一批任务。

```bash
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
```

查一下任务状态。

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

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

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

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

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

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

```bash
kubectl logs -n keda deploy/keda-operator -f | grep -E "Scale|scale"
```

```text
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](https://img.lixueduan.com/kubernetes/keda/04-scaled-object-vs-job.jpg)


### 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

---

清理环境。

```bash
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，建议重新审视一下扩容策略，可能它已经在「失灵」了，只是你还没注意到。


---

> 作者: [意琦行](https://github.com/lixd)  
> URL: https://www.lixueduan.com/posts/kubernetes/60-keda/  

