HPA 扩容了 8 个 Pod 仍然扛不住:一次 Kubernetes 弹性伸缩的链路复盘
上周有个服务在晚高峰被打穿,监控上看到的现象很典型:QPS 从 1200 涨到 2600,HPA 在 90 秒内把副本数从 4 拉到 12,但 P99 延迟不降反升,从 180ms 一路爬到 2.3s,最后触发熔断。事后复盘发现,扩容本身没有错,错在我们把"副本数"当成了"处理能力",而这条链路上有四个环节都不随副本数线性增长。
一、先把现象拆成四段
一次请求的耗时可以粗略拆成:排队、调度到 Pod、应用内处理、依赖调用。扩容只能改善第三段(而且只在不饱和时),对另外三段几乎没有帮助。我们把这四段各自的指标拉出来对齐时间轴,问题立刻清楚了。
- 排队:入口 SLB 的连接队列在 14:32 开始堆积,等待连接数从 3 涨到 47。SLB 的后端权重是轮询,新 Pod 注册进后端组需要 20~40 秒(就绪探针 + 注册延迟),这段时间里流量仍然压在老 Pod 上。
- 应用内处理:单 Pod 的 goroutine 数稳定在 8000 左右,CPU 却只有 45%。这说明不是 CPU 打满,而是被下游阻塞。
- 依赖调用:MySQL 连接池 20 个连接,12 个 Pod 共享一个 200 的数据库最大连接数上限,实际每个 Pod 只拿到 16 个左右,大量请求在
sql.DB的WaitCount上排队。扩容反而加剧了连接争抢。 - 冷启动:新 Pod 启动后要预热本地缓存(约 4 万条配置),预热期间处理能力只有稳态的 30%,这段时间它还在接收全量流量。
二、四个反直觉的结论
1. 扩容速度和流量增长速度要比较,而不是和"当前副本数"比较。
HPA 的默认 --horizontal-pod-autoscaler-sync-period 是 15 秒,配合 stabilizationWindowSeconds 默认 300 秒的缩容窗口。扩容看起来快,但从指标触发到新 Pod 真正接住流量,中间还有调度、拉镜像、启动、就绪探针、注册五道关。我们的实测端到端是 55~75 秒。如果流量在 30 秒内翻倍,HPA 一定追不上,必须提前预扩或者用队列长度这类前置指标。
2. requests 定得太低,HPA 的利用率就算不准。
HPA 计算副本数的公式是 期望副本 = ceil(当前副本 × 当前指标值 / 目标值),而 CPU 利用率的分母是 requests。我们最初把 requests.cpu 设成 200m,实际稳态用 600m,于是利用率天然是 300%,HPA 一直在扩容,直到副本数上限 12 才停。把 requests 调到接近 P50 实际用量(500m)之后,同样的负载下 HPA 只扩到 7 个副本就够了。requests 不是"最小值",它是 HPA 的度量基准。
3. 有状态依赖的服务,副本数和吞吐不是线性关系。
数据库连接、Redis 连接、下游 API 的 QPS 配额,这三样都是全局预算。副本数翻倍,单副本可用预算减半。这类服务更适合"限流 + 排队 + 少量大副本",而不是"大量小副本"。我们最后把副本上限从 12 降到 8,同时在应用层加了基于 golang.org/x/time/rate 的本地限流(每 Pod 180 QPS),P99 反而回到了 220ms。
4. 就绪探针写错,等于让扩容失效。
原来的 readinessProbe 只检查 /healthz 返回 200,这个接口只验证进程活着,不验证缓存预热完成。改成检查 /readyz(缓存条目数 > 90% 且数据库连接可用)之后,新 Pod 在预热完成前不会进后端组,避免了"新 Pod 拖慢整体"的雪崩。
三、改完之后的具体配置
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-api
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-api
minReplicas: 4
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 65
behavior:
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 1
periodSeconds: 60
几个关键点:扩容窗口设为 0(允许立即扩),缩容窗口保留 300 秒(避免抖动);缩容每次只减 1 个 Pod,给缓存和连接池留出回收时间;averageUtilization 用 65% 而不是 80%,留出突发余量。
四、还可以再往前一步
上面这些都是"被动响应"。真正的解法是把扩容决策提前:
- 用队列深度代替 CPU 做扩容指标。CPU 是滞后指标,队列长度是领先指标。接入 KEDA 之后按 RabbitMQ 队列积压数扩容,平均响应时间从 70 秒降到 8 秒。
- 定时预扩。业务有明显的时间规律(比如每天 10:00 和 20:00 两个峰),用 CronHPA 在峰值前 15 分钟把
minReplicas提到 8,比任何实时伸缩都稳。 - 给冷启动做真正的预热。把缓存预热做成 initContainer,或者用 startupProbe 给足 120 秒,别让业务流量参与"热身"。
五、一句话总结
弹性伸缩的瓶颈通常不在 HPA 本身,而在"副本数能线性改善的那部分容量"占整体容量的比例。先量出这个比例,再决定是加副本、加限流、还是加缓存。盲目调大 maxReplicas 往往只是把雪崩的时间点往后推了 30 秒。
- 点赞
- 收藏
- 关注作者
评论(0)