
一个上传服务始终收到约 35 RPS。只把每个请求体从 1 MiB 改成 512 KiB，HPA 却把副本从 4 个缩到 2 个。这正常吗？

> **请求数没有减少，副本为什么会减半？**

答案取决于 HPA 实际读取的指标。本次实验不用 CPU，而是让应用导出请求数和已处理字节数，再分别用 RPS 与 B/s 控制副本。

本文是 HPA 实验系列的第二篇。第一篇先解释了[一个 CPU 指标如何变成 Ready Pod](/posts/hpa-cpu-autoscaling/)；本文只关注应用吞吐指标。

## 自定义指标从哪里来

```text
业务请求
   ↓
应用 Counter → /metrics → Prometheus → rate(...[30s])
                                         ↓
Pod 指标 ← custom.metrics.k8s.io ← Adapter
   ↓
HPA → Deployment /scale
```

HPA 不会调用应用业务接口，也不会自动数 HTTP 请求。应用先定义指标口径，Prometheus 采集累计值，Adapter 把它转换并映射为 Kubernetes 指标。

本例的两个 Counter：

| Counter | 何时增加 | HPA 使用的速率 |
| --- | --- | --- |
| `demo_http_requests_total` | `/request` 或 `/upload` 成功处理一次 | `http_requests_per_second` |
| `demo_processed_bytes_total` | `/upload` 完整读取请求体后 | `processed_bytes_per_second` |

Counter 只增不减，进程重启时可能归零。直接把累计值交给 HPA，会让历史流量一直影响副本数。因此先用 `rate()` 计算每秒速率：

```promql
rate(demo_http_requests_total[30s])
rate(demo_processed_bytes_total[30s])
```

较长窗口更平滑，但降载反映更慢；较短窗口更敏感，新 Pod 却可能还没有足够样本。Prometheus 的 [`rate`](https://prometheus.io/docs/prometheus/latest/querying/functions/#rate) 还会处理 Counter 重置和窗口边界外推。

## 为什么这里用 Pods，而不是 Object

Prometheus 通过 Pod 服务发现逐个抓取 `app=web` 的实例，并保留 `namespace`、`pod` 标签。Adapter 因此能返回每个 Pod 的独立指标：

```yaml
# 把每个 Pod 的累计处理字节数转换成 B/s。
- seriesQuery: 'demo_processed_bytes_total{namespace!="",pod!=""}'
  resources:
    overrides:
      namespace: {resource: namespace} # 映射命名空间
      pod: {resource: pod} # 保留 Pod 维度
  name:
    matches: '^demo_processed_bytes_total$'
    as: processed_bytes_per_second
  # 先对 Counter 求速率，再按 Pod 聚合。
  metricsQuery: 'sum(rate(<<.Series>>{<<.LabelMatchers>>}[30s])) by (<<.GroupBy>>)'
```

HPA 使用 `Pods + AverageValue`：

```yaml
# 按 web Pod 的平均处理带宽扩缩容。
metrics:
  - type: Pods # 指标来源是扩缩目标中的各个 Pod
    pods:
      metric:
        name: processed_bytes_per_second # 指标语义是 B/s
      target:
        type: AverageValue # 每个 Pod 的平均绝对值
        averageValue: 10Mi # 每 Pod 目标为 10 MiB/s
```

`Mi` 只是 Kubernetes Quantity 的数值倍率；B/s 来自指标定义。`10Mi` 在这里表示 `10485760 B/s`，约等于 `83.89 Mbit/s`。

自定义指标不能直接使用 CPU 那样的 `Utilization`，因为它没有 `requests.cpu` 这样的分母。不同指标来源支持的目标类型如下：

| `metrics[].type` | 可用的 `target.type` |
| --- | --- |
| `Resource` / `ContainerResource` | `Utilization`、`AverageValue` |
| `Pods` | `AverageValue` |
| `Object` / `External` | `Value`、`AverageValue` |

如果业务要表达「处理能力使用率 70%」，应先在应用或 Prometheus 中定义分母和比例，再把结果作为一个数值指标交给 HPA；HPA 不会替自定义指标寻找容量分母。

## 两个 HPA 不能同时控制一个 Deployment

请求速率实验使用的指标配置只有名称和目标不同：

```yaml
# 按 web Pod 的平均请求速率扩缩容。
metrics:
  - type: Pods
    pods:
      metric:
        name: http_requests_per_second
      target:
        type: AverageValue
        averageValue: "10" # 每 Pod 目标为 10 RPS
```

实验顺序替换同一个 `web` HPA。不要创建两个 HPA 同时写同一个 Deployment：它们会相互覆盖期望副本数。如果确实要同时考虑 RPS 和带宽，应把两项写进同一个 `metrics` 数组，HPA 分别计算后选择较大的副本建议。

## 动手验证：保持 RPS，只改变请求体大小

实验文件固定在 [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-throughput.XXXXXX")
python3 check-upload.py --output "$HPA_OUTPUT/upload-api.json"
python3 run.py requests --output "$HPA_OUTPUT"
python3 run.py bandwidth --output "$HPA_OUTPUT"
```

每个场景同时检查 HPA 建议、Deployment 期望副本、Ready 副本、Prometheus 查询和 custom metrics API。首次带宽运行被 macOS 休眠打断，采样出现长时间空档；失败记录被保留，重跑使用 `caffeinate -i` 并加入连续采样检查。

## 结果：指标选择改变了答案

请求速率 HPA：

| 实测 RPS | Ready 副本 | 估算 |
| ---: | ---: | --- |
| 35.08 | 4 | `ceil(35.08 / 10)` |
| 15.12 | 2 | `ceil(15.12 / 10)` |
| 0 | 1 | 受 `minReplicas` 限制 |

处理带宽 HPA：

| 请求条件 | 实测 RPS | 实测带宽 | Ready 副本 |
| --- | ---: | ---: | ---: |
| 1 MiB × 35 RPS | 35.16 | 35.16 MiB/s | 4 |
| 512 KiB × 35 RPS | 34.88 | 17.44 MiB/s | 2 |
| 停止上传 | 0 | 0 | 1 |

第二阶段的计算是：

```text
ceil(17.44 MiB/s ÷ 10 MiB/s/Pod) = 2 Pods
```

此时 RPS 仍约为 35。如果 HPA 依据请求速率，它会继续建议 4 个副本；实际缩到 2，证明本阶段参与决策的是处理带宽，而请求数只用于核对实验条件。

同一轮 custom metrics API 的返回值也保留了 Pod 维度。高带宽阶段的四个值为：

```text
web-...-7zgx5  processed_bytes_per_second = 8640957516m
web-...-85xh5  processed_bytes_per_second = 9101639680m
web-...-db7tr  processed_bytes_per_second = 10402706136m
web-...-w7wcf  processed_bytes_per_second = 8725199343m
Prometheus sum                              = 36870502.68 B/s
```

Kubernetes Quantity 中的 `m` 表示千分之一，所以第一项是 `8640957.516 B/s`，不是 86 亿 B/s。半请求体阶段只剩两个 Pod，API 返回 `8682209280m` 与 `9604956160m`，Prometheus 总值为 `18287165.44 B/s`。

原始证据：

- [请求速率记录](https://github.com/jimyag/k8sdev/blob/c48f1405c9886b048c95c3b46a8b3e766e34f6f5/hpa/advanced/evidence/requests-20260907T002714Z.jsonl)
- [带宽记录](https://github.com/jimyag/k8sdev/blob/c48f1405c9886b048c95c3b46a8b3e766e34f6f5/hpa/advanced/evidence/bandwidth-20260907T022836Z.jsonl)
- [休眠中断的失败记录](https://github.com/jimyag/k8sdev/blob/c48f1405c9886b048c95c3b46a8b3e766e34f6f5/hpa/advanced/evidence/interrupted-bandwidth-20260907T003105Z.jsonl)

## 这个实验没有证明什么

`processed_bytes_per_second` 统计的是程序完整读取的请求体，不是节点网卡吞吐，也不含 HTTP 头和 TCP 重传。程序读取后直接丢弃数据，没有写盘和业务计算。

生产指标必须回答两个问题：

1. 增加 Pod 后，这个负载能否被更多实例分担？
2. 服务饱和时，指标是否仍能表达尚未处理的压力？

共享网卡、数据库或下游已经饱和时，增加 Pod 可能没有收益。完成请求数也可能在过载时不再增长，这时应结合排队、在途请求或延迟指标。

## 回答开头的问题

请求数不变而副本减半，是因为 HPA 当时没有按请求数决策。请求体减半让应用实际处理的字节速率从 `35.16 MiB/s` 降到 `17.44 MiB/s`，每 Pod 目标仍是 `10 MiB/s`，所以建议从 4 变成 2。

**导出一个指标，不等于它会参与扩缩容；只有写入当前 HPA `spec.metrics` 的指标才参与计算。** 下一篇将把指标从「每 Pod 吞吐」换成「一个队列还有多少任务」，并处理缩容时的在途任务。

