eBPF(extended Berkeley Packet Filter)是过去十年 Linux 内核最激动人心的基础设施变革之一。过去,想在内核里"看一眼"某个事件,要么改内核源码、要么用永远滞后的 perf 采样;现在,借助 eBPF,你可以在内核几乎每个函数入口挂上自己写的小程序——安全、即时、无需重启机器,也不需要加载内核模块。
本文将从 BPF 程序模型讲起,说明它为什么安全(验证器)、它挂在哪里(program type 与挂载点)、它怎么与用户态通信(BPF Map),随后对比三条开发路径(bpftrace / BCC / libbpf+CO-RE),最后落到生产场景:TCP 重传定位、调度延迟分析、容器逃逸安全审计。它与 https://plumephp.com/os-performance-tools/(perf/bpftrace 入门)互为深浅两层,建议先读那篇再深入本文。
为什么需要 eBPF?
先看传统内核观测手段的困境:
| 手段 | 局限 |
|---|---|
perf 采样 | 只能采样到内核给你看的统计点,无法回答"为什么这个进程被阻塞了 3ms" |
| 内核模块 | 能注入任意代码,但权限极大、一次野指针就能让整机崩溃,生产环境没人敢随便加载 |
strace | 开销大,且只看系统调用边界,看不到内核内部 |
| 修改内核 | 需要重新编译、重启,反馈周期以月计 |
eBPF 给出的答案:定义一种受验证器检查的受限指令集,程序加载进内核时由验证器静态分析,保证它安全终止、内存访问受限、不会破坏内核,然后把它挂到几乎任何内核事件点(函数入口、tracepoint、网络收包路径……)。因为验证严格、权限最小化,生产环境可以放心使用。
BPF 程序模型:指令、寄存器与验证器
一个"受限的虚拟机"
eBPF 程序用近似汇编的指令编写,运行在内核中的 eBPF 虚拟机(JIT 编译为原生代码)。它只有 11 个 64 位寄存器(R0-R10)、有限指令集(mov/add/call/jmp/exit 等),禁止循环(直到 bounded loop 扩展)、禁止任意内存访问。
// 一段最简 eBPF 程序(C 语法),统计调用次数
SEC("tp/syscalls/sys_enter_write")
int count_write(void *ctx) {
u64 *counter;
counter = bpf_map_lookup_elem(&counts, &key); // 查 Map
if (counter) {
__sync_fetch_and_add(counter, 1); // 原子自增
}
return 0;
}
验证器(Verifier):安全性的核心
程序加载时,内核验证器做两件事:控制流分析(确保有穷执行,无死循环、无未定义跳转)和数据流分析(模拟寄存器与栈,确保指针合法、数组访问有界、类型匹配)。下图是加载流程:
BPF 程序(ELF/字节码)
│ bpf()
▼
验证器(Verifier)─── 失败则拒绝(返回 EACCES/EFAULT)
│ 通过
▼
JIT 编译(x86-64 / ARM64 原生代码)或解释执行
│
▼
挂载到挂载点(tracepoint / kprobe / XDP / cgroup hook ...)
验证器是 eBPF 安全模型的关键:即使程序是恶意写的,验证器也能在加载时拒之门外,因此内核才敢让普通用户(配合 kernel.unprivileged_bpf_disabled 策略)加载受控程序。
程序类型(Program Type):决定能调什么
BPF 程序必须声明类型,不同类型可用的辅助函数(helper)与上下文不同:
| program type | 挂载点场景 | 典型 helper |
|---|---|---|
BPF_PROG_TYPE_KPROBE | kprobe/uprobe 动态探针 | bpf_probe_read_kernel |
BPF_PROG_TYPE_TRACEPOINT | tracepoint 静态探针 | 直接读 tracepoint 参数 |
BPF_PROG_TYPE_SCHED_CLS | TC(流量控制)收发包路径 | bpf_redirect |
BPF_PROG_TYPE_XDP | 网卡驱动层(最早截获包) | bpf_xdp_adjust_head |
BPF_PROG_TYPE_CGROUP_SKB | cgroup 收发包 | bpf_get_current_cgroup_id |
BPF_PROG_TYPE_TRACING | 与 fentry/fexit 绑定(BTF) | bpf_get_current_task |
BPF Map:内核与用户态之间的"共享内存"
eBPF 程序不能在用户态与内核态之间传任意数据,唯一的通道是 Map——内核里的键值存储。Map 的数据由内核持久保存,用户态通过 bpf() 系统调用读写,BPF 程序通过 helper 读写。常见类型:
BPF_MAP_TYPE_HASH:哈希表BPF_MAP_TYPE_ARRAY:定长数组(如 per-CPU 计数)BPF_MAP_TYPE_PERCPU_ARRAY:每 CPU 一份,避免锁竞争BPF_MAP_TYPE_RINGBUF/PERF_EVENT_ARRAY:向用户态输出事件流
# 用 bpftool 查看已加载的 BPF 程序与 Map
bpftool prog list
bpftool map dump id 27 # 把 Map 27 的内容打印出来
bpftool prog trace # 实时打印 BPF 程序输出(bpf_trace_printk)
挂载点:tracepoint、kprobe 与 uprobe
eBPF 程序必须挂在"事件"上。挂载点决定了你能观测什么、以及开销多大。
tracepoint:静态插桩
tracepoint 是内核源码里预先埋好的观测点,参数格式稳定(有 TRACE_EVENT 宏定义)。它们开销极小、ABI 稳定,是首选。例如 sys_enter_write、sched_switch、block_rq_complete、kmem_cache_alloc。
# 查看系统所有可用 tracepoint
ls /sys/kernel/tracing/events/
cat /sys/kernel/tracing/events/sched/sched_switch/format
kprobe:动态插桩
kprobe 可以在任意内核函数入口/出口动态插入探针,无需内核源码埋点。代价是参数格式随内核版本变动(无稳定 ABI),且不能保证所有位置都安全。fentry/fexit(基于 BTF)是它的现代替代,参数访问更自然、开销更低。
SEC("kprobe/do_sys_openat2")
int trace_openat(struct pt_regs *ctx) {
// 通过 pt_regs 取第一个参数
const char __user *filename = (void *)PT_REGS_PARM2(ctx);
char buf[256];
bpf_probe_read_user_str(buf, sizeof(buf), filename);
bpf_trace_printk("open: %s\n", buf);
return 0;
}
uprobe:用户态动态插桩
uprobe 挂到用户态进程的任意函数(如 libc 的 malloc、应用自己的 handle_request),对应用程序性能分析意义重大。需要知道符号地址,通常配合 symbol 解析。
# 追踪用户态进程执行 malloc 的次数
bpftrace -e 'uprobe:/usr/lib/x86_64-linux-gnu/libc.so.6:malloc { @[comm] = count(); }'
挂载点选择原则
| 挂载点 | 稳定性 | 开销 | 适用 |
|---|---|---|---|
| tracepoint | 高(ABI 稳定) | 极低 | 首选,覆盖大部分需求 |
| fentry/fexit | 高(BTF) | 低 | 需要函数参数/返回值 |
| kprobe | 低(随版本变) | 中 | 没有 tracepoint 的旧函数 |
| uprobe | 中(依赖符号) | 中 | 用户态函数级观测 |
三条开发路径:bpftrace、BCC 与 libbpf/CO-RE
bpftrace:一行脚本的观测语言
bpftrace 是最高层的 DSL,几行脚本就能完成高级观测,适合在线快速排查。它基于 awk/C 语法,自带许多内建变量(comm、pid、kstack、arg0…)。
# 统计各进程的系统调用次数(Top 10)
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }' | head -20
# 追踪新进程创建
bpftrace -e 'tracepoint:sched:sched_process_exec { printf("%s → %s\n", comm, str(args->filename)); }'
# 统计内核对每个文件 read/write 的字节数
bpftrace -e 'kprobe:vfs_read, kprobe:vfs_write { @[func, comm] = sum(arg2); }'
bpftrace 内置的 profile 探针还能做 CPU 火焰图式采样:
bpftrace -e 'profile:hz:99 { @[kstack, comm] = count(); }' > stacks.txt
BCC:Python 包裹 eBPF
BCC(BPF Compiler Collection)用 Python 驱动 C 写的 eBPF 程序,运行时把 C 代码编译并加载。它预置了大量工具(bcc 工具集:execsnoop、biotop、tcplife、runqlat……)。缺点是每个环境都要重新编译,依赖内核头文件。
#!/usr/bin/env python3
from bcc import BPF
bpf = BPF(text="""
int kprobe__sys_clone(void *ctx) {
bpf_trace_printk("new process!\\n");
return 0;
}
""")
while True:
print(bpf.trace_fields()[6])
libbpf + CO-RE:可移植的现代实践
libbpf + CO-RE(Compile Once - Run Everywhere) 是当前生产环境的最佳实践。核心思想是:用 BTF(BPF Type Format) 携带内核类型信息,程序编译时记录"重定位",加载时按目标内核的 BTF 自动调整字段偏移,从而一份编译产物在不同内核版本上通用。这也是 Cilium、Katran、Falco 等生产系统选择它的原因。
// 现代 libbpf 风格:用 BTF 结构体直接访问字段,无需 bpf_probe_read 逐字段读
SEC("fentry/tcp_v4_connect")
int BPF_PROG(trace_connect, struct sock *sk) {
struct sock_common *skc = &sk->skc;
bpf_trace_printk("connecting to port %d\n", bpf_ntohs(skc->skc_dport));
return 0;
}
选型速记:快速排查用 bpftrace,原型验证用 BCC,生产组件用 libbpf/CO-RE。
生产调试实践:把 eBPF 用在刀刃上
理论讲完,来看三个高频真实场景。
场景一:定位 TCP 重传与连接卡顿
网络抖动排查,第一步就是看重传发生在哪个进程、哪个目标。
# tcptrace 的 eBPF 版本:观测 TCP 事件
bpftrace -e '
tracepoint:sock:tcp_retransmit_skb {
printf("重传 pid=%d comm=%s sport=%d dport=%d state=%d\n",
pid, comm, args->sport, args->dport, args->state);
}' | head -50
结合 ss -i 查看具体 socket 的 retrans 计数器,就能把"重传"定位到连接。若想进一步看内核在等待什么,用 sched_switch 追踪阻塞唤醒延迟。
场景二:分析进程为何"卡顿"——调度延迟与阻塞唤醒
进程响应慢,往往是它被放到了运行队列末尾、或被锁/IO 阻塞。runqlat 统计任务在运行队列上的延迟分布:
# runqlat:内核调度延迟直方图
bpftrace -e 'tracepoint:sched:sched_wakeup, tracepoint:sched:sched_wakeup_new {
@qtime[comm] = nsecs; }
tracepoint:sched:sched_switch {
if (@qtime[comm]) { @usecs = hist((nsecs - @qtime[comm]) / 1000); }
delete(@qtime[comm]); }'
输出是一张直方图,如果大量样本落在 10ms+ 区间,说明 CPU 不足或调度策略有问题。这类分析也和 https://plumephp.com/os-cpu-scheduling/ 的 CFS 机制形成对照——观测结果直接对应内核里的调度实体与负载均衡。
场景三:慢磁盘定位——块层延迟
# 统计每个进程的块 IO 延迟
bpftrace -e 'tracepoint:block:block_rq_issue { @start[arg0] = nsecs; }
tracepoint:block:block_rq_complete {
@usecs = hist((nsecs - @start[arg0]) / 1000); delete(@start[arg0]); }'
若直方图呈现长尾,对应 https://plumephp.com/os-nvme-storage-stack/ 中讨论的 NVMe 队列深度与 IO 调度问题。
安全审计实践:让 eBPF 成为"内核摄像头"
eBPF 的另一个主战场是安全。因为能挂到系统调用与关键内核函数,它是容器逃逸检测、恶意行为取证的最佳技术底座(Falco 就是这么做的)。
execsnoop:谁在偷偷执行命令?
攻击者进入容器后通常会 exec 一个反弹 shell 或下载器。execsnoop 实时打印所有新进程:
# execsnoop 用 tracepoint 追踪 execve 家族
bpftrace -e 'tracepoint:syscalls:sys_enter_execve {
printf("%-8s %-6d %s\n", comm, pid, str(args->filename));
}' | grep -v -E '^(systemd|sshd|bash|sh)\b'
如果出现 wget、curl、nc、python3 -c 且来源可疑,就是高置信告警。
检测容器逃逸尝试
容器逃逸常涉及对宿主 / 的挂载、对关键目录的写、或 setuid 提权。可用 kprobe 追踪 mount、unshare 等敏感调用:
# 监控 mount 系统调用(容器内挂载宿主机路径是逃逸前兆)
bpftrace -e 'kprobe:do_mount {
printf("mount by pid=%d comm=%s dev=%s dir=%s\n",
pid, comm, str(args->dev_name), str(args->dir_name));
}'
安全审计要求 eBPF 程序本身可靠且低开销,因此生产安全代理几乎都用 libbpf/CO-RE 编译(与 Cilium Tetragon、Falco 同路线),并通过 bpf_lsm 程序类型挂到 LSM 钩子做强制访问控制。这与 https://plumephp.com/os-security-hardening-trust/ 的 LSM 与内核加固话题直接衔接。
eBPF 的边界与运维要点
- 权限模型:加载 BPF 程序需要
CAP_BPF(或 root),未特权 BPF 默认受限。审计时要看kernel.unprivileged_bpf_disabled。 - 稳定性:kprobe 挂点随内核版本漂移;生产环境优先 fentry/tracepoint + CO-RE。
- 开销:虽然 eBPF 开销低,但高频 kprobe(如
vfs_read)仍可能放大数倍调用成本,线上应先用bpftrace -c小流量验证。 - 内核版本:BTF 与 ringbuf 需要较新内核(5.4+ 基本覆盖),老内核用 BCC 兜底。
结语
eBPF 之所以被称为"重构内核的可观测性",在于它把"观测内核"从"只能看统计数"推进到"可以在事件发生点执行你自己的受控逻辑"。它既是大规模的性能诊断工具,也是安全审计的事实标准,还是 Cilium 这类云原生网络栈的底座。
掌握 eBPF 的关键路径:先会用 bpftrace 快速提问,再理解 BPF Map 与验证器为何让程序安全,最后用 libbpf/CO-RE 写出可交付的生产代码。当你能够在一次线上卡顿中五分钟内回答"哪个进程、在哪个内核函数、等了多久",你就真正拥有了系统级洞察力。
延伸阅读
- Cilium eBPF 文档:
docs.cilium.io的 BPF 与 XDP 章节 - Linux Kernel Documentation:
Documentation/bpf/ - Brendan Gregg《BPF Performance Tools》
- libbpf 官方示例:
tools/testing/selftests/bpf/ - Falco 与 Tetragon 源码:容器运行时安全审计的工业实现
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。