Kubernetes HPA 调优与排障:从指标计算到副本就绪
最后更新:
HPA 的 status.desiredReplicas 已经是 4,业务监控却只有一个实例能接收流量。在一次慢启动实验中,新增的 3 个 Pod 已经 Running,但约一分钟后才全部 Ready。副本建议、Pod 数量和可服务的实例数分别处于不同阶段。
本文是系列第四篇,沿着指标计算、行为策略和工作负载启动定位这些差异,最后用一个“已经全部 Ready,吞吐仍未提升”的实验检查扩容后的瓶颈。指标计算基础见指标如何变成期望副本。
HPA 写入期望副本后,谁继续执行
HPA 控制器读取指标、计算建议并应用行为策略,最终通过目标工作负载的 Scale 子资源修改副本数。Deployment 控制器协调 ReplicaSet,ReplicaSet 控制器创建 Pod,scheduler 选择节点,kubelet 启动容器并报告就绪状态。每个控制循环都可能等待自己的条件,HPA 不会替代后续组件执行这些动作。机制见 HPA 工作原理。
默认 kubectl get hpa 的表格没有 DESIRED 列。需要确认建议与执行结果时,分别读取 HPA 的 status.desiredReplicas、Deployment 的 spec.replicas 和 status.readyReplicas:
| |
| |
排障时依次记录:
| |
自定义指标还要分别查询 Prometheus 与指标 API,避免把「没采到」「没映射」「HPA 没采用」混成一个问题。
第一步:确认原始建议不是碰巧撞到上限
队列每副本目标为 10,依次把总量改成 15、25、35,再降到 25、15、5:
| 队列总量 | ceil(Q/10) | Ready 副本 |
|---|---|---|
| 15 | 2 | 2 |
| 25 | 3 | 3 |
| 35 | 4 | 4 |
| 25 | 3 | 3 |
| 15 | 2 | 2 |
| 5 | 1 | 1 |
完整走过 1 → 2 → 3 → 4 → 3 → 2 → 1,说明中间副本建议也符合公式;只验证 1 → 4 → 1,可能只是每次都撞到 maxReplicas。
重跑中 Q=35、4 个副本时,HPA 的 current.averageValue 是 8750m,即 35 / 4 = 8.75:
| |
第二步:看建议是否被稳定窗口保留
实验将缩容稳定窗口改为 60 秒:先用 Q=35 保持 4 个副本,把队列降到 0 约 25 秒,再恢复到 35。整个低值脉冲期间,期望副本始终是 4;最后持续置零才缩回 1。
| |
缩容稳定窗口保存近期的副本建议,采用窗口内较高的建议来抑制短暂下降。它不会对指标求平均,也不会在每次指标变化后重新开始一个固定倒计时。实现见 Kubernetes v1.35.0 的 stabilizeRecommendationWithBehaviors。
第三步:看变化速度是否被限制
下面的策略关闭稳定等待,只观察每 30 秒最多改变一个 Pod:
| |
与 Percent: 100 对照,并在方向切换前等待上一轮历史离开窗口:
| 策略 | 扩容序列 | 缩容序列 |
|---|---|---|
Pods: 1 / 30s | 1 → 2 → 3 → 4 | 4 → 3 → 2 → 1 |
Percent: 100 / 30s | 1 → 2 → 4 | 4 → 1 |
periodSeconds 是统计近期副本变化的窗口,不是控制器严格每 30 秒执行一次。方向切换时,窗口内的历史变化仍会进入基数计算;第一次未隔离历史的探索结果因此没有用作上表结论。
第四步:多指标不是投票
CPU 与队列可以放进同一个 HPA。控制器分别计算副本建议,再选择较大的值,不是取平均,也不要求两项都超过阈值。
| CPU / 队列状态 | 本次结果 |
|---|---|
| CPU 约 1%,Q=35 | 队列建议占优,保持 4 |
| CPU 约 1%,队列指标失败 | 拒绝按低 CPU 缩容,保持 4 |
| CPU 高负载,队列指标失败 | 有效 CPU 建议触发 1 → 4 |
| CPU 高负载,Q=0 | CPU 独立触发 1 → 4 |
部分指标失败时,HPA 可以使用仍然有效的扩容建议,但不会仅凭其他指标的低值冒险缩容。因此 ScalingActive=True 也不等于每一条指标链路都健康;要同时查看 status.currentMetrics 和事件。
第五步:指标在哪一层消失
实验在 Q=35、4 个副本时分别暂停 exporter、Prometheus 和 Adapter:
| 暂停组件 | Prometheus 查询 | custom metrics API | worker |
|---|---|---|---|
| queue exporter | 先读到旧值,后为空向量 | 先返回 35,后 NotFound | 4 |
| Prometheus | 无可用 endpoint | InternalError | 4 |
| Adapter | 仍返回 35 | ServiceUnavailable | 4 |
对应的关键错误日志为:
| |
恢复组件并明确让应用返回 Q=0 后,worker 才缩回 1。排障时需要区分四种输入状态:
| |
Prometheus 有值而指标 API 没值时,检查 Adapter 的 seriesQuery、资源标签和映射;指标 API 有值但副本不变时,再检查目标、condition、稳定窗口和限速。
少几个指标时,HPA 为什么还要重新算一次
“部分 Pod 没有采样”和“整个指标查询失败”是不同情况。对于 CPU 利用率,v1.35.1 的 GetResourceReplicas会先分组处理未就绪、缺失指标和应忽略的 Pod,用可用数据计算初步比例;需要时再补入保守估计,重新计算。
初步结果要求缩容时,缺失指标的 Pod 按 request 的 100% 估算;若目标利用率高于 100%,则使用更高的目标值。初步结果要求扩容时,缺失指标的 Pod 按零用量估算,相关未就绪 Pod 也可能按零补入。两个方向采用不同估计,都是为了减弱样本不全导致的调整:避免因没看到部分负载就贸然缩容,也避免只看到少数高负载 Pod 就过度扩容。
重新计算后,如果落入容忍范围或反转了扩缩方向,函数保留当前副本数。因而把界面上的平均利用率直接代入最简单的副本公式,不一定得到最终建议。这一段解释的是源码计算规则;本文瓶颈实验没有故意删除指标样本来验证这些分支。
稳定窗口和速率限制也记录不同的历史。HPA 控制器用 recommendations 保存过去的副本建议,稳定窗口从中选择保守值;scaleUpEvents、scaleDownEvents 记录已发生的副本调整,用于计算某个策略周期内还能改变多少副本。因此 periodSeconds 不是“每隔这么久才执行一次”,稳定窗口也不是收到新建议后简单等待一个定时器到期。
第六步:desired=4 后,Pod 卡在哪里
调度失败
独立实验把每个 Pod 的 CPU request 写成 1000,即 1000 核,而不是 1000m。HPA 正确写入 4,但 4 个 Pod 全部 Pending:
| |
这是调度容量不足,不是 HPA 没有计算。实验没有启用节点自动扩容。
启动慢
另一个实验给 web 增加 60 秒 readiness 延迟:
| 时间(UTC) | desired | Running | Ready |
|---|---|---|---|
| 14:50:49 | 4 | 4 | 1 |
| 14:51:56 | 4 | 4 | 4 |
这约 67 秒里,指标从 35 变为按 4 个副本展示的 8750m,但 Ready 一直为 1。差值包含容器启动、探针和采样间隔,不能全部算成 HPA 延迟。HPA、Deployment、调度器、kubelet 和 readiness 是不同的调谐过程。
4 个副本都 Ready,为什么吞吐仍没有提升
前面的检查解决的是“期望副本为什么没有变成可用副本”。但即使这一整条链路正常,业务性能也不一定随着副本数增加。
这次通过 SSH 在 zj 的 kind v0.33.0、Kubernetes v1.35.1 集群验证了一个下游瓶颈:前端每个请求先消耗约 10 毫秒 CPU,再同步调用单线程后端;后端每个请求固定等待 0.2 秒。客户端保持 8 个并发请求。
前端设置 requests.cpu: 5m,HPA 的 CPU utilization 目标为 50%,最多 4 个副本。很小的 request 用来让实验容易触发扩容,不是生产资源配置建议。Metrics Server 使用 v0.8.0,实验集群对测试 kubelet 证书显式配置了 --kubelet-insecure-tls。
先在 1 个副本时测量,再启动 HPA 并持续施压。脚本等到 Deployment 确实有 4 个 Ready 副本后,单独再测一轮:
| 状态 | 成功吞吐 | 成功请求 P50 | 成功请求 P95 | 错误数 |
|---|---|---|---|---|
| 1 个前端副本 | 4.91 请求/秒 | 1.403 秒 | 1.404 秒 | 1 |
| 4 个前端副本 | 4.97 请求/秒 | 1.403 秒 | 1.406 秒 | 2 |
每轮发起新请求的时间窗口为 20 秒,之后等待已发出的请求完成,所以总耗时约 21 秒。错误请求单独统计,不混进成功请求的延迟分位数;不能把这个小样本对照写成精确的生产容量结论。
结果与故障模型一致:单线程下游每 0.2 秒完成一个请求,服务能力约为每秒 5 个。增加前端副本没有增加下游处理能力,更多前端仍在等待同一个后端。HPA 的计算和副本创建都成功了,性能瓶颈却不在它扩容的那一层。
实验代码与使用说明见 k8sdev/deep-dive-hpa-bottleneck。
因此,副本已经 Ready 之后,还要继续观察请求在前端计算、连接池、下游处理和队列等待上分别花了多少时间。若瓶颈在下游,仅放宽前端 maxReplicas 可能增加下游压力;若新增副本还没接到流量,则应回到 Service、连接复用和负载分布检查,不能把两类问题混在一起。
稳定窗口、速率限制与多指标实验的代码和使用说明见 k8sdev/hpa/advanced。
排障速查
| 现象 | 先检查 |
|---|---|
CPU 是 <unknown> | metrics.k8s.io、Metrics Server、CPU request、Pod readiness |
| Prometheus 没有序列 | /metrics、target up、服务发现与标签 |
| API 没发现自定义指标 | APIService、Adapter 日志、seriesQuery 与资源映射 |
| API 有值,副本不变 | 当前值/目标、condition、min/max、稳定窗口、限速 |
| desired 增加,Pod Pending | Pod Events、资源 request、节点容量 |
| Pod Running,Ready 未增加 | readiness、启动日志、依赖服务 |
用期望数、就绪数和吞吐区分故障
若 HPA 建议与 Deployment 期望数不同,检查 Scale 写入与控制器事件;期望数已经增加而 Ready 数没有增加,检查调度、启动与探针。Ready 数已经达到目标而吞吐未提升,则继续看新增副本是否收到请求,以及请求耗时是否集中在共享下游。前面的慢启动和单线程下游实验分别停在后两种情况,处理方式也随之不同。