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_BTFy这一项如果没开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 callunknown func辅助函数不支持核对内核版本和辅助函数白名单R1 typectx上下文访问越界检查结构体字段偏移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 这东西写起来快调起来慢前期把兼容性和边界条件想清楚后面能省大量返工。
阅读完成 · 觉得有帮助?