【eBPF学习】使用kprobe监测捕获unlink系统调用

本文是对于 Eunomia Tutorials 2 的一个学习记录

什么是 kprobe

Kprobe​​(Kernel Probe)是 Linux 内核提供的一项强大功能,它允许开发者和系统管理员在不​​修改内核源代码​​或重启系统的前提下,在任意内核函数处动态插入“探针”:

  • ​工作原理​​:通过​​临时替换​​目标函数的前几条指令为一个断点指令(如 int3)
  • ​执行流程​​:当程序执行到断点时,CPU 控制权会交给 kprobe 系统
  • ​事件回调​​:系统执行注册的回调函数,完成数据采集后恢复原函数执行
  • ​两种类型​​:
    • ​Kprobe​​:在函数入口处执行
    • ​Kretprobe​​:在函数返回时执行

这种机制为我们提供了​​零侵入式​​的内核行为洞察能力,特别适用于​​实时监控​​、​​性能分析​​和​​故障排查​​等场景。

do_unlinkat 的作用

do_unlinkat 是 Linux 内核中的一个内部函数,它的作用是执行文件或目录的删除操作。其在内核源码中的定义如下:

1
2
3
static int do_unlinkat(int dfd, struct filename *name) {
	...
}
  • do_unlinkat 是内核中实际执行文件删除逻辑的最终汇聚点
  • 用户空间调用 unlink()unlinkat() 或 rmdir() 等系统调用时,最终都会通过系统调用表路由到这个函数
  • 采用文件描述符 (AT_FDCWD) 和路径名的组合方式,提供了灵活的路径解析能力

vmlinux.h

不同内核版本之间,内核数据结构如结构体字段位置、字段名称等都可能发生变化。传统的 eBPF 程序直接使用内核头文件会导致:

  • 兼容性问题:程序在​​不同内核版本​​中崩溃
  • 字段偏移错误:读取到​​无效内存数据​
  • 维护困难:需要针对​​每个内核版本​​进行适配

vmlinux.h 利用内核的​​BTF(BPF Type Format)​​ 信息生成与当前运行内核完全匹配的类型定义:

1
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

内核态程序 kprobe_unlink.bpf.c

do_unlinkat 函数同时设置入口探针和返回探针:

  1. ​函数入口​​:
    • 捕获调用进程的 PID
    • 获取要删除的文件名
  2. ​函数返回​​:
    • 捕获调用进程的 PID
    • 获取函数返回值(删除操作的结果)
 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
//go:build ignore

#include "vmlinux.h"

// 下面几个头文件需要安装了libbpf库
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

SEC("kprobe/do_unlinkat")
int BPF_KPROBE(do_unlinkat, int dfd, struct filename* name) {
    pid_t pid;
    const char* filename;

    pid = bpf_get_current_pid_tgid() >> 32;
    filename = BPF_CORE_READ(name, name);
    bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename);
    return 0;
}

SEC("kretprobe/do_unlinkat")
int BPF_KRETPROBE(do_unlinkat_exit, long ret) {
    pid_t pid;

    pid = bpf_get_current_pid_tgid() >> 32;
    bpf_printk("KPROBE EXIT: pid = %d, ret = %ld\n", pid, ret);
    return 0;
}

用户态 Go 程序

用户态程序负责三个主要任务:

  1. ​环境初始化​​:
    • 移除 eBPF 的内存限制
    • 加载编译好的 eBPF 程序
  2. ​探针附加​​:
    • 将 kprobe 绑定到 do_unlinkat
    • 将 kretprobe 绑定到 do_unlinkat 的返回点
  3. ​日志处理​​:
    • 读取内核的跟踪日志
    • 筛选并显示与文件删除相关的事件
 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
package main

import (
	"bufio"
	"context"
	"fmt"
	"log"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/rlimit"
)

func main() {
	// 移除内存限制
	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatal(err)
	}

	objs := bpfObjects{}
	if err := loadBpfObjects(&objs, nil); err != nil {
		log.Fatalf("loading bpf objects: %v", err)
	}
	defer objs.Close()

	// SEC("kprobe/do_unlinkat")
	kp, err := link.Kprobe("do_unlinkat", objs.DoUnlinkat, nil)
	if err != nil {
		log.Fatalf("linking kprobe: %v", err)
	}
	defer kp.Close()

	// SEC("kretprobe/do_unlinkat")
	krep, err := link.Kretprobe("do_unlinkat", objs.DoUnlinkatExit, nil)
	if err != nil {
		log.Fatalf("linking kretprobe: %v", err)
	}
	defer krep.Close()

	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()

	fmt.Println("eBPF 程序已成功加载,开始监控文件删除操作...")
	fmt.Println("请在另一个终端执行文件删除操作,如: rm test.txt")
	fmt.Println("按 Ctrl+C 退出")

	// 启动协程读取内核日志
	go readTraceLog()

	// 等待中断信号
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	<-ctx.Done()
	fmt.Println("\n正在退出...")
}

// 读取内核跟踪日志
func readTraceLog() {
	file, err := os.Open("/sys/kernel/debug/tracing/trace_pipe")
	if err != nil {
		log.Printf("无法打开 trace_pipe: %v", err)
		log.Printf("提示: 请确保以 root 权限运行程序")
		return
	}
	defer file.Close()

	scanner := bufio.NewScanner(file)
	for scanner.Scan() {
		line := scanner.Text()
		// 只显示我们程序产生的日志
		if strings.Contains(line, "KPROBE ENTRY") || strings.Contains(line, "KPROBE EXIT") {
			fmt.Printf("[%s] %s\n", time.Now().Format("15:04:05"), line)
		}
	}

	if err := scanner.Err(); err != nil {
		log.Printf("读取 trace_pipe 出错: %v", err)
	}
}

编译与运行

Makefile

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 1. 克隆项目并进入目录
git clone https://github.com/kerolt/learn-ebpf

# 2. 生成vmlinux.h
./vmlinux/update.sh

# 3. 生成.bpf.c对应的go代码和字节码
cd kprobe_unlink
go generate

# 4. 运行程序
sudo go run .

运行后会得到类似的结果:

使用 Hugo 构建
主题 StackJimmy 设计