
`kubectl get hpa` 已经显示 `DESIRED=4`，业务监控却只有一个实例能接收流量。是 HPA 失效了吗？

不一定。在一次慢启动实验中，HPA 很快写入 4，新增的 3 个 Pod 也进入 Running，但约一分钟后才全部 Ready。

> **HPA 已经要求 4 个副本，为什么仍可能只有 1 个可用？**

本文沿着控制链路逐层回答。它是系列第四篇，默认已经理解[指标如何变成期望副本](/posts/hpa-cpu-autoscaling/)。

## 用一条链路代替猜测

```text
指标有值？
   ↓
原始副本建议是多少？
   ↓
是否受稳定窗口、限速、min/max 或多指标约束？
   ↓
Deployment 期望副本是否更新？
   ↓
Pod 能否调度、启动并通过 readiness？
```

排障时依次记录：

```sh
kubectl --context kind-hpa-lab -n hpa-demo get hpa
kubectl --context kind-hpa-lab -n hpa-demo describe hpa worker
kubectl --context kind-hpa-lab -n hpa-demo get deployment,pod
kubectl --context kind-hpa-lab -n hpa-demo get events --sort-by=.lastTimestamp
```

自定义指标还要分别查询 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`：

```text
metric:          queue_depth
averageValue:    8750m
target:          10
desiredReplicas: 4
Ready:           4
```

## 第二步：看建议是否被稳定窗口保留

实验将缩容稳定窗口改为 60 秒：先用 Q=35 保持 4 个副本，把队列降到 0 约 25 秒，再恢复到 35。整个低值脉冲期间，期望副本始终是 4；最后持续置零才缩回 1。

```text
最近 60 秒的原始建议：4, 4, 1, 1, 4
缩容采用的约束：max(...) = 4
```

稳定窗口保存的是**副本建议**，不是指标平均值，也不是每次指标变化后重启的定时器。实现见 Kubernetes v1.35.0 的 [`stabilizeRecommendationWithBehaviors`](https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/controller/podautoscaler/horizontal.go#L1151)。

## 第三步：看变化速度是否被限制

下面的策略关闭稳定等待，只观察每 30 秒最多改变一个 Pod：

```yaml
# 只验证速度限制，不引入稳定窗口。
behavior:
  scaleUp:
    stabilizationWindowSeconds: 0
    policies:
      - type: Pods # 使用绝对 Pod 数限制变化
        value: 1
        periodSeconds: 30
  scaleDown:
    stabilizationWindowSeconds: 0
    policies:
      - type: Pods
        value: 1
        periodSeconds: 30
```

与 `Percent: 100` 对照，并在方向切换前等待上一轮历史离开窗口：

| 策略 | 扩容序列 | 缩容序列 |
| --- | --- | --- |
| `Pods: 1 / 30s` | `1 → 2 → 3 → 4` | `4 → 3 → 2 → 1` |
| `Percent: 100 / 30s` | `1 → 2 → 4` | `4 → 1` |

`periodSeconds` 是统计近期副本变化的窗口，不是控制器严格每 30 秒执行一次。方向切换时，窗口内的历史变化仍会进入基数计算；第一次未隔离历史的探索结果因此没有用作上表结论。原始探索记录仍保存在 evidence 目录。

## 第四步：多指标不是投票

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 |

对应的关键错误日志为：

```text
exporter 停止: Prometheus result=[]
               custom metrics API: NotFound: could not find metric queue_depth
Prometheus 停止: ServiceUnavailable: no endpoints available for service "prometheus"
                 InternalError: unable to fetch metrics
Adapter 停止: Prometheus value=35
              custom metrics API: ServiceUnavailable
```

恢复组件并明确让应用返回 Q=0 后，worker 才缩回 1。这三个状态不能互换：

```text
旧样本 ≠ 没有样本 ≠ 指标 API 不可用 ≠ 应用明确报告 0
```

Prometheus 有值而指标 API 没值时，检查 Adapter 的 `seriesQuery`、资源标签和映射；指标 API 有值但副本不变时，再检查目标、condition、稳定窗口和限速。

## 第六步：desired=4 后，Pod 卡在哪里

### 调度失败

独立实验把每个 Pod 的 CPU request 写成 `1000`，即 1000 核，而不是 `1000m`。HPA 正确写入 4，但 4 个 Pod 全部 Pending：

```text
0/1 nodes are available: 1 Insufficient cpu
```

这是调度容量不足，不是 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 是不同的调谐过程。

## 复现实验

配置、脚本和证据固定在 [k8sdev `c48f140`](https://github.com/jimyag/k8sdev/tree/c48f1405c9886b048c95c3b46a8b3e766e34f6f5/hpa/advanced)。按[第一篇](/posts/hpa-cpu-autoscaling/)创建集群后执行：

```sh
cd ../advanced
./setup.sh
HPA_OUTPUT=$(mktemp -d "${TMPDIR:-/tmp}/hpa-debug.XXXXXX")
for case in steps policies multi failures scheduling; do
  python3 run.py "$case" --output "$HPA_OUTPUT" || break
done
```

脚本只使用 Python 标准库。每个场景只有在全部条件满足后才输出 PASS；失败会非零退出。JSONL 约每 5 秒记录动作、HPA 状态、期望副本、Ready 副本和相关 API 查询。不要在真实队列消费实验运行时暂停指标组件。

## 排障速查

| 现象 | 先检查 |
| --- | --- |
| 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 显示 `DESIRED=4`，只证明副本建议已经通过指标计算与行为策略，并成功写入 Deployment。实际只有 1 个可用，可能是其他 Pod 尚未被调度、容器还未启动，或 readiness 尚未通过。

所以，**不要用一个 HPA condition 判断整条扩缩容链路。先定位指标、建议、策略、期望副本、调度、启动和就绪中的哪一层没有前进。** 本次慢启动实验的答案是：HPA 没有失效，新增 Pod 已经 Running，只是约一分钟后才 Ready。

