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)

看起来像线性缩放,但生产可用的三个细节全在公式外面:

  1. 容差带(tolerance)默认 0.1:只有当 observed/target 偏离 1 超过 10% 时才会动。这是一条死区,专门用来吸收指标毛刺。
  2. 多指标取 max,不是取平均:CPU 要 10 个副本、RPS 要 4 个,结果是 10 个。很多"HPA 不缩容"的工单,根因是某个被遗忘的边缘指标在拉高期望值。
  3. 缩容稳定窗口:--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,触发扩容,形成振荡。必须配 preStop hook 先摘流量再退出:
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(需要 InPlacePodVerticalScaling feature 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 有三个必须配置的旋钮:

  1. disruption.budgets:限制同时被扰动的节点数量或比例。没有它,一次 consolidation 可能同时驱逐某个 Deployment 的全部副本。
  2. karpenter.sh/do-not-disrupt: "true" 注解:保护长跑任务(批处理、模型训练、有状态节点)。这是防止"节点被回收导致 6 小时任务重跑"的唯一手段。
  3. consolidation 与 PDB 的配合:WhenUnderutilized 会持续压实,如果 PDB 设置过严(minAvailable 接近副本数),压实会一直失败并产生无效扰动循环。

六、三层伸缩的协同边界

层次控制对象决策周期信号源典型参数常见误配置
HPAPod 副本数15s ~ 60sCPU / RPS / 队列深度target 65%、缩容窗口 600starget 设到 90%;与 VPA 同指标打架
VPAPod 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,而在它是一个跨三层、带纯滞后的反馈系统。判断一个团队的伸缩方案是否成熟,可以问三个量化问题:

  1. 从负载上升到新副本开始处理请求,端到端滞后是多少秒?(T_metrics + T_boot)
  2. target 利用率对应的排队延迟是多少?(1/(1-ρ))
  3. 缩容一个副本时,正在处理的请求有多少被打断?(优雅退出是否真的生效)

答不上来,说明伸缩还停留在"配了个 HPA"的阶段。真正的工程解法是把 headroom、target、稳定窗口、disruption budget 全部从"默认值"变成"按排队论算出来的值"——这三项工具无一例外,都是在用可配置的保守性换取闭环的稳定性。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部