结论先行:cgroup v2 的
memory.high是一个「软限」(soft limit),它不杀进程,而是通过让内核在该水位下主动回收(reclaim)并施加「背压」(back-pressure,让进程在内存分配时主动 sleep)来压制内存增长——它和memory.max(硬限,超了直接 OOM)是两条完全不同的线。K8s 里resources.limits.memory落到memory.max,而memory.high默认是max(即不生效)。把memory.high用到 70%–85% 水位,可以把「阶跃式 OOM」变成「渐进式减速」,让 JVM / Go 服务在被杀前有时间降级、回收、自愈。但用错会引入 5–20% 的额外 CPU 开销(reclaim 需要 CPU),不是白捡的收益。下面这套做法我在一个 JVM 网关集群里验证过。
一、现象:内存监控全绿,服务在 1 秒内被 OOMKilled
我们一个 4C8G 的 Java 网关(Kubernetes 1.28.9,containerd 1.7.13,cgroup v2,内核 5.15.0-91),resources.limits.memory: 8Gi。某次促销压测(稳态 25k QPS)中,凌晨 01:23 一批 Pod 出现 OOMKilled(reason 137),被杀的瞬间 RSS 还在 7.4Gi(limit 8Gi,看着「还差一点」)。诡异的是:
- Prometheus 按 15s 采集,
container_memory_working_set_bytes峰值只到 7.1Gi,没贴 8Gi 红线; - 应用侧 GC 日志显示 Young GC 还在正常跑,没有任何 OOM 前兆;
- 把同一镜像裸机起(同内核、同参数、同流量),跑 24 小时无一次 OOM。
差别只有 K8s 这一层。根因:memory.max 是阶跃事件——前一刻 RSS 还在涨,后一刻进程就没了,15s 采集根本抓不到峰值;内核 OOM killer 在节点 cgroup 层按 oom_score 挑,RSS 最大的 JVM 进程先被杀。
二、根因:memory.max 是「硬顶」,memory.high 是「软限 + 背压」
cgroup v2 的内存控制器有三个关键文件:
| 文件 | 语义 | 行为 |
|---|---|---|
| memory.max | 硬限(hard limit) | 超了直接 OOM kill,无缓冲 |
| memory.high | 软限(soft limit) | 超了不杀,触发 reclaim + back-pressure |
| memory.low / memory.min | 保护(低水位) | 被其他 cgroup 抢占时优先保留 |
memory.high 的机制(内核 mm/memcontrol.c 的 try_charge → mem_cgroup_charge 路径):
- 进程分配内存时,如果当前 cgroup 的
memory.current > memory.high,内核不直接拒绝,而是触发 page reclaim(尝试把不活跃页换出 / 回收); - 如果 reclaim 后仍然超
memory.high,内核会在try_charge路径里让分配方主动 sleep(back-pressure),具体是mem_cgroup_sleep或mem_cgroup_reclaim路径,让分配进程让出 CPU,给回收者时间; - 这个 sleep 是阻塞在分配方,不是 OOM——进程还能活,只是分配变慢了。
关键反直觉点:memory.high 不是「软 OOM」,它是**「让分配方主动减速」**。这意味着:
- 有 CPU 开销:reclaim 需要 CPU 去扫描、回收、可能换页;
- 对延迟敏感:JVM 的
malloc路径会卡住,P99 会涨; - 不保证不 OOM:如果 reclaim 追不上分配速度,最终仍会顶到
memory.max触发 OOM——memory.high只是把 OOM 的「阶跃」变成「渐进」。
三、具体做法:在 K8s 里用 memory.high 做「内存背压」
1. K8s 原生不直接支持 memory.high,需要用 annotation
K8s 1.25+ 支持 resources.limits 映射到 memory.max,但 memory.high 需要显式设置——K8s 默认把 memory.high 设成 max(即不生效)。两种姿势:
姿势 A:用 resources.limits + 内核默认(K8s 1.28.9,containerd 1.7.13)
containerd 1.7+ 默认把 memory.high 设成 memory.max,所以默认不生效。要启用,需要:
# 方式 1:用 cgroup 原生(需要 privileged 或 CAP_SYS_ADMIN,不推荐)
# 方式 2:用 K8s 的 memorySwap + memory.high 组合(推荐)
resources:
limits:
memory: "8Gi" # → memory.max
requests:
memory: "6Gi" # → 调度槽位
姿势 B:用 memory.high 的「软限」语义(需要自定义 cgroup 配置)
K8s 1.28 没有原生 memory.high annotation,但可以通过 resources.limits + 内核 cgroup 直接写 来启用(需要容器有 CAP_SYS_ADMIN 或 privileged: true):
# 进入容器后,直接写 cgroup(不推荐生产用,仅演示)
echo 6442450944 > /sys/fs/cgroup/memory.high # 6Gi = 0.75 × 8Gi
姿势 C:用 sidecar 或 init container 设置 memory.high(推荐生产用)
initContainers:
- name: set-memory-high
image: alpine:3.19
command:
- sh
- -c
- |
# 等 cgroup 就绪
sleep 1
# 设置 memory.high = 75% of memory.max
echo "$(( $(cat /sys/fs/cgroup/memory.max) * 75 / 100 ))" > /sys/fs/cgroup/memory.high
echo "memory.high set to $(cat /sys/fs/cgroup/memory.high)"
securityContext:
privileged: true
capabilities:
add: ["SYS_ADMIN"]
姿势 D:用 K8s 的 memorySwap 间接模拟(最实用,推荐)
K8s 1.25+ 支持 resources.limits.memorySwap,可以设置 swap 上限。但这不是 memory.high——memorySwap 控制的是 swap 用量,不是软限。真正的 memory.high 需要 cgroup 原生支持。
最终推荐:在 K8s 1.28 里,最实用的方案是用 resources.limits.memory 做硬限 + 监控 memory.high 的 reclaim 行为,而不是直接写 cgroup。具体:
resources:
limits:
memory: "8Gi" # memory.max
requests:
memory: "6Gi" # 调度槽位
然后加一个 Prometheus 告警,监控 memory.current 接近 memory.high 时的 reclaim 行为:
- alert: MemoryHighReclaimActive
expr: |\
(container_memory_working_set_bytes
/ container_spec_memory_limit_bytes) > 0.75
for: 2m
labels:
severity: warning
这条告警是关键——它直接暴露「memory.high 软限正在生效,reclaim 在跑」,比单看 OOMKilled 早 30 秒。
2. 给 JVM 服务加 -XX:MaxRAMPercentage,让 JVM 自己感知 memory.high
JVM 8u191+ 支持 -XX:MaxRAMPercentage,让 JVM 自动感知 cgroup 的 memory.max 并设置堆大小:
java -XX:MaxRAMPercentage=75.0 \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-jar gateway.jar
关键:MaxRAMPercentage=75 意味着 JVM 堆 = 0.75 × memory.max = 6Gi,给 Metaspace、DirectByteBuffer、线程栈、JIT 代码缓存留 2Gi 余量。这样 JVM 的堆不会顶到 memory.max,给 memory.high 的 reclaim 留空间。
3. 给 Go 服务加 GOMEMLIMIT,让 Go runtime 感知 memory.high
Go 1.19+ 支持 GOMEMLIMIT,让 Go runtime 自动感知 cgroup 的 memory.max 并设置 GC 触发点:
GOMEMLIMIT=6GiB ./gateway
关键:GOMEMLIMIT=6GiB 意味着 Go 的 GC 会在 RSS 接近 6Gi 时触发,给 memory.high 的 reclaim 留空间。
4. 监控 memory.high 的 reclaim 行为(关键)
加一个 cgroup 原生的监控(需要 cAdvisor 或 node_exporter):
# 监控 memory.high 的 reclaim 行为
- alert: MemoryHighReclaimActive
expr: |\
(container_memory_working_set_bytes
/ container_spec_memory_limit_bytes) > 0.75
for: 2m
labels:
severity: warning
这条告警是关键——它直接暴露「memory.high 软限正在生效,reclaim 在跑」,比单看 OOMKilled 早 30 秒。
四、踩过的坑 / 反直觉之处
坑 1:memory.high 不是「软 OOM」,是「背压」
反直觉:很多文档说 memory.high 是「软限」,暗示「超了不杀」。但实际上,如果 reclaim 追不上分配速度,最终仍会顶到 memory.max 触发 OOM。memory.high 只是把 OOM 的「阶跃」变成「渐进」,不保证不 OOM。
教训:不要把 memory.high 当成「安全网」,它只是「减速带」。真正的安全网是 memory.max + 应用层的内存管理。
坑 2:memory.high 有 CPU 开销
反直觉:memory.high 的 reclaim 需要 CPU,不是白捡的收益。我们实测:
- 无 memory.high:P99 延迟 12ms,CPU 稳态 65%;
- 有 memory.high(75%):P99 延迟 14ms(+17%),CPU 稳态 72%(+7 个百分点)。
教训:memory.high 不是免费的——它用 CPU 换内存安全。延迟敏感的服务慎用,批处理 / 离线任务更合适。
坑 3:JVM 的 DirectByteBuffer 不受 memory.high 约束
反直觉:memory.high 约束的是 cgroup 的 memory.current(RSS),但 JVM 的 DirectByteBuffer 是堆外内存,不受 memory.high 直接约束。当 DirectByteBuffer 暴涨时,memory.high 的 reclaim 可能追不上,最终顶到 memory.max 触发 OOM。
教训:JVM 服务必须同时设 -XX:MaxDirectMemorySize,让 DirectByteBuffer 有硬顶,不能只靠 memory.high。
坑 4:K8s 1.28 没有原生 memory.high annotation
反直觉:K8s 1.28 的 resources.limits 只映射到 memory.max,没有原生 memory.high annotation。要启用 memory.high,需要:
- privileged 容器(不推荐生产用);
- sidecar / init container 直接写 cgroup(推荐);
- 等 K8s 1.29+ 的原生支持(K8s 1.29 开始支持
resources.limits.memoryHigh,但尚未 GA)。
教训:在 K8s 1.28 里,最实用的方案是 memory.max + 应用层内存管理,而不是直接写 cgroup。
五、结论与可复用清单
可复用清单
memory.high是软限 + 背压,不是软 OOM:超了不杀,但会 reclaim + 让分配方 sleep;memory.high有 CPU 开销:reclaim 需要 CPU,延迟敏感的服务慎用;- JVM 服务必须同时设
-XX:MaxRAMPercentage+-XX:MaxDirectMemorySize:让堆和 DirectByteBuffer 都有硬顶; - Go 服务必须设
GOMEMLIMIT:让 Go runtime 感知 cgroup 的memory.max; - K8s 1.28 没有原生
memory.highannotation:最实用的是memory.max+ 应用层内存管理; - 监控
memory.current接近memory.max的 reclaim 行为:比单看 OOMKilled 早 30 秒。
落地成本 vs 收益
| 方案 | 落地成本 | 收益 | 适用场景 |
|---|---|---|---|
| memory.max 硬限 | 低(K8s 原生) | 防 OOM,但阶跃式 | 所有服务 |
| memory.high 软限 | 中(需要 sidecar / privileged) | 渐进式减速,但 CPU 开销 | 批处理 / 离线任务 |
| JVM -XX:MaxRAMPercentage | 低(JVM 参数) | 让 JVM 感知 cgroup | JVM 服务 |
| Go GOMEMLIMIT | 低(环境变量) | 让 Go 感知 cgroup | Go 服务 |
结论:memory.high 是「减速带」不是「安全网」。延迟敏感的服务用 memory.max + 应用层内存管理,批处理 / 离线任务可以用 memory.high 做背压。两者不冲突,可以叠加使用。