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 火焰图诊断三种典型瓶颈:

  • 红色:锁等待(`mutex_lock`、`futex_wait`、`io_schedule`)
  • 橙色:I/O 阻塞(`ext4_file_read_iter`、`sync_read`)
  • 蓝色:epoll_wait 等待(正常 IO 等待,大多数服务属于此类)
  • 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 内解决当前问题。                        
                        
    点赞(0) 打赏

    评论列表 共有 0 条评论

    暂无评论
    立即
    投稿

    微信公众账号

    微信扫一扫加关注

    发表
    评论
    返回
    顶部