最近社区里最热闹的动静之一,就是茶总那套 memcg BPF hooks。大伙都叫他茶总,不是搞茶饮料的,是常年泡在内存子系统里的内核玩家。他这次把 BPF 的可编程性引到了 memory cgroup 内部,等于给内存记账本上装了几个能让外部策略插手的传感器。如果你天天被容器内存抖动、OOM 重启、memory.high 阈值只能看着不能干预这些问题折磨,这套东西值得你花十分钟看完。它不是一个玄乎的新内核,思路也不复杂:在 memcg 的 charge、uncharge、回收、OOM 这些关键路径上埋 eBPF 挂载点,让运维和内核开发可以跑自己写的 BPF 策略。不管是做实时监控、动态拒绝超大分配,还是给普通进程一个优雅降级的机会,都能在事件发生的当下完成,而不是等用户态轮询 cgroupfs 才发现问题。这篇文章我会从背景、设计、实操到踩坑,完整拆一遍这套 memcg BPF hooks,适合对 eBPF 有基础、或者正在做容器内存治理的同学。
1. memcg 和 BPF hooks:先对个暗号
1.1 memcg 到底在辛苦记账做什么
memory cgroup,缩写就是 memcg,是内核里负责内存账单的子系统的名字。它给每个 cgroup 建账本,记录这个组里的进程到底用了多少内存,并且可以设置限额。cgroup v2 里最常见的是 memory.max 和 memory.high。memory.max 是硬墙,超过了就触发回收,回收不掉就 OOM kill;memory.high 是软墙,只是“尽量收”,超过之后内核会疯狂试图回收,但不会立刻杀进程。
这套机制本身很成熟,问题在于它对外暴露的接口太“哑”了。你想知道一个 cgroup 是不是正在因为 memory.high 反复回收,只能去读 memory.events 看计数器,或者用 PSI 指标猜。可你要想在那个瞬间做点什么,比如主动缩容、给客户端返回 503、切流到备用集群,对不起,内核没给你机会。你只能在用户态轮询,而且轮询周期一长,事件早过去了。这个时候,如果能在 memcg 的路径上挂一段可以执行外部策略的代码,就非常自然。这就是整个 memcg BPF hooks 的切入点。
1.2 “hooks 是什么动作”——一个被热搜带火的词
最近“hooks”这个词在外围圈也很热,比如 codex hooks,大家都在问 hooks 是什么动作。其实它不是一个动作,而是一个“挂钩点”。生活里最常见的例子就是门铃:门铃不决定谁该进门,但它把“有人来了”这个事件暴露给主人;主人可以选择开门、不开门、或者通过猫眼先看一眼。门铃就是 hook,主人的决定就是回调策略。
内核里的 hook 也类似。某个重要事件发生的时候,内核在固定位置预留了一个调用点。默认情况下这个调用点什么都不干,成本几乎为零;但只要有人注册了回调,它就会跳去执行。BPF hooks 就是把回调开放给用户,以 eBPF 程序的形式运行。eBPF 程序必须通过内核验证器检查,不能死循环、不能乱踩内存,所以放在内核里比较安全。codex hooks 是在命令行工具里给外部脚本一个回调机会,memcg BPF hooks 是在内存子系统里给策略程序一个回调机会。同一个思路,不同赛道。
1.3 茶总这一版 hooks 要覆盖哪些关键事件
茶总这次设计里,最核心的是选了四个 hook 点:charge、uncharge、reclaim、oom。
charge 是内存“入账”的动作,一个进程申请页、申请 slab 对象、映射文件页,都会走到这里。uncharge 是对应的“出账”。reclaim 是回收路径,系统发现内存不够了,开始尝试从 cgroup 里回收页面。oom 则是最后一步,回收失败要杀进程了。这四个点基本覆盖了 cgroup 内存从分配、释放到危机的完整生命周期。
选点的逻辑很克制。茶总没有在每个内存函数里都塞 hook,因为那样性能代价不可控。他只在这几个“阶段变化”明显的节点打洞,既能观察到内存使用的快照,又能干预结果。比如在 charge 之前你还有机会拒绝一次过大的申请;在 oom 之前你有机会发送信号让应用自救。这个设计决定了它既能做可观测性,又能做策略干预。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心设计拆解:memcg BPF hooks 怎么把 eBPF 挂进内存路径
2.1 架构概览:一个全新的 BPF 程序类型
要在 memcg 路径上执行 BPF,就得给内核新增一种 BPF 程序类型,常见命名是 BPF_PROG_TYPE_CGROUP_MEMCG。为什么不能直接复用现有的 BPF_PROG_TYPE_CGROUP_SKB 或者 tracing 类型?因为这类程序运行时的上下文完全不一样。
网络路径上的 cgroup BPF 程序拿到的是 skb,你可以检查包的五元组,但拿不到内存申请的大小和 cgroup 的 id。tracing 类程序虽然能任意 kprobe,但你要在 mem_cgroup_charge 函数头尾各打个点,再自己拼上下文,麻烦而且版本兼容性差。茶总做法是直接在 memcg 子系统的核心函数里内嵌一个专用调用点,当 BPF 程序挂载的时候,通过 static_call 机制跳转到一个统一的入口,然后执行 BPF 程序。这个入口的上下文结构体是专门设计的,直接暴露 cgroup id、申请字节数、gfp flags、分配 order 等信息,省去用户自己去猜。
这种设计相当于把“BPF 能干什么”这件事,包装成 memcg 领域语义。程序写起来很简单,不用关心内核内部细节。
2.2 关键 hook 点选型与语义
我把这套补丁里的核心 hook 点整理成了一张表。这里提到的名字是社区讨论时的常见叫法,具体以茶总仓库里的 README 为准,但概念是一致的。
| hook 名称 | 触发路径 | 典型用途 | 语义 |
|---|---|---|---|
memcg/charge |
内存入账前 | 统计/拦截超大申请 | 返回 0 放行;非 0 拒绝本次 charge |
memcg/uncharge |
内存出账时 | 统计释放量 | 返回值暂被忽略 |
memcg/reclaim_begin |
回收开始 | 记录回收压力时序 | 只读上下文 |
memcg/reclaim_end |
回收结束 | 计算回收耗时 | 只读上下文 |
memcg/oom |
OOM 事件触发前 | 发送信号/告警/调整策略 | 返回 0 继续 OOM;非 0 可临时跳过 OOM(慎用) |
我对 oom 这个 hook 特别感兴趣,因为它在现有内核里是没有出口的。OOM killer 一旦启动,基本就是一梭子。如果能在 OOM 触发之前给用户态程序一个 SIGUSR1,让应用自己清理缓存,逻辑就会温柔很多。当然,这是双刃剑:如果你的 BPF 程序写的太慢,或者你选择“不许 OOM”,整个 cgroup 可能直接卡死。
2.3 上下文结构体和返回值设计
为了让 BPF 程序读取事件数据,补丁定义了一个核心结构体。示例长这样(我用常见命名还原,细节可能和正式补丁有差异):
c复制struct memcg_bpf_ctx {
__u64 cgroup_id; // 当前 cgroup v2 id
__u64 size; // charge/uncharge 涉及的字节数
__u64 memcg_id; // 内核内部 memcg id,调试用
__u32 flags; // gfp flags 或事件类型掩码
__s32 order; // 页面分配阶数,0 表示单页
__s32 ret; // hook 返回值,非 0 表示干预
};
cgroup_id 是你在用户态用 cgroup v2 文件系统打开目录时拿到的那个 id,可以通过 bpf_get_current_cgroup_id() helper 对照。size 在 charge 里表示这次申请要入账多少内存,在 uncharge 里表示释放多少。order 是页面分配器的阶数:内存申请常见的是 4KB,也就是 order 0;如果申请 2MB 的 huge page,就是 order 9。
BPF 程序对这个结构体只有读权限。想要干预,只能通过返回值。charge 返回非 0,相当于对这次内存申请说“不”,上层会得到一个 -ENOMEM。目前补丁里这个返回值被设计成直接传递到 memcg 的 try_charge 调用者,所以语义是明确的。
2.4 性能开销:为什么敢把 eBPF 放高频路径
内存 charge 是非常高频的路径,一个繁忙的数据库每秒可能发生几十万次 page fault,每一次都走 memcg。如果在这条路上加一点多余的开销,业务可能直接掉性能。茶总能把这套东西做出来,关键就在于他用了 static_call。
static_call 是一个跳转修补机制。默认情况下,hook 位置是一个 patchable 的 nop 指令,CPU 执行过去相当于什么都没发生。只有当你真的加载了一个 BPF 程序并 attach 到对应 cgroup 时,内核才会把它 patch 成真正的调用跳转。所以没挂 BPF 程序时,性能损耗可以忽略。
这个思路和 kprobe 很不一样。kprobe 哪怕只是插个空探针,也要经过 breakpoint 或 ftrace 机制,开销比一个 nop 大得多。所以这套 memcg hooks 不是简单把 eBPF 塞进热路径,而是先设计好了“零成本路径”,再放开执行能力。实测补丁里给出的数据,未挂载时 malloc/free 基准测试基本没有可测差异。这一点比什么都重要,否则上线就被性能团队给否了。
3. 实操:跑通第一个 memcg BPF hook
3.1 环境准备与内核开启的开关
在看代码之前,环境要先准备好。这套补丁目前不是主线内核的一部分,所以你需要一个能打补丁的内核源码树,建议直接用 6.6 或更新的 longterm 版本。编译内核的时候,以下开关必须打开:
CONFIG_BPF、CONFIG_BPF_SYSCALL:eBPF 基础支持CONFIG_BPF_EVENTS:BPF 事件支持CONFIG_CGROUPS、CONFIG_MEMCG:cgroup v2 内存控制CONFIG_BPF_JIT:BPF JIT 编译,不开的话性能会大打折扣
用户态也需要工具链。clang 版本建议 14 以上,因为新 BPF CO-RE 特性需要 Clang 支持。另外要安装 libbpf-dev 和 bpftool,后面从 ELF 文件生成骨架和加载程序都靠它们。
如果不想自己编译内核,可以先用一个支持 BPF_PROG_TYPE_TRACING 的老办法做临时替代,但那就体会不到新 hook 的语义了。所以我建议还是老老实实编译,反正现在编译内核已经没有以前那么可怕。
3.2 最小 BPF 程序:记录每一次 charge
第一个程序不用干复杂的事,只需要把每次 charge 事件记录到 ring buffer,然后打印出 cgroup id、申请大小和 gfp flags。这个程序可以帮你理解 hook 触发的节奏。
c复制// memcg_hook.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
struct event {
__u64 cgroup_id;
__u64 size;
__u32 flags;
__s32 order;
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 20);
} events SEC(".maps");
SEC("memcg/charge")
int memcg_charge_hook(struct memcg_bpf_ctx *ctx)
{
struct event *e;
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 0;
e->cgroup_id = ctx->cgroup_id;
e->size = ctx->size;
e->flags = ctx->flags;
e->order = ctx->order;
bpf_ringbuf_submit(e, 0);
return 0;
}
char LICENSE[] SEC("license") = "GPL";
编译命令:
bash复制clang -target bpf -g -O2 -c memcg_hook.bpf.c -o memcg_hook.bpf.o
这里 SEC("memcg/charge") 是补丁里约定的 section 名。libbpf 加载的时候会解析这个 section,知道这是要给 memcg/charge hook 用的程序。如果你的编译环境缺少 vmlinux.h,先用 bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h 生成。
3.3 用户态加载与 attach
BPF 程序编译成 ELF 文件之后,最简单的加载方式是 bpftool。先加载程序,再 attach 到你想监控的 cgroup 目录。命令大致是:
bash复制bpftool prog load memcg_hook.bpf.o /sys/fs/bpf/memcg_hook
bpftool cgroup attach /sys/fs/cgroup/my-container prog /sys/fs/bpf/memcg_hook memcg
第一行把程序加载进内核,得到一个 pinned 路径 /sys/fs/bpf/memcg_hook。第二行把它挂到 /sys/fs/cgroup/my-container 这个 cgroup 上,attach 类型是 memcg。注意,attach 是带 subtree 的,也就是说这个 cgroup 下面的所有子 cgroup 内存事件都会触发程序,这通常也是我们想要的。
如果是写正式服务,建议用 libbpf 的 BPF skeleton,加载逻辑藏在 C 代码里,比敲命令行稳定。但调试阶段用 bpftool 是最快的。
3.4 造压验证:在容器里把它跑起来
挂载完之后,怎么知道 hook 真的被触发了?我们用 stress-ng 制造内存压力。先建立一个 cgroup 并限制内存:
bash复制mkdir -p /sys/fs/cgroup/demo
echo 50M > /sys/fs/cgroup/demo/memory.max
echo $$ > /sys/fs/cgroup/demo/cgroup.procs
然后把用户态读取 ring buffer 的程序跑起来,再用 stress-ng 申请内存超过 50M:
bash复制stress-ng --vm 1 --vm-bytes 256M --timeout 5
如果一切正常,你会看到 ring buffer 里疯狂出现 charge 事件,cgroup id 指向 demo,size 从 4KB 到 2MB 不等。这一步能通过,说明整个加载、attach、事件触发的链路是通的。
我自己第一次跑的时候,一个非常隐蔽的问题是 attach 到了 root cgroup,导致 systemd 和各种系统服务的内存事件全部灌进来,ring buffer 一下被打满。所以实验的时候,务必要 attach 到一个干净的专用 cgroup,而不是 root。
4. 三个真实场景的扩展玩法
4.1 用 hook 给 memory.high 做用户态感知
cgroup v2 的 memory.high 是一个软限制,超过后内核会努力回收,但如果你不对它做任何响应,进程可能一直处在“申请内存 -> 触发回收 -> 再申请 -> 再回收”的抖动状态。默认的用户态感知方式是轮询 memory.events 里的 high 计数,等你看到的时候,往往已经抖了几百次。
有了 BPF hook,你可以直接在 charge 里判断:如果当前 cgroup id 等于目标,而且这次 charge 会让用量超过 high 水位,就给用户态发一个信号。BPF 里可以用 bpf_send_signal helper,在 memcg/charge hook 里触发:
c复制SEC("memcg/charge")
int charge_signal_handler(struct memcg_bpf_ctx *ctx)
{
if (ctx->cgroup_id != target_cgroup_id)
return 0;
if (ctx->size >= (256 * 1024))
bpf_send_signal(SIGUSR1);
return 0;
}
用户态收到 SIGUSR1,就知道这个容器内存开始紧张了,可以主动释放缓存、降低并发、走降级逻辑。这个响应的延迟是“事件发生当下”,而不是轮询周期,体感完全是两个时代。
4.2 在 charge 里拦截超大申请
内存问题最讨厌的一种情况是,程序突然向内核申请一个巨大的虚拟内存块,比如几百 MB,然后慢慢碰。默认场景里,这个申请会成功,直到真正分配物理页面触发 memory.max 时,才在 page fault 路径上被 OOM kill。你想拦住你拦不住,因为用户态申请虚拟内存是合法的。
现在你可以在 charge hook 里直接返回非 0,拒绝超过阈值的单次申请。比如:
c复制SEC("memcg/charge")
int charge_reject_hook(struct memcg_bpf_ctx *ctx)
{
if (ctx->cgroup_id == target &&
ctx->size > (4 * 1024 * 1024))
return -ENOMEM;
return 0;
}
这样应用的 mmap 或 malloc 会直接返回失败,而不是等到内存耗尽才被 OOM。这里要特别提醒:返回非 0 是干预性动作,可能让原本能正常运行的程序崩溃。所以在生产环境使用前,一定要先灰度,并且对返回码的影响做好预案。我自己的经验是:宁可慢一点让应用收到信号自己降级,也不要轻易直接拒绝 charge,除非你很清楚业务的行为。
4.3 回收延迟的火焰图与 P99 统计
第三个用法相对温和:把 reclaim_begin 和 reclaim_end 两个 hook 配对,计算一次回收的耗时,记录到 histogram map 里。这样你能知道这个 cgroup 最近回收是快还是慢,是不是已经被 memory.high 卡到黑洞。
BPF 侧可以定义 BPF_MAP_TYPE_PERCPU_ARRAY 存临时时间戳,再定义一个 BPF_MAP_TYPE_HASH 存延迟统计。reclaim_begin 写入时间戳,reclaim_end 用 bpf_ktime_get_ns() 算出差值,丢进 histogram。用户态周期性读取 map,不需要碰 cgroupfs,也不会有轮询盲区。
这套玩法在现网调优时非常有用。我曾经在定位一个抖动问题时,靠打印每 10 秒的回收延迟 P95,直接锁定了是某个 cgroup 的 page cache 反复写入导致的回收风暴,而不是网络或者 CPU 问题。现在有专用 hook,比这种历史方案更省事。
5. 我踩过的坑与排查方法
5.1 验证器报错:memcg context 访问权限
第一次编译 BPF 程序,最常见的是验证器报 invalid mem access。原因通常是程序访问了 memcg_bpf_ctx 结构体里没有暴露的字段,或者你用了解引用方式访问一个潜在空指针。这个上下文和普通函数的 ctx 不一样,它只保证在 hook 入口的那一瞬间是有效的。所以最安全的做法是:只读取结构体本身暴露的字段,不要尝试顺着 cgroup_id 再去查什么内核对象;如果需要额外信息,用配套 helper。
我还见过一个报错:R2 invalid mem access 'map_value_or_null'。这是 ringbuf reserve 返回后你没有判空就直接 submit。在 eBPF 里,bpf_ringbuf_reserve 可能失败返回空,必须先判断再使用。这个很多新手会忽略。
5.2 为什么 hook 静默失效
有时候程序加载成功、attach 也成功,但事件就是不来。我遇到过的几种情况:
- attach 的 cgroup 和实际运行的进程不在同一个 subtree。memcg hooks 和 cgroup skb hooks 一样,只对 attach 点之下的 cgroup 生效。
- 程序里没有主动读 ring buffer,满之后新事件被丢弃,看起来像是“没触发”。检查 ring buffer 的
bpftool map peek或者消费者逻辑。 - 某些内存路径根本不走 memcg charge,比如直接使用
__GFP_ACCOUNT的页面才会计入。你如果制造压力的进程没有关联到 cgroup,自然看不到事件。 - 用了 cgroup v1 的 legacy hierarchy。这套 hooks 只面向 cgroup v2。
如果确认都没有问题,可以打开 bpftool prog tracelog,看 BPF 程序里面有没有丢 log。再加一点打印临时调试。
5.3 性能回归怎么测
性能回归是这类 hooks 上线前必过的关卡。测试方法不复杂:在完全相同的机器上,对同一 benchmark 跑三组数据。第一组是不加载 BPF 程序的基准值,第二组是加载了但 BPF 程序只 return 0 的空载值,第三组是完整业务逻辑的负载值。
推荐用 perf bench 或者 tbench、redis-benchmark 这类能压出内存路径的负载。重点关注 page fault 吞吐和 malloc/free 的 QPS。如果空载值对比基准有明显损耗,先检查你是不是真的用上了 static_call 路径,还是程序编译时被内联到了什么奇怪位置。内核侧可以通过 bpftool prog show 看到 attach 状态;如果 attach 成功后依然有大量 nop patch 的延迟,多半是 JIT 或者指令 cache 的问题。
我拿这套东西做过一轮快速压测,空载 hook 对 page fault 的影响大约在 1%-2% 以内,这个量级在容器内存治理场景里是可以接受的。再往上走,就要反思是不是 hook 点埋得太深、调用太频繁了。
最后再分享一个体会:内核里的策略干预和用户态完全是两个量级。用户态你写错一个分支,最多就是业务崩了重启;内核里你在 charge 路径返回一个错误的错误码,可能让整个机器的进程一起遭殃。所以我的建议是,先用这套 hooks 做监控、做告警、做数据采集,等到对这些事件的规律非常熟了,再考虑做自动化的干预策略。茶总把钩子给你,不代表你要急着把所有策略都塞进去。先让钩子帮你看到过去看不到的东西,这一步的价值已经很大了。
