最近在给AI漫剧和推文短视频的渲染服务做弹性伸缩改造,踩了一圈坑之后终于把方案稳定下来。这个项目核心就是让Kubernetes里的渲染worker按队列积压量自动伸缩,而不是靠CPU内存这种滞后指标硬扛。整套服务跑在K8s上,上游任务由AI编排服务生成,渲染worker从Redis Streams拉取任务执行,队列一变长就能在几十秒内拉高并发,积压清空后自动缩容,夜里基本能收拢到个位数副本。如果你也在做AI短视频、漫剧渲染或者任何“排队型的计算任务”,这篇文章里的思路和配置可以直接参考。
1. 项目背景:AI漫剧渲染服务的弹性难题
1.1 一条漫剧短视频是怎么生产出来的
先梳理一下业务链路。AI漫剧的生产线大致是:脚本生成、分镜绘图、角色配音、镜头拼接、转场特效,最后才是渲染输出成片。推文短视频也类似,只是画面素材从漫画图换成了图文卡片和口播配音,整体流程没有那么重,但渲染这个环节同样是绕不开的。
中间那一大段AI生成环节,我们用了一套独立的编排服务,它把每一步的产物(分镜图片、配音音频、字幕文件、转场参数)统一丢到对象存储里,然后把渲染任务序列化成一条消息,写到Redis Streams队列。渲染worker启动后从队列里消费消息,拉取素材、加载渲染模板、跑ffmpeg或者自研的合成进程,最后把成品视频上传回存储,再回调业务方。
这个链路有一个很明显的特征:任何时候都可能被“催单”。比如运营在某个平台投了一条爆款剧本,或者某个时间段集中发布了一批漫剧,任务会在几分钟内突然从几十条涨到几千条。传统做法是常年开着三五十个worker副本,但这意味着每天大部分时间都在烧钱空转。
1.2 为什么CPU内存指标的弹性在这里行不通
很多团队一提到弹性伸缩,第一反应就是用HPA监控CPU和内存。但这套方案放到渲染场景下,效果非常糟糕。
原因在于一个时间差问题。当任务开始堆积时,worker可能正在忙渲染、也可能闲着等任务。如果是前者,CPU确实会高,HPA可以基于CPU扩容;但如果worker闲着呢?队列里刷了几千条新任务,CPU利用率依然很低,HPA根本不会触发扩容,任务就只能干等着。等到某个worker处理完手头任务开始拉取新消息时,CPU才飙高,这时候再扩容,已经晚了。
更麻烦的是渲染任务的CPU波动极其剧烈。ffmpeg在跑转码时能把多核吃满,但在拉素材、解析字幕阶段CPU又很低。基于CPU做弹性,扩出来的副本可能正好赶上任务短的空档,属于典型的“指标滞后、反应错位”。
所以我一开始就排除了纯CPU内存方案,把注意力放到队列积压量上。渲染任务天然就是“排队型任务”,队列里有多少条待处理消息,才是最直接、最能提前反映负载的信号。
1.3 队列驱动伸缩的核心思想
队列驱动的思路其实不复杂:你不用关心现在哪个worker到底忙不忙,只看队列里的积压情况就够了。比如Redis Streams里有800条消息,每个worker每30秒能消化15条,那瞬间就需要约53个worker才能保持积压不涨。
类比一下就像银行柜台:判断是否加开窗口,不是盯着某个柜员敲键盘快不快,而是看等候区坐了多少人。排队的人超过一定数量,就再开一个窗口;排队的人少了,关掉多余窗口。
这套思路落到K8s架构上,需要解决一个核心问题:**HPA如何感知队列积压指标?**Kubernetes原生HPA只认CPU、内存或者自定义metrics,而队列积压数据在Redis里,两者之间缺一座桥。这座桥就是KEDA。
2. 为什么选KEDA:架构设计与方案选型
2.1 KEDA是什么,解决了什么问题
KEDA全称是Kubernetes Event-Driven Autoscaling,CNCF孵化项目,定位就是“事件驱动的弹性伸缩组件”。它做的事可以简化成:监听外部事件源(Redis、RabbitMQ、Kafka、SQS、PostgreSQL等)的指标,把指标翻译成HPA需要的数据,然后驱动Deployment扩容缩容。
我没有选择自研metrics采集器耦合到Prometheus再用custom metrics API给HPA喂数据,那个方案要写的代码和维护成本都不低。KEDA的好处在于,它把“从Redis Streams读取长度数值”这个动作做成了标准scaler,用户只需要通过一个CRD声明“我要监控哪个Redis、哪个队列、长度大于多少就扩容到多少副本”,剩下的轮询、指标暴露、缩到0都由KEDA处理。
KEDA目前支持60多种scaler。我们项目用到的是redis-streams类型的scaler,它会查询指定Stream和Consumer Group的长度,然后根据配置计算期望副本数。
2.2 KEDA的工作原理拆解
KEDA有几个核心组件要理解清楚。
第一个是ScaledObject。这是用户定义的CRD,声明弹性伸缩的“目标对象”(Deployment)和“触发器”(比如Redis Streams队列),以及伸缩边界、轮询间隔、冷却时间等参数。
第二个是Scale Loop。KEDA的operator会为每个ScaledObject启动一个控制循环,按pollingInterval指定的时间间隔(默认30秒)去调scaler接口,查询当前队列积压量。
第三个是Metrics Adapter。它把队列积压量转成external.metrics.k8s.io指标,HPA的controller定期(默认60秒)拉取这个指标,结合配置计算需要的副本数,最终调整Deployment的replica数量。
这里有一个容易被忽略的点:KEDA并不替代HPA,它是HPA的上游“情报员”。KEDA通过ScaledObject生成或管理一个HPA对象,HPA始终是最终执行扩容缩容的组件。KEDA对HPA的增强体现在两点:
- 支持外部事件源指标;
- 支持把副本数缩到0。
原生HPA不允许设置0副本,但KEDA通过一个Activator组件代理流量,当队列空闲时能把工作负载缩到0,有新消息进入队列时再重新激活。在渲染这个场景,如果我们有一天能优化到“任务全自动、无人值守”,就能用这个能力把非高峰期的GPU节点成本直接打下来。
2.3 为什么最终弃用Cron定时伸缩
在做方案对比时,很多同事第一反应是“任务高峰是不是有规律?能不能用Cron定时扩容?”比如每天晚上8点扩到50个副本,凌晨缩到5个。
这个方案确实实现最简单,但我用了一段时间后发现问题:AI漫剧的发布节奏并不完全可控。运营投放效果、外部内容平台的流量分发、热点话题的突然爆发,都会让任务量出现无规律尖峰。有一次我们预判晚上9点有一波大推,提前扩了副本,结果任务推到凌晨1点才被平台放量走出来,Cron策略完全失效。
队列驱动是更贴近真实负载的伸缩策略——副本数量永远跟随B端的任务积压量变化,不需要猜测未来。这也是我推荐所有“任务型计算服务”优先考虑的方向。
3. 落地实操:从部署到配置
3.1 部署KEDA到K8s集群
KEDA的部署过程非常顺,直接用Helm Chart就能搞定。生产环境建议固定版本,我用的是2.12左右这个版本线。
helm repo add kedacore https://kedacore.github.io/charts helm repo update helm install keda kedacore/keda --namespace keda --create-namespace部署完成后,确认几个核心组件都起来了:
kubectl get pods -n keda kubectl get crd | grep keda正常情况下能看到keda-operator和keda-metrics-apiserver两个Pod运行,同时集群里多了scaledobjects.keda.sh、triggerauthentications.keda.sh等CRD。metrics-apiserver就是前面说的Metrics Adapter,它把自己注册成聚合API,这样HPA就能直接通过external.metrics.k8s.io读取队列指标。
注意:KEDA版本和K8s版本有对应关系,主流云厂商的托管K8s都在支持范围内。安装前最好看一眼官方文档的兼容性表格。
3.2 Redis Streams作为渲染任务队列的细节
我们选Redis Streams而不是Redis List,是因为Streams天然支持Consumer Group,每个消费者组有独立的消费进度和Pending消息列表。一个渲染worker挂掉的时候,它未确认的消息还能被组内其他worker重新消费,这一点对任务可靠性很重要。
任务生产时,AI编排服务执行以下操作:
XADD render:task:stream * taskId 20241010-001 type comic scriptUrl oss://... assetList [...]任务字段包括任务ID、渲染类型(漫剧/推文)、剧本地址、素材清单、回调地址等。每条消息都是一个JSON字符串,序列化后写入Stream。
消费端,每个渲染worker在启动时加入render:task:group消费者组,通过XREADGROUP读取新消息:
XREADGROUP GROUP render:task:group worker-1 COUNT 1 STREAMS render:task:stream >跑完渲染流程后,用XACK确认消息处理完成,再用XDEL删除消息(如果不需要保留记录)。
这里我特别强调一个点:**KEDA的redis-streams缩放长度指标,既可以是Stream的总长度,也可以是某个Consumer Group的Pending消息数,取决于配置里取哪个key值。**一开始我配置错了字段,监控的是整个Stream的长度,导致KEDA以为积压严重,实际上大部分消息都已经被某个消费者组领走了,只是还没确认。这个问题在后面“常见坑”里细讲。
3.3 ScaledObject配置详解
这是整篇最核心的一块配置,直接贴生产环境用的例子。
apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: render-worker-scaler namespace: ai-render spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: render-worker pollingInterval: 15 cooldownPeriod: 300 minReplicaCount: 2 maxReplicaCount: 30 advanced: horizontalPodAutoscalerConfig: behavior: scaleDown: stabilizationWindowSeconds: 300 triggers: - type: redis-streams metadata: address: redis://10.0.0.10:6379 stream: render:task:stream consumerGroup: render:task:group pendingEntriesCount: "150" activationPendingEntriesCount: "0" authenticationRef: name: keda-trigger-auth-redis-streams --- apiVersion: keda.sh/v1alpha1 kind: TriggerAuthentication metadata: name: keda-trigger-auth-redis-streams namespace: ai-render spec: secretTargetRef: - parameter: password name: redis-secret key: redis-password逐个参数说下我的实际调整经验。
pollingInterval:KEDA查询指标频率,默认30秒,我压到了15秒。这样队列积压的响应速度更快,但要注意不能设太小,否则每15秒扫一次Redis,对Redis压力虽小,对KEDA operator的负担会增加。15秒是我实测后的平衡点。
cooldownPeriod:触发缩容前的冷却时间,默认300秒。它表示“在指标降低之后,等待多久才允许缩容”。这个参数直接决定系统会不会频繁抖动。我一开始用默认值,发现任务清空后副本还要持续5分钟才降下来,后来配合HPA behavior一起调,才达到理想效果。
minReplicaCount:我设成2而不是0。纯理想情况可以缩到0,但实际因为渲染worker启动时要加载模型和资源文件,冷启动时间较长,完全缩到0会带来较大的首次请求延迟。生产上我们保留了2个常驻副本应对低频零星任务,同时也是为了维持消费者组的存活状态。
pendingEntriesCount:这是核心阈值。它的含义是“单个副本需要承担多少积压任务”。KEDA每次计算期望副本数的公式是:
期望副本数 = 当前队列积压量 / pendingEntriesCount比如队列里有3000个pending条目,pendingEntriesCount=150,那期望副本数就是20。所以这个值要根据你单个worker的吞吐能力来定。我统计过线上worker平均处理一条漫剧渲染耗时大约20秒到2分钟不等(取决于视频长度和特效复杂度),我取了一个保守值150,相当于期望每个worker在同一时间积压不超过150条任务。
activationPendingEntriesCount:激活阈值,通常设0,表示“队列里只要有一条任务就该尝试扩容”。如果你希望小批量任务不触发扩容,可以调大它。
3.4 压测验证:从波谷到波峰的真实变化
配置上线后,最重要的就是验证扩容和缩容是否符合预期。我压测时用的方法是直接往Redis里灌消息,模拟一波高峰。
压测场景:当前副本数为2,队列积压2000条渲染任务,单任务平均耗时40秒左右。按pendingEntriesCount=150算,期望副本数约14个。实际观察到的过程是:
- 第15秒左右,KEDA从Redis拉取到队列长度并更新external metric;
- 第30秒左右,HPA获取到新指标,计算出目标副本数14;
- 第40-60秒,Deployment滚动扩容,Pod逐步启动,每个Pod的启动时间约20秒(主要是加载渲染模板和模型);
- 第90秒左右,副本数稳定在14,worker开始并行消费积压任务。
链路延迟总共90秒。这个结果说明两个问题:第一,从队列积压到扩容完成之间存在固有的分钟级延迟,任务本身要能容忍这个等待;第二,如果对扩容速度有更高要求,可以考虑把pollingInterval进一步调小,或者增加一个基于任务量的“预扩容”机制。
缩容侧我也做了验证:停止灌消息后,等所有任务消费完毕,队列归零。KEDA在冷却时间结束后开始缩容,加上HPA的缩容稳定窗口(我设了300秒),整个缩容过程耗时约5分钟,最终回落到2个副本,没有出现快速抖动。
4. 常见问题与排查技巧实录
4.1 扩容不够快:从“任务积压”到“副本增加”延迟太长
这是最常见的投诉。理论上pollingInterval=15只是KEDA的查询间隔,HPA controller的同步周期默认是60秒。也就是最坏情况下,一条新消息进入队列后,最多要经过“KEDA等待15秒+下次HPA同步最长60秒”才能感受到扩容动作。两个周期叠加,最坏延迟到90秒。
要缩短这个延迟,两个方向:
- 调大HPA controller的同步频率,在kube-controller-manager启动参数里加
--horizontal-pod-autoscaler-sync-period=15s。云托管集群一般不支持直接改master参数,可以用KEDA官方支持的调整方式,或者联系云厂商确认能否配置; - 在ScaledObject里设置KEDA自带的HPA behavior条件,提前放大扩容步长。
另外还有一个容易踩坑的点:如果多个ScaledObject监控同一个Deployment,KEDA会合并它们的指标。比如一个看Redis Streams、一个看CPU,指标会取最大值,这个要注意别因为别处的干扰指标影响了整体扩容。
4.2 副本频繁横向抖动:任务扫完瞬间缩容又立刻扩容
这个坑我印象很深。上线早期,因为业务方会周期性重推任务,队列出现一种“积压一阵、清空一阵、又积压一阵”的节奏。结果副本在4个和12个之间反复横跳,worker频繁创建销毁,任务队列跟着波动,整体吞吐反而更差了。
原因有两层:一是KEDA的cooldownPeriod没设够长,任务清空后马上触发缩容;二是HPA的原生缩容行为太激进,默认缩容稳定窗口只有5分钟,而且没有配置“缩容比例限制”。
我的解法:
advanced: horizontalPodAutoscalerConfig: behavior: scaleDown: stabilizationWindowSeconds: 300 policies: - type: Percent value: 50 periodSeconds: 60stabilizationWindowSeconds=300保证缩容前至少观察5分钟,避免队列短暂空转就缩容。scaleDown的Percent策略限制每分钟最多缩50%副本数,避免大起大落。
4.3 队列明明很长,但副本就是不扩容
这个坑的信息量最大。有一次线上队列积压几千条,KEDA日志里却显示指标值为0,副本纹丝不动。排查下来发现是pendingEntriesCount和consumerGroup配置的问题。
KEDA的redis-streams scaler查询的有两种长度:
- Stream总长度:
XLEN,表示Stream里所有消息的数量,不管有没有被消费者领取; - Consumer Group Pending数:查询指定消费者组的Pending消息数量,表示已领取但未确认的消息数量。
我误配成了查询Stream总长度,但实际Worker一直在消费消息,新消息进Stream后很快被某个worker通过XREADGROUP领走,所以Stream总长度其实不高,但Worker处理速度又跟不上Pending增长,指标就一直虚低。
正确做法是监控Consumer Group的Pending数量,也就是pendingEntriesCount直接对应Consumer Group里未确认的消息量。这样才能准确反映“任务积压且没有被处理”。
定位方法很简单,用Redis的命令直接看数据:
XINFO STREAM render:task:stream XINFO GROUPS render:task:stream观察每个Consumer Group的pending数值,配合KEDA日志比对就能发现配置是否对得上。
4.4 消息确认丢失导致重复渲染
渲染任务非常吃资源,如果同一个任务被多个worker重复执行,不仅白白烧算力,还可能因为并发写同一个输出文件导致结果错乱。我们用的Redis Streams的消费机制是:worker通过XREADGROUP读到消息后,如果还没执行完就崩了,消息会留在Pending列表里;重启后同一个Consumer Group的其他worker会重新消费这些Pending消息。
这本身是可靠性设计,但也带来了重复渲染风险。当XACK和业务成功回调之间出现异常(比如网络抖动、进程被OOM杀掉),消息没有确认,下一个worker就会再次消费。
我们的做法是设计任务幂等机制:每条渲染任务的输出结果都按taskId存储到对象存储,worker执行前先检查输出路径是否存在且完整;如果已存在,直接跳过渲染流程,只做XACK确认。这样即使重复消费,也不会产生重复计算。
4.5 冷启动带来的隐性延迟
压缩所有优化之后,你可能会发现一个扎心的事实:从队列里来一条消息到第一个AI渲染真正开始执行,用户体感上可能已经等了几分钟。原因在于worker启动时需要加载较大体积的模型文件、字体、转场素材等资源。
我的建议有两步。第一步,给worker的Pod增加initContainers用来预热资源目录,或者用emptyDir+共享卷的方式把模型目录挂载进去,避免每个副本启动时都从对象存储拉取完整模型。第二步,设置minReplicaCount=2,维持常驻worker,这样零星任务不会触发冷启动。
如果你的服务是纯事件驱动且可以接受首次任务慢,那么缩到0是省钱利器;但渲染这种重计算服务,我个人更倾向保留一个极小副本池。
4.6 监控告警:别等队列炸了才发现
弹性伸缩只能保证系统在负载变化时自动调整规模,但万一KEDA本身出问题(比如Redis密码过期、ScaledObject被误删),队列就会悄悄积压。所以监控告警必须跟上。
我建议至少监控三块内容:
- KEDA组件状态:
keda-operator和keda-metrics-apiserver的Pod状态、日志错误率; - Redis队列长度:通过Prometheus的redis exporter采集
redis_stream_length,设置积压阈值告警。我设了两级:积压超过500条持续5分钟告警,超过2000条立即告警; - HPA状态:检查HPA的
CurrentReplicas和期望副本是否有异常波动。
至于成本监控,可以根据扩容后的Pod运行时长估算费用。压测时可以留意,高峰期把maxReplicaCount从30提到50,一个月如果频繁出现,预算差别会相当明显。
5. 一些心得和后续可以扩展的方向
改造完成之后,我最明显的感觉是“终于不用半夜起来手动扩副本了”。以前高峰期靠值班同学盯监控,看到Queue Lag上涨就赶紧kubectl scale,晚了就被用户投诉视频生成太慢。现在整个流程自动化,任务量上来时系统自动扩,清空后自动缩,运维工作量降了很多。
实际跑了一段时间,我还想在两个方向继续优化。
一是把pendingEntriesCount从静态改成动态。现在150这个值是拍脑袋定的,实际上不同类型任务差异不小:漫剧渲染时间明显长于推文视频,大分辨率4K项目比1080P更耗时。可以考虑给每条消息带上预估耗时权重,生产端在写入队列时就用一个预计算的“时长分”替代处理条数,KEDA根据总预估时长除以worker吞吐能力来决定扩容。这样伸缩会更精准。
二是把KEDA的缩容和GPU节点池的弹性联动起来。目前KEDA缩容到2个副本后,底层GPU工作节点并不一定立即释放,因为节点上有其他负载或正好在排空阶段。后面计划把KEDA的副本数指标接到Cluster Autoscaler,让节点池也能跟着任务量缩,这样才算真正端到端的成本优化。
队列驱动弹性伸缩这个方案,不只是适用于AI漫剧渲染,任何“任务先进队列再被worker消费”的场景都值得试——异步导出、批处理任务、爬虫抓取、甚至是数据处理管道,都可以用KEDA把队列积压变成扩容信号。先想清楚你的积压指标是什么、单副本吞吐量是多少、可接受的冷启动时间是多长,再去调ScaledObject参数,基本就能跑得很稳。