eBPF 技术简介与实践

Posted by caodailiang on November 23, 2022

基础概念

  • 内核虚拟机技术,通过sys_bpf加载到内核中,绑定到事件执行
  • 访问内核数据结构和API,如网络数据包、系统调用、内核跟踪点,对数据进行过滤、修改、分析
  • 安全性和可验证性,可通过Verifier进行严格检查

技术原理和实现

  • 字节码,JIT编译器优化,转化为机器码
  • 指令集是虚拟机的执行单元,并提供辅助函数来完成内存访问、系统调用和跟踪等
  • 传统虚拟机提供用户程序的运行环境,ebpf虚拟机用于增强内核功能 eBPF架构

eBPF程序剖析

事件和钩子

  • 系统调用:当用户空间程序通过系统调用执行内核功能时
  • 功能的进入和退出:在函数退出之前拦截调用
  • 网络事件:当接收到数据包时
  • kprobe和uprobe中:挂载到内核或用户函数中

辅助函数

让ebpf访问内存的丰富功能,如Helper:

  • 在数据表中对键值对进行搜索、更新和删除
  • 生成伪随机数
  • 搜集和标记隧道元数据
  • 把ebpf程序连接起来,也叫tail call
  • 执行socket相关任务,如绑定、获取cookie、数据包重定向等

map

  • 在ebpf程序和内核、用户空间之间存储和共享数据,ebpf程序通过辅助函数在map中发送和接收数据

应用场景

  • 网络数据包过滤和流量控制:在网络驱动程序中加载ebpf程序
  • 系统性能分析和调优:关键代码路径中插入ebpf程序,实时收集系统运行情况和性能指标
  • 安全监控和防护:加载ebpf程序到内核中,实时捕获和分析系统行为

发展前景

  • 社区发展和生态系统
  • 云原生和容器技术中的应用
  • 挑战和未来发展:安全和性能平衡、开发和调试工具等

编码实践

无论eBPF程序的用户态部分是用什么语言开发,内核态部分都还是需要用C语言开发。本例使用Go语言开发用户态部分,使用社区目前最为活跃的cilium/ebpf包。

环境安装

在Debian中使用如下命令安装 clang/llvm 编译器。

$ sudo apt-get update -y
$ sudo apt-get install -y llvm
$ sudo apt-get install -y clang

以及还需要安装 docker 运行环境。

下载 cilium/ebpf 库:

$ git get github.com/cilium/ebpf

在examples目录下有几个ebpf示例程序可以参考。

cilium/ebpf 提供了便利的构建脚本,我们只需在 ebpf/examples 下面执行 make -C .. 即可进行示例代码的构建。

示例分析

我们可以看一下 tracepoint_in_c 例子,其中包含以下几个文件:

$ tree tracepoint_in_c/
tracepoint_in_c/
├── bpf_bpfeb.go
├── bpf_bpfeb.o
├── bpf_bpfel.go
├── bpf_bpfel.o
├── main.go
└── tracepoint.c

1 directory, 6 files

其中C语言文件 tracepoint.c 就是ebpf程序内核态部分,bpf_bpfeb.obpf_bpfel.o 是编译后的bpf字节码文件,bpf_bpfel.gobpf_bpfeb.go 则是bpf2go基于字节码生成,提供给用户态程序 main.go 使用。

bpf_bpfeb.obpf_bpfel.o 分别对应大小端不同的CPU架构,后缀 _bpfeb 中的 eb 代表 Big Ending,同理 el 代表 Little Endingbpf_bpfel.go / bpf_bpfeb.go 文件也是如此,所以最终根据具体的CPU架构只会使用二中其一。

比如因为X86的CPU都是小端所以使用 bpf_bpfel.o 和 bpf_bpfel.go 即可,bpf_bpfel.go 与 main.go 一起编译就形成了ebpf程序。ebpf程序的字节码 bpf_bpfel.o 会以二进制数据的形式内嵌到这两个go源文件中,具体说就是利用 go:embed 特性嵌到 _BpfBytes 变量中。

可在bpf2go生成的bpf_bpfel.go文件的末尾的找到以下内容:

//go:embed bpf_bpfel.o
var _BpfBytes []byte

bpf_bpfel.go文件中也包含一个重要的结构 bpfObjects,对应eBPF程序的两个关键组成:eBPF程序数据用于用户态和内核态数据交互的map。eBPF程序还有一个关键组成就是 挂载点(attach point)

// bpfObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfObjects struct {
    bpfPrograms
    bpfMaps
}

操作实践

kprobe 为例,统计系统调用 sys_execve 被调用的次数,使用 map 传递数据。在 examples 目录下新建一个 kprobe_count 示例目录,C源码:

#include "common.h"

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

struct bpf_map_def SEC("maps") kprobe_map = {
        .type        = BPF_MAP_TYPE_ARRAY,
        .key_size    = sizeof(u32),
        .value_size  = sizeof(u64),
        .max_entries = 1,
};

SEC("kprobe/sys_execve")
int kprobe_execve() {
        u32 key     = 0;
        u64 initval = 1, *valp;

        valp = bpf_map_lookup_elem(&kprobe_map, &key);
        if (!valp) {
                bpf_map_update_elem(&kprobe_map, &key, &initval, BPF_ANY);
                return 0;
        }
        __sync_fetch_and_add(valp, 1);

        return 0;
}

其中第一行的 common.h 是 cilium/ebpf 提供的开发ebpf程序用户态部分所需的头文件,在 examples/headers目录中。

$ ls kprobe_count/
kprobe.c

1. bpf2go 内核态程序代码编译

使用 bpf2go 根据上面的C代码生成bpf字节码和Go代码,bpf2go执行中会调用到 clang/llvm

$ GOPACKAGE=main /home/caodailiang/go/bin/bpf2go -cc clang-14 -cflags '-O2 -g -Wall -Werror' -target bpfel,bpfeb bpf kprobe.c -- -I /home/caodailiang/go/src/github.com/cilium/ebpf/examples/headers
Compiled /home/caodailiang/go/src/github.com/cilium/ebpf/examples/kprobe_count/bpf_bpfel.o
Stripped /home/caodailiang/go/src/github.com/cilium/ebpf/examples/kprobe_count/bpf_bpfel.o
Wrote /home/caodailiang/go/src/github.com/cilium/ebpf/examples/kprobe_count/bpf_bpfel.go
Compiled /home/caodailiang/go/src/github.com/cilium/ebpf/examples/kprobe_count/bpf_bpfeb.o
Stripped /home/caodailiang/go/src/github.com/cilium/ebpf/examples/kprobe_count/bpf_bpfeb.o
Wrote /home/caodailiang/go/src/github.com/cilium/ebpf/examples/kprobe_count/bpf_bpfeb.go

生成的文件:

$ ls kprobe_count/
bpf_bpfeb.go  bpf_bpfeb.o  bpf_bpfel.go  bpf_bpfel.o  kprobe.c

2. 用户态程序

用户态 main.go 文件,内容如下:

// This program demonstrates attaching an eBPF program to a kernel symbol.
// The eBPF program will be attached to the start of the sys_execve
// kernel function and prints out the number of times it has been called
// every second.
package main

import (
	"log"
	"time"

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

const mapKey uint32 = 0

func main() {

	// Name of the kernel function to trace.
	fn := "sys_execve"

	// Allow the current process to lock memory for eBPF resources.
	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatal(err)
	}

	// Load pre-compiled programs and maps into the kernel.
	objs := bpfObjects{}
	if err := loadBpfObjects(&objs, nil); err != nil {
		log.Fatalf("loading objects: %v", err)
	}
	defer objs.Close()

	// Open a Kprobe at the entry point of the kernel function and attach the
	// pre-compiled program. Each time the kernel function enters, the program
	// will increment the execution counter by 1. The read loop below polls this
	// map value once per second.
	kp, err := link.Kprobe(fn, objs.KprobeExecve, nil)
	if err != nil {
		log.Fatalf("opening kprobe: %s", err)
	}
	defer kp.Close()

	// Read loop reporting the total amount of times the kernel
	// function was entered, once per second.
	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()

	log.Println("Waiting for events..")

	for range ticker.C {
		var value uint64
		if err := objs.KprobeMap.Lookup(mapKey, &value); err != nil {
			log.Fatalf("reading map: %v", err)
		}
		log.Printf("%s called %d times\n", fn, value)
	}
}

代码已有详细注释,先通过 loadBpfObjects 将内核态bpf程序和map加载到内核中,然后通过 kprobe 将内核程序挂载到 sys_execve 函数,后续通过循环读取 map 中的数据进行输出。

编译运行该程序

$ go run -exec sudo main.go bpf_bpfel.go
2022/11/23 12:22:23 Waiting for events..
2022/11/23 12:22:24 sys_execve called 23 times
2022/11/23 12:22:25 sys_execve called 46 times
2022/11/23 12:22:26 sys_execve called 69 times
2022/11/23 12:22:27 sys_execve called 92 times
2022/11/23 12:22:28 sys_execve called 115 times
2022/11/23 12:22:29 sys_execve called 138 times
...

3. 使用go generate来驱动bpf2go转换

上述第1步中的 bpf2go 可由Go语言工具链提供的 go generate 工具来驱动,在 main.go 的 main 函数上面增加一行 go:generate 指示语句,语句内容即为之前的 bpf2go 命令。当我们执行 go generate 命令时,go generate 会扫描到该指示语句,并执行后面的命令。

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go  -cc clang-14 -cflags '-O2 -g -Wall -Werror' -target bpfel,bpfeb bpf kprobe.c -- -I../headers

const mapKey uint32 = 0

func main() {
...

go generate 指示语句可以使用变量参数,变量的值在 Makefile 中定义。

参考文档