扩容立刻,缩容要等五分钟:K8s HPA 到底在算什么
用过 HPA 的人大多有两个困惑:CPU 一冲高副本立刻翻倍,流量退了副本却磨蹭好几分钟才降;副本数还老是 7、13 这种不整的数。我为一个挂不上 HPA 的服务照抄了一遍它的算法,才发现它总共只做三件事:一个 ceil 公式、多指标取最大、缩容等满一扇五分钟的窗。三件事看懂了,上面的困惑都有答案,调参也有了方向。顺带说说这周 K8s 1.37 刚把"缩到 0"升成默认开启,以及 KEDA 到底在 HPA 上面加了什么。
用过 K8s 自动扩缩容的人,大概都有过这两个困惑。一个是它的脾气很不对称:CPU 一冲到 85%,副本几乎立刻翻倍;流量退下去以后,副本却要磨蹭好几分钟才慢慢降回来。另一个是副本数老是 7、13 这种不整的数,明明设的目标是 50%,它扩出来的数怎么算的?
上周我碰上一个挂不上 HPA 的服务:它的多个实例不是一个 Deployment 的多副本,而是每个实例各自一组独立的 K8s 对象,由外面的控制程序管着。HPA 只认能改 replicas 字段的东西,这种结构它够不着。于是我自己写了个小评估器,把 HPA 的算法照抄了一遍。抄完才发现,HPA 总共只做三件事,上面两个困惑全在这三件事里。
正好这周 K8s 1.37 发布,把 HPA “缩到 0”这个躺了好几年的功能升成了默认开启,文末一起说。
第一件事:一个 ceil 公式
HPA 的核心就一行:
期望副本数 = ceil( 当前副本数 × 当前指标值 / 目标指标值 )
举个例子。现在 4 个副本,CPU 目标是 50%,实测平均 80%。那么 4 × (80 / 50) = 6.4,向上取整,7 个。“不整的数”就是这么来的:它不是在目标附近加一减一,而是按比例一步算到位,再向上取整。
为什么是乘比例,不是加减?背后的假设很朴素:负载摊在所有副本上,副本翻倍,每个副本的压力减半。这对无状态服务基本成立,对有会话黏性的服务就不那么准,但 HPA 只认这个比例。ceil 向上取整是宁多勿少,6.4 取 7,多出来的 0.6 就是保险。
另一个容易忽略的点是”指标值”到底是什么。HPA 的目标有三种写法:
- Utilization,百分比。这个百分比的分母是 Pod 的
request,不是limit,也不是节点的物理容量。request 设得太大,利用率永远上不去,HPA 就永远不扩;设得太小,服务一启动就被判定超载。很多”HPA 不工作”的排查最后都落在这个分母上。 - AverageValue,每个 Pod 的平均值,比如每 Pod 每秒 100 个请求。
- Value,整个服务的总量。
选指标时还要看量纲对不对得上。我踩过一次:拿 GPU 利用率当触发条件,阈值设 75%,可那个指标是按整张卡口径上报的,而 Pod 只分到卡的一小份算力,跑满了也只显示 20% 出头。阈值永远碰不到,评估器算得再对也白搭。公式没错,喂进去的数错了。
然后是容忍带。算出比例以后,HPA 不是立刻执行,它先看这个比例离 1 有多远:落在 0.9 到 1.1 之间,什么都不做。原因是 HPA 每 15 秒跑一轮,指标天然会抖,CPU 在 48% 和 53% 之间来回晃很正常,没有这条带,副本数就会 5、6、5、6 地跟着抖。这个 10% 长期是集群级参数,从 1.33 开始可以在每个 HPA 上单独设,扩容和缩容还能设成不同的宽度。

第二件事:多个指标,取最大
一个 HPA 可以同时挂几个指标:CPU、内存、每秒请求数,甚至外部的队列长度。每个指标各自套一遍上面的公式,各算出一个期望副本数,然后取最大的那个。
为什么不是平均?因为每个指标代表一种瓶颈。CPU 说要 3 个副本、QPS 说要 8 个,说明请求量已经顶到了,CPU 不高只是因为这个服务不吃 CPU。任何一种资源被顶满,服务就不健康,所以谁最紧张听谁的。
这条规则有个不太被注意的反面:缩容需要所有指标都同意。只要有一个指标还觉得需要 8 个副本,另外几个再空闲也缩不下去。如果挂了一个从来不会低的指标,比如内存(JVM 的堆基本不会主动还回去),HPA 就永远缩不了。挂什么指标进去,得先想清楚它会不会”投反对票”。
指标缺失怎么办?刚起来的 Pod 还没有指标,或者监控暂时抓不到某几个 Pod。HPA 的处理相当保守:算缩容时,缺指标的 Pod 按用满目标值算,假定它很忙;算扩容时按 0 算,假定它很闲。如果加上假设值以后方向反了,本来要扩变成要缩,这一轮就干脆不动。整套算法的哲学一致:不确定的时候,偏向不动。
第三件事:缩容为什么要等五分钟
这是最反直觉的一件事,也是我复刻时最费劲的一段。
HPA 每 15 秒算出来的期望副本数,并不直接执行。它先把这个数记进一个滑动窗口,缩容的窗口默认 5 分钟,然后真正采用的是窗口里的最大值。
所以缩容的过程是这样的:流量下去了,每一轮算出来的推荐值立刻变小,比如从 8 降到 3。但窗口里还留着 5 分钟前那些 8,取最大值还是 8。要等到所有的 8 都滑出窗口,最大值才会掉下来。这就是”缩容要等五分钟”的全部原因,它不是慢,它是在等最近 5 分钟里最高的那次需求过期。
扩容的窗口默认是 0 秒。算出要扩,这一轮就扩。

为什么要这么不对称?因为两个方向的错误代价不对称。扩多了,代价是多花几分钟的机器钱。缩早了,代价是把服务打挂:流量说不定只是喘了口气,两分钟后又回来,这时候新 Pod 还在拉镜像、预热 JVM,要是大模型推理服务,光加载权重就得几分钟。请求在这几分钟里排队超时。不对称的代价,配不对称的窗口。
复刻的时候我踩了一个细节:窗口要”满”才能缩。评估器刚启动那一分钟,窗口里只有一两条记录,取最大值就是当前值,会立刻缩容,而它对过去五分钟一无所知。正确的做法是攒够整个窗口再开始做缩容判断。HPA 控制器也是把推荐值记在内存里的,重启后同样有一小段记忆空白,只是它重启得少,很少有人注意到。
稳定窗口之后还有第二道闸,叫 behavior 策略:每 15 秒最多缩多少。默认是 100%,窗口一放行就可以一步缩到位;你可以改成每分钟最多缩 10%,让它一点一点降。扩容的默认策略是每 15 秒最多加 4 个或翻一倍,取大的。窗口管能不能动,策略管一次动多少。
缩到 0,以及 KEDA 加了什么
HPA 长期缺一件事:minReplicas 不能设成 0。原因就藏在第一件事里。CPU、内存这些指标来自正在运行的 Pod,缩到 0 个 Pod,指标就没了,公式的”当前指标值”没有来源,服务永远醒不过来。
所以缩到 0 只对两类指标成立:对象指标和外部指标,比如消息队列的长度、一条 Prometheus 查询的结果。这些指标不依赖 Pod 活着。相应的功能 HPAScaleToZero 从 1.16 就有了 alpha,一躺就是好几年。这周发布的 1.37 把它升成 beta,默认开启,还加了一个 ScaledToZero 状态条件。这个条件解决的是一个很实际的歧义:副本数是 0,到底是 HPA 因为队列空了缩下去的,还是有人手动 kubectl scale --replicas=0 关掉的?以前分不清,现在 HPA 只唤醒它自己缩下去的那些。
说到缩到 0,就绕不开 KEDA。很多人以为 KEDA 是 HPA 的替代品,其实它是骑在 HPA 上面的。你写一个 ScaledObject,KEDA 背地里建一个名叫 keda-hpa-<你的名字> 的 HPA,上面三件事照旧由它做。KEDA 自己干的是另外两件事:
- 翻译信号。几十种外部数据源,Kafka 的堆积量、Redis 的列表长度、一条 PromQL、甚至一个 cron 表达式,KEDA 把它们统一翻译成 K8s 的外部指标接口,喂给 HPA 去算那个公式。
- 管 0 和 1 之间。从 0 醒来,看的是 KEDA 自己的激活阈值;缩回 0,看的是 KEDA 的
cooldownPeriod,默认 300 秒没信号就缩到 0。1 到 N 之间交给 HPA,用 HPA 的稳定窗口。
所以在 KEDA 的场景里有两道互不相干的冷却:KEDA 的 cooldown 只管最后一步回到 0,HPA 的稳定窗口管中间的缩容。调参时把它们弄混,会出现”明明设了 cooldown 60 秒,怎么还是等了五分钟才缩”这种困惑。

1.37 之后,0 和 1 之间原生 HPA 也能管了,KEDA 的价值更多落在它那几十个现成的 scaler 上。要接消息队列、按业务指标扩缩的,还是 KEDA 省事。
复盘一句话
HPA 的算法只有三件事:一个 ceil 公式,多指标取最大,缩容等满一扇窗。看懂以后,调参就有了方向:副本数不整,是 ceil;副本数来回抖,看容忍带;缩得慢,看稳定窗口和 behavior 策略;死活不扩,先查 Utilization 的分母 request 和指标的量纲。
自己把算法抄一遍,是读懂文档最快的方式。文档写着”取最大值”的时候你会点头,自己写到”那窗口没满怎么办”的时候,才算真的想过。
评论