eBPF 可观测性入门:从 OOM Killer 监控开始

eBPF(Extended Berkeley Packet Filter)最初是网络包过滤工具,经过近十年发展,已经成为 Linux 内核最强大的可观测性框架。它允许你在不修改内核源码、不加载内核模块的前提下,安全地注入并执行自定义程序。

这篇文章是这个系列的开端——从 OOM(Out-of-Memory)监控这个具体的切入点出发,逐步掌握 eBPF 的核心概念和工具链。

eBPF 为什么适合做可观测性

传统的系统监控工具(如 topfreeps)只能看到最终状态——进程使用了多少内存、系统还剩多少。但很多问题发生在"过程中":进程为什么会 OOM?是哪个进程触发的?OOM 发生时的上下文是什么?

eBPF 的独特优势在于它能挂载到内核的关键路径上,在事件发生的瞬间捕获完整的上下文信息:

  • 安全:eBPF 程序经过 verifier 验证,不会导致内核崩溃
  • 无侵入:不需要重启系统或修改应用程序
  • 高性能:事件在内核态完成初步处理,避免用户态-内核态频繁切换
  • 动态:按需加载和卸载,不做无谓的开销

对比内核模块开发和 eBPF 开发:

维度内核模块eBPF
安全性一个 bug 就能崩掉整个系统Verifier 严格检查,安全沙箱
开发成本需熟悉内核 API,调试困难CO-RE + libbpf,一次编译到处运行
部署需重新编译 / 加载模块动态加载,无需重启
性能直接执行,零额外开销JIT 编译为本机代码,性能接近原生
可编程性完全的内核能力受限(有限循环、有限栈、有限指令数)

先看效果:一行命令监控 OOM

在正式开始之前,我们先跑一个真实的 bpftrace 命令,看看 OOM Killer 事件长什么样:

bash
1
2
3
4
5
6
# 终端 A:启动 OOM 监控(等待事件)
sudo bpftrace -e '
  kprobe:oom_kill_process {
    $task = (struct task_struct *)arg1;
    printf("OOM 杀死了进程: %s (PID: %d)\n", $task->comm, $task->pid);
  }'

这条命令会在 oom_kill_process() 内核函数入口挂载一个探针。当系统发生 OOM 时,它会打印出被杀死进程的 PID 和进程名。

但你需要等一个 OOM 事件发生才能看到输出——这正是大多数新手的困境:“命令跑起来了,但什么也没发生”。下面我们会专门演示如何安全地触发一次 OOM 来观察效果。

除了 bpftrace,BCC 工具集自带的 oomkill 也可以直接使用:

bash
1
sudo /usr/share/bcc/tools/oomkill

输出示例(来自一个实际触发 OOM 的 Java 进程):

1
2
06:13:42  oom-killer  gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=0
06:13:42  Killed process 1234 (java), total-vm:2.5GB, anon-rss:1.8GB

OOM Killer 是什么

当 Linux 系统内存耗尽时,内核的 OOM Killer 会被触发,选择并杀死一个进程来释放内存。OOM Killer 的选择逻辑基于 oom_badness() 函数——一个综合评分算法,考虑进程的内存占用、运行时间、优先级(oom_score_adj)等因素。

OOM 的关键内核函数在 mm/oom_kill.c 中:

c
1
2
3
4
5
6
7
// oom_kill_process — 选中并杀死进程
void oom_kill_process(struct oom_control *oc, struct task_struct *p,
                      const char *message);

// mem_cgroup_out_of_memory — cgroup 级别的 OOM 处理
bool mem_cgroup_out_of_memory(struct mem_cgroup *memcg, gfp_t gfp_mask,
                              int order);

传统 OOM 处理的问题是:你只能通过 dmesgkubectl describe pod 看到 OOM 发生了,但看不到 OOM 前的内存分配趋势、看不到是哪个容器触发的、看不到进程的内存分配热点。eBPF 可以填补这些空白。

环境搭建

开发 eBPF 需要 Linux 内核支持 BTF(BPF Type Format),这已经是主流发行版的标配了:

bash
1
2
3
4
5
6
# 确认内核支持 BTF
cat /boot/config-$(uname -r) | grep CONFIG_DEBUG_INFO_BTF

# 安装工具链(Ubuntu/Debian)
sudo apt install -y llvm clang libbpf-dev linux-tools-common
sudo apt install -y bpfcc-tools bpftrace

推荐内核版本 Linux 5.4+,此时 CO-RE、BTF、BPF 程序指令数限制扩展到 100 万等关键特性都已就绪。

验证安装

跑一个最简单的 bpftrace 命令来确认环境可用:

bash
1
sudo bpftrace -e 'BEGIN { printf("Hello eBPF!\n"); exit(); }'

如果看到 Hello eBPF! 输出,说明环境一切正常。

bpftrace:最快捷的监控方式

bpftrace 是一个基于 eBPF 的高级追踪语言,可以让你用一两行命令就完成内核事件的监控。它是入门 eBPF 最好的起点。

查看可用的探针

在开始写监控命令之前,先看看系统上有哪些 OOM 相关的探针:

bash
1
2
3
4
5
# 列出所有 OOM 相关的 kprobe
bpftrace -l 'kprobe:*oom*'

# 列出所有 OOM 相关的 tracepoint
bpftrace -l 'tracepoint:oom:*'

输出示例:

1
2
3
4
kprobe:oom_kill_process
kprobe:__oom_kill_process
tracepoint:oom:mark_victim
tracepoint:oom:reap_task

使用 tracepoint 监控(推荐)

tracepoint 是内核开发者预埋的稳定追踪点,有稳定的 ABI,应该优先使用:

bash
1
2
3
4
sudo bpftrace -e '
  tracepoint:oom:mark_victim {
    printf("OOM victim 被标记: PID=%d\n", args->pid);
  }'

使用 kprobe 监控(更灵活)

kprobe 可以挂载到任意内核函数上,但依赖内核内部实现。oom_kill_process 的第二个参数是被杀进程的 task_struct

bash
1
2
3
4
5
sudo bpftrace -e '
  kprobe:oom_kill_process {
    $task = (struct task_struct *)arg1;
    printf("OOM! killed=%s pid=%d\n", $task->comm, $task->pid);
  }'

如果要获取被杀进程的内存占用信息,可以从 task_struct 中读取 mm_struct

bash
1
2
3
4
5
6
7
8
9
sudo bpftrace -e '
  kprobe:oom_kill_process {
    $task = (struct task_struct *)arg1;
    $mm = $task->mm;
    printf("OOM! %s (PID: %d) total_vm=%dMB rss=%dMB\n",
      $task->comm, $task->pid,
      ($mm->total_vm * 4096) / 1024 / 1024,
      ($mm->resident_vm * 4096) / 1024 / 1024);
  }'

动手实践:模拟 OOM 观察效果

前面都是"等 OOM 发生才看得到"。这一节教你如何主动触发一次 OOM,亲眼看到 eBPF 的捕获效果。

方法一:stress-ng + Docker(最安全)

用 Docker 限制容器的可用内存,然后在容器内部触发 OOM。这样做不会影响宿主机

bash
1
2
3
4
5
6
7
8
9
# 终端 A:启动 bpftrace 监控
sudo bpftrace -e '
  kprobe:oom_kill_process {
    $task = (struct task_struct *)arg1;
    printf("OOM! killed=%s pid=%d\n", $task->comm, $task->pid);
  }'

# 终端 B:在 Docker 容器内触发 OOM(限制内存 64MB,试图分配 128MB)
docker run --rm -m 64m alpine stress-ng --vm 1 --vm-bytes 128m --timeout 5s

终端 B 中的容器会因内存不足触发 OOM Killer,bpftrace 会在终端 A 中实时捕获并输出。

如果 stress-ng 不在你的源里,可以先用一个简单的 C 程序:

bash
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 终端 B:编译并运行一个内存贪婪程序
cat > /tmp/oom-trigger.c << 'EOF'
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main() {
    while (1) {
        char *p = malloc(10 * 1024 * 1024);  // 每次分配 10MB
        if (!p) break;
        memset(p, 0, 10 * 1024 * 1024);
        sleep(1);
    }
    pause();
    return 0;
}
EOF
docker run --rm -m 64m -v /tmp:/tmp alpine sh -c '
  apk add gcc musl-dev >/dev/null 2>&1 && \
  gcc -o /tmp/trigger /tmp/oom-trigger.c && \
  /tmp/trigger'

方法二:stress-ng 直接运行(谨慎)

如果你在虚拟机或测试机上操作,可以直接在宿主机上使用 cgroup 限制内存:

bash
1
2
3
4
5
6
7
8
9
# 创建 cgroup(cgroup v1)
sudo mkdir -p /sys/fs/cgroup/memory/oom-lab
echo "50M" | sudo tee /sys/fs/cgroup/memory/oom-lab/memory.limit_in_bytes

# 在这个 cgroup 内运行 stress-ng
sudo bash -c '
  echo $$ > /sys/fs/cgroup/memory/oom-lab/cgroup.procs
  exec stress-ng --vm 1 --vm-bytes 100M --timeout 5s
'

预期输出

当你触发 OOM 时,bpftrace 会输出类似以下的信息:

1
2
Attaching 1 probe...
OOM! killed=stress-ng pid=7891

BCC 的 oomkill 工具输出更详细:

1
2
06:13:42  oom-killer  gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=0
06:13:42  Killed process 7891 (stress-ng), total-vm:102.4GB, anon-rss:49.6GB

注意 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 事件。