KEDA队列驱动弹性伸缩实战:解决HPA在渲染任务中的扩容滞后
做这个项目之前我一度觉得K8s的HPA已经够用了直到一个晚上后台渲染队列堆了三万多个任务集群里的Worker却因为CPU没到阈值而纹丝不动。AI漫剧的推文短视频渲染量暴增指标却测不出来那一刻我意识到对于这种“任务排队驱动”的服务只看CPU和内存去扩容本质上是在用一个滞后的信号做反应。这也是我后来决定做“基于KEDA的队列驱动弹性伸缩”的起因。这篇内容不是搬运官方文档也不是泛泛介绍KEDA是什么。我会从AI漫剧推文短视频这个具体业务场景出发讲清楚为什么传统HPA在这类渲染服务上会失灵、KEDA的队列驱动机制是怎么和工作负载配合的以及我们在生产环境落地过程中踩过的坑。适合正在做视频渲染、批量任务处理、异步消费类服务的同学参考尤其是那些已经被“任务积压但指标正常”折磨过的人。1. 漫剧渲染任务的流量特征与“指标失灵”困局1.1 AI漫剧短视频的完整链路AI漫剧短视频本质上是一条流水线先用大模型批量产出剧本和分镜脚本再通过图像模型生成每一帧的漫剧画面配合TTS语音合成字幕最后交给渲染服务把图片、音频、字幕、特效合成一条竖屏短视频。这里面最吃算力的不是前面的生成环节而是最后的“渲染合成”环节——一段60秒的竖屏视频可能需要渲染几十秒甚至几分钟具体取决于分辨率、帧率、转场特效和字幕轨道的复杂度。渲染服务一旦开始处理就是一个比较重的计算过程CPU、内存、GPU都会飙升。但问题在于渲染任务并不是持续均匀到达的。漫剧内容运营有很强的“爆点”属性一条预告片投放、一个话题上了热门、某个时间段集中更新几十条剧集任务队列会瞬间从几百条膨胀到几万条。这种流量特征用“突发洪峰”来形容最贴切。1.2 CPU/内存HPA为什么在这里失效我最早给渲染Worker配的是标准的CPU HPAapiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: render-worker-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: render-worker minReplicas: 2 maxReplicas: 20 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70这套配置在服务类型比较稳定时没什么大问题但在渲染任务场景下它有两个致命缺陷第一任务还没被Worker拉起来之前队列积压反映不到CPU指标上。一个Worker从空闲到被塞满任务需要先轮询队列、拉取数据、初始化渲染引擎这个过程CPU可能还很平稳。等所有Worker都进入满载状态CPU指标冲上来了但新增的任务已经在队列里等了好几分钟了。HPA从检测到指标变化到完成扩容中间还有一段延迟等新的Pod Ready积压已经非常严重。第二CPU指标的波动幅度和任务队列的积压程度不成正比。渲染任务有大有小有些任务本身计算密度不高但并发量大有些任务CPU跑满但单任务耗时长。HPA只认得CPU平均值不认得队列里有多少任务在“排队等待”。换句话说指标和真实负载之间隔了一层队列缓冲这一层缓冲在突发场景下会把所有异常都“捂”住。1.3 队列指标才是真正的“负载信号”我当时把这套逻辑画过一张图越看越觉得清晰请求或任务进入系统后先写入消息队列再被Worker消费系统真正的“待处理积压量”就是队列长度。这个数字才是和用户体验直接相关的指标。如果队列长度在持续增长说明消费能力已经跟不上生产速度这时候无论CPU有没有超标都应该扩容反之如果队列长度长期为0说明Worker多了应该缩容。你可能会想那我直接写个脚本监控队列长度然后调用K8s API去改Deployment的副本数行不行当然可以但Custom HPA的稳定性、容错、冷却逻辑都得自己造轮子。KEDA的出现解决的就是这个问题——它把“读队列指标”这件事标准化了然后通过HPA的标准机制去驱动扩缩容。这就是为什么我最终选择KEDA而不是自己写监控脚本的原因。2. KEDA队列驱动伸缩的核心机制ScaledObject与HPA如何配合2.1 KEDA在这条链路上的角色KEDAKubernetes Event-driven Autoscaling不是一个独立于HPA之外的另一个伸缩器也不是要替代HPA。它的核心价值是把K8s集群之外的系统和HPA连接起来。KEDA的组件分两块一块是Operator负责监听ScaledObject这个CRD对象另一块是Metrics Adapter负责把外部指标暴露给K8s的metrics接口。你在集群里创建一个ScaledObject之后KEDA会根据你配置的trigger类型去外部系统Redis、Kafka、RabbitMQ、Prometheus等拉取实际的业务指标然后计算出一个期望副本数再通过创建或更新一个HPA对象来实现最终扩缩容。这个架构看起来很绕但实际运行中非常稳定。用一句话总结ScaledObject告诉KEDA“看哪个指标、什么时候扩容”KEDA把指标换算成HPA期望副本数并交给HPA去执行。2.2 ScaledObject各字段背后代表的伸缩策略一个最典型的Redis Streams队列驱动ScaledObject长这样apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: render-worker-scaler namespace: production spec: scaleTargetRef: name: render-worker pollingInterval: 10 cooldownPeriod: 120 minReplicaCount: 2 maxReplicaCount: 30 fallback: failureThreshold: 3 replicas: 10 triggers: - type: redis-streams metadata: address: redis-master:6379 stream: render:task:stream consumerGroup: render-workers pendingEntriesCount: 10 lagCount: 5这里的每个字段都不是随便填的背后对应着明确的伸缩策略scaleTargetRef.nameKEDA会去控制这个Deployment的副本数通过HPA间接操作。pollingIntervalKEDA每隔多少秒去外部系统拉一次指标。设得太短会对Redis造成额外压力设得太长会让扩容响应变慢。我最终用的是10秒。cooldownPeriod这是缩容冷却时间代表指标降到目标值以下后要等多久才允许缩容。这里有个比较关键的设定思路后面单开一节讲。minReplicaCount/maxReplicaCount这是扩缩容的边界防止队列波动时副本数失控。fallback当KEDA无法从外部系统读到指标时比如Redis挂了它不会把副本数降到最小值而是直接设置一个兜底值。这对“宁可多跑几个Pod也不能让任务完全卡死”的场景非常有用。2.3 KEDA不是单纯替换HPA而是“升级指标来源”可能你会有一个疑问既然KEDA最终还是通过HPA来实现伸缩那为什么我不直接写一个自定义指标给HPA用答案是理论上可以但你要自己去实现一个Metrics Adapter去对接Redis拿到队列长度再把它格式化成HPA认识的external metric。KEDA把这些工作全部封装了你只需要声明trigger类型和鉴权信息。KEDA创建HPA之后这个HPA的指标类型是external或object不再是CPU/内存那类resource指标。KEDA的Metrics Adapter会根据ScaledObject里trigger的配置动态地给HPA提供指标值。所以你在集群里看到的HPA是这样的REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE render-worker external/render-task-pending/10 2 30 4 5mTARGETS那列显示的值就是KEDA从Redis Streams里读到的pending entries数量除以触发阈值之后的结果。HPA根据这个值实时调整副本数。2.4 多个Worker并发模型对capacity设定的影响给ScaledObject配置触发阈值时有一个最容易忽略的地方一个Worker同时能消费几个任务。如果你的Worker是单线程串行消费那么每个Worker同一时刻只处理一个任务pendingEntriesCount设为多少就代表“多少个任务对应一个Pod”。但如果你的Worker内部用了并发协程一次可以同时处理5个任务那么你就要把阈值加大否则每个Pod都会被分配太多任务。我们做渲染Worker的时候内部是分了两层的入口侧有一个调度器它从Redis Streams拉一条消息然后交给渲染池子里的一个工作协程渲染池子大小可配置。这种情况下KEDA看到的“一个Pod的处理能力”实际上是这个池子的大小。你不能简单把pendingEntriesCount设为1否则一个Pod明明能同时跑8个任务你却让KEDA在8个任务时就扩容到8个Pod资源浪费非常明显。3. 从0到1落地基于Redis Streams的渲染Worker弹性伸缩3.1 架构与队列选型当时我们的任务队列有三套候选Redis Streams、RabbitMQ、Kafka。RabbitMQ和Kafka都是非常成熟的消息中间件各有各的优势但我最终还是选了Redis Streams核心原因有三个第一我们的任务量级还没有到需要Kafka来扛的程度。AI漫剧单天任务量峰值也就是百万级左右Redis Streams在单机或哨兵模式下完全扛得住。第二团队内部所有基础设施已经在用Redis不需要额外搭建和运维一套Kafka集群成本和心智负担更低。第三Redis Streams天然支持Consumer Group具备消费组、消费确认、Pending Entries List这些和任务队列场景完全匹配的机制。当然如果你已经有Kafka或RabbitMQKEDA对它们也都有内置的Scaler原理是类似的。选型上我不建议为了“用KEDA”而换队列关键还是看你的任务特征和现有基础设施。Worker侧的消费逻辑也很简单每个Pod内的调度器循环执行XREADGROUP读取新消息通过渲染池子并发处理处理完执行XACK确认消息。如果渲染失败不确认消息让消息停留在Pending Entries List里后续由专门的重试机制处理。3.2 安装KEDA组件安装KEDA最简单的方式是Helmhelm repo add kedacore https://kedacore.github.io/charts helm repo update helm install keda kedacore/keda --namespace keda --create-namespace \ --set metricsServer.useHostNetworktrue这里注意一点metricsServer.useHostNetwork如果网络环境有特殊限制比如某些云厂商的Pod网络策略可以不开。默认情况下Metric Server会通过Service的方式暴露接口大多数场景都没问题。安装完成后确认一下Pod状态kubectl get pods -n keda正常情况下你会看到keda-operator和keda-admission这些Pod处于Running状态。KEDA的版本建议选最新的稳定版我们当时从2.10升到2.12主要是为了用上更多Scaler的修复和日志优化。3.3 渲染Worker Deployment的改造Worker本身不需要为了适配KEDA做太多改造只做两件事第一给Deployment设置合理的resources.requests因为HPA判断Pod是否Ready、以及KEDA计算期望副本数时都会参考Pod的调度和运行状态。如果你不给Worker设置requests调度器可能把多个Worker塞到同一台机器上反而导致单机资源争抢。第二配置一个合理的readinessProbe。这里有个我们踩过的坑渲染Worker从启动到真正可以消费任务中间要加载一堆模型权重和模板资源可能需要30到60秒。如果你不配置就绪探针K8s会在Pod刚启动时就认为它Ready了KEDA可能在扩容瞬间就把大量消息分给一个还没准备好的Pod导致消息处理超时。配置一个就绪探针让Pod真正准备好之后才对外提供服务能有效避免这类问题。apiVersion: apps/v1 kind: Deployment metadata: name: render-worker namespace: production spec: replicas: 2 selector: matchLabels: app: render-worker template: metadata: labels: app: render-worker spec: containers: - name: worker image: registry.internal/render-worker:2025.06.01 resources: requests: cpu: 2 memory: 4Gi limits: cpu: 4 memory: 8Gi readinessProbe: exec: command: - sh - -c - curl -sf http://localhost:8081/health/ready initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 63.4 TriggerAuthentication密码与鉴权处理如果Redis没开密码直接在ScaledObject里写地址就行。但生产环境不可能不开密码Redis的密码不能直接明文裸写在ScaledObject里得通过TriggerAuthentication来引用K8s SecretapiVersion: v1 kind: Secret metadata: name: redis-credentials namespace: production type: Opaque stringData: redis_password: some-secure-password --- apiVersion: keda.sh/v1alpha1 kind: TriggerAuthentication metadata: name: redis-auth namespace: production spec: secretTargetRef: - parameter: password name: redis-credentials key: redis_password然后在ScaledObject的trigger里加上authenticationRef指向这个TriggerAuthentication。这里要强调TriggerAuthentication和ScaledObject必须在同一个命名空间。3.5 从零调试到看到扩容发生创建ScaledObject之后先去确认KEDA有没有正常给它生成HPAkubectl get hpa -n production这时候应该能看到一个由KEDA自动创建的HPA对象名字是你给ScaledObject起的名字。然后观察它的TARGETS值kubectl describe hpa render-worker-scaler -n production如果TARGETS显示的是unknown要么是Metrics Adapter没有正确暴露指标要么是TriggerAuthentication配置有问题。我调试的时候最常用的是kubectl logs -f deployment/keda-operator -n keda以及Metric Server的接口测试kubectl get --raw/apis/external.metrics.k8s.io/v1beta1 | head能列出你的metric列表说明指标链路已经通了。接下来就可以实际往Redis Streams里塞几条测试消息观察HPA会不会在下一轮polling周期把副本数往上抬。4. 生产环境踩坑记录四个真实问题排查链路4.1 队列满但Pod不扩容卡在TriggerAuthentication第一次联调的时候我往Redis里塞了500条消息等了3分钟Replicas纹丝不动。当时我去查KEDA的日志没有任何报错HPA的TARGETS显示unknown。我一度以为是Redis Streams的Scaler配置写错了。排查过程是这样的先检查Metric Adapter的日志发现组件在读取Redis的连接串时一直报认证失败。后来才发现是TriggerAuthentication指向的Secret名称写错了把redis-credentials写成了redis-passKEDA在启动时读不到密钥Metrics Adapter自然拿不到指标。改过来之后TARGETS立刻从unknown变成了50HPA在下一轮直接扩容。这算是一个很基础的错误但很典型。遇到TARGETS为unknown优先排查TriggerAuthentication的secret是否存在、名称是否一致、KEDA版本与TriggerAuthentication的apiVersion是否匹配。4.2pendingEntriesCount设成1导致副本数失控刚开始上线时我只求“一有任务就扩容”把pendingEntriesCount设成了1。结果每一次任务洪峰过来副本数都直接冲到max完全起不到“弹性”的效果反而把集群资源瞬间打满。后来我把阈值改成了10并且把Worker内部并发池调整到8。这样每个Pod可以同时处理8个任务KEDA看到队列里有80条消息时期望副本数就是8而不是80。这两个数字之间的换算关系一定要算清楚这是KEDA调优最核心的常数。我当时总结了一套简单公式[ \text{期望副本数} \lceil \frac{\text{pendingEntriesCount指标值}}{\text{触发阈值}} \rceil ]这里的“触发阈值”就是ScaledObject里配的pendingEntriesCount值它不是“队列里积压多少条就扩容到多少”而是“每个Pod期望承担多少条积压”。理解这一点就不会出现副本数失控的问题。4.3 cooldown设太小导致渲染中途反复缩容渲染任务有个特点单任务执行时间长短则10秒长则几分钟。如果在Worker还在处理任务的时候队列长度恰好暂时低于阈值KEDA就会触发缩容。任务处理到一半Pod被灭掉这个任务就丢了或者要重排队非常伤。我一开始把cooldownPeriod设为30秒结果高峰期出现了反复横跳队列积压1000条扩容到10个Pod任务被拉起来10秒后队列长度降到阈值以下30秒后开始缩容但新的任务又来了又触发扩容……整个集群像一个在不停抽风的橡皮筋。后来我把cooldownPeriod调到了120秒并且在Worker这边加了优雅退出机制收到SIGTERM信号后先停止拉新任务然后等待当前渲染任务完成再退出。K8s的terminationGracePeriodSeconds也要同步调大否则优雅退出还没执行完Pod就被强制杀掉了。这样配合起来问题基本解决。4.4 模型权重冷启动让扩容“迟到”前面提到readinessProbe能解决“未就绪就被打流量”的问题但真正上线后我发现扩容响应还是慢半拍。KEDA确实已经把副本数加上去了但新Pod要加载模型权重从创建到Ready需要40到50秒。这段时间内旧Pod还在拼命消费队列虽然增速放缓但积压量仍然居高不下。这个问题的本质是“扩容链路就绪时间 队列增长时间”。我们做了三个改进第一模型权重和模板资源打到本地缓存用initContainer预加载减少启动时间第二晚高峰期提前把minReplicaCount从2调高到5给系统预热出一批“热Pod”第三在maxReplicaCount允许的情况下把触发阈值调低一点让KEDA更早地触发扩容给冷启动留出时间余量。4.5 多命名空间下ScaledObject的作用域管理后期我们把环境拆了dev、staging、prod三个命名空间每个环境都有自己的一套Redis和Worker。ScaledObject的坑在于它默认只能作用于自己所在的命名空间你要给每个命名空间单独创建ScaledObject和TriggerAuthentication。我采过的一个坑是在prod环境创建ScaledObject时没有注意命名空间直接在默认命名空间创建出来导致线上HPA一直没生效。这个问题不值得细说但值得提醒ClusterRole绑定和命名空间隔离在多人协作的集群里特别容易被忽略。可以用kubectl get scaledobject -A来检查所有命名空间下的ScaledObject确保创建的位置是对的。5. 调优路径与实测效果5.1 关键参数与调优优先级KEDA接入之后真正需要长期调的参数不多但每一项都不能马虎。我按优先级排序参数建议值区间调优理由Worker并发池per Pod4~8影响单个Pod的消费能力也决定触发阈值设置pendingEntriesCount略大于并发池数太高则扩容太慢太低则副本数容易冲高pollingInterval5~15秒太小增加Redis压力太大扩容延迟增加cooldownPeriod90~180秒要大于单任务最长耗时避免缩容打断任务minReplicaCount视业务平稳量而定固定兜底避免空队列时全部缩完fallback.replicas至少是高峰期一半外部指标拉取异常时的兜底保障调优的顺序我建议是先定Pod并发池再根据并发池定触发阈值然后根据单任务最大耗时定cooldown最后用压测去微调pollingInterval。5.2 固定副本部署 vs KEDA队列驱动的对比数据接入KEDA稳定运行一个月之后我拉了一组对比数据。在这一个月里我们经历了三次内容高峰单日任务量峰值是平峰期的8倍左右。固定部署模式下我们常年保留8个Worker Pod高峰期偶尔还是要靠人工扩到12个平峰期大量机器白闲着。KEDA队列驱动模式下HPA会在平峰时把副本压到2~3个高峰时自动扩到15~20个任务平均排队延迟从原来的最高40分钟降到了2分钟以内集群峰值CPU使用率也更平滑没有再出现CPU打满后排队爆炸的情况。具体资源数上月均计算成本下降了大概46%主要省的是平峰期的冗余副本。这个数字在不同业务场景下会不一样但整体的收益趋势是明确的用任务积压指标驱动扩缩容比按照固定冗余量去预留资源要节省得多。5.3 后续扩展思路KEDA的触发器类型非常丰富不只是Redis Streams。如果你后续把任务生产方式改成Kafka事件驱动只需要换trigger类型其它配置基本不动。KEDA还支持定时伸缩如果你知道每天凌晨3点是更新高峰期可以额外加一个cron类型的trigger让最小副本数在那个时段提前提升减少冷启动带来的延迟。另外KEDA的ScaleTargetRef不止支持Deployment还支持StatefulSet、CustomResource这类工作负载。如果你的渲染Worker后续有状态依赖比如每个Worker负责固定的分镜资产那可以考虑从Deployment迁到StatefulSetKEDA同样可以驱动它伸缩。最后再分享一个经验队列驱动伸缩上线后最直观的感受是“心里踏实了很多”。以前每次运营说“今晚要发一批新剧”我们第一反应是“要不要手动加一下Worker”现在我们只需要看一眼队列积压数和KEDA的当前副本数然后该干嘛干嘛。不敢说这套方案适配所有业务但对于任务特征明显、流量波动大的渲染类服务KEDA的队列驱动模式确实是一个值得优先考虑的方向。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →