Kubernetes VPA InPlace Resize:原理、实战与避坑
目录
背景#
如果你在 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 来说,链路可以理解为:
- metrics-server 提供实时资源使用数据
- recommender 根据历史和当前负载生成建议
- admission controller 负责在新 Pod 创建时注入更合理的资源请求
- updater 负责在运行中调整 Pod
- 在
InPlaceOrRecreate模式下,updater 会先尝试原地 resize,失败再回退到传统 recreate
底层原理:一次 resize 是怎么发生的#
前面讲的是 API 层面的变化。真正让"in-place"成为可能的,是 kubelet、CRI 和 Linux cgroup 三者的协同。
完整的 resize 链路#
整个流程可以拆成五步:
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=resize 由 kubectl 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 会周期性重试,优先级排序:
- Pod PriorityClass
- QoS 等级(Guaranteed > Burstable)
- 在 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 的行为就是:
- CPU 变化 → 原地修改 cgroup,不重启
- 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 |
优先原地调整,不行再回退到重建 |
如果你的目标是先观察建议值,再慢慢打开自动化,我更倾向按这个顺序走:
Off- 看 recommendation 是否合理
- 切到
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,至少要先回答这几个问题:
- 谁负责吞吐量扩展?
- 谁负责单 Pod 资源 right-sizing?
- 这两个控制环会不会在同一个指标上打架?
小结#
VPA 的 InPlaceOrRecreate 不是一个“更酷的名字”,而是一个实打实的运行时体验改进。
- VPA 过去的问题:有推荐,但落地时常常要驱逐 Pod
- In-Place Resize GA:原地 Pod Resize 变成了可用的底层能力
InPlaceOrRecreate的价值:优先无扰动调整,失败再回退- 最适合的场景:状态服务、长生命周期服务、对重启敏感的工作负载
- 落地前提:先确认 metrics-server、节点余量和运行时支持
如果你一直把 VPA 当成“会把服务重启掉的右移工具”,现在可以重新评估它了。