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 参数划算

预热没做好,扩容会把请求打挂——这条比“扩得慢”更隐蔽:

  1. 新实例通过就绪探针开始接流量,但连接池是空的:每个新连接要 TCP + TLS 握手,数据库端还要分配线程(池上限与预热参数见 44.12 连接池)。
  2. 本地缓存命中率 0,本该 5% 打到 DB 的请求变成 100% 打到 DB。
  3. 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

读数:

  1. 预扩 80% + 弹性 20% 比按峰值长备省 20%($13.76 vs $17.28),这是峰均比 10 的业务最常选的那一档。
  2. 纯弹性在纯算力上永远最便宜($4.96),但每次爬坡按模拟值丢 26,355 个请求。预扩 80% 每天多花 $8.80,折算每个失败请求 0.033 分钱就回本——只要一次失败的损失超过这个数(下单、支付类必然超过),预扩就更便宜。
  3. 预热池那一行成本最低且失败为零,但它要求容器能在暂停态保留内存,且预热态怎么计费取决于云厂商——这一行是假设,不是通用结论。

🔑 峰均比决定策略:峰均比 < 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

逐条读:

  1. 过载在 34s 开始,不是 40s。 流量爬到 230 QPS 时 2 台副本的 200 QPS 容量就穿了——过载点是“容量线”,不是“流量峰值时刻”。任何按峰值时刻做预案的做法都晚了 6 秒。
  2. 60s 时的账:2 台就绪、2 台启动中、单实例 CPU 100%、延迟 1000ms。HPA 在 45s 就做了扩容决策,但首台新实例要到 105s 才就绪——决策到可用是 60 秒的空窗,这就是“1–5 分钟”的具体形状。
  3. 基线 131 秒过载、26,355 个失败请求、27.7% 的请求延迟超 SLO。 一次 10 秒的流量阶跃,代价是 2 分 11 秒的不可用。
  4. 预热池把失败砍掉 79%(26,355 → 5,355),过载时间从 131s 降到 31s,只多花 $0.0070/15min。冷启动是唯一变量,收益/成本比最高的一刀就砍在这里。
  5. 预扩到 5 副本:失败 0,但 SLO 违约 19.8%。 5 × 100 = 500 QPS 刚好等于峰值,CPU 打满到 100%,延迟 1000ms——从“打挂”变成“慢死”。预扩必须按目标 CPU 反算(500/0.6/100 = 9 台),按峰值 QPS 反算等于把余量抹平。
  6. 预扩到 9 副本:全部归零,成本 +$0.0363/15min(比基线多 25%)。这是唯一“花钱买确定性”的选项。
  7. 缩容段更贵: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,扩出来的实例全在等连接
缩容窗口比冷启动还短 刚缩完就扩回,每次都在付冷启动的钱

关联

自检

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。