我把 KEDA 接进现有 HPA 后,事件驱动扩容把冷启动从 4 分钟压到 8 秒
上周凌晨又被 Kafka 消费者 lag 告警叫醒,这已经是本月第三次。
问题很诡异:消息队列里的积压肉眼可见地涨,但 Pod 数量纹丝不动。等我打开 Grafana 一看,CPU 早就飙到 80% 以上,可 HPA 还在那慢悠悠地算平均值。等它终于决定扩容,又一批新 Pod 从镜像拉取到启动 health check,整整 4 分钟过去,队列早就堆成山。
我当时的想法就是:HPA 这玩意儿,对事件驱动型工作负载真的不太够用。
为什么 HPA 在队列消费场景下这么慢
先说清楚,HPA 本身没问题。它按 CPU / 内存利用率扩容,适合 Web 服务这种请求均匀打进来的场景。但我们的 Consumer 是典型的"突发型负载":
- 消息批量灌进来的时候,Consumer 忙着拉消息、反序列化、写业务库,CPU 不一定立刻上去。
- CPU 上去之后,HPA 默认每 15 秒采样一次,再等 30 秒稳定窗口,才决定扩容。
- 扩容动作触发后,新 Pod 从 0 到 Ready 又要 1-2 分钟。
这一套下来,黄花菜都凉了。
说白了,HPA 看的是"后果"(CPU 已经被打高),而我想看的是"诱因"(队列里有多少消息没消费)。
KEDA 是什么:把事件源直接变成扩容信号
KEDA(Kubernetes Event-driven Autoscaling)的核心思路很简单:让 Kubernetes 根据外部事件源的指标来扩缩 Pod,而不是只能看 CPU / 内存。
支持的事件源非常多,常用的包括:
- Kafka / RabbitMQ / NATS / Pulsar 等消息队列
- Prometheus 自定义指标
- PostgreSQL / MySQL 查询结果
- Redis 队列长度
- AWS SQS / Azure Service Bus / GCP Pub/Sub
- Cron 定时触发
最香的一点是:KEDA 不替代 HPA,而是复用 HPA。它自己只负责把事件指标翻译成 HPA 能理解的external metric,然后让 HPA 去执行扩缩容。所以它对现有集群侵入性很小。
安装 KEDA:一条 Helm 命令搞定
直接上命令:
helm repoaddkedacore https://kedacore.github.io/charts helm repo update helminstallkeda kedacore/keda\--namespacekeda\--create-namespace\--setresources.operator.requests.cpu=100m\--setresources.operator.requests.memory=128Mi装完之后会多出几个 Pod:
kubectl get pod-nkeda# keda-operator-xxx# keda-operator-metrics-apiserver-xxx# keda-admission-webhooks-xxxmetrics-apiserver 是关键,它负责把 KEDA 采集到的事件指标暴露给 Kubernetes metrics API,HPA 才能拿到。
实战:用 ScaledObject 按 Kafka lag 自动扩容 Consumer
我们有一个订单对账 Consumer,Deployment 长这样:
apiVersion:apps/v1kind:Deploymentmetadata:name:reconcile-consumerspec:replicas:2selector:matchLabels:app:reconcile-consumertemplate:metadata:labels:app:reconcile-consumerspec:containers:-name:workerimage:registry.local/reconcile-consumer:v1.4.2resources:requests:cpu:200mmemory:256Milimits:cpu:1000mmemory:1Gi给它配一个 ScaledObject:
apiVersion:keda.sh/v1alpha1kind:ScaledObjectmetadata:name:reconcile-consumer-kafka-scalernamespace:defaultspec:scaleTargetRef:name:reconcile-consumerpollingInterval:10cooldownPeriod:60minReplicaCount:2maxReplicaCount:50advanced:horizontalPodAutoscalerConfig:behavior:scaleDown:stabilizationWindowSeconds:120policies:-type:Percentvalue:20periodSeconds:60triggers:-type:kafkametadata:bootstrapServers:kafka-prod:9092consumerGroup:reconcile-consumer-grouptopic:order-reconcile-eventslagThreshold:"100"activationLagThreshold:"10"offsetResetPolicy:latestauthenticationRef:name:keda-kafka-trigger-auth几个关键参数解释:
pollingInterval: 10:每 10 秒查一次 Kafka lag,比 HPA 默认灵敏很多。lagThreshold: "100":每个 Consumer 平均 lag 超过 100 条就扩容。activationLagThreshold: "10":lag 低于 10 时直接缩到 minReplicaCount(不是 0,因为我们设了 min=2)。scaleDown.stabilizationWindowSeconds: 120:缩容前观察 2 分钟,避免抖动。
如果要把没消息时缩到 0,把minReplicaCount改成 0 就行。我们对账服务不能长时间 0 副本,所以留了 2 个垫底。
我还加了一层 Cron Scaler,早晚高峰提前预热
对账任务有明显的潮汐特征:每天早上 8 点和晚上 10 点会有两波高峰。与其等 lag 起来了再扩容,不如提前把副本数拉上去。
triggers:-type:kafkametadata:bootstrapServers:kafka-prod:9092consumerGroup:reconcile-consumer-grouptopic:order-reconcile-eventslagThreshold:"100"-type:cronmetadata:timezone:Asia/Shanghaistart:0 7 * * *end:0 10 * * *desiredReplicas:"15"KEDA 支持多个 trigger 组合,取所有触发器中计算出的最大值作为目标副本数。也就是说,Cron 时段内即使 lag 不高,也维持 15 个副本;非 Cron 时段按 Kafka lag 自动调节。
这个设计比单纯依赖 HPA 聪明多了。
效果:冷启动等待从 4 分钟压到 8 秒
直接上对比数据:
| 指标 | 改造前(纯 HPA) | 改造后(KEDA + HPA) |
|---|---|---|
| 触发扩容的滞后时间 | 45-90 秒 | 10 秒 |
| 从消息堆积到新增 Pod Ready | 约 4 分钟 | 约 8 秒(预热时段)/ 50 秒(非预热) |
| 峰值 Consumer 数 | 固定 8 个 | 自动 2-50 个 |
| 高峰期 P99 消费延迟 | 3.2 秒 | 180 毫秒 |
| 夜间低谷 CPU 利用率 | 18% | 降到 5% 以下 |
说明一下,8 秒不是 Pods 从 0 启动到 Ready 只要 8 秒,而是 Cron 预扩容已经在高峰前把副本铺好了。真正从 lag 触发到新增的 Pod Ready,大概在 50 秒左右,也比之前快了近 5 倍。
踩坑记录
这几处坑帮我省下你几天调试时间。
坑 1:offsetResetPolicy 用 earliest,结果没消息时lag永远是历史最大值
默认值有时候是 earliest。如果 Consumer 组是新建的,或者 topic 有历史数据没清,KEDA 会一直看到一个巨大 lag,然后无限扩容。
改成latest就好了:
offsetResetPolicy:latest坑 2:lagThreshold 配得太小,扩容像抽风
一开始我设了lagThreshold: "10",结果 Consumer 稍微慢一点就疯狂扩容到 50 个。后来改成 100,配合 Consumer 单实例处理能力算下来刚好。
公式参考:
目标副本数 = ceil(当前总 lag / lagThreshold)所以 lagThreshold 要根据你单 Consumer 的吞吐来调,不是越小越好。
坑 3:缩容太快,Consumer 还在处理中被 Kill
KEDA 缩容走的是 Kubernetes 优雅停机,但如果你的 Consumer 没正确处理 SIGTERM,消息可能处理到一半就被强杀。
我们在业务代码里加了:
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)deferstop()// 收到信号后,先停止拉取新消息,等待当前 batch 处理完<-ctx.Done()consumer.Close()同时把terminationGracePeriodSeconds调到 60 秒,给 Consumer 留足退出时间。
坑 4:metrics-apiserver 负载过高
KEDA 默认每个 ScaledObject 单独轮询。如果 ScaledObject 特别多,metrics-apiserver 的 CPU 会被拉满。
方案是开启 metric caching:
# values.yamlmetricsServer:useMetricsServiceGrpc:true或者把同一个 Topic 的多个 Consumer 用 ScaledObject 合并管理。
坑 5:authenticationRef 写错 namespace,触发器找不到 Secret
Kafka SASL/SSL 的认证信息建议用TriggerAuthentication或ClusterTriggerAuthentication。注意ScaledObject引用的authenticationRef如果没有写 namespace,默认只在 ScaledObject 同 namespace 查找。
apiVersion:keda.sh/v1alpha1kind:TriggerAuthenticationmetadata:name:keda-kafka-trigger-authnamespace:defaultspec:secretTargetRef:-parameter:saslname:keda-kafka-secretkey:sasl-parameter:usernamename:keda-kafka-secretkey:username-parameter:passwordname:keda-kafka-secretkey:passwordKEDA 和 HPA 不是替代关系,是互补
说实话,我并不是把 HPA 扔了。KEDA 解决的是"事件触发扩缩",HPA 解决的是"资源负载扩缩"。
我们现在的做法是:
- 队列型 Consumer 用 KEDA 按 lag / Cron 扩容。
- HTTP 服务继续用 HPA 按 CPU / 自定义响应延迟扩容。
- 有些服务两者结合,KEDA 兜底事件源,HPA 兜底 CPU 上限。
这样既保留了 HPA 的成熟稳定,又补上了事件驱动场景的短板。
写在最后
这次改造最大的感受是:扩容的"信号源"选对了,比扩容算法本身更重要。
HPA 看 CPU 没问题,但它看到的是结果。对于消息队列、定时任务、外部触发器这类场景,真正该看的是事件本身。KEDA 做的就是这件事,而且做得足够轻、足够 Kubernetes-native。
如果你也有 Consumer 半夜 lag 爆炸、CPU 已经红了 Pod 还没起来的经历,不妨花半天时间装个 KEDA 试试。
另外说一句,KEDA 的文档非常友好,大部分 scaler 直接复制改改就能跑。踩的坑基本都在上面列出来了,希望对你有用。
有问题评论区见。