eBPF基础

简介

eBPF(Extended Berkeley Packet Filter)起源于Linux内核,它可以在中断上下文中(如操作系统内核)运行沙盒程序。它用于安全有效地扩展内核的功能,与传统方法需要修改内核源代码或加载新模块不同,eBPF使得动态定制和优化网络行为成为可能,且不会中断系统操作。

eBPF的强大之处包括:

  • 直接内核交互:eBPF程序在内核中执行,与系统级事件如网络包、系统调用或追踪点交互。
  • 安全执行:eBPF通过验证器在程序运行前检查其逻辑,防止潜在的内核崩溃或安全漏洞。
  • 最低开销:eBPF通过使用即时编译器(JIT),将eBPF字节码转换为针对特定架构的优化机器码,实现近原生执行速度。

eBPF程序是事件驱动的,当内核或应用程序通过某个钩子点时运行。预定义的钩子包括系统调用、函数入口/退出、内核跟踪点、网络事件等。如果预定义的钩子不能满足特定需求,则可以创建内核探针(kprobe)或用户探针(uprobe),以便在内核用户或应用程序的几乎任何位置附加eBPF程序。
确定需要的钩子后,就可以使用bpf系统调用将eBPF程序加载到Linux内核中。当程序被加载到Linux内核时,它在被附加到所请求的钩子上之前需要经过两个步骤:

  1. 验证:验证步骤用于保证eBPF程序可以安全运行。它可以验证程序是否满足几个条件:
    • 加载eBPF程序的进程必须有必需的能力(特权)。除非启用非特权eBPF,否则只有特权进程可以加载eBPF程序。
    • eBPF程序不会崩溃或者对系统造成损害。
    • eBPF程序一定会运行至结束(即程序不会处于循环状态中,否则会阻碍后续的处理)。
  2. JIT编译:JIT(Just-in-Time)编译步骤将程序的通用字节码高效转换为机器的特定指令集,从而优化程序的执行。这使得eBPF程序可以像本地编译的内核代码或作为内核模块加载的代码一样地运行。

eBPF程序的其中一个重要方面是共享和存储所收集的信息和状态的能力。为此,eBPF程序可以利用eBPF Maps的概念来存储和检索各种数据结构中的数据。如下图所示,eBPF Maps既可以从eBPF程序访问,也可以通过系统调用从用户空间中的应用程序访问。

一个典型的eBPF程序包含两个部分:内核空间代码(*_kern.c)和用户空间代码(*_user.c)。

  1. 内核空间代码定义逻辑:
功能 说明
定义事件处理逻辑 编写当某个内核事件触发时要执行的代码(如系统调用被调用、网络包到达、函数进入/退出等)
数据收集与过滤 在内核态直接捕获数据,进行初步过滤和聚合,减少用户态开销
Map操作 将处理结果写入BPF Map(内核与用户空间共享的键值存储)
触发通知 通过bpf_ringbuf_submit()或perf_event向用户空间发送事件
1
2
3
4
5
6
7
8
// 声明程序类型(如跟踪点、Kprobe、XDP等)
SEC("tracepoint/syscalls/sys_enter_openat")
int trace_openat(struct trace_event_raw_sys_enter *ctx)
{
// 内核态逻辑:读取参数、过滤、记录到Map
bpf_printk("PID %d opening file", bpf_get_current_pid_tgid() >> 32);
return 0;
}
  1. 用户空间代码负责加载和与内核交互:
功能 说明
加载eBPF程序 将编译好的*_kern.o字节码加载进内核,经过验证器检查
附着到钩子点 把程序挂到具体的事件源(如tracepoint、kprobe、XDP等)
管理BPF Map 创建、读取、更新Map,与内核程序交换数据
处理数据输出 从Map或Ring Buffer读取结果,格式化输出(打印、存文件、发网络等)
生命周期控制 信号处理、优雅退出、资源清理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main() {
// 1. 打开并加载eBPF对象
struct bpf_object *obj = bpf_object__open_file("program_kern.o", NULL);

// 2. 加载进内核(此时触发验证器)
bpf_object__load(obj);

// 3. 找到并附着程序到内核钩子
struct bpf_program *prog = bpf_object__find_program_by_name(obj, "trace_openat");
bpf_program__attach(prog);

// 4. 查找Map并读取数据
struct bpf_map *map = bpf_object__find_map_by_name(obj, "events");

// 5. 轮询或事件驱动地处理数据...
while (running) {
// 从Ring Buffer读取内核发来的事件
}

// 6. 清理
bpf_object__close(obj);
}

在实际开发中,eBPF并不是直接使用,而是通过像Cilium、bcc或bpftrace这样的项目间接使用,这些项目提供了eBPF上面的抽象,不需要直接编写定义程序,而是提供了基于意图的来实现的能力,然后用eBPF实现。以下是一些eBPF开发工具:

  • BCC:一个基于Python的工具链,简化了eBPF程序的编写、编译和加载。它提供了许多预构建的追踪工具,但在依赖和兼容性方面存在一些限制。
  • eBPF Go库:一个Go库,解耦了获取eBPF字节码的过程与加载和管理eBPF程序的过程。
  • libbpf-bootstrap:基于libbpf的现代脚手架,提供了高效的工作流用于编写eBPF程序,提供简单的一次性编译过程以生成可重用的字节码。
  • eunomia-bpf:一个用于编写仅包含内核空间代码的eBPF程序的工具链,它通过动态加载eBPF程序简化了eBPF程序的开发。
  • Aya:一个纯Rust实现的eBPF开发框架,提供了完全基于Rust的工具链来同时编写内核空间和用户空间代码。

环境搭建

由于eBPF是内核技术,因此需要具备较新版本的Linux内核(推荐Ubuntu24.04),以支持eBPF功能。以ebpf-go框架为例,安装如下依赖与相关包:

1
2
3
4
5
6
7
sudo apt-get install -y make clang llvm libelf-dev libbpf-dev bpfcc-tools libbpfcc-dev linux-tools-$(uname -r) linux-headers-$(uname -r)

go env -w GOPROXY=https://goproxy.cn,direct
go install github.com/cilium/ebpf/cmd/bpf2go@latest
go install github.com/aya-rs/bpf-linker@latest
echo 'export PATH=$PATH:$(go env GOPATH)/bin' >> ~/.bashrc
source ~/.bashrc

或者可以搭建一个Docker环境来进行代码的编写和编译。

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
100
101
102
103
104
105
106
107
108
109
110
111
112
FROM --platform=linux/amd64 ubuntu:24.04

ENV DEBIAN_FRONTEND=noninteractive
ENV CLANG_VERSION=18
ENV SSH_USER=root
ENV SSH_PASSWORD=root

# 换源并安装 eBPF 开发依赖
RUN set -eux; \
sed -i 's|http://archive.ubuntu.com/ubuntu|https://mirrors.aliyun.com/ubuntu|g' /etc/apt/sources.list && \
sed -i 's|http://security.ubuntu.com/ubuntu|https://mirrors.aliyun.com/ubuntu|g' /etc/apt/sources.list && \
apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates build-essential git make cmake pkg-config \
curl wget vim openssh-server sudo \
clang-${CLANG_VERSION} \
lld-${CLANG_VERSION} \
llvm-${CLANG_VERSION} \
llvm-${CLANG_VERSION}-dev \
libclang-${CLANG_VERSION}-dev \
libbpf-dev \
libelf-dev \
zlib1g-dev \
libssl-dev \
linux-headers-generic \
libc6-dev-amd64-cross \
gcc-x86-64-linux-gnu \
linux-tools-generic \
libncurses-dev \
libcap-dev \
libbfd-dev \
trace-cmd tcpdump strace \
iproute2 iputils-ping net-tools ethtool \
python3 python3-pip python3-dev \
python3-setuptools python3-wheel \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

# 设置 Clang/LLVM 默认版本
RUN update-alternatives --install /usr/bin/clang clang /usr/bin/clang-${CLANG_VERSION} 100 \
&& update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-${CLANG_VERSION} 100 \
&& update-alternatives --install /usr/bin/llc llc /usr/bin/llc-${CLANG_VERSION} 100 \
&& update-alternatives --install /usr/bin/llvm-strip llvm-strip /usr/bin/llvm-strip-${CLANG_VERSION} 100 \
&& update-alternatives --install /usr/bin/llvm-objdump llvm-objdump /usr/bin/llvm-objdump-${CLANG_VERSION} 100

# 安装 bpftool
RUN set -eux; \
git clone --recurse-submodules --depth=1 https://github.com/libbpf/bpftool.git /tmp/bpftool && \
cd /tmp/bpftool/src && \
make -j$(nproc) && \
make install && \
bpftool version && \
rm -rf /tmp/bpftool

# 安装 Go
ENV GOPATH=/root/go
ENV PATH=$PATH:/usr/local/go/bin:$GOPATH/bin

RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
wget -q "https://go.dev/dl/go1.24.0.linux-${arch}.tar.gz" -O /tmp/go.tar.gz && \
tar -C /usr/local -xzf /tmp/go.tar.gz && \
rm /tmp/go.tar.gz && \
go version

# 配置 Go 并安装 eBPF 工具
RUN set -eux; \
go env -w GOPROXY=https://goproxy.cn,direct && \
go env -w GOSUMDB=sum.golang.google.cn && \
go install github.com/cilium/ebpf/cmd/bpf2go@latest && \
which bpf2go

# 安装 Rust
ENV CARGO_HOME=/root/.cargo
ENV RUSTUP_HOME=/root/.rustup
ENV PATH=$PATH:$CARGO_HOME/bin

RUN set -eux; \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable && \
. $CARGO_HOME/env && \
rustup toolchain install nightly && \
rustup component add rust-src --toolchain nightly && \
rustup component add rustfmt clippy && \
cargo install cargo-generate

# 写入 Bash 启动环境, 进入容器后可直接使用 Go/Rust/eBPF 工具
RUN set -eux; \
cat >> /root/.bashrc <<'EOF'

# eBPF development toolchain
export GOPATH=/root/go
export CARGO_HOME=/root/.cargo
export RUSTUP_HOME=/root/.rustup
export PATH=/usr/local/go/bin:$GOPATH/bin:$CARGO_HOME/bin:$PATH
EOF

# 配置 SSH
RUN set -eux; \
mkdir -p /var/run/sshd && \
echo "root:${SSH_PASSWORD}" | chpasswd && \
mkdir -p /root/.ssh && \
chmod 700 /root/.ssh && \
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config && \
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config && \
sed -i 's/^#\?UsePAM.*/UsePAM no/' /etc/ssh/sshd_config && \
ssh-keygen -A

WORKDIR /workspace

EXPOSE 22

CMD ["/usr/sbin/sshd", "-D", "-e"]
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
version: "3.8"

services:
ebpf-dev:
build:
context: .
dockerfile: Dockerfile
platforms:
- linux/amd64
image: ebpf-dev:latest
container_name: ebpf-dev
hostname: ebpf-dev
privileged: false

ports:
- "2222:22"

volumes:
- ./workspace:/workspace
- ~/.ssh:/root/.ssh:ro

deploy:
resources:
limits:
cpus: '4'
memory: 8G

environment:
- GO_ENV=development
- GOPROXY=https://goproxy.cn,direct
- GOSUMDB=sum.golang.google.cn

restart: unless-stopped
stdin_open: true
tty: true

测试代码

先编写一个eBPF程序,并使用bpf2go进行编译,该程序运行在内核态。它挂到sock/inet_sock_set_state tracepoint,筛选IPv4、TCP、状态变为已建立、且本地端口是22的连接,然后把客户端IP、客户端端口和服务端端口写进ring buffer,上报给用户态。

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
/* SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause) */
#define BPF_NO_GLOBAL_DATA
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

// 仅处理IPv4 + TCP + 新建SSH连接
#define AF_INET 2
#define IPPROTO_TCP 6
#define TCP_ESTABLISHED 1
#define SSH_PORT 22

// 每次触发上报到用户态的事件结构
struct event {
__u8 src_ip[4];
__u16 src_port;
__u16 dst_port;
};

// ring buffer, 将每次SSH连接事件推送给用户态程序实时读取
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");

// 挂载到inet_sock_set_state, 关注连接进入ESTABLISHED状态
SEC("tracepoint/sock/inet_sock_set_state")
int handle_ssh_conn(struct trace_event_raw_inet_sock_set_state *ctx)
{
if (ctx->family != AF_INET) {
return 0;
}

if (ctx->protocol != IPPROTO_TCP) {
return 0;
}

if (ctx->newstate != TCP_ESTABLISHED) {
return 0;
}

// 对服务端视角, sport是本地端口, 过滤本地22端口
if (ctx->sport != SSH_PORT) {
return 0;
}

// daddr为客户端IP, dport为客户端端口
struct event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (e) {
e->src_ip[0] = ctx->daddr[0];
e->src_ip[1] = ctx->daddr[1];
e->src_ip[2] = ctx->daddr[2];
e->src_ip[3] = ctx->daddr[3];
e->src_port = ctx->dport;
e->dst_port = ctx->sport;
bpf_ringbuf_submit(e, 0);
}

return 0;
}

// eBPF程序许可证声明, 内核加载器会读取该字段
char LICENSE[] SEC("license") = "Dual BSD/GPL";

接着编写用户态程序,负责加载eBPF对象、把程序挂到tracepoint、打开ring buffer读事件,并把读到的源IP打印出来。

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

package main

// 使用bpf2go将C代码编译并生成Go绑定代码
// 生成后会得到helloObjects、loadHelloObjects等类型/函数供本文件调用
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags "-O2 -g -Wall -I/usr/include/x86_64-linux-gnu" hello ../../bpf/hello.bpf.c -- -I../../bpf

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

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

type event struct {
SrcIP [4]byte
SrcPort uint16
DstPort uint16
}

func main() {
// 监听Ctrl+C/SIGTERM, 用于退出
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

// 放宽memlock限制, 避免加载eBPF对象时因锁页内存不足失败
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatalf("remove memlock limit failed: %v", err)
}

// 加载由bpf2go生成的eBPF程序与map
var objs helloObjects
if err := loadHelloObjects(&objs, nil); err != nil {
log.Fatalf("load eBPF objects failed: %v", err)
}
defer objs.Close()

// 将eBPF程序挂载到inet_sock_set_state tracepoint
tp, err := link.Tracepoint("sock", "inet_sock_set_state", objs.HandleSshConn, nil)
if err != nil {
log.Fatalf("attach tracepoint failed: %v", err)
}
defer tp.Close()

// 打开ring buffer读取器, 用于实时接收每次触发事件
rd, err := ringbuf.NewReader(objs.Events)
if err != nil {
log.Fatalf("open ring buffer failed: %v", err)
}
defer rd.Close()

go func() {
<-ctx.Done()
_ = rd.Close()
}()

log.Println("eBPF program attached. Printing SSH source IP on new connections. Press Ctrl+C to exit.")
for {
rec, err := rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrClosed) {
log.Println("stopping...")
return
}
log.Printf("read ring buffer failed: %v", err)
continue
}

var e event
if err := binary.Read(bytes.NewReader(rec.RawSample), binary.LittleEndian, &e); err != nil {
log.Printf("decode event failed: %v", err)
continue
}

srcIP := net.IPv4(e.SrcIP[0], e.SrcIP[1], e.SrcIP[2], e.SrcIP[3])
fmt.Printf("new SSH connection: src=%s:%d dst_port=%d\n", srcIP.String(), e.SrcPort, e.DstPort)
}
}