某次大促压测复盘会上,一个团队展示了他们的”弹性架构”:HPA
配置了 CPU 目标利用率 60%,流量涨了自动加
Pod,流量降了自动减 Pod,看起来无懈可击。压测开始后 40
秒,新 Pod 确实被拉起来了;但订单服务的数据库很快报出
FATAL: sorry, too many clients already——12
个新副本,每个连接池按 (2N+1) 公式配置了 20
个连接,瞬间把数据库连接数顶到了上限。数据库连接耗尽后,所有副本(包括早已存在的、原本健康的)开始排队等待连接,HPA
看到的 CPU
使用率不升反降(线程都在等锁,不在算),于是又触发了一轮缩容——服务在扩容和缩容之间反复抖动,故障持续了将近十分钟。
这个场景暴露的不是 HPA 配置错误,而是一个更根本的误解:把自动扩缩容当成”加机器”的自动化脚本,而不是一个带延迟的反馈控制系统。控制系统一旦引入延迟,就自带过冲(overshoot)和振荡(oscillation)的风险;而”加 Pod”这个动作本身,只解决了应用层的并发问题,完全没有触碰数据库连接数、下游配额、限流阈值这些不会随 Pod 数量一起扩展的约束。
在系列前面的扩展性原理中,我们讨论过 Amdahl 定律和 USL 给水平扩展设下的数学上限;在连接池设计中,我们拆解过连接建立的代价和池大小的计算方法。这两篇文章共同的结论是:扩展性不是无条件的,它受串行化比例、协调开销和资源上限约束。自动扩缩容要做的,就是在这些约束之内,用尽可能小的延迟把资源供给和真实负载对齐。
本文要回答的问题:
- 自动扩缩容作为一个反馈控制环,延迟从哪里来,为什么会导致振荡?
- CPU、自定义 QPS、延迟这几类常见指标,各自在什么场景下会做出错误决策?
- Kubernetes 的 HorizontalPodAutoscaler(HPA)和 VerticalPodAutoscaler(VPA)官方算法具体怎么算,两者能不能一起用?
- KEDA 这类事件驱动伸缩解决的是什么问题,和 HPA 是什么关系?
- 冷启动、连接池预热、缩容保护要怎么和扩缩容决策配合,否则新副本反而拖累系统?
- 扩容自身解决不了下游容量不够的问题,这个约束要怎么显式建模?
- 扩缩容跟不上流量的时候,靠什么兜底?
需要说明本文的边界:Kubernetes 网络层面 kube-proxy、Service、CNI 如何把流量转发到新起的 Pod,属于Kubernetes 网络深度系列的范畴,这里不展开;不同云厂商的实例价格、Spot 折扣、Savings Plan 这类采购策略也不在本文讨论范围内——本文只关心扩缩容决策本身的正确性,不关心它花多少钱。
一、当”加机器”本身成为故障源
开头的故障案例可以拆成三个独立的问题,它们经常被笼统地归为”扩容没做好”,但根因完全不同:
第一个问题:决策依据(CPU)和真实瓶颈(数据库连接)脱节。 应用层 CPU 利用率下降不代表系统更健康,可能是因为线程都阻塞在等待连接,根本没有机会消耗 CPU。用一个不能反映真实瓶颈的指标做决策,扩容和缩容都会做反。
第二个问题:扩容动作没有考虑下游容量。
新增的 Pod 副本让应用层的并发处理能力上升了,但数据库的
max_connections、下游服务的限流阈值、第三方 API
的配额,都不会因为你多起了几个 Pod
而跟着变大。应用层的水平扩展在这里撞上了一个不随之扩展的硬约束。
第三个问题:反馈环本身的延迟制造了振荡。
指标采集有延迟,Pod 从被创建到 Ready
有延迟,判断”这次扩容/缩容是否该做”需要参考一个时间窗口。如果这几个延迟叠加起来比负载变化的周期还长,扩缩容决策依据的就是”过去”的状态,而不是”现在”的状态,越努力追赶越可能追反方向。
这三类问题分别对应本文第三节(指标选择)、第九节(多维约束)和第二节(控制环延迟)。它们不是三个孤立的知识点,而是同一个系统性风险的三个切面:自动扩缩容把”要不要加资源”这个决策自动化了,但它没有,也不可能,自动解决”加了资源之后系统是否真的变好”这个问题。 后者取决于指标选不选得对、下游扛不扛得住、反馈延迟压不压得住。
反馈控制的直觉
把自动扩缩容看成一个闭环控制系统会更容易理解它的行为边界:系统观测一个指标(如 CPU 利用率),和目标值比较得到误差,根据误差调整”控制量”(副本数),控制量的调整又反过来影响下一轮观测到的指标。这和恒温器控制房间温度、巡航定速控制车速是同一类结构。
经典控制论里一个朴素但重要的结论是:闭环系统中控制动作和观测反馈之间的时间差(回路延迟)越大,系统越容易在目标值附近振荡,而不是平滑收敛。 原因很直观——决策者看到的永远是若干秒或若干分钟之前的状态,如果在这段延迟里状态已经发生了显著变化,决策者按照”旧状态”做出的调整,落地时刚好可能是错误方向。开头的故障案例里,“CPU 利用率下降 → 触发缩容”就是一次典型的方向错误的调整:真实原因是连接耗尽导致的排队,而不是负载下降。
这不是要否定自动扩缩容的价值,而是要明确一个工程共识:扩缩容系统的设计重心不是”要不要扩”,而是”如何在有延迟、有噪声的观测下,尽量不做错误决策,做错了也能尽快收敛”。 这正是下一节要展开的控制环模型。
二、扩缩容是一个带延迟的反馈控制环
观测、决策、执行三阶段
不论是 Kubernetes 的 HPA、云厂商的 Auto Scaling Group,还是自研的扩缩容组件,本质上都在重复同一个三阶段循环:
观测(Observe):从监控系统拉取指标(CPU、内存、自定义业务指标),聚合到”当前值”。这一步的延迟来自指标采集间隔、聚合窗口和监控系统本身的处理延迟。
决策(Decide):把当前值和目标值比较,计算出”期望副本数”,再叠加平滑策略(滑动窗口、变化速率限制)得到”最终要执行的副本数”。这一步是纯计算,延迟很小,但决策规则的保守程度直接决定了系统对噪声的抵抗力。
执行(Act):把新的副本数下发给编排系统,等待新副本被调度、启动、通过健康检查、真正进入可服务状态。这一步的延迟通常是全链路里最长的一段——镜像拉取、进程启动、依赖预热(数据库连接池、本地缓存、JIT 预热)都发生在这里。
以 Kubernetes 的 HPA
为例,这三个阶段有明确的官方实现细节。HPA
控制器不是持续运行的进程,而是一个按固定周期轮询的控制环:控制器管理器每隔
--horizontal-pod-autoscaler-sync-period(默认
15
秒)查询一次目标资源的指标;查询完成后立即计算是否需要调整副本数[1]。这意味着
HPA 对负载变化的最快反应速度就是这个同步周期,任何比 15
秒更短的负载脉冲,HPA
都来不及在脉冲结束前完成一次完整的观测。
flowchart LR
subgraph Loop["Control loop, one iteration per sync period"]
direction LR
OBS["Observe:\nquery metrics API"] --> DEC["Decide:\ncompute desiredReplicas"]
DEC --> STAB["Stabilize:\napply tolerance + window"]
STAB --> ACT["Act:\npatch scale subresource"]
ACT --> RUN["New pods start,\nold pods terminate"]
end
RUN -.->|"readiness delay,\nwarm-up time"| OBS
图中从「新 Pod 启动」回到「观测」的虚线是整个环路里延迟最长、也最容易被忽视的一段:新副本从被创建到指标真正反映它的负载,中间隔着调度、镜像拉取、启动探针、就绪探针、以及应用自身的预热(这一点第六节会详细展开,并和连接池设计直接挂钩)。
下面这张图把同样的观测-决策-执行三阶段画成 Kubernetes 场景下更具体的组件对应关系,标注了这条回路里延迟最容易累积的位置:
延迟如何转化为振荡:一个简化模型
不引入具体数值,也可以用一个简化的离散时间模型说明延迟为什么会制造振荡。设第 (t) 轮观测到的负载误差为 (e_t)(当前指标值与目标值的偏差),如果控制器采用比例调整规则
\[r_{t+1} = r_t + k \cdot e_{t-d}\]
其中 (r_t) 是第 (t) 轮的副本数,(k) 是调整系数,(d) 是观测到执行的回路延迟(以采样周期为单位)。当负载本身的变化周期和 (d) 处于同一量级时,控制器在第 (t) 轮看到的误差 (e_{t-d}) 描述的是 (d) 轮之前的负载状态;如果负载在这 (d) 轮里已经反向变化,(r_{t+1}) 就会把资源调整到错误的方向。这正是开头故障案例里发生的事情:控制器在第 (t) 轮看到”CPU 利用率下降”,据此调低副本数,但导致 CPU 下降的真实原因(连接排队)恰恰需要更多副本才能缓解,调整方向和真实需要完全相反。下一小节会说明这个延迟模型如何被 HPA 的稳定窗口和容忍度部分抵消,但无法被完全消除——延迟的下限由观测周期、Pod 启动时间等物理约束决定,任何调整算法都无法绕开。
稳定窗口:用”看历史”换振荡抑制
只靠即时指标做决策,噪声会被直接放大成振荡:某一次采样恰好偏高就扩容,下一次采样回落就立刻缩容,副本数在几分钟内反复跳动(这种现象在控制论里对应”迟滞”,Kubernetes 官方文档称之为 thrashing / flapping[1])。
HPA 的应对方式是稳定窗口(stabilization window):在做出扩容或缩容决策前,控制器回看过去一段时间窗口内所有计算出的”期望副本数”,取其中最大值作为最终执行的副本数[1]。默认配置是:
scaleUp.stabilizationWindowSeconds = 0:扩容没有稳定窗口,指标一旦显示需要扩容就立即执行,因为”该扩容而没扩容”的代价(请求排队甚至超时)通常比”扩容多了浪费资源”更高;scaleDown.stabilizationWindowSeconds = 300:缩容默认要看过去 5 分钟内所有的期望副本数,取最大值,即只要过去 5 分钟里有任何一个时刻显示需要更多副本,就不会缩容到当前这个更低的值[1]。
这是一个明确的工程取舍:扩容宁可快、缩容必须慢。 快速扩容的成本是短暂的资源浪费,快速缩容的成本可能是把刚刚还在用的容量收回去,紧接着又要重新经历一次冷启动。
除了稳定窗口,HPA v2
还提供了容忍度(tolerance),用来过滤掉指标的微小波动。默认的全局容忍度是
10%,即只有当前指标值与目标值的比值偏离 1.0
超过这个容忍度时才会触发扩缩容[1];Kubernetes
v1.33 引入了 HPAConfigurableTolerance
特性门控(Beta),支持针对单个 HPA 对象的
behavior.scaleUp 和
behavior.scaleDown
分别设置不同的容忍度[13]。以及速率限制策略(scaling
policies):可以限制每个周期最多扩容/缩容多少个 Pod
或百分之多少的副本,避免一次决策就把副本数从 5 拉到
50[1]。
这几个机制合起来解决的都是同一个问题:观测有噪声、决策有延迟,系统必须在”反应够快”和”不被噪声牵着走”之间找一个可配置的平衡点,而不是假装这个权衡不存在。
叠加的另一层控制环:节点级别的伸缩
HPA 调整的是”某个 Deployment 有多少个
Pod”,它假设集群里始终有足够的节点资源来调度新增的
Pod。当这个假设不成立——新 Pod 因为没有节点能容纳它而停留在
Pending 状态——就需要另一个独立的控制器,Cluster
Autoscaler,来增加节点数量。Cluster Autoscaler 的触发条件和
HPA 完全不同:它不看
CPU/内存利用率,而是持续检查是否存在因资源不足而无法调度的
Pod,一旦发现就向底层基础设施申请新节点;反方向上,如果某个节点在一段时间内利用率持续偏低且其上的
Pod
可以被安全迁移到其他节点,则会将其排空并移除[7]。
这一层引入了一个新的延迟来源:新节点从被请求创建到真正
Ready、能够接纳 Pod,涉及节点的启动、Kubelet
注册、必要组件的初始化,这段延迟通常比”在已有节点上调度一个新
Pod”要长得多,具体时长因基础设施类型而异。这意味着完整的水平扩容延迟链条,在触发了节点扩容的场景下,要再叠加一层节点启动延迟:HPA
算出需要更多副本 → 副本因为没有可用节点而
Pending → Cluster Autoscaler 观测到
Pending 的 Pod → 请求新节点 → 节点就绪 → 原本
Pending 的 Pod 被调度 →
走完第六节讨论的应用预热流程,才真正开始分担负载。第九节讨论的”扩容不代表下游也能扩容”,在节点这一层同样成立:如果集群本身设置了最大节点数上限,或者可用的机型配额已经用尽,HPA
算出再多的期望副本数也无法真正落地。
三个控制器的边界对照
把 HPA、VPA、Cluster Autoscaler 放在一张表里对照,能更清楚地看出它们各自的触发条件、调节周期和典型故障点完全不同,出问题时不能笼统地归为”扩容没做好”:
| 维度 | HPA | VPA | Cluster Autoscaler |
|---|---|---|---|
| 调整对象 | Deployment/StatefulSet 的副本数 | 单个容器的资源请求量 | 节点组的节点数量 |
| 触发信号 | 指标值 / 目标值的比率 | 历史用量的统计推荐 | 是否存在因资源不足而 Pending 的
Pod;节点是否长期低利用率 |
| 默认调和/扫描周期 | 15 秒 | Recommender 持续分析,应用时机由更新模式决定 | 10 秒 |
| 是否会驱逐/重启现有实例 | 不会(只增删副本) | Recreate
会驱逐;InPlaceOrRecreate/InPlace
尽量避免 |
缩容时会驱逐节点上的 Pod |
| 典型故障点 | 指标失真、震荡、maxReplicas
与下游约束冲突 |
与 HPA 在同一资源指标上耦合振荡;对内存泄漏等异常增长无法区分 | 节点组容量/配额限制导致扩容失败;缩容延迟造成成本浪费 |
三层控制器各自独立运行,没有一个”总控制器”把它们编排在一起——这是
Kubernetes
组合式设计的优点(每一层可以独立演进、独立替换),也是排查问题时容易迷失方向的地方:Pod
数不够是 HPA 的问题,Pod Pending 调不上去是 CA
或资源配额的问题,单 Pod 资源配置不合理是 VPA
的问题,三者互不知晓对方的存在。
三、指标选择:CPU、自定义 QPS、延迟各自的失效模式
选错扩缩容依据的指标,是自动扩缩容里最常见、也最不容易在压测之外被发现的问题——因为大部分指标在”正常流量”下看起来都合理,只有在特定的失效模式下才会露出马脚。
CPU 利用率:最常用,也最容易撒谎
CPU 利用率是 HPA 的默认指标,原因是它接入成本最低(Kubernetes 自带 Metrics Server 即可)、语义直观。但 CPU 利用率反映的是”CPU 有没有忙”,不是”服务有没有过载”,两者在以下场景会脱节:
- I/O 密集型服务:请求大部分时间在等待数据库、等待下游 RPC 返回,CPU 利用率可能长期低于 20%,即使服务已经因为连接池耗尽而大量超时(正是开头的故障场景)。
- 锁竞争或队列积压:线程阻塞在锁或者等待队列上不消耗 CPU,CPU 利用率反而会随着积压加剧而下降——积压越严重,“有效工作”占比越低,这与直觉相反。
- JVM / GC 场景:GC 暂停期间业务线程停顿,但 GC 本身会短暂占用大量 CPU,CPU 曲线在负载正常和 Full GC 时可能同样”高”,无法区分两种状态。
CPU 指标真正适合的场景是计算密集型、无外部依赖阻塞的工作负载(图像处理、编解码、纯计算的批任务),此时 CPU 利用率和处理能力之间近似线性,扩缩容决策才可靠。
自定义 QPS/RPS:贴近业务,但假设了请求同质
以每秒请求数作为扩缩容依据,理论上更贴近”用户实际打过来多少流量”。但它隐含一个假设:每个请求的处理成本大致相等。当请求成本差异很大时(例如同一个接口,有的请求命中缓存耗时 1 毫秒,有的请求触发全表扫描耗时 2 秒),QPS 相同但系统实际负载可以相差几个数量级。一个典型失效场景是批量导出接口和普通查询接口共享同一个 Deployment:导出请求量很少但极重,QPS 曲线看起来平稳,系统却已经在导出请求上过载。
QPS 指标适合请求成本同质、接口边界清晰的服务(网关层、单一职责的微服务),不适合把重请求和轻请求混在一起统计的场景。
延迟:滞后指标,且会自我强化
用 P99 延迟作为扩缩容触发条件,问题在于延迟是一个滞后指标:延迟明显劣化的时候,系统往往已经处于饱和状态一段时间了(请求已经在队列里排了队,只是还没超时)。等延迟触发扩容再等新副本就绪,用户已经经历了完整的一段劣化体验。
更麻烦的是延迟指标存在自我强化的反馈:副本数越少,单个副本承担的并发越高,排队时间越长,延迟越高;扩容之后新副本要经历预热(第六节),预热期内响应可能反而更慢,导致延迟指标短期继续恶化,如果扩缩容器把这误判为”扩容没有效果”而继续加码扩容,就会造成过冲。
延迟对利用率变化不敏感、又在某个点后急剧恶化的原因,可以用排队论里最基础的 M/M/1 模型直观解释——假设到达过程近似泊松、服务时间独立同分布、系统处于稳态,平均排队等待时间和资源利用率 () 的关系是[15]:
\[W_q = \frac{\rho}{\mu(1-\rho)}\]
其中 () 是服务速率。取 ()(把结果表示成服务时间的倍数,方便看形状而不是具体数值),代入不同的 ():
| () | (1-) | (W_q)(相对单位) |
|---|---|---|
| 0.50 | 0.50 | 1.00 |
| 0.70 | 0.30 | 2.33 |
| 0.80 | 0.20 | 4.00 |
| 0.90 | 0.10 | 9.00 |
| 0.95 | 0.05 | 19.00 |
| 0.99 | 0.01 | 99.00 |
从 0.50 涨到 0.80,等待时间涨了 4 倍;但从 0.80 再涨到 0.95,利用率只多了 15 个百分点,等待时间却又涨了将近 5 倍。这就是”延迟在低利用率区间几乎不动,越过某个拐点就急剧上升”的数学来源:系统运行在拐点附近很久之后,延迟才刚开始明显抬头,留给控制器纠正的时间窗口天然就短,这也是稳定窗口和容忍度在用延迟做主控信号时更难调、震荡风险更高的根本原因。需要再次强调这组数字依赖的假设——真实服务很少是严格的 M/M/1 排队系统,这里只用它说明”非线性发散”这个形状本身,不能直接套用来预测具体系统的延迟数值。
内存利用率:形状完全不同的坑
内存指标经常被拿来和 CPU 放在一起配置(HPA 原生支持两者),但内存的失效模式和 CPU 不是同一类问题。CPU 利用率的问题是”忙不代表过载”;内存利用率的问题是内存曲线的形状本身就不代表负载:
- 带有堆内存管理的运行时(JVM、Go 的垃圾回收器)会主动占用比”当前实际存活对象”更多的内存,作为回收的缓冲空间,内存利用率的基线随 GC 参数、堆大小配置变化,同样的业务负载在不同 GC 配置下会呈现不同的内存利用率曲线;
- 内存有短期不可压缩的特点:一旦分配就很难在不重启或不主动触发回收的情况下降下来,靠内存利用率触发扩容往往会伴随一个”扩了之后内存也不降”的假象,让人误以为扩容没起作用;
- 内存溢出(OOM)是阶跃式故障,不是渐进式过载——利用率在 OOM 发生前的最后一刻可能看起来还有余量,指标的采样间隔很容易错过真正的拐点。
内存指标更适合用来做准入保护(配合
resources.limits 防止单个 Pod
把节点内存耗尽)和 VPA
的资源请求调优(第四节),而不是直接驱动副本数量的扩缩容决策——除非业务负载本身就和内存占用量强相关(例如内存里缓存了和请求量成正比的数据结构)。
自定义指标接入需要额外的基础设施
上面讨论的 QPS、延迟、在途并发这些指标,都不是 Kubernetes
自带的资源指标,要接入 HPA 需要通过
custom.metrics.k8s.io 或
external.metrics.k8s.io 这两个聚合
API。最常见的落地方式是部署一个 Adapter(例如 Prometheus
Adapter),把 Prometheus 里已有的时序数据翻译成这两个 API
能返回的格式,再由 HPA
定期查询[1]。这意味着”选对指标”只是第一步,指标要真正驱动扩缩容,还依赖一条完整的、本身也可能有延迟和故障点的数据管道——Adapter
的采集延迟、聚合窗口、Prometheus
自身的抓取周期,都会叠加到第二节讨论的回路延迟里。
用 Little 定律理解为什么”在途并发数”是更稳的中间指标
排队论中的 Little 定律给出了一个简单但普适的关系:稳态系统中,系统内的平均请求数 (L)、请求到达速率 () 与请求在系统中的平均停留时间 (W) 满足
\[L = \lambda \cdot W\]
这个关系在长期平均、系统达到稳态(到达率和离开率相等,不存在无限堆积)的假设下成立,不依赖到达过程或服务时间服从什么分布[2]。它对指标选择的启发是:“在途并发请求数”(in-flight concurrency)同时把请求到达速率和处理耗时编码在一个数字里——它既不像纯 QPS 那样忽略请求成本差异(处理越慢的请求,在 (L) 里停留的时间越长,贡献的权重越大),也不像 CPU 那样对 I/O 等待不敏感(请求即使在等待 I/O 也仍然计入在途并发)。
这也是为什么很多网关和 Service Mesh(如 Envoy、gRPC 服务端)默认暴露”当前并发请求数”或”活跃连接数”作为一等指标:它是一个介于 QPS 和延迟之间的中间量,对请求成本异质性和 I/O 阻塞都不敏感,作为扩缩容依据比单纯 CPU 或 QPS 更稳健。需要强调,这里只是借用 Little 定律的比例关系做直觉解释,不是说可以直接拿它反推精确的副本数——真实系统很少严格满足到达过程独立同分布、服务时间独立同分布这些教科书假设,把它当成精确公式使用会得到误导性的结论(容量规划一文中对排队论假设的边界有更完整的讨论)。
指标失效模式对照
| 指标 | 直觉含义 | 典型失效场景 | 适用条件 |
|---|---|---|---|
| CPU 利用率 | 计算资源是否吃紧 | I/O 密集、锁竞争、GC 停顿时 CPU 不能反映真实压力 | 计算密集、无阻塞式外部依赖 |
| 内存利用率 | 内存是否吃紧 | 语言运行时(JVM/Go)的内存曲线受 GC 策略影响,不直接对应”负载” | 内存随请求数据量线性增长的场景 |
| QPS / RPS | 流量大小 | 请求成本异质时,QPS 相同负载可能相差数量级 | 请求成本同质、接口边界单一 |
| P99 延迟 | 用户体验 | 滞后指标,且预热期内会自我强化恶化 | 作为兜底告警指标,不建议作为唯一扩缩容依据 |
| 在途并发数 | 请求到达与处理时间的综合 | 依赖稳态假设,突发瞬时脉冲仍会低估 | 请求成本异质、有阻塞式依赖的通用服务 |
| 队列深度(消息数) | 待处理的积压量 | 只适用于异步/事件驱动架构 | 消息队列消费者,见第五节 KEDA |
工程实践中很少只用一个指标。HPA v2 支持配置多个指标源,控制器会分别计算每个指标对应的期望副本数,取其中最大值作为最终结果[1]——这是一个保守策略:任意一个维度显示需要更多资源,就按需要更多资源处理,避免”综合评分掩盖了单一维度的过载”。
四、水平与垂直:HPA 与 VPA 的官方模型
HPA 算法细节
HorizontalPodAutoscaler 通过增减 Pod 副本数来匹配负载,是 Kubernetes 核心 API 的一部分。它的调整逻辑从最基础的角度看,是一个简单的比例关系[1]:
\[desiredReplicas = \left\lceil currentReplicas \times \frac{currentMetricValue}{desiredMetricValue} \right\rceil\]
例如当前指标值是目标值的 2 倍,副本数就翻倍;当前指标值是目标值的一半,副本数就减半。官方文档特别指出,这个比值如果足够接近 1.0(在可配置的容忍度范围内,默认 10%),控制器会跳过本轮的扩缩容动作,避免对微小波动做出反应[1]。
真实计算比这个公式复杂,控制器还要处理几类边界情况[1]:
- 尚未就绪的 Pod:一个 Pod
如果还处于初始化阶段(受
--horizontal-pod-autoscaler-initial-readiness-delay,默认 30 秒,和--horizontal-pod-autoscaler-cpu-initialization-period,默认 5 分钟,两个参数控制),它的 CPU 指标会被搁置不参与计算,避免把应用启动阶段的 CPU 尖峰(比如 Java 应用的类加载和 JIT 预热)误判为真实负载。 - 指标缺失的 Pod:如果部分 Pod 拿不到指标,控制器会更保守地重新估算——缩容场景下假设这些 Pod 消耗了 100% 的目标值,扩容场景下假设消耗了 0%,两种假设都是为了抑制潜在的错误决策幅度。
- 多指标取最大:如果配置了多个指标,每个指标独立算出一个期望副本数,最终取所有结果中最大的一个;如果其中某个指标因为拉取失败无法计算,且其他可用指标建议缩容,则整体跳过本次缩容,但仍然允许扩容——保证 HPA 在指标不完整时,“该加的资源不会因为一个指标故障而加不上”。
flowchart TD
A["Compute ratio =\ncurrentMetricValue / desiredMetricValue"] --> B{"abs(ratio - 1)\nwithin tolerance?"}
B -- yes --> C["Skip scaling this cycle"]
B -- no --> D["Adjust for not-ready pods\nand missing metrics"]
D --> E["recommendedReplicas =\nceil(currentReplicas * ratio)"]
E --> F{"scale up or down?"}
F -- up --> G["scaleUp window,\ndefault 0s: act immediately"]
F -- down --> H["scaleDown window,\ndefault 300s:\ntake max over window"]
G --> I["Apply rate-limit policies\n(pods / percent per periodSeconds)"]
H --> I
I --> J["Patch scale subresource"]
这套算法本身没有”预测”能力,它只处理”当前观测到什么、该怎么换算成副本数”,属于纯反应式(reactive)控制。第八节会讨论反应式和预测式的权衡。
一轮示意推演:容忍度和稳定窗口如何共同起作用
下面用一组假设数值走一遍完整的决策流程,帮助把公式、容忍度、稳定窗口串起来——数值是为了说明机制而设置的示意场景,不是任何生产环境的实测记录。假设目标 CPU 利用率是 65%,容忍度是默认的 10%,当前副本数 10,在连续 5 个 15 秒调和周期里测得的平均利用率依次是 68%、74%、82%、79%、70%:
| 轮次 | 测得利用率 | 比值(测得/目标) | 是否超出容忍度(±10%) | 公式建议副本数 | 实际决策 |
|---|---|---|---|---|---|
| 1 | 68% | 1.05 | 否 | 10(不变) | 10 |
| 2 | 74% | 1.14 | 是 | ⌈10×1.14⌉=12 | 12(扩容零稳定窗口,立即生效) |
| 3 | 82% | 1.26 | 是 | ⌈12×1.26⌉=16 | 16 |
| 4 | 79% | 相对新基准 16 后回落到容忍度内 | 否 | 16(不变) | 16 |
| 5 | 70% | 相对 16 后进一步回落到容忍度内 | 否 | 16(不变) | 16 |
这个推演有两个容易被忽略的细节:第一,公式里的
currentReplicas
是每一轮决策时最新的副本数,副本数的增长是复利式的(10
到 12 到 16),不是线性叠加;第二,第 4、5
轮的利用率其实还在 70% 上下,高于 65%
的目标,但因为相对当前副本数计算出的比值落在容忍度范围内,控制器选择不动——这正是容忍度机制在发挥作用。如果接下来利用率持续走低,缩容方向的
300 秒稳定窗口会要求控制器在过去 5
分钟的所有建议值里取最大值,也就是说只要 5
分钟内出现过一次”16”的建议,即使当前利用率已经很低,副本数也不会立刻跌破
16——这是”缩容宁可慢、不可错”的设计在数值上的直接体现。
VPA:调整的是资源请求量,不是副本数
VerticalPodAutoscaler 解决的是另一个问题:单个 Pod 该分配多少 CPU 和内存请求量(request),而不是要多少个副本。它不是 Kubernetes 核心 API 的一部分,需要作为插件单独安装[3],由三个协作组件构成[3]:
- Recommender:分析目标 Pod
的历史与当前资源使用数据,计算出目标推荐值、下界和上界,写入
VPA 对象的
.status.recommendation; - Updater:对比当前的资源请求和推荐值,当差异超过阈值时,按配置的更新模式驱逐旧 Pod 或原地调整;
- Admission Controller:作为一个变更型(mutating)准入 Webhook,在新建或重建的 Pod 上直接把推荐值写入资源请求字段。
VPA 支持多种更新模式[3],语义差异很大:
| 模式 | 行为 |
|---|---|
Off |
只生成建议,不做任何自动修改 |
Initial |
仅在 Pod 首次创建时应用建议,运行中的 Pod 不受影响 |
Recreate |
差异超过阈值时驱逐 Pod,由控制器重建并应用新的资源请求 |
InPlaceOrRecreate |
优先尝试原地调整(依赖 Kubernetes 的原地 Pod 资源调整特性),失败则回退到驱逐重建 |
InPlace |
只做原地调整,若暂时无法原地调整则等待重试,不回退到驱逐(较新引入的
alpha 特性,需要开启 InPlacePodVerticalScaling
集群特性门控和 VPA 侧对应的 feature gate) |
Auto(已废弃) |
自 VPA 1.4.0 起废弃,行为等价于
Recreate |
Recreate
及更早期模式的一个直接代价是:调整资源意味着 Pod
被驱逐重建,业务会经历一次容器重启,这本身就是一次值得关心的中断——第七节讨论的缩容保护机制(PDB、优雅终止)同样适用于
VPA 触发的驱逐,且官方文档明确说明 Updater
组件在驱逐 Pod 时会遵守
PodDisruptionBudget[3]。这一点和第七节将要讨论的
HPA 缩容行为形成直接对照:HPA 缩小副本数时走的是 Deployment
的直接删除路径,不受 PDB 约束;VPA 的 Updater 走的是标准的
Eviction API,因此会被 PDB
约束——同样是”减少这个应用当前运行的 Pod
数量”,两种控制器因为实现路径不同,受到的保护完全不对等,这是排查”为什么
PDB 好像没生效”时容易被忽略的一个细节。
Recommender
给出的建议分为目标值、下界和上界三档,resourcePolicy.containerPolicies
里的 minAllowed/maxAllowed
则是运维侧再加一层硬约束——VPA 永远不会给出低于
minAllowed 或高于 maxAllowed
的推荐值,即使实际用量数据指向别的方向[3]。这层约束不是可选项:VPA
的官方已知限制文档明确指出,推荐值可能超出集群实际可用资源(例如超过最大节点的可分配容量),导致新
Pod 因为请求量过大而无法调度,长期处于 Pending
状态[4]——maxAllowed
正是防止这种情况的最后一道闸门。
HPA 和 VPA 不能在同一个资源指标上同时使用
这是一个容易被忽视、但官方明确警告过的组合陷阱。kubernetes/autoscaler
项目的已知限制文档写得很直接:“此刻不应该在同一个资源指标(CPU
或内存)上同时使用 VPA 和
HPA”,可以在不同的资源指标上分别使用两者(例如 VPA
管内存、HPA 管 CPU),也可以让 HPA
使用自定义或外部指标而不是 CPU/内存[4]。
原因是两个控制器之间没有协调机制,各自基于不同的口径计算:VPA
根据历史使用量调整资源请求(分母),HPA 根据”当前用量 /
资源请求”算出的利用率百分比调整副本数。当 VPA 把某个 Pod 的
CPU 请求调低,即使真实用量没变,HPA
看到的利用率百分比也会瞬间升高,从而触发扩容;扩容之后单个
Pod 分摊的负载下降,VPA
观测到的用量降低,又会建议进一步调低请求——两个控制器互相踩对方的分母,形成一个没有收敛点的振荡循环。避免方式很简单:让两者管不同的维度,或者让
HPA 使用绝对值指标(AverageValue 而不是
AverageUtilization),因为绝对值不受资源请求变化的影响。
配置示例
# HPA:基于 CPU 利用率水平扩展
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
minReplicas: 4
maxReplicas: 40
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Pods
value: 4
periodSeconds: 15# VPA:只管内存请求量,避免和上面的 HPA 在 CPU 上打架
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: order-service-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
updatePolicy:
updateMode: "InPlaceOrRecreate"
resourcePolicy:
containerPolicies:
- containerName: order-service
controlledResources: ["memory"]
controlledValues: RequestsOnly
minAllowed:
memory: 256Mi
maxAllowed:
memory: 4Gi五、事件驱动伸缩:KEDA 与队列深度
HPA 原生支持的指标来源是资源指标
API(CPU/内存)、自定义指标 API 和外部指标
API,理论上可以接任意指标,但需要自己实现一个符合
custom.metrics.k8s.io 或
external.metrics.k8s.io 规范的
Adapter,这对大多数团队是不小的门槛。KEDA(Kubernetes
Event-Driven
Autoscaling)要解决的就是这个接入成本问题:它内置了一批常见事件源(Kafka、RabbitMQ、SQS、Redis
List 等)的
Scaler,把”队列里还有多少条消息”这类指标直接转换成 HPA
能消费的外部指标。
KEDA 的架构定位:增强 HPA,不是替代它
KEDA 官方文档明确说明它是在 HPA 之上做增强,而不是一套独立的调度系统[5]。它由三个组件构成:
- keda-operator:管理
ScaledObject/ScaledJob这类自定义资源的完整生命周期,并且直接负责”0 到 1”和”1 到 0”这两段的扩缩容——把一个空闲到零副本的 Deployment 在事件到来时拉起来,或者在没有待处理事件时缩到零; - keda-metrics-apiserver:把从事件源(Kafka、RabbitMQ、Elasticsearch、SQS 等)轮询到的指标,通过 Kubernetes API Server 暴露成外部指标,供 HPA 查询;
- keda-admission-webhooks:在
ScaledObject被创建时做校验,防止例如两个ScaledObject同时指向同一个 Deployment 这类配置错误。
真正的扩缩容路径分成两段[5]:0 到 1 /
1 到 0 这两段由 keda-operator 直接处理;1
到 N / N 到 1 这段委托给标准的 HPA——keda-operator
会自动创建并维护一个 HPA 对象,HPA 通过
keda-metrics-apiserver
查询外部指标后按第四节的算法正常运作。换句话说,KEDA
只是在”指标从哪来”这一层做了扩展,扩缩容的决策算法仍然是 HPA
那一套(稳定窗口、容忍度、速率限制策略同样适用)。
graph LR
EV["事件源<br/>Kafka / RabbitMQ / SQS / Prometheus"] -->|"轮询"| MAS["keda-metrics-apiserver"]
MAS -->|"External Metrics API"| HPA["原生 HPA Controller"]
OP["keda-operator"] -->|"管理生命周期"| SO["ScaledObject"]
OP -->|"0↔1 切换"| DEP["Deployment"]
HPA -->|"1→N 扩缩容"| DEP
KEDA 支持的 Scaler 覆盖了消息队列、云厂商托管服务和通用指标系统,几类常见输入信号:
| Scaler 类型 | 典型触发信号 | 适用场景 |
|---|---|---|
| Kafka | Consumer Group Lag | 流式处理、事件驱动的消费者服务 |
| RabbitMQ | 队列消息数 | 传统消息队列驱动的任务处理 |
| AWS SQS | 队列可见消息数 | 云厂商托管队列的消费者 |
| Prometheus | 任意 PromQL 查询结果 | 无现成 Scaler 时的通用兜底方案 |
| Cron | 时间表达式 | 已知的周期性任务,见下方示例 |
需要特别注意的一点:如果
ScaledObject 用的是 CPU
或内存触发器,指标走的是标准
metrics-server,不经过
keda-metrics-apiserver,因此不支持缩容到零——没有运行中的
Pod 就没有 CPU/内存数据可以观测,KEDA
无法判断什么时候该从零重新拉起[5]。缩容到零这个能力,只对基于外部事件源(队列深度等)的触发器有效。
队列深度作为扩缩容依据
对于消费者从消息队列拉取任务的架构,“队列里还有多少条待处理消息”通常是比 CPU 更直接的负载信号:消费者的 CPU 利用率可能一直不高(等待网络 I/O 或下游写入),但队列深度能直接反映积压程度。KEDA 的典型配置:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: order-consumer-scaledobject
spec:
scaleTargetRef:
name: order-consumer
minReplicaCount: 0
maxReplicaCount: 30
cooldownPeriod: 120
triggers:
- type: rabbitmq
metadata:
queueName: order.events
mode: QueueLength
value: "20"
authenticationRef:
name: rabbitmq-trigger-auth这里 value: "20" 的含义是”平均每个副本处理
20 条积压消息”,KEDA 会把它转换成 HPA
能理解的外部指标目标值,副本数的计算仍然遵循第四节的比例公式:队列深度越过
20 * currentReplicas,就按比例扩容。这个模式本质上是把
Little 定律的直觉又用了一遍——用积压量(对应在途请求数
(L))而不是瞬时 CPU 来驱动决策,天然避开了”I/O 密集型服务
CPU 指标失真”这个第三节讨论过的失效模式。
KEDA 的 ScaledJob
则是另一种模式:不调整一个长驻 Deployment
的副本数,而是为每条(或每批)消息创建一个 Kubernetes
Job,适合处理时间长、彼此没有共享状态、天然适合”一条消息一个执行单元”的批处理任务。两种资源的选择本质上是”长驻服务水平扩展”和”批处理任务并发度”这两个不同问题的分野:
| 维度 | ScaledObject |
ScaledJob |
|---|---|---|
| 管理的对象 | 已有的 Deployment/StatefulSet | 按需创建的 Job |
| 扩缩容语义 | 调整副本数(复用同一批长驻进程) | 为每条/每批事件创建独立的 Job 实例 |
| 适合的负载 | 长驻服务,请求边界清晰 | 一次性、彼此独立的批处理任务 |
| 任务失败处理 | 由应用自身处理重试 | 由 Job 的重试/重启策略处理 |
cooldownPeriod(示例中的 120 秒)是 KEDA
特有的一层稳定窗口,专门作用于”缩容到零”这个动作:即使外部指标显示队列已经空了,KEDA
也会在最近一次活跃事件之后再等待这个时长才真正把副本数降到零,避免队列深度在零附近抖动时反复触发”降到零
→ 冷启动拉起 → 再降到零”的循环——这和第二节讨论的 HPA 默认
300
秒缩容稳定窗口是同一类设计动机,只是作用的临界点不同(一个是任意缩容动作,一个专门针对”归零”这个最容易引发抖动的边界)。
authenticationRef 指向的
TriggerAuthentication 资源,负责给 Scaler
提供访问事件源所需的凭证(连接字符串、API
Key、云平台的身份联邦凭证等),把认证信息和扩缩容规则本身解耦,避免凭证明文写在
ScaledObject 里。
六、冷启动与连接池预热:让新副本真正能扛流量
第二节提到,控制环里延迟最长的一段是”新副本从被创建到真正能分担负载”。这段延迟里最容易被低估的部分不是容器启动本身,而是应用进程启动之后、连接池和缓存还没预热完成之前的这段时间——一个刚起来的 Pod 如果被立刻推上流量,第一批请求会集中触发连接建立,这正是连接池设计一文中分析过的、代价最高的路径:TCP 三次握手加 TLS 协商加数据库认证,单次耗时可能是复用已有连接的几十到上百倍。如果扩容动作恰好发生在流量已经很高的时刻,新副本集中建连接反而会短暂增加对数据库的压力,和目标(分担压力)背道而驰。
就绪探针要覆盖预热完成,不只是”进程活着”
Kubernetes 的存活探针(liveness
probe)判断的是”进程是否需要被重启”,就绪探针(readiness
probe)判断的是”这个 Pod
现在能不能接收流量”——两者语义不同,扩容场景真正关心的是后者。一个只检查”HTTP
服务端口能连上”的就绪探针,无法反映连接池是否已经建立好最小空闲连接数(对应连接池配置里的
minIdle)。更稳妥的做法是让
/readyz 端点显式检查依赖状态:
func readinessHandler(w http.ResponseWriter, r *http.Request) {
stats := dbPool.Stat()
if stats.IdleConns() < minIdleThreshold {
// 连接池尚未预热到最小空闲连接数,暂不接收流量
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if !cacheWarmed.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}配合 startupProbe
给预热过程一个独立的、更宽松的超时窗口,避免预热期间被就绪探针和存活探针的短超时误杀:
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet:
path: /readyz
port: 8080
periodSeconds: 3
failureThreshold: 2主动预热优于被动等待
上面的就绪探针方案属于”被动等待”:应用自己后台建连接,探针只是负责在没建好之前不接流量。更主动的做法是在进程启动逻辑里显式地把连接池预热到
minIdle,而不是让连接池按需惰性创建连接——很多连接池实现(包括
HikariCP)默认就是在初始化阶段同步建满
minimumIdle
数量的连接,这一步应该在探针开始检查之前就已经启动,而不是等第一个业务请求触发:
func main() {
dbPool := initConnectionPool(cfg)
// 显式预热,阻塞直到达到最小空闲连接数或超时
if err := dbPool.WarmUp(context.Background(), minIdleThreshold, 10*time.Second); err != nil {
log.Fatalf("connection pool warm-up failed: %v", err)
}
warmLocalCache()
cacheWarmed.Store(true)
startHTTPServer(dbPool)
}不同运行时的预热成本差异很大,这也是”扩容延迟”里容易被低估的一块:Go、Rust
编译出的静态二进制进程启动本身接近瞬时,预热时间基本等于连接池和缓存加载的时间;JVM
应用除了连接池预热,还叠加类加载和即时编译(JIT)预热——刚启动的
JVM
进程用解释执行或低层级编译,性能要经过一段时间的”爬升”才能达到稳定水平。如果扩缩容系统对不同技术栈的服务使用同一套
--horizontal-pod-autoscaler-cpu-initialization-period
或者同一个 startupProbe 超时配置,JVM
服务很容易出现”探针通过了,但性能还没爬升到位就被推流量”的情况。
在应用自身的预热之前,还有一段更早的延迟经常被忽略:镜像拉取。如果新副本被调度到一个从未拉取过该镜像的节点上,冷拉取一个体积较大的镜像所花的时间可能比应用启动加预热还长。常见的缓解手段包括:把镜像做小(多阶段构建、去掉不必要的调试工具链)、给高频扩缩容的节点池配置镜像预拉取(比如用 DaemonSet 在节点加入集群时就拉好基础镜像层),或者使用支持按需分层拉取的镜像格式来缩短”镜像可用”和”容器可以启动”之间的间隔。这一步的优化收益经常被低估,因为它发生在应用代码完全接触不到的层面,但它是整条冷启动延迟链条里实际发生的第一环。
把镜像拉取、进程启动、连接池与缓存预热这三段延迟按顺序排列,就是第二节图中那条”新 Pod 启动”到”真正能分担负载”之间虚线所代表的全部内容——它不是一个笼统的”启动时间”,而是三段可以分别测量、分别优化的独立延迟,工程上应该分别针对它们设定告警阈值,而不是笼统地看一个端到端的”扩容耗时”指标。
HPA 自身对”刚起来的 Pod”也做了保护
第四节提到的
--horizontal-pod-autoscaler-cpu-initialization-period(默认
5 分钟)和
--horizontal-pod-autoscaler-initial-readiness-delay(默认
30 秒),本质上就是官方对”新 Pod
的指标不可信”这个问题给出的补丁:启动阶段的 CPU
尖峰(类加载、JIT 预热、连接池初始化本身也会短暂拉高
CPU)如果被当成真实负载信号,会导致 HPA 因为”扩容之后 CPU
更高了”而继续扩容,形成误判的正反馈[1]。官方文档给出的建议是:把
startupProbe 或 readinessProbe 的
initialDelaySeconds
设置得足够覆盖启动尖峰,并且让
--horizontal-pod-autoscaler-cpu-initialization-period
覆盖预期的启动时长[1]——这再次印证了扩缩容决策和应用自身的启动行为是耦合在一起的,不能只在编排层配置参数,而不管应用代码里连接池、缓存是怎么初始化的。
七、缩容保护:PreStop、优雅终止与 PodDisruptionBudget
扩容时的风险是”新副本还没准备好就被推上流量”,缩容时的风险方向相反:正在处理请求的副本被直接杀死,请求要么超时失败,要么被重试放大成第二次冲击。
优雅终止的标准顺序
Kubernetes 删除一个 Pod 的标准流程是:先把 Pod 从对应
Service 的 Endpoints 中摘除(这一步和实际发送
SIGTERM
是并行的,不保证先后顺序),随后向容器发送
SIGTERM,同时执行 preStop
钩子(如果配置了),等待最多
terminationGracePeriodSeconds(默认 30
秒),仍未退出则发送 SIGKILL
强制结束。这个流程里两个容易踩的坑:
- 因为摘除 Endpoints 和发送
SIGTERM是并行的,负载均衡器感知到”这个 Pod 不该再收流量”需要时间,如果应用收到SIGTERM立刻停止接受新连接,会有一个短暂窗口内新流量仍然被转发过来却被拒绝。常见做法是在preStop钩子里先sleep几秒,把这段时间差留出来。 terminationGracePeriodSeconds必须大于业务处理一个请求的最大耗时,否则长请求会被SIGKILL中断而不是优雅完成。
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
terminationGracePeriodSeconds: 45PodDisruptionBudget 保护的是”自愿驱逐”,不是缩容本身
PodDisruptionBudget(PDB)常被误认为”可以防止服务在扩缩容时被缩得太狠”,但它的实际保护范围更窄,也更精确:PDB
只约束通过 Eviction API 发起的自愿中断(voluntary
disruption),例如 kubectl drain
排空节点、集群自动伸缩器(Cluster
Autoscaler)为了整理节点而驱逐 Pod。Kubernetes
官方文档在中断(Disruptions)概念页明确写着一条警示:“删除
Deployment 或 Pod 会绕过
PodDisruptionBudget”[6]。
这意味着:HPA 缩容的执行路径是把 Deployment 的
spec.replicas 调小,由 ReplicaSet
控制器直接选择多余的 Pod
并删除,这个删除动作走的是普通的对象删除,不经过
Eviction API,因此不受 PDB 约束——PDB 不会阻止 HPA
把副本数从 10 缩到 2,即使 minAvailable 设置为
5。PDB
真正发挥作用的场景是节点维护、节点自动伸缩缩容这类由集群管理侧发起的驱逐操作。
这个边界很重要:如果团队把”配置了 PDB
就等于缩容安全”当成假设,实际的保护网并不存在。真正约束 HPA
缩容节奏的是 behavior.scaleDown
里的稳定窗口和速率限制策略(第二节),以及应用自身对
SIGTERM
的正确处理——这两者才是缩容安全的真正防线,PDB
保护的是另一类风险(节点驱逐、集群维护)。
| 保护机制 | 约束的动作 | 典型场景 |
|---|---|---|
| PodDisruptionBudget | 通过 Eviction API 发起的驱逐 | 节点排空、Cluster Autoscaler 缩节点、VPA Updater 的驱逐 |
behavior.scaleDown 稳定窗口/速率限制 |
HPA 自身触发的副本数下调 | 负载下降后的自动缩容 |
preStop +
terminationGracePeriodSeconds |
单个 Pod 收到删除信号后的行为 | 任何原因导致的 Pod 终止,包括 HPA 缩容 |
八、被动式与预测式伸缩的权衡
前七节讨论的 HPA、VPA、KEDA 都是反应式(reactive)扩缩容:先观测到负载变化,再决策,再执行。这个模式有一个结构性的下限——不可能在负载变化发生之前完成扩容,扩容延迟(第二节的观测+决策+执行三段之和,加上第六节的预热时间)永远存在。
对于变化平缓的负载(日增夜减的流量曲线),反应式的这点延迟通常不是问题;但对于上升速度快于扩容延迟的负载(秒杀开始瞬间、突发的社交媒体传播),反应式扩容注定跟不上——第一批涌入的流量必然要靠已有副本硬扛,扩容只能保护”稍后到来”的流量。
预测式伸缩的两种朴素形式
最简单的预测式伸缩是基于日历的定时伸缩:如果历史数据显示某个时间点流量会规律性上涨(比如每天中午
12 点的用餐高峰),可以在负载真正上涨之前按计划先扩容。KEDA
提供的 cron
触发器就是这类场景的直接实现——本质上是把”预测”简化成”人工总结的周期性规律”,不需要任何机器学习模型:
triggers:
- type: cron
metadata:
timezone: Asia/Shanghai
start: "30 10 * * *"
end: "30 14 * * *"
desiredReplicas: "20"更进一步的形式是基于历史时间序列做负载预测,提前若干分钟到若干小时下发扩容指令,云厂商已经把这类能力做成了标准产品。AWS
EC2 Auto Scaling 的预测式扩缩容功能会分析过去最多 14 天的
CloudWatch 指标历史,生成未来 48
小时的按小时容量预测,预测结果每 6
小时更新一次;实例应该提前多久启动由
SchedulingBufferTime 参数控制(默认 300
秒),保证预测窗口到来之前新实例已经完成预热[16]。这类方案要求至少
24
小时的历史数据才能开始预测,官方文档也明确说明它只适合有明显周期性模式(每日早高峰、每周工作日模式)的负载,对突发、无历史先例的流量脉冲无能为力[16]——这与本节前面讨论的”定时伸缩”是同一类思路的工业化实现,只是把”人工总结的规律”换成了”模型从历史数据里提取的规律”。
学术界在这个方向的探索更早、更系统。Google 的 Autopilot 系统就是一个公开发表的例子:它同时对任务做水平扩展(调整并发任务数)和垂直扩展(调整单任务的 CPU/内存限制),用历史执行数据训练的机器学习模型加上一组精细调校的启发式规则来决定资源配置,论文报告在 Google 内部生产环境中,使用 Autopilot 的作业平均资源浪费(limit 与实际用量的差值)为 23%,而人工配置的作业平均浪费达到 46%,同时因内存溢出被杀的作业数量减少了一个数量级[9]——但要注意这组数字来自 Google 内部 Borg 集群的工作负载画像和长期历史数据积累,不能直接当作”预测式扩缩容通常能节省一半资源”的普适结论去套用到规模、工作负载特征都不同的系统上。
一种更朴素的”预测”:按比例伸缩
介于纯反应式和基于模型预测之间,还有一类更朴素的确定性方案:按集群规模成比例伸缩。典型场景是 CoreDNS 这类为整个集群提供基础设施能力的组件——它需要处理的请求量大致和集群里的节点数、Pod 数成正比,而不是和某个具体的业务指标相关。Cluster Proportional Autoscaler 就是为这类场景设计的:它不依赖 CPU、内存或自定义指标,而是直接读取集群当前的节点数和核心数,按预设的线性公式算出副本数,本质上是把”预测”简化成”一个已知成立的比例关系”,不需要任何历史数据积累或模型训练。这类方案能用的前提很窄——只适用于负载确实和集群规模强相关的基础设施组件,绝大多数业务服务的负载和集群规模没有这种直接关系,不能照搬[8]。
权衡不是”预测更先进所以更好”
预测式方法的代价同样具体:需要足够长、足够稳定的历史数据支撑预测模型;面对突发的、没有历史先例的流量(一次意外的媒体报道),预测模型和反应式控制一样会失效;预测错误(预测流量会涨结果没涨)本身就是一种成本,多扩的容量在错误预测期间被浪费。工程上更常见的是反应式兜底 + 定时/预测式打底的组合:日历规律用定时扩容提前铺好一部分基础容量,反应式扩缩容在这个基础上处理波动,而不是用反应式去覆盖整条负载曲线。这个组合本质上是在用”确定性较高但覆盖面窄”的预测,去缩短”确定性低但覆盖面全”的反应式控制需要追赶的差距,而不是相互替代。
九、多维约束:扩容自己扩不动下游
回到开头的故障:12
个新副本让应用层的并发处理能力上升了,但没有人告诉数据库”也请把
max_connections
一起扩容”。这不是配置疏忽,而是水平扩展的一个结构性特征:应用层的水平扩展和它依赖的每一个下游资源,扩展速度和扩展方式都是独立的,除非显式建模,否则不会自动对齐。
连接数是最直接的例子
连接池设计中给出的
HikariCP 经验公式
connections = core_count * 2 + effective_spindle_count,是针对单个应用实例的连接池大小建议。当应用水平扩展到
(N)
个副本时,如果每个副本仍然按这个公式配置连接池,数据库侧看到的总连接数会是:
\[N_{replicas} \times pool\_size_{per\_pod} \le max\_connections_{db}\]
这个不等式一旦被打破,故障模式和开头案例完全一致:连接耗尽导致排队,排队导致所有副本(包括本来健康的)响应变慢,CPU
利用率不升反降,扩缩容器根据这个错误信号做出更糟的决策。修复方式不是让连接池”更聪明”,而是在扩缩容策略里显式加入这个约束:给
maxReplicas 设一个上限,使得 (maxReplicas
pool_size_{per_pod})
不超过数据库连接上限留给这个服务的配额;或者在数据库前置一层连接池代理(如
PgBouncer),把”应用副本数”和”数据库实际连接数”解耦,代理层的连接数不随应用副本数线性增长。
用一个示例算一遍这个约束怎么落到具体配置上:假设数据库的
max_connections 是 500,运维和迁移工具需要预留
100 个连接,留给这个服务的连接预算是 400;如果每个 Pod
的连接池按 minIdle = maxPoolSize = 20
配置,那么
\[maxReplicas \le \frac{400}{20} = 20\]
也就是说,不论 CPU 或 QPS
指标算出的期望副本数是多少,maxReplicas
都不应该超过
20——超过这个数字,扩容动作本身就会成为下一次故障的起点。这个数字需要随连接池配置、数据库连接预算的变化同步调整,是容量规划台账里应该长期维护的一项,而不是配置一次就不再回顾的常量。
StatefulSet:扩缩容语义完全不同的一类工作负载
前面几节默认扩缩容的对象是 Deployment——副本之间完全对等、互相可替换,删除任意一个都不影响其他副本的正确性。StatefulSet 打破了这个假设:每个副本有固定的序号(Ordinal Index)和稳定的网络身份,缩容时 Kubernetes 按序号从大到小逐个终止,不会跳着删[14],这个顺序保证对很多需要按序下线的有状态系统是必要的,但也意味着 StatefulSet 的缩容天然是串行的,不能像 Deployment 那样并行加速。
更关键的问题是 HPA 对 StatefulSet
做扩缩容时存在语义缺失:控制器只会调用
scale 子资源把 replicas
字段改小,它不知道、也不关心这个即将被删除的副本上是否持有数据分片,是否需要先完成数据迁移或重平衡才能安全下线。对
Kafka Broker、Cassandra 节点、Elasticsearch
数据节点这类需要显式重平衡的有状态服务,直接对 StatefulSet
挂 HPA
是危险的——缩容动作完全可能在数据还没迁移完的情况下就把持有数据的副本删除,造成数据丢失或短暂的数据不可用。这类工作负载如果确实需要弹性,通常依赖专门的
Operator(比如 Strimzi 处理 Kafka
的分区重平衡)在缩容前完成数据搬迁,确认安全之后再调用底层的副本数变更,HPA
不应该直接作用于这类持有分片数据的 StatefulSet。
即使是无状态但用 StatefulSet 部署的场景(比如需要稳定网络标识但不持有数据的服务),扩容时新增的副本序号总是递增,缩容时总是从最大序号开始删——如果业务代码里有任何地方假设了”序号小的副本更老、更稳定”,扩缩容的这个顺序保证需要被纳入设计考虑,而不是被当成和 Deployment 完全等价的行为。
不止是数据库连接:配额、限流、IP 地址都是同一类约束
同样的结构性问题出现在多个维度上:
- 下游服务的限流阈值:如果下游对每个调用方设置了固定的 QPS 配额,上游扩容带来的更高并发能力不会转化为更高的实际吞吐,多出来的请求会被下游限流拒绝,表现为错误率上升而不是吞吐上升。
- 第三方 API 配额:按调用方整体(而非按副本)计算的调用限额,扩容不会增加总配额,只会让配额在更多副本间分摊,单副本可用额度反而下降。
- 云平台的网络资源配额:例如某些 CNI 方案下每个节点可分配的 IP 地址数量有上限,Pod 数量扩到一定规模会遇到 IP 地址耗尽而非计算资源耗尽的瓶颈——这属于Kubernetes 网络系列讨论的范畴,本文不展开具体的 CNI IP 管理机制,但需要知道这类约束确实存在,是容量规划里必须核对的一项。
把约束显式写进容量模型,而不是留给故障复盘
容量规划一文强调过,容量规划的核心不是”哪个组件不够就加哪个”,而是先识别整条链路里最窄的瓶颈。自动扩缩容策略如果只盯着应用层的 CPU 或 QPS,本质上是假设”应用层永远是最窄的瓶颈”,这个假设在没有验证之前是危险的。工程上可执行的做法是:
- 给每个自动扩缩容的 Deployment 设置
maxReplicas上限,上限的计算要覆盖它依赖的所有下游配额,而不是拍脑袋定一个”够用”的数字; - 对下游资源(数据库连接数、第三方配额)本身做容量监控,把”下游资源使用率”也纳入告警甚至纳入扩缩容的联动决策(例如连接池使用率超过阈值时,即使 CPU 未达标也主动预警,而不是被动等 CPU 指标反映出问题);
- 扩容前用第三节讨论的”多指标取最大值”策略,把下游相关的信号(连接池等待队列长度、下游错误率)也作为独立的指标源纳入 HPA,而不是只依赖应用自身的资源指标。
十、扩缩容、过载保护与 SLO 的三角关系
自动扩缩容、限流降级、SLO 管理这三者经常被当作互相独立的模块分别设计,但它们其实在共同争夺同一个东西:系统在负载超出正常水位时的行为。它们的分工边界可以这样理解:扩缩容试图让容量跟上负载,过载保护(限流、降级)在容量暂时跟不上时保住系统不崩,SLO 定义”跟不上”到什么程度算是不可接受。
扩容需要时间,而流量脉冲不会等
第二节和第六节已经拆解过完整的扩容延迟链条:指标采集延迟 + 决策稳定窗口 + Pod 调度启动 + 就绪探针通过 + 应用预热。把这些加起来,一次扩容从”负载开始上涨”到”新副本真正能分担流量”,通常是几十秒到几分钟的量级,具体取决于镜像大小、依赖预热复杂度和是否需要触发节点级别的扩容(Cluster Autoscaler 新增节点通常比调度到已有节点慢得多)。
如果流量脉冲的上升速度快于这条延迟链,自动扩缩容在脉冲结束之前根本来不及生效——这不是配置问题,是这类反应式系统结构性的下限。Google SRE Workbook 在讨论负载管理时给出的建议是把这个时序关系显式排出优先级:“最好把阈值设置成让系统先自动扩容,再触发限流;否则系统可能在本可以通过扩容来承接的流量上,直接开始丢弃”[11]。这句话反过来也说明:限流与过载保护不是扩缩容”失败了才需要”的备用方案,而是扩缩容结构性延迟的必然补充——任何反应式扩缩容系统都需要一层过载保护,用来兜住”扩容还没来得及生效”的这段窗口;如果限流和降级本身也顶不住,优雅降级讨论的业务级裁剪(关闭非核心功能、返回缓存兜底数据)就是再往后退一层的防线。三者的时间尺度依次是分钟级(扩缩容)、毫秒到秒级(限流/熔断)、以及业务方提前设计好的降级开关(一旦决定启用几乎是瞬时的),缺了中间这层,扩缩容的延迟窗口就完全暴露给用户。
Google SRE Book 在讨论容量规划时给出的另一条经验,是把容量按 N+2 的冗余度配置:以最大集群故障点为 2 个计算的容量储备,使得同时发生一次计划内维护和一次计划外故障时,剩余容量仍能承接峰值流量[12]。这条经验本质上是承认”完全依赖动态扩容来应对所有场景不现实”,静态冗余和动态扩缩容需要配合,而不是用动态扩缩容完全替代静态冗余。
扩缩容策略如何影响错误预算
在SLO 工程一文的框架下,扩容延迟窗口内产生的超时和错误会直接消耗错误预算(error budget)。这带来一个容易被忽视的联动:如果扩缩容的稳定窗口和速率限制策略配置得过于保守(比如为了”防止抖动”把扩容也加上很长的稳定窗口),扩容速度变慢,错误预算消耗速度反而会加快。第二节强调”扩容宁快、缩容宁慢”正是这个联动关系的直接体现——扩容延迟直接兑换成错误预算,缩容过快则兑换成后续可能需要立刻重新扩容的抖动成本,两者的风险不对称,配置也应该不对称。
反过来,扩缩容也不应该被设计成”直到错误预算即将耗尽才触发”。更合理的次序是:负载信号先驱动扩容(正常路径)→ 扩容跟不上时触发限流保住核心链路(异常路径)→ SLO 的错误预算和燃烧率告警是衡量”前两层配合得好不好”的结果指标,而不是触发扩缩容或限流本身的一等信号——把结果指标当成触发条件,往往意味着已经晚了一步。
分工小结
把本文和系列里相邻几篇的边界摆在一起看会更清楚:
| 文章 | 回答的问题 | 不回答的问题 |
|---|---|---|
| 本文(22 弹性伸缩) | 容量该不该变、怎么变、变化过程中如何不引入新故障 | 容量已经不够时如何保住系统(见下一行) |
| 限流与过载保护 | 容量确定不够用时,如何决定丢弃谁、降级谁 | 长期该配置多大的容量基线(见下一行) |
| SLO 工程 | “不够用”的量化标准是什么,错误预算怎么花 | 具体用什么机制去追赶或兜住容量缺口 |
| 容量规划 | 长期、跨越多个组件的容量基线该怎么定 | 基线内的短期波动由谁、怎么实时响应 |
这四篇文章合在一起,才是”系统在负载变化下应该如何表现”这个问题的完整答案:容量规划给出中长期的基线,自动扩缩容在基线内追踪短期波动,过载保护兜住扩缩容来不及追上的窗口,SLO 定义整条链路”表现得好不好”的量化标准。缺任何一环,剩下的机制都会被迫承担它本不擅长的职责——比如没有过载保护,扩缩容会被短时脉冲逼到不断振荡;没有容量规划的下游配额核算,扩缩容会像开头的故障案例一样,把上游的问题原样转嫁给下游。
扩缩容配置是否有效,靠主动注入故障验证
maxReplicas、稳定窗口、容忍度这些参数配置得对不对,仅靠日常流量很难验证——正常流量下系统大概率不会撞到边界条件,配置里的隐患要等到真正的大促或者一次意外脉冲才会暴露,而那时候的代价已经是真实的用户影响。混沌工程一文讨论的主动故障注入方法论,同样适用于验证扩缩容行为:在预发布或者经过隔离的生产环境里,主动模拟一次超过
maxReplicas
能承受的流量脉冲,观察系统是否按预期触发限流而不是直接过载;主动杀掉一部分节点触发
Cluster
Autoscaler,测量真实的节点补充延迟是否符合第二节给出的预估;主动制造一次数据库连接数逼近上限的场景,验证第九节讨论的
maxReplicas
安全边界是否真的能防止连接耗尽。这类实验的价值在于把”扩缩容配置是否正确”从”没出过事所以应该没问题”的假设,转变成一个有明确假设、可重复验证的实验结论。
十一、常见故障模式
前面十节分散讨论过一些具体的故障场景,这里把它们集中列出来,作为设计和排查时的检查清单。
震荡(Flapping / Thrashing)
第二节已经解释了成因:指标噪声、延迟、正反馈叠加在一起,让副本数在几分钟内反复升降。表现是
kubectl get hpa 里 REPLICAS
列频繁变化,或者 Deployment
的滚动历史里能看到密集的扩缩容事件。排查顺序通常是:先看稳定窗口和容忍度是不是配置得过窄,再看指标本身的采样噪声是不是过大(比如把
CPU
目标定得太接近实际运行水位,正常的抖动就足以越过阈值)。
Scale-to-Zero 的意外
KEDA、Knative 之类支持缩容到零的方案,节省成本的代价是把”冷启动”从”边缘情况”变成了”每次从零启动都会发生的常态路径”。意外通常出在两处:一是缩容到零之后,第一个请求要等待完整的冷启动链路(调度、镜像拉取、启动探针、应用预热)才能拿到响应,如果调用方设置的超时时间是按”热实例”的响应时间估算的,这个请求会直接超时失败,而不是慢一点返回;二是如果事件源在短时间内产生大量并发的第一个请求(比如一个长期沉寂的 Webhook 端点突然被批量触发),所有请求会同时触发从零的冷启动,造成第六节讨论的冷启动集中效应被放大到”资源还完全不存在”的程度。评估是否使用 scale-to-zero 时,需要明确评估最坏情况下第一个请求的延迟能不能被调用方接受。
指标管道延迟(Metrics Pipeline Lag)
第三节提到,HPA 消费的指标是”经过管道处理之后”的值,不是瞬时真实值。Metrics Server 到 kube-controller-manager 之间,以及自定义指标从 Prometheus 抓取到 Prometheus Adapter 转换的链路上,都存在数据管道自身的延迟——Prometheus 的抓取间隔、聚合窗口、Adapter 的缓存策略都会叠加额外的滞后。这个延迟经常被忽视,因为它不会导致告警或报错,只会让 HPA 看到的世界比真实世界”慢一步”,在流量变化剧烈的时段表现为扩容总是慢半拍,排查时容易被误判为”HPA 反应慢”,实际根因在指标管道而不是控制器本身。
扩容后的惊群效应(Thundering Herd After Scale-Up)
一批新副本几乎同时通过就绪探针、同时开始接收流量,如果每个副本启动时都要做类似的初始化动作——建立数据库连接池、拉取配置中心的全量配置、预热本地缓存——这些动作会在同一个时间窗口内集中冲击下游,形成惊群。第九节讨论的连接数瞬时峰值就是这类效应的一种;另一种常见形式是配置中心或服务发现组件在一瞬间收到几十个新实例的注册/拉取请求,如果这类组件本身没有为这种脉冲做容量预留,反而会成为扩容链路上新的瓶颈。缓解手段包括给客户端的初始化请求加入随机抖动(jitter),避免所有副本在完全相同的时刻发起同类请求。
故障模式对照
| 故障模式 | 触发条件 | 主要影响 | 缓解方向 | 对应章节 |
|---|---|---|---|---|
| 震荡 | 稳定窗口/容忍度过窄,指标噪声大 | 副本数反复跳动,资源浪费,可能影响下游连接数 | 放宽稳定窗口,提高容忍度,换用更平滑的指标 | 第二节 |
| Scale-to-zero 意外 | 缩容到零后首次请求触发完整冷启动 | 首个请求超时或延迟远超预期 | 评估最坏延迟是否可接受,必要时保留最小副本数 | 第五、六节 |
| 指标管道延迟 | 指标采集/转换链路自身的滞后 | 扩容决策系统性慢半拍 | 缩短抓取间隔,监控管道自身的延迟,与控制器决策延迟区分排查 | 第三节 |
| 扩容后惊群 | 新副本集中初始化,同时冲击下游 | 下游瞬时过载,抵消扩容本应带来的缓解效果 | 初始化请求加抖动,下游为扩容脉冲预留缓冲容量 | 第六、九节 |
扩缩容系统本身也需要被监控
上面这些故障模式有一个共同的排查前提:得先知道扩缩容系统正在做什么、做得对不对。HPA
对象自身暴露的状态(kubectl describe hpa 里的
Conditions
字段、AbleToScale/ScalingActive/ScalingLimited
三个状态位)是最基本的信号来源——ScalingLimited
为 True
意味着控制器已经算出了一个更大或更小的期望副本数,但因为撞到了
minReplicas/maxReplicas
而被截断,这是”扩缩容配置的容量上限是否合理”最直接的信号,长期停留在这个状态往往说明
maxReplicas
设置得偏小,或者第九节讨论的下游约束已经先于应用层触顶。除了单个
HPA
对象的状态,副本数随时间变化的曲线本身(有多少次扩缩容事件、每次跨越了多大的副本数变化、两次事件之间的间隔)也应该纳入常规监控,持续偏高的事件频率就是震荡的量化信号,比事后翻日志更早发现问题。一个典型的、值得直接告警的
Conditions 输出如下(字段名和取值来自
Kubernetes 官方文档给出的示例结构,具体数值为示意):
Conditions:
Type Status Reason Message
---- ------ ------ -------
AbleToScale True ReadyForNewScale recommended size matches current size
ScalingActive True ValidMetricFound the HPA was able to successfully calculate
a replica count from cpu resource utilization
ScalingLimited True TooManyReplicas the desired replica count is more than the
maximum replica count
ScalingLimited: True 加
TooManyReplicas
单独出现一两次不是问题——它只是说明当前这一轮计算出的期望值超过了上限,是设计好的保护行为。但如果这个状态在一段较长的时间窗口里持续保持为
True,就意味着 maxReplicas
长期封顶,应用层认为自己需要更多副本却被人为限制,这时候需要回到第九节的多维约束分析,先确认下游是否真的能承受更多副本,再决定是提高上限还是从别的维度解决瓶颈。
十二、开放问题
有状态服务的自动扩缩容仍然缺少通用方案。
HPA/VPA
的模型隐含假设副本是无状态、可以任意增减且互相等价的(这也是无状态设计一文强调”先无状态、再谈扩展”的原因)。对于分片数据库、需要
Leader 选举的组件、或者像 Kafka
消费者组这样”分区数决定了并发上限”的系统,简单的
replicas++
并不必然带来吞吐提升——新副本可能拿不到分片,也可能触发一次代价不小的
rebalance。这类场景目前更多依赖针对特定系统的专用
Operator(感知分片拓扑、协调重分布过程),而不是通用的
HPA/VPA
语义;如何把”扩容”和”重新分片/重新选主”的协调过程标准化,仍然是开放的工程问题。
多指标、多控制器之间的冲突缺少系统性的协调机制。 第四节讨论的 HPA/VPA 在同一资源指标上互相踩踏是官方明确记录的已知限制,本质上反映了一个更普遍的问题:当一个工作负载同时被多个独立的自动化控制器(HPA、VPA、Cluster Autoscaler、自定义的业务级弹性组件)管理时,各控制器基于局部信息做出局部最优决策,不保证全局收敛。学界对 Kubernetes 环境下的自动扩缩容技术做过多轮综述,普遍指出主流方案仍以单一维度的反应式策略为主,跨维度、跨控制器的协调仍是尚未被充分解决的方向[10],对应的工程实践入口可以从该综述整理的分类框架和引用文献出发。
预测式伸缩的可推广性没有定论。 第八节引用的 Autopilot 系统建立在 Google 内部 Borg 集群多年积累的作业画像数据之上,这个前提条件(长期稳定的历史数据、同构的调度基础设施)在大多数公司的实际环境里并不成立。预测模型在数据不足、负载模式频繁变化、或者存在对抗性流量(例如突发的爬虫或攻击)的场景下能不能可靠工作,仍然缺少被广泛验证的公开答案,目前更稳妥的工程结论是:预测式方法适合补充日历规律明显的基础负载,不适合替代反应式控制作为系统的唯一防线。
Scale-to-Zero 场景下的冷启动延迟应该由谁来兜底,没有形成共识。 第十一节提到,缩容到零把冷启动变成了常态路径,而不是边缘情况。目前的工程实践大体分两种取向:一种是接受这个延迟,把它写进服务的延迟 SLO 里,让调用方感知并容忍;另一种是用”最小副本数不为零”或者预留少量常驻实例的方式,把冷启动的代价转化成持续的资源成本。这两种取向哪一种更合理,取决于事件触发的频率分布、调用方对延迟的容忍度、以及资源成本的约束,目前没有一个通用的判断准则能直接套用,需要针对具体业务场景做取舍。
十三、小结
回到开头的问题:副本数应该在什么时候、按什么规则自动调整。这篇文章给出的答案不是一个具体的阈值配置,而是一个思考框架——把自动扩缩容当成一个带延迟的反馈控制系统去理解,而不是当成”配置一次就完事”的开关。几个反复出现的主线值得在这里重申一遍:控制环里的延迟(指标滞后、冷启动、稳定窗口)是震荡和过冲的根源,参数调整的本质是在响应速度和稳定性之间取舍;没有一个指标是完美的,CPU、QPS、延迟各自在不同的失效场景下会撒谎,选择指标要先想清楚它在你的系统里最可能以什么方式失真;HPA、VPA、Cluster
Autoscaler
是三个独立运作、互不知晓对方存在的控制器,排查问题要先定位是哪一层;扩容不是免费的,它会把压力转嫁给连接池、下游配额、限流阈值这些第二维度的约束,maxReplicas
应该由这些约束反推出来,而不是随手设一个足够大的数字;扩缩容的时间尺度是分钟级的,过载保护和优雅降级的时间尺度是毫秒到秒级的,两者是互补关系而不是替代关系。
把这些原则落到具体系统上,还需要结合扩展性原理给出的扩展上限、容量规划给出的中长期基线,以及SLO 工程给出的量化验收标准——自动扩缩容负责的是这几篇文章划定的范围之内、时间尺度在分钟级的动态调节,它既不是容量规划的替代品,也不是过载保护的替代品,找准它在整套可靠性体系里的边界,才能把它的参数调对。
最后一点提醒:本文讨论的所有参数(稳定窗口、容忍度、maxReplicas、目标利用率)都不存在一组可以直接照抄的”最佳实践数值”,因为它们都是相对于具体系统的流量模式、启动耗时、下游承受能力算出来的相对值,脱离具体系统谈论”应该设成多少”没有意义。能够沉淀下来、跨系统通用的是本文反复强调的方法——先画出自己系统的控制环延迟链条,再逐项核对每一层约束,最后用混沌工程的主动实验去验证这些参数在真实故障场景下是否还成立。
导航
上一篇:连接池设计:被忽视的性能杀手
参考资料
规范与官方文档
- Kubernetes Documentation, “Horizontal Pod Autoscaling”. kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/. HPA 控制环同步周期、算法公式、容忍度、稳定窗口、速率限制策略与就绪期处理的权威说明。
- Little, J.D.C., “A Proof for the Queuing Formula: L = λW”. Operations Research, 9(3), 1961. 排队论 Little 定律的原始证明,本文用于说明”在途并发数”指标的直觉来源,并明确了稳态假设的适用边界。
- Kubernetes Documentation, “Vertical Pod Autoscaling”. kubernetes.io/docs/concepts/workloads/autoscaling/vertical-pod-autoscale/. VPA 三组件架构(Recommender/Updater/Admission Controller)、各更新模式(Off/Initial/Recreate/InPlaceOrRecreate/InPlace)与 Updater 遵守 PodDisruptionBudget 的官方定义。
- Kubernetes SIG Autoscaling,
vertical-pod-autoscalerKnown Limitations. github.com/kubernetes/autoscaler/blob/master/vertical-pod-autoscaler/docs/known-limitations.md. 明确指出不应在同一资源指标上同时使用 HPA 与 VPA,以及推荐值可能导致 Pod Pending 的限制。 - KEDA Documentation, “KEDA Concepts”. keda.sh/docs/2.20/concepts/. KEDA 三组件架构、ScaledObject/ScaledJob 语义、以及”CPU/内存触发器不支持缩容到零”的限制说明。
- Kubernetes Documentation, “Disruptions”. kubernetes.io/docs/concepts/workloads/pods/disruptions/. PodDisruptionBudget 的适用范围,以及”删除 Deployment 或 Pod 会绕过 PDB”的官方警示。
- Kubernetes SIG Autoscaling,
cluster-autoscalerFAQ. github.com/kubernetes/autoscaler/blob/master/cluster-autoscaler/FAQ.md. Cluster Autoscaler 的触发条件(Pod 因资源不足而 Pending)与节点扩缩容机制说明。 - Kubernetes Documentation, “Autoscaling Workloads”. kubernetes.io/docs/concepts/workloads/autoscaling/. 对 HPA、VPA 与 Cluster Proportional Autoscaler 的适用场景做了官方分类说明。
论文
- Rzadca, K., Findeisen, P., Świderski, J., et al., “Autopilot: workload autoscaling at Google”. Proceedings of the Fifteenth European Conference on Computer Systems (EuroSys ’20), 2020. 报告了 Google 内部同时做水平和垂直自动扩缩容的生产系统设计,及其相对人工配置在资源浪费和 OOM 发生率上的对比数据(数据来自 Google 内部 Borg 工作负载,不可直接推广)。
- Nguyen, T., et al., “A Survey of Autoscaling in Kubernetes”. 2022 Thirteenth International Conference on Ubiquitous and Future Networks (ICUFN), 2022. 对 Kubernetes 生态下反应式/预测式/混合式自动扩缩容研究的系统综述,可作为深入了解跨维度协调等开放问题的入口。
工程实践参考
- Google SRE Workbook, “Managing Load”. sre.google/workbook/managing-load/. 讨论了自动扩缩容与负载丢弃(load shedding)之间的时序关系,建议系统先扩容后限流。
- Google, Site Reliability Engineering, “Production Services: Being Prepared for Success” 章节(N+2 容量冗余的定义与算例). sre.google/sre-book/service-best-practices/.
- Kubernetes Blog, “Kubernetes v1.33:
HorizontalPodAutoscaler Configurable Tolerance”.
kubernetes.io/blog/2025/04/28/kubernetes-v1-33-hpa-configurable-tolerance/.
HPAConfigurableTolerance特性门控的引入版本与按方向配置容忍度的说明。 - Kubernetes Documentation, “StatefulSets - Deployment and Scaling Guarantees”. kubernetes.io/docs/concepts/workloads/controllers/statefulset/#deployment-and-scaling-guarantees. StatefulSet 缩容时按序号从大到小终止的官方保证。
- Kleinrock, L. Queueing Systems, Volume 1: Theory. Wiley-Interscience, 1975. M/M/1 排队模型平均等待时间公式的标准教科书推导。
- AWS Documentation, “Predictive Scaling for Amazon EC2
Auto Scaling: How It Works”.
docs.aws.amazon.com/autoscaling/ec2/userguide/predictive-scaling-policy-overview.html.
预测式扩缩容的历史数据窗口、预测更新频率与
SchedulingBufferTime参数的官方说明。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计】容量规划:从拍脑袋到数据驱动
容量规划不是'加机器'。本文从排队论基础讲起,用 Little 定律和 M/M/c 模型建立容量预测框架,再拆解全链路压测的设计方法和容量基线与水位线管理的工程实践,用一个电商大促案例走完从历史数据分析到资源供给的全过程。
【列存引擎内核】配置陷阱与容量规划
parts_to_throw_insert、merge 线程池、max_bytes_to_merge、merge_max_block_size、磁盘 SSD/HDD 策略与内存预算;MergeTree settings 与服务器级 config 的容量规划方法。
【可观测性工程】日志管道:Fluent Bit、Vector、Logstash、Cribl 的取舍
日志 Agent 与管道决定数据能否采得上来、送得到、不丢不炸。对比 Fluent Bit、Vector、Logstash、Cribl 的架构与可靠性,给出 K8s DaemonSet、背压处理与带假设的性能对比模型。
【零信任安全架构】微分段深度拆解:从 VLAN 到 eBPF 的访问控制演化
微分段是零信任在网络层的核心机制——从传统 VLAN 的广播域隔离,到 Kubernetes NetworkPolicy 的 IP 级别过滤,再到 Cilium 基于身份的 eBPF 执行和 Istio 的 L7 策略。本文拆解四层微分段技术的实现原理、性能差异和适用场景,以及从'全通'到'全白名单'的策略制定流程。