背景#

如果你在 Kubernetes 里用过 Vertical Pod Autoscaler(VPA),大概率遇到过一个很现实的问题:推荐值有了,但真正生效时,Pod 先被驱逐,再由控制器拉起新实例。

对无状态服务来说,这种做法还能接受。但对数据库、消息队列、长任务、状态服务,甚至一些对尾延迟敏感的 API 服务来说,重建 Pod 本身就是一次明显的扰动。

如果你还没有把 requests / limits、QoS 和 throttling 这些基础概念串起来,可以先看我之前的这篇:Kubernetes CPU 资源管理与 QoS 机制

Kubernetes 1.35(2025 年 12 月发布)把这个能力补齐了:

  • In-Place Pod Resize 已经 GA
  • VPA 的 InPlaceOrRecreate 更新模式进入 beta

也就是说,VPA 现在可以优先尝试在原地修改运行中 Pod 的 CPU 和内存资源,而不是直接把 Pod 赶走。

先区分三件事#

很多讨论会把 HPA、VPA 和 in-place resize 混在一起,其实它们解决的是三层不同的问题。

能力 调整对象 会不会重建 Pod 典型用途
HPA 副本数 不一定 跟随流量扩缩容
VPA 单个 Pod 的 requests / limits 旧模式通常会 让资源更贴合真实负载
In-Place Resize 运行中 Pod 的资源定义 目标是不重建 降低垂直调容带来的扰动

一句话总结:

  • HPA 解决“要不要多开几个”
  • VPA 解决“每个 Pod 配多少资源”
  • In-Place Resize 解决“改资源时能不能别重建”

In-Place Resize 的 API 层面变化#

在 API 层面,核心变化是 spec.containers[*].resources 从"创建时一次性设定"变成了运行时可调的期望值;而 status.containerStatuses[*].resources 记录的是容器当前实际生效的配置。

这意味着调容不再只能靠"删掉再建",而是可以走新的 resize 子资源路径。具体底层怎么走,下一节展开。

对于 VPA 来说,链路可以理解为:

  1. metrics-server 提供实时资源使用数据
  2. recommender 根据历史和当前负载生成建议
  3. admission controller 负责在新 Pod 创建时注入更合理的资源请求
  4. updater 负责在运行中调整 Pod
  5. InPlaceOrRecreate 模式下,updater 会先尝试原地 resize,失败再回退到传统 recreate

底层原理:一次 resize 是怎么发生的#

前面讲的是 API 层面的变化。真正让"in-place"成为可能的,是 kubelet、CRI 和 Linux cgroup 三者的协同。

完整的 resize 链路#

flowchart TD A[客户端 kubectl patch] -->|/resize 子资源| B[API Server] B -->|验证 Quota / LimitRange| C[etcd] C -->|Watch 通知| D[kubelet] D -->|容量检查| E{节点够不够?} E -->|不够| F[PodResizePending / Deferred] E -->|够| G[CRI UpdateContainerResources] G --> H[containerd / CRI-O] H -->|直接写 cgroup 文件| I[Linux kernel] I --> J[容器资源生效] D -->|更新状态| K[status.containerStatuses.resources] F -.->|周期性重试| E

整个流程可以拆成五步:

1. API Server:只认 /resize 子资源#

你不能直接 kubectl patch 改普通 Pod 的 spec.containers[*].resources——API Server 会拒绝。这里需要走 /resize 子资源

kubectl patch pod <name> --subresource=resize \
  -p '{"spec":{"containers":[{"name":"nginx","resources":{"requests":{"cpu":"200m","memory":"256Mi"},"limits":{"cpu":"200m","memory":"256Mi"}}}]}}'

--subresource=resizekubectl v1.32 引入,到 1.35 时代已经是常规功能了。

API Server 收到请求后会做两件事:

  • 校验是否超出 Namespace 的 ResourceQuota / LimitRange
  • 把新的 spec 写入 etcd

kubelet 通过 Watch 机制感知到 spec 和自己已分配的 status.containerStatuses[*].resources 不一致,就触发 resize。

2. Kubelet:容量检查和状态持久化#

kubelet 拿到新的期望值后,第一件事是检查节点有没有能力接纳

检查结果 条件状态 reason
当前节点放不下 PodResizePending Infeasible
现在放不下,但以后可能腾出空间 PodResizePending Deferred
容量充足,开始应用 PodResizeInProgress

Deferred 的 resize 不会丢弃,kubelet 会周期性重试,优先级排序:

  1. Pod PriorityClass
  2. QoS 等级(Guaranteed > Burstable)
  3. 在 Deferred 状态中的等待时间

另外,resize 状态会checkpoint 到磁盘,kubelet 重启后不会丢失进度。

3. CRI 调用:UpdateContainerResources#

kubelet 通过 Container Runtime Interface 向 containerd 或 CRI-O 发出 UpdateContainerResources gRPC 调用,把新的 CPU / memory 配额传给容器运行时。

这一步是关键分水岭:之前是 Kubernetes 控制面,之后是 Linux 内核态。

4. cgroup 层面:直接写文件,不重启容器#

containerd / CRI-O 收到调用后,直接修改目标容器的 Linux cgroup 文件。

  • CPU:修改 cgroup 的 cpu.max / cpu.weight 参数,实时生效
  • Memory:修改 cgroup 的 memory.max 参数

因为是直接写 cgroup 层级,容器进程无需重启,资源边界就变了。这就是"in-place"的本质。

5. 内存下调的安全检查#

下调 memory limit 时,kubelet 会做一次 best-effort 检查

如果容器当前内存使用量 > 新 limit,跳过这次 resize

这只是一个瞬间检查,不做持续保护。如果 resize 刚完成就来一个内存尖峰,容器仍可能被 OOMKill。

这个特性由什么驱动#

底层由 feature gate InPlacePodVerticalScaling 控制,1.35 已经是 stable(默认开启)。如果你用的是更早版本或自定义发行版,还是要确认控制面和所有节点上的实际开启状态。

不支持的场景#

场景 原因
swap 启用的节点 与 cgroup 内存管理冲突
static CPU Manager / Memory Manager 独占核策略不允许动态修改
GPU、hugepages、ephemeral storage 目前只有 CPU 和 memory 可变
Init 容器 / 临时调试容器 不在 resize 范围内
Windows 节点 仅 Linux 支持

Pod 级别的 resizePolicy#

每个容器可以通过 resizePolicy 控制 resize 行为:

containers:
- name: nginx
  resizePolicy:
  - resourceName: cpu
    restartPolicy: NotRequired    # 默认:原地调整
  - resourceName: memory
    restartPolicy: RestartContainer  # 内存调整时重启容器
  • NotRequired(默认):就地修改 cgroup,不需要重启
  • RestartContainer:kubelet 会在合适的时机重启容器后再应用新资源

对 Java 这类运行时,RestartContainer 很重要——JVM 的 -Xmx 是在启动时确定的,就算 cgroup 的 memory limit 变了,JVM 进程也不会自动调整堆大小。所以原地改了 cgroup 不等于应用真正"吃到"了新配置。JVM 的具体问题单独展开一节。

RestartContainer 是怎么工作的#

RestartContainer 不是简单的"立刻重启"。Kubelet 收到 resize 请求后,行为是这样的:

步骤 行为
1. 收到 resize 新资源写入 /resize 子资源
2. 评估当前状态 检查容器是否需要重启才能应用新资源
3. 等待合适时机 不立刻重启,等容器自然退出或被 PDB 允许驱逐
4. 重启容器 用新的资源配置启动容器

这个过程有几个关键细节:

  • 不会中断正在处理的请求:Kubelet 会等待合适的时机,通常配合 PDB(PodDisruptionBudget)一起工作
  • 不是立刻执行:与直接驱逐不同,RestartContainer 策略下 kubelet 会选择一个对应用影响最小的时机
  • 资源需要可用:重启时节点要能满足新的资源请求,否则会进入 PodResizePending

什么时候该用 RestartContainer

场景 更稳妥的起点
纯 Go/Node.js/Python(无固定内存分配) NotRequired(CPU 和 memory 都可原地调)
JVM(固定堆大小) CPU NotRequired,memory RestartContainer
有启动时资源检测的应用 对应资源 RestartContainer
不确定时 先用 NotRequired,观察 status.containerStatuses[*].resources 是否真的生效

JVM 为什么无法原地调整内存#

这是 VPA + In-Place Pod Resize 落地时最容易被忽略的一个坑

Kubernetes 官方博客原文写得很直白:

Java and Python runtimes do not support resizing memory without restart. There is an open conversation with the Java developers, see JDK-8359211.

问题出在两层不匹配。

第一层:JVM 的堆天花板在启动时就定死了#

JVM 启动时会读取当时的 cgroup memory.max,然后根据 -XX:MaxRAMPercentage 计算出最大堆大小。这个值一旦确定,运行时不会自动跟随 cgroup 变化。

容器启动时 cgroup limit = 512Mi
MaxRAMPercentage = 75%
→ JVM MaxHeap ≈ 384Mi

此时 VPA 检测到内存不够,把 limit 调到 1Gi。cgroup 确实变了,但 JVM 进程的堆上限仍然是 384Mi——它根本"看不到"新的内存。

这就是生产环境里最常见的问题:VPA 以为修复了资源不足,但应用的 heap ceiling 没变,OOMKill 依然会发生。

第二层:内存下调也不安全#

反过来,VPA 如果建议下调 memory limit,也存在风险:JVM 的 RSS 可能还没降下来(堆内存不会主动 uncommit 给 OS),此时如果新 limit 低于当前 RSS,容器直接被 OOMKill。

Java 25 的 G1/ZGC uncommit 改进有所缓解,但仍然不是真正的动态 heap sizing——JDK 的 Automatic Heap Sizing(JDK-8359211)目前还是 draft 阶段。

我目前更倾向的做法:混合 resizePolicy#

最合理的策略是让 CPU 原地调整,让内存走可控重启

containers:
- name: java-app
  image: myapp:1.0
  env:
  - name: JDK_JAVA_OPTIONS
    value: "-XX:MaxRAMPercentage=75.0"
  resources:
    requests:
      cpu: "500m"
      memory: "512Mi"
    limits:
      cpu: "500m"
      memory: "512Mi"
  resizePolicy:
  - resourceName: cpu
    restartPolicy: NotRequired      # CPU 就地调整即可
  - resourceName: memory
    restartPolicy: RestartContainer # 内存变化时安全重启

配合 updateMode: InPlaceOrRecreate,VPA 的行为就是:

  1. CPU 变化 → 原地修改 cgroup,不重启
  2. Memory 变化 → 驱逐 Pod,控制器用新资源配置拉起新 Pod

Sidecar 可以分开处理#

如果你的 Pod 有 sidecar(比如 Envoy / logging agent),sidecar 通常不关心内存 ceiling,可以全用 NotRequired

containers:
- name: java-app
  resizePolicy:
  - resourceName: memory
    restartPolicy: RestartContainer
- name: envoy
  resizePolicy:
  - resourceName: cpu
    restartPolicy: NotRequired
  - resourceName: memory
    restartPolicy: NotRequired

JVM 调优的额外要点#

要点 说明
-XX:MaxRAMPercentage=75 留 25% 给 off-heap、metaspace、线程栈等
监控 heap vs RSS vs cgroup limit 用 JFR + Native Memory Tracking + Prometheus
staging 先做 chaos 测试 模拟 resize 场景,观察 JVM 行为
不要完全依赖零重启 按我目前的理解,“可控重启 + headroom” 仍然更稳妥

CPU 场景:启动加速#

对于 Java 应用,启动阶段需要大量 CPU 编译,稳定后需求反而降低。结合 Kube Startup CPU Boost 和 In-Place Resize,可以实现"启动时高 CPU,稳态后缩减"的模式,对支付/电商等需要快速 warmup 的系统特别实用。

VPA 的更新模式#

VPA 的 updateMode 决定了建议值如何落地。

模式 作用
Off 只计算 recommendation,不自动改 Pod
Initial 只在 Pod 创建时注入资源建议
Recreate 通过驱逐和重建 Pod 应用新资源
InPlaceOrRecreate 优先原地调整,不行再回退到重建

如果你的目标是先观察建议值,再慢慢打开自动化,我更倾向按这个顺序走:

  1. Off
  2. 看 recommendation 是否合理
  3. 切到 InPlaceOrRecreate

按最小闭环跑一遍#

下面用 kind + Spring Boot (Java 25) 把完整链路跑一遍:搭集群、安装 VPA、部署 Java 应用、触发 CPU 原地 resize,再验证内存 resize 的 RestartContainer 行为。

配套代码:k8s1.35-vpa-java25

一键运行#

git clone https://github.com/meirongdev/k8s1.35-vpa-java25
cd k8s1.35-vpa-java25
make test-e2e

执行完毕后,test-results/ 下会生成结构化报告,包含 CPU 原地 resize 和内存重启 resize 的验证结果。详细脚本逻辑请查看 scripts/experiments/ 目录。

分步执行#

make setup-cluster      # 创建 kind 集群(k8s 1.35)+ metrics-server
make install-vpa        # 安装 VPA 控制器(recommender / updater / admission-controller)
make build-app          # Maven 打包 + Docker 镜像
make deploy-app         # 部署到 kind 集群
make test-smoke         # 冒烟测试,验证 /actuator/health 和 /jvm-info 端点可用
make test-cpu           # CPU 原地 resize 实验
make test-memory        # 内存 resize(RestartContainer)实验
make clean              # 清理 kind 集群

实验结论#

实验 观察指标 结论
CPU 原地 resize Pod UID 不变,containerID 不变,restart count = 0 ✅ CPU 无需重启即可调整
内存 resize(RestartContainer) 容器重启,新 JVM 进程读取新 cgroup 限制 ✅ 内存通过重启生效

CPU 原地 resize:VPA 在 ~60 秒后给出 CPU 上调建议(request 从 100m → ~500m),kubelet 直接修改 cgroup 的 CPU 配额,容器进程无需重启,restart count 保持为 0。

内存 resize:JVM 启动时读取 cgroup memory.max 计算 -XX:MaxRAMPercentage,堆上限在启动时就定死了,运行时不会跟随 cgroup 变化。通过 restartPolicy: RestartContainer,VPA 调整内存后容器重启,新 JVM 进程正确读取了新的 cgroup 限制——maxHeapMB 从 371 降到 ~189,证明新配置生效。

一句话总结:CPU 可以放心原地调,JVM 内存调容需要配合 RestartContainer 让进程重启才能生效。

你需要注意的坑#

不是所有 resize 都一定能原地完成#

InPlaceOrRecreate 的意思不是“任何时候都能无损修改”,而是“先尝试原地调容,做不到再回退”。

常见的失败原因有两类:

状态 含义
PodResizePending / Infeasible 当前节点根本放不下这次 resize
PodResizePending / Deferred 现在放不下,但未来可能腾得出空间
PodResizeInProgress resize 已经接受,正在应用

CPU 和内存的体验并不一样#

从实践上看,CPU 调整通常比内存更顺滑。内存 resize 更容易受到运行时、内核和当前使用量的影响。

对 JVM 应用来说,这个问题更突出——即使 cgroup 的 memory limit 变了,JVM 的堆上限也不会自动跟着调整。关于 JVM 的详细分析和我更倾向的配置方式,见上一节。

所以我不建议把"原地 resize"理解成"任何资源都随便改",更合理的做法是:

  • 先让 CPU right-size
  • 再谨慎验证 memory 的上/下调
  • 只在你能接受的 workload 上开启自动化

VPA 和 HPA 可以共存,但要小心边界#

HPA 调副本,VPA 调单 Pod 资源,这两个控制环如果边界没划清,很容易互相影响。

如果同一个工作负载同时用 HPA 和 VPA,至少要先回答这几个问题:

  1. 谁负责吞吐量扩展?
  2. 谁负责单 Pod 资源 right-sizing?
  3. 这两个控制环会不会在同一个指标上打架?

小结#

VPA 的 InPlaceOrRecreate 不是一个“更酷的名字”,而是一个实打实的运行时体验改进。

  1. VPA 过去的问题:有推荐,但落地时常常要驱逐 Pod
  2. In-Place Resize GA:原地 Pod Resize 变成了可用的底层能力
  3. InPlaceOrRecreate 的价值:优先无扰动调整,失败再回退
  4. 最适合的场景:状态服务、长生命周期服务、对重启敏感的工作负载
  5. 落地前提:先确认 metrics-server、节点余量和运行时支持

如果你一直把 VPA 当成“会把服务重启掉的右移工具”,现在可以重新评估它了。

参考资料#