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.goreconcileAutoscaler 方法里,我把它精简成伪代码:

// 源码位置: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.goGetResourceReplicas 里,核心公式朴素得让人意外:

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 分成 readyPodsunreadyPodsmissingPodsignoredPods 四组。如果未就绪 Pod 比例过高(默认超过 10%),HPA 会跳过这次扩容决策,避免基于不完整数据误判。
  • 缺失指标的处理:如果拉不到指标(比如 metrics-server 挂了),HPA 不会盲目缩容到 0,而是保持当前副本数并记录事件。

2. 扩缩容行为策略:防止"抖动"的关键

从 K8s 1.18 起,HPA 引入了 behavior 字段,这是生产环境必配的东西。它分 scaleUpscaleDown 两个方向,每个方向有 stabilizationWindowSecondspoliciesselectPolicy

stabilizationWindow(稳定窗口) 的源码逻辑很巧妙:它会记录过去一段时间内所有的扩缩容建议,然后取最保守的那个。比如缩容稳定窗口设为 300 秒,过去 5 分钟里 HPA 建议过 10、8、6、5 个副本,那么它会选择 10(最大值),从而避免因为瞬时流量下降就急着缩容。

graph TD A[HPA Sync Loop 每15s] --> B[采集指标
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)。架构上由三个组件组成,这一点很多人搞不清:

graph LR subgraph VPA组件 R[Recommender
计算推荐值] 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 配合,否则扩容会卡在调度层。

一个典型的多层协同架构:

graph TD T[流量突增] --> HPA[HPA: 副本 6→30] HPA --> S{节点资源够吗?} S -->|不够| CA[Cluster Autoscaler: 加节点] S -->|够| RUN[Pod 正常运行] CA --> RUN RUN --> VPA[VPA: 持续优化单Pod规格] VPA -->|推荐值| HPA KEDA[KEDA: 事件驱动缩到0] --> HPA

最佳实践与避坑指南

坑 1:HPA 抖动 —— 副本数像心电图一样上下跳

根因通常是没配 behaviorstabilizationWindowSeconds 太短。解决方案:缩容稳定窗口至少 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.conditionsScalingActive 是否为 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 的价值所在。

最佳实践清单

  1. HPA 必配 behavior,遵循"快上慢下"
  2. 优先用业务指标(QPS/队列深度)而非纯 CPU
  3. VPA 先跑 Off 模式观察一周,再决定是否自动化
  4. HPA 与 VPA 按维度解耦,永不争抢同一指标
  5. 全链路监控 HPA 事件、VPA 推荐值、CA 扩容日志
  6. 所有自动扩缩容服务必须配 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 的驱逐行为,生产上优先用 OffInitial 模式。
  • 弹性是分层的:HPA 调副本、VPA 调规格、CA 调节点、KEDA 做事件驱动,四者协同才是完整方案。
  • 快上慢下、按维度解耦、必配 PDB 是三条铁律。

延伸思考:随着 K8s 1.30+ 对 ContainerResource 类型指标的支持,HPA 已经能基于单个容器的指标扩缩容,这对 sidecar 泛滥的 Service Mesh 场景是重大利好。另外,预测式扩缩容(基于时序预测提前扩容)正在成为新的研究方向,KEDA 和部分商业方案已经在探索。弹性扩缩容的终局,可能不是"响应式"而是"预测式"——在流量到达之前就把资源准备好。

如果你的团队还在用固定副本数硬扛流量,从今天开始,先把 HPA 的 behavior 配上吧。