系列第 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)`
三个踩过才知道疼的细节:
- 要除副本数,不是拿总和 。QPS 总量涨一倍,可能只是副本涨了一倍,负载没变。除以
replicas_available才是"每 Pod 负载"------这正是 HPA 的averageUtilization在做的事。 clamp_min(..., 1)防除零 。刚创建或滚动更新中副本数可能为 0,PromQL 返回+Inf/NaN,直接喂给决策引擎就是灾难。- 窗口长度 。默认 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 件事