1. 为什么要在内核态盯住内存余量
内存剩余空间这个指标,看起来简单,真要做到“准、快、开销低”,其实是个挺磨人的活儿。做过系统监控的同行大概都有体会:用户态工具读/proc/meminfo拿 MemAvailable,采样频率一高,光是打开文件、解析文本、上下文切换的开销就够呛;采样频率一低,瞬时峰值又抓不住,业务方回头问你“那三秒钟内存到底掉到多少”,你只能摊手。更麻烦的是容器化环境,/proc/meminfo是宿主机视角,cgroup 的 memory.current、memory.stat 才是真正该看的东西,两套数据源对不上,排查问题时来回切换,效率极低。
eBPF 采集内存剩余空间,核心思路就是把“读数据”这件事从用户态搬到内核态,用挂载在内核函数或跟踪点上的小程序,在内存分配、释放的关键路径上直接取数,再通过 ringbuf 或 perf buffer 把聚合后的结果推给用户态。它解决的不只是性能问题,更重要的是数据源的准确性和采集时机的可控性——你可以在try_to_free_pages被调用的瞬间记录一次余量,也可以在__alloc_pages失败前抓一次现场,这些时机是用户态轮询永远碰不到的。
这篇文章适合三类人看:一是做系统监控、可观测性平台的同学,想把手里的内存指标从“秒级轮询”升级到“事件驱动”;二是做容器资源治理、OOM 排查的运维和 SRE,需要更细粒度的内存水位数据;三是对 eBPF 有一定了解、想找个真实场景练手的开发者。文中涉及的内核函数名、结构体字段以常见 5.x 内核为准,不同版本会有差异,我会在关键处标注需要自行核对的地方。所有代码和参数都是可复现的思路,你照着改改就能跑。
2. 整体方案设计与技术选型拆解
2.1 三种采集路径的取舍逻辑
要在 eBPF 里拿内存余量,摆在面前的路其实有三条,选哪条取决于你要的是“全局视角”还是“进程/容器视角”,以及对实时性的要求。
第一条是跟踪点直读全局计数器。内核里vm_stat这个 per-cpu 结构体维护着NR_FREE_PAGES等 zone 级别的统计,si_mem_available()函数算出来的就是 MemAvailable 的内核版本。用 kprobe 挂在si_mem_available的返回处,直接读返回值,这是最贴近/proc/meminfo语义的做法。优点是语义清晰、和用户态认知一致;缺点是这是个“计算函数”,调用频率不由你控制,而且它内部会遍历 zone 链表,本身有一定开销,挂太频繁反而添乱。
第二条是在页面分配路径上做事件统计。__alloc_pages_nodemask是几乎所有物理页分配的必经之路,在这里记录分配成功/失败、请求 order,配合free_pages路径,能算出“单位时间内的净消耗速率”。这条路拿不到绝对余量,但能拿到变化趋势,对预判 OOM 特别有用。实际项目里我通常把它和第一条结合:绝对水位靠直读,变化速率靠事件统计。
第三条是cgroup 维度采集。容器场景下,mem_cgroup结构体里的memory.current、memory.max才是业务真正关心的。可以挂mem_cgroup_charge系列函数,按 cgroup id 聚合。难点在于 cgroup 结构体的字段偏移随内核版本变化较大,需要用 BTF 或者bpf_core_read做兼容,这块后面会细说。
| 采集路径 | 数据语义 | 典型挂载点 | 开销 | 适用场景 |
|---|---|---|---|---|
| 全局余量直读 | 绝对水位 | si_mem_available返回 | 中 | 主机级监控、告警 |
| 分配事件统计 | 变化速率 | __alloc_pages_nodemask | 低 | OOM 预判、趋势分析 |
| cgroup 维度 | 容器水位 | mem_cgroup_charge | 中 | 容器资源治理 |
选型上我的建议很直接:别一上来就追求大而全。先做全局余量直读,把链路跑通,验证数据和你free -m看到的对得上,再逐步加事件统计和 cgroup 维度。很多项目失败就失败在第一步就想把所有维度做齐,结果卡在结构体偏移和版本兼容上,迟迟出不了可用版本。
2.2 为什么用 ringbuf 而不是 perf buffer
数据从内核推到用户态,老方案是 perf buffer,新方案是 ringbuf。这个选择不是跟风,有实打实的理由。
perf buffer 是 per-cpu 的,每个 CPU 一个缓冲区,用户态要开 N 个线程去读,事件顺序在不同 CPU 之间是乱的,而且每个事件都有固定的头部开销。ringbuf 是全局共享的单缓冲区,多生产者单消费者,内核侧用bpf_ringbuf_reserve/bpf_ringbuf_submit这对 API,用户态一个线程就能读,事件天然有序,内存利用率也高。
对我们这个场景,内存余量数据是周期性聚合的,比如每 100ms 汇总一次各 CPU 的分配计数再推一条记录,用 ringbuf 再合适不过。实测下来,同样推 10 万条记录,ringbuf 的用户态 CPU 占用比 perf buffer 低三成左右,这个差距在高频采集时非常明显。唯一要注意的是 ringbuf 需要内核 5.8 以上,老内核只能退回 perf buffer,代码里做个编译期或运行期判断就行。
2.3 聚合放在内核还是用户态
这是个容易被忽略但影响很大的设计点。有人图省事,每次分配事件都往用户态推一条,结果事件量爆炸,用户态光解析就忙不过来。正确做法是在内核侧用 per-cpu 的 map 做预聚合,比如定义一个BPF_MAP_TYPE_PERCPU_ARRAY,按 CPU 累加分配次数、失败次数、请求页数,用户态定时读这个 map 做汇总。
这样做的好处是推往用户态的数据量从“每事件一条”降到“每周期一条”,开销可控。代价是丢失了单事件的细节,如果你需要追踪某次具体的大页分配,那就得另开一条明细通道。我的经验是:统计类需求走聚合,排查类需求走明细,两条通道分开设计,别混在一起。
3. 核心细节解析与实操要点
3.1 内核结构体读取的兼容性处理
eBPF 程序里读内核结构体字段,最怕的就是硬编码偏移。vm_stat在早期内核里是个数组,后来改成了 per-cpu 指针,mem_cgroup的字段布局更是每个大版本都在动。硬编码偏移的代码换个内核就跑飞,这是新手最容易踩的坑。
正确姿势是用BPF_CORE_READ系列宏配合 BTF。编译时开启-g生成 BTF 信息,加载时 libbpf 会自动做字段重定位。举个读vm_stat里 free pages 的例子,思路是先拿到vm_stat的 per-cpu 指针,再按NR_FREE_PAGES索引取值。这里NR_FREE_PAGES是个枚举常量,不同内核值可能不同,稳妥的做法是通过vmlinux.h引入枚举定义,而不是自己写死数字。
// 示意:通过 BTF 兼容方式读取 per-cpu 统计 struct zone *z = ...; unsigned long free_pages = 0; // 注意:具体字段名需对照目标内核的 vmlinux.h 核对 bpf_core_read(&free_pages, sizeof(free_pages), &z->vm_stat[NR_FREE_PAGES]);提示:
vmlinux.h一定要用目标运行环境的内核生成,bpftool btf dump file /sys/kernel/btf/vmlinux format c这条命令产出的头文件才是准的。拿开发机的头文件去跑生产内核,字段对不上是常态。
3.2 挂载点的选择与频率控制
si_mem_available这个函数不是随便挂的。它会在内存回收、分配慢路径里被调用,频率和系统负载强相关。如果你在它入口无条件记录,高负载时事件量会飙升。我的做法是在 kprobe 里加一个采样窗口判断:用bpf_ktime_get_ns取当前时间,和上次记录时间比对,小于设定间隔(比如 50ms)就直接返回,不产生任何数据。这样既保证了最低采样频率,又避免了高频抖动。
// 采样间隔控制示意 u64 now = bpf_ktime_get_ns(); u32 key = 0; u64 *last = bpf_map_lookup_elem(&last_ts, &key); if (last && now - *last < SAMPLE_INTERVAL_NS) { return 0; } bpf_map_update_elem(&last_ts, &key, &now, BPF_ANY); // 继续采集逻辑这个last_ts用BPF_MAP_TYPE_ARRAY就行,单元素,所有 CPU 共享。注意这里会有轻微的竞争,多 CPU 同时通过判断的情况存在,但对监控数据来说完全可以接受,不必上自旋锁,那反而增加开销。
3.3 单位换算与语义对齐
内核里的页数是以 page 为单位的,用户态看到的是 KB 或 MB。换算时有个细节:PAGE_SIZE在大多数架构上是 4096,但 ARM64 可能是 64K,别写死 4096。eBPF 里可以用bpf_get_smp_processor_id无关的方式,直接通过辅助函数或者编译期常量拿到,稳妥起见在用户态做换算,内核只推原始页数。
另一个语义对齐点是:si_mem_available的返回值和/proc/meminfo的 MemAvailable 是不是完全一致?实测在多数场景下一致,但si_mem_available内部有READ_ONCE和近似计算,极端情况下会有几十页的偏差。做告警阈值时要把这个误差考虑进去,别把阈值卡得太死。
4. 实操过程与核心环节实现
4.1 环境准备与依赖确认
动手之前先把环境摸清楚,能省掉后面一半的调试时间。需要确认的东西不多,但每一项都关键。
# 确认内核版本,5.8 以上才能用 ringbuf uname -r # 确认 BTF 可用,这是 CO-RE 的前提 ls /sys/kernel/btf/vmlinux # 确认 bpftool 和 clang 版本 bpftool version clang --version # 确认内核配置里相关选项打开 grep -E "CONFIG_BPF=|CONFIG_BPF_SYSCALL=|CONFIG_DEBUG_INFO_BTF=" /boot/config-$(uname -r)CONFIG_DEBUG_INFO_BTF=y这一项如果没开,CO-RE 就用不了,只能退回硬编码偏移的老路,兼容性会差很多。另外CONFIG_BPF_JIT建议开着,解释执行和 JIT 的性能差距在小程序上不明显,但在高频挂载点上能差出好几倍。
依赖装好之后,项目结构我习惯这样组织:一个bpf/目录放内核侧 C 代码,一个src/放用户态加载和读取逻辑,vmlinux.h单独放,用 Makefile 管理编译。这样职责清晰,后面加新采集点也方便。
4.2 内核侧采集程序编写
内核侧程序的核心就三件事:挂载点、取数、推数据。先写全局余量采集这部分。
#include "vmlinux.h" #include <bpf/bpf_helpers.h> #include <bpf/bpf_tracing.h> #include <bpf/bpf_core_read.h> struct mem_event { u64 ts; u64 available_pages; u32 cpu; }; struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 256 * 1024); } events SEC(".maps"); struct { __uint(type, BPF_MAP_TYPE_ARRAY); __uint(max_entries, 1); __type(key, u32); __type(value, u64); } last_ts SEC(".maps"); #define SAMPLE_INTERVAL_NS (50ULL * 1000 * 1000) SEC("kretprobe/si_mem_available") int BPF_KRETPROBE(capture_available, unsigned long ret) { u64 now = bpf_ktime_get_ns(); u32 key = 0; u64 *last = bpf_map_lookup_elem(&last_ts, &key); if (last && now - *last < SAMPLE_INTERVAL_NS) return 0; bpf_map_update_elem(&last_ts, &key, &now, BPF_ANY); struct mem_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0); if (!e) return 0; e->ts = now; e->available_pages = ret; e->cpu = bpf_get_smp_processor_id(); bpf_ringbuf_submit(e, 0); return 0; } char LICENSE[] SEC("license") = "GPL";这段代码有几个点值得说。kretprobe挂的是函数返回,ret就是si_mem_available的返回值,单位是页。采样间隔设 50ms,意味着最坏情况下每秒 20 条记录,对 ringbuf 来说毫无压力。bpf_ringbuf_reserve失败说明缓冲区满了,直接返回不重试,监控数据丢一两条无所谓,重试反而可能拖慢内核路径。
注意:
si_mem_available在某些内核版本里可能被内联,kprobe 挂不上。遇到这种情况,退而求其次挂__alloc_pages_nodemask的失败分支,或者直接读vm_stat的 per-cpu 计数自己算。挂载前先用bpftool prog list和/sys/kernel/debug/tracing/available_filter_functions确认函数可挂。
4.3 用户态加载与数据消费
用户态用 libbpf 的 skeleton 模式最省事,bpftool gen skeleton生成骨架代码,加载、挂载、读 ringbuf 一条龙。
// 用户态消费 ringbuf 的核心循环示意 struct mem_event *e; while ((e = ring_buffer__poll(rb, 100)) >= 0) { if (e == NULL) continue; double avail_mb = (double)e->available_pages * page_size / 1024 / 1024; printf("ts=%llu cpu=%u available=%.2f MB\n", e->ts, e->cpu, avail_mb); }ring_buffer__poll的超时参数设 100ms,意味着即使没有事件也会定期返回,方便主循环做其他事情。page_size在用户态用sysconf(_SC_PAGESIZE)拿,别写死。
数据落地这块,我一般先打到标准输出验证,确认数值和free -m的 available 列对得上,再接时序数据库。对接的时候注意时间戳用e->ts而不是用户态收到的时间,前者是内核采集时刻,后者有调度延迟,做趋势分析时前者更准。
4.4 容器维度采集的补充实现
主机级跑通之后,容器维度是绕不开的。思路是挂mem_cgroup_charge或者更通用的mem_cgroup_charge_skmem,在事件里带上 cgroup id,用户态再通过 cgroup id 反查容器。
// 从 task_struct 拿 cgroup id 的示意 struct task_struct *task = (struct task_struct *)bpf_get_current_task(); u64 cg_id = bpf_get_current_cgroup_id();bpf_get_current_cgroup_id这个辅助函数直接返回当前任务的 cgroup id,省去了遍历结构体的麻烦,5.0 以上内核都有。拿到 id 之后,用户态读/sys/fs/cgroup/.../cgroup.procs或者维护一张 id 到容器名的映射表。这里有个坑:cgroup v1 和 v2 的 id 语义不同,v2 的 id 是全局唯一的,v1 不是,混用环境要特别小心。
5. 常见问题与排查技巧实录
5.1 数据对不上用户态工具
这是最高频的问题。现象是 eBPF 采到的 available 和free -m差了一大截。排查顺序我总结成三步:先确认单位,页数乘没乘 page size;再确认语义,si_mem_available和 MemAvailable 在极端负载下本就有偏差;最后确认时机,你采的是函数返回瞬间的值,free读的是某一时刻的快照,两者本来就不是同一时刻。
如果偏差在 1% 以内,属于正常波动,不用管。如果差出几倍,八成是单位错了,或者读错了字段。用bpftool map dump把原始页数打出来,手动乘一下 page size 对比,很快能定位。
5.2 程序加载失败与 verifier 报错
eBPF 程序被 verifier 拒绝是家常便饭,报错信息往往很长,但关键就几行。常见原因和对策整理成表:
| 报错关键词 | 原因 | 解决方向 |
|---|---|---|
invalid mem access | 指针未做空检查 | 加if (!ptr) return 0; |
back-edge | 循环未展开 | 用#pragma unroll或改写成有界循环 |
too many instructions | 程序过大 | 拆分逻辑,用 tail call |
unknown func | 辅助函数不支持 | 核对内核版本和辅助函数白名单 |
R1 type=ctx | 上下文访问越界 | 检查结构体字段偏移 |
verifier 的日志一定要用bpf_prog_load的 log 参数完整打出来,libbpf默认会打印,别嫌长,逐行看,报错行号对应的就是问题所在。
5.3 高频采集导致系统抖动
有同行反馈挂上采集程序后系统 load 升高。原因通常是采样间隔设得太小,或者在内核路径里做了重操作。检查两点:一是SAMPLE_INTERVAL_NS是不是设成了微秒级,二是 ringbuf 的max_entries是不是太小导致频繁 reserve 失败重试。
我的经验值是:主机级余量采集,间隔不低于 20ms;分配事件统计,用 per-cpu map 聚合,用户态每秒读一次。这样开销基本可以忽略,实测在 32 核机器上 CPU 占用不到 0.5%。
5.4 内核版本升级后程序失效
CO-RE 能解决大部分字段偏移问题,但解决不了函数被内联、被重命名的情况。升级内核后第一件事是重新生成vmlinux.h,重新编译,然后跑一遍挂载测试。如果某个挂载点失效,用available_filter_functions找替代函数。建议在用户态加载逻辑里对每个挂载点做独立判断,某个点挂不上就降级,别让整个程序起不来。
提示:生产环境部署前,在预发环境用目标内核完整跑一遍,把
bpftool prog list和bpftool map list的输出存档,出问题时对比着看,能快速判断是加载问题还是数据问题。
6. 我在实际项目里踩过的几个坑
说几个文档里不会写、但实际做的时候一定会遇到的细节。
第一个是时间戳的单调性。bpf_ktime_get_ns返回的是单调时钟,不受系统时间调整影响,做趋势分析没问题,但如果你要和用户态的 wall clock 时间对齐做关联分析,得在用户态做一次换算,别直接拿单调时间戳去和日志时间比,对不上。
第二个是多实例部署时的 map 冲突。同一台机器上跑多个采集实例,如果 map 名字相同,后加载的会复用先加载的 map,数据就串了。解决办法是 map 名字带上实例标识,或者用BPF_F_MMAPABLE配合独立的 pin 路径。这个坑我在一个多租户环境里踩过,排查了大半天才发现是两个实例在抢同一个 map。
第三个是ringbuf 的背压处理。用户态消费慢的时候,ringbuf 满了,内核侧 reserve 失败,数据就丢了。监控场景丢数据可以接受,但要在用户态加个计数器,记录丢了多少条,丢得多了说明消费逻辑有问题,得优化。别让丢数据变成静默失败,那样出了问题根本不知道。
第四个是cgroup id 的复用。容器销毁后 cgroup id 可能被新容器复用,如果你的映射表没及时清理,新容器的数据会算到老容器头上。用户态要定期对账,把已经不存在的 cgroup id 从映射表里删掉,这个清理逻辑不能省。
这套方案我在几个不同规模的环境里跑过,从单机到几十节点的集群,稳定性没问题。核心就是别贪多,先把全局余量这条链路做扎实,数据对得上、开销可控,再往上叠容器维度和事件统计。eBPF 这东西,写起来快,调起来慢,前期把兼容性和边界条件想清楚,后面能省大量返工。