一、从 cgroup v1 到 v2:架构设计的范式转变

理解 memcg 必须先理解 cgroup 两代架构的本质差异。

cgroup v1 的碎片化设计

在 v1 中,memory 控制器独立于进程层次结构之外,每个 hierarchy 可以挂载独立的 memory 控制器。这种"多挂载点"模型导致资源控制碎片化——同一个进程在不同 hierarchy 中可能受到不同的内存限制,行为难以预测。

关键问题包括:


- memory.limit_in_bytes 与 memory.memsw.limit_in_bytes 分离配置
- 内存统计接口分散在多个文件中
- 层级上限(hierarchy limit)导致子 cgroup 受限于父节点总量
- 无法对 v1 内存控制器做统一的一致性约束

cgroup v2 的统一模型

cgroup v2(Linux 4.5+ 引入,5.15+ 成熟)彻底解决了这些问题:


┌──────────────────────────────────────────────────┐
│                    Root cgroup                    │
│  memory.max = max                                │
│  memory.high = 80%  (软限,触发回收)             │
│  memory.low  = 30%  (保护阈值,尽量不被回收)     │
│  memory.min  = 10%  (硬保护,内核绝不回收)       │
├──────────────────────────────────────────────────┤
│  ├── production.workload                         │
│  │   memory.max = 16G                            │
│  │   memory.high = 12G                           │
│  │   memory.low  = 8G                            │
│  └── batch.jobs                                  │
│      memory.max = 4G                             │
│      memory.weight = 100                         │
└──────────────────────────────────────────────────┘

v2 的核心设计理念是单层次结构统一控制 + 软硬梯度约束,四个关键参数形成完整的内存保护谱系:

参数 含义 触发时机
memory.max 硬上限,触发 cgroup 级别 OOM 内存分配超过该值
memory.high 软上限,触发异步回收 用量超过该值(默认 95% of max)
memory.low 尽力而为的低水位保护 系统内存紧张时保护此 cgroup 不被回收
memory.min 硬保护,内核绝不回收除非整体 OOM 绝对保留量

环境检测与启用


# 检查当前系统使用的 cgroup 版本
stat -fc %T /sys/fs/cgroup
# 输出 cgroup2fs 表示 v2
# 输出 tmpfs 表示 v1

# 检查内核是否支持 cgroup v2 内存控制器
grep CONFIG_MEMCG /boot/config-$(uname -r)
# CONFIG_MEMCG=y
# CONFIG_MEMCG_SWAP=y(v1 交换记账)

# 统计 cgroup v2 内存控制器是否启用
cat /sys/fs/cgroup/cgroup.controllers
# 输出应包含 memory

二、内存计费模型:内核如何计算"内存使用量"

memcg 的计费是整个系统最精妙的部分之一。理解计费逻辑是排障的前提。

计费层次结构

内存用量按以下层次累计:


┌─────────────────────────────────────────────┐
│             memory.current                  │  ← cgroup 总用量
│  ┌───────────────┬───────────────┐          │
│  │ memory.stat   │ 细分统计      │          │
│  │ ├─ file       │ 页缓存        │          │
│  │ ├─ anon       │ 匿名页(堆/栈) │          │
│  │ ├─ kernel_stack│ 内核栈       │          │
│  │ ├─ pagetables │ 页表开销      │          │
│  │ ├─ sock       │ socket 缓冲   │          │
│  │ ├─ shmem      │ 共享内存      │          │
│  │ └─ file_mapped│ 文件映射      │          │
│  └───────────────┴───────────────┘          │
│  ┌──────────────────────────────┐           │
│  │ memory.swap.current          │           │
│  │ ├─ swap                      │           │
│  │ └─ zswap(压缩交换)         │           │
│  └──────────────────────────────┘           │
└─────────────────────────────────────────────┘

关键计费规则

1. 页所有权归属(Page MemCgroup)


// 每个物理页的关联信息
struct page {
    // 一个页只属于一个 cgroup(分配它的那个)
    struct mem_cgroup *mem_cgroup;  
};

// RSS(Resident Set Size)统计的是:
// 该 cgroup 内进程分配且仍在使用的物理页数量
// 注意:共享库只被首次加载者计费

共享内存一个经典的计费陷阱:POSIX shmget()/mmap(MAP_SHARED) 创建的共享内存,只由创建者(或第一次访问者的父进程)计费。这导致一个进程可能"背负"其他进程的共享内存开销。

2. Swap 与内存的统一记账(cgroup v2 新特性)


# cgroup v1 中 swap 和 memory 是两个独立维度
# memory.usage_in_bytes   ← 物理内存
# memory.memsw.usage_in_bytes ← 物理内存 + swap,单独限制

# cgroup v2 统一模型
# memory.current = anon + file + kernel + bounce + swap(压缩交换)
# swap.current = 已交换到磁盘的页

# 配置示例
echo "16G" > /sys/fs/cgroup/workload/memory.max      # 物理内存硬上限
echo "24G" > /sys/fs/cgroup/workload/memory.swap.max  # 物理+swap 总上限

3. 内核内存记账(kmem accounting)

从 cgroup v2 开始,内核内存(slab、per-cdata、内核栈、页表)也纳入统一计费:


# 查看内核内存开销(常常是 OOM 的"隐形杀手")
cat /sys/fs/cgroup/workload/memory.stat | grep -E 'kernel_stack|pagetables|slab|percpu'

# 典型输出
# kernel_stack 258048     (252 KB,通常固定)
# pagetables   8388608    (8 MB,大页表场景下可能很高)
# slab         52428800   (50 MB,高密度网络/存储场景)
# percpu       16777216   (16 MB,大核心数机器上)

实战:memcg 统计接口速查


# 读取 cgroup 总用量
cat /sys/fs/cgroup/workload/memory.current
# 17179869184 (16 GB)

# 详细细分统计
cat /sys/fs/cgroup/workload/memory.stat

# 只关心匿名内存 + 页缓存(最常见的诊断组合)
awk '/anon/ {anon=$2} /file/ {file=$2} END {print "anon:",anon,"file:",file}' \
    /sys/fs/cgroup/workload/memory.stat

# Swap 用量
cat /sys/fs/cgroup/workload/memory.swap.current

三、生产环境配置实战:分层策略设计

场景设定

假设一个典型的生产服务器上运行三类工作负载:


在线服务(Nginx + 业务进程):16GB,不可被驱逐
异步处理(日志聚合、批处理):4GB,系统紧张时可回收
系统守护(sshd、监控 agent):2GB,硬保护

完整的 cgroup v2 配置脚本


#!/bin/bash
# memcg_production_setup.sh
# 在每个节点上执行,建立生产级内存控制拓扑

ROOT_CG="/sys/fs/cgroup"

setup_workload() {
    local name=$1
    local max_mem=$2       # 硬上限
    local high_mem=$3      # 触发回收的软上限
    local low_mem=$4       # 低水位保护
    local min_mem=$5       # 硬保护保留
    local swap_max=$6      # swap 总上限
    
    # 创建 cgroup
    mkdir -p ${ROOT_CG}/${name}
    
    # 关键配置顺序:先设置保护参数,再设置限制参数
    # 顺序很重要——设置 max 会立即触发 OOM 如果当前用量已超限
    
    # 1. 先设置保护参数(这些不会触发 OOM)
    echo "${min_mem}"  > ${ROOT_CG}/${name}/memory.min
    echo "${low_mem}"  > ${ROOT_CG}/${name}/memory.low
    
    # 2. 设置硬上限(必须先确保当前用量低于此值)
    echo "${max_mem}"  > ${ROOT_CG}/${name}/memory.max
    
    # 3. 设置软上限(触发异步回收的水位,必须 <= max)
    echo "${high_mem}" > ${ROOT_CG}/${name}/memory.high
    
    # 4. Swap 限制
    echo "${swap_max}" > ${ROOT_CG}/${name}/memory.swap.max
    
    # 5. 启用 memória 事件通知
    # event_control 监听 memory.usage 增长
    echo "max" > ${ROOT_CG}/${name}/memory.events.local  # 清空旧事件
    
    echo "Configured cgroup: ${name}"
    echo "  current: $(cat ${ROOT_CG}/${name}/memory.current)"
    echo "  max:     $(cat ${ROOT_CG}/${name}/memory.max)"
    echo "  high:    $(cat ${ROOT_CG}/${name}/memory.high)"
}

# 在线服务:高优先级,低延迟要求
echo "max" > ${ROOT_CG}/memory.max
echo "16G" > ${ROOT_CG}/memory.swap.max

setup_workload "production" \
    "16G" \          # memory.max
    "12G" \          # memory.high(80% of max,提前回收避免OOM)
    "8G"  \          # memory.low(保证至少 8GB 可用)
    "4G"  \          # memory.min(硬保护,内核绝不回收)
    "24G"            # memory.swap.max(允许 8GB swap)

# 批处理任务:弹性,可压缩
setup_workload "batch" \
    "4G"  \          # memory.max
    "3G"  \          # memory.high(更早开始回收,更快响应)
    "512M" \         # memory.low(尽力保护 512MB)
    "0"   \          # memory.min(不硬保,生存优先于性能)
    "6G"             # memory.swap.max(允许 2GB swap)

# 系统守护:极小,硬保护
setup_workload "system" \
    "2G"  \          # memory.max
    "1.5G" \         # memory.high
    "1G"  \          # memory.low
    "256M" \         # memory.min(核心守护进程必须有)
    "2G"             # memory.swap.max

OOM 优先级控制:oom.group

cgroup v2 引入了 oom.group 参数,声明当 cgroup OOM 时是否级联杀死整个 cgroup(而非单个进程):


# 为在线服务启用 group OOM(微服务架构中常见)
# 好处:OOM 时整个应用组一起被替换,避免半死不活状态
echo 1 > ${ROOT_CG}/production/oom.group

# 批处理通常不启用(只 kill 最脏的进程)
echo 0 > ${ROOT_CG}/batch/oom.group

四、OOM 行为深度解析与事件监控

cgroup OOM 行为远不止"杀掉进程"这么简单,理解其内部机制对生产排障至关重要。

cgroup OOM 触发路径


┌─────────────────────────────────────────────────────────┐
│               内存分配路径 (alloc_pages)                  │
│                          │                               │
│                   检查 cgroup 限额                        │
│                          │                               │
│              ┌───────────┴───────────┐                   │
│              │  未超限,分配成功       │                   │
│              └───────────┬───────────┘                   │
│                          │ 超限                          │
│              ┌───────────┴───────────┐                   │
│              │  struct oom_control     │                   │
│              │  选择受害者 badness()   │                   │
│              ├─────────────────────────┤                  │
│              │ 评分依据:               │                  │
│              │ 1. 内存占用总量          │                  │
│              │ 2. CPU 时间(长进程更脏)│                  │
│              │ 3. oom_score_adj        │                  │
│              │ 4. 子 cgroup 总内存     │                  │
│              └───────────┬───────────┘                   │
│                          │                               │
│              ┌───────────┴───────────┐                   │
│              │  调用 oom_kill_process │                  │
│              │  发送 SIGKILL         │                  │
│              │  写入 memory.events   │                  │
│              └─────────────────────────┘                  │
└─────────────────────────────────────────────────────────┘

memory.events 监控


# memory.events 是 OOM 监控的核心接口
# 每次 OOM 内核自动递增这些计数器
cat /sys/fs/cgroup/production/memory.events
# low 0
# high 3       ← 3 次触发 soft reclaim(memory.high)
# max 1        ← 1 次触发 OOM
# oom 1        ← 实际执行了 oom kill
# oom_kill 1   ← 杀死了 1 个(组)进程

# memory.events.local 只统计本地 cgroup,不含子 cgroup
cat /sys/fs/cgroup/production/memory.events.local

实战:OOM 事件守护脚本


#!/usr/bin/env python3
"""
oom_monitor.py - 基于 inotify+ePoll 的 cgroup OOM 实时告警
部署为 systemd unit 服务,监控关键 cgroup 的 OOM 事件
"""
import os
import select
import struct
import json
import logging
from pathlib import Path

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("oom-monitor")

CGROUP_BASE = Path("/sys/fs/cgroup")
WATCHED_CGROUPS = ["production", "batch", "system"]

def read_events(cgroup_name):
    """读取并重置 memory.events"""
    events_file = CGROUP_BASE / cgroup_name / "memory.events"
    with open(events_file, 'r') as f:
        lines = f.read().strip().split('\n')
    
    events = {}
    for line in lines:
        if ' ' in line:
            parts = line.split()
            events[parts[0]] = int(parts[1])
    return events

def watch_oom(cgroup_name, threshold=0):
    """使用 inotify 监控 memory.events 文件变更"""
    events_file = str(CGROUP_BASE / cgroup_name / "memory.events")
    
    # 使用 fanotify 或 轮询方式(简化版使用定时检查)
    # 实际生产建议使用 inotify 或 netlink 接口
    import time
    last_state = None
    
    while True:
        current = read_events(cgroup_name)
        if last_state:
            if current.get('oom_kill', 0) > last_state.get('oom_kill', 0):
                logger.critical(
                    f"[OOM KILL] cgroup={cgroup_name} "
                    f"total_kills={current['oom_kill']} "
                    f"current_usage={read_memory_current(cgroup_name)}"
                )
                # 此处可接入 Prometheus/Alertmanager/webhook
            if current.get('max', 0) > last_state.get('max', 0):
                logger.warning(
                    f"[MEM.MAX HIT] cgroup={cgroup_name} "
                    f"current_usage={read_memory_current(cgroup_name)}"
                )
        last_state = current
        time.sleep(5)

def read_memory_current(cgroup_name):
    cur_file = CGROUP_BASE / cgroup_name / "memory.current"
    with open(cur_file, 'r') as f:
        return int(f.read().strip())

if __name__ == "__main__":
    for cg in WATCHED_CGROUPS:
        if (CGROUP_BASE / cg).exists():
            logger.info(f"Watching {cg} for OOM events...")
    # 单进程轮询实现,生产可改为多线程或 asyncio
    import threading
    for cg in WATCHED_CGROUPS:
        if (CGROUP_BASE / cg).exists():
            t = threading.Thread(target=watch_oom, args=(cg,), daemon=True)
            t.start()
    
    # 主线程保持运行
    import signal
    signal.pause()

五、Kubernetes 与 containerd 集成:从 cgroup 到 Pod

Kubernetes 的内存管理最终是通过 cgroup v2 memcg 实现的,理解映射关系对调优至关重要。

Request/Limit → cgroup 参数的映射


# Pod 定义的 memory 字段
resources:
  requests:
    memory: "8Gi"    # → memory.low = 8Gi(尽力保证)
  limits:
    memory: "16Gi"   # → memory.max = 16Gi(硬上限)
K8s 字段 cgroup v2 映射 触发行为
requests.memory memory.low 系统紧张时不低于此值被回收
limits.memory memory.max 超过即触发 cgroup OOM
requests.memory 留空 memory.low = 0 最优先被回收
limits.memory 留空 memory.max = max 不限制,但受节点总内存约束

memory.high 的自动计算(K8s 1.27+)

从 K8s 1.27 开始,当 kubelet 配置为 cgroup v2 模式时:


# kubelet 自动计算 memory.high = memory.max * (1 - systemReserved比例)
# 默认 formula:
# memory.high = max - max * eviction-hard.memory.available阈值
# 
# 例如 memory.max = 16G,eviction-hard 默认阈值 100Mi
# memory.high ≈ 15.9G(接近 max 时触发回收)

containerd 直接操作 cgroup


# 查看容器的实际 cgroup 路径
crictl inspect <container_id> | jq '.info.runtimeSpec.linux.cgroups_path'

# 示例输出:
# "/kubepods.slice/kubepods-burstable.slice/.../docker-<id>.scope"

# 直接调整运行中容器的内存(应急操作,重启后失效)
CONTAINER_CG="/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podXXX.slice/container.scope"
echo "12G" > ${CONTAINER_CG}/memory.high     # 紧急降低回收水位
echo "0"   > ${CONTAINER_CG}/memory.min      # 解除硬保护(谨慎!)

内存 QoS 等级对调优的影响

K8s 中 Pod 的 QoS 等级直接影响 cgroup 行为:


# Guaranteed(request == limit)
# → cgroup: memory.low = X, memory.max = X
# → 最接近 bare-metal 行为,内核绝不对该 cgroup 做非必要回收

# Burstable(只设 limit 或 request < limit)
# → cgroup: memory.low = request, memory.max = limit
# → 系统紧张时可能低于 request 的保护水平被回收

# BestEffort(request/limit 均不设)
# → cgroup: memory.low = 0, memory.max = max
# → 最先被回收,OOM 时第一受害者

六、高级调优:swappiness、脏页控制与回收策略

memory.swappiness 的层级行为

cgroup v2 支持独立的 swappiness 参数,覆盖全局 /proc/sys/vm/swappiness:


# 在线服务几乎禁用 swap(延迟敏感)
echo 0 > /sys/fs/cgroup/production/memory.swap.max
echo 0 > /sys/fs/cgroup/production/memory.swappiness  # 尽量避免交换

# 批处理允许大量 swap
echo 100 > /sys/fs/cgroup/batch/memory.swappiness

# 注意:memory.swappiness=0 在 v2 中含义已从"完全禁止"改为"尽量避免"
# 全局内存不足时仍会交换,如需彻底禁止需设置 memory.swap.max=0

脏页(Dirty Page)控制

脏页写回策略直接影响内存分配压力,cgroup 级别可配置:


# 全局默认值(影响所有未配置 cgroup 的行为)
cat /proc/sys/vm/dirty_ratio          # 默认 20(脏页占总内存 20% 时开始阻塞写)
cat /proc/sys/vm/dirty_background_ratio  # 默认 10(后台写回阈值)

# cgroup v2 中通过 memory.dirty 接口配置(Linux 6.1+)
# echo "1G" > /sys/fs/cgroup/workload/memory.dirty  # cgroup 级脏页上限

# 应急场景:突然大量写入导致内存压力
# 降低脏页比例,让写回更积极,减少内存积压
echo 5 > /sys/fs/cgroup/batch/memory.high  # 更早触发回收

内存压力信息(psi)联动

Pressure Stall Information(PSI)是监控内存压力的最佳手段:


# 读取 cgroup 级别的 PSI 指标(需要内核 4.20+)
cat /sys/fs/cgroup/production/memory.pressure
# some avg10=12.50 avg60=8.30 avg300=5.20 total=123456789
# 'full' avg10=3.80 avg60=2.10 avg300=1.50 total=98765432

# 解读:
# some = 该 cgroup 中至少一个任务因内存被阻塞的比例
# full = 所有任务都因内存被阻塞的比例
# avg10 = 最近 10 秒阻塞占比(如 12.50% = 10 秒内有 1.25 秒在等)

# PSI 告警阈值建议(生产):
# - avg10 > 20% → 触发告警(说明内存压力已影响吞吐)
# - avg60 > 10% → 触发扩容
# - full avg10 > 5% → 紧急告警

七、常见陷阱与故障排查

陷阱 1:memory.current 显示低于 max 但仍然触发 OOM

根因:内核内存(slab、pagetables、kernel_stack)急速增长,但 memory.current 的更新是异步的——页面的 memcg 记账存在延迟。当内核在内存极高压力时分配内核结构(如 TCP socket、页表),可能瞬间突破 max 而统计文件尚未更新。

诊断:


# 查看内核内存占比
cat /sys/fs/cgroup/production/memory.stat | grep -E 'slab|kernel|pagetables'
# 如果 kernel 部分占比 > 5%,说明内核分配是 OOM 元凶

修复:适当加大 max 余量,或使用 memory.oom.group 确保 kill 整个组。

陷阱 2:Swap 风暴导致节点无响应

现象:批处理 cgroup 占满 swap,页面频繁换入换出,iowait 飙升,整个节点卡顿。

诊断:


# 查看 swap IO 速率
sar -W 1 10
# pswpin/s 持续 > 100 说明正在风暴

# 定位哪个 cgroup 在疯狂 swap
for cg in /sys/fs/cgroup/*/memory.swap.current; do
    usage=$(cat $cg 2>/dev/null || echo 0)
    if [ "$usage" -gt 104857600 ]; then  # > 100MB
        echo "$cg: $((usage/1024/1024)) MB"
    fi
done

修复:batch cgroup 设置 memory.swappiness=0 + memory.swap.max=0(即完全不用 swap),让 OOM 杀死批处理而非拖垮节点。

陷阱 3:memory.low 的"竞争饥饿"

现象:多个 cgroup 同时设置了 memory.low,总量超过物理内存。当系统内存紧张时,内核试图同时保护所有 low,结果谁都保护不了。

原理:memory.min 是硬保护(优先保证),memory.low 是尽力保护。超过总能力的 low 保护不会触发错误,但会静默失效。

修复:


# 确认所有 cgroup 的 low 总和不超过物理内存
# 为真正关键的 workload 保留 memory.min
# 只为短期峰值保护的 workload 使用 memory.low

陷阱 4:K8s 中 memory.limit=memory.request 但服务仍被驱逐

根因:K8s 的 eviction 机制独立于 cgroup OOM。当节点整体内存紧张时,kubelet 根据 memory.available 阈值驱逐 Pod,不管 cgroup 是否还能承受。节点级别的驱逐不经过 memcg。

修复:在 kubelet 配置中为关键 Pod 添加优先级或适当配置 systemReserved:


# kubelet-config.yaml
evictionHard:
  memory.available: "500Mi"  # 适当调大,给 memcg OOM 留缓冲
systemReserved:
  memory: "2Gi"             # 保证系统不被驱逐影响

八、性能监控与可观测性架构


# 推荐在生产中部署的监控栈

# 1. node-exporter 收集 node 级别 /proc/meminfo + cgroup 统计
# 2. cAdvisor 以 Prometheus 格式导出 container 级别 cgroup 指标
# 3. 自定义 exporter 读取 memory.current / memory.stat / PSI 文件

# Prometheus 查询示例:

# 内存用量趋势(按 namespace)
histogram_quantile(0.99,
  sum(rate(container_memory_working_set_bytes{container!=""}[5m])) by (namespace, le)
)

# OOM kill 告警
increase(container_oom_events_total[5m]) > 0

# cgroup 内存使用率
container_memory_working_set_bytes / container_spec_memory_limit_bytes > 0.85

总结

memcg 作为 Linux 内存隔离的核心机制,其设计深度远超表面上的"设个上限"那么简单。从 cgroup v2 统一模型的四个梯度约束(min/low/high/max)、内核内存计费陷阱、到与 Kubernetes QoS 的映射关系,每一个环节都直接影响生产系统的稳定性与性能。

关键原则总结:

  1. memory.min 保生存,memory.low 保性能,memory.high 防滑坡,memory.max 保系统
  2. Swap 配置必须分层:对延迟敏感型服务禁用,对吞吐型服务放开
  3. PSI 指标优于单纯看用量:avg10 > 20% 就该扩容
  4. OOM 不是终点而是信号:每次 OOM 都应触发告警和事后分析
  5. K8s 驱逐与 cgroup OOM 是两套独立系统:需分别配置
  6. 掌握这些原则后,面对容器化环境中的内存问题,就能从"凭感觉加 limit"进化到"数据驱动精准调控"的工程水准。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部