eBPF 可观测性入门:从 OOM Killer 监控开始
eBPF(Extended Berkeley Packet Filter)最初是网络包过滤工具,经过近十年发展,已经成为 Linux 内核最强大的可观测性框架。它允许你在不修改内核源码、不加载内核模块的前提下,安全地注入并执行自定义程序。
这篇文章是这个系列的开端——从 OOM(Out-of-Memory)监控这个具体的切入点出发,逐步掌握 eBPF 的核心概念和工具链。
eBPF 为什么适合做可观测性
传统的系统监控工具(如 top、free、ps)只能看到最终状态——进程使用了多少内存、系统还剩多少。但很多问题发生在"过程中":进程为什么会 OOM?是哪个进程触发的?OOM 发生时的上下文是什么?
eBPF 的独特优势在于它能挂载到内核的关键路径上,在事件发生的瞬间捕获完整的上下文信息:
- 安全:eBPF 程序经过 verifier 验证,不会导致内核崩溃
- 无侵入:不需要重启系统或修改应用程序
- 高性能:事件在内核态完成初步处理,避免用户态-内核态频繁切换
- 动态:按需加载和卸载,不做无谓的开销
对比内核模块开发和 eBPF 开发:
| 维度 | 内核模块 | eBPF |
|---|---|---|
| 安全性 | 一个 bug 就能崩掉整个系统 | Verifier 严格检查,安全沙箱 |
| 开发成本 | 需熟悉内核 API,调试困难 | CO-RE + libbpf,一次编译到处运行 |
| 部署 | 需重新编译 / 加载模块 | 动态加载,无需重启 |
| 性能 | 直接执行,零额外开销 | JIT 编译为本机代码,性能接近原生 |
| 可编程性 | 完全的内核能力 | 受限(有限循环、有限栈、有限指令数) |
先看效果:一行命令监控 OOM
在正式开始之前,我们先跑一个真实的 bpftrace 命令,看看 OOM Killer 事件长什么样:
| |
这条命令会在 oom_kill_process() 内核函数入口挂载一个探针。当系统发生 OOM 时,它会打印出被杀死进程的 PID 和进程名。
但你需要等一个 OOM 事件发生才能看到输出——这正是大多数新手的困境:“命令跑起来了,但什么也没发生”。下面我们会专门演示如何安全地触发一次 OOM 来观察效果。
除了 bpftrace,BCC 工具集自带的 oomkill 也可以直接使用:
| |
输出示例(来自一个实际触发 OOM 的 Java 进程):
| |
OOM Killer 是什么
当 Linux 系统内存耗尽时,内核的 OOM Killer 会被触发,选择并杀死一个进程来释放内存。OOM Killer 的选择逻辑基于 oom_badness() 函数——一个综合评分算法,考虑进程的内存占用、运行时间、优先级(oom_score_adj)等因素。
OOM 的关键内核函数在 mm/oom_kill.c 中:
| |
传统 OOM 处理的问题是:你只能通过 dmesg 或 kubectl describe pod 看到 OOM 发生了,但看不到 OOM 前的内存分配趋势、看不到是哪个容器触发的、看不到进程的内存分配热点。eBPF 可以填补这些空白。
环境搭建
开发 eBPF 需要 Linux 内核支持 BTF(BPF Type Format),这已经是主流发行版的标配了:
| |
推荐内核版本 Linux 5.4+,此时 CO-RE、BTF、BPF 程序指令数限制扩展到 100 万等关键特性都已就绪。
验证安装
跑一个最简单的 bpftrace 命令来确认环境可用:
| |
如果看到 Hello eBPF! 输出,说明环境一切正常。
bpftrace:最快捷的监控方式
bpftrace 是一个基于 eBPF 的高级追踪语言,可以让你用一两行命令就完成内核事件的监控。它是入门 eBPF 最好的起点。
查看可用的探针
在开始写监控命令之前,先看看系统上有哪些 OOM 相关的探针:
| |
输出示例:
| |
使用 tracepoint 监控(推荐)
tracepoint 是内核开发者预埋的稳定追踪点,有稳定的 ABI,应该优先使用:
| |
使用 kprobe 监控(更灵活)
kprobe 可以挂载到任意内核函数上,但依赖内核内部实现。oom_kill_process 的第二个参数是被杀进程的 task_struct:
| |
如果要获取被杀进程的内存占用信息,可以从 task_struct 中读取 mm_struct:
| |
动手实践:模拟 OOM 观察效果
前面都是"等 OOM 发生才看得到"。这一节教你如何主动触发一次 OOM,亲眼看到 eBPF 的捕获效果。
方法一:stress-ng + Docker(最安全)
用 Docker 限制容器的可用内存,然后在容器内部触发 OOM。这样做不会影响宿主机:
| |
终端 B 中的容器会因内存不足触发 OOM Killer,bpftrace 会在终端 A 中实时捕获并输出。
如果 stress-ng 不在你的源里,可以先用一个简单的 C 程序:
| |
方法二:stress-ng 直接运行(谨慎)
如果你在虚拟机或测试机上操作,可以直接在宿主机上使用 cgroup 限制内存:
| |
预期输出
当你触发 OOM 时,bpftrace 会输出类似以下的信息:
| |
BCC 的 oomkill 工具输出更详细:
| |
注意 total-vm 显示的是虚拟内存映射大小(包括被 overcommit 的部分),实际物理内存占用看 anon-rss。
常见问题
Q: 没有任何输出,是不是 bpftrace 没装好?
A: 先跑 sudo bpftrace -e 'BEGIN { printf("OK\n"); exit(); }' 确认 bpftrace 本身正常。如果这个都不输出,重新安装 bpftrace。
Q: Docker 容器内跑 stress-ng 报错?
A: 试试直接 docker run --rm -m 64m alpine sh -c 'dd if=/dev/zero of=/dev/null bs=1M count=200',这个也会触发 OOM。
Q: 我在 WSL2 / macOS 上能跑吗? A: eBPF 需要 Linux 内核。WSL2 可以用(需要 WSL2 内核模式),macOS 不行。建议用 Linux 虚拟机。
核心概念速览
做完上面的动手实践,你已经用到了 eBPF 最重要的几个概念。这里做一个系统梳理。
kprobe / kretprobe
动态内核函数探针。可以在任何内核函数入口(kprobe)和返回(kretprobe)处挂载 eBPF 程序。优势是灵活——只要有内核函数就能挂。劣势是依赖内核内部实现,函数签名可能随内核版本变化。
上面的 kprobe:oom_kill_process 就是典型的 kprobe 用法。
tracepoint
静态内核追踪点。内核开发者在内核源码中预埋的稳定 hook 点,有稳定的 ABI。优先使用 tracepoint,兼容性更好。
tracepoint:oom:mark_victim 就是 OOM 相关的稳定 tracepoint。
BPF Maps
内核态和用户态之间的数据交换通道。常见类型:
BPF_MAP_TYPE_HASH— 键值对存储BPF_MAP_TYPE_PERCPU_HASH— 每 CPU 独立 hash,无锁竞争BPF_MAP_TYPE_RINGBUF— 高性能环形缓冲区,事件流传输BPF_MAP_TYPE_STACK_TRACE— 内核调用栈
后续文章会深入这些 map 类型的实际用法。
CO-RE 与 BTF
CO-RE(Compile Once, Run Everywhere)是 eBPF 能够一次编译到处运行的关键。BTF 编码了内核数据结构的布局信息,eBPF 程序运行时通过 BTF 信息和 BPF_CORE_READ 宏来访问字段,而不是硬编码偏移量。这意味着你在 Ubuntu 22.04 上编译的 eBPF 程序,可以直接在不同内核版本上运行。
从 bpftrace 到正式程序
bpftrace 适合"看一眼就走"的场景。当你需要构建一个持续运行的监控工具时,就需要用完整的 eBPF 库了。
在这一系列中,接下来:
- 下一篇:用 C 编写 eBPF 内核态程序 + Go 用户态加载器,构建完整的 OOM 监控工具
- 第三篇:深入容器和 cgroup 级别的 OOM 定位,用 Rust Aya 实现
- 第四篇:探秘 BPF OOM 内核补丁的演进
小结
这篇文章覆盖了:
- eBPF 为什么适合做可观测性(安全、无侵入、高性能)
- 用 bpftrace 一行命令监控 OOM Killer 事件
- OOM Killer 的基础知识
- 开发环境搭建和验证
- 动手模拟 OOM 并观察 eBPF 的捕获效果
- kprobe、tracepoint、BPF Maps、CO-RE 核心概念
立刻试试:打开两个终端。终端 A 跑 sudo bpftrace -e 'kprobe:oom_kill_process { printf("OOM! %s\n", ((struct task_struct *)arg1)->comm); }',终端 B 跑 docker run --rm -m 64m alpine stress-ng --vm 1 --vm-bytes 128m --timeout 5s。看看终端 A 会不会捕获到 OOM 事件。