先聊个我踩过的场景:某个接近上了生产的模拟项目,压测一跑,CPU 使用率直接拉到 90% 以上,值班同事的第一反应是“把 replicas 从 3 改成 10”,然后kubectl apply -f deployment.yaml。改完后 CPU 曲线确实掉下来了,但第二天流量回落,10 个副本又在那里空转,白白占着配额。后来我把这份 Deployment 清单里面嵌入了 HPA,同时把整套资源配置收进 Helm Chart,再用helm upgrade --install做发布,这件事才算彻底闭环。这篇博客就围绕 Kubernetes 里最常用的两个工具来写:HPA(Horizontal Pod Autoscaler,水平 Pod 自动伸缩)和Helm(Kubernetes 包管理工具)。
如果你是刚接触 Kubernetes 的开发者,或者已经在生产环境里手动改副本数改到烦的运维,这篇文章会帮你弄明白三件事:HPA 背后的控制逻辑和那套数学公式,为什么仅靠 HPA 还不够、清单需要交给 Helm 管理,以及如何基于一个 Helm Chart 把“带自动伸缩的服务”完整地部署出来并压测验证。写作过程中会把计算方式、参数含义、踩坑过程都拆开讲,尽量做到不光给配置,还给你解释为什么这样配。
1. 自动伸缩的前提:HPA 的底层逻辑和那套数学公式
1.1 指标从哪来:metrics-server 与 kubelet 的采集链路
先明确一个容易被忽略的前提:HPA 本身并不采集任何指标,它只是一个“消费者”。指标由metrics-server从集群各节点的kubelet里定期拉取,然后通过kube-aggregator暴露成metrics.k8s.io这个 API。HPA 控制器每隔一段时间(默认 15 秒,由 kube-controller-manager 的参数--horizontal-pod-autoscaler-sync-period控制)去查询一次这个 API,拿到所有目标 Pod 的 CPU 或内存使用量,再换算成平均使用率。
我喜欢把这套链路类比成“体温计 - 护士站 - 医生开药”的关系:kubelet内置的 cAdvisor 就是体温计,负责采集 Pod 的实时资源消耗;metrics-server像是护士站,把散落在各节点的体温数据汇总登记;HPA 控制器则是医生,每次值班巡房(默认 15 秒)看一眼体温记录,超出阈值就调整副本数。
部署metrics-server是启用 HPA 的前提。用下面的命令可以快速验证它是否在线:
kubectl get apiservice v1beta1.metrics.k8s.io -o yaml kubectl top pod -n <namespace>如果kubectl top能返回每个 Pod 的 CPU 和内存值,说明采集链路已经通了。这一步不通,HPA 就算配置了也会一直显示<unknown>指标,什么伸缩都不会发生。我在新集群上排查 HPA 问题时,十次里至少有三次是metrics-server没装或者被防火墙挡住了。
1.2 targetAverageUtilization 的真正含义:为什么 70% 不是“直接用 70%”
HPA 配置里最常出现的参数是targetAverageUtilization,很多人把它简单理解为“当 Pod CPU 达到 70% 就扩容”。实际上它是一套平均值的逻辑:目标副本数 = ceil(当前副本数 × 当前所有 Pod 的平均使用率 / 目标平均使用率)。
举个例子:当前有 4 个副本,HPA 查询到的平均使用率是 150%,目标平均使用率是 70%,那么计算过程是:
4 × (150 / 70) = 8.57 ceil(8.57) = 9也就是控制器会直接跳到 9 个副本,而不是一点点扩到 5、6、7。这个公式是 HPA 的“眼睛和大脑”——它在每个控制周期都会重新计算一次,指标波动大的时候副本数跟着跳变非常正常。
这里有个关键点:使用率的分母是 Pod 里声明的 requests,而不是节点实际分配的 CPU 核数。一个 Pod 的requests.cpu=500m,那么它的使用率达到 70%,指的是用了 350m 的 CPU 时间;如果你的请求值设得过高,HPA 对压力的反应就会迟钝——因为分母被放大了,同样 80% 的节点压力,在 request 偏大的 Pod 上看使用率只有 50%。这就是为什么很多人配置了 HPA 却发现自己服务都快被打挂了才触发扩容,回头一查,requests 设得太保守了。
1.3 控制循环:扩容 15 秒内生效,缩容却要等冷却窗口
HPA 还有一个不对称的时间特性,这是它设计上的一个特点,也是实践中必须接受的现实:扩容快,缩容慢。
扩容时,HPA 控制器一旦发现使用率超过目标值,会在下一个控制周期直接算出新的副本数并写入 Deployment 的spec.replicas,之后 ReplicaSet 控制器开始创建 Pod。整个过程通常只需要 15 ~ 30 秒——前提是集群里有足够的空闲节点资源。缩容则相反,HPA 默认要观察一段“稳定时间窗口”(stabilizationWindowSeconds)才会真正减少副本数,默认值是 300 秒。也就是说,即使在 5 分钟前流量就已经降下来了,控制器也会等 5 分钟,确认使用率真的持续走低之后才动手缩。
这么设计的原因也好理解:流量短时波动太常见,如果缩容也特别敏捷,就会出现“扩容 15 秒、缩容 3 分钟”这种抖动循环,反而把集群状态搞得很乱。实际生产里,我会在behavior字段里把缩容的窗口和单次步长都显式写出来,而不是依赖默认值,避免某个瞬时低谷把副本数砍得太狠,下个高峰又被迫重新扩容。具体的配置方法后面在实战小节里会给出。
2. 真正的坑往往在 HPA 之外:扩容设计里最容易被忽略的细节
2.1 没有设置资源 request,HPA 直接形同虚设
如果你把一个 Deployment Pod 的resources.requests完全省略,然后配上 HPA,控制器往往查不到 Pod 的 CPU 使用率,因为使用率需要“已用 / 配额”才能计算,配额是 requests 决定的。没有 requests,kubectl top可能能显示数据,但 HPA 在 autoscaling/v2 协议下会因为“无法获取目标指标”而一直卡在 unknown 状态。
我见过不少团队的生产事故记录:HPA 加了,告警也接上了,但某次流量峰值到来时没有一个 Pod 扩出来,排查半天发现新版本上线时有人把资源 requests 块从 Deployment 里删掉了。所以第一道验收线就是:修改 HPA 之前,先检查 Pod 的spec.containers[].resources.requests是否存在且数值合理。建议给每个业务容器都设置requests.cpu和requests.memory,不仅是为了让 HPA 有“分母”,也是给调度器提供最基本的容量判断依据。
顺便补充一个直观的对照,requests 对 HPA 行为的影响:
| requests.cpu 设置 | HPA 计算出使用率 | 实际效果 |
|---|---|---|
| 200m | 200%,立即扩容 | Pod 很小,触发很敏感 |
| 1000m | 40%,不扩容 | Pod 很大,触发很迟钝 |
| 不设置 | 无法计算 | HPA 不工作 |
2.2 突刺流量与“扩容 5 分钟、缩容 3 分钟”的抖动问题
生产环境里最让人头疼的不是持续高压,而是“每隔半小时来一波突发请求”。这类流量下 HPA 会出现一种典型图景:波峰一到,15 秒内扩出几个新副本;波峰刚过,stabilizationWindowSeconds还在倒计时,使用率已经回落到目标值以下;可窗口一过,又开始缩容。如果下一波流量恰好在缩容刚完成时到来,又得重新扩容——副本数的曲线就像锯齿一样来回抖动。
这个问题需要用 HPA 的behavior字段来做“整形”。以autoscaling/v2版本为例,可以这样限制缩容节奏:
behavior: scaleDown: stabilizationWindowSeconds: 300 policies: - type: Percent value: 20 periodSeconds: 60含义是:缩容观察窗口 300 秒,每次最多允许缩掉当前副本数的 20%,而且每 60 秒只执行一次。这样一来,即使某次低谷测出的平均使用率极低,副本数也不会一夜之间被打回原形,而是缓慢退潮。对于那种“波峰规律出现、持续 10 分钟”的业务,把缩容步长调得更小,通常能把抖动控制在可接受范围。
2.3 CPU 平均值被稀释:单个 Pod 卡死时为什么 HPA 无动于衷
HPA 默认处理的是所有 Pod 的平均值,这会掩盖一个常见的故障态:某个 Pod 因为代码里某个连接泄漏或者死循环导致 CPU 暴涨到 300%,而其他副本很正常地停在 20%,平均值只有 60% 出头,还没到 70% 的阈值,HPA 就不会扩容。结果就是那个异常 Pod 持续影响这批实例里的部分请求,但集群层面却“一切正常”。
遇到这类问题,单一平均值就不够用了。务实点的做法是配合告警系统,单独盯每个 Pod 的 CPU 使用率,而不是只盯 HPA 是否扩容;复杂一点的做法是启用自定义指标,比如按 Pod 维度统计 QPS 或错误率,把“单 Pod 异常”变成可伸缩的触发条件。但后者需要引入 Prometheus Adapter 这类组件,运维成本并不低,至少在你确认基础 HPA 已经稳定之前,不建议一上来就搞自定义指标。
2.4 内存型指标更棘手:它不会自己释放
相比 CPU,按内存使用率做 HPA 在很多业务场景里是“看起来合理、用起来别扭”的方案,因为内存天然有惰性。一个 Java 应用冷启动后堆内对象逐渐增长,使用率达到 90%,HPA 扩容到 5 个副本后,新的 Pod 起来,内存确实被分摊了,你总内存使用率下来了;但老 Pod 的内存并不会因为“有了新 Pod”就自动下降,它只是不再承担更多新请求。于是你可能在一个流量平稳的时段里发现内存使用率依然偏高,HPA 继续扩容到 6、7 个副本,直到业务重启或容器重建才释放。
用内存做 HPA 指标不是不行,但一定要和业务关联:如果你的服务是典型的内存缓存型(比如大量数据驻留在进程内),那么“内存使用率”往往接近容量上限,HPA 会反复扩容;如果是无状态服务,建议优先用 CPU 或 QPS 类指标,把内存 HPA 关掉,或者把内存使用率阈值调得比 CPU 高得多(比如 85% 以上),避免过早触发。
3. 为什么生产环境里 HPA 清单要交给 Helm 管理,而不是 kubectl apply
3.1 Chart、Release、values.yaml:Helm 解决的是“可重复部署”问题
HPA 本身只是一个 Kubernetes 对象,你可以直接用一行kubectl apply -f hpa.yaml部署。但它的配置和 Deployment 强绑定:目标对象名要一致,requests 的大小影响伸缩灵敏度,minReplicas和maxReplicas又和集群容量规划挂钩。这些参数如果散落在不同的 YAML 文件里、每次上线都要手工改,出错概率会非常高。
Helm 做的事就是把这些关联配置打包成一个 Chart,可重复、可参数化、可版本化。一个 Helm Chart 里通常包含 Deployment、Service、HPA、ConfigMap 等清单文件,values.yaml统一暴露配置项。发布的时候执行:
helm upgrade --install <release-name> <chart-dir> -f values-prod.yaml --namespace <namespace>你可以把这份 values 文件视为“环境的唯一入口”。测试环境把hpa.minReplicas设为 2,生产环境设为 5;同一个 Chart 部署到不同集群,行为不同但结构完全一致。这才是 HPA 真正想要的东西——副本数不再靠手改,环境差异被收敛到配置层。
3.2 HPA 的 API 版本演进:模板里怎么兼容 autoscaling/v1 和 v2
一个比较隐蔽的坑是 HPA 的 API 版本。autoscaling/v1只支持 CPU 使用率,而且只能表达targetCPUUtilizationPercentage这种简单形式;autoscaling/v2(以及现在的稳定版本 autoscaling/v2)才支持内存、自定义指标和behavior字段。如果你照抄一份老博客上的 HPA 示例到两年前的集群里,很可能会因为 API 版本不匹配而部署失败。
Helm 的模板可以利用内置的Capabilities.APIVersions.Has做条件判断,让同一个 Chart 能在不同版本的 Kubernetes 上自动选择兼容写法,经典的模板片段如下:
{{- if .Capabilities.APIVersions.Has "autoscaling/v2" }} apiVersion: autoscaling/v2 {{- else }} apiVersion: autoscaling/v1 {{- end }}不过从 2023 年之后的 Kubernetes 版本看,autoscaling/v1基本已经退出主流视野,新项目直接写autoscaling/v2即可。至于更早的autoscaling/v2beta1、v2beta2,除非你在维护一个很老集群,否则不建议在模板里为它们做兼容分支,会让 Chart 变得很啰嗦。
3.3 helm upgrade、回滚与发布记录:把 HPA 变成可审计的配置资产
直接kubectl apply的问题在于无状态——文件改了、资源改了,但没有人能说清楚每次变更的原因和前后 diff。Helm 把每次发布的清单快照记录在ConfigMap或Secret里,默认保留最近 10 条记录,你可以通过下面的命令回溯每次 HPA 参数的变更:
helm history <release-name> -n <namespace> helm diff revision <release-name> <revision> -n <namespace>helm diff revision需要安装helm-diff插件,我比较建议装一下,因为升级前先看一下 diff,能避免“以为自己只改了镜像 tag,结果把 HPA 的最小副本数也带跑了”这种乌龙。如果某次helm upgrade把 HPA 配置改坏了,一条命令就能回滚到安全版本:
helm rollback <release-name> <revision> -n <namespace>对生产环境而言,这比“打开 YAML 文件反查上次内容”要可靠得多。HPA 从此不再是某个成员电脑上的本地文件,而是一个能回放、能审计的配置资产。
4. 完整跑通:用 Helm 部署一个自带 HPA 的服务,并压测验证
4.1 最小可用 Chart 结构:HPA、Deployment、Service 三个文件的协同
为了把前面这些原理落到实操里,我准备了一个模拟项目 X 的最小 Chart 结构,读者可以直接照着搭:
simulate-project-x/ ├── Chart.yaml ├── values.yaml └── templates/ ├── deployment.yaml ├── service.yaml └── hpa.yamldeployment.yaml里定义一个无状态服务,镜像用一个会响应请求的模拟服务即可,重点是必须声明资源 requests:
resources: requests: cpu: 500m memory: 256Mi limits: cpu: "1" memory: 512Mihpa.yaml是整份 Chart 的伸缩策略核心。下面这一版配置把观察窗口、扩容步长都显式写了出来,属于生产可用的“基础模版”:
{{- if .Values.autoscaling.enabled }} apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: {{ include "simulate-project-x.fullname" . }} labels: app: {{ include "simulate-project-x.name" . }} spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: {{ include "simulate-project-x.fullname" . }} minReplicas: {{ .Values.autoscaling.minReplicas }} maxReplicas: {{ .Values.autoscaling.maxReplicas }} metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} - type: Resource resource: name: memory target: type: Utilization averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} {{- end }} behavior: scaleDown: stabilizationWindowSeconds: {{ .Values.autoscaling.stabilizationWindowSeconds }} policies: - type: Percent value: {{ .Values.autoscaling.scaleDownPercent }} periodSeconds: 60 scaleUp: stabilizationWindowSeconds: 0 policies: - type: Percent value: 100 periodSeconds: 15 {{- end }}这段模板有几点值得说明:scaleUp的stabilizationWindowSeconds设置为 0,意味着只要指标越界就立刻扩容,保证响应速度;scaleDown则刻意拉长时间窗口并限制单次缩容比例,避免抖动。
service.yaml不需要特别设计,只要把 Deployment 暴露出来,用于压测时触达服务即可。
4.2 values.yaml 中的伸缩参数设计:把策略暴露给不需要懂模板的人
values.yaml的价值在于把 hpa.yaml 里的{{ .Values.autoscaling.xxx }}全部替换为可填写的配置项。一个比较完整的伸缩配置块长这样:
autoscaling: enabled: true minReplicas: 2 maxReplicas: 10 targetCPUUtilizationPercentage: 70 targetMemoryUtilizationPercentage: 0 stabilizationWindowSeconds: 300 scaleDownPercent: 20实际部署时,如果只是调整副本范围或阈值,不改模板路径里的 YAML,直接改这几个值即可。这也是 Helm 项目在团队协作中比较好用的点:懂 Kubernetes 的工程师只需要把模板写好,普通运维或研发通过values.yaml调参,不需要深入理解 HPA 的控制循环细节。
发布命令可以这样跑:
cd simulate-project-x helm upgrade --install simulate-project-x . -f values.yaml --namespace demo-hpa --create-namespace部署完成后确认 HPA 对象已经创建:
kubectl get hpa -n demo-hpa预期输出里会有一行,TARGETS列显示0%/70%,MINPODS为 2,MAXPODS为 10。如果显示<unknown>,说明 metrics-server 链路有问题,需要先回到本文 1.1 节检查。
4.3 压测验证:从 2 个副本扩到 5 个,再缩回来的完整过程
部署完之后,真正的验证才开始。我用一台独立压测机器对 Service 发起请求,压测工具用的是常见的hey或ab,这类工具能生成足够大的 CPU 压力。由于本文侧重 HPA 本身,压测命令保持朴素:
nohup hey -z 5m -c 20 -q 200 http://<service-ip>/ > /tmp/hey.log 2>&1 &压测开始后约 15 秒,再用kubectl get hpa -w观察副本变化。我实测的曲线大致是:开始时 2 个副本,CPU 使用率很快逼近 100%,HPA 开始扩容,约 1 分钟内升到 5 个副本;此时平均使用率回落到 50% 左右,副本数停住不再变化。停止压测后,由于stabilizationWindowSeconds=300,副本数会继续保持 5 个左右约 5 分钟,之后才开始慢慢往回收,每次缩容不超过 20%。完整状态变化可以通过下面命令跟进:
kubectl get hpa simulate-project-x -n demo-hpa -w kubectl get pods -n demo-hpa -w这里有个我实际踩过的提醒:压测机的并发量要够大,否则 Pod 的 CPU 使用率永远打不到 70%。比如低并发请求,对 CPU 消耗不足,服务可能用 200m CPU 就轻松跑完,HPA 什么时候都不会触发。压测时先看kubectl top pod,确认单 Pod 的 CPU 用量已经超过 350m(也就是 500m 的 70%),再谈观察伸缩行为。
另一个值得留意的现象是:如果压测机本身部署在一个资源较紧张的集群里,扩容到第 6、7 个 Pod 时可能会因为节点资源不足而调度失败。HPA 只负责计算副本数,不保证每个副本都能成功调度。这个边界在下一节详细展开。
5. 生产经验的边界:HPA 和 Cluster Autoscaler、VPA、自定义指标的配合边界
5.1 HPA 与 Cluster Autoscaler 的联动顺序:扩 Pod 永远先于扩节点
HPA 扩容后,如果集群的节点资源不够,新增 Pod 会一直处于Pending状态。此时需要另一套机制——节点级自动扩缩容(通常由 Cluster Autoscaler 负责)来补充节点。两者配合时的顺序很重要:先有 Pod 调度失败,才会有节点扩容需求;节点扩容完成后,Pod 再被调度上去。因此扩容链路里实际存在两次等待:第一次等 HPA 计算并创建新 Pod,第二次等新节点创建并加入集群。
如果集群里的节点很小,HPA 又突增 5 个副本,节点扩容可能要 3 ~ 5 分钟。这段时间内服务会持续处于高负载状态,所以生产上比较务实的做法是:不要只依赖 HPA 的自动扩容,还要预留一定的节点余量,或者把 HPA 的maxReplicas与节点容量上限做好匹配,避免“计算出的新副本永远调度不上去”的死循环。我用过的一个经验是:集群总可分配 CPU 至少要比 HPA 最大副本数的 request 总量多出 20% 的安全余量。
5.2 HPA 和 VPA 不要同时启用:两份建议会打架
VPA(Vertical Pod Autoscaler)负责调整 Pod 的 CPU 和内存请求值,HPA 负责调整副本数,两者从理论上讲可以共存,但在绝大多数场景里,它们在“CPU 先扩容还是先调 request 值”这件事上很可能打架。因为 VPA 调大 request 后,HPA 看到的平均使用率分母变大,可能就不再扩容;而 VPA 又会不断尝试修改 requests,导致整个扩缩逻辑的变量变得不可预测。
我见过最省心的分工是:业务的 requests 值由性能测试或 VPA 的推荐模式(Off / Initial)来定,部署后固定写入 values.yaml;运行期的副本数完全交给 HPA。也就是说,VPA 可以拿来“做一次容量推荐”,但不要让它和 HPA 同时实时生效。实在要做动态资源调优,也建议只在测试环境验证,生产环境保持资源请求值稳定。
5.3 自定义指标适配器:好用,但要先想清楚运维成本
当 CPU 或内存无法代表真实业务压力时(例如 WebSocket 长连接服务,CPU 很低但连接数很多),就需要把业务指标暴露给 HPA。常见做法是部署 Prometheus Adapter,让它把 Prometheus 里某个指标映射到custom.metrics.k8s.io或external.metrics.k8s.io上,再在 HPA 里通过type: Object或type: External引用。
这套方案能解决“按 QPS、按连接数、按队列深度伸缩”的问题,但它的运维成本常常被低估:Prometheus 本身需要稳定运行,指标命名和采集间隔会影响 HPA 的响应速度,Adapter 的规则配置一旦写错,HPA 可能直接查不到指标。我的建议是分两步走:第一步,无条件地把 CPU 基础 HPA 配上,保证兜底;第二步,确定某个业务指标真的能稳定描述容量压力,再规划自定义指标方案。最怕的是自定义指标还没调通,基础 HPA 也没配,高峰期两手空空。
5.4 目标使用率不是拍脑袋:给在线业务和批处理服务的选值参考
最后回到一个最常见的决策问题:targetCPUUtilizationPercentage到底设多少。这个值没有绝对标准,但可以参考以下维度来选择。对延迟敏感型的在线服务(比如订单接口、登录服务),我通常把 CPU 阈值设在 40% ~ 50%,留足突发流量缓冲;对离线批处理、消息消费类任务,可以把阈值提高到 70% 以上,因为这类任务多跑一会儿影响不大,重点是吞吐量,所以副本数不容易被频繁拉起。
还需要补充一点:目标使用率不是越大越好,也不是越小越好。设得太小,副本数会比较激进,集群成本上升;设得太大,单个 Pod 在高负载下持续运行,P99 延迟会先劣化,等到 HPA 触发扩容时,用户感知到的卡顿已经发生了。线上服务的稳定性指标要多看 P99 延迟曲线,让它反过来检验你的 CPU 目标值是否合理。如果 CPU 才到 50% 延迟就已经告警,说明瓶颈不在 CPU,HPA 按 CPU 伸缩的意义也不大,需要回到业务指标层面去分析。
就我个人的实际体验而言,把 HPA 和 Helm 放在一起理解,比单独看任何一方面的文档都更容易建立完整的操作直觉。HPA 解决“什么时候扩、什么时候缩”的决策问题,Helm 解决“这些决策配置怎么被安全地分发、变更和回滚”的管理问题。先跑通一个最简单的模拟项目,观察扩容缩容的整个过程,再逐步把节点伸缩、自定义指标加进来,你会比直接在生产环境里调试配置从容得多。