Linux eBPF 实战:从内核探针到可观测性革命
三十年的等待:为什么现在轮到 eBPF
2024 年 Linux 内核维护者峰会上,Linus Torvalds 被问及对 eBPF 的看法。这位很少正面评价社区项目的创始人说了四个字:"game changer."
这不是客套话。在此之前,修改内核行为意味着重新编译内核,或者加载内核模块——两者都伴随着生产事故的风险和数小时的维护窗口。eBPF 的出现首次在"运行中的内核"与"极其危险的修改"之间划出了一条安全边界。
你可以把 eBPF 理解为一个内核态的沙盒化虚拟机:你写的程序会在内核执行,但永远不会让系统崩溃。得益于验证器(verifier)的静态分析和 JIT 编译,eBPF 程序在加载前会被逐条指令检查,确保没有无限循环、没有非法内存访问、没有不可达路径。
这篇文章会从零构建多个 eBPF 实战项目,覆盖 kprobe/uprobe、XDP、tracepoint、BPF ring buffer 等核心机制,并展示如何用 eBPF 解决真实的生产环境可观测性问题。
运行前准备
在开始前,确保你的内核版本在 5.8 以上(推荐 5.15+ LTS),并安装必要的工具链。
# 检查内核版本
uname -r
# 安装 eBPF 工具链 (Ubuntu/Debian)
sudo apt update
sudo apt install -y linux-headers-$(uname -r) \
libbpf-dev clang llvm \
linux-tools-$(uname-rr) \
bpfcc-tools bpftool
# 验证 libbpF 安装
pkg-config --modversion libbpf
# 验证 BTF 支持
ls /sys/kernel/btf/vmlinux
BTF(BPF Type Format)是现代 eBPF 的基石——它允许 eBPF 程序跨内核版本兼容,而无需为每个内核版本编译不同的版本。2020 年以后的大多数发行版内核都开启了 CONFIG_DEBUG_INFO_BTF=y。
检查清单:
✓ 内核版本 ≥ 5.8 (推荐 5.15+ LTS)
✓ libbpf ≥ 0.5 (推荐 1.0+)
✓ BTF 支持: /sys/kernel/btf/vmlinux
✓ 权限: root 或 CAP_BPF 能力
✓ 调试: /sys/kernel/debug/tracing
eBPF 程序生命周期
要让一个 eBPF 程序在内核中执行,需要经历五个阶段。理解这个流程对排错至关重要:
编写 C 源码 → clang 编译为 BPF ELF → sys_bpf() 加载
→ verifier 验证 → JIT 编译为机器码 → 挂载到钩子点
第三阶段的 Syscall `BPF(BPF_PROG_LOAD)` 是关键分水岭:verifier 会模拟执行你的程序,追踪每条指令的寄存器状态和栈指针偏移。如果发现越界访问、未初始化读取或可能无限循环的路径,加载会直接失败,返回 EINVAL。
下面是一个 verifier 会拒绝的典型例子:
// 错误示范:verifier 会报错
SEC("kprobe/do_sys_openat2")
int BPF_KPROBE(open_probe)
{
char buf[256];
// buf 未初始化,verifier 拒绝:可能读取未初始化的栈内存
bpf_printk("buf[0]=%c\n", buf[0]);
return 0;
}
// 正确做法
SEC("kprobe/do_sys_openat2")
int BPF_KPROBE(open_probe_fixed)
{
char buf[256] = {0}; // 显式零初始化
bpf_printk("safe buf[0]=%c\n", buf[0]);
return 0;
}
在排错时,如果 `bpf(BPF_PROG_LOAD)` 失败,可以通过 `strace -e bpf` 或 `libbpf_set_print()` 设置回调来获取 verifier 的详细拒绝日志。日志中会标注"back-edge from insn X to Y"(发现可能环路)或"R1 invalid mem access 'inv'"(非法内存访问)等具体原因。
Hello World:第一个 kprobe 追踪程序
我们从最小可用的 eBPF 程序开始——在内核函数 `do_sys_openat2` 被调用时打印一行 trace。
open.c(eBPF 内核侧程序):
// open.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
char LICENSE[] SEC("license") = "GPL";
SEC("kprobe/do_sys_openat2")
int BPF_KPROBE(trace_openat2, int dfd, const char *filename, struct open_flags *flags)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("PID %d opening %s\n", pid, filename);
return 0;
}
Makefile(编译与加载):
# Makefile
CLANG ?= clang
LLC ?= llc
CFLAGS := -g -O2 -target bpf -D__TARGET_ARCH_x86
open.bpf.o: open.bpf.c
$(CLANG) $(CFLAGS) -c $< -o $@
load: open.bpf.o
sudo bpftool prog load open.bpf.o /sys/fs/bpf/open_probe \
type kprobe autoattach
unload:
sudo rm /sys/fs/bpf/open_probe
clean:
rm -f *.o
编译并运行后,观察 `/sys/kernel/debug/tracing/trace_pipe` 的输出。你会看到实时的文件打开记录,这是理解系统行为的利器。
# 加载
sudo make load
# 观察追踪输出
sudo cat /sys/kernel/debug/tracing/trace_pipe
# 另开一个终端执行任意命令以触发追踪
ls /tmp
# 卸载
sudo make unload
需要特别说明的是 `SEC("kprobe/...")` 这个节名约定:libbpf 会根据路径中的函数名自动构造 `struct bpf_program` 的附加信息,无需手动 `kprobe_attach`。
如果你的内核版本低于 5.5 且没有 `BPF_KPROBE` 宏,可以用传统的 PT_REGS 方式访问参数:
// 兼容旧内核(< 5.5)的写法
SEC("kprobe/do_sys_openat2")
int trace_openat2_old(struct pt_regs *ctx)
{
const char *filename = (const char *)PT_REGS_PARM2(ctx);
u32 pid = bpf_get_current_pid_tgid() >> 32;
bpf_printk("PID %d opening %s\n", pid, filename);
return 0;
}
BPF Maps:内核态-用户态的高速数据通道
`bpf_printk` 适合调试,但它经过 `trace_pipe`,既慢又不便程序化消费。生产环境应该使用 BPF Maps——eBPF 在 Google 时期就构建的通用数据结构。
Libt 提供的 map 类型已逾 30 种,可按四个维度选择:
按功能分:
┌─────────────────────┬──────────────────────────────────────┐
│ 通用型 │ BPF_MAP_TYPE_HASH, ARRAY, LRU_HASH │
│ │ LRU_CPU_HASH, PERCPU_HASH │
├─────────────────────┼──────────────────────────────────────┤
│ 事件推送型 │ BPF_MAP_TYPE_RINGBUF (推荐) │
│ │ BPF_MAP_TYPE_PERF_EVENT_ARRAY (兼容) │
├─────────────────────┼──────────────────────────────────────┤
│ 栈追踪型 │ BPF_MAP_TYPE_STACK_TRACE │
│ │ BPF_MAP_TYPE_LPM_TRIE │
├─────────────────────┼──────────────────────────────────────┤
│ 批处理/队列型 │ BPF_MAP_TYPE_QUEUE, STACK │
│ │ BPF_MAP_TYPE_HASH_OF_MAPS │
└─────────────────────┴──────────────────────────────────────┘
选型决策:
• 键值查找 + 高写入吞吐 → LRU_PERCPU_HASH
• 定时聚合 → PERCPU_ARRAY (避免原子操作)
• 事件流 → ring buffer (零拷贝, per-CPU 预留)
• 长生命周期 → HASH + 用户态定期清理
ring buffer 是 5.8 引入的新一代事件推送接口,替代了旧版 `perf_event_array`。它的 API 更接近内存映射的环形缓冲区,不会丢消息(在缓冲区满时拒绝新写入而非覆盖),语义更清晰。
下面用 ring buffer 重构 open 追踪程序,在用户态消费事件:
// open.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#define MAX_PATH 256
struct event {
u32 pid;
u32 uid;
u64 timestamp;
char comm[16]; // TASK_COMM_LEN
char filename[MAX_PATH];
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024); // 256KB ring buffer
} rb SEC(".maps");
char LICENSE[] SEC("license") = "GPL";
SEC("kprobe/do_sys_openat2")
int BPF_KPROBE(trace_openat2, int dfd, const char *filename, struct open_flags *flags)
{
struct event *e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e) return 0;
e->pid = bpf_get_current_pid_tgid() >> 32;
e->uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
e->timestamp = bpf_ktime_get_ns();
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_probe_read_user_str(&e->filename, sizeof(e->filename), filename);
bpf_ringbuf_submit(e, 0);
return 0;
}
用户态加载程序和消费 ring buffer(使用 libbpf skeleton):
// open.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include "open.skel.h"
static volatile bool running = true;
void sig_handler(int sig) { running = false; }
static int handle_event(void *ctx, void *data, size_t len)
{
struct event *e = data;
printf("%-6d %-6d %-16s %llu %s\n",
e->pid, e->uid, e->comm,
e->timestamp, e->filename);
return 0;
}
int main(int argc, char **argv)
{
struct open_bpf *skel;
struct ring_buffer *rb;
int err;
signal(SIGINT, sig_handler);
signal(SIGTERM, sig_handler);
skel = open_bpf__open_and_load();
if (!skel) { fprintf(stderr, "open load failed\n"); return 1; }
err = open_bpf__attach(skel);
if (err) { fprintf(stderr, "attach failed\n"); goto cleanup; }
rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), handle_event, NULL, NULL);
if (!rb) { fprintf(stderr, "ring buffer create failed\n"); goto cleanup; }
printf("%-6s %-6s %-16s %s\n", "PID", "UID", "COMM", "TIMESTAMP FILENAME");
while (running) {
err = ring_buffer__poll(rb, 100);
if (err == -EINTR) err = 0;
if (err < 0) break;
}
cleanup:
open_bpf__destroy(skel);
return err < 0 ? -err : 0;
}
这套架构的核心优势是零拷贝:ring buffer 在内核态预留(`bpf_ringbuf_reserve`)、填充(写数据)、提交(`bpf_ringbuf_submit`),用户态 `ring_buffer__poll` 直接看到同一块物理内存,不需要额外 `read()` 系统调用或内核缓冲区到用户缓冲区的 `copy_to_user`。
进阶篇:uprobe 追踪 glibc malloc
uprobe 让我们可以在用户空间函数的入口/出口动态插入探针。这是观察应用程序行为而不侵入源码的利器。
下面追踪 `glibc` 的 `malloc` 调用链,识别分配大小超过 256KB 的"大分配":
// mem_tracer.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
struct alloc_event {
u32 pid;
u64 size;
u64 addr; // 分配返回地址
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_events, 16 * 1024);
} events SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, u64); // pid_tgid
__type(value, u64); // malloc size
} in_malloc SEC(".maps");
char LICENSE[] SEC("license") = "GPL";
// uprobe: 进入 malloc 时记录请求的 size
SEC("uprobe//lib/x86_64-linux-gnu/libc.so.6:malloc")
int trace_malloc_enter(struct pt_regs *ctx)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
u64 size = PT_REGS_PARM1(ctx);
if (size > 256 * 1024) {
// 记录入参,等 uretprobe 取返回值
bpf_map_update_elem(&in_malloc, &pid_tgid, &size, BPF_ANY);
}
return 0;
}
// uretprobe: 退出 malloc 时获取返回值
SEC("uretprobe//lib/x86_64-linux-gnu/libc.so.6:malloc")
int trace_malloc_exit(struct pt_regs *ctx)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
u64 *size_p = bpf_map_lookup_elem(&in_malloc, &pid_tgid);
if (!size_p) return 0;
u64 addr = PT_REGS_RC(ctx); // RAX = malloc 返回值
struct alloc_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (e) {
e->pid = pid_tgid >> 32;
e->size = *size_p;
e->addr = addr;
bpf_ringbuf_submit(e, 0);
}
bpf_map_delete_elem(&in_malloc, &pid_tgid);
return 0;
}
Segment: 大内存分配追踪输出
$ sudo ./mem_tracer -p $(pidof myapp)
PID SIZE(KB) ADDRESS TIME_NS
12345 512 0x7f8a40000000 18446744073709551615
12345 1024 0x7f8a50000000 18446744073709551615
对于大内存分配追踪,核心洞察是通过 size 阈值过滤发现分配模式变化。对于长时间运行的进程,这种时间维度的分配监控能提前预警内存泄漏。
进阶篇:XDP 拒绝服务防护
XDP(eXpress Data Path)是 eBPF 在网络领域的杀手级应用。它在数据包进入 Linux 栈之前执行——甚至在 `sk_buff` 分配之前——因此具备极致的低延迟处理性能。
下面是一个三层/四层 DDoS 防御的 XDP 程序,结合 IP 黑名单、SYN 速率限制和 ICMP 限速:
// xdp_ddos.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
#define MAX_BLACKLIST 4096
#define MAX_SYN_TRACK 65536
#define SYN_THRESHOLD 100 // 每秒 SYN 包上限
struct {
__uint(type, BPF_MAP_TYPE_LPM_TRIE);
__uint(max_entries, MAX_BLACKLIST);
__type(key, struct bpf_lpm_trie_key);
__type(value, u32);
__uint(map_flags, BPF_F_NO_PREALLOC);
} blacklist SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);
__uint(max_entries, MAX_SYN_TRACK);
__type(key, __be32); // source IP
__type(value, u64); // last timestamp + count 打包
} syn_count SEC(".maps");
char LICENSE[] SEC("license") = "GPL";
static __always_inline int parse_eth_ip(struct xdp_md *ctx, struct iphdr **ip)
{
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return -1;
if (eth->h_proto != bpf_htons(ETH_P_IP))
return -1;
*ip = (struct iphdr *)(eth + 1);
if ((void *)(*ip + 1) > data_end)
return -1;
return 0;
}
SEC("xdp")
int xdp_ddos_filter(struct xdp_md *ctx)
{
struct iphdr *ip;
if (parse_eth_ip(ctx, &ip) < 0)
return XDP_PASS;
__be32 src_ip = ip->saddr;
// 1. 检查 LPM 前缀黑名单
struct bpf_lpm_trie_key key32 = { .prefixlen = 32, .data = { src_ip } };
if (bpf_map_lookup_elem(&blacklist, &key32))
return XDP_DROP;
// 2. TCP SYN 速率限制
if (ip->protocol == IPPROTO_TCP) {
struct tcphdr *tcp = (struct tcphdr *)(ip + 1);
void *data_end = (void *)(long)ctx->data_end;
if ((void *)(tcp + 1) > data_end)
return XDP_PASS;
if (tcp->syn && !tcp->ack) {
u64 *entry = bpf_map_lookup_elem(&syn_count, &src_ip);
u64 now = bpf_ktime_get_ns();
if (entry) {
u64 count = *entry >> 24;
u64 last = *entry & 0xFFFFFFFFFF;
if (now - last < 1000000000ULL) { // 1 秒窗口
count++;
if (count > SYN_THRESHOLD)
return XDP_DROP;
__u64 new_val = (count << 24) | (now & 0xFFFFFFFFFF);
bpf_map_update_elem(&syn_count, &src_ip, &new_val, BPF_ANY);
} else {
// 新时间窗口
__u64 new_val = (1ULL << 24) | (now & 0xFFFFFFFFFF);
bpf_map_update_elem(&syn_count, &src_ip, &new_val, BPF_ANY);
}
} else {
__u64 new_val = (1ULL << 24) | (now & 0xFFFFFFFFFF);
bpf_map_update_elem(&syn_count, &src_ip, &new_val, BPF_ANY);
}
}
}
// 3. ICMP 限速:直接丢弃所有 ICMP (简单粗暴但有效生产)
if (ip->protocol == IPPROTO_ICMP)
return XDP_DROP;
return XDP_PASS;
}
加载方式:
# 用 iproute2 加载 XDP 程序到网卡
sudo ip link set dev eth0 xdp obj xdp_ddos.bpf.o sec xdp
# 或者用 bpftool 实现持久化加载
sudo bpftool prog load xdp_ddos.bpf.o /sys/fs/bpf/xdp_ddos \
type xdp dev eth0
# 添加黑名单 IP (CIDR 前缀)
sudo bpftool map update pinned /sys/fs/bpf/blacklist \
key 32 0 10 0 0 1 value 1
# 验证 XDP 已加载
sudo ip link show eth0
# 卸载 XDP
sudo ip link set dev eth0 xdp off
XDP 的三种执行模式各有生态:
┌──────────────┬───────────────┬─────────────────────┐
│ 执行模式 │ 延迟级别 │ 适用场景 │
├──────────────┼───────────────┼─────────────────────┤
│ 硬件卸载 │ 最低 (NIC 上) │ 运营商/云厂商, 裸金属│
│ 原生(驱动态) │ 低 (驱动层) │ 通用服务器, ReDriver │
│ 通用(SKB 态) │ 较高 (最晚) │ 虚拟机, 不支持的驱动 │
└──────────────┴───────────────┴─────────────────────┘
NIC 硬件卸载模式能将 eBPF 程序直接编译到 SmartNIC(NVIDIA ConnectX-6 Dx、Intel E810)的固件中,实现 100Gbps+ 的线速过滤,CPU 完全不参与。
进阶篇:tracepoint 系统调用追踪
tracepoint 是内核中预定义的稳定跟踪钩子点。与 kprobe 相比,tracepoint 的接口在版本间保持稳定,是二进制兼容的最佳选择。
以追踪 `execve` 系统调用为例,捕获进程启动事件构建"进程树指纹",用于安全审计:
// exec_tracer.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
struct exec_event {
u32 pid;
u32 ppid;
u32 uid;
char comm[16];
char argv[128]; // 简化:只取第一个参数
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 128 * 1024);
} events SEC(".maps");
char LICENSE[] SEC("license") = "GPL";
// 追踪点:syscalls:sys_enter_execve
SEC("tp/syscalls/sys_enter_execve")
int trace_execve(struct trace_event_raw_sys_enter *ctx)
{
struct exec_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e) return 0;
// 获取父母进程 ID(内核 task_struct)
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
e->pid = bpf_get_current_pid_tgid() >> 32;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
e->uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
bpf_get_current_comm(&e->comm, sizeof(e->comm));
// 读取 argv[0] (可执行文件路径)
const char *filename = (const char *)ctx->args[0];
bpf_probe_read_user_str(&e->argv, sizeof(e->argv), filename);
bpf_ringbuf_submit(e, 0);
return 0;
}
BPF_CORE_READ 是 libbpf 提供的 CO-RE(Compile Once, Run Everywhere)宏,它通过 BTF 信息在编译时将结构体字段偏移量重写到 eBPF 程序中。这意味着你的 eBPF 程序从 Kernel 5.10 编译后,可以直接在 Kernel 6.1 上运行,无需重新编译。
tracepoint 在稳定性上的优势极为突出:kprobe 追踪的是内核函数的实现细节——内核开发者可能重命名函数、调整参数签名、或者 merge/删除函数。2022 年的 Linux 5.18 中 `do_sys_open` 被拆分为 `do_sys_openat2`,如果你 kprobe 挂的是 `do_sys_open`,升级就会失效。但 `syscalls:sys_enter_execuge` 是内核 ABI 的一部分,罕有变动。
对于不需要参数提取、只需要计数或采样的场景,可以用 `tp_btf` 或者 `raw_tp`。它们比 tracepoint 更底层、限制更宽松。
进阶篇:CO-RE 与可移植性
CO-RE 是现代 eBPF 开发的事实标准。它的三个技术支柱:
1. BTF (BPF Type Format)
内核类型信息编码在 /sys/kernel/btf/vmlinux 中,
eBPF 程序在加载时读取目标系统的 BTF 进行重定位。
2. BPF_CORE_READ() 宏
替代直接洞穿 task_struct 的指针解引用,
libbpf 根据 BTF 在 load 时计算字段偏移。
3. vmlinux.h
用 bpftool gen vmlinux 生成的内核头文件,
包含所有结构体和函数的原型。
它与当前编译目标的内核版本匹配,但重定位到实际运行的内核。
生成 vmlinux.h 头文件:
# 从当前内核提取 BTF 并生成完整头文件
sudo bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
使用 `BPF_CORE_READ` 的交叉字段访问示例:
// 直接访问 (Linux 5.15 编译)
u64 utime = task->utime;
// CO-RE 兼容写法 (跨版本)
u64 utime = BPF_CORE_READ(task, utime);
// 多级解引用也安全(自动处理 NULL 指针?不,需手动检查)
// 但字段重定位解决了偏移变化
u32 pid = BPF_CORE_READ(task, group_leader, pid);
对比:
原生写法 CO-RE 写法
跨内核编译 ❌ 不可用 ✅
字段偏移修改 ❌ 必须改源码 ✅ 自动适配
函数签名变更 ❌ 必须重写 ✅ (用隐蔽点)
调试符号 ID GDB 指令地址 BTF 字段名可读
实战案例一:容器逃逸检测
在 Kubernetes 环境下,容器逃逸是最严重的安全威胁之一。下面用 eBPF 实时检测可疑的 `unshare` 和 `mount` 系统调用,这些是容器逃逸的常见路径。
// container_escape.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#define UNSHARE_NEWPID 0x20000000
#define UNSHARE_NEWNET 0x40000000
#define CLONE_NEWUSER 0x10000000
struct escape_event {
u32 pid;
u32 uid;
u64 flags;
u32 syscall_id;
char comm[16];
char container_id[12]; // 截断 cgroup 路径取容器 ID
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 64 * 1024);
} events SEC(".maps");
static __always_inline int get_container_id(char *buf, int len)
{
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
// 从 cgroup 路径提取 k8s pod 容器 ID
// 简化:直接读取 task->cgroups->dfl_cgrp->kn->name
// 生产环境需根据 cgroupfs 版本做路径解析
struct css_set *cgroups = BPF_CORE_READ(task, cgroups);
if (!cgroups) return -1;
struct cgroup *cgrp = BPF_CORE_READ(cgroups, dfl_cgrp);
if (!cgrp) return -1;
struct kernfs_node *kn = BPF_CORE_READ(cgrp, kn);
if (!kn) return -1;
bpf_probe_read_kernel_str(buf, len, BPF_CORE_READ(kn, name));
return 0;
}
SEC("tp/syscalls/sys_enter_unshare")
int detect_unshare(struct trace_event_raw_sys_enter *ctx)
{
u64 flags = ctx->args[0];
// 容器内进程尝试创建新的 PID/Network/User 命名空间
if (flags & (UNSHARE_NEWPID | UNSHARE_NEWNET | CLONE_NEWUSER)) {
struct escape_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e) return 0;
e->pid = bpf_get_current_pid_tgid() >> 32;
e->uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
e->flags = flags;
e->syscall_id = 1; // unshare
bpf_get_current_comm(&e->comm, sizeof(e->comm));
get_container_id(e->container_id, sizeof(e->container_id));
bpf_ringbuf_submit(e, 0);
}
return 0;
}
SEC("tp/syscalls/sys_enter_mount")
int detect_mount(struct trace_event_raw_sys_enter *ctx)
{
struct escape_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e) return 0;
// source = args[0], type = args[2]
const char *source = (const char *)ctx->args[0];
const char *fstype = (const char *)ctx->args[2];
char fs[16] = {};
bpf_probe_read_user_str(fs, sizeof(fs), fstype);
// 挂载 procfs/tmpfs 到新命名空间是逃逸标志
// 实际中可加入白名单:非容器内 root 用户的 mount 调用
if (bpf_strncmp(fs, 4, "proc") == 0 ||
bpf_strncmp(fs, 6, "tmpfs") == 0) {
e->pid = bpf_get_current_pid_tgid() >> 32;
e->uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
e->flags = 0;
e->syscall_id = 2; // mount
bpf_get_current_comm(&e->comm, sizeof(e->comm));
get_container_id(e->container_id, sizeof(e->container_id));
bpf_ringbuf_submit(e, 0);
}
bpf_ringbuf_discard(e, 0);
return 0;
}
char LICENSE[] SEC("license") = "GPL";
部署在 Kubernetes 节点上的效果:
节点: worker-3 | 命名空间: production
─────────────────────────────────────────────
PID UID SYSCALL COMM CONTAINER_ID
8521 0 unshare exploit.sh a1b2c3d4e5f6
8521 0 mount exploit.sh a1b2c3d4e5f6
告警: 容器 a1b2c3d4e5f6 (pod: webapp-7df8b5d6c9-x4v5k)
尝试执行 unshare+mount 组合 (典型逃逸路径)
动作: 已触发 SIGKILL, 命名空间已隔离
将这个 eBPF 程序和 Falco、Tetragon 等安全框架集成,能得到生产级运行时安全检测体验。
实战案例二:性能火焰图
`bpf_get_stackid` 让你可以在 eBPF 中捕获内核栈和用户栈,配合 flamegraph.pl 生成火焰图,是分析 CPU 热点、内存分配、IO 延迟的"不二制法"。
下面是一个 off-CPU 时间追踪程序,捕获每次进程被调度出 CPU 时的唤醒链:
// offcpu.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#define MAX_STACK_DEPTH 20
struct key_t {
u32 pid;
u32 user_stack_id;
u32 kernel_stack_id;
char comm[16];
};
// 聚合 off-CPU 时间
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, struct key_t);
__type(value, u64); // off-CPU 总时间(ns)
} counts SEC(".maps");
// 保存入队时刻的唤醒时间
struct {
__uint(type, BPF_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, u64); // pid_tgid
__type(value, u64); // timestamp
} start SEC(".maps");
// 栈存储 maps
struct {
__uint(type, BPF_MAP_TYPE_STACK_TRACE);
__uint(max_entries, 10240);
__uint(key_size, 4);
__uint(value_size, MAX_STACK_DEPTH * sizeof(u64));
} stackmap SEC(".maps");
char LICENSE[] SEC("license") = "GPL";
// finish_task_switch: 正在被换下来的进程还在执行
SEC("tp/sched/sched_switch")
int trace_sched_switch(struct trace_event_raw_sched_switch *ctx)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
u32 pid = pid_tgid >> 32;
u32 tid = pid_tgid & 0xFFFFFFFF;
// 只追踪目标进程(都追踪则设为 pid != 0)
if (pid == 0) return 0;
u64 ts = bpf_ktime_get_ns();
// 记录换出时刻
bpf_map_update_elem(&start, &pid_tgid, &ts, BPF_ANY);
// 同一个 CPU 上新进程正在进入,即 prev_state 不等于 0 时是睡眼
// 更精确:获取睡眠时间需要另外追踪 wake_up_new_task 和 try_to_wake_up
// 简化版:这里用 sched_switch + 唤醒时刻的双事件对
// 继续...
return 0;
}
更推荐直接使用 BCC 工具包中的 `offcputime` 脚本,它已经实现了精确的 off-CPU 时间火焰图:
# 安装 BCC-tools 后,追踪 PID 1234 的 off-CPU 调用栈 30 秒
sudo offcputime -p 1234 -f 30 > out.stacks
# 生成火焰图
./flamegraph.pl --color=io --countname=us --title="Off-CPU Time" \
out.stacks > offcpu-flamegraph.svg
火焰图解读
─────────────────────────────────────────────
█ 100% (3500ms total off-CPU time)
┌─ schedule (kernel)
│ ┌─ hrtimer_nanosleep (kernel)
│ │ ┌─ SyS_nanosleep (kernel)
│ │ │ ┌─ do_syscall_64 (kernel)
│ │ │ │ ┌─ ep_poll (kernel) ← 网络服务主循环
│ │ │ │ │ ┌─ ep_scan_ready_list
│ │ │ │ │ │
│ │ │ │ │ └─ ...
栈越宽代表该函数累积的 off-CPU 时间越长。
鼠标点击展开,右键重置缩放。
通过 off-CPU 火焰图诊断三种典型瓶颈:
eBPF 程序化开发框架对比
现代 eBPF 开发已 C 语言一家独大。不同语言和框架在做光谱上的选择:
┌─────────────────┬─────────┬─────────┬──────────┬─────────┐
│ 框架 │ 语言 │ 易用性 │ 性能 │ 生态 │
├─────────────────┼─────────┼─────────┼──────────┼─────────┤
│ libbpf-bootstrap│ C/C++ ★★★ ★★ ★★★★ │ ☆☆☆☆☆ │
│ BCC │ Python ★★★★★ ★★ ★★★ │ ☆☆☆☆☆☆ │
│ libbpf-rs │ Rust ★★ ★★★★ ★★★ │ ☆☆☆ │
│ Aya │ Rust ★★ ★★★★★ ★★★★☆ │ ☆☆☆☆ │
│ cilium/ebpf │ Go ★★★★ ★★ ★★ │ ☆☆☆☆☆ │
│ eunomia-bpf │ JSON/WASM│★★★★ ★★ ★★ │ ☆☆ │
└─────────────────┴─────────┴─────────┴──────────┴─────────┘
生产环境推荐 C (libbpf) 或 Rust (Aya)。
研究与原型迭代用 BCC/Python 最高效。
Go 系适合编写 operator、loader 等控制平面。
libbpf-bootstrap 模板提供:
# 快速开始模板仓库
git clone https://github.com/libbpf/libbpf-bootstrap.git
cd libbpf-bootstrap/examples/c
make
sudo ./bootstrap
如果你想用 Aya (Rust),几个值得注意的点:
// Aya 示例: 用 Rust 写 eBPF
use aya_ebpf::macros::kprobe;
use aya_ebpf::programs::ProbeContext;
#[kprobe]
fn try_wake_up(ctx: ProbeContext) -> u32 {
// Rust 编译器确保:eBPF 子集内不会 panic
match some_try(ctx) {
Ok(_) => 0,
Err(_) => 1,
}
}
fn some_try(ctx: ProbeContext) -> Result<u32, &'static str> {
let pid: u32 = ctx.arg(0).ok_or("missing pid")?;
Ok(pid)
}
Aya 借助 Rust 的类型系统,在编译时就避免了空指针、越界访问等问题,但它不能绕过 verifier 验证——实际上它鼓励你用更安全的抽象来间接满足 verifier 的约束。
最佳实践与避坑指南
下面是我在收集生产环境案例后整理的实用清单。
**Map 内存占用**:每个 BPF map 的内核内存是 `max_entries * value_size`,且以 page 对齐。如果你创建了一个 max_entries=1000000 的 HASH map,每个 entry 有额外 16 字节的 hash table 开销。1M entries × (64+16) bytes ≈ 76 MB。在容器化环境中,这会被 memcg 审计到目标 cgroup。用 LRU 类 MAP (如 LRU_HASH)限制热点 key 数量。
**Verifier 循环限制**:虽然 5.10+ 内核支持有限循环(在 verifier 证明安全的前提下),但 5.1 以下任何分支都被拒绝。不要在 eBPF 里写搜索。用 BPF_MAP_TYPE_LPM_TRIE 替代路由前缀搜索,用 BPF_MAP_TYPE_HASH_ASSOC_SIZE 替代关联查询。
**全局变量 vs Map**:CO-RE 支持在 ELF 中定义全局变量(`const volatile u32 threshold = 100;`),这些变量可以在用户态通过 `bpf_map__set_initial_value()` 修改。全局变量存储在 `.rodata` section 中,比 map 查找更快,适合只读/少写的配置参数。使用 map 需要做写入通道。
**Ring buffer 水位控制**:ring buffer 提供 `bpf_ringbuf_output()` 的 wakeup_watermark 回调,用于控制事件潮涌时的唤醒频率。如果你在用户态消费缓慢,ring buffer 在高写入量时会丢消息(丢弃模式由 `BPF_RB_NO_WAKEUP` / `BPF_RB_FORCE_WAKEUP` 标志控制)。对关键事件可以用 `BPF_MAP_TYPE_QUEUE` 替代 ring buffer。
**跨命名空间追踪**:进程(pid)命名空间内的 pid 不等于主机真实 pid。如果追踪容器内的进程,你需要在 eBPF 中同时获取 `task->nsproxy->pid_ns_for_children->ns` 的 inode 编号,结合主机 PID,才能在用户态正确关联。这是可观测性的常见问题。
**权限边界**:加载 eBPF 程序需要 `CAP_SYS_ADMIN`(5.8 以前)或 `CAP_BPF | CAP_PERFMON`(5.8+)。在 Kubernetes 的 docker 容器中需要设置 `securityContext.capabilities.add: [SYS_ADMIN]` 或者更细粒度的 `BPF,PERFMON,SYS_RESOURCE`。```text
Seccomp 过滤器可能拦截 bpf() 系统调用,需要提前放行。
## 性能数据
eBPF 的开销到底有多大?以下是 2024 年 Linux Foundation eBPF Survey 的量化数据:
追踪 100 万系统调用/秒:
├── bpf_printk (trace_pipe) : ~28% CPU overhead
├── perf_event_array (旧版) : ~5-8% CPU overhead
├── ring buffer (5.8+) : ~3-5% CPU overhead
└── (idle 状态,无事件空转) : <0.1% overhead
XDP 处理 10Gbps 小包(64B):
├── NIC 转发 + XDP_PASS : 0.3% CPU (单核)
├── IP 黑名单 + 速率限制 : 1.2% CPU (单核)
└── 完全 DDoS 防护 (200 条规则) : 4.8% CPU (单核)
JIT 编译延迟:
├── libbpF 程序加载 (首次 verifier) : 50-200ms
├── 迭代加载同类型新程序 : <5ms (verifier 缓存)
└── bpf(BPF_PROG_LOAD) 系统调用延迟 : <1ms
这些数据证实了 eBPF 的可负担性——在分布式追踪、网络控制平面、容器安全、性能剖析四大场景中,它几乎是"免费"的安全钩子。
## 下一步:eBPF 的未来展望
2025 年后 eBPF 的演进已经聚焦三个方向:
1. BPF Type Format (BTF) 升级 → 用户态函数追踪更稳定
未来的 uprobes 将不依赖特定函数签名直接挂地址。
2. BPF for Windows
Microsoft 开源了 eBPF on Windows,目标是能配合 WSL2 实现
Windows/Linux 统一网络策略。内核技术跨平台是里程碑。
3. eBPF 硬件卸载普及
NVIDIA BlueField DPU、Intel IPU、Broadcom Stingray 等
SmartNIC 已是数据中心的心跳,eBPF 固件卸载未来五年内将成为标配。
我们将看到一个 eBPF 原生(eBPF-native)的 Linux 内核——在今天的内核维护者看来,这不是科幻而是路线图的必然。
## 小结
本文从运行准备到 kprobe/uprobe、XDP、tracepoint 三种钩子,展示了 eBPF 在生产环境的可观测性与安全性价值。核心知识点回顾:
- BPF maps 是 kernel-userspace 数据通道,ring buffer 是性能最优解
- CO-RE(BPF_CORE_READ)让 eBPF 跨内核版本可移植
- verifier 是 eBPF 安全模型的基石,理解它的拒绝模式至关重要
- uprobe 打开了用户态追踪的大门,你可以追踪 Go/Rust/Python 等语言的内部
- XDP 在网络层实现微秒级 CPU 过滤,是 DDoS 防御利器
- libbpf-bootstrap 是入门模板,Aya/libbpf-rs 代表 Rust 未来
- BCC 工具包中的现成脚本(offcputime / tcpconnect / execsnoop)是真正的就业杠杆
真正的高手不是写出最长的 eBPF 程序,而是知道在三个 tracepoint 内解决当前问题。

发表评论 取消回复