eBPF 可观测与内核追踪:从 BPF 程序模型到生产调试与安全审计

eBPF 让开发者无需修改内核代码,就能在内核事件发生处动态注入安全受限的程序,是现代可观测性、性能诊断与安全审计的事实标准。本文从 BPF 指令集与验证器出发,拆解程序类型与挂载点(tracepoint/kprobe/uprobe),讲解 BPF Map 的数据通道,再对比 bpftrace、BCC 与 libbpf/CO-RE 三条开发路径,最后给出 TCP 重传追踪、阻塞唤醒分析、execsnoop 审计等真实生产场景的完整脚本。

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_KPROBEkprobe/uprobe 动态探针bpf_probe_read_kernel
BPF_PROG_TYPE_TRACEPOINTtracepoint 静态探针直接读 tracepoint 参数
BPF_PROG_TYPE_SCHED_CLSTC(流量控制)收发包路径bpf_redirect
BPF_PROG_TYPE_XDP网卡驱动层(最早截获包)bpf_xdp_adjust_head
BPF_PROG_TYPE_CGROUP_SKBcgroup 收发包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 写出可交付的生产代码。当你能够在一次线上卡顿中五分钟内回答"哪个进程、在哪个内核函数、等了多久",你就真正拥有了系统级洞察力。


延伸阅读

  1. Cilium eBPF 文档:docs.cilium.io 的 BPF 与 XDP 章节
  2. Linux Kernel Documentation: Documentation/bpf/
  3. Brendan Gregg《BPF Performance Tools》
  4. libbpf 官方示例:tools/testing/selftests/bpf/
  5. Falco 与 Tetragon 源码:容器运行时安全审计的工业实现

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 虚拟化与容器隔离:从 KVM/QEMU 到 namespace 与 cgroup
  2. 系统启动全流程:从 BIOS/UEFI、GRUB 到内核与 systemd
  3. 操作系统安全加固与可信计算:LSM、内核加固、TPM 与容器逃逸防护