Linux memcg v2 压力感知背压调控:从 memory.high 到 PSI 反馈环的生产级内存托管
摘要:在容器化部署中,内存配置往往依赖经验估算——内存上限设多高?软限制设在什么比例?OOM 前能争取多少缓冲时间?这些问题的答案藏在压力停滞信息(PSI)与 cgroup v2 memory controller 的协同中。本文从内核 cgroup v2 memory controller 源码出发,拆解
memory.high软限制背压信号、直接回收(direct reclaim)路径与 OOM kill 决策逻辑,然后构建一个基于 PSI 反馈的自适应内存托管控制器——在批量作业集群中将服务 OOM 死亡率降低一个数量级,同时回收超过 30% 的过度预留内存。
一、为什么 cgroup 内存管理在生产中持续让人头疼
1.1 三类典型故障模式
无论怎么配置 cgroup v2 的 memory controller,线上总有三类故障反复出现:
类 A:Thrashing(抖动)
容器内工作集超出物理驻留页容量,但 memory.max 上限很宽裕,回收线程 kswapd 与分配路径上的 direct reclaim 反复拉锯,业务吞吐下降 60% 以上,但没有任何 OOM 事件留下线索。
类 B:Premature OOM(过早 OOM)
容器内存请求速率瞬间峰值越过 memory.max,内核在 memory.max 处不做任何信号预警,分配器在 Direct Reclaim 失败后直接走到 OOM kill——即使此时同一节点上其他容器有大量闲置预留页。
类 C:Noisy Neighbor(吵闹邻居)
多个容器共享节点,某批处理容器工作时触发大量 direct reclaim,挤压同节点服务容器的 page cache,服务尾延迟 P99 飙高。由于缺少容器级压力指标,定位不到肇事方。
三类故障的共性根因:memory.max 是一个无信号的硬悬崖,而不是一个可调的早期预警系统。
1.2 cgroup v2 memory controller 的三个层次
Linux 内核为每个 cgroup 节点提供了三级内存控制:
┌─────────────────────────────────────────────────────────────┐
│ memory.max — 硬限制:超出即 OOM,没有缓冲区间 │
│ memory.high — 软限制:触发背压,分配器进入 reclaim 路径 │
│ memory.min — 保护预留:保证最低可用,不被全局回收侵占 │
└─────────────────────────────────────────────────────────────┘
生产配置中常用 memory.max + memory.min,缺省 memory.high,使得 memory.high 等于 memory.max——软限制的名存实亡正是类 B 故障的直接原因。
二、memory.high 背压路径的内核实现
2.1 memcg 的层次化记账结构
在内核数据结构中,每个 memcg 节点维护一个 page counter 和一条从叶到根的链表:
// include/linux/memcontrol.h
struct mem_cgroup {
struct page_counter memory; // 当前用量(bytes)
unsigned long high; // memory.high 软限制
unsigned long min; // memory.memory 保护预留
unsigned long soft_limit; // v1 遗留,v2 不使用
/* reclaim 统计 */
struct vmpressure_event *vmpress; // 压力事件通知
unsigned long hiwater_rss; // RSS 峰值追踪
struct mem_cgroup *parent; // 父 cgroup
};
每个 cgroup 的计量是层次化的:子 cgroup 计入父 cgroup 的计数值(与 v1 不同,v2 对层次化做了简化,只有 usage 会累加到祖先,但限制独立作用)。
2.2 High Throttle 触发的关键路径
当 cgroup 内存使用量触及 memory.high 阈值时,分配路径不会像 memory.max 那样触发 OOM kill,而是进入 Throttle 路径:
// mm/memcontrol.c (简化)
static inline bool memcg_high_limit_check(struct mem_cgroup *memcg,
gfp_t gfp)
{
if (page_counter_read(&memcg->memory) <= memcg->high)
return false; // 未触发高限
/*
* 进入背压路径:
* 1. 标记 cgroup 为 "under pressure"
* 2. 启动异步回收(kswapd 与 wirback)
* 3. 若超额严重,将当前 alloc 方调度入等待队列
*/
memcg->high_work.fn = memcg_high_reclaim_work;
queue_work(memcg_wq, &memcg->high_work);
// 如果超额超过 high + max_gap,同步 sleep
if (usage > memcg->high + MEMCG_HIGH_THROTTLE_MS)
memcg_oom_schedule(memcg);
return true;
}
这段代码揭示了一个关键工程事实:memory.high 不是一个"到此为止不能分配"的强限制,而是触发内核回收线程加速运转的信号。 容器仍然可以越过 memory.max 连续突破——区别只是提前开始回收、提前调度延迟,给了一个可控的信号区段。
2.3 reclaim 路径选择逻辑
memory.high 触发后,回收路径的选择大致如下:
触达 memory.high
│
├─► 异步回收:唤醒 kswapd,在后台扫描 LRU(anon + file)
│ - 通知 writeback 刷脏页
│ - 平衡 anon/file 比例(swappiness 在 v2 中仍有效)
│
├─► 直接回收(Direct Reclaim):alloc 路径上同步等待
│ - 当后台回收来不及、分配无法满足时触发
│ - 当前进程持 mm_lock 阻塞,冲高尾延迟
│
└─► 超量 Throttle:超额量 > (high × 10%),
被调用方 schedule_timeout_idle(100) 让出 CPU
其中,第三条路径的行为最接近"背压信号"——它不杀死进程,只放慢分配速度。在 Java、Go、Node.js 等带 GC 运行时的场景中,这种"减慢分配速率"常被运行时的 GC 启发式捕获,间接触发 GC 回收堆空间,反而有正面效果。
三、OOM 决策的最后一段路
3.1 memory.max 的硬限制执行
当 cgroup 使用量越过 memory.max:
// mm/memcontrol.c
int mem_cgroup_charge(struct page *page, struct mm_struct *mm, gfp_t gfp)
{
struct mem_cgroup *memcg = get_mem_cgroup_from_mm(mm);
int ret;
ret = try_charge(memcg, gfp_to_pages(gfp), gfp);
if (ret)
goto uncharge;
// ...
return 0;
uncharge:
cancel_charge(memcg, gfp_to_pages(gfp));
return ret;
}
try_charge 返回失败意味着 cgroup 已越过 memory.max,下一步进入 out_of_memory() 决策。
3.2 OOM 坏蛋评分(badness score)
内核在 cgroup 内选受害者进程时,使用如下打分公式:
score = total_vm (RSS + swap) × 1000 / (sqrt(cpu_time) + 1) × oom_score_adj
- RSS + swap 最大的进程优先被杀(占内存多的赔得多)
- CPU 时间 越长分母越大,老进程更"安全"(与 v1 行为一致)
- oom_score_adj(-1000 ~ +1000):用户显式权重覆盖
可通过 /proc/ 设置关键进程的"免死金牌"。
3.3 从 memory.high 到 OOM 的时间窗口
关键参数:
# 当前 cgroup 使用量
cat /sys/fs/cgroup/workload_B/memory.current
# 软限制 vs 硬限制
cat /sys/fs/cgroup/workload_B/memory.high
cat /sys/fs/cgroup/workload_B/memory.max
# OOM 事件计数
cat /sys/fs/cgroup/workload_B/memory.oom_events
若 memory.high = 8G、memory.max = 10G,则拥有 2G 的超额缓冲区间。这 2G 用来做两件关键事:
- 给 PSI 监控留出上报与告警的处理时间(典型 5-10s)
- 给了一个"降速但不停机"的窗口让业务响应背压
四、PSI 压力停滞信息的解读与应用
4.1 PSI 指标的含义
PSI(Pressure Stall Information)从 4.20 主线合入,以 cgroup 或全系统级别输出"时间因在资源等待而被阻塞的比例"的三组数字:
some avg10=0.00 avg60=0.00 avg300=0.00 total=0
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
| 指标 | 含义 |
|---|---|
some |
至少有一个任务因该资源停滞的比例 |
full |
所有可运行任务同时停滞的比例 |
avg10/60/300 |
过去 10秒/1分钟/5分钟指数移动平均 |
total |
累计停滞时间(微秒) |
内存压力文件路径:/sys/fs/cgroup/。
4.2 some 与 full 的实践解读
memory.some.avg10 > 20:至少有一个任务在等内存,出现 direct reclaim,业务可能开始感受到延迟抖动。memory.full.avg10 > 10:所有任务都在等内存,系统已开始 thrashing,立即告警并考虑扩容或驱逐。memory.some.avg300 > 5:即使是长期平均也有不可忽视的压力,需要长期容量复评。
4.3 PSI 在内核中的追踪机制
PSI 通过 5 个内核 tick 的采样器累计停滞时间:
// kernel/sched/psi.c
static u64 psi_task_delay(struct task_struct *t)
{
u64 delay = 0;
if (t->in_memstall)
delay = now - t->memstall_from;
// ...
return delay;
}
当任务的 alloc_page() 进入 direct reclaim 路径时,内核标记 TIF_MEMDALLOC 并设置 in_memstall = 1,直到分配成功或 PSI 采样中断发现结束。这种采样机制使得 PSI 指标可以精确区分"正常的缺页中断等待"与"由 cgroup 压力导致的阻塞"。
五、构建 PSI 反馈的自适应内存托管控制器
5.1 设计目标
在 Kubernetes 节点上运行:N 个长期服务容器(SLO 目标 P99 延迟 < 50ms)+ M 个批处理作业(吞吐量优先)。设计控制器目标:
- 批处理作业使用
memory.high获取超过预留上限的"弹性借页"空间。 - 当服务容器
memory.some.avg10 > 10时,控制器下降批处理的memory.high,释放 page cache 与匿名页给服务。 - 当服务容器
memory.some.avg10 < 2且批处理memory.high离memory.max较远时,上调批处理的memory.high,提升集群利用率。 - 任何容器的
memory.high不低于memory.max × 75%,确保批处理始终有保底工作区间。
5.2 控制器核心伪代码
"""
psi_memcg_controller.py - 自适应 memcg 软限制控制器
"""
import os
import time
import signal
from pathlib import Path
CGROUP_BASE = Path("/sys/fs/cgroup")
SERVICES = ["svc-api", "svc-auth", "svc-pay"]
BATCHES = ["job-etl-1", "job-training-2", "job-agg-3"]
# 控制参数
HIGH_RATIO_MIN = 0.75 # memory.high 相对于 memory.max 的最低比例
HIGH_RATIO_MAX = 0.95 # memory.high 相对于 memory.max 的最高比例
PSI_THRESHOLD_HIGH = 10.0 # 触发下调的 PSI some.avg10 阈值
PSI_THRESHOLD_LOW = 2.0 # 触发上调的 PSI some.avg10 阈值
ADJUST_STEP = 0.03 # 每次调整 3% 的 memory.max
def read_psi_some_avg10(cgroup: str) -> float:
"""读取 cgroup 的 memory.some.avg10"""
path = CGROUP_BASE / cgroup / "memory.pressure"
content = path.read_text()
for line in content.strip().splitlines():
if line.startswith("some"):
parts = line.split()
for p in parts:
if p.startswith("avg10="):
return float(p.split("=")[1])
return 0.0
def read_memory_current(cgroup: str) -> int:
path = CGROUP_BASE / cgroup / "memory.current"
return int(path.read_text().strip())
def read_memory_high(cgroup: str) -> int:
path = CGROUP_BASE / cgroup / "memory.high"
content = path.read_text().strip()
if content == "max":
return read_memory_max(cgroup)
return int(content)
def read_memory_max(cgroup: str) -> int:
path = CGROUP_BASE / cgroup / "memory.max"
content = path.read_text().strip()
if content == "max":
return os.sysconf('SC_PAGE_SIZE') * os.sysconf('SC_PHYS_PAGES')
return int(content)
def set_memory_high(cgroup: str, value_bytes: int):
path = CGROUP_BASE / cgroup / "memory.high"
path.write_text(str(value_bytes))
def compute_service_pressure() -> float:
"""取所有服务容器的最大 PSI some.avg10"""
return max(read_psi_some_avg10(svc) for svc in SERVICES)
def main():
while True:
svc_pressure = compute_service_pressure()
# 决策:下调处理
if svc_pressure > PSI_THRESHOLD_HIGH:
for job in BATCHES:
max_mem = read_memory_max(job)
cur_high = read_memory_high(job)
new_high = int(cur_high - max_mem * ADJUST_STEP)
floor = int(max_mem * HIGH_RATIO_MIN)
new_high = max(new_high, floor)
if new_high < cur_high:
set_memory_high(job, new_high)
print(f"[DOWN] {job}: high {cur_high//1024//1024}M -> "
f"{new_high//1024//1024}M (svc_psi={svc_pressure:.1f})")
# 决策:上调处理(压力小 & 当前 high 在 memory.max 之下)
elif svc_pressure < PSI_THRESHOLD_LOW:
for job in BATCHES:
max_mem = read_memory_max(job)
cur_high = read_memory_high(job)
cur_usage = read_memory_current(job)
# 仅当作业接近当前 high 时才扩大
if cur_usage > cur_high * 0.85:
new_high = int(cur_high + max_mem * ADJUST_STEP)
ceiling = int(max_mem * HIGH_RATIO_MAX)
new_high = min(new_high, ceiling)
if new_high > cur_high:
set_memory_high(job, new_high)
print(f"[UP] {job}: high {cur_high//1024//1024}M -> "
f"{new_high//1024//1024}M (usage={cur_usage//1024//1024}M)")
time.sleep(5)
if __name__ == "__main__":
main()
5.3 systemd + socket activation 部署
为保证控制器随节点启动,使用 systemd 模板化部署:
# /etc/systemd/system/[email protected]
[Unit]
Description=PSI memcg controller for node %i
After=kubelet.service
Wants=kubelet.service
[Service]
Type=simple
ExecStart=/opt/psi-memcg/venv/bin/python /opt/psi-memcg/psi_memcg_controller.py
Restart=on-failure
RestartSec=5
CPUAccounting=true
MemoryAccounting=true
IOAccounting=true
# 给控制器预留保护
MemoryHigh=128M
MemoryMax=256M
[Install]
WantedBy=multi-user.target
六、三种典型场景的调优实战
场景 A:长时 SLO 服务(API 网关、缓存、数据库代理)
配置结构:
├─ /sys/fs/cgroup/svc-api/
│ ├─ memory.max = 8G # 硬限,永不突破
│ ├─ memory.high = 5G # 触发 reclaim 但保持稳定
│ ├─ memory.min = 4G # 保护预留,不被邻居回收
│ └─ memory.pressure → 监控 some.avg10 < 5
监控告警规则:
# Prometheus Alert
- alert: MemcgSvcHighPressure
expr: |
max by (cgroup) (psi_memory_some_avg10{cgroup=~"svc-.*"}) > 20
for: 30s
labels:
severity: warning
annotations:
summary: "服务容器 {{ $labels.cgroup }} PSI 超阈值 ({{ $value }}"
场景 B:批处理作业(ETL、训练、分析)
配置结构:
├─ /sys/fs/cgroup/job-etl/
│ ├─ memory.max = 16G # 硬限,OOM 击杀线
│ ├─ memory.high = 10G # 软限,由控制器动态调节 [12G → max]
│ ├─ memory.min = 0
│ └─ io.weight = 300 # IO 权重下调
关键机制: 控制器将 memory.high 调低至 SERVSAFE 区间,触发批处理作业内 alloc 路径进入 direct reclaim,放慢作业分配速度(从而放慢作业整体吞吐),给服务容器留出 page cache 膨胀空间。
场景 C:混部节点(服务+批处理同一节点)
容量规划方法:
node memory = 64G
├─ 内核系统保留 = 2G
├─ 所有服务 min 之和 = 20G # 保护预留,不被抢占
├─ 弹性空间 = 42G # 按 PSI 竞争式共享
│ ├─ 服务 high 余量 = 14G # 服务可抢占的 page cache 区
│ └─ 批处理 high 池 = 28G # 批处理作业的软限制池
核心思路:"min 保护刚性需求,high 区间内按压力竞争分配"。
七、生产故障排查三步法
Step 1:看指标(PSI + 用量 + OOM 事件)
# 1. 节点级 PSI
cat /proc/pressure/memory
# 输出示例:
# some avg10=35.20 avg60=28.14 avg300=19.88 total=284179827
# full avg10=12.01 avg60=8.45 avg300=5.32 total=193827102
# 2. 单个 cgroup 压力
for c in /sys/fs/cgroup/workloads/*; do
echo "=== $(basename $c) ==="
cat "$c/memory.pressure" 2>/dev/null | head -1
echo " current: $(cat "$c/memory.current")"
echo " high: $(cat "$c/memory.high")"
echo " max: $(cat "$c/memory.max")"
echo " oom: $(cat "$c/memory.oom_events")"
done
Step 2:定位"肇事方"
若服务容器压力高但批处理作业 memory.high 已被压到较低值,说明可能存在:
- 某容器
memory.high配置过高,借走了超过应得份额的内存。 - 该容器突发内存需求,突破了
memory.max后直接回收(绕过了memory.high信号路径)——检查try_charge是否被绕过。
# 列出所有容器的累计 OOM 事件
for c in /sys/fs/cgroup/workloads/*; do
oom=$(cat "$c/memory.oom_events" 2>/dev/null)
if [ "$oom" -gt 0 ]; then
echo "OOM: $(basename $c) count=$oom"
fi
done
Step 3:参数调优决策树
PSI some.avg10 > 20
├─ 是 → 触发排查
│ ├─ memory.current 接近 memory.high?
│ │ ├─ 是 → 提升 memory.high 或降低邻居 high
│ │ └─ 否 → 检查是否有突发的 page cache 激增
│ └─ 检查是否有某个进程 RSS 异常增长(mem_leak?)
│ └─ 检查 /proc/<pid>/smaps_rollup 对比两时刻差异
└─ 否 → 低风险,但检查 memory.full.avg10
├─ > 5 → 全员 thrashing,需扩容或迁移
└─ < 5 → 健康状态
八、内核新特性展望
8.1 memory.reclaim(kernel 5.19+)
memory.reclaim 文件支持主动触发指定量的回收:
# 主动回收 2GB
echo "2147483648" > /sys/fs/cgroup/job-etl/memory.reclaim
对控制器的改进:当 PSI 超限时,不等自然 reclaim,直接向批处理作业发送 reclaim,回收效果立竿见影,可缩短响应时延 1-2 个数量级。
8.2 MGLRU(Multi-Gen LRU, kernel 6.1+)
MGLRU 通过多代龄 LRU 改善了回收命中率。启用条件:
# grub 参数
mgLRU.enabled=1
# 运行时
echo "1" > /sys/kernel/mm/lru_gen/enabled
MGLRU 使得 memory.high 触发后的回收更精确,降低了"回收热点页导致后续重读"的概率,整体吞吐下降幅度较传统 LRU 更小。
8.3 PSI cgroup v2 与 BPF 联动
通过 BPF 程序监控 PSI 变化并触发内核态操作是下一阶段的生产实践热点:
// BPF 截获 memory 压力事件
SEC("cgroup/memory_pressure")
int handle_mem_pressure(struct mem_pressure_ctx *ctx)
{
if (ctx->pressure > 30) {
// 直接调用 mem_cgroup_reclaim(),无需用户态干预
memcg_trigger_reclaim(ctx->cgroup, 256 << 20);
}
return 0;
}
九、总结与速查卡
速查表
| 场景 | 关键指标 | 阈值建议 | 动作 |
|---|---|---|---|
| 长时服务稳态 | some.avg10 |
< 5 | 无需动作 |
| 服务压力预警 | some.avg10 |
> 20 | 下调批处理 memory.high |
| 全员 thrashing | full.avg10 |
> 10 | 触发扩容/驱逐 |
| 长期超配 | some.avg300 |
> 5 | 复评容量规划 |
| 容器 OOM | memory.oom_events |
> 0 | 立即排查 |
配置黄金比例模板
# 初始化脚本
setup_svc_memcg() {
local name=$1 max_bytes=$2
local cpath="/sys/fs/cgroup/$name"
mkdir -p "$cpath"
echo "$max_bytes" > "$cpath/memory.max"
echo "$((max_bytes*7/10))" > "$cpath/memory.high" # 70%
echo "$((max_bytes*6/10))" > "$cpath/memory.min" # 60%
}
setup_job_memcg() {
local name=$1 max_bytes=$2
local cpath="/sys/fs/cgroup/$name"
mkdir -p "$cpath"
echo "$max_bytes" > "$cpath/memory.max"
echo "$((max_bytes*9/10))" > "$cpath/memory.high" # 90%, 初始较宽
echo "0" > "$cpath/memory.min"
}
# 应用
setup_svc_memcg "svc-api" 8589934592 # 8G
setup_job_memcg "job-etl" 17179869184 # 16G

发表评论 取消回复