K8s HPA与VPA自动扩缩容策略详解
引言
凌晨两点,告警群炸了。某电商核心订单服务在大促预热期间 QPS 从 800 飙升到 12000,Pod 数量却纹丝不动地停在 6 个,CPU 被打到 98%,接口 P99 从 120ms 涨到 8 秒,用户下单页面转圈转到了天亮。运维同学手忙脚乱地 kubectl scale 到 50 个副本,十分钟后流量回落,50 个 Pod 又白烧了一整晚的机器钱。
这个场景几乎每个上过 K8s 的团队都经历过:要么扩容太慢,要么缩容太懒,要么根本不知道该扩 CPU 还是扩内存。而这正是 K8s 两个核心自动扩缩容组件要解决的问题——HPA(Horizontal Pod Autoscaler,水平扩缩容) 负责"多开几家分店",VPA(Vertical Pod Autoscaler,垂直扩缩容) 负责"给现有店面加桌子"。
但很多人对它们的理解停留在"配个 CPU 阈值就完事了"的层面,结果踩了一堆坑:HPA 抖动、VPA 重启 Pod、两者互相打架、自定义指标接不上……这篇文章我会从 控制循环源码、指标采集链路、算法细节 三个层面把 HPA 和 VPA 拆开讲透,并给出可直接落地的 YAML 和自定义指标适配器代码。
核心概念:餐厅后厨的两种扩容哲学
想象你经营一家连锁餐厅:
HPA 就像"开分店"。当一家店门口排队的客人越来越多(QPS/CPU 上升),总部决定在隔壁再开一家分店分担客流。分店开张需要时间(Pod 调度、镜像拉取、就绪探针通过),所以 HPA 本质上是面向吞吐量的弹性——它增加的是"并行处理单元"的数量。
VPA 就像"给现有店加灶台"。当发现厨师们挤在一个小厨房里转不开身(内存不够、CPU 被限流),总部决定把厨房扩建,换更大的灶台。扩建期间这家店得停业改造(Pod 需要重建),所以 VPA 本质上是面向单实例资源规格的弹性——它调整的是"每个处理单元的大小"。
用一句话总结区别:
| 维度 | HPA | VPA |
|---|---|---|
| 调整对象 | 副本数(Replicas) | 容器 resources.requests/limits |
| 生效方式 | 增减 Pod,无需重启 | 重建 Pod(默认) |
| 适用负载 | 无状态、可水平扩展 | 有状态、难水平扩展、资源画像不稳 |
| 响应速度 | 秒级~分钟级 | 分钟级~小时级 |
| 核心指标 | CPU/内存/自定义/外部 | CPU/内存历史用量 |
关键认知:HPA 和 VPA 不是二选一,而是可以协同的。比如一个 Java 服务,VPA 帮你找到"单 Pod 到底该给 2C4G 还是 4C8G",HPA 负责"流量来了开几个 Pod"。但两者同时基于 CPU 指标时会打架,后面会专门讲怎么避免。
源码与原理深度分析
1. HPA 控制循环:K8s 里最优雅的"负反馈系统"
HPA 的核心代码在 k8s.io/kubernetes/pkg/controller/podautoscaler。它的本质是一个周期性调谐循环(Reconcile Loop),默认每 15 秒跑一次(--horizontal-pod-autoscaler-sync-period)。核心逻辑在 horizontal.go 的 reconcileAutoscaler 方法里,我把它精简成伪代码:
// 源码位置:pkg/controller/podautoscaler/horizontal.go
func (a *HorizontalController) reconcileAutoscaler(hpa *autoscalingv2.HorizontalPodAutoscaler) error {
// 1. 拿到当前 scale 子资源(当前副本数)
scale, err := a.scaleNamespacer.Scales(hpa.Namespace).Get(...)
// 2. 计算期望副本数
desiredReplicas, metricStatuses, metricTimestamp, err :=
a.computeReplicasForMetrics(hpa, scale, hpa.Spec.Metrics)
// 3. 应用扩缩容行为策略(stabilization window 就在这里生效)
if a.replicaCalc != nil {
desiredReplicas = a.replicaCalc.CalculateReplicasForMetrics(...)
}
// 4. 写回 scale 子资源,触发 Deployment/RS 调整副本数
scale.Spec.Replicas = desiredReplicas
a.scaleNamespacer.Scales(hpa.Namespace).Update(ctx, scale, ...)
}真正决定"扩到几个"的算法在 replica_calculator.go 的 GetResourceReplicas 里,核心公式朴素得让人意外:
desiredReplicas = ceil[currentReplicas * (currentMetricValue / desiredMetricValue)]举个数:当前 6 个 Pod,平均 CPU 利用率 80%,目标 50%,那么:
desired = ceil[6 * (80 / 50)] = ceil[9.6] = 10看起来简单,难点全在细节里:
- 容忍度(Tolerance):默认 0.1。如果
currentMetricValue / desiredMetricValue落在[0.9, 1.1]之间,认为"已经够接近了",不做任何扩缩容。这防止了副本数在目标值附近反复横跳。
- 未就绪 Pod 的处理:正在启动、未 Ready 的 Pod 会被特殊对待。源码里有
groupPods函数,把 Pod 分成readyPods、unreadyPods、missingPods、ignoredPods四组。如果未就绪 Pod 比例过高(默认超过 10%),HPA 会跳过这次扩容决策,避免基于不完整数据误判。
- 缺失指标的处理:如果拉不到指标(比如 metrics-server 挂了),HPA 不会盲目缩容到 0,而是保持当前副本数并记录事件。
2. 扩缩容行为策略:防止"抖动"的关键
从 K8s 1.18 起,HPA 引入了 behavior 字段,这是生产环境必配的东西。它分 scaleUp 和 scaleDown 两个方向,每个方向有 stabilizationWindowSeconds、policies、selectPolicy。
stabilizationWindow(稳定窗口) 的源码逻辑很巧妙:它会记录过去一段时间内所有的扩缩容建议,然后取最保守的那个。比如缩容稳定窗口设为 300 秒,过去 5 分钟里 HPA 建议过 10、8、6、5 个副本,那么它会选择 10(最大值),从而避免因为瞬时流量下降就急着缩容。
metrics-server/自定义适配器] B --> C{指标是否可用} C -->|否| D[保持当前副本数
记录事件] C -->|是| E[过滤未就绪Pod
计算 currentMetricValue] E --> F{比值在容忍度内?} F -->|是| G[不做变更] F -->|否| H[计算 desiredReplicas] H --> I[查询 stabilizationWindow
历史建议取最保守值] I --> J[应用 policies 限速规则] J --> K[写回 scale 子资源] K --> L[Deployment 调整 ReplicaSet]
3. VPA 的三驾马车:Recommender / Updater / Admission Controller
VPA 不在 K8s 主仓库里,它是 kubernetes/autoscaler 项目下的独立组件(vertical-pod-autoscaler)。架构上由三个组件组成,这一点很多人搞不清:
计算推荐值] U[Updater
驱逐Pod触发重建] AC[Admission Controller
准入时改写requests] end M[metrics-server] --> R R -->|写VPA对象status| API[K8s API Server] API --> U U -->|Evict Pod| API API -->|Pod创建请求| AC AC -->|Patch resources| API API --> D[Pod重建]
- Recommender:从 metrics-server 拉取历史用量(默认保留 8 天,
--history-length),用滑动窗口 + 百分位算法算出推荐值。它默认用 P95 作为目标(--target-cpu-percentile=0.95),并且用--pod-minimum-memory-request等参数兜底。推荐值写入 VPA 对象的status.recommendation字段。
- Updater:对比推荐值和当前 Pod 的实际 requests,如果偏离超过阈值(
--eviction-tolerance=0.5),就驱逐这个 Pod。注意是Evict不是Delete,会走 PDB(PodDisruptionBudget),尽量减少对可用性的影响。
- Admission Controller:Pod 重建时,作为 MutatingWebhook 拦截创建请求,把容器里的
resources.requests改写成推荐值。
VPA 最大的坑就在 Updater 的驱逐行为上。默认情况下它会对偏离的 Pod 执行驱逐重建,如果你的服务没有配 PDB、没有配 updatePolicy,一次推荐更新可能把所有副本全干掉。这就是为什么生产上 VPA 更适合配 updateMode: "Initial"(只在创建时生效)或 "Off"(只给推荐不执行)。
实战代码:三个可直接运行的示例
示例 1:生产级 HPA —— 基于 CPU + 自定义 QPS 指标
先说结论:只基于 CPU 的 HPA 在生产环境几乎必然抖动,因为 CPU 是滞后指标,等它涨上来流量早就打满了。正确姿势是引入业务指标(QPS、队列长度)。下面是一个基于 Prometheus Adapter 的自定义指标 HPA:
# hpa-order-service.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-hpa
namespace: prod
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
minReplicas: 3
maxReplicas: 60
metrics:
# 指标1:业务 QPS,主扩缩容依据(权重最高)
- type: Pods
pods:
metric:
name: http_requests_per_second # 由 prometheus-adapter 暴露
target:
type: AverageValue
averageValue: "300" # 每 Pod 目标 300 QPS
# 指标2:CPU 作为兜底,防止 QPS 指标异常时服务被拖垮
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 65
behavior:
scaleUp:
# 扩容要快:稳定窗口短,允许翻倍
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100 # 每 15s 最多翻倍
periodSeconds: 15
- type: Pods
value: 8 # 或每 15s 最多加 8 个
periodSeconds: 15
selectPolicy: Max # 两个策略取更激进的
scaleDown:
# 缩容要稳:5 分钟稳定窗口 + 慢速策略
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 10 # 每 60s 最多缩 10%
periodSeconds: 60
selectPolicy: Min # 取更保守的关键注释:
type: Pods表示"每个 Pod 的平均指标值",适合 QPS、并发数这类可以按 Pod 平摊的指标。
selectPolicy: Max在扩容时取更激进的策略,Min在缩容时取更保守的策略——这是"快上慢下"原则的代码化。
stabilizationWindowSeconds: 0表示扩容不做延迟,因为流量来了必须立刻响应。
示例 2:Prometheus Adapter 配置 —— 把业务指标喂给 HPA
自定义指标要能被 HPA 消费,需要部署 prometheus-adapter,它把 Prometheus 的查询结果转换成 K8s 的 custom.metrics.k8s.io API。核心是这段 ConfigMap:
# prometheus-adapter-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: adapter-config
namespace: monitoring
data:
config.yaml: |
rules:
# 规则1:把 Prometheus 的 QPS 指标映射成 K8s 自定义指标
- seriesQuery: 'http_requests_total{namespace!="",pod!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
pod: {resource: "pod"}
name:
# 输出指标名:http_requests_per_second
matches: "^(.*)_total$"
as: "${1}_per_second"
metricsQuery: |
sum(rate(<<.Series>>{<<.LabelMatchers>>}[2m])) by (<<.GroupBy>>)
# 规则2:暴露队列积压深度,用于消费型服务扩缩容
- seriesQuery: 'kafka_consumer_lag{namespace!="",pod!=""}'
resources:
overrides:
namespace: {resource: "namespace"}
pod: {resource: "pod"}
name:
matches: "^(.*)$"
as: "kafka_consumer_lag"
metricsQuery: |
avg(<<.Series>>{<<.LabelMatchers>>}) by (<<.GroupBy>>)踩坑提醒:metricsQuery 里的 <<.GroupBy>> 必须是 Pod 级别(by (pod)),否则 HPA 拿到的不是"每 Pod 平均值",算法会算错。另外 rate 窗口用 [2m] 而不是 [1m],是为了平滑掉瞬时抖动。
验证是否生效:
# 查看自定义指标是否注册成功
kubectl get --raw "/apis/custom.metrics.k8s.io/v1beta1/namespaces/prod/pods/*/http_requests_per_second" | jq示例 3:VPA 的两种安全用法 —— 离线推荐 + 在线协同
用法 A:只推荐不执行(最安全,强烈推荐先跑这个)
# vpa-order-service-off.yaml
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: order-service-vpa
namespace: prod
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
updatePolicy:
updateMode: "Off" # 只计算推荐值,绝不驱逐 Pod
resourcePolicy:
containerPolicies:
- containerName: "*"
minAllowed:
cpu: 100m
memory: 256Mi
maxAllowed:
cpu: 4
memory: 8Gi
controlledResources: ["cpu", "memory"]跑上一周后查看推荐值:
kubectl describe vpa order-service-vpa -n prod
# 关注 status.recommendation 里的 target / lowerBound / upperBound然后手动把推荐值填进 Deployment 的 requests,这样既拿到了 VPA 的智能画像,又避免了它自动驱逐 Pod 的风险。
用法 B:与 HPA 协同 —— 用 VPA 调 requests,HPA 调副本数
两者同时用 CPU 会打架(VPA 改了 requests 会改变 HPA 看到的利用率分母)。正确做法是:VPA 只控制 memory,HPA 只控制 CPU,从维度上彻底解耦:
# VPA 只管内存,把 CPU 排除在外
spec:
resourcePolicy:
containerPolicies:
- containerName: "*"
controlledResources: ["memory"] # 关键:不碰 CPU
mode: "Auto"# HPA 只管 CPU 副本数
spec:
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70这样 HPA 的分母(CPU requests)不会被 VPA 改动,两者各管一摊,互不干扰。
方案对比:HPA / VPA / Cluster Autoscaler / KEDA 该选谁
很多人只盯着 HPA 和 VPA,其实完整的弹性体系有四层,各管一段:
| 方案 | 调整层级 | 触发条件 | 响应速度 | 典型场景 |
|---|---|---|---|---|
| HPA | Pod 副本数 | CPU/内存/自定义/外部指标 | 秒~分钟 | 无状态服务、突发流量 |
| VPA | Pod 资源规格 | 历史用量 P95 | 分钟~小时 | 有状态服务、资源画像不稳 |
| Cluster Autoscaler | Node 节点数 | Pod 因资源不足 Pending | 分钟级 | 云上动态节点池 |
| KEDA | Pod 副本数(事件驱动) | Kafka lag / MQ 深度 / Cron | 秒级 | 事件驱动、缩到 0 |
核心区别与选型建议:
- HPA vs KEDA:HPA 是 K8s 原生,指标来自 metrics-server/自定义适配器;KEDA 是 CNCF 项目,内置 60+ 种 Scaler(Kafka、RabbitMQ、Redis、Cron),最大优势是支持缩容到 0 副本(HPA 的 minReplicas 最小是 1)。事件驱动型服务(消费 Kafka 的任务处理)首选 KEDA。
- HPA vs VPA:前面说过,无状态可水平扩展用 HPA,有状态/难扩展用 VPA,两者按维度协同。
- Cluster Autoscaler 是底座:HPA 把副本数扩到 60,结果节点资源不够,Pod 全 Pending 了——这时候需要 CA 扩容节点。所以 HPA 必须和 CA 配合,否则扩容会卡在调度层。
一个典型的多层协同架构:
最佳实践与避坑指南
坑 1:HPA 抖动 —— 副本数像心电图一样上下跳
根因通常是没配 behavior 或 stabilizationWindowSeconds 太短。解决方案:缩容稳定窗口至少 300 秒,缩容策略用 Percent: 10 慢速爬坡。另外 CPU 目标利用率别设太高(建议 60%~70%),留出应对突发的缓冲。
坑 2:VPA 自动驱逐导致服务雪崩
默认 updateMode: "Auto" 会驱逐 Pod。生产上要么用 "Off" 只拿推荐值,要么用 "Initial" 只在 Pod 创建时生效。如果非要用 Auto,必须配 PDB:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: order-service-pdb
spec:
minAvailable: 80%
selector:
matchLabels:
app: order-service坑 3:HPA 和 VPA 同时控制 CPU,互相打架
VPA 改了 CPU requests,HPA 的利用率分母就变了,导致 HPA 误判。必须从维度解耦:VPA 管内存,HPA 管 CPU。
坑 4:metrics-server 挂了,HPA 静默失效
HPA 拉不到指标时会保持当前副本数,但不会告警。必须监控 HPA 的 status.conditions 里 ScalingActive 是否为 True,以及 FailedGetResourceMetric 事件。
坑 5:扩容速度跟不上流量洪峰
Pod 从创建到 Ready 要经历调度、拉镜像、启动、就绪探针,Java 应用动辄 30~60 秒。解决方案:① 用 startupProbe 加速就绪判断;② 配置 minReplicas 保底;③ 大促前预热镜像到节点;④ 极端场景用 KEDA 提前基于 Cron 扩容。
坑 6:忽略 requests 设置,HPA 算出来的值是错的
HPA 的 CPU 利用率 = 实际用量 / requests。如果 requests 设成 100m 但实际用 500m,利用率直接爆到 500%,HPA 会疯狂扩容。requests 必须贴近真实用量——这正是 VPA 的价值所在。
最佳实践清单:
- HPA 必配
behavior,遵循"快上慢下" - 优先用业务指标(QPS/队列深度)而非纯 CPU
- VPA 先跑
Off模式观察一周,再决定是否自动化 - HPA 与 VPA 按维度解耦,永不争抢同一指标
- 全链路监控 HPA 事件、VPA 推荐值、CA 扩容日志
- 所有自动扩缩容服务必须配 PDB
总结
回到开头那个凌晨两点的告警。如果当时团队配置了基于 QPS 的 HPA(示例 1)+ Prometheus Adapter(示例 2)+ 合理的 behavior 策略,流量从 800 涨到 12000 时,HPA 会在 1~2 分钟内把副本从 6 扩到 40,配合 Cluster Autoscaler 补节点,接口 P99 能稳在 200ms 以内——而不是靠人肉 kubectl scale。
这篇文章的核心要点:
- HPA 是负反馈控制循环,核心公式
desired = ceil(current * currentMetric / desiredMetric),难点在容忍度、未就绪 Pod 过滤、stabilizationWindow 三个细节。
- VPA 是三组件协作(Recommender/Updater/Admission Controller),最大风险是 Updater 的驱逐行为,生产上优先用
Off或Initial模式。
- 弹性是分层的:HPA 调副本、VPA 调规格、CA 调节点、KEDA 做事件驱动,四者协同才是完整方案。
- 快上慢下、按维度解耦、必配 PDB 是三条铁律。
延伸思考:随着 K8s 1.30+ 对 ContainerResource 类型指标的支持,HPA 已经能基于单个容器的指标扩缩容,这对 sidecar 泛滥的 Service Mesh 场景是重大利好。另外,预测式扩缩容(基于时序预测提前扩容)正在成为新的研究方向,KEDA 和部分商业方案已经在探索。弹性扩缩容的终局,可能不是"响应式"而是"预测式"——在流量到达之前就把资源准备好。
如果你的团队还在用固定副本数硬扛流量,从今天开始,先把 HPA 的 behavior 配上吧。