先说个结论:跑推理服务的人,早晚都要跟自动扩缩容死磕一次。我最早接手一个AI原生应用的云端推理服务时,生产环境还处于“人肉扩缩容”状态——流量高峰来了,大家盯着监控看GPU排队长度,排队超过某个数就手动加副本,等压下去了再手动减。这套流程听起来原始,当时倒也没出过大乱子,直到有一次午后流量突然涨了两倍,而峰值只持续了十分钟,人肉扩缩容根本追不上,那一瞬间我意识到,成本问题和稳定性问题必须一起解决,只能走自动扩缩容这条路。
这篇文章就把我整个落地过程复盘一遍:从指标选型、KEDA配置、部署细节,到一次真实的缩容事故排查。适合正在做推理服务部署、被扩缩容问题困扰的开发和运维同学,也包括准备把模型服务化上云的算法工程师。内容偏实战,我会把踩过的坑和文档里不写的细节都放进来。
1. 为什么推理服务的扩缩容不能照搬Web服务的玩法
1.1 GPU资源与CPU资源的本质差异
很多团队习惯把Web服务的扩容经验直接搬过来,结果发现完全不灵。原因是GPU推理和普通Web服务在资源模型上有本质区别。
CPU服务可以超卖,容器之间共享CPU核,请求处理是抢占式的,一个请求顶多拖慢其他请求,但很少直接堵死。GPU不是这样:显存是独占的,一旦分配给某个进程,就不会被其他进程抢走;算力核心也需要进程独占,推理引擎一般按模型最大序列长度预分配显存,进程没启动完,其余请求根本进不来。更关键的是,GPU是稀缺资源,节点上的调度通常是一次性分配整卡,一个Pod占了一张卡,这张卡的算力利用率再低,其他Pod也挤不进去。
这种独占性带来一个直接结果:给推理服务做自动扩缩容,本质上是在做“稀缺资源的预约管理”,而不是像CPU服务那样做“共享资源的弹性调配”。你需要在请求真正堆积之前就把新副本拉起来,因为拉起副本这一步本身就是一笔不小的时间开销。
1.2 模型冷启动的时间账单
我见过不少人在压测时忽略冷启动时间,结果一上线就翻车。一个典型的GPU推理实例从零到可服务,时间账单大致如下:
| 阶段 | 耗时 | 说明 |
|---|---|---|
| 拉取镜像 | 1~2分钟 | 推理镜像通常几个GB,涉及CUDA、推理框架、依赖库,节点无缓存时耗时明显 |
| 下载模型权重 | 几十秒到几分钟 | 模型文件从几GB到几十GB不等,取决于存储位置和网络带宽 |
| 初始化推理引擎 | 几秒到几十秒 | CUDA上下文初始化、模型加载进显存、权重反序列化 |
| 预热与编译 | 几秒到几十秒 | 使用加速引擎时首次推理前需要算子编译或加载缓存 |
把这四段加起来,单实例冷启动时间常常在2到5分钟。这意味着什么?如果等到GPU利用率冲到80%再触发扩容,新副本起来的时候,流量高峰可能已经过了,或者已经攒了一大批排队请求。自动扩缩容在这种场景下必须提前动作,不能像Web服务那样后知后觉。
1.3 GPU成本带来的容错空间缩小
自动扩缩容还要算经济账。GPU实例和普通CPU实例的价格差距很大,尤其是大显存卡,按小时计费跑一天的成本相当可观。如果为了应对尖峰流量长期保底10个副本,低谷期这10个副本基本就是在烧钱。
反过来,缩容太狠也不行。GPU实例一旦被回收,再要拉起一个新副本又要经历上面那几分钟的冷启动。低谷期缩到1个副本,结果流量突然回来,整个服务会在几分钟内处于“有副本但接不住流量”的尴尬状态。
所以推理服务自动扩缩容的容错空间比Web服务小得多,设计目标不是“尽量让副本数贴近流量”,而是“在成本可接受的前提下,让请求延迟始终处于可控范围”。这个定位想清楚之后,下面的指标选型和参数设计才有方向。
2. 指标选型:GPU利用率之外还要看延迟与队列
2.1 CPU利用率在推理场景里基本是“军师”
很多刚接触推理服务的人会习惯性设置CPU指标,但这在推理场景里基本没用。推理服务的算力主场在GPU,CPU只负责数据加载、预处理、结果返回这些边角活。一个服务即使QPS已经打满,CPU利用率也可能只有20%到30%。用CPU利用率做扩缩容信号,结果就是流量已经翻倍了,副本数还睡在那里一动不动。
2.2 GPU利用率及格但服务已经过载的场景
GPU利用率也不是万能的,它有一个很典型的盲区。推理请求通常分两个阶段:prefill阶段,负责处理输入Token,属于计算密集型,GPU算力被大量占用;decode阶段,负责逐个生成输出Token,属于显存带宽密集型,算力核心占用反而不高。
当多个请求被引擎拼进同一个Batch执行时,GPU利用率可能看起来很高,但如果请求队列已经堆了几百个,说明服务早就过载了,GPU只是在埋头干活而已。反过来,如果请求序列特别长,decode阶段占主导,GPU利用率可能只有50%甚至更低,但显存带宽已经饱和,新请求全部要排队。只看利用率,你会以为还有余量。
| 现象 | GPU利用率 | 真实状态 |
|---|---|---|
| 大批短请求并发 | 高 | 可能正常,也可能已经排队 |
| 长序列生成占主导 | 低 | 可能已过载,带宽瓶颈 |
| 利用率低且队列深 | 不确定 | 肯定过载,需要扩容 |
| 利用率高但延迟稳定 | 高 | 正常状态,可以先观察 |
所以我建议的组合是:GPU利用率作为底线指标,显存使用量作为容量约束,推理延迟和请求队列深度作为真正的扩缩容信号。这几个指标配合起来看,才能避免“被GPU利用率这个平均数骗了”的情况。
2.3 延迟与请求队列:离用户更近的信号
判断是否需要扩容,最直接的信号是用户感受到的延迟,而不是资源利用率。推理服务通常有明确的延迟目标,比如p95延迟小于300毫秒。如果p95延迟已经逼近上限,说明算力不足;如果请求队列深度持续增长,说明新请求等待的轮次越来越长,这是过载的前兆。
排队理论里有一个很直观的规律:利用率一旦超过某个点,排队时间是爆发式增长,而不是线性增长。所以看延迟和队列深度,本质上是在看“离悬崖还有多远”。
2.4 容量模型:把“感觉要扩容”变成可计算的阈值
光有指标不够,还得给出能写进自动扩缩容配置的数字。可以用一个很朴素的容量模型:
单副本吞吐容量 C = 1000 / 单请求平均处理时间(ms)
目标副本数 N = ceil(目标QPS / (C × 目标利用率))
举个例子:单请求平均处理时间50ms,也就是单副本满负荷20 QPS。设置目标利用率70%,高峰期期望支撑200 QPS,那么需要的副本数就是:
N = ceil(200 / (20 × 0.7)) = ceil(200 / 14) ≈ 15个副本
这个模型很粗糙,但把“凭感觉扩容”变成了可以计算、可以写入配置的数值。实际运行中再根据延迟和队列深度微调目标利用率参数,让系统自动修正。
3. 落地链路:Prometheus指标采集到KEDA自动扩缩容
3.1 整个链路各组件怎么分工
说完了指标,回到落地。我采用的方案是Prometheus加KEDA,整体链路分工如下:
| 组件 | 职责 |
|---|---|
| 推理服务 | 暴露业务指标和运行时指标,比如QPS、延迟、在途请求数、GPU利用率 |
| Prometheus | 定时抓取指标,存储序列数据并提供查询接口 |
| KEDA | 周期性查询Prometheus指标,根据阈值计算期望副本数 |
| Kubernetes HPA能力 | 执行最终的副本数调整,滚动更新或缩容 |
这套链路的好处是每一环都能独立排查。指标没暴露,Prometheus捞不到;KEDA没触发,看ScaledObject事件就知道原因;副本没调,Kubernetes的Event也能查出来。
3.2 指标暴露:框架自带与自研中间件的取舍
指标怎么暴露?第一种是推理框架自带的Prometheus端点。现在主流推理框架基本都内置了metrics端口,比如请求总数、处理时长、排队数都有现成的,直接用ServiceMonitor抓取就行,省事。
第二种是自己写中间件暴露业务指标。我强烈建议至少额外暴露“请求队列深度”和“在途请求数”这两个。原因很简单:框架自带的指标偏底层,业务侧看的“队列堆积”往往要靠自己统计。比如你用的是异步请求模式,请求进来先落队列,框架给你的指标可能完全不体现队列积压。
我目前的方案是两种都用:框架自带指标给基础监控和告警用,自定义指标给扩缩容触发器用。自定义指标通过Prometheus的客户端库暴露,配合Pod重启丢失数据这个问题,一般用Rate或者Gauge短期窗口就够。
3.3 ScaledObject配置与参数逐项拆解
这是整个方案里最核心的配置文件。下面是一份我整理过的ScaledObject示例,可以直接参考:
apiVersion: keda.sh/v1alpha1 kind: ScaledObject metadata: name: inference-scaler namespace: ai-serving spec: scaleTargetRef: kind: Deployment name: inference-server minReplicaCount: 2 maxReplicaCount: 10 cooldownPeriod: 180 pollingInterval: 15 advanced: restoreToOriginalReplicaCount: false triggers: - type: prometheus metricType: Value metadata: serverAddress: http://prometheus.monitoring.svc:9090 query: max(inference_inflight_requests{namespace="ai-serving",pod=~".*inference-server.*"}) threshold: "200" - type: prometheus metricType: AverageValue metadata: serverAddress: http://prometheus.monitoring.svc:9090 query: sum(rate(inference_request_duration_seconds_count{namespace="ai-serving"}[2m])) threshold: "12"参数逐项说明:
- scaleTargetRef:指定要扩缩容的目标工作负载类型和名称,这里用的是Deployment。
- minReplicaCount = 2:保底两个副本,原因后面单独说。
- maxReplicaCount = 10:GPU节点资源有限,需要设上限防失控。
- cooldownPeriod = 180:缩容后必须等180秒才能再次缩容,防止副本数抖动。
- pollingInterval = 15:每15秒查一次指标,不能太频繁,否则Prometheus压力大;也不能太慢,否则跟不上流量变化。
- 第一个trigger,metricType为Value,查询在途请求总数,阈值200。在途请求数超过200就扩容。
- 第二个trigger,metricType为AverageValue,查询QPS,阈值12表示每个副本平均承担12 QPS,超过就扩容。
关于KEDA触发器有个细节:Value类型是把查询结果与阈值直接比较,AverageValue类型会把查询结果除以当前副本数再与阈值比较。这两个语义不一样,配置前先想清楚自己想要的比较方式。
3.4 为什么KEDA而不是原生HPA
很多人会问:既然底层还是HPA,直接用HPA不就行了?原生HPA的问题在GPU场景下很明显。
原生HPA想要自定义指标,需要额外部署adapter,把自定义指标接口暴露给Kubernetes,然后配置多个资源对象。这套体系能用,但配置分散、升级麻烦。KEDA把Prometheus触发器、Cron触发器、自定义逻辑统一在一个CRD里,扩缩容的关键参数像cooldownPeriod、pollingInterval都在一个清单里集中管理,可维护性好很多。
更实际的一点是,KEDA对“缩容抑制”的处理比原生HPA更细。推理服务最怕的就是副本数来回抖,刚扩上去又缩回来,白付冷启动成本。KEDA的cooldown逻辑在这种场景下更实用,所以我在这个项目里直接选了KEDA。
4. 决定成败的部署细节:从镜像到探针到缩容
4.1 镜像与模型分离、节点预热
镜像体积直接影响冷启动时间。我的第一个建议是:千万别把模型打进镜像。模型文件动辄几个GB,打进镜像之后每次更新代码都要重新构建、推送、拉取一遍,代价太高。
正确的做法是模型放对象存储或者共享文件存储,启动时下载,镜像体积控制在合理范围内。我在生产里一般用initContainer先下载模型到共享的空目录,业务容器起来之后直接挂载同一个卷,避免了业务启动等待模型下载的过程。
如果集群有固定的GPU节点池,还可以提前把常用镜像预热到节点上。做法不复杂:用一个DaemonSet在每个节点上拉取一次镜像,之后新副本调度到这些节点时,省去从远端仓库拉镜像的时间,冷启动时间能压缩不少。
4.2 就绪探针:探针返回200不等于模型能接流量
这可能是最容易被忽略的坑。有一个项目上线前没有调整就绪探针,探针只检测到进程启动就返回200,但模型其实还在加载。管理面认为Pod已经Ready,流量一放进来,全部报错。
后来我改成了“模型引擎真正Ready才返回200”的健康检查版本,并且把initialDelaySeconds按冷启动时间放宽,把failureThreshold也调大,避免模型加载时间稍微波动就触发重启。经验是:初始延迟宁可多配一点,也不要让Pod处于假Ready状态。
4.3 最小副本数与冷却时间:给流量回流留容错
minReplicaCount设为2,不是拍脑袋。推理服务最大的风险是流量突然回来,而新副本冷启动要好几分钟。保底一个奇数副本,至少能先撑着一些请求,另一个副本正在冷启动,能争取一段时间。如果minReplicaCount设为1,这个副本一旦被线上流量打崩,整个服务就完全空窗。
cooldownPeriod我也调得比Web服务长,一般是120到180秒。缩容不能太激进,GPU实例回收快,重新拉起却很慢,频繁缩容扩容只会让系统一直处于抖动状态,成本反而更高。
4.4 缩容的优雅退出:等队列排空再走
缩容时不能直接SIGTERM杀掉还在处理推理请求的Pod,这一点怎么强调都不过分。默认情况下,Pod收到SIGTERM之后,Kubernetes会等terminationGracePeriodSeconds到期就强制杀掉进程,默认只有30秒。
推理请求往往比普通HTTP请求长,一个长序列生成可能要40秒甚至更久。30秒内处理不完,进程被杀,在途请求全部失败,用户侧看到的就是一堆504和超时。解决办法是配置preStop钩子和调大terminationGracePeriodSeconds,这会是我下一章展开讲的重点,因为我在生产上为这个坑付出过代价。
5. 一次缩容事故的完整排查链路
5.1 现场表象:错误总是发生在缩容之后
有一次服务发布后,午后流量平稳回落,系统自动触发了一次缩容,把副本数从6降到4。结果就在缩容完成后一两分钟内,监控上出现了一波零星的504和推理超时。从QPS曲线看,当时的流量并不大,GPU利用率也不算高,完全不该有超时问题。第一反应是模型出问题了,但模型最近根本没有更新,毫无头绪。
5.2 取证过程:把日志、监控和KEDA事件拉到一起比对
我把三份数据拉到一起比对:应用日志、Prometheus监控曲线、KEDA事件记录。应用日志里大量出现connection reset by peer,集中在某一个Pod名字下;监控曲线显示错误率升高恰好发生在那一次缩容事件之后1分钟;KEDA事件里记录了一次缩容,时间点和错误时间对得上。三个证据拼在一起,方向基本锁定:缩容过程出了问题。
5.3 根因定位:Terminating标记与在途请求的交错窗口
继续深挖时发现,问题出在缩容的时序上。Pod被标记为Terminating之后,状态变为Terminating,但此时容器内的进程还在处理一批推理请求。旧进程收到SIGTERM后立刻开始退出,但在途请求还没来得及排空,进程就已经退出了。排查了当时的配置:preStop钩子只做了一个5秒的sleep,terminationGracePeriodSeconds是默认的30秒,而当时这批大batch推理请求最长处理时间超过了40秒。请求被强杀,错误自然就出来了。
为什么平时没暴雷?之前流量小,单个请求处理时间短,30秒足够退完,所以一直没有触发这个窗口。那次午后流量刚好有一批长请求,冷启动时间长的隐患被一起点燃了。
5.4 修复方案:优雅排空、探针联动与回归验证
修复方案从两个方向同时下手。
方向一是为Pod配置preStop钩子,钩子不再用sleep,而是调用Pod内部的排空接口。这个接口会检查当前在途请求数,等队列和正在推理的请求全部结束才返回,然后进程再退出。同时把terminationGracePeriodSeconds调到180秒,给长请求留足时间。
方向二是给缩容前的检查增加一道屏障:确认服务当前的在途请求数低于某个阈值才开始缩容。KEDA可以配合高级配置做缩容条件判断,我这边还接了一个自定义触发器,实现在途请求偏高时抑制缩容。
改完之后做了三轮回归:第一轮模拟短请求高峰,第二轮模拟长序列请求高峰,第三轮模拟曲线回落触发缩容。三轮跑完,错误率归零,Pod生命周期事件里不再出现“容器进程被强杀”的记录。这之后我再也不敢忽略preStop了,每个推理服务的模板都默认带排空逻辑。
6. 进阶优化:从被动响应到主动规划
6.1 用历史流量画像做定点扩容
自动扩缩容本质上还是响应式的,指标触发之后才扩容,冷启动期不可避免。想进一步压缩空窗期,可以利用历史流量数据做定点扩容。
我的做法是把Prometheus里最近一个月、按小时聚合的QPS曲线拉出来,画出业务流量的典型形态。比如每天早上8点到10点是早高峰,下午2点到4点是另一个高峰,把这些时段标出来。然后在KEDA里叠加Cron触发器,在高峰来临前10分钟先把副本数扩到预测值,等真正的大流量到了,系统已经储备好算力。
KEDA的多个触发器默认逻辑是“任一触发即扩容”,同时满足缩容条件才缩容。把Prometheus触发器加Cron触发器组合在一起,可以实现“预测扩容+实时修正”。
6.2 GPU节点池、亲和性调度与混部
再往后要碰的就是节点层面的规划。
GPU节点池和CPU节点池分开是基本操作。推理Pod通过nodeSelector或者亲和性调度到GPU节点,其他无状态服务留在CPU节点。这样做的好处是资源隔离清晰,GPU节点不会被CPU密集型业务拖累,节点自动扩缩容的判断也更准确。
GPU很贵,所以混部值得考虑。推理服务通常是白天高峰、夜间低谷,而离线训练任务往往是夜间跑批。两个负载在时间上互补,就可以考虑错峰共用一个GPU节点池。这里我不展开太多,因为混部的调度复杂度是另一个量级,需要专门的调度器和资源隔离方案,不是随便把Pod扔上去就行。
这类节点级的策略,只有在服务Pod的自动扩缩容跑稳了之后才有意义,否则节点规划得再好,副本层的抖动也会让你焦头烂额。
6.3 后续可以继续扩展的方向
最后说几个我还在尝试、也可能是你后续会碰到的方向。
第一个是预测式扩缩容。不是简单叠加Cron,而是用最近几周的历史指标作为时间序列输入,让系统预测未来10到15分钟的流量,提前扩容。这个模型不用做得很复杂,简单的外推加上节假日特殊处理就有明显效果。
第二个是结合模型版本灰度做扩缩容。新版本模型上线时,先用少量副本承载小流量,确认指标稳定后再全量扩出去。自动扩缩容配合灰度发布,可以把发布过程中的资源波动也纳入控制,避免出现“发布成功但服务挂了”的尴尬局面。
第三个是把成本数据接进来。监控里增加GPU实例的费用估算,扩缩容策略除了延迟指标,还可以绑定成本预算。当某些非核心服务的延迟容忍度高,就可以把目标利用率调高一些,牺牲少量延迟换取成本下降。
我个人这一年跑下来,最大的体会是:自动扩缩容不是一个“配置完就结束”的功能,它更像是一个需要持续调参和观察的系统。指标选型决定方向,参数配置决定稳定性,而优雅缩容决定下限。先把最基础的生命周期管理做扎实,再谈预测式扩缩容和成本优化,这条路是最稳的。尤其是在GPU场景下,每一步缩容和扩容背后都是真金白银和时间成本,值得认真对待。