bpftrace 适合快速探查和临时调试,但如果要构建持续运行的生产级监控工具,就需要完整的 eBPF 程序了。典型的架构分两层:
- 内核态:用 C 编写 eBPF 程序,挂载到 hook 点,采集事件数据
- 用户态:用 Go(或 Rust / libbpf C)编写 loader,加载 eBPF 程序并读取事件
这个模式在行业里已经非常成熟——Pixie、Parca、Cilium 等开源项目都遵循这个架构。
整体架构
flowchart LR
classDef kern fill:#E3F2FD,stroke:#1565C0,color:#1565C0
classDef user fill:#FFF3E0,stroke:#E65100,color:#BF360C
classDef data fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20
subgraph kernel["内核态"]
hook@{ shape: rounded, label: "oom_kill_process\\(kprobe\\)" }
ebpf@{ shape: proc, label: "eBPF 程序\n事件采集" }
ring@{ shape: cyl, label: "Ring Buffer" }
end
subgraph userspace["用户态 (Go)"]
loader@{ shape: notch-rect, label: "bpf2go loader\n加载 + 挂载" }
reader@{ shape: proc, label: "RingBuf Reader\n事件解析" }
end
hook -->|触发| ebpf -->|写入| ring
ring -->|读取| reader
loader -.->|加载| ebpf
class hook,ebpf,ring kern
class loader,reader user
内核态和用户态通过 BPF Ring Buffer 通信。事件发生时,eBPF 程序在中断上下文完成数据采集和写入,用户态程序异步读取。
eBPF 内核态程序(C)
将 eBPF C 程序命名为 oom_kprobe.bpf.c——bpf 后缀是 cilium/ebpf 的约定,告诉 bpf2go 代码生成器这是 BPF 源代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| //go:build ignore
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
char LICENSE[] SEC("license") = "Dual BSD/GPL";
// OOM 事件结构
struct oom_event {
u32 pid; // 被杀进程 PID
u32 tgid; // 被杀进程 TGID
u64 fpid; // 触发 OOM 的进程 PID
long pages; // 涉及的内存页数
char comm[TASK_COMM_LEN]; // 被杀进程名
char fcomm[TASK_COMM_LEN]; // 触发进程名
u64 timestamp; // 时间戳 (ns)
};
// Ring Buffer 定义 — 16MB
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");
// kprobe 挂载到 oom_kill_process
SEC("kprobe/oom_kill_process")
int BPF_KPROBE(oom_kill_process, struct oom_control *oc,
struct task_struct *p, const char *message)
{
struct oom_event *event;
event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event)
return 0;
// 读取被杀进程信息
event->pid = BPF_CORE_READ(p, pid);
event->tgid = BPF_CORE_READ(p, tgid);
bpf_probe_read_kernel_str(&event->comm, sizeof(event->comm),
BPF_CORE_READ(p, comm));
// 读取触发 OOM 的进程
struct task_struct *fp = BPF_CORE_READ(oc, chosen);
if (fp) {
event->fpid = BPF_CORE_READ(fp, tgid);
bpf_probe_read_kernel_str(&event->fcomm, sizeof(event->fcomm),
BPF_CORE_READ(fp, comm));
}
event->pages = BPF_CORE_READ(oc, totalpages);
event->timestamp = bpf_ktime_get_ns();
bpf_ringbuf_submit(event, 0);
return 0;
}
|
关键点解释:
BPF_KPROBE 宏自动处理 kprobe 参数的读取,不需要手动从 struct pt_regs 中解包BPF_CORE_READ 通过 BTF 信息访问内核数据结构,不依赖硬编码偏移——这就是 CO-RE 的精髓bpf_ringbuf_reserve + bpf_ringbuf_submit 是无锁的生产者-消费者模式,事件提交后用户态能立刻读到vmlinux.h 由 bpftool btf dump 生成,包含了当前内核所有数据结构的定义
Go 用户态程序
用 cilium/ebpf 库提供的 bpf2go 代码生成器:
1
2
3
| //go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 \
// -target bpfel -type oom_event oom_kprobe oom_kprobe.bpf.c \
// -- -I../headers -O2 -g -D__TARGET_ARCH_x86
|
bpf2go 会读取 C 代码,解析其中的 SEC 标记和 map 定义,自动生成对应的 Go 结构体和加载代码。-type oom_event 让它为我们的 C 结构体生成 Go 版本。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
| package main
import (
"encoding/binary"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/rlimit"
)
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 \
// -target bpfel -type oom_event oom_kprobe oom_kprobe.bpf.c \
// -- -I../headers -O2 -g -D__TARGET_ARCH_x86
func main() {
// 1. 提升 rlimit(eBPF 需要锁定内存)
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal(err)
}
// 2. 加载 eBPF 对象
objs := oom_kprobeObjects{}
if err := loadOom_kprobeObjects(&objs, nil); err != nil {
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()
// 3. 附加 kprobe
kp, err := link.Kprobe("oom_kill_process", objs.OomKillProcess, nil)
if err != nil {
log.Fatalf("opening kprobe: %v", err)
}
defer kp.Close()
// 4. 创建 Ring Buffer 读取器
rd, err := ringbuf.NewReader(objs.OomKprobeMaps.Events)
if err != nil {
log.Fatalf("opening ringbuf reader: %v", err)
}
defer rd.Close()
// 5. 信号处理(优雅退出)
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
go func() {
<-stop
rd.Close()
}()
log.Println("OOM monitor started. Press Ctrl+C to exit.")
// 6. 事件循环
var event oom_kprobeOomEvent
for {
record, err := rd.Read()
if err != nil {
if err == ringbuf.ErrClosed {
return
}
log.Printf("reading from ringbuf: %v", err)
continue
}
if err := binary.Read(record.Reader, binary.LittleEndian, &event); err != nil {
log.Printf("parsing event: %v", err)
continue
}
fmt.Printf("\n[%s] OOM KILL DETECTED\n",
time.Now().Format("15:04:05"))
fmt.Printf(" Killed: PID=%d COMM=%s\n",
event.Pid, trimNull(event.Comm[:]))
fmt.Printf(" Trigger: PID=%d COMM=%s\n",
event.Fpid, trimNull(event.Fcomm[:]))
fmt.Printf(" Pages: %d (%.2f MB)\n",
event.Pages, float64(event.Pages)*4/1024)
}
}
func trimNull(b []byte) string {
for i, c := range b {
if c == 0 {
return string(b[:i])
}
}
return string(b)
}
|
编译和运行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| # 生成 bpf2go 的 Go 代码
go generate ./...
# 编译
go build -o oom-monitor .
# 运行(需要 root 权限)
sudo ./oom-monitor
# 另一个终端触发 OOM(注意:会消耗大量内存,建议在空闲系统上运行)
sudo stress-ng --vm 1 --vm-bytes 80% -t 30s
# 安全替代方案:使用 Docker 限制内存(需要 Docker)
# docker run --rm -m 64m ubuntu:22.04 bash -c "apt-get update -qq && apt-get install -y -qq stress-ng && stress-ng --vm 1 --vm-bytes 50m -t 10s"
sudo stress-ng --vm 1 --vm-bytes 80% -t 30s
|
编译好的二进制是自包含的——BPF 字节码通过 go:embed 打包进了 Go 程序里。
预期输出
程序运行后,当系统触发 OOM 时(通过上述 stress-ng 或 Docker 模拟),会看到类似下面的输出:
1
2
3
4
5
6
7
8
9
| [14:30:25] OOM KILL DETECTED
Killed: PID=12345 COMM=stress-ng
Trigger: PID=9876 COMM=oom-monitor
Pages: 262144 (1024.00 MB)
[14:30:26] OOM KILL DETECTED
Killed: PID=12346 COMM=stress-ng
Trigger: PID=9876 COMM=oom-monitor
Pages: 131072 (512.00 MB)
|
如果长时间没有输出,说明内核没有触发 OOM——可以调高 stress-ng 的内存比例或关掉部分占用内存的应用。
扩展方向
添加更多 hook 点
OOM 监控不仅仅限于 oom_kill_process。将多个内核 hook 点的数据关联起来,可以获得更完整的图景:
1
2
3
4
5
6
7
8
9
10
11
12
13
| // 捕获容器级 OOM
SEC("kprobe/mem_cgroup_out_of_memory")
int BPF_KPROBE(memcg_oom, struct mem_cgroup *memcg, gfp_t gfp_mask, int order)
{
// 采集 cgroup ID、容器内存限制、当前内存使用量
}
// 内存压力事件
SEC("tracepoint/psi/memory_stall")
int trace_memory_stall(struct trace_event_raw_psi_group *ctx)
{
// PSI 压力超过阈值时提前预警
}
|
用户态增强
- Prometheus Exporter:将 OOM 事件转为 Counter 和 Gauge 指标
- 容器关联:读取
/proc/<pid>/cgroup 将 OOM 事件映射到 Kubernetes Pod - 事件持久化:写入 ClickHouse / Loki 等存储用于历史分析
- 告警:OOM 事件发生时实时通知
小结
这篇文章实现了完整的 OOM 事件追踪工具:C 编写的 eBPF 内核态程序负责采集,Go 编写的用户态程序负责加载和读取,通过 Ring Buffer 高效传递事件。这个模式是 eBPF 应用开发的标准架构,可以复用到各种可观测性场景中。
下一篇文章将深入到内存分配追踪和容器级 OOM 监控,并展示 Rust / Aya 的另一种实现方式。