【eBPF学习】使用fentry跟踪tcp连接

本文是对 cilium/ebpf examples: fentry 的一个学习记录

在这里,我们将会利用 ebpf 监控并记录系统上所有新发起的 IPv4 TCP 连接,其工作流程如下:

  1. 挂载点:程序使用 fentry 机制把自己附加到内核函数 tcp_connect 的入口。每当系统中有任何一个进程尝试发起一个 TCP 连接时,这个内核函数就会被调用,从而触发我们的 eBPF 程序。
  2. 过滤:程序首先检查连接的地址族是否为 AF_INET,即 IPv4。如果不是(例如是 IPv6),程序会直接退出,不做任何处理。
  3. 数据提取:对于 IPv4 连接,程序会从传递给 tcp_connect 函数的 struct sock 参数中提取以下关键信息:
    • 源 IP 地址 (saddr)
    • 目标 IP 地址 (daddr)
    • 目标端口 (dport)
    • 源端口 (sport)
  4. 获取进程信息:使用 bpf_get_current_comm() 辅助函数获取当前发起连接的进程名(例如 curlssh 等)。
  5. 数据发送:程序将收集到的所有信息(IP 地址、端口、进程名)打包成一个 struct event 结构体,并通过一个高效的 ringbuf 映射发送到用户空间。

什么是 fentry

fentry 是 eBPF 中的一种程序附加类型,全称为 “function entry”(函数入口)。它是现代 Linux 内核中用于跟踪和性能分析的高效机制。

fentry 允许将 eBPF 程序附加到内核函数的入口点,当该函数被调用时,eBPF 程序会在函数的主体执行前运行。这种机制让我们可以:

  • 拦截函数调用
  • 检查函数参数
  • 收集统计数据
  • 修改执行路径

为什么使用 fentry

使用 fentry(函数入口钩子)来跟踪 TCP 连接,相比于 kprobe 或其他方式,主要有以下几个优点:

  1. 性能更高:fentry 是 BPF 的原生钩子,直接插入到内核函数入口,开销比 kprobe 更低,延迟更小,适合高频事件的跟踪。
  2. 更安全稳定:fentry 直接集成在内核 BPF 框架中,API 更加稳定,不容易因为内核升级或符号变化而失效。而 kprobe 依赖于符号解析,容易受到内核实现细节变化影响。
  3. 类型安全:fentry 支持 BTF(BPF Type Format),可以直接访问函数参数,类型安全且易于开发。而 kprobe 只能通过寄存器或栈手动解析参数,容易出错。
  4. 更好的可维护性:fentry 程序更容易与内核源码保持同步,代码更简洁,维护成本更低。

而 kprobe 适合没有 fentry 支持的老内核或特殊场景。

内核态 C 程序

 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
//go:build ignore

#include "../vmlinux/vmlinux.h"

#include <bpf/bpf_endian.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

#define AF_INET 2
#define TASK_COMM_LEN 16

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

// 
struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 1 << 24);
    __type(value, struct event);
} events SEC(".maps");

// 用于捕获和传递 TCP 连接事件的关键信息,通常在 eBPF 程序监控网络活动时使用。
// 当程序检测到新的 TCP连接时,会将连接的详细信息打包到这个结构体中,然后发送给用户空间程序进行处理。
struct event {
    u8 comm[16];    // 进程名称(最多15个字符 + null终止符)
    __u16 sport;    // 源端口(主机字节序)
    __be16 dport;   // 目标端口(网络字节序,big-endian)
    __be32 saddr;   // 源IP地址(网络字节序,big-endian)
    __be32 daddr;   // 目标IP地址(网络字节序,big-endian)
};

SEC("fentry/tcp_connect")
int BPF_PROG(tcp_connect, struct sock* sk) {
    if (sk->__sk_common.skc_family != AF_INET) {
        return 0;
    }

    struct event* tcp_info;
    tcp_info = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);
    if (!tcp_info) {
        return 0;
    }

    tcp_info->saddr = sk->__sk_common.skc_rcv_saddr;
    tcp_info->daddr = sk->__sk_common.skc_daddr;
    tcp_info->dport = sk->__sk_common.skc_dport;
    tcp_info->sport = bpf_htons(sk->__sk_common.skc_num);

    bpf_get_current_comm(&tcp_info->comm, TASK_COMM_LEN);

    bpf_ringbuf_submit(tcp_info, 0);

    return 0;
}

使用到的 bpf helper function

  1. bpf_ringbuf_reserve()

作用:从环形缓冲区(ring buffer)中预留一块内存空间

  • 参数 1:&events - 环形缓冲区的引用
  • 参数 2:sizeof(struct event) - 需要预留的字节数
  • 参数 3:0 - 标志位(通常为 0)
  • 返回值:指向预留内存的指针,失败时返回 NULL

关键点:这是一个 " 预留 - 提交 " 模式的第一步,先申请空间但数据还未对用户空间可见。

  1. bpf_htons()

作用:将主机字节序转换为网络字节序(Host TO Network Short)。网络协议使用大端序,而主机可能使用小端序,需要转换保证数据一致性。

  • 参数:16 位的端口号
  • 返回值:网络字节序的端口号
  1. bpf_get_current_comm()

作用:获取当前进程的命令名称(进程名)

  • 参数 1:&tcp_info->comm - 存储进程名的缓冲区指针
  • 参数 2:TASK_COMM_LEN - 缓冲区大小(通常是 16 字节)
  1. bpf_ringbuf_submit()

作用:将之前预留的数据提交到环形缓冲区,使其对用户空间可见

  • 参数 1:tcp_info - 之前通过 bpf_ringbuf_reserve() 获得的指针
  • 参数 2:0 - 标志位

用户态 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
94
95
96
97
98
99
package main

import (
	"bytes"
	"encoding/binary"
	"errors"
	"log"
	"net"
	"os"
	"os/signal"
	"syscall"

	"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 -cflags "-O2 -g -Wall -Werror -D__TARGET_ARCH_x86" bpf fentry_tcp_connect.bpf.c

func main() {
	stopper := make(chan os.Signal, 1)
	signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)

	// 移除内存限制
	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()

	link, err := link.AttachTracing(link.TracingOptions{
		Program: objs.TcpConnect,
	})
	if err != nil {
		log.Fatalf("linking Fentry: %v", err)
	}
	defer link.Close()
	
	rd, err := ringbuf.NewReader(objs.Events)
	if err != nil {
		log.Fatalf("opening ringbuf reader: %s", err)
	}
	defer rd.Close()

	go func() {
		<-stopper

		if err := rd.Close(); err != nil {
			log.Fatalf("closing ringbuf reader: %s", err)
		}
	}()

	log.Printf("%-16s %-15s %-6s -> %-15s %-6s",
		"Comm",
		"Src addr",
		"Port",
		"Dest addr",
		"Port",
	)

	// 不断从 ringbuf 中读取事件、解析事件并打印相关信息,直到接收到终止信号
	var event bpfEvent
	for {
		record, err := rd.Read()
		if err != nil {
			if errors.Is(err, ringbuf.ErrClosed) {
				log.Println("received signal, exiting..")
				return
			}
			log.Printf("reading from reader: %s", err)
			continue
		}

		// Parse the ringbuf event entry into a bpfEvent structure.
		if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.BigEndian, &event); err != nil {
			log.Printf("parsing ringbuf event: %s", err)
			continue
		}

		log.Printf("%-16s %-15s %-6d -> %-15s %-6d",
			event.Comm,
			intToIP(event.Saddr),
			event.Sport,
			intToIP(event.Daddr),
			event.Dport,
		)
	}
}

// intToIP converts IPv4 number to net.IP
func intToIP(ipNum uint32) net.IP {
	ip := make(net.IP, 4)
	binary.BigEndian.PutUint32(ip, ipNum)
	return ip
}
使用 Hugo 构建
主题 StackJimmy 设计