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//oom_score_adj 设置关键进程的"免死金牌"。

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 用来做两件关键事:

  1. 给 PSI 监控留出上报与告警的处理时间(典型 5-10s)
  2. 给了一个"降速但不停机"的窗口让业务响应背压

四、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//memory.pressure。

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 个批处理作业(吞吐量优先)。设计控制器目标:

  1. 批处理作业使用 memory.high 获取超过预留上限的"弹性借页"空间。
  2. 当服务容器 memory.some.avg10 > 10 时,控制器下降批处理的 memory.high,释放 page cache 与匿名页给服务。
  3. 当服务容器 memory.some.avg10 < 2 且批处理 memory.high 离 memory.max 较远时,上调批处理的 memory.high,提升集群利用率。
  4. 任何容器的 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 已被压到较低值,说明可能存在:

  1. 某容器 memory.high 配置过高,借走了超过应得份额的内存。
  2. 该容器突发内存需求,突破了 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

参考与延伸阅读

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部