踩坑记,如何使用ebpf-go,实战案例:拦截vfs_read函数获取文件名输出

原创 吴就业 301 0 2024-05-31

本文为博主原创文章,未经博主允许不得转载。

本文链接:https://wujiuye.com/article/5a3ec2e5754141c78b8e699023c8ad65

作者:吴就业
链接:https://wujiuye.com/article/5a3ec2e5754141c78b8e699023c8ad65
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。

要使用github.com/cilium/ebpf(ebpf-go),必需是在linux操作系统环境下,在Mac OS或windows下都是无法使用ebpf的。但我们可以安装个linux虚拟机!例如Ubuntu。作为开发ebpf项目的开发环境。

Mac系统推荐Parallels Desktop这款虚拟机软件,装个虚拟机非常简单,缺点就是收费。

本篇基于Ubuntu 22.04 系统,arm64 CPU架构 做的实验。

开发环境准备

首先我们需要根据ebpf-go官方文档列出的编译环境来准备我们的开发环境。

官方文档:https://ebpf-go.dev/guides/getting-started/#whats-next

截屏2024-05-29 11.21.02

截屏2024-05-29 11.18.21

总结要求如下:

  1. 要求系统的linux内核版本>=5.7。
  2. 要求安装clang,并且clang的版本>=11。如果安装的clang不包含llvm,那么还需要安装llvm。
  3. 如果我们用的是Debian/Ubuntu系统,那么我们需要安装libbpf-dev。
  4. 如果我们用的是Debian/Ubuntu系统,需要使用sudo ln -sf /usr/include/asm-generic/ /usr/include/asm创建软连接,不然编译的时候会出现找不到asm/*.h头文件。
  5. 要求go语言版本号 >= ebpf-go的go.mod中声明使用的go版本。

当我们准备好Linux虚拟机后,并且安装go之后(建议直接安装go的最新版本),根据要求安装工具。

先检查确认一下内核版本大于5.7:

$ uname -r 

安装clang和llvm:

sudo apt install clang
sudo apt install llvm

验证clang版本:

$ clang --version

安装libbpf-dev:

sudo apt install libbpf-dev

创建软链接,按说明去创建这个软链可以跑官方给的例子:

sudo ln -sf /usr/include/asm-generic/ /usr/include/asm

开始实现demo

首先整理需求编写ebpf c程序代码。例如,我们实现用ebpf-go 拦截vfs_read系统函数调用,获取文件名并输出。

c代码(vfs-trace.c):

//go:build ignore

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <linux/ptrace.h>

char __license[] SEC("license") = "Dual MIT/GPL"; // 这句必须

SEC("kprobe/vfs_read")
int kprobe_vfs_read(struct pt_regs *ctx) {
    struct file *f = (struct file *)PT_REGS_PARM1(ctx); // PT_REGS_PARM1方法为bpf/bpf_tracing.h头文件中定义的方法,用于获取拦截的方法的第一个参数,如果想获取第二个参数就是PT_REGS_PARM2,第三个就是PT_REGS_PARM3,...
  	struct path path;
    bpf_probe_read(&path,sizeof(struct path),&f->f_path);
    struct dentry dentry;
    bpf_probe_read(&dentry,sizeof(struct dentry),path.dentry);
    char filename[256];
    bpf_probe_read(filename, sizeof(filename), dentry.d_name.name);
    bpf_printk("file name: %s \n",filename);
  	return 0;
}

其中PT_REGS_PARM1这个宏定义在bpf_tracing.h头文件中。

初始化项目,创建一个go项目目录,例如:vfs-ebpf-trace,将刚刚生成的c代码文件也放到这个目录下。

进入目录按顺序执行下面命令,用ebpf-go初始化go项目。

cd vfs-ebpf-trace
go mod init vfs-ebpf-trace
go mod tidy
## 请先确保安装的go版本跟ebpf-go用的go版本一样新
go get github.com/cilium/ebpf/cmd/bpf2go

编写gen.go文件:

根据cpu架构修改-target arm64,通过-I/usr/include指定所有头文件的根目录在/usr/include,代码中使用#include <bpf/bpf_tracing.h>就是找/usr/include/bpf/bpf_tracing.h文件。

package main

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags $BPF_CFLAGS -target arm64 vfstrace vfs-trace.c -- -I/usr/include

编译c程序生成go代码:

go generate

编译可能会报错:

In file included from ~/wujiuye/goprojects/src/vfs-ebpf-trace/vfs-trace.c:3:
/usr/include/linux/ip.h:21:10: fatal error: 'asm/byteorder.h' file not found
#include <asm/byteorder.h>
         ^~~~~~~~~~~~~~~~~
1 error generated.
Error: can't execute clang: exit status 1
exit status 1
gen.go:3: running "go": exit status 1

进入/usr/include/asm-generic/目录查看发现确实没有byteorder.h这个头文件,但是我从另一个目录找到了,就是aarch64-linux-gnu

截屏2024-05-29 11.32.19

参考文档的sudo ln -sf /usr/include/asm-generic/ /usr/include/asm,我改成sudo ln -sf /usr/include/aarch64-linux-gnu/ /usr/include/asm就不会报找不到byteorder.h了,但是还是会出现很多头文件找不到的情况。

记得改之前先rm -rf /usr/include/asm。

/usr/include/string.h:26:10: fatal error: 'bits/libc-header-start.h' file not found
#include <bits/libc-header-start.h>
         ^~~~~~~~~~~~~~~~~~~~~~~~~~
1 error generated.
Error: can't execute clang: exit status 1
exit status 1
gen.go:3: running "go": exit status 1


/usr/include/features.h:461:12: fatal error: 'sys/cdefs.h' file not found
#  include <sys/cdefs.h>
           ^~~~~~~~~~~~~
1 error generated.
Error: can't execute clang: exit status 1
exit status 1
gen.go:3: running "go": exit status 1

/usr/include/features.h:485:10: fatal error: 'gnu/stubs.h' file not found
#include <gnu/stubs.h>
         ^~~~~~~~~~~~~
1 error generated.
Error: can't execute clang: exit status 1
exit status 1
gen.go:3: running "go": exit status 1

我按同样的方法,给这些目录创建软链接到aarch64-linux-gnu

sudo ln -sf /usr/include/aarch64-linux-gnu/asm /usr/include/asm
sudo ln -sf /usr/include/aarch64-linux-gnu/bits /usr/include/bits
sudo ln -sf /usr/include/aarch64-linux-gnu/gnu /usr/include/gnu

按照这个方案就可以正常编译了,如果还出现错误,根据错误提示看看是不是自己写的c代码抛的错,如果是,就很好解决了。

如果你的是X86_64(amd64)的Ubuntu,也应该会遇到这样的问题,把aarch64-linux-gnu换成x86_64-linux-gnu即可:

sudo ln -sf /usr/include/x86_64-linux-gnu/asm /usr/include/asm
sudo ln -sf /usr/include/x86_64-linux-gnu/bits /usr/include/bits
sudo ln -sf /usr/include/x86_64-linux-gnu/gnu /usr/include/gnu

最后,我们的 eBPF C 代码已经编译完成,Go 脚手架也已生成,剩下的工作就是编写Go代码,负责加载程序并将其附加到 Linux 内核的钩子上。

mian.go的实现:

package main

import (
	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/rlimit"
	"log"
	"os"
	"os/signal"
	"time"
)

func main() {
	// 删除内核的资源限制。 内核版本 < 5.11
	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatal("Removing memlock:", err)
	}

	// 加载编译好的eBPF ELF并将其加载到内核中。
	var objs fusetraceObjects
	if err := loadFusetraceObjects(&objs, nil); err != nil {
		log.Fatal("Loading eBPF objects:", err)
	}
	defer objs.Close()

	// 使用kprobe挂载
	kp, err := link.Kprobe("vfs_read", objs.KprobeVfsRead, nil)
	if err != nil {
		log.Fatalf("opening kprobe: %s", err)
	}
	defer kp.Close()

	// 监听中断信号退出进程
	stop := make(chan os.Signal, 5)
	signal.Notify(stop, os.Interrupt)
	for {
		select {
		case <-stop:
			log.Print("Received signal, exiting..")
			return
		}
	}
}

编译运行:

go build
./vfs-ebpf-trace

三个命令连起来:

go generate && go build && ./vfs-ebpf-trace

查看日记输出:

bpf_printk打印的日记并不在控制台输出,而是输出到/sys/kernel/debug/tracing/trace_pipe文件。

sudo cat /sys/kernel/debug/tracing/trace_pipe

bpf_printk

如果查看日记报错:cat: /sys/kernel/debug/tracing/trace_pipe: Device or resource busy,这是因为文件被其它进程占用了,可能是我们前一次ssh查看没有关闭,然后ssh超时,我们打开新的窗口,原来的进程还占用着这个文件,所以就会报这个错。

使用lsof命令查出来是哪个进程占用的这个文件,然后kill掉进程即可。

root@vultr:~# sudo lsof /sys/kernel/debug/tracing/trace_pipe
COMMAND    PID USER   FD   TYPE DEVICE SIZE/OFF  NODE NAME
cat     122966 root    3r   REG   0,12        0 12385 /sys/kernel/debug/tracing/trace_pipe
root@vultr:~# kill -9 122966

踩坑记

头文件找不到问题

关于编译报错找不到头文件的问题,上述做法只适合文章中的案例,如果我们再用到一些其它的头文件,比如<linux/fs.h>,可能还是会遇到各种各样的问题。

我们可以使用下面这个大杀招。

通过使用bpftool命令,根据当前内核版本生成一个囊括所有结构体的头文件。

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

然后将vmlinux.h这个文件放到/usr/include目录下:

mv ./vmlinux.h /usr/include

最后修改c程序代码的导入头文件部分:

#include <vmlinux.h>
//#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
//#include <linux/ptrace.h>

bpf/开头的,是我们安装libbpf-dev得到的头文件,不是系统的,所以vmlinux.h替代不了。而linux/开头的是系统提供的,include了vmlinux.h之后就不需要了,所以注释掉。

参考文献:https://www.strickland.cloud/post/1

读数据优化

在案例中,我们要读取f->path.dentry->d_name.name,直接这样调用是不行的,所以我们使用了好几次bpf_probe_read,才读取到文件名。

SEC("kprobe/vfs_read")
int kprobe_vfs_read(struct pt_regs *ctx) {
    struct file *f = (struct file *)PT_REGS_PARM1(ctx);
    struct path path;
    bpf_probe_read(&path,sizeof(struct path),&f->f_path);
    struct dentry dentry;
    bpf_probe_read(&dentry,sizeof(struct dentry),path.dentry);
    char filename[256];
    bpf_probe_read(filename, sizeof(filename), dentry.d_name.name);
    bpf_printk("file name: %s \n",filename);
}

我们可以使用bpf_core_read.h这个头文件定义的BPF_CORE_READ这个宏,支持链式读取。

我们安装libbpf-dev后,这个头文件在/usr/include/bpf/bpf_core_read.h这个头文件中。

....
#include <bpf/bpf_core_read.h> // 引入这个头文件
  
SEC("kprobe/vfs_read")
int kprobe_vfs_read(struct pt_regs *ctx) {
   struct file *f = (struct file *)PT_REGS_PARM1(ctx);
	 const unsigned char *filename;
   filename = BPF_CORE_READ(&f->f_path,dentry,d_name.name); 
   bpf_printk("file name: %s \n",filename);
}

这里我们使用BPF_CORE_READ一步到位读文件名,解决了需要调用多次bpf_probe_read的问题。

案例中,BPF_CORE_READ(&f->f_path,dentry,d_name.name)是从f_path(结构体)中读取dentry(结构体),再从dentry中读取d_name(结构体),并获取d_name的name字段(const unsigned char *)。

其它问题

1

验证点有没有正确,可以使用bpftrace工具。

例如:

sudo bpftrace -e 'kprobe:vfs_open {printf("hello")}'

2

如果go mod tidy遇到下载依赖超时,如:

github.com/go-quicktest/qt: github.com/go-quicktest/[email protected]: Get "https://proxy.golang.org/github.com/go-quicktest/qt/@v/v1.101.0.zip": dial tcp 142.250.204.113:443: i/o timeout

可以将虚拟机的网络从“共享网络”切换到“侨接网络WIFI”。或者自己检查一下网络不通的原因、网络是否能打开海外的网站等。

3

如果运行遇到Removing memlock:failed to set memlock rlimit: operation not permitted错误,需要我们以root权限启动。

如果你是在虚拟机里面装goland开发工具来run项目,那么需要以root权限来启动goland。

sudo ./goland.sh

4

如果运行出现这个错误:

 Loading eBPF objects:field KprobeFuseFileWrite: program kprobe_fuse_file_write: load program: invalid argument: cannot call GPL-restricted function from non-GPL compatible program (13 line(s) omitted)

参照例子,我们需要修改我们的c程序代码,添加下面这行:

char __license[] SEC("license") = "Dual MIT/GPL";

需要重新执行go generate

5

如果我们不想安装虚拟机,也可以在云上启动个虚拟机,然后本地开发远程编译运行,goland提供远程编译运行的支持,可以参考这个文章:https://blog.51cto.com/u_61922975357296

6

关于linux的系统函数,可以在这个网站搜索,还能根据自己系统的内核版本去搜索。可以用来找某个函数的都有哪些参数,参数的类型是什么。 https://elixir.bootlin.com/linux/v5.10.218/source/fs/open.c#L928

#eBPF

声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。

文章推荐

看图理解linux内核网络流量控制工具tc(Traffic Control)

tc是Linux内核中用于流量控制的一套功能强大的工具和框架、是内核的一个子系统。本篇通过画图理解tc涉及的概念,并通过实验理解这些概念,以及了解tc命令怎么用。

一个稍微复杂的ebpf-go学习案例:给vfs_write写操作挂个hook

借助eBPF,给vfs_write写操作挂个hook,可以用这个hook实现一些可观察需求。本案例仅供参考学习,可以把案例中的vfs_write改成其它的。

ebpf-go c结构体和go结构体的映射

使用ebpf-go,假如bpf map的value需要用到结构体,而value由go程序写入,c程序读,value结构体在c中声明,那么怎么生成对应的go结构体呢?

如何使用bpftrace追踪系统调用性能问题

所以,这个案例的用途是:在read函数调用之前,记录时间戳,在read函数return前计算方法执行耗时,将结果保存到`us`全局变量。使用`@`声明的全局变量,会在bpftrace脚本进程结束时输出到控制台。