Kubernetes 弹性伸缩的控制论:从 HPA 比例控制、VPA 分位推荐到 Karpenter 即时供给
执行摘要:大多数团队把弹性伸缩理解为"配一个 HPA,CPU 超过 70% 就加副本"。这在压测里能跑通,在生产里会振荡、会缩容踩踏、会在流量脉冲时永远慢半拍。原因很简单:伸缩不是一个阈值开关,而是一条带纯滞后(dead time)的闭环控制回路——指标延迟、镜像拉取、就绪探针、优雅退出共同构成了滞后,而 HPA 本身只是一个带死区的比例控制器。本文从控制论与排队论出发,拆开 HPA 的期望副本数公式、VPA 的指数衰减分位直方图、Karpenter 的即时装箱求解三层机制,给出可直接落地的参数、代码与生产清单。
一、先算清楚:伸缩的目标到底是什么
伸缩要回答的问题不是"什么时候加副本",而是"稳态下需要多少并发能力"。排队论给出一个非常硬的约束:对 M/M/c 队列,当资源利用率 ρ 趋近 1 时,等待时间按 1/(1-ρ) 发散。ρ=0.5 时排队延迟约为服务时间的 1 倍,ρ=0.8 时是 4 倍,ρ=0.95 时是 19 倍。
这就是为什么 CPU target 不要设成 90%。90% 意味着你的服务自己就已经在排队了,HPA 再加副本时,用户侧的 P99 早就被排队项吃掉。
用 Little's Law 反推所需容量:
L = λ × W
并发数 = 到达率(QPS) × 单请求耗时
副本数 = 并发数 / 单副本并发上限 × (1 + headroom)
举个具体例子:峰值 3000 QPS,单请求 P50 耗时 80ms,单副本并发上限 50:
并发数 = 3000 × 0.08 = 240
副本数 = 240 / 50 = 4.8 → 5
headroom 30% → 7 副本
这 30% 的 headroom 不是浪费,而是控制回路的相位裕度。后面会看到,它本质上是在为滞后时间买缓冲。
二、HPA:一个带死区的纯比例控制器
HPA 的核心公式只有一行:
desiredReplicas = ceil(currentReplicas × observedMetric / targetMetric)
看起来像线性缩放,但生产可用的三个细节全在公式外面:
- 容差带(tolerance)默认 0.1:只有当
observed/target偏离 1 超过 10% 时才会动。这是一条死区,专门用来吸收指标毛刺。 - 多指标取 max,不是取平均:CPU 要 10 个副本、RPS 要 4 个,结果是 10 个。很多"HPA 不缩容"的工单,根因是某个被遗忘的边缘指标在拉高期望值。
- 缩容稳定窗口:
--horizontal-pod-autoscaler-downscale-stabilization-window默认 5 分钟,控制器在过去 5 分钟的所有期望值里取最大值,而不是用当前值。这是一个非对称设计——扩容立刻生效,缩容必须被历史压制。
下面是这段逻辑的等价实现,看懂它基本就能徒手推演任何一次伸缩行为:
func desiredReplicas(cur int32, observed, target float64,
history []float64, tolerance float64) int32 {
ratio := observed / target
// 1) 死区:容差带内直接返回当前副本数,不产生任何变更事件
if math.Abs(ratio-1.0) <= tolerance {
return cur
}
want := math.Ceil(float64(cur) * ratio)
// 2) 缩容稳定窗口:取历史窗口内的最大值,抑制快速回落
if want < float64(cur) {
for _, w := range history {
if w > want {
want = w
}
}
}
return int32(math.Max(1, want))
}
纯比例控制的代价:滞后制造振荡
控制回路里的总滞后是这几项之和:
T_metrics 指标采集+聚合延迟 15s ~ 60s(metrics-server 默认 15s,Prometheus Adapter 常配 30s)
T_decision HPA 同步周期 15s ~ 15s(默认 syncPeriod)
T_boot 调度+拉镜像+就绪探针 20s ~ 180s(镜像 >1GB 或 initContainer 拉依赖时更长)
T_drain 缩容时的优雅退出 terminationGracePeriodSeconds,常配 30s ~ 60s
扩容方向的滞后 T_metrics + T_boot 经常超过 60 秒。对于比例控制器,纯滞后会直接吃掉相位裕度:当环路周期与滞后量级相当时,系统表现为典型的阶跃响应过冲 + 持续振荡——扩容生效时峰值已过,副本加上去后负载突然掉下去,于是缩容缩过头,下一波流量来了又来不及。
一条可直接使用的经验律:
扩容提前量 ≥ T_metrics + T_boot
所以在利用率到达 target 之前就要开始扩,
这就是为什么 target 必须留出 headroom(即 target 不能设到 90%)。
如果业务是脉冲型(秒杀、定时任务、上游批处理回放),纯 HPA 无论怎么调都追不上,必须引入预测式伸缩(按历史周期预扩容)或队列深度指标作为前馈信号。
三、behavior 字段:把控制律显式写出来
K8s 1.18 之后 HPA 支持 behavior,这才让控制律变成可声明的东西。生产上强烈建议显式配置,不要依赖默认值:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: checkout
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: checkout }
minReplicas: 6
maxReplicas: 80
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 65 }
- type: Pods
pods:
metric: { name: http_requests_queue_depth }
target: { type: AverageValue, averageValue: "4" }
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # 扩容不设稳定窗口,越快越好
policies:
- type: Percent
value: 100 # 单周期最多翻倍
periodSeconds: 30
- type: Pods
value: 8 # 或绝对个数取较小者
periodSeconds: 30
selectPolicy: Max
scaleDown:
stabilizationWindowSeconds: 600 # 10 分钟,长于默认的 5 分钟
policies:
- type: Percent
value: 10 # 每 60s 最多缩 10%,线性回落
periodSeconds: 60
三个值得强调的点:
scaleUp.stabilizationWindowSeconds: 0:扩容方向的默认稳定窗口本来就是 0,但显式写出来能防止后来者误改。maxReplicas不是保险丝,而是预算上限。它必须和节点容量、下游数据库连接池、上游限流配额一起算。见过最典型的事故是:HPA 扩到 200 副本,把 PostgreSQL 的连接池打满,整个链路雪崩——伸缩把一个层的过载转嫁成了另一个层的过载。- 缩容踩踏:缩容时 Pod 被杀,还在处理的请求被打断,客户端重试又推高 QPS,触发扩容,形成振荡。必须配
preStophook 先摘流量再退出:
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"] # 等 endpoint 传播 + 负载均衡摘除
terminationGracePeriodSeconds: 60
preStop 的 sleep 时长要大于 endpoint 变更在 kube-proxy/Ingress 上的传播时间(通常 2~10 秒),否则就是无效配置。
四、VPA:用指数衰减直方图给 request 定价
HPA 解决"多少个",VPA 解决"每个多大"。多数团队的 request 是拍脑袋写的,结果是要么超卖到 OOMKilled,要么浪费一半集群。
VPA Recommender 的核心数据结构是带半衰期的指数桶直方图:CPU 用约 100 个桶、桶宽按 1.05 倍增长;每个桶带权重,采样写入时先对所有桶做一次衰减:
import math, time
HALF_LIFE = 24 * 3600 # VPA CPU 默认半衰期 24h
BUCKETS = 100
GROWTH = 1.05 # 桶宽增长比,越靠上越粗
def edges(first=0.01):
e, out = first, []
for _ in range(BUCKETS + 1):
out.append(e); e *= GROWTH
return out
EDGES = edges()
def decay(weights, now, last):
"""按时间差对所有桶权重做指数衰减,保证老样本自然失忆"""
if now <= last:
return weights
factor = 0.5 ** ((now - last) / HALF_LIFE)
return [w * factor for w in weights]
def add_sample(weights, value, weight=1.0):
for i in range(BUCKETS):
if EDGES[i] <= value < EDGES[i + 1]:
weights[i] += weight
return weights
weights[-1] += weight # 溢出落入最后一个桶
return weights
def quantile(weights, q=0.90):
total = sum(weights)
if total == 0:
return None
acc, need = 0.0, total * q
for i, w in enumerate(weights):
acc += w
if acc >= need:
return (EDGES[i] + EDGES[i + 1]) / 2
return EDGES[-1]
推荐值 = quantile(P90 for CPU / P95 for memory) × 安全边际 × 置信系数。置信系数是关键:样本量不足或观测窗口太短时,VPA 会主动把推荐值放大(上限通常到 1.8~2 倍),这是刻意的保守设计——宁可多给资源,也不愿在冷启动时被 OOM 打死。
VPA 的生产陷阱
- Updater 默认靠驱逐来改资源,改 request 必须重建 Pod。对有状态服务或单点任务,这会引发中断。解决路径是使用 K8s 1.27+ 的
InPlaceOrRecreate更新模式,走原地 resize(需要InPlacePodVerticalScalingfeature gate)。 - VPA 与 HPA 不能同时作用于同一个资源指标。两者都基于 CPU 做决策时会互相打架:HPA 看到利用率下降就缩副本,VPA 看到用量下降就调小 request,最终收敛到一个既脆弱又难以解释的稳态。正确组合是 HPA 管副本数(用 RPS 或业务指标)+ VPA 管内存 request,或者只开 VPA 的
recommendationOnly模式,把建议当报表看,人工审批后落地。 - 内存是"只涨不怕"的资源:OOMKilled 是硬失败,而内存超配只是浪费。所以内存 target 通常设得比 CPU 更保守(P95 而非 P90)。
五、Karpenter:把节点供给变成即时装箱求解
Cluster Autoscaler 的模型是"扩节点组":它必须先在云厂商的 ASG / MIG 里挑一个组,再等该组的实例启动。粒度粗(整组同规格)、决策慢(常见 60~120 秒)、而且对多可用区 / 多实例类型的组合爆炸处理得很笨拙。
Karpenter 换了个思路:直接对 Pod 做装箱,然后向云 API 下单。
监听 unschedulable Pod
→ 汇总 pending Pod 的 resource request / 亲和性 / 拓扑约束
→ 在 NodePool 允许的 instance type 集合里求解装箱(通常 FFD 变体)
→ CreateFleet 一次性下单(含 Spot + OD 混合策略)
→ 节点 ready 后由 kube-scheduler 正常调度
→ 持续做 consolidation:把空/低利用率节点上的 Pod 重新压实后回收
首次适配降序(First-Fit-Decreasing)是这个求解器的骨架,几十行就能复现它的核心收益:
def ffd(pods, node_capacity):
"""pods: [(name, cpu, mem)];返回装箱方案"""
pods = sorted(pods, key=lambda p: -p[1]) # 按 CPU 降序,FFD 的关键
nodes = []
for name, cpu, mem in pods:
placed = False
for n in nodes:
if n['cpu'] + cpu <= node_capacity['cpu'] and \
n['mem'] + mem <= node_capacity['mem']:
n['cpu'] += cpu; n['mem'] += mem
n['pods'].append(name); placed = True
break
if not placed:
nodes.append({'cpu': cpu, 'mem': mem, 'pods': [name]})
return nodes
# 20 个 pending Pod,随机规格,节点 4C8G
import random
random.seed(7)
pods = [(f"p{i}", random.choice([0.5, 1, 2]), random.choice([1, 2, 4]))
for i in range(20)]
plan = ffd(pods, {'cpu': 4, 'mem': 8})
print(f"需要节点数: {len(plan)}, 平均装箱率: "
f"{sum(n['cpu'] for n in plan)/len(plan)/4:.0%}")
FFD 的近似比是 11/9·OPT + 6/9,对节点供给这种"宁可多开一个也不能 pending"的场景完全够用——Karpenter 追求的是秒级决策而非最优解。
生产上 Karpenter 有三个必须配置的旋钮:
disruption.budgets:限制同时被扰动的节点数量或比例。没有它,一次 consolidation 可能同时驱逐某个 Deployment 的全部副本。karpenter.sh/do-not-disrupt: "true"注解:保护长跑任务(批处理、模型训练、有状态节点)。这是防止"节点被回收导致 6 小时任务重跑"的唯一手段。- consolidation 与 PDB 的配合:
WhenUnderutilized会持续压实,如果 PDB 设置过严(minAvailable接近副本数),压实会一直失败并产生无效扰动循环。
六、三层伸缩的协同边界
| 层次 | 控制对象 | 决策周期 | 信号源 | 典型参数 | 常见误配置 |
|---|---|---|---|---|---|
| HPA | Pod 副本数 | 15s ~ 60s | CPU / RPS / 队列深度 | target 65%、缩容窗口 600s | target 设到 90%;与 VPA 同指标打架 |
| VPA | Pod request | 小时 ~ 天(半衰期 24h) | 历史用量直方图 | CPU P90 / Mem P95 | 驱逐式更新打断有状态服务 |
| Karpenter | 节点数量与规格 | 秒级供给 / 分钟级压实 | unschedulable Pod + 节点利用率 | disruption budget 10% | 未设 do-not-disrupt,长任务被回收 |
三者的时间尺度必须至少相差一个数量级,否则会互相追尾:VPA 刚把 request 调大,HPA 就看到利用率下降而缩副本;Karpenter 刚压实节点,HPA 又扩容触发新的供给。一个稳妥的默认顺序是:先固定 VPA 的推荐(或设为建议模式),再调 HPA 的 behavior,最后开 Karpenter 的 consolidation。
七、结论
弹性伸缩的复杂度不在 API,而在它是一个跨三层、带纯滞后的反馈系统。判断一个团队的伸缩方案是否成熟,可以问三个量化问题:
- 从负载上升到新副本开始处理请求,端到端滞后是多少秒?(
T_metrics + T_boot) - target 利用率对应的排队延迟是多少?(
1/(1-ρ)) - 缩容一个副本时,正在处理的请求有多少被打断?(优雅退出是否真的生效)
答不上来,说明伸缩还停留在"配了个 HPA"的阶段。真正的工程解法是把 headroom、target、稳定窗口、disruption budget 全部从"默认值"变成"按排队论算出来的值"——这三项工具无一例外,都是在用可配置的保守性换取闭环的稳定性。

发表评论 取消回复