37.6 弹性伸缩
一句话
弹性比的是“容量追得上负载变化的速度”;扩得快、缩得慢是正确设计,而决定体验的是冷启动与预热,不是 HPA 的阈值。
弹性不是“能自动加机器”
弹性是一个时间问题,不是一个开关问题。三条时标必须同时说清,缺一条就会得出错误结论:
| 时标 | 谁在动 | 典型量级 | 谁来兜 |
|---|---|---|---|
| 负载变化时标 | 业务流量 | 秒级(秒杀开抢、热点突刺、故障转移) | 追不上,只能预留 + 限流 |
| 分钟级(大促爬坡、定时任务、早高峰) | 弹性的主战场 | ||
| 小时级(日周期、周周期) | 定时伸缩 / 预测式伸缩 | ||
| 容量响应时标 | 你的系统 | 30s–5min(六段延迟,可逐段压) | 想压到 30s 以内,只能靠预热池 |
| 用户可容忍时标 | 用户 | 交互 < 1s,异步几十秒,批处理几分钟 | 决定你能“慢”到什么程度 |
判据只有一句:负载爬坡时间 vs 容量响应时标。
| 负载从 20% 涨到 100% 用多久 | 响应时标 1–5 分钟时怎么办 |
|---|---|
| > 30 分钟 | 纯弹性绰绰有余,缩容窗口可以放长 |
| 5–30 分钟 | 纯弹性可行,但要保证冷启动 < 爬坡时间 |
| 1–5 分钟 | 弹性 + 预热池,两者缺一不可 |
| < 1 分钟 | 必须预留,弹性只能在事后补救,35.7 限流才是第一道闸 |
弹性是37.1 垂直扩展与水平扩展里“水平”那一支的自动化版本——它自动的只是“加几台”,不是“能不能加”。前提是37.2 无状态化:有状态的服务,扩出来的实例接不了流量,弹性等于零。
伸缩延迟从哪来:1–5 分钟的账
一次扩容从“负载涨了”到“新实例真的能扛活”,要过六段路。每一段的量级都在表里,最后一段最容易被忽略:
| 阶段 | 典型耗时 | 能否压缩 | 压缩手段 |
|---|---|---|---|
| 指标采集 | 15s 抓取 + 1 个窗口(0–15s) | 部分 | 缩短 sync-period,但窗口太短会震荡 |
| 决策 | 15s(HPA 默认同步周期) | 部分 | 同上,不建议低于 10s |
| 调度 + 拉镜像 | 10s–3min | 能 | 镜像预热、P2P 分发、精简镜像 |
| 进程启动 | Go 0.1s;JVM 30s+ | 能 | 原生镜像、AppCDS、PGO |
| 预热 | 1s–60s(连接池、JIT、本地缓存、DNS) | 能 | 就绪探针前置、启动期打暖流 |
| 合计 | 1–5 分钟 | 采集与决策两段共 30s,且压不动 |
拉镜像这一段可以用算术锁死:1.5 GB 镜像 @ 25 MB/s = 60 s,网络带宽是硬下限,换更快的调度器没有用。把它换成 200 MB 精简镜像,同样带宽下只要 8 s——这是唯一能靠工程手段砍掉一半总延迟的地方。
触发指标:选错的代价比阈值设错更大
| 指标 | 适合 | 不适合 | 陷阱 |
|---|---|---|---|
| CPU | CPU-bound 的计算服务 | IO-bound 服务(CPU 永远 30%,扩了不涨) | 平均值掩盖单实例热点 |
| 内存 | 缓存型负载、内存泄漏兜底 | 大多数业务服务 | 只升不降,缩容永远触发不了 |
| QPS / RPS | 无状态请求型服务,最直接 | 长连接、粘性会话 | 必须按就绪实例均摊,否则被慢实例拖偏 |
| 并发连接数 | 长连接网关、WebSocket、gRPC 流 | 短连接服务 | 连接数 ≠ 负载,空闲长连接会虚高 |
| 队列长度 | 异步消费者,最合适 | 同步服务没有队列 | 积压与背压见 43.5 积压与背压 |
| 延迟(P99) | 最贴近 SLO | 单独用时 | 反馈环震荡:扩容→延迟降→缩容→延迟涨 |
决策表,按服务形态直接对号:
| 你的服务 | 主指标 | 辅助指标 | 兜底 |
|---|---|---|---|
| 无状态 HTTP API | CPU 或 QPS | P99 | 入口限流 |
| 长连接网关 | 并发连接数 | CPU | 单实例连接数上限 |
| 异步消费者 | 队列积压量 | 消费速率 | 积压告警 + 扩容上限 |
| 有状态缓存 | 内存 | 命中率 | 预留,不做弹性 |
| IO 密集型(大量下游调用) | 并发数 + 延迟 | CPU | 限流 + 连接池 |
CPU 之所以不能单独当指标:一个每次请求要等 80 ms 下游 RPC 的服务,CPU 利用率天然只有 20%,HPA 永远不动。这类服务的容量瓶颈是并发数,不是 CPU。
扩缩容不对称:快上、慢下
理由不对称,所以策略必须不对称:
- 扩容慢了,请求直接被拒或超时,钱已经丢了,不可逆。
- 缩容快了,只是多花钱和抖动,可逆。
所以:扩容容忍过度扩几十秒,缩容要多等 5–10 分钟。Kubernetes autoscaling/v2 的 behavior 就是在表达这件事:
| 字段 | 语义 | 默认值 |
|---|---|---|
scaleUp.stabilizationWindowSeconds | 取窗口内最大建议值;0 = 不回溯,立刻按当前建议扩 | 0 |
scaleDown.stabilizationWindowSeconds | 取窗口内最小建议值,压住抖动 | 300 |
selectPolicy | Max(扩,取最激进策略)/ Min(缩,取最保守) | Max / Max |
policies | 每 periodSeconds 最多变多少个 / 百分之多少 | 4 pods / 15s 与 100% / 15s |
一份能直接抄的 HPA,每个数字都有出处:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-api
namespace: prod
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-api
minReplicas: 4 # 2 个可用区 × 2 台:任一 AZ 故障仍剩 2 台
maxReplicas: 40 # 成本闸门:40 × 2 vCPU = 80 vCPU,超了就说明该限流了
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60 # ρ=0.6 时排队约 1.5 倍服务时间;0.9 时是 9 倍
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # 扩容不回溯,任何延迟都是纯亏
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 15 # 大规模时按翻倍走:40 台 → 80 台(15s 内)
- type: Pods
value: 4
periodSeconds: 15 # 小规模时至少 4 台/15s,否则爬坡太慢
scaleDown:
stabilizationWindowSeconds: 300 # 5 分钟窗口,覆盖一个完整波动周期
selectPolicy: Min
policies:
- type: Pods
value: 1
periodSeconds: 60 # 40 台 → 4 台要 36 分钟,留足观察时间
四个数字的理由,一句话一个:
- 60%:排队时间
R = S/(1−ρ),ρ 从 0.6 到 0.9,队列从 2.5 倍服务时间涨到 10 倍;60% 是在 30 秒的响应延迟里还能兜住一波突刺的位置(曲线见 34.1 性能指标)。 - 100%/15s 与 4/15s 取 Max:4 台时按 4 台扩,40 台时按翻倍扩——同一份策略覆盖两个数量级,不用为规模改配置。
- 300s 缩容窗口:比冷启动(60s)长 5 倍,保证不会刚缩完又扩回去。
- 1 台/60s:缩容是唯一可以慢慢来的动作,慢没有代价。
minReplicas 直接决定冗余系数,取 4 而不是 2 的算法见 57.3 冗余系数。
⚠️ 这份 YAML 只是配置示例,未在本机验证:
kubectl apply --dry-run=client -f hpa.yaml需要连 API server,本机无集群,实测报The connection to the server localhost:8080 was refused。要验证请在有集群的环境跑kubectl apply --dry-run=server -f hpa.yaml,预期输出<namespace>/order-api configured (server dry run)。
冷启动与预热:决定体验的那一段
| 形态 | 启动耗时 | 说明 |
|---|---|---|
| Go 静态二进制 | 50–100 ms | 纯进程启动,不含依赖初始化 |
| Go + 连接池 + 本地缓存预热 | 1–10 s | 预热才是大头,不是启动 |
| JVM + Spring Boot | 20–40 s | 类加载 + JIT 冷代码 |
| JVM + AppCDS + 分层编译调优 | 8–15 s | 省一半 |
| GraalVM 原生镜像 | 50–500 ms | 代价:构建慢、反射要配置 |
| 拉 1.5 GB 镜像 @ 25 MB/s | 60 s | 网络是硬下限 |
| 拉 200 MB 精简镜像 @ 25 MB/s | 8 s | 换基础镜像比调 JVM 参数划算 |
预热没做好,扩容会把请求打挂——这条比“扩得慢”更隐蔽:
- 新实例通过就绪探针开始接流量,但连接池是空的:每个新连接要 TCP + TLS 握手,数据库端还要分配线程(池上限与预热参数见 44.12 连接池)。
- 本地缓存命中率 0,本该 5% 打到 DB 的请求变成 100% 打到 DB。
- JIT 还在解释执行,同一段代码慢 3–10 倍。
结果:新实例拿到 1/N 的流量却处理不动,排队 → 探针超时 → 被摘掉 → 流量回到老实例 → 老实例更满 → 全线崩。这不是“扩容没用”,是“扩容把容量变成了负数”。可验证的判据:扩容后 30 秒内 P99 上升而不是下降,就是预热问题。
解法按性价比排序:
| 手段 | 效果 | 代价 |
|---|---|---|
| 精简镜像(多阶段构建、distroless) | 拉取 60s → 8s | 一次性改造 |
| 预热池(pause 容器占位,秒级转正) | 冷启动 → 1–2s | 常驻内存成本 |
| 就绪探针前置真实预热(打 100 个暖请求再置 ready) | 消除冷实例拖垮 | 延迟就绪 3–10s |
慢启动(nginx slow_start、Envoy slow_start) | 新实例流量从 10% 渐增 | 需入口配合 |
| 连接池最小空闲连接预热 | 消除首请求握手 | 参数一行 |
预扩 vs 弹性:按 vCPU·小时算的账
设定:峰值 500 QPS、日均 50 QPS(峰均比 10),峰值每天持续 2 小时;单实例 100 QPS,按 60% 目标反算峰值需 9 台、日均需 2 台;每台 2 vCPU、$0.04/vCPU·小时(示例单价)→ $0.08/台·小时。
| 方案 | 常驻 | 峰值额外 | 实例·小时/天 | vCPU·小时/天 | 成本/天 | 爬坡失败请求 |
|---|---|---|---|---|---|---|
| 按峰值长备 9 台 | 9 | 0 | 216 | 432 | $17.28 | 0 |
| 纯弹性 min=2 | 2 | 7×2h | 62 | 124 | $4.96 | 约 26,355/次 |
| 预扩 80% + 弹性 20% | 7 | 2×2h | 172 | 344 | $13.76 | 约 0 |
| 纯弹性 + 4 台预热池(预热态按 30% 计费) | 2 + 4 预热 | 7×2h | 90.8 | 181.6 | $7.26 | 约 0 |
读数:
- 预扩 80% + 弹性 20% 比按峰值长备省 20%($13.76 vs $17.28),这是峰均比 10 的业务最常选的那一档。
- 纯弹性在纯算力上永远最便宜($4.96),但每次爬坡按模拟值丢 26,355 个请求。预扩 80% 每天多花 $8.80,折算每个失败请求 0.033 分钱就回本——只要一次失败的损失超过这个数(下单、支付类必然超过),预扩就更便宜。
- 预热池那一行成本最低且失败为零,但它要求容器能在暂停态保留内存,且预热态怎么计费取决于云厂商——这一行是假设,不是通用结论。
🔑 峰均比决定策略:峰均比 < 3 用纯弹性,3–10 用预扩打底 + 弹性补峰,> 10 直接用定时伸缩而不是反应式弹性。
过度弹性的两种病
病一:flapping(副本数来回震荡)。 1 分钟指标窗口 + 60% 阈值,负载在 58%–62% 之间抖动,10 台副本时:
62% → 期望 ceil(10 × 62/60) = 11 台 → 扩
11 台时单台 CPU = 62% × 10/11 = 56.4% → 期望 ceil(11 × 56.4/60) = 10 台 → 缩
10 台时又是 62% → 11 台 …
抖动周期 2 分钟时,每小时 30 次扩容 + 30 次缩容。更糟的是冷启动 60s 不短于半个抖动周期,扩出来的实例一次也没就绪过——付了调度和拉镜像的钱,容量零提升。
病二:thrashing(缩容制造扩容信号)。 从 10 台缩到 9 台,原本 10 台分摊的流量挤到 9 台,单台 CPU 从 58% 变成 58% × 10/9 = 64.4%,越过 60% 阈值 → 下一周期又扩回 10 台。缩容这个动作自己生产了扩容的理由,所以缩容必须靠稳定窗口压住,而不是靠调高阈值。
两种病的共同解药是同一个:让阈值远离工作点。工作点在 58%–62% 抖动,就把目标设到 70%,让抖动落在阈值带之内,副本数根本不会动。
把控制回路跑一遍
模型:阶跃流量 50 → 500 QPS(10 秒内),目标 CPU 60%,采集与决策各 15s,冷启动 60s,单实例 100 QPS,缩容走 300s 稳定窗口 + 每 60s 减 1 台。四个场景用同一套代码、同一个流量曲线。
package main
import (
"fmt"
"math"
)
const (
targetCPU = 0.60 // HPA 目标:单实例 CPU 60%
decideStep = 15.0 // 采集周期 = 决策周期 = 15s
perInstanceQPS = 100.0 // 单实例在 100% CPU 下的处理能力
baseLatency = 20.0 // 单请求服务时间(ms),用于排队近似
sloLatency = 300.0 // SLO:< 300ms
maxReplicas = 40
totalSeconds = 900.0
)
// 流量:30s 起,10s 内 50 → 500 QPS;300s 时断崖回落到 50
func demandAt(t float64) float64 {
switch {
case t < 30:
return 50
case t < 40:
return 50 + 450*(t-30)/10
case t < 300:
return 500
default:
return 50
}
}
type scenario struct {
name string
minR int // minReplicas
coldStart float64 // 拉镜像 + 进程启动 + 预热,合计秒数
preScale int // 起始就位的副本数
}
type replica struct{ readyAt float64 }
type result struct {
name string
overloadSec float64
fail float64 // 被拒/超时的请求数
served float64
viol float64 // 延迟超过 SLO 的请求数
replicaSec float64
peak int
}
func simulate(s scenario, verbose bool) result {
pods := make([]replica, 0, maxReplicas*2)
for i := 0; i < s.preScale; i++ {
pods = append(pods, replica{readyAt: 0})
}
readyCount := func(t float64) int {
n := 0
for _, p := range pods {
if p.readyAt <= t {
n++
}
}
return n
}
res := result{name: s.name}
lowSince, lastDown := -1.0, -1e9
prevState := "正常"
if verbose {
fmt.Printf("\n=== %s ===\n", s.name)
fmt.Printf("%5s %7s %5s %7s %10s %8s %s\n",
"t(s)", "需求QPS", "就绪", "启动中", "单实例CPU", "延迟ms", "状态")
}
for t := 0.0; t < totalSeconds; t++ {
// ---- 每 15s:读一次指标 → 算期望副本 → 执行扩缩 ----
if math.Mod(t, decideStep) == 0 {
r := readyCount(t)
cpu := 0.0
if r > 0 {
cpu = math.Min(1.0, demandAt(t)/(float64(r)*perInstanceQPS))
}
desired := s.minR
if r > 0 {
desired = int(math.Ceil(float64(r) * cpu / targetCPU))
}
if desired < s.minR {
desired = s.minR
}
if desired > maxReplicas {
desired = maxReplicas
}
total := len(pods)
switch {
case desired > total:
// 扩容:每轮最多 +max(4, 100%),与 k8s 的 Max 策略一致
allow := int(math.Max(4, float64(total)))
add := desired - total
if add > allow {
add = allow
}
for i := 0; i < add; i++ {
pods = append(pods, replica{readyAt: t + s.coldStart})
}
lowSince = -1
case desired < total:
// 缩容:先过 300s 稳定窗口,之后每 60s 只拿掉 1 台
if lowSince < 0 {
lowSince = t
}
if t-lowSince >= 300 && t-lastDown >= 60 {
for i := len(pods) - 1; i >= 0; i-- {
if pods[i].readyAt <= t {
pods = append(pods[:i], pods[i+1:]...)
lastDown = t
break
}
}
}
default:
lowSince = -1
}
}
// ---- 结算这一秒 ----
r := readyCount(t)
capacity := float64(r) * perInstanceQPS
d := demandAt(t)
served := math.Min(d, capacity)
over := d - served
lat := 0.0
if capacity > 0 {
rho := math.Min(served/capacity, 0.98)
lat = baseLatency / (1 - rho) // M/M/1 近似:ρ→1 时排队爆炸
}
if over > 0.5 {
res.overloadSec++
}
res.fail += over
res.served += served
if lat > sloLatency {
res.viol += served
}
res.replicaSec += float64(len(pods))
if len(pods) > res.peak {
res.peak = len(pods)
}
state := "正常"
switch {
case over > 0.5:
state = "过载"
case lat > sloLatency:
state = "排队超SLO"
}
if verbose && (math.Mod(t, 60) == 0 || state != prevState) {
cpuNow := 0.0
if r > 0 {
cpuNow = math.Min(1.0, d/(float64(r)*perInstanceQPS))
}
fmt.Printf("%5.0f %7.0f %5d %7d %9.0f%% %8.0f %s\n",
t, d, r, len(pods)-r, cpuNow*100, lat, state)
}
prevState = state
}
return res
}
func main() {
fmt.Println("HPA 控制回路:阶跃流量 50→500 QPS(10s 内),目标 CPU 60%,每实例 100 QPS")
fmt.Println("冷启动 = 拉镜像+进程启动+预热;缩容走 300s 稳定窗口 + 每 60s 减 1 台")
var rs []result
for i, s := range []scenario{
{"基线:min=2,冷启动 60s", 2, 60, 2},
{"改法A:预热池,冷启动 5s", 2, 5, 2},
{"改法B:预扩到 5 副本(按峰值 QPS 反算)", 5, 60, 5},
{"补充:预扩到 9 副本(按 60% CPU 反算)", 9, 60, 9},
} {
rs = append(rs, simulate(s, i < 2))
}
fmt.Println("\n================ 横向对比(900s 窗口)================")
fmt.Printf("%-40s %8s %9s %8s %9s %7s %8s\n",
"场景", "超载秒", "失败请求", "失败率", "SLO违约率", "峰值", "副本·秒")
for _, r := range rs {
total := r.fail + r.served
failPct, violPct := 0.0, 0.0
if total > 0 {
failPct = r.fail / total * 100
}
if r.served > 0 {
violPct = r.viol / r.served * 100
}
fmt.Printf("%-40s %8.0f %9.0f %7.1f%% %8.1f%% %7d %8.0f\n",
r.name, r.overloadSec, r.fail, failPct, violPct, r.peak, r.replicaSec)
}
base := rs[0]
for _, c := range []struct {
label string
r result
}{{"改法A 预热池(5s)", rs[1]}, {"改法B 预扩5副本", rs[2]}, {"预扩9副本", rs[3]}} {
cost := (c.r.replicaSec - base.replicaSec) / 3600 * 2 * 0.04 // 2 vCPU/副本、$0.04/vCPU·h
fmt.Printf("%s:失败 %.0f→%.0f(-%d%%),SLO违约 %.1f%%→%.1f%%,容量成本 %+.4f 美元/15min\n",
c.label, base.fail, c.r.fail,
int((base.fail-c.r.fail)/base.fail*100),
base.viol/base.served*100, c.r.viol/c.r.served*100, cost)
}
}
本机实跑输出(GO111MODULE=off go run main.go):
HPA 控制回路:阶跃流量 50→500 QPS(10s 内),目标 CPU 60%,每实例 100 QPS
冷启动 = 拉镜像+进程启动+预热;缩容走 300s 稳定窗口 + 每 60s 减 1 台
=== 基线:min=2,冷启动 60s ===
t(s) 需求QPS 就绪 启动中 单实例CPU 延迟ms 状态
0 50 2 0 25% 27 正常
34 230 2 0 100% 1000 过载
60 500 2 2 100% 1000 过载
120 500 4 3 100% 1000 过载
165 500 7 2 71% 70 正常
180 500 7 2 71% 70 正常
240 500 9 0 56% 45 正常
300 50 9 0 6% 21 正常
360 50 9 0 6% 21 正常
420 50 9 0 6% 21 正常
480 50 9 0 6% 21 正常
540 50 9 0 6% 21 正常
600 50 8 0 6% 21 正常
660 50 7 0 7% 22 正常
720 50 6 0 8% 22 正常
780 50 5 0 10% 22 正常
840 50 4 0 12% 23 正常
=== 改法A:预热池,冷启动 5s ===
t(s) 需求QPS 就绪 启动中 单实例CPU 延迟ms 状态
0 50 2 0 25% 27 正常
34 230 2 0 100% 1000 过载
60 500 4 3 100% 1000 过载
65 500 7 0 71% 70 正常
120 500 9 0 56% 45 正常
180 500 9 0 56% 45 正常
240 500 9 0 56% 45 正常
300 50 9 0 6% 21 正常
360 50 9 0 6% 21 正常
420 50 9 0 6% 21 正常
480 50 9 0 6% 21 正常
540 50 9 0 6% 21 正常
600 50 8 0 6% 21 正常
660 50 7 0 7% 22 正常
720 50 6 0 8% 22 正常
780 50 5 0 10% 22 正常
840 50 4 0 12% 23 正常
================ 横向对比(900s 窗口)================
场景 超载秒 失败请求 失败率 SLO违约率 峰值 副本·秒
基线:min=2,冷启动 60s 131 26355 16.1% 27.7% 9 6465
改法A:预热池,冷启动 5s 31 5355 3.3% 5.8% 9 6780
改法B:预扩到 5 副本(按峰值 QPS 反算) 0 0 0.0% 19.8% 9 7080
补充:预扩到 9 副本(按 60% CPU 反算) 0 0 0.0% 0.0% 9 8100
改法A 预热池(5s):失败 26355→5355(-79%),SLO违约 27.7%→5.8%,容量成本 +0.0070 美元/15min
改法B 预扩5副本:失败 26355→0(-100%),SLO违约 27.7%→19.8%,容量成本 +0.0137 美元/15min
预扩9副本:失败 26355→0(-100%),SLO违约 27.7%→0.0%,容量成本 +0.0363 美元/15min
逐条读:
- 过载在 34s 开始,不是 40s。 流量爬到 230 QPS 时 2 台副本的 200 QPS 容量就穿了——过载点是“容量线”,不是“流量峰值时刻”。任何按峰值时刻做预案的做法都晚了 6 秒。
- 60s 时的账:2 台就绪、2 台启动中、单实例 CPU 100%、延迟 1000ms。HPA 在 45s 就做了扩容决策,但首台新实例要到 105s 才就绪——决策到可用是 60 秒的空窗,这就是“1–5 分钟”的具体形状。
- 基线 131 秒过载、26,355 个失败请求、27.7% 的请求延迟超 SLO。 一次 10 秒的流量阶跃,代价是 2 分 11 秒的不可用。
- 预热池把失败砍掉 79%(26,355 → 5,355),过载时间从 131s 降到 31s,只多花 $0.0070/15min。冷启动是唯一变量,收益/成本比最高的一刀就砍在这里。
- 预扩到 5 副本:失败 0,但 SLO 违约 19.8%。 5 × 100 = 500 QPS 刚好等于峰值,CPU 打满到 100%,延迟 1000ms——从“打挂”变成“慢死”。预扩必须按目标 CPU 反算(500/0.6/100 = 9 台),按峰值 QPS 反算等于把余量抹平。
- 预扩到 9 副本:全部归零,成本 +$0.0363/15min(比基线多 25%)。这是唯一“花钱买确定性”的选项。
- 缩容段更贵:300s 流量已经回到 50 QPS,副本数一动不动到 600s(300s 稳定窗口),之后每 60s 减 1 台,840s 才到 4 台。540 秒里一直按 9 台的规格付费,其中 300 秒是纯粹在等窗口。
反模式
| 反模式 | 后果 |
|---|---|
| 用平均 CPU 做指标,而服务是有状态的 | 热点实例被平均值掩盖,扩容后热点还在,CPU 反而更低 |
不设 minReplicas(或设成 0) | 缩到 0 后第一个请求要等一次完整冷启动,首屏 30s+ |
| 缩到 0 而服务带本地状态 | 状态随 Pod 消失,请求打到空实例,全量缓存击穿 |
| 只扩不预热 | 新实例接流量即雪崩,扩容后 P99 反而上升(见冷启动段) |
| 指标窗口 1s + 无稳定窗口 | 副本数按秒震荡,一小时几十次扩缩,容量零提升 |
| 用弹性代替容量规划 | 峰值超出 maxReplicas 就限流;弹性只处理“计划内的波动” |
| 扩容后没同步改连接池上限 | 应用扩了 10 倍,DB 侧连接数还是 20,扩出来的实例全在等连接 |
| 缩容窗口比冷启动还短 | 刚缩完就扩回,每次都在付冷启动的钱 |
关联
- 前置:37.1 垂直扩展与水平扩展、37.2 无状态化
- 并列:34.1 性能指标 (延迟 / 吞吐 / P99)、57.3 冗余系数
- 下游:35.7 限流 (漏桶 / 令牌桶 / 滑动窗口 / 自适应)、43.5 积压与背压、44.12 连接池
自检
Q1 一个服务的负载在 3 分钟内从 30% 涨到 100%,冷启动 60s,HPA 目标 60%,minReplicas=2。扩容来得及吗?差在哪一段?
▸ 答案:来不及,差在“决策→可用”这一段。3 分钟的爬坡里,前 30s 花在采集与决策,之后每批新实例还要 60s 才能接流量;等容量追上时爬坡已经结束,中间的过载窗口照样丢请求。正确做法不是调低阈值,而是把 60s 冷启动压到 10s 以内(预热池 + 精简镜像),或者直接预扩到能覆盖前 3 分钟的副本数。
Q2 有人把 HPA 的 scaleDown.stabilizationWindowSeconds 从 300 改成 30,理由是“缩得快省钱”。会出什么问题?
▸ 答案:缩容制造扩容信号,进入 thrashing。10 台缩到 9 台,单台 CPU 从 58% 涨到 64.4%(58×10/9),越过 60% 阈值,下一周期又扩回 10 台;30s 窗口短于冷启动 60s,扩出来的实例还没就绪就又被缩掉。省下的是缩容那点钱,赔上的是反复调度、拉镜像和抖动。
Q3 服务 CPU 利用率长期 15%,QPS 涨了 3 倍 HPA 也不动,但 P99 已经从 200ms 涨到 900ms。指标该换成什么?
▸ 答案:这是 IO 密集型服务,CPU 不是它的容量约束——请求大部分时间在等下游 RPC,CPU 自然低。换成并发数(或 in-flight 请求数)做主指标,延迟做辅助指标;同时检查连接池上限是否已经成为真正的瓶颈(见 44.12 连接池)。
Q4 大促前把 minReplicas 从 4 调到 9,和“把 HPA 目标 CPU 从 60% 调到 30%”相比,哪个对?
▸ 答案:调 minReplicas。调低目标 CPU 会让 HPA 在任何流量下都维持更多副本,包括凌晨低谷,成本是全天候的;调 minReplicas 只在大促窗口内抬高底线,活动结束调回去即可。而且调低目标值会让工作点靠近阈值带,反而更容易 flapping。