AI 推理任务阻塞在网络 IO 而非 CPU,传统 HPA 无法感知;KEDA 读取 Redis 队列深度驱动弹性伸缩,可缩至零节省闲置成本。
为什么 CPU 是错误的信号
标准 HPA 按资源利用率来扩缩容。对于一个工作内容是等待网络调用的 Worker 来说,无论队列里是 3 条消息还是 3 万条消息,CPU 利用率都接近零。内存也好不到哪去。唯一能反映实际需求的是队列深度,而这个数字存在于消息代理中,不在 Kubernetes 的指标管道里。
KEDA 架起了那座桥。它按调度读取外部数据源,并在底层驱动标准 HPA,这意味着你保留了正常的 HPA 行为,同时获得了缩容到零的能力——这在这里真正有用,因为一个只为了持有 API 套接字而存在的空闲 Worker 池完全是浪费。
ScaledObject 及其默认值
KEDA 的 ScaledObject 位于 keda.sh/v1alpha1。其文档中的默认值是:pollingInterval 为 30 秒,cooldownPeriod 为 300 秒,minReplicaCount 为 0,maxReplicaCount 为 100。
其中有两个值在这个工作负载上值得深思。 每 30 秒轮询一次意味着从突发请求到达,到 KEDA 感知到它之间最多有半分钟延迟——当你的任务需要一分钟时这还好,但当任务只需两秒时就很差了。300 秒的 cooldownPeriod 是最后一次触发激活后、缩容回零之前的冷却期——它防止队列一空 Pod 就被杀掉,而且它与任务时长相互影响,因为一个在调用中途被终止的 Worker 意味着一次丢失或重复的模型调用。
KEDA 这里的字段名和默认值来自 KEDA 2.17 文档,阅读于 2026 年 8 月。Scaler 的 metadata 字段在次版本之间曾被重命名过;请检查你实际运行的版本对应的文档。 KEDA: ScaledObject specification
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: celery-inference-worker
spec:
scaleTargetRef:
name: celery-inference-worker
pollingInterval: 15
cooldownPeriod: 300
minReplicaCount: 0
maxReplicaCount: 30
triggers:
- type: rabbitmq
metadata:
protocol: http
queueName: celery
mode: QueueLength
value: "5"
activationValue: "1"
excludeUnacknowledged: "true"
authenticationRef:
name: keda-trigger-auth-rabbitmq-conn
触发器类型是 rabbitmq。mode 接受 QueueLength 或 MessageRate,value 是每个副本的目标值——当 value 为 5 且有 50 条消息在等待时,KEDA 会请求 10 个副本。activationValue 是一个独立的阈值,决定是否完全离开零状态,这就是为什么它与 value 不是同一个字段。
当你想用 MessageRate 或队列名正则时,请用 protocol: http 而不是 amqp,因为那些功能走的是管理 API。这也意味着管理插件必须可达——在 RabbitMQ for queued model inference on Kubernetes 中,它位于 operator 创建的 Service 上。
Redis 触发器,以及它计数什么
用 Redis 作为 Celery broker 时,相关的 KEDA scaler 是 redis,使用 Redis Lists 变体:Celery 将排队的任务存储在一个以队列命名的 Redis 列表中,默认是 celery。metadata 字段有 address、listName、listLength 作为平均目标值、activationListLength、databaseIndex(默认为 0)和 enableTLS。
与 RabbitMQ 的重要区别在于这个数字意味着什么。Redis 列表长度计算的是尚未交给 Worker 的任务。它不感知未确认投递的概念,因为 Redis 没有确认机制——Kombu 模拟了它们。所以一个已投递但未完成的任务已经离开了列表,scaler 看不到它。你的指标严格来说是"尚未开始的工作",这通常是你扩缩容想要的,但不同于实际积压。
如果你把任务路由到多个命名队列,记住每个队列都是一个独立的列表,需要各自的触发器。一个 ScaledObject 可以携带多个触发器,KEDA 取它们请求的副本数的最大值。
预取陷阱,以及抖动
这是一个浪费一下午的失败场景。Celery 文档记载 worker_prefetch_multiplier 默认为 4。并发量为 8 时,每个 Worker 在消息一出现就预留 32 条消息。两个 Worker 立即从可见队列中排干 64 条消息的积压,然后花一个小时处理它们。scaler 看到空队列,缩容到零或最小值,而积压在它被清除的整个期间都不可见。
修复有两方面。将 worker_prefetch_multiplier 设为 1,这样 Worker 只领取正在处理的任务,不再多领。在 RabbitMQ 上,则要刻意设置 excludeUnacknowledged:如果它是 false,scaler 会把已投递但未确认的消息也算作积压,这会高估需求,而且可能导致副本数上升,而现有 Worker 其实正忙。哪个设置正确取决于你的 prefetch 是否为 1;当 prefetch 为 1 且任务很长时,只计算就绪消息才是诚实的信号。
抖动是另一个需要从设计上防止的问题,而且这里它比通常更糟糕,因为缩容会杀死一个正持有付费飞行中调用的 Worker。三种设置可以缓解它:Deployment 上比你的硬任务时限更长的 terminationGracePeriodSeconds、不短于典型任务的 cooldownPeriod,以及通过 ScaledObject 的高级 HPA behaviour 部分配置的 HPA 缩容稳定窗口。没有这些的话,一个围绕目标值振荡的队列会产生一个随之振荡的 Worker 池,而每次振荡都消耗一代(Pod)。
将 KEDA 安装到它自己的命名空间,并确认 scaledobjects.keda.sh CRD 存在。
在 Celery 应用中将 worker_prefetch_multiplier 设为 1,并将 task_acks_late 设为 true,然后重新部署 Worker,再添加任何 scaler。
创建一个 TriggerAuthentication 来引用 broker 凭证 Secret,而不是把连接字符串放在 ScaledObject metadata 里。
应用 ScaledObject 时用一个保守的 value 和一个你已核对过的、符合提供商并发限制的 maxReplicaCount——否则 scaler 会乐意把你扩展到一堆 429 错误里。
将 terminationGracePeriodSeconds 设置为大于 Worker Deployment 上硬任务时限的值。
入队一个已知的积压,并观察副本数相对于队列深度的变化,完整经历一次上升到零的周期。如果深度在工作仍在继续时瞬间降到零,说明你的 prefetch 修改没有生效。
Celery and Redis for Queued Inference on Kubernetes
RabbitMQ for Queued Model Inference on Kubernetes
Autoscaling on GPU: Metrics That Actually Work