eBPF 是什么

eBPF(extended Berkeley Packet Filter)让你可以在不修改内核源码、不加载内核模块的前提下,把一小段经过安全校验的程序注入内核执行。

它已经成为云原生可观测性、网络与安全的事实标准:

  • 网络:Cilium 用它替代 kube-proxy 与 iptables;
  • 可观测性:Pixie、Parca、Pyroscope 用它采集指标与火焰图;
  • 安全:Falco、Tetragon 用它做运行时行为审计。

一、最快的上手方式:bpftrace

# 安装
apt install -y bpftrace

# 统计所有进程的系统调用次数
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'

# 查看慢于 10ms 的文件读取
bpftrace -e '
  tracepoint:syscalls:sys_enter_read /args->count > 0/ {
    @start[tid] = nsecs;
  }
  tracepoint:syscalls:sys_exit_read /@start[tid]/ {
    $d = (nsecs - @start[tid]) / 1_000_000;
    if ($d > 10) { printf("%s %dms\n", comm, $d); }
    delete(@start[tid]);
  }'

一行命令就能看到内核视角的行为,这是传统 APM 做不到的。

二、用 Go 写一个真正的探针(Cilium eBPF 库)

先写 C 侧的 eBPF 程序:

// counter.bpf.c
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, __u32);      // pid
    __type(value, __u64);    // 次数
} open_count SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_openat")
int count_open(void *ctx) {
    __u64 pid = bpf_get_current_pid_tgid() >> 32;
    __u64 *val = bpf_map_lookup_elem(&open_count, &pid);
    if (val) {
        __sync_fetch_and_add(val, 1);
    } else {
        __u64 init = 1;
        bpf_map_update_elem(&open_count, &pid, &init, BPF_ANY);
    }
    return 0;
}

char LICENSE[] SEC("license") = "GPL";

Go 侧加载与读取:

//go:build linux

package main

import (
    "log"
    "os"
    "os/signal"
    "syscall"

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

func main() {
    // 移除内存锁定限制(5.11+ 内核不需要)
    if err := rlimit.RemoveMemlock(); err != nil {
        log.Fatal(err)
    }

    spec, err := ebpf.LoadCollectionSpec("counter.bpf.o")
    if err != nil {
        log.Fatal(err)
    }

    coll, err := ebpf.NewCollection(spec)
    if err != nil {
        log.Fatalf("加载失败: %v", err)
    }
    defer coll.Close()

    l, err := link.Tracepoint("syscalls", "sys_enter_openat", coll.Programs["count_open"], nil)
    if err != nil {
        log.Fatal(err)
    }
    defer l.Close()

    log.Println("探针已挂载,Ctrl+C 退出")
    sig := make(chan os.Signal, 1)
    signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
    <-sig

    // 读取结果
    m := coll.Maps["open_count"].(*ebpf.Map)
    var key uint32
    var val uint64
    iter := m.Iterate()
    for iter.Next(&key, &val) {
        log.Printf("pid=%d openat=%d\n", key, val)
    }
}

编译:

clang -O2 -target bpf -D__TARGET_ARCH_x86 -c counter.bpf.c -o counter.bpf.o
# 用 bpf2go 可以把 .o 直接嵌进 Go 二进制
GOOS=linux go generate ./...
go build -o probe

三、CO-RE:一次编译,到处运行

早期 eBPF 最大的痛点是内核结构体布局差异。BPF CO-RE(Compile Once, Run Everywhere)配合 BTF 解决了这个问题:

#include <vmlinux.h>
#include <bpf/bpf_core_read.h>

SEC("kprobe/__x64_sys_execve")
int trace_exec(struct pt_regs *ctx) {
    struct task_struct *task = (struct task_struct *)bpf_get_current_task();

    // CO-RE 重定位:字段偏移在加载时按目标内核修正
    pid_t pid = BPF_CORE_READ(task, pid);
    char comm[16];
    bpf_core_read_str(&comm, sizeof(comm), &task->comm);

    bpf_printk("exec: pid=%d comm=%s\n", pid, comm);
    return 0;
}

只要目标内核带 BTF(/sys/kernel/btf/vmlinux,主流发行版默认开启),同一个 .o 文件就能跨内核版本运行。

四、生产落地注意点

  1. 验证器限制:eBPF 程序必须通过校验(无越界、无死循环)。早期内核指令数上限较低,复杂逻辑建议拆成多程序 + tail call。
  2. 开销可控:tracepoint/kprobe 每次事件都会执行,高频事件(如 sys_enter_*)务必在内核态聚合,不要每个事件都 bpf_perf_event_output 到用户态。
  3. 权限与安全:加载 eBPF 需要 CAP_BPF 或 root;Kubernetes 里建议用 DaemonSet 集中采集,而不是给业务容器加特权。
  4. 可移植性:优先用稳定的 tracepoint 而非 kprobe;kprobe 符号名可能随版本变化。

五、工具选型速查

场景推荐
临时排查bpftrace、BCC 工具集
Go 长期服务cilium/ebpf + bpf2go
Rustaya
Kubernetes 网络Cilium
持续 profilingParca / Pyroscope

小结

eBPF 把“观察系统”这件事从应用层下沉到了内核层,且成本极低——不改代码、不重启服务、开销可控。掌握它,等于拿到了一把能解释任何性能问题的万能钥匙。