HPA 显示 30% 却不扩容:requests 定大了,阈值永远够不着

压测时每个 Pod 都在 0.6 核,HPA 却只算 30%,副本数一动不动。requests 是半年前按峰值拍的,成了 utilization 的分母。改小之后副本冲到 20,数据库连接又被占满。

大促前两周做压测,脚本一上来就把 P99 打到三秒。看监控,四个应用 Pod 的 CPU 都在 0.6 核上下,节点整体 CPU 到 90%,kubectl top pod 一排红字。按我的理解,这时候 HPA 应该疯狂扩容才对,但它纹丝不动,副本数一直是 4,kubectl describe hpa 里当前利用率显示 30%,目标 60%。

我盯着那个 30% 看了很久。明明 top 显示每个 Pod 都在 0.6 核,4 个加起来 2.4 核,这怎么可能只有 30%?

一、先搞清楚 HPA 算的不是 CPU

这个误解很普遍,包括当时的我:以为 HPA 看的是「CPU 使用率」,也就是 top 里那个百分比。实际上 HPA 的 averageUtilization 是用量占 requests 的比例:

utilization = 当前 CPU 用量 / CPU requests

我们的 Deployment 是这么写的:

resources:
  requests:
    cpu: 2000m      # 2 核
    memory: 2Gi
  limits:
    cpu: 4000m
    memory: 4Gi

requests 写 2 核,实际用量 0.6 核,算下来就是 30%,正好对上 describe hpa 里的数字。而阈值是 60%,也就是要等单个 Pod 用到 1.2 核才会触发扩容——在那之前,节点已经被别的负载挤满了。

这个 2000m 是怎么来的?翻提交记录,是半年前一次「按峰值拍脑袋」的调整。当时有个批量导出任务会把 CPU 顶到 2 核,为了避免它被限流,直接把 requests 提到了 2 核,之后再没人动过。

于是形成了一种很别扭的状态:requests 定得比日常用量高四倍,HPA 的百分比永远在低位,扩容永远不触发;而调度器按 2 核去预留资源,节点上能塞的 Pod 数量又被压得很低。

指标 数值 含义
Pod 实际 CPU 用量 0.6 核 真实消耗
CPU requests 2 核 调度与 HPA 计算的分母
HPA 显示的 utilization 30% 0.6 / 2
HPA 目标 utilization 60% 要到 1.2 核才扩
节点可分配 CPU 8 核 按 requests 只能放 4 个 Pod

二、requests 到底该定多少

改之前必须想清楚一件事:requests 同时承担两个职责,它既是调度依据,又是 HPA 的分母,还是 QoS 等级的判定条件。改它等于同时动了三样东西。

我的做法是先看真实分布,把 P95 用量作为 requests 的起点,而不是平均值。平均值会被大量空闲时段拉低,按平均值定 requests 会导致扩容过于敏感;按峰值定又会让 HPA 迟钝。

# 用 metrics-server 的原始数据估算 P95,30 天窗口
kubectl top pod -n prod --containers | awk 'NR>1 {print $3}' \
  | sed 's/m$//' | sort -n | awk '{a[NR]=$1} END {print "P50="a[int(NR*0.5)]"m P95="a[int(NR*0.95)]"m"}'

# 更准的做法是查 Prometheus,metrics-server 只保留很短的历史
# histogram_quantile(0.95, sum(rate(container_cpu_usage_seconds_total{namespace="prod"}[5m])) by (pod))

跑出来 P95 在 620m,我们最终把 requests 定成 800m,留了一点缓冲。limits 保持 2 核不变——limit 是「最多能用多少」,跟 HPA 无关,不需要跟着改。

改完立刻出现第二个问题。

三、改小 requests 之后,副本冲到了 20

新 requests 生效的当天下午,压测一开始副本数就从 4 涨到 20,kubectl get pods -w 刷得停不下来。这次不是不扩容,是扩过头了。

原因是扩容判断有延迟:从 CPU 真的上来,到 HPA 看到指标、算出新副本数、调度、拉镜像、容器就绪,中间大概有一到两分钟。这一分钟里流量还在涨,HPA 每一轮(默认 15 秒)都在看过载的指标,于是每次都往上加。

更麻烦的是新 Pod 启动阶段本身要吃 CPU(JVM 预热、类加载),它们一上来就把节点 CPU 顶满,进一步推高指标,形成正反馈。

这里要区分两个概念:kubectl top 的数据来自 metrics-server,默认 15 秒采集一次;HPA 的 controller 也是 15 秒同步一次,但它算 utilization 时会用一个时间窗口(默认 --horizontal-pod-autoscaler-cpu-initialization-period 是 5 分钟,新 Pod 在这段时间内的指标不计入)。所以理论上不该这么快失控。

我们失控的真实原因是另一条:HPA 的 scaleUp 没有配任何策略,默认行为是「每 15 秒最多翻倍,或者加 4 个」,两个取大。4 个 Pod 的基础上,一轮就能到 8,再一轮到 16,再一轮到 20(受 maxReplicas 限制)。这个默认值对突发流量是合理的,但我们的流量是脚本打进来的阶梯,不是真实突发。

四、把扩容变成有策略的动作

最后定下来的 HPA 是这样,重点是 behavior 段:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-api
  namespace: prod
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-api
  minReplicas: 6
  maxReplicas: 24
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 65
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0
      selectPolicy: Max
      policies:
        - type: Percent
          value: 50
          periodSeconds: 60
        - type: Pods
          value: 4
          periodSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Percent
          value: 25
          periodSeconds: 120

几个参数的实际效果,都是压测里试出来的:

scaleUp 的 stabilizationWindowSeconds: 0 表示扩容不看历史,立刻响应,这是对的——扩容慢了就是用户等。但 policies 把它限制成「每分钟最多加 50% 或 4 个」,避免一轮翻倍。

scaleDown 的 300 秒窗口是关键。删掉这个窗口的话,流量一回落副本数立刻下降,下一波流量再来又要重新拉镜像、预热 JVM,来回横跳比不扩还糟。5 分钟的窗口意味着「要持续低于阈值 5 分钟才缩」。

还有一个容易被忽略的点:minReplicas: 6。原来的 4 个在低峰期就够用,但每次扩缩都要走一遍冷启动,我们把下限抬到 6,用一点常驻成本换掉冷启动的不确定性。

五、扩出来的副本为什么起不来

副本数上去了,kubectl get pods 里却有一半是 Pending。原因很直接:节点没有可分配的 CPU 了。调度器看的是 requests,20 个 Pod 各 800m 就是 16 核,我们那个节点池一共 24 核,还要留给系统组件。

这里暴露了两个之前没注意的问题。

第一,节点池没有开自动伸缩。集群用的是固定节点数,HPA 只能把 Pod 调度到已有节点上,扩到 24 个必然有一批 Pending。后来加了 Cluster Autoscaler,但它本身也有延迟:检测到 Pending 到新节点 Ready 大概两三分钟,这段时间 Pending 的 Pod 不提供服务,等于扩容白做。

第二,maxReplicas 定得比「节点能装下」还大。这不是错,但要知道后果:HPA 会一直尝试扩到 24,而集群装不下,最终状态是「一半 Pod 跑着、一半 Pending」,从外部看是「扩容了但没效果」。更稳的做法是让 maxReplicas 与「节点池容量 ÷ requests」对齐,再靠节点池扩容去突破上限。

六、还有一个没预料到的瓶颈

Pod 从 4 个扩到 20 个之后,接口反而更慢了。查下来是数据库连接数:每个 Pod 的 HikariCP maximumPoolSize 配的是 20,20 个 Pod 就是 400 个连接,MySQL 的 max_connections 是 500,剩下 100 个还要给定时任务、运维工具、从库同步用。

连接数打满之后,新建连接被拒,应用侧报 Too many connections,重试又把连接池占得更满。这个链路跟 HPA 完全无关,但它是由扩容触发的——横向扩容放大的不只是计算能力,还有所有「每个副本一份」的资源。

我们最后把 maximumPoolSize 改成 8,同时在 HPA 之外加了一层保护:用 maxReplicas 乘以单 Pod 连接数,不能超过数据库连接预算的 70%。这个约束后来写进了容量评审的检查清单。

小结

  • HPA 的 averageUtilization 是「用量 ÷ requests」,requests 定得过大,百分比永远到不了阈值,扩容就永远不触发。
  • requests 建议按 P95 用量定,不要按历史峰值拍;改 requests 会同时影响调度、HPA 分母和 QoS 等级。
  • 默认的扩容策略是「每 15 秒翻倍或加 4 个取大」,突发场景容易扩过头,用 behavior.scaleUp.policies 限制速率。
  • scaleDown 一定要配 stabilizationWindowSeconds,否则副本反复横跳,冷启动成本比多留几个 Pod 更高。
  • HPA 只能扩 Pod,扩不了节点;maxReplicas 要和节点池容量对齐,否则会出现「扩了但一半 Pending」。
  • 扩容会放大所有「每副本一份」的资源,数据库连接池、文件句柄、注册中心实例数都要提前算进预算。
查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

HPA 显示 30% 却不扩容:requests 定大了,阈值永远够不着 - 起航社区