
一个 HPA 只看到了 `164%` 的 CPU 利用率，为什么最后会得到 4 个 Pod，而不是 2 个或 3 个？停止负载后，CPU 已经降到 `1%`，它又为什么没有马上缩容？

本文从这两个现象出发，用一次 `1 → 4 → 1` 的 kind 实验回答一个问题：

> **一个指标，经过哪些步骤才会变成真正可用的 Pod？**

这是 HPA 实验系列的第一篇：

1. 本文：CPU 指标与基本控制循环
2. [应用的 RPS 和处理带宽如何驱动扩缩容？](/posts/hpa-application-throughput-metrics/)
3. [队列已经空了，正在处理的任务能安全结束吗？](/posts/hpa-queue-worker-autoscaling/)
4. [HPA 已经要求 4 个副本，为什么只有 1 个可用？](/posts/hpa-tuning-troubleshooting/)

## 先看答案：HPA 只负责修改期望副本数

```text
业务负载 → CPU 指标 → HPA 计算建议 → Deployment /scale
                                      ↓
实际容量 ← Pod Ready ← kubelet 启动 ← 调度器 ← ReplicaSet
```

HPA 周期性读取指标，计算期望副本数，再更新工作负载的 `/scale` 子资源。随后由 Deployment、ReplicaSet、调度器和 kubelet 完成创建与启动。HPA 写入 4，不代表已经有 4 个 Pod 可以接收流量。

这条链路至少要观察两个值：

| 值 | 回答的问题 |
| --- | --- |
| `spec.replicas` / HPA `desiredReplicas` | 控制器想要几个 Pod？ |
| Deployment `readyReplicas` | 现在有几个 Pod 真正可用？ |

## 实验环境

实验在 macOS / Apple Silicon 上运行，Docker 使用 OrbStack。配置、脚本和原始记录固定在 [k8sdev `c48f140`](https://github.com/jimyag/k8sdev/tree/c48f1405c9886b048c95c3b46a8b3e766e34f6f5/hpa)。

| 组件 | 版本 |
| --- | --- |
| kind | v0.32.0 |
| Kubernetes | v1.35.0 |
| Metrics Server | v0.8.1 |
| Prometheus / Adapter | v3.5.0 / v0.12.0 |

检出固定版本并创建隔离集群：

```sh
git clone https://github.com/jimyag/k8sdev.git k8sdev-hpa
git -C k8sdev-hpa checkout --detach c48f1405c9886b048c95c3b46a8b3e766e34f6f5
cd k8sdev-hpa/hpa/lab
export KUBECONFIG="${TMPDIR:-/tmp}/kind-hpa-lab-repro/kubeconfig"
./setup.sh
alias k='kubectl --context kind-hpa-lab -n hpa-demo'
```

脚本只操作 `kind-hpa-lab`，若同名集群已经存在会停止。Metrics Server 为适配本地 kind 使用 `--kubelet-insecure-tls`；生产环境应配置可信证书。

## CPU 目标到底是什么

worker 的 CPU request 是 `100m`：

```yaml
# CPU request 既参与调度，也是 Utilization 的分母。
resources:
  requests:
    cpu: 100m
    memory: 32Mi
  limits:
    cpu: 500m
    memory: 128Mi
```

HPA 将目标设为 request 的 50%：

```yaml
# 根据 worker Pod 的平均 CPU 利用率扩缩容。
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: worker
  namespace: hpa-demo
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: worker
  minReplicas: 1
  maxReplicas: 4
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 50 # 相当于每 Pod 平均使用 50m CPU
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 30
```

`Utilization: 50` 不是宿主机 CPU 的 50%，也不是 CPU limit 的 50%。如果 request 改为 `200m`，同一个目标就相当于每 Pod `100m`。若希望目标始终是绝对用量，可以写成：

```yaml
# Resource 指标也可以直接指定每 Pod 平均绝对用量。
target:
  type: AverageValue
  averageValue: 50m
```

资源指标支持 `Utilization` 与 `AverageValue`；应用自定义指标没有资源 request 作为分母，通常使用 `AverageValue`。第二篇会用 RPS 和 B/s 实测这种差别。

## 从 164% 算出 4 个副本

先等资源指标就绪，再启动持续 CPU 负载：

```sh
k top pods
k get hpa
k apply -f load.yaml
k get hpa -w
```

在指标完整、Pod 已就绪且忽略容忍区间等修正时，可用下面的比例式理解计算：

```text
ceil(当前副本数 × 当前指标 / 目标指标)
= ceil(1 × 164 / 50)
= 4
```

首次运行记录：

| 时间（UTC） | CPU / condition | desired | Ready |
| --- | --- | ---: | ---: |
| 13:59:35 | `1m / 1%` | 1 | 1 |
| 14:00:14 | `164% / 50%` | 4 | 1 |
| 14:00:24 | 扩容完成 | 4 | 4 |
| 14:01:27 | `463%`，`TooManyReplicas` | 4 | 4 |

最后一行说明副本已经碰到 `maxReplicas: 4`，不表示 CPU 已回到目标范围。原始采样见 [lab/evidence](https://github.com/jimyag/k8sdev/tree/c48f1405c9886b048c95c3b46a8b3e766e34f6f5/hpa/lab/evidence)。

9 月 8 日又从固定提交重新创建集群。高负载时保存的 HPA 状态包含具体指标和限制原因：

```text
current.averageValue:       387m
current.averageUtilization: 387
currentReplicas:            1
desiredReplicas:            4
ScalingLimited:             True / TooManyReplicas
```

原始比例建议是 `ceil(1 × 387 / 50) = 8`，随后被 `maxReplicas: 4` 限制。Deployment 达到 4 Ready 后停止负载；降载采样为 `1m / 1%`，HPA 状态是 `currentReplicas=4`、`desiredReplicas=1`，最终 Deployment 也回到 1 Ready。新旧两轮都验证了同一控制路径，而具体 CPU 数值会随宿主机和采样时刻变化。

实际算法还会做保守修正：

- 指标比例落在默认容忍区间内时，不改变副本数。
- 部分 Pod 缺指标时，扩容按低用量、缩容按高用量估算，避免过度调整。
- CPU 路径会单独处理尚未就绪和刚启动的 Pod。
- 完全取不到指标时返回错误，不会把负载当成零。

对应实现可以从 Kubernetes v1.35.0 的 [`GetResourceReplicas`](https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/controller/podautoscaler/replica_calculator.go#L80) 开始阅读。

## CPU 已降到 1%，为什么没有马上缩容

停止负载：

```sh
k delete -f load.yaml
k get hpa -w
```

结果不是 `4 → 1` 立即发生：

| 时间（UTC） | 观测 | 副本 |
| --- | --- | ---: |
| 14:02:27 | CPU `1%`，`ScaleDownStabilized` | 4 |
| 14:02:29 | HPA 发起缩容 | 1 |
| 14:02:42 | 终止完成 | 1 Ready |

缩容稳定窗口保留近期的副本建议，并使用窗口内较大的建议约束缩容。它保存的是**副本建议**，不是对 CPU 指标求平均。第四篇会把稳定窗口、限速和故障路径放在同一条排障链路中实测。

## 回答开头的问题

`164%` 先通过比例计算得到 4，再受容忍区间、缺失指标、`minReplicas`、`maxReplicas` 和行为策略修正。HPA 只把最终结果写成 Deployment 的期望副本数；后续控制器、调度器、kubelet 和 readiness 才把它变成可用容量。

所以，**一个指标不会直接变成 Pod。它先变成副本建议，再变成期望状态，最后才可能变成 Ready Pod。** 这也是后面三篇分析自定义指标、队列任务和故障现象时共同使用的主线。

实验结束后删除集群：

```sh
kind delete cluster --name hpa-lab
```

