为什么 HPA 会震荡?手写一个带冷却窗口的伸缩决策引擎

系列第 2 篇《从零写一个 K8s Operator:kubebuilder 初始化到第一个 Reconcile》里,我们的 KubeSentry 只会照着 spec 抄 Deployment。这篇给它装「脑子」:把 QPS / P99 / 错误率接进来,写一个真正能用的伸缩决策引擎,并解释清楚 HPA 那些让人抓狂的抖动到底从哪来。

一、先看两个会真实发生的现场

现场 A:扩容之后更慢了

秒杀开始,QPS 从 800 涨到 3000。HPA 看到 CPU 65%(超过 60% 阈值),把副本从 2 扩到 4,再扩到 6。

诡异的是:P99 从 180ms 涨到了 900ms,CPU 却掉到了 45%。HPA 觉得"负载不高",开始缩容。缩到 3 个,P99 冲到 1.2s,CPU 又飙回 70%,HPA 再扩。

现场 B:缩容缩出了雪崩

错误率 0.3% 的时候一切正常。HPA 按 CPU 缩到 2 个副本,剩下两个 Pod 的连接池瞬间被打满,错误率飙到 8%,CPU 反而降到 30%(请求都在排队,没在算)。HPA 看着低 CPU 继续缩------直到只剩 1 个 Pod 彻底躺下。

两个现场的共同点:伸缩器读到的指标,不是它以为的那个因果。

二、HPA 到底在算什么

先把 HPA 的算法摊开,它核心只有一行:

ini 复制代码
desiredReplicas = ceil(currentReplicas × currentMetricValue / targetMetricValue)

外面套三道刹车(kube-controller-manager 默认值):

机制 默认值 作用
--horizontal-pod-autoscaler-tolerance 0.1 期望/当前比值落在 ±10% 内就不动
--horizontal-pod-autoscaler-sync-period 15s 控制循环频率
behavior.scaleDown.stabilizationWindowSeconds 300s 缩容前在 5 分钟窗口内取最大建议值

看起来挺稳,为什么还是会抖?四个结构性原因:

1. 指标是滞后的,而扩容是立刻生效的

CPU 链路是 cAdvisor(10~15s 采集) → metrics-server → HPA。HPA 算出 ceil(2 × 3000/1000) = 6 的那一刻,用的是扩到 6 个之前的负载。再叠加冷启动------Go/JVM 的连接池预热、warmup、JIT------新 Pod 头 30~90 秒根本吃不满流量。

于是必然过冲(overshoot):该扩 4 个,扩成了 6 个;等指标回落又该缩,来回一趟就是两次波动。

2. CPU 是结果指标,不是原因指标

CPU 只在"瓶颈在 CPU"时才和负载正相关。若瓶颈在下游数据库、在锁竞争、在 GC:

  • 扩副本 → 每 Pod CPU 反而下降(都在等 IO)→ HPA 想缩
  • 缩副本 → 单 Pod 排队更多 → 错误率上升、CPU 依旧不高 → HPA 继续缩

这就是现场 B:HPA 用"CPU 低"这个信号,读出了"负载低"这个错误结论。

3. 多指标取 max,一个抖动信号就能支配全局

HPA 对每个指标单独算比例再取最大比值。逻辑没错(保守优先),但意味着只要有一个指标抖动,副本数就由它说了算,其他信号再健康也没用。

4. 缩容没有"缩完再看一眼"的机制

stabilizationWindowSeconds 解决的是"别缩太快",它不看缩完之后会发生什么。而缩容是唯一会产生瞬时容量缺口的操作------3 缩 2,第 3 个 Pod 上的存量连接全压到剩下两个头上。

所以 HPA 的防抖是时间维度 的(等 5 分钟),不是因果维度的(缩了会不会崩)。

顺带说清:KEDA 也不是解药。KEDA 强在事件源驱动、支持缩到 0,但伸缩决策最终仍交给 HPA 执行,防震荡这块并没有变。

三、KubeSentry 的解法:三件套 + 一个方向性假设

目标不是"比 HPA 扩得更准",而是让副本曲线可预测。四条规则:

markdown 复制代码
1. 多信号取最紧急者     desired = max(desiredBy(s) for s in signals)
2. 迟滞带              |desired - current| ≤ max(1, current × 10%) → 不动(回落 minReplicas 除外)
3. 冷却窗口(单向)     距上次伸缩 < cooldown 时:拒绝缩容,允许扩容
4. 不对称速率          扩:最多 ×2 / 次;缩:最多减 20% / 次(至少 1 个)

第 3 条是核心取舍:扩容和缩容的风险不对称。

  • 扩容错了,代价是几块钱的闲置资源;
  • 缩容错了,代价是一次线上故障。

所以冷却窗口只锁缩容,扩容永远可以紧急放行。这和 HPA 把 stabilizationWindowSeconds 只作用于 scaleDown 是同一个直觉,但我们把它和"上次真实动作"绑定,而不是只看一个固定的时间窗。

CRD 里的策略:

yaml 复制代码
apiVersion: apps.sentry.io/v1alpha1
kind: App
metadata:
  name: order-service
spec:
  image: registry.example.com/order-service:v1.4.2
  replicas: 2
  scaling:
    minReplicas: 2
    maxReplicas: 12
    cooldownSeconds: 60
    metrics:
      - type: QPS
        target: "800"      # 每个 Pod 期望承载 800 QPS
      - type: P99Latency
        target: "200ms"
      - type: ErrorRate
        target: "0.01"     # 1%

四、指标从哪来:三个 PromQL 模板

治理伸缩的第一步不是算法,是把指标定义清楚。每个信号一条 instant query:

go 复制代码
// 每 Pod QPS:总量 ÷ 当前可用副本数
// rate 窗口必须 ≥ 4 × scrape_interval,短窗口会算出尖刺
queryQPS = `sum(rate(http_requests_total{namespace="%s", pod=~"%s-.*"}[2m]))
            / clamp_min(kube_deployment_status_replicas_available{deployment="%s"}, 1)`

// P99 延迟:直方图分位
queryP99 = `histogram_quantile(0.99,
              sum by (le) (rate(http_request_duration_seconds_bucket{namespace="%s", pod=~"%s-.*"}[2m])))`

// 错误率:5xx 占比
queryErrRate = `sum(rate(http_requests_total{namespace="%s", pod=~"%s-.*", status=~"5.."}[2m]))
                / clamp_min(sum(rate(http_requests_total{namespace="%s", pod=~"%s-.*"}[2m])), 1e-9)`

三个踩过才知道疼的细节:

  1. 要除副本数,不是拿总和 。QPS 总量涨一倍,可能只是副本涨了一倍,负载没变。除以 replicas_available 才是"每 Pod 负载"------这正是 HPA 的 averageUtilization 在做的事。
  2. clamp_min(..., 1) 防除零 。刚创建或滚动更新中副本数可能为 0,PromQL 返回 +Inf/NaN,直接喂给决策引擎就是灾难。
  3. 窗口长度 。默认 15s 抓取,就要用 [1m] 以上;[30s] 这种窗口在两三个样本上算 rate,抓取抖动会让值翻倍或腰斩,是副本抖动的隐形来源。

五、决策引擎本体

internal/scaler/engine.go 的职责只有一个:给定当前副本数和一组信号,返回一个决策。它不碰集群、不发请求,是纯函数。

go 复制代码
package scaler

import (
	"math"
	"time"

	appsv1alpha1 "github.com/weirong-ui/kubesentry/api/v1alpha1"
)

type Action string

const (
	ActionNone      Action = "None"
	ActionScaleUp   Action = "ScaleUp"
	ActionScaleDown Action = "ScaleDown"
)

const (
	maxScaleUpFactor  = 2.0 // 单次扩容最多 ×2,防止一条脏指标把副本数冲上天
	maxScaleDownStep  = 0.2 // 单次缩容最多减 20%
	errRateBoostFactor = 1.5 // 错误率越阈时按 1.5 倍扩,而不是套线性公式
)

// Signal 是某一时刻采集到的单个指标值及其目标值
type Signal struct {
	Type   appsv1alpha1.MetricType
	Target float64
	Value  float64
}

// Decision 一次决策的完整上下文:打 Event、写 Status、断言测试都靠它
type Decision struct {
	Action    Action
	From      int32
	To        int32
	Reason    string
	PerSignal map[appsv1alpha1.MetricType]int32 // 每个信号独立算出的期望副本数
}

type Engine struct{}

func NewEngine() *Engine { return &Engine{} }

// Decide 是纯决策:不读集群、不写集群;now 由调用方注入,便于测试冷却窗口
func (e *Engine) Decide(app *appsv1alpha1.App, signals []Signal, now time.Time) Decision {
	current := int32(1)
	if app.Spec.Replicas != nil {
		current = *app.Spec.Replicas
	}
	policy := app.Spec.Scaling

	// 1. 各信号独立算期望值,取最紧急者
	perSignal := make(map[appsv1alpha1.MetricType]int32, len(signals))
	desired := policy.MinReplicas
	for _, s := range signals {
		d := desiredBySignal(current, s)
		perSignal[s.Type] = d
		if d > desired {
			desired = d
		}
	}

	// 2. 卡上下限
	desired = clamp(desired, policy.MinReplicas, policy.MaxReplicas)

	// 3. 迟滞带:差值太小就不折腾
	hysteresis := int32(math.Max(1, math.Round(float64(current)*0.1)))
	withinBand := desired-current <= hysteresis && current-desired <= hysteresis
	// 例外:回落到 minReplicas 不受迟滞带约束。
	// 否则 3 副本时"缩到 2"的差值 1 落在带宽内,永远缩不回去(成本下限失守)
	backToMin := desired == policy.MinReplicas && current > policy.MinReplicas
	if withinBand && !backToMin {
		return Decision{Action: ActionNone, From: current, To: current,
			Reason: "within hysteresis band", PerSignal: perSignal}
	}

	// 4. 冷却窗口:只锁缩容方向
	if desired < current && inCooldown(app, now) {
		return Decision{Action: ActionNone, From: current, To: current,
			Reason: "scale down blocked by cooldown window", PerSignal: perSignal}
	}

	// 5. 不对称速率限制
	if desired > current {
		desired = minI32(desired, int32(math.Ceil(float64(current)*maxScaleUpFactor)))
	} else if desired < current {
		step := int32(math.Ceil(float64(current) * maxScaleDownStep))
		if step < 1 {
			step = 1
		}
		desired = maxI32(desired, current-step)
	}

	if desired == current {
		return Decision{Action: ActionNone, From: current, To: current,
			Reason: "rate limited", PerSignal: perSignal}
	}

	action, reason := ActionScaleUp, "load above targets"
	if desired < current {
		action, reason = ActionScaleDown, "load below targets"
	}
	return Decision{Action: action, From: current, To: desired, Reason: reason, PerSignal: perSignal}
}

// desiredBySignal 把一个信号换算成"需要几个副本"
func desiredBySignal(current int32, s Signal) int32 {
	// 指标不可用时绝不参与决策------宁可这一轮不缩,也不能因为脏数据扩缩容
	if s.Target <= 0 || math.IsNaN(s.Value) || math.IsInf(s.Value, 0) {
		return current
	}
	switch s.Type {
	case appsv1alpha1.MetricErrorRate:
		// 错误率与副本数不是线性关系:越阈就快速加一档,恢复即停
		if s.Value > s.Target {
			return int32(math.Ceil(float64(current) * errRateBoostFactor))
		}
		return current
	default: // QPS / P99Latency:近似线性,用比例公式
		return int32(math.Ceil(float64(current) * s.Value / s.Target))
	}
}

func inCooldown(app *appsv1alpha1.App, now time.Time) bool {
	if app.Status.LastScalingAt == nil {
		return false
	}
	return now.Sub(app.Status.LastScalingAt.Time) <
		time.Duration(app.Spec.Scaling.CooldownSeconds)*time.Second
}

(clamp / minI32 / maxI32 是三个一行能写完的小工具,略。)

说明:上面是 P1 的完整形态(指标由上层采集后以 Signal 注入)。仓库 main 上的 internal/scaler/engine.go 还处在 P0 骨架阶段,指标采集标着 TODO(P1),但本文涉及的两处修复(缩容步长、迟滞带回落 min)已经同步进仓库。

一个真实抓到的 bug:ceil(current × 0.8)

最初我写缩容限速是这么写的:

go 复制代码
// 错误写法
floor := int32(math.Ceil(float64(current) * 0.8))
if desired < floor {
	desired = floor
}

看起来没问题:8 个副本 → ceil(6.4)=7,缩到 7。但单测跑到 4 副本时发现它永远缩不下去 ------ceil(4 × 0.8) = ceil(3.2) = 4,floor 等于 current,决策变成 ActionNone。2 副本同理:ceil(1.6) = 2。

这个 bug 的特征很阴险:副本数越小越致命,而小副本数恰恰是最需要缩容的时候(流量低谷)。写成"最多减 20%,且至少减 1 个"就没有这个问题:

go 复制代码
step := int32(math.Ceil(float64(current) * maxScaleDownStep))
if step < 1 { step = 1 }
desired = maxI32(desired, current-step)

没有表驱动单测,这个 bug 会一直活到线上流量低谷才爆发。

第二个 bug:迟滞带把"缩回最小副本数"也拦住了

迟滞带 max(1, current × 10%) 的本意是"差 1 个副本不值得折腾"。但它在小副本数下会变成陷阱:

  • 当前 3 个副本,指标算出该缩到 2 个 ------ 差值 1 ≤ 带宽 1 → 判定为无意义,不动作
  • 于是它永远停在 3,minReplicas: 2 形同虚设,每个月多花一台机器的钱

单测断言"最终必须回到 minReplicas"直接把它抓了出来。修法不是调参数,而是加一条语义更明确的例外:

go 复制代码
backToMin := desired == policy.MinReplicas && current > policy.MinReplicas
if withinBand && !backToMin {
    // ... 不动作
}

回落到最小副本数不受迟滞带约束 ------因为 minReplicas 是成本的下限承诺,不是一个"大概的范围"。这类 bug 的共同点是:算法本身没错,错在默认了所有副本数地位平等,而实际业务里 2 和 200 的语义完全不同。

三个值得讲的取舍

为什么错误率不套线性公式? 线性公式的前提是"负载可分摊",而错误率是结果 不是负载。3% 错误率(目标 1%)套公式会算出 ceil(2 × 0.03/0.01) = 6,等于假设"错误率随副本线性下降",这没有物理意义。所以错误率走阶梯:越阈 ×1.5,恢复即停。

为什么取 max 而不是加权? 加权平均会稀释最危险的信号。三个信号两个正常、一个 P99 已破 300ms,加权后"还行",但用户感受到的是"已经卡了"。取 max 是保守决策,符合伸缩器"宁可多给"的价值取向。

为什么 now 是入参? 冷却窗口靠时间判断,而 time.Now() 不可控。把它变成参数,单测才能在 1 毫秒里模拟 5 分钟的时间推进。凡是"时间相关逻辑",都该这样设计。

六、执行侧:决策怎么落地

go 复制代码
func (r *AppReconciler) applyScaling(ctx context.Context, app *appsv1alpha1.App, d scaler.Decision) error {
	if d.Action == scaler.ActionNone {
		r.Recorder.Event(app, corev1.EventTypeNormal, "ScalingSkipped", d.Reason)
		return nil
	}

	dep := &appsv1.Deployment{}
	if err := r.Get(ctx, types.NamespacedName{Name: app.Name, Namespace: app.Namespace}, dep); err != nil {
		return err
	}

	patch := client.MergeFrom(dep.DeepCopy())
	dep.Spec.Replicas = ptr.To(d.To)
	if err := r.Patch(ctx, dep, patch); err != nil {
		return fmt.Errorf("patch deployment replicas: %w", err)
	}

	// 只有写成功之后才更新时间戳:否则一次失败的伸缩会白白吃掉一个冷却窗口
	stPatch := client.MergeFrom(app.DeepCopy())
	now := metav1.Now()
	app.Status.LastScalingAt = &now
	app.Status.CurrentReplicas = d.To
	if err := r.Status().Patch(ctx, app, stPatch); err != nil {
		return err
	}

	r.Recorder.Eventf(app, corev1.EventTypeNormal, "Scaled",
		"%s: %d -> %d (%s) signals=%v", d.Action, d.From, d.To, d.Reason, d.PerSignal)
	return nil
}

两个容易翻车的点:

顺序 :LastScalingAt 必须在 Deployment 真的改成功之后才写。先写时间戳再改 Deployment,一旦改失败,冷却窗口就被白吃 60 秒,这期间所有缩容决策都会被误拦。

可观测性 :Event 里带上 PerSignal。线上排查"为什么扩到 6 个"时,kubectl describe app 一眼能看出是 QPS 顶上来的还是错误率顶上来的。可观测性不是加个 Prometheus 就完事------决策依据本身才是最该被观测的东西。

七、用单测回放负载曲线

这是这个引擎最值钱的部分:所有决策逻辑都能在毫秒内验证,不需要集群、不需要 Prometheus、不需要等 5 分钟。

先看一个最有代表性的场景:20 秒的流量尖刺(比如一次推送或者一次缓存击穿,来得快去得也快)。

go 复制代码
func TestDecide_SingleTickSpike(t *testing.T) {
	// 总 QPS:尖刺只持续一个 tick(20s),随后立刻回落到基线
	totalQPS := []float64{1600, 5200, 1600, 1600, 1600, 1600, 1600, 1600}

	app := baseApp() // replicas=2, min=2, max=12, cooldown=60s, target=800 QPS/Pod
	eng := scaler.NewEngine()
	replicas := *app.Spec.Replicas
	now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

	curve := make([]int32, 0, len(totalQPS))
	for _, total := range totalQPS {
		// 每 Pod 负载 = 总量 / 当前副本数,这是最关键的一点:
		// 不能用固定序列喂进去,否则扩了副本指标还不变,算法必然一路扩到 max
		sig := scaler.Signal{
			Type:   appsv1alpha1.MetricQPS,
			Target: 800,
			Value:  total / float64(replicas),
		}

		d := eng.Decide(app, []scaler.Signal{sig}, now)
		if d.Action != scaler.ActionNone {
			replicas = d.To
			*app.Spec.Replicas = replicas
			tm := metav1.NewTime(now)
			app.Status.LastScalingAt = &tm // 模拟写回时间戳
		}
		curve = append(curve, replicas)
		now = now.Add(20 * time.Second) // 一次控制循环 20s
	}

	t.Logf("replica curve: %v", curve)
	// 断言:单 tick 尖刺不该把副本数冲到 4 以上
	for _, r := range curve {
		if r > 4 {
			t.Fatalf("overshoot on single-tick spike: peak=%d, curve=%v", r, curve)
		}
	}
	// 最终必须回到 minReplicas
	if last := curve[len(curve)-1]; last != 2 {
		t.Fatalf("did not settle back to min: %d, curve=%v", last, curve)
	}
}

跑出来是这样(tick = 20 秒):

tick 1 2 3 4 5 6 7 8
总 QPS 1600 5200 1600 1600 1600 1600 1600 1600
无刹车(只留 10% 迟滞) 2 7 2 2 2 2 2 2
KubeSentry 2 4 4 4 3 3 3 2

差别非常直观:

  • 无刹车 :40 秒内创建 5 个 Pod、又立刻销毁 5 个。每个新 Pod 都要拉镜像、预热连接池、可能还要重建本地缓存------这一轮"伸缩"本身就把集群搞得更忙了,而它想解决的尖刺早就过去了。
  • KubeSentry :×2 限速只让它扩到 4。尖刺过去后不急着缩(冷却窗口挡着),然后 3 → 2 阶梯回收。整个过程 2 个 Pod 生死,副本曲线单调,没有任何往返。

再看持续高负载的场景(秒杀,约 2 分钟):两条曲线都会扩到 8 附近,但回落时无刹车是从 7 直接砍到 3(一次减少 57%),KubeSentry 是 8 → 6 → 4 → 3 单调阶梯下降。关键不是峰值高低,是"有没有往回跳"------每一次方向反转,都意味着一批 Pod 被创建或销毁,而这批 Pod 的生命周期可能只有 40 秒。

代价也必须说清楚:资源回收慢 。尖刺过去后,KubeSentry 要多留约 2 分钟的资源(冷却 60s + 逐级缩)。对成本极度敏感的场景把 cooldownSeconds 调到 20~30 秒即可,代价是抗抖动能力同步下降。这是配置问题,不是算法问题------把旋钮交出来,比写死一个"最优值"更负责。

说明:上面两组曲线是单测回放合成负载的结果,可直接跑测试复现;"无刹车"那一行是同一条比例公式去掉冷却与限速后的推算,不是线上压测数据。

八、五个踩过的坑

坑 1:拿 QPS 总量当每 Pod 负载。 扩到 8 个副本后总量涨了、每 Pod 没变,引擎会算出"还要扩",一路顶到 maxReplicas。必须除以 replicas_available。这也是我在单测里坚持用"总量 ÷ 当前副本数"构造信号的原因------模拟数据本身不符合物理,算法再对也白测。

坑 2:[30s] 的 rate 窗口。 15 秒抓取间隔下窗口里只有两个样本,任何一次抓取抖动都让 QPS 翻倍或腰斩。窗口至少 4 倍抓取间隔。

坑 3:NaN 被当成 0 处理。 Prometheus 在指标消失时返回 NaN,而 NaN > target 恒为 false,代码会认为"负载很低",于是缩容。必须显式过滤 NaN/Inf,宁可这一轮不决策。

坑 4:冷却窗口里用 time.Now()。 直接后果是这个逻辑没法测;间接后果是恰好等于 cooldown 的边界行为不可复现。注入时钟。

坑 5:滚动更新期间做伸缩决策。 新版本刚起时 replicas_available 可能只有一半,每 Pod 负载翻倍 → 立刻扩容 → 滚动完成后又被判定超配 → 缩容。做法:app.Status.Phase != Stable 时跳过伸缩,等滚动收敛。

九、下一篇

现在 KubeSentry 会在"应用健康"的前提下聪明地伸缩了。但注意这个前提------如果应用本身正在崩(CrashLoopBackOff、5xx 飙到 30%),再聪明的伸缩也只是给一个病入沉疴的服务加机器。

下一篇《给 K8s 加一个"自动值班员":分级故障自愈引擎》会做三件事:把 CrashLoopBackOff、就绪探针持续失败、金丝雀错误率越阈这三类故障做成一套"扩容 → 降级 → 回滚"的状态机;解决自愈动作本身引发新故障的问题(比如回滚到一个同样有问题的旧版本);以及一个绕不开的工程难题------怎么判断"该重试"还是"该放弃"。

代码在 github.com/weirong-ui/...。觉得有用点个赞,仓库点个 star 追更不迷路;对算法有不同意见欢迎评论区拍砖,尤其是"冷却窗口该不该锁扩容"这个问题------我倾向于不该,但确实有场景需要。


系列目录:01 项目设计 | 02 第一个 Reconcile | 03 伸缩决策引擎(本篇) | 04 分级自愈 | 05 Admission Webhook | 06 手写 mini-Informer | 07 上生产前的 6 件事

相关推荐
-星空下无敌2 小时前
Windows Docker K8s 安装部署Naocs ,SpringBoot 3.x 4.x 整合Naocs 版本兼容 配置中心、服务注册与发现
windows·docker·nacos·kubernetes·springboot·alibaba
吴声子夜歌2 小时前
Kubernetes权威指南——深入掌握Pod(使用StatefulSet搭建MongoDB集群)
容器·kubernetes
fb_123452 小时前
K8s动态卷供应实战_StorageClass与LocalPath_NFS双Provisioner
云原生·容器·kubernetes
大囚长2 小时前
Kubernetes 的资源开销、适用边界,以及游戏与有状态负载的工程现实
游戏·容器·kubernetes
RuoyiOffice2 小时前
Hyper-V+Kubernetes 搭建实战:保留 Windows,构建三节点 Linux 容器集群
linux·spring boot·kubernetes·containerd·hyper-v·微服务部署·ruoyi office
fb_123453 小时前
K8s StatefulSet五大有状态集群实战_Nginx_Etcd_Redis_Cassandra_MySQL
nginx·kubernetes·etcd
mengge.cloud4 小时前
10-29-Kubernetes动态卷供应StatefulSet与ETCD
容器·kubernetes·etcd
小哈里4 小时前
【推理】NVIDIA DLI培训:大规模部署与优化 AI 推理(推理原理,K8S,网关层,pd分离,MoE,Dynamo)
人工智能·容器·kubernetes·nvidia·推理
shangjian0071 天前
K8S学习-基础组件
学习·容器·kubernetes