更名说明 :本项目原名 KubeSentry ,自 2026-11-03 起更名 KubeStasis (避免与既有项目重名);CRD group 也于 11-04 由
apps.sentry.io改为kubestasis.io。前几篇文章里的旧名指的都是同一个项目,代码仓库现为 github.com/weirong-ui/...。
本文不讲"我实现了什么",讲"我拿什么证明它有用,以及我怎么把自己的证据推翻四次"。 系列第 6 篇是准入控制,属于"把功能做对";这一篇属于"证明功能有效"------后者更难,因为你随时可以骗自己。
数据版本声明(必读) :本文所有数字来自 commit565b0fb(第 5 轮数据),采集于缩容稳定窗口 (scaling.scaleDownStabilizationSeconds,1301365)落地之前 。当前main的缩容时序已经和本文不同:第三节那个"突发#2 起点误缩容"已被稳定窗口修复、不会再出现;"回到 1 副本慢 65s"现在会再慢约一个窗口。重测协议已按预注册锁定(82f8e40),但数据尚未采集------本文不含任何重测结果,也不拿旧数据冒充新口径。
零、先给结论(按可信度排序,不按好听程度)
| 结论 | 组间差 | 噪声 | 我的信心 |
|---|---|---|---|
| 全程副本-分钟 63.7 vs 72.8(-12.5%) | 9.1 | σ≈1.6(≈6σ) | 相对最稳,但仍然只是一次运行 |
| 空闲#1 期间真的收缩了(5→4) | --- | --- | 事实清楚;代价也清楚(见下) |
| 突发首次扩容 23s vs 40s | 17s | σ≈8.2(≈2.1σ) | 不敢当结论,只作定性参考 |
写完这五轮,我最大的收获不是"我的项目比 HPA 好",而是:我原本最想写进简历的那句"快 1.7 倍",被我自己的数据算成了 2.1σ------和噪声同量级。
40s / 23s ≈ 1.74,这个数字很漂亮,也是最先被我写进 README 的。它的问题不在算错,而在没算方差 :HPA 自己在三轮几乎相同的实验里,首次扩容响应分别是 35s / 51s / 40s。跨三轮极差 16s,而两组之间的差只有 17s。你不能用一个比噪声还小的差异去宣告胜利------这在任何领域都是常识,只是在"自己给自己做验收"的时候特别容易忘。
一、我一开始想证明什么
题目很朴素:项目文档里说"多信号伸缩比 HPA 更聪明",那总得有个对照实验。我给自己定的目标是证明 KubeStasis 比原生 HPA 强。
现在回头看,这个目标本身就错了。它错在把实验当成了论证:实验的产出应该是"在什么口径下、差异有多大、有多可信",不是"我赢了"。 目标是"证明我赢"的时候,你会不自觉地把不稳定的一条当成主结论,把不利的一条塞进脚注------这不是造假,是人的默认行为。
所以我后来的做法是:把"什么算赢"在跑数据之前写死,把"哪一轮该废"交给脚本判。这两件事都在第七节的预注册协议里,也是我建议任何做对比实验的人照抄的部分。
二、六条公平性约定(少一条,对比就不可信)
对照实验的难点从来不是跑起来,是让两边"公平"。这是我实际用的约定:
| 约定 | 做法 | 不这么做会怎样 |
|---|---|---|
| 同一负载镜像 | 两侧都用 kubestasis-demoapp:v1-bench,CPU_SPIN_MS=8(每请求忙等 8ms) |
忙等让 CPU 与 QPS 严格线性,CPU 型 HPA 才有得比;换成 IO 型负载,HPA 的 CPU 信号就没意义了 |
| 同一资源声明 | requests 250m / limits 500m,单 Pod 吞吐上限 ≈ 62 QPS | 资源不同则"单 Pod 能扛多少"不同,扩缩容时机天然错开 |
| 同一副本上下限 | 两侧都 1..5 | 上限不同,峰值到达时间不可比 |
| 同一流量 | 同一 loadgen 镜像、同一时间线、同一时刻切换两侧突发源 | 分开注入两段流量,比的就不是伸缩器而是流量脚本 |
| 各用原生信号 | HPA 走 metrics-server 的 CPU(50%,全默认,含 300s 缩容稳定窗口);KubeStasis 走 Prometheus 的 QPS(16 QPS/Pod,默认迟滞 10% + 冷却 120s + 渐进 20%) | 给 HPA 调小稳定窗口再比"谁缩得快",是把自己想要的结果做进实验 |
| 唯一变量是伸缩器 | HPA 不削弱、KubeStasis 不为压测调参(全用 CRD 默认值) | 任何一侧"优化过",结论都只对这个配置成立 |
最后一条最容易被破坏,也最不该破坏。"HPA 没调优"本身就是一种口径 :默认 HPA 才是绝大多数集群的真实形态。把 HPA 的 behavior.scaleDown.stabilizationWindowSeconds 从 300s 调到 60s,空闲窗口的故事会完全反过来------但那已经不是"和 HPA 比",是"和调过的 HPA 比"。
QPS 目标 16 的来历 (不是拍的):250m × 50% ÷ 8ms ≈ 15.6 QPS/Pod。也就是让 KubeStasis 的 QPS 目标和 HPA 的 CPU 目标余量相同------两边都按"跑到一半容量"来扩。目标设成 60 QPS/Pod 的话,KubeStasis 会更晚扩、看起来"更省"、实际是过载换来的假省。
流量时间线(挂钟驱动,总长 1140s):
bash
0s──120s 基线 12 QPS/侧
120s──360s 突发#1:+150 QPS/侧(共 162 QPS;单 Pod 容量 62 → 必须扩容)
360s──480s 空闲#1:回到 12 QPS
480s──600s 突发#2:+150 QPS/侧
600s──1140s 空闲#2:观察缩容(HPA 300s 稳定窗口 vs KubeStasis 冷却 120s + 渐进 20%)
三、五轮实验:前四轮废在哪
先给总表,再逐轮讲机理。每一轮的废数据都归档在仓库里,可查证。
| 轮次 | 表面现象 | 真正废在哪 | 修了什么 | 归档 |
|---|---|---|---|---|
| run1 | KubeStasis 突发下只到 4 副本 | 对称迟滞带把扩容也挡住了 | 迟滞带改为只抑制缩容、扩容直通 | data-run1 |
| run2 | KubeStasis 侧 QPS 信号恒为 0 | 250 QPS 突发 = 4 倍超卖,单副本饱和到 /metrics 都抓不动 |
突发降到 150 QPS;QPS 目标 60→16 | 未归档(时间轴口径也不对) |
| run3 | KubeStasis 全程 1 副本,像"不扩容" | Prometheus 自己 CrashLoop,整轮信号为 0 | interval: 15s / timeout: 10s(timeout ≤ interval 是硬约束) |
data-run3 |
| run4 | 渐进缩容一个采样窗口内连跳两步 | 写 spec 立刻触发下一轮 Reconcile,早于本轮 status 可见 | 写 spec 之前先写 status 的冷却起点 | data-run4 |
| run5 | ------ | ------ | ------ | data.csv |
3.1 run1:迟滞带把扩容也挡住了
我原本的迟滞带是对称 的:|desired - current| <= hysteresis 就不动作。看起来很合理------小幅波动别折腾。
带宽是这么算的:hysteresis = max(1, current × 10%)。current=4 时 4 × 10% = 0.4,被 max(1, ...) 兜到 1 ;于是 current=4, desired=5、差值正好 1,落进带内 → 该扩不扩。突发流量灌进来,KubeStasis 停在 4 副本,被 HPA 的 5 副本比下去。
注意这个"兜底":max(1, ...) 本来是为了防止小副本数下带宽取整成 0(等于没有死区),结果在小副本数下把带宽撑到和"一步扩容"一样大。任何按比例算的阈值,在小数值区间都会被取整规则改写语义。
这不是参数没调好,是设计错了:震荡的根源从来不是"该扩的时候扩了",而是"刚扩完就缩"。所以死区只该出现在收缩方向------扩要快(保可用性),缩要稳(保稳定)。修复后的代码里我把这段原因直接写进了注释,免得以后有人"顺手改成对称的":
go
// 这条不对称是压测逼出来的(2026-11-04,docs/benchmark/):
// 对称迟滞带在 current=4、desired=5 时把扩容也挡住了------差 1 落在带内,
// 于是突发流量下少一个副本(约 62 QPS 承载),2 分钟多出几百个失败请求。
// 而震荡的根源从来不是"该扩的时候扩了",而是"刚扩完就缩"。
// 所以死区只该出现在收缩方向:扩要快(保可用性),缩要稳(保稳定)。
hysteresis := int32(math.Max(1, float64(current)*float64(policy.Hysteresis())/100))
// 例外:回落到 minReplicas 不受迟滞带约束,否则 3 副本时永远缩不回 2
backToMin := desired == policy.MinReplicas && current > policy.MinReplicas
if desired < current && current-desired <= hysteresis && !backToMin {
return Decision{Action: ActionNone, FromReplicas: current, ToReplicas: current,
Reason: "scale-down within hysteresis band", ReasonCode: ReasonHysteresis,
Signals: signals, BelowTarget: clearedBelowTarget()}
}
改完这一条,run1 的数据整体作废。
这个 bug 只有跑对照压测才会暴露。日常演示里 1→5 的扩容幅度大,"差 1 个副本"根本不会出现;只有把上限压到 5、让控制器停在 4,才会撞上。
3.2 run2:超卖失明,以及一个更隐蔽的时间轴 bug
run2 我加大了突发(250 QPS)。结果是 KubeStasis 侧的 QPS 信号全程 0------在需要扩容的那一刻,控制器瞎了。
原因很物理:250 QPS 对单 Pod 62 QPS 的容量来说是 4 倍超卖,单副本被打到饱和,/metrics 端点自己也响应不过来。Prometheus 抓不到,rate() 算出来就是 0。伸缩器依赖的指标,恰恰在系统最需要伸缩的时候最先失效------这条经验后来写进了我对任何"指标驱动系统"的设计直觉里。
修法是把突发降到 150 QPS(2.4 倍超卖),并顺手把 QPS 目标从 60 调到 16(对齐第二节那个余量口径)。
但 run2 还藏了一个更蠢的问题:我的采样循环用循环计数器当时间轴。当时的结构大意是这样(不是仓库现存代码,那段已经删了):
bash
# 错的做法(run2 之前)
i=0
while (( i < 1140 / 5 )); do
sample
i=$((i+1))
sleep 5 # 实际每轮要 6-9 秒(kubectl 调用 + Prometheus 查询)
done
一轮采样要 6-9 秒,因为里面有好几个 kubectl get 加一次 Prometheus 查询。计数器以为过了 5 秒,实际过了 7 秒------19 分钟的压测被拉成 35 分钟 。而 HPA 的 300s 缩容稳定窗口是真实时间,被拉伸的时间轴会直接毁掉整个对比:你以为 HPA"300 秒没缩",其实它只过了 214 秒。
bash
# 对的做法(当前 hack/benchmark.sh,节选,省掉了采样调用与事件触发)
START=$SECONDS
while :; do
t=$((SECONDS - START)) # 挂钟,不是计数器
(( t > DURATION )) && break
...
printf '%d,%s,%s,%s,%s,%s,%s,%s,%s,%s\n' \
"$t" "$label" "$(hpa_des)" "$(hpa_cur)" "$(ks_spec)" "$(ks_ready)" \
"$(cpu_m bench-hpa)" "$(cpu_m bench-ks)" "$(qps_of bench-hpa)" "$(qps_of bench-ks)" >> "$OUT"
next=$(( (t / 5 + 1) * 5 )) # 对齐到下一个 5 秒边界
while (( SECONDS - START < next )); do sleep 0.3; done
done
教训很直白:任何和时间有关的实验,时间轴必须来自时钟,不能来自循环次数。 采样本身有开销,这个开销不是常数,还会随集群负载变化------用计数器就等于假设它不存在。
3.3 run3:Prometheus 自己 CrashLoop 了
run2 之后我把 scrape_timeout 从 5s 调到 20s,想让饱和的 Pod 也能被抓到。结果整轮跑完,KubeStasis 侧恒为 1 副本。
Prometheus 拒绝启动。 因为 scrape_interval 还是 5s,而 scrape_timeout 是 20s------timeout > interval 在 Prometheus 里是硬错误,进程直接退出、CrashLoopBackOff。
这个坑最阴的地方是它崩得很安静:
- 压测脚本不关心 Prometheus 活没活,照常每 5 秒采一行、照常出 CSV;
- 唯一异常是 QPS 那一列恒为 0;
- 而 QPS 恒为 0 的控制器行为看起来完全合理------不扩容,因为"没有流量"。
差一点我就把 run3 写成"KubeStasis 在突发下不扩容"的反面案例了。真正的结论是:看对照数据的第一件事,是验证对弱势一侧的输入是否存在,而不是急着解释结果。 这一条后来被我写成判废规则(7.3 的规则 2、6)。
3.4 run4:一个采样窗口连跳两步(这个是真 bug)
run4 时间线全对,指标也正常,但我在副本数曲线上看到一个不该出现的形状:渐进缩容应该每轮只走一步(20%),实际却在一个采样窗口里 3 → 2 → 1 连跳两步,冷却窗口形同虚设。
机理是这样的:
Apply写spec.replicas(比如 3→2);- 这个写操作立刻触发下一轮 Reconcile;
- 但本轮刚写的 status(
lastScalingAt,冷却起点)还没进 cache; - 下一轮读到的是旧的
lastScalingAt,判断"冷却已过期",于是再缩一步。
修法是调整写顺序:先写 status 的冷却起点,再写 spec。因为 watch 事件按 resourceVersion 有序送达,先写的 status 一定先到 cache,下一轮 Reconcile 必然看到新的冷却时间。
go
if decision.Action != scaler.ActionNone {
// 冷却起点必须先于 spec 写入落地:spec patch 会立刻触发下一轮 Reconcile,
// 若那一刻 cache 里的 status 还没带上 LastScalingAt,冷却窗口形同虚设------
// 压测 run4 抓到 3→2→1 在一个采样窗口内连跳两步(docs/benchmark/)。
// watch 事件按 resourceVersion 有序送达,先写 status 就能保证后续任何
// Reconcile 读到的都是"已开冷却"的对象。
now := metav1.Now()
app.Status.LastScalingAt = &now
if err := r.Status().Patch(ctx, &app, client.RawPatch(types.MergePatchType,
[]byte(`{"status":{"lastScalingAt":"`+now.UTC().Format(time.RFC3339)+`"}}`))); err != nil {
return r.failOn(ctx, &app, "scale", fmt.Errorf("pre-write lastScalingAt: %w", err))
}
if err := r.Scaler.Apply(ctx, r.Client, &app, decision); err != nil {
return r.failOn(ctx, &app, "scale", err)
}
}
凡是"本轮写、下轮读"的状态,写入顺序本身就是正确性的一部分。 这句话后来被我抄进了项目记忆,因为它不只适用于伸缩------finalizer、phase、任何被下一轮 Reconcile 当输入用的字段都是。
3.5 run5:终于干净了,但只干净一次
run5 就是本文的数据。它没有废,但它只有一次------这是第六节要处理的另一个问题。
四、跑出来的数字
所有指标都由 hack/benchmark_report.py 从 data.csv 现算,不手填 。下面这张表是我刚在本机重跑脚本的输出(python hack/benchmark_report.py docs/benchmark/data.csv),你可以拿 CSV 自己复算:
| 指标 | HPA(CPU 50%) | KubeStasis(QPS 16/Pod) |
|---|---|---|
| 扩容响应(突发#1,首次 >1 副本) | 40s | 23s |
| 到达峰值用时(突发#1,5 副本) | 67s | 40s |
| 峰值副本数 | 5 | 5 |
| 空闲#1 结束时副本数 | 5 | 4 |
| 突发#2 回到峰值用时 | 0(未缩容,无需回补) | 82s |
| 副本变更次数(全程) | 5 | 9 |
| 副本-分钟(成本代理) | 72.8 | 63.7(-12.5%) |
| 空闲后回到 1 副本 | 385s | 450s |
副本数时间线(30s 刻度,节选;完整采样 212 行在 CSV 里):
| t(s) | 阶段 | HPA | KubeStasis |
|---|---|---|---|
| 120 | burst1 注入 | 1 | 1 |
| 150 | burst1 | 1 | 2 |
| 180 | burst1 | 4 | 5 |
| 210--330 | burst1 | 5 | 5 |
| 390 | idle1 | 5 | 5 |
| 420--450 | idle1 | 5 | 4 |
| 480--540 | burst2 | 5 | 4→3→5(误缩容,见 5.2) |
| 690--780 | idle2 | 5 | 4 |
| 810--900 | idle2 | 5 | 3 |
| 930--960 | idle2 | 5 | 2 |
| 990--1020 | idle2 | 1 | 2 |
| 1050--1140 | idle2 | 1 | 1 |
先给自己验个"对比没被污染" :突发窗口(t=180--360)均值,HPA 158 QPS / 1498m,KubeStasis 160 QPS / 1517m------每千 QPS 的 CPU 核数两侧都是 9.5。同一镜像的必然结果,但必须算一遍:如果两侧这个数不同,说明有一侧跑得更慢、占的 CPU 更多,那"谁扩得快"就掺进了性能差异。
4.1 副本-分钟是怎么算的
这个指标是本文的主结论,口径必须说清。它不是"平均副本数 × 时长"估的,而是对采样序列做阶梯积分:
python
def replica_seconds(rows, key):
"""Σ replicas × 采样间隔,/60 得 replica-minutes。"""
total = 0
prev_ts = None
prev_v = None
for r in rows:
if prev_ts is not None:
total += prev_v * (r["ts"] - prev_ts) # 用真实间隔,不是常数 5s
prev_ts, prev_v = r["ts"], r[key]
return total / 60.0
注意这里用的是 r["ts"] - prev_ts(真实间隔),不是常数 5。因为采样循环本身会漂到 6--9 秒(第三节 run2 那个坑的另一面),用固定 5s 会把总时长算少、把副本-分钟低估。这是同一个坑在两处的表现:一处让时间轴错位,一处让积分漏算。
五、赢在哪、输在哪(其中一条不算我的功劳)
5.1 "快"这一项,一半不是我的功劳
23s vs 40s,看起来是控制器实现得好。实际原因是信号不同型:
- QPS 信号在请求落地的瞬间就开始爬升(1m 窗口的
rate); - CPU 信号要等 metrics-server 15s 采样 + HPA 同步周期 + 利用率跨过 10% 容忍度才动作。
换成任何 QPS 驱动的伸缩器(KEDA 之类)大概率也有这个优势。这是设计选择的红利,不是实现优劣。 我在报告里把这句话写在"赢的三项"里,因为不写就是在偷功------面试时被追问一句"KEDA 是不是也一样"就露馅了。
真正属于我的部分是:我能在信号选择上做这个决定,并且知道它的代价(下面 5.2 就是代价)。
5.2 输的第一项:突发起点误缩容(已被修复)
t≈535 时,QPS 的 1m 窗口均值还在 30--50,控制器算出 desired=3 < current=4,冷却刚好到期,于是 4→3;13 秒后 QPS 冲到 120+,又直接扩回 5。这一缩一扩,就是"变更次数 9 vs 5"里多出来的两次之一,也对应了本次运行里 KubeStasis 侧更多的失败请求(99 / 13580 = 0.7% vs 33 / 13406 = 0.2%)。
根因不是参数:我根本没有缩容稳定窗口。任何短促的信号低谷都会被当成缩容依据------突发流量刚注入时 loadgen 还没起来(t=528 实测 QPS 甚至是 0),这个"0"就被当成了"需求下降"。
这是与 HPA 对比中输得最实的一项 ,不是调参能掩盖的,需要"信号持续低于目标 N 秒才允许缩容"的机制。后来落地成 scaling.scaleDownStabilizationSeconds(默认 60s)+ status.belowTargetSince:任一信号高于目标的采样立即重置计时。真集群验证过------3 副本零流量,窗口期内纹丝不动,到点后单步 3→2。
顺带说明为什么"默认 60s"这个数我不敢吹:它只在单节点单应用口径下验证过。真实流量回放下该用 0 / 60 / 300 哪一档,得再跑实验。这就是 STATUS 里把它列进"已知不解决"的原因。
5.3 输的第二项:回到 1 副本反而慢 65s
HPA 385s,KubeStasis 450s。看起来 HPA 赢。
但口径要看清楚:HPA 的 385s 里,前 ~300s 副本一直挂在 5(稳定窗口白等),然后一步 5→1;KubeStasis 的 450s 里副本是 5→4→3→2→1 逐级下降的。所以:
- "回到 1 的时间" HPA 赢;
- "空闲期平均成本" KubeStasis 赢。
两个指标互相打架,不是谁算错了,是这套机制的结构性结果:渐进缩容用"更晚到最低点"换"更平滑地降下来"。任何号称"又快又省"的伸缩方案,都该被问一句"你这两个指标是怎么同时拿到的"。
5.4 输的第三项:变更有成本,而副本-分钟算不到
9 vs 5 次变更。副本-分钟这个口径只算副本数的时间积分,不算变更本身的开销:Pod 创建/销毁、连接抖动、冷启动、JVM 预热、模型加载......对长启动的服务,多变更两次的代价可能远大于多挂两个副本。
我没法用一个数字把这件事折算进去(不同服务差太多),所以只能在报告里写明:这个指标偏向"变更便宜"的服务。 这也是主结论必须带口径的原因。
六、把自己最想讲的卖点算成 2.1σ
run5 只有一次。单次运行的组间差,怎么知道不是运气?
我做了个现在看很值得的检查:找一组"准重复"样本,量一下噪声底。
6.1 一个被忽略的样本池
run1(对称迟滞带 bug)和 run4(跨步 bug)的 bug 都只影响 KubeStasis 侧。HPA 侧的配置从头到尾没动过:同镜像、CPU 50%、1..5 副本、同一流量时间轴。
所以 run1 / run4 / run5 的 HPA 侧,是同一配置下的 3 次准重复。 这三个 CSV 本来都被我扔进"废数据"目录了,结果成了唯一能估噪声的东西。
先声明两个不纯净:
- 三轮是双组同时跑的 。KubeStasis 侧每轮行为不同(迟滞 bug、跨步竞态、修复后),副本数不同 → 对 HPA 的 CPU 争抢也不同。所以 σ 混着组间污染,是噪声的上界估计。
- n=3。σ 的置信区间非常宽(自由度 2 时 95% CI 大约横跨 4.6--27),只能定量级。
6.2 复算结果
python hack/variance_precheck.py:
| 指标 | run1 | run4 | run5 | 均值 | 组内 σ | 极差 |
|---|---|---|---|---|---|---|
| 扩容响应(突发#1,s) | 35 | 51 | 40 | 42.0 | 8.19 | 16 |
| 到峰值用时(s) | 68 | 82 | 67 | 72.3 | 8.39 | 15 |
| 副本-分钟 | 70.2 | 70.0 | 72.8 | 71.0 | 1.58 | 2.8 |
6.3 三个结论的效应量
| 报告原结论 | 组间差 | 组内 σ | 效应量 | 判定 |
|---|---|---|---|---|
| 扩容响应 23s vs 40s | 17s | 8.2s | ≈2.1σ | ⚠️ 不稳。HPA 跨三轮自身就波动 16s(35→51),和组间差同量级 |
| 到峰值 40s vs 67s | 27s | 8.4s | ≈3.2σ | 🟡 勉强,边界薄 |
| 副本-分钟 63.7 vs 72.8 | 9.1 | 1.6 | ≈6σ | ✅ 最稳的一条 |
结论:我最想讲的那个卖点(快),恰恰最不稳;最稳的那个(省),被我一直放在次要位置。
顺带还找到了噪声的来源证据:run1 与 run4 在突发起点的 CPU 爬升相差近 20s(t=132 时 257m vs 137m),同镜像同脚本。抖动来自 loadgen Pod 的启动与调度(单节点争抢),不是策略差异------第八节第 1 条那条"单节点"的口径限定,在这里被量化了。
6.4 于是我把报告的一句话结论改了
不是加脚注,是改写:
- 旧:"KubeStasis 扩容响应比 HPA 快 1.7 倍"(这条曾经在 README 里)
- 新:"在本实验口径内,KubeStasis 全程副本-分钟少 12.5%,这是效应量最大(≈6σ)、最可复现的结论;首次扩容响应单次观察到 23s vs 40s,但 HPA 侧跨三轮自身波动就达 16s,该差异与噪声同量级,在补齐重复实验前只作定性参考"
如果一份实验报告的主结论,在你自己算出方差之后需要降级------那它本来就该降级。先算方差再写结论,比写完再被读者算出来要好得多。
七、预注册:在看到数据之前把判定线写死
6.4 之后问题变成:怎么把"12.5%"从"单次观察"升级成"可复现结论"?答案是重复实验。但重复实验有个更隐蔽的坑------你会在看到数据之后,忍不住挑口径。
这就是 p-hacking 的三个入口,我逐条对着自己写:
| 入口 | 具体表现 | 对策 |
|---|---|---|
| 挑指标 | 8 个指标里总有两三个"显著",挑那个讲 | 主指标只有一个:副本-分钟。其余全部标"描述性",不做检验 |
| 挑轮次 | 10 轮里 7 轮支持,删掉 3 轮"异常"的 | 删轮权力只归机器判废规则,人不能推翻 |
| 挑方向 | 双侧不显著就改单侧,单侧不行就换说法 | 判定线、措辞、检验方法,全部在看到数据前入库 |
所以我把协议写成一份预注册文件 ,docs/benchmark/pre-registration.md,规则自己定的:
首轮数据落盘之后,本文只允许在 §11 修订记录追加,§1--§10 的判定线一个字不许改。要改 = 重新预注册,git 历史可查。
7.1 为什么是 5 个配对,不是 3 个
判定用单侧精确符号检验 ,5 个配对差 d_i = 副本分钟_KS − 副本分钟_HPA:
| 结果 | p 值 | 判定 |
|---|---|---|
5/5 个 d_i < 0 |
0.031 | 支持 H1 |
4/5 个 d_i < 0 |
0.188 | 方向一致、证据不足 |
| ≤3/5 | --- | 未检出(≠ 证明无差异) |
算一下就知道为什么轮数是 5:n=3 时全对也只有 1/8 = 0.125,怎么都过不了 0.05 ;n=5 全对是 1/32 = 0.031,是能过线的最小样本。这不是"跑 5 轮比较吉利",是在 5% 显著性水平下,配对数不能少于 5。
7.2 ABBA 交替,不是为了好看
10 轮 = 5 个配对,对内顺序交替:
| 配对 | 第 1 轮 | 第 2 轮 |
|---|---|---|
| 1 | HPA | KubeStasis |
| 2 | KubeStasis | HPA |
| 3 | HPA | KubeStasis |
| 4 | KubeStasis | HPA |
| 5 | HPA | KubeStasis |
原因是笔记本会随时间漂移:电池策略、温控降频、后台任务。如果总是先跑 HPA 再跑 KubeStasis,那 KubeStasis 永远承受"更热、更慢"的那一半时间,系统性偏差会伪装成组间差异。ABBA 让两臂在时间轴上对称采样。
同时改成串行单臂 :每轮只部署一臂,另一臂的 Deployment / HPA / App / loadgen 都不存在,单臂独占节点 CPU。这一条直接消灭了 6.1 说的组间争抢污染,代价是绝对数字和 run5(双组同跑)不可比------重测结果自成一体,不和 run5 直接比数。 这个取舍也写进协议了,免得跑完又想把两套数据拼在一起讲。
7.3 判废规则:把人从"要不要删这轮"里拿出去
每一条都对应历史上真实废掉的一轮,全部由脚本自动判定:
| # | 规则 | 来源教训 |
|---|---|---|
| 1 | 相邻采样间隔中位数 > 7s,或 >10% 的间隔 > 10s | run2 前的循环计数器 bug |
| 2 | 任一突发窗口内该臂 QPS 连续 > 60s 为 0 | run3 Prometheus CrashLoop |
| 3 | 突发窗口内副本峰值 < 5 | run2 单副本饱和失明 |
| 4 | t < 120 时副本数 > 1 | 清场不彻底(每轮开始前删掉整个 bench 命名空间) |
| 5 | QPS 首次超过 100 的时刻距突发起点 > 60s | run5 的 loadgen 48s 启动延迟 |
| 6 | 采样期间 Prometheus 或 metrics-server 不可用 | run3 |
规则 5 还有个连带修复:四个 loadgen Deployment 全部补了 resources.requests(稳态 50m、突发 100m,两侧同值)。run5 那个 48s 的启动延迟,是单节点调度把没声明 request 的 loadgen 饿死的直接产物------一个没写 requests 的压测工具,会把测量误差算到被测对象头上。
7.4 三种结局的措辞,也提前写死
这是我觉得最值得抄的一段:
| 结局 | 预注册措辞 |
|---|---|
| H1 站住(5/5) | "在预注册协议口径内,副本-分钟差异经 5 次独立配对重复确证" |
| H1 未达线(4/5) | "方向与预注册假设一致,但未达判定线"------不许用"确认""证明" |
| 中止(判废 > 4 或超时) | 只报已完成轮数的描述统计,明确写"未完成预注册协议",不提取任何方向性结论 |
外加一条止损:自首轮采集起 3 个自然日硬上限,超限即停;完整配对 < 5 则主判定自动降级为"不判定"。这条现在写死,就是为了防止到时候"再凑一轮凑到显著"。
八、这份结论在哪些条件下不成立
对照实验最容易被误用的地方,是读者拿你的数字去套自己的场景。所以口径限定不是免责声明,是结论的一部分:
| # | 边界 | 为什么不能外推 |
|---|---|---|
| 1 | 单节点 docker-desktop,被测服务/loadgen/Prometheus/metrics-server 挤同一节点抢 CPU | 真实多节点会引入调度、镜像拉取、跨节点网络,两边都变慢,比例未必保持 |
| 2 | 负载是纯忙等 (CPU_SPIN_MS=8) |
没有内存压力、没有 IO、没有长尾请求;真实业务的 HPA 未必这么"听话" |
| 3 | 信号不同型是设计差异,不是实现优势 | 换 KEDA 之类 QPS 驱动方案大概率同样快 |
| 4 | HPA 全默认行为 | 调小缩容稳定窗口、改行为策略,空闲窗口的故事会完全反过来 |
| 5 | 单次运行、19 分钟 | 无多轮均值、无方差(已实测 σ≈8.2s,见第六节) |
| 6 | KubeStasis 参数全默认 | 冷却 120s / 渐进 20% / 迟滞 10% 没扫过参数空间,"最优"无从谈起 |
| 7 | 副本-分钟算不到变更成本 | 对长启动服务(JVM 预热、模型加载),变更代价会被放大 |
第 3 条和第 4 条是"对自己不利"的,但必须写。一份只讲"我赢了"的对照报告,读的人没法判断可信度------把不利条件列全,剩下的才是可信部分。
九、复现
bash
# 前提:集群已部署 KubeStasis,manager 带
# --prometheus-url=http://prometheus.bench.svc:9090 --prometheus-window=1m
kubectl apply -f deploy/benchmark/benchmark.yaml # 双组对照 + loadgen + Prometheus
bash hack/benchmark.sh docs/benchmark/data.csv 1140 # 19 分钟完整时间线
python hack/benchmark_report.py docs/benchmark/data.csv
python hack/variance_precheck.py # 噪声底(用三轮 HPA 侧准重复)
三样东西都能查:原始 CSV(212 行 5 秒粒度)、算指标的脚本、算方差的脚本。你可以拿 CSV 复算、质疑、推翻本文任何一行------这是我说"这些数字不是手填的"唯一能兑现的方式。
十、这五轮真正教我的
把可迁移的部分拎出来,和 K8s 无关的那些:
- 时间轴必须来自时钟。 采样有开销,开销不是常数;用循环计数器当时间就是在假设它不存在。
- 指标驱动系统的指标,会在最需要它的时候失效。 饱和的 Pod 抓不动
/metrics,这和"监控在故障时最先挂"是同一件事。设计时要假设信号可能为 0,并区分"真的是 0"和"读不到"。 - 对照实验的第一件事是验证输入存在。 run3 差点被我写成"不扩容"的结论,实际是 Prometheus 崩了。看结果之前先看两边的输入。
- "本轮写、下轮读"的状态,写入顺序就是正确性。 run4 的连跳缩容,根因是 spec 写操作触发的下一轮 Reconcile 读到了旧 status。
- 先算方差,再写结论。 我原本的"快 1.7 倍"是 2.1σ,这件事只有算了才知道。
- 在跑数据之前定判定线。 不是仪式感,是把"我会不会挑口径"这个变量提前锁住。
最后一条是我现在最看重的:做实验最难的从来不是设备和方法,是承认自己想要的答案可能不成立。
十一、下一步
预注册已经锁在 82f8e40(pre-registration.md),数据还没跑。跑完之后的处理方式也提前定了:
- H1 站住 → 主报告顶部声明"副本-分钟结论已被预注册重复实验确证",其余结论维持单次观察口径;
- H1 未达线 → 主报告的 -12.5% 整份降级为"单次运行观察,预注册重复未能确证",不是加个脚注;
- 中止 → 只发描述统计,不提取方向性结论。
KEDA 臂(D2b)后置,理由写在协议第 9 节:先重复、再对照。如果 D2a 连 H1 都没站住,KEDA 臂没有对象;如果站住了,KEDA 臂是解释性验证------同为 QPS 信号,预期与 KubeStasis 持平,真持平了恰好证明"扩容响应优势属于信号类型而非实现"。它不进任何判定线,不做胜负宣告。
至于"缩容稳定窗口该设 0 还是 60s"(G3),协议里也定了触发条件:仅当 D2a 的 5 个 KubeStasis 轮里误缩容 ≥ 2 次才立项;0--1 次视为已修复,不再花实验预算。把"什么情况下不做实验"也写下来,是同一套纪律的另一半。
系列其他文章:
- HPA 只会伸缩不会自愈?我设计了一个 K8s 应用自愈 Operator:KubeStasis
- 从零写一个 K8s Operator:kubebuilder 初始化到第一个 Reconcile
- 为什么 HPA 会震荡?手写一个带冷却窗口的伸缩决策引擎
- 给 K8s 加一个"自动值班员":分级故障自愈引擎
- 图解 client-go Informer:手写一个 mini-Informer 才敢说懂
- 给 Operator 加 Admission Webhook 踩的 4 个坑(其中 3 个本可以不踩)
代码与完整能力清单:github.com/weirong-ui/...(v0.1.0 快照) · 压测报告全文 · 预注册协议 · docs/STATUS.md。
下一篇 是这套系列的收官:Operator 上生产前到底要补哪些事------RBAC 最小权限、distroless 与多架构镜像、CI 六道门(含 -race 与 envtest)、cosign 验签与 Kyverno 策略、监控与告警的两处真 bug、以及 Helm 交付时踩的"两套 webhook 打架"。也会把"这份项目明确不做什么"摆出来------一份只列优点的清单,没人能判断可信度。
对 2.1σ 这个判定、对"该不该用 5 个配对"、或者你觉得我这份口径限定还漏了哪一条,欢迎评论区拍砖------尤其是最后一种,那正是我最想要的外部评审。