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

发表评论 取消回复