为什么 CPU/内存指标不足以支撑真实业务伸缩
去年双十一大促期间,我们某核心服务因 CPU 利用率一直徘徊在 30%~40%,HPA 始终没有触发扩容。然而实际 QPS 已经飙到正常值的 5 倍,响应延迟从 50ms 飙升到 2s,最终导致大量超时和 502。事后复盘发现:仅靠 CPU/内存指标做弹性伸缩,在 IO 密集型、异步处理或长连接业务中几乎形同虚设。
这篇文章会手把手带你配置基于业务 QPS 的自定义指标 HPA,结合 Prometheus Adapter 暴露指标,并对伸缩行为策略做压测调优,最后总结生产环境中常见的坑和应对方案。
为什么 CPU/内存指标不足以支撑真实业务伸缩
原理说明
K8s 原生的HorizontalPodAutoscaler默认支持 Pod 的cpu和memory利用率。但这两类指标反映的是系统资源占用,而非业务负载。许多场景下:
- 异步处理服务:大量请求入队列后立即返回 200,CPU 空闲,但队列深度陡增。
- I/O 密集型服务:网络、磁盘 IO 成为瓶颈,CPU 利用率低但吞吐已到极限。
- 长连接 WebSocket:连接数高但 CPU 消耗小,按照 CPU 永远不扩容。
- Java 应用 GC 频繁:偶尔 CPU 飙升但瞬时过去后复位,HPA 来不及响应。
结论:生产环境必须用业务指标(QPS、请求延迟、队列深度等)定义弹性伸缩策略。
Prometheus Adapter 暴露自定义指标的完整配置
前置条件
- 已部署 Prometheus Operator 或独立 Prometheus。
- 业务服务已采集 QPS 指标(例如
http_requests_total,通过rate()计算 QPS)。 - 需要安装
prometheus-adapter(也可用kube-metrics-adapter,但社区推荐前者)。
配置步骤
1. 确认服务指标采集
假设你的业务 Pod 已通过prometheus.io/scrape: "true"暴露指标,Prometheus 中可查到:
rate(http_requests_total{namespace="production", job="my-service"}[1m])2. 安装 prometheus-adapter(Helm)
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts helm upgrade --install prometheus-adapter prometheus-community/prometheus-adapter \ --namespace monitoring \ -f adapter-values.yaml3. 编写核心配置文件adapter-values.yaml
# adapter-values.yaml prometheus: url: http://prometheus.monitoring.svc:9090 # 指向自己的 Prometheus port: 9090 rules: default: false # 不加载默认的 CPU/Mem 指标(可选) custom: - seriesQuery: 'http_requests_total{namespace!="",job!=""}' resources: overrides: namespace: { resource: "namespace" } pod: { resource: "pod" } # 定义如何将 Prometheus 查询映射为 K8s 自定义指标 name: as: "qps_per_pod" metricsQuery: | sum(rate(http_requests_total{<<.LabelMatchers>>}[1m])) by (<<.GroupBy>>)关键注释:
-seriesQuery:用于发现指标,adapter 会据此自动探测可用的标签。
-resources.overrides:将 Prometheus 的namespace、pod标签映射为 K8s 资源对象,HPA 才能正确匹配 Pod。
-name.as:暴露的自定义指标名称,HPA 中引用pods/qps_per_pod。
-metricsQuery:最终发往 Prometheus 的查询模板。<<.LabelMatchers>>会被替换为namespace=xxx,pod=xxx,<<.GroupBy>>自动分组。
4. 验证指标暴露
# 查看所有自定义指标 kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1 | jq . # 查看特定 Pod 的 QPS kubectl get --raw /apis/custom.metrics.k8s.io/v1beta1/namespaces/production/pods/*/qps_per_pod | jq .正确输出应返回类似:
{ "kind": "MetricValueList", "items": [ { "metricName": "qps_per_pod", "value": "12345m", // 12345m = 12.345 qps "describedObject": {"kind": "Pod", "name": "my-service-6f8b7c9d-abc12"} } ] }踩坑:如果返回
"status": "Failure",检查 Prometheus 地址是否可通、seriesQuery能否匹配到指标。
HPA v2 行为策略:scaleUp、scaleDown 与稳定窗口调优
完整 HPA 配置
apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: my-service-hpa namespace: production spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: my-service minReplicas: 3 maxReplicas: 30 metrics: - type: Pods pods: metric: name: qps_per_pod target: type: AverageValue averageValue: 5000m # 目标每 Pod 5 QPS behavior: scaleUp: stabilizationWindowSeconds: 30 # 稳定窗口 30 秒 policies: - type: Pods value: 4 # 每次最多增加 4 个 Pod periodSeconds: 60 # 每 60 秒最多执行一次策略 selectPolicy: Max scaleDown: stabilizationWindowSeconds: 300 # 缩容稳定窗口 5 分钟防止抖动 policies: - type: Pods value: 2 periodSeconds: 120 selectPolicy: Max策略参数说明与调优建议
| 参数 | 作用 | 推荐初始值 | 备注 |
|---|---|---|---|
stabilizationWindowSeconds(扩容) | 在窗口期内取指标最大值,防止毛刺触发扩容 | 30~60s | 如果业务流量波动剧烈,可设 10s;窗口越小对突发越敏感 |
stabilizationWindowSeconds(缩容) | 窗口期内取最小值,防止短暂低谷就缩容 | 300~600s | 缩容一定要保守,缩容后 Pod 冷启动需要时间,避免频繁震荡 |
policies.value(扩容) | 一次扩容最多加 Pod 数量 | 2~5 | 配合periodSeconds控制扩容速率,防止瞬间启动大量 Pod 把集群打满 |
policies.value(缩容) | 一次缩容最多减 Pod 数量 | 1~3 | 避免一次性杀死过多 Pod,导致后续请求积压 |
selectPolicy | Max:取所有策略中最大的变化量;Min:取最小 | 扩容用Max,缩容用Max或Min | 一般用Max保留决策权 |
调优技巧:将稳定窗口视为低通滤波器——窗口越长,对瞬时抖动过滤越强,但响应越慢。对于突发流量敏感的在线服务(如 API 网关),建议缩短扩容窗口、缩窄稳定窗口,同时用policies限制单次扩容上限,避免 Pod 冷启动抢占集群资源。
压测验证:QPS、延迟、Pod 数量与冷启动时间的关系
测试环境
- 服务:基于 Spring Boot 的 RESTful API,平均处理时间 20ms。
- 指标:Prometheus Adapter 暴露
qps_per_pod。 - 压测工具:wrk,持续 5 分钟,初始 QPS 1000,每 30 秒递增 500。
- 冷启动时间:JVM 预热约 12s,加上 K8s Pod Ready 约 20s。
测试步骤与结果
- 未配置 HPA,固定 3 Pod:QPS 到 3000 时延迟从 50ms 飙到 800ms,开始超时。
- 配置默认 HPA(CPU 80%):CPU 仅有 30%,未触发扩容,结果同上。
- 配置自定义 QPS HPA(目标 5 QPS/Pod):QPS 到 1200 时触发扩容,每 60 秒加 4 Pod。压测结束前 Pod 数达到 12,延迟稳定在 80ms 左右。
关键数据记录:
| 压测时间点 | 当前 QPS | 期望 Pod 数(理想) | 实际 Pod 数 | 平均延迟 |
|---|---|---|---|---|
| 0s | 1000 | 3.3 → 4 | 3 | 45ms |
| 60s | 1500 | 5 → 6 | 3 → 7(首次扩容) | 52ms |
| 120s | 2000 | 6.7 → 7 | 7 → 11 | 68ms |
| 180s | 2500 | 8.3 → 9 | 11 → 12 | 75ms |
| 240s | 3000 | 10 → 10 | 12 | 82ms(稳定) |
可以看到:由于扩容滞后和冷启动时间,实际 Pod 数始终落后于理想值,但在 3 个周期后趋于收敛。
重要结论:
-扩容永远滞后于流量突增。因此业务系统必须设计合理的限流熔断,防止在 Pod 启动完成前过载。
- 冷启动时间越长,需要的稳定窗口越大。例如 Python 应用启动只需 3s,窗口可降到 10s;而 Java 应用建议窗口不低于 30s。
生产踩坑:指标延迟、抖动、突刺流量和回滚策略
坑1:Prometheus 指标延迟导致扩缩容偏差
现象:Prometheus 的rate()计算依赖时间窗口(如 1m),而 HPA 每 15 秒拉取一次指标。当流量突降时,rate()窗口内仍包含旧数据,导致 QPS 指标缓慢下降,HPA 延迟缩容。
解决:
- 减小rate()的窗口到 30s,代价是短期抖动更大。
- 组合使用avg_over_time平滑,或者直接使用 Prometheus 的last_over_time(瞬时值)。
- 在metricsQuery中使用rate(...[30s])代替[1m]。
坑2:Pod 状态不健康导致指标异常
某次我们发现 QPS 指标突然为 0,导致 HPA 把 Pod 缩到 minReplicas,但实际服务正常运行。原因:某个 Pod 被调度到异常节点,Prometheus 抓取失败,sum(rate(...)) by (pod)返回 0。
解决:
- 在metricsQuery中加入or on(pod) absent(...)填充默认值,或者使用max避免被 0 拖低。
- 更稳健的做法:暴露存活 Pod 的指标总和,而非直接用rate() by(pod)取均值。例如暴露namespace+service级别的总 QPS,然后让 HPA 用AverageValue除以 Pod 数。但这种方法需要额外计算。
坑3:突刺流量导致 Pod 总数暴涨
双十一流量瞬时 10 倍,HPA 配置了scaleUp.stabilizationWindowSeconds=10,结果 30s 内扩容了 20 个 Pod,集群资源不足,部分 Pod 启动失败。
解决:
-硬性限制:maxReplicas设合理上限(根据集群资源 + 业务容灾冗余估算)。
-两阶段扩容:使用policies限制单次扩容数量,例如 60s 内最多加 5 个 Pod。
-水平 + 垂直组合:突发时先对现有 Pod 增加资源请求(VPA),再缓慢扩容。
坑4:回滚策略
生产环境 HPA 配置错误可能导致服务雪崩。必须准备回滚方案:
- 配置版本化:所有 HPA、Prometheus Adapter 配置都存 Git,并关联 CI/CD 回滚。
- 手动回滚脚本:一键恢复旧的 HPA 配置,并临时关闭 HPA(
spec.paused: true)。 - 监控告警:设置 Pod 变化速率告警,当 5 分钟内 Pod 数变化超过 50% 时触发通知,人工介入。
总结
- 放弃纯 CPU/Memory 的迷信,业务 QPS、延迟分位数、队列深度才是实际负载的准确反映。
- Prometheus Adapter 配置核心在于
seriesQuery+metricsQuery模板的正确性,务必用kubectl get --raw验证。 - HPA v2 的
behavior字段是调优主战场:扩容窗口要短(捕获突发)、缩容窗口要长(防抖动)、单次扩容数量要限制(防雪崩)。 - 压测是唯一检验手段,务必记录 Pod 启动时间 vs 指标收敛时间,据此调整窗口参数。
- 生产环境必须考虑指标延迟、异常 Pod、突刺流量,并且准备一键回滚能力。
最后一句真心话:弹性伸缩不是万能的,它只能缓解“可预期的繁忙”,无法应对“完全的失控”。业务本身必须有限流、降级、熔断的最后一环。
