本文地址:https://www.ebpf.top/post/ebpf_tour_basic

如你构建系统或运维集群时,频繁遇到代理 (agent)、iptables 或内核模块的各种限制,那么 eBPF 正是你一直在寻找的更安全、更快、更具动态性的技术选择。eBPF 允许你在 Linux 内核中运行小型且经安全验证的程序,实现运行态观察和调系统运行。实际上,eBPF 技术在无需承担自定义内核模块的运维风险的基础上,创新地开启了新一轮可观测性、安全性和网络工具的浪潮。

1. eBPF 是什么,为何如此重要?

eBPF 前身是 cBPF (classic Berkeley Packet Filter),cBPF 是一个小型内核字节码引擎,tcpdump 等工具基于 cBPF 技术在内核中实现高效的包过滤。eBPF 技术在 cBPF 基础上进行了诸多功能增强:

  • 可使用受限的 C 或 Rust 编写 eBPF 函数,通过编译器将其转化为字节码

  • 然后通过 bpf() 系统调用将其加载到内核加空间

  • 在 eBPF 程序允许运行前,验证器 (verifier) 模块会通过静态分析和动态模拟执行,以确保程序不会对内核造成风险:

    • 代码绝不解引用无效指针
    • 循环必须为有界循环
    • 程序结果值返回必须为目标挂载点可识别的正确类型
    • 不允许包含不运行的死代码 (dead code)
    • 代码必须在有限时间时间内可完成运行
  • 若通过验证器验证,内核会将字节码通过**即时编译 (JIT-compile) ** 编译为 CPU 原生指令,这也是 eBPF 程序高效运行的重要原因。

eBPF 并非要取代内核模块,而是在无需编写和分发内核模块基础上,提供了一种可控的路径来扩展内核行为。你可在运行时动态加载 eBPF 程序,将其附加到事件,并可实时实施卸载操作,事件包括网络接收点、内核函数调用出入点或安全决策点。同时,你也可以将程序和数据结构固定在 eBPF 专用虚拟文件系统中 (/sys/fs/bpf 目录下的 bpffs 文件系统) ,用于保障 eBPF 程序在其加载器进程退出仍可正常存活工作。与内核模块相比,eBPF 的开发成本更低,安全风险也小得多。

同时 eBPF 在内核中运行,可避免上下文切换带来的性能损失,该机制完美契合了当今云原生的需求:海量遥测数据、低延迟的策略决策,以及在确保安全的前提下快速执行。

2. eBPF 快速上手

使用 eBPF 的流程很简单:加载 (load)、附加 (attach)、观测 (observe)、卸载 (detach)。最快的体验方式是通过 bpftrace 工具快速运行监控进程运行的 demo (bpftrace 是一个用于类似 awk/shell 脚本方式运行 eBPF 程序的便捷项目)。

这是一个简单的 bpftrace 示例代码,实现按进程名统计 execve() 调用次数:

1
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve { @[comm] = count(); }'

如果你想更加深入一步,可通过 BCC 项目实现更复杂定制能力 (BCC 项目是快速使用 eBPF 能力项目,用户可通过 Python 代码编写和运行 eBPF 代码) ,下述 Python 片段将内核程序绑定到 kprobe,并在 BPF map 中维护每个进程 PID 访问计数器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from bcc import BPF

prog = r"""
BPF_HASH(exec_count, u32, u64);

int on_execve(void *ctx) {
  u32 pid = bpf_get_current_pid_tgid() >> 32;
  u64 *val = exec_count.lookup(&pid);
  u64 one = 1;
  if (val) { (*val)++; } else { exec_count.update(&pid, &one); }
  return 0;
}
"""

b = BPF(text=prog)
b.attach_kprobe(event="do_execveat_common", fn_name="on_execve")
print("Counting execve() per PID... Ctrl-C to stop.")
try:
    b.trace_print()
except KeyboardInterrupt:
    pass

for k, v in b.get_table("exec_count").items():
    print(f"PID {k.value}: {v.value}")

基于 BCC 项目的示例引入了 BPF map, map 是在内核中的键值存储,可用于保存事件间的共享状态。map 是 eBPF 实用性的核心:可用于存储计数器、缓存、配置和策略等,同时还通过 perf buffer 和 ring buffer 机制向用户空间流式事件传输机制。map 机制实现用户空间和内核空间双向通信,为数据和策略管理等动态更新提供了基础支撑。

3. 从源码到内核中运行

eBPF 程序就像一个小型专用函数。编写前,你需要选择一个合适的事件挂载点,然后再编写函数参数类型与事件上下文匹配的函数,例如:

  • 在 XDP 场景中使用 xdp_md 数据结构

  • 跟踪点或 kprobe/fentry 使用对应记录 (record)

  • 网络挂载点场景中使用套接字缓冲区 (socket buffer)

编写完成后使用 clang -target bpf 命令源码其编译为字节码,生成对象文件中的内容包含 eBPF 指令、map 定义、可选调试信息和 BTF (BPF Type Format) 元数据。针对生成的对象文件可使用工具 bpftoolllvm-objdump 进行检查确认构建内容,并了解验证器的大致验证逻辑。

加载 eBPF 程序到内核会触发验证器 (verifier) 验证流程。若验证通过,内核会保留原始字节码,用于验证分析,基于 CPU 的指令通过 JIT 编译将字节码转为原生指令。

随后,内核将 eBPF 程序附着到对应事件,在事件触发时候,执行对应的 eBPF 程序。常见的附着事件包括:

  • 在网络接口的 XDP 点 (在进入到网络协议栈前执行)

  • 网络栈内的 TC 点 (用于分类和整形)

  • fentry/kprobe 点 (跟踪函数入口)

  • 跟踪点 (稳定机制的事件点)

  • LSM 挂载点 (安全决策)

对于附着 eBPF 程序的生命周期,推荐的实践是使用 BPF link机制,可保证加载器退出后 eBPF 程序也可保持程序附加状态,后续可以可通过删除链接来卸载对应的 eBPF 程序。若需要将 eBPF 程序和 map 在进程退出后存活,可将其固定在 bpffs 路径下。

编写 XDP 代码时,务必记住关键边界检查:在处理数据包前,用 xdp_md->dataxdp_md->data_end 指针验证头部。

4. Map、事件流与可发现性

map 有多种类型:哈希 hash、数组 array、per-CPU 版本, LRU 缓存, LPM 前缀, 队列 queue 和栈 stack ,以及专用的环形缓冲区ring buffer。早期代码常通过 perf_event_open() 通过 perf event 向用户空间流式传输事件,而新代码倾向于使用环形缓冲区 ring buffer,通过单个文件描述符和简单的生产者/消费者模型简化了使用姿势。bpftool 工具可用于列出内核已加载的 BPF 程序、检查不可变标签、打印 JIT 后原生指令、创建和检查 map,也可通过 BTF 数据展示类型和函数原型元数据。通过将 eBPF程序和 map 固定到 /sys/fs/bpf 下的 bpffs 文件系统,可方便跨进程和重启场景下的复用。

5. eBPF 程序类型与挂载位置

每个 eBPF 程序都有程序类型属性,类型决定接收的上下文、可调用的辅助函数,以及程序返回值的效果。

在跟踪场景:

  • kprobeskretprobes 用于跟踪内核函数进入和退出

  • 跟踪点是内核暴露的稳定事件点

  • 基于 BTF 的 fentry/fexit 挂载点可低开销地跟踪函数进入和退出

  • 用户空间可通过 uprobesuretprobes 机制

  • 将 BPF LSM 程序附加到内核安全挂载点,实现细粒度的决策控制。

在网络场景:

  • XDP 位于驱动接收的早期路径上,可基于解析头部信息,决策报文定放行、丢弃、重定向或传输数据包
  • TC 位于网络协议栈内,可用于分类 (classify) 和整形 (shape) 数据报文
  • 套接字 socket 和 cgroup 挂载点将控制更贴近进程维度
  • 流解析器和其他子系统提供更具体场景的 触发点

网络方面的主要优势在于可预测的延迟:通过用已编译的数据路径替代冗长的 iptables 规则链,实现了服务负载均衡和网络策略执行。

6. CO-RE、BTF 与 libbpf:可移植性的分发模式

在目标运行主机编译适合验证测试,但该方式在生产环境中往往会遇到麻烦。CO-RE (一次编译,到处运行,Compile Once, Run Everywhere) 通过 BTF 类型信息在加载时适配预编译对象到不同内核,解决了这一问题。大多数现代发行版在 /sys/fs/bpf/vmlinux 提供规范 BTF 文件。eBPF 对象包含引用内核类型或字段的重定位记录,libbpf 库加载时会根据主机 BTF 解析重定位信息,避免结构布局差异导致程序失效。常见方式是从 BTF 生成 vmlinux.h 头文件,然后用 -O2 -g -target bpf 编译,确保加载器获取所需信息:

1
2
$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
$ clang -O2 -g -target bpf -c prog.c -o prog.bpf.o

Libbpf 还会生成轻量级的 C 头文件封装体 (称为 “skeletons”) ,将 map 、程序和附着点等信息进行包装,使得用户空间的加载操作几乎达到声明式的统一效果:open, load,attach,read。CO-RE 实际成效非常显著:你可以仅凭单一制品,就在多样化的 Linux 内核上提供可靠、高效的可观测性。

7. 验证器 (Verifier) 简述

验证器可视为安全审查者,让自定义内核代码在生产环境中运行变得可行。验证器模拟执行程序、跟踪指针来源,并证明所有内存访问都在边界内。确保解引用前检查指针、循环有界或展开、调用辅助函数时参数类型正确,以及为挂载点返回正确值 (如 XDP 的 XDP_PASSXDP_DROPXDP_REDIRECTXDP_ABORTED) 。验证器还检查对象中的许可证字符串,因为某些辅助函数仅对 GPL 许可程序开放。验证失败时,你可通过详细日志快速定位,日志就像与挑剔审查者的对话记录,能快速教会你内核接受的编码风格。

8. 可观测、安全与网络的收益

可观测性方面:eBPF 可监控系统调用、文件打开、凭证变更和内核函数调用;用进程谱系、cgroup 和容器标识、命名空间丰富事件内容;并近实时流式将数据传输到用户空间。这一切无需修改应用或配置即可获得深度洞察。

安全性方面:BPF LSM 将允许/拒绝决策直接嵌入内核安全挂载点,能识别容器和工作负载身份 (不仅是 uid/gid) 。Tetragon 等工具融合深度跟踪与策略,可及早检测并阻止可疑行为。

网络方面:XDP 处理早期快速路径决策 (丢弃、重定向、转发) ,TC 在网络协议栈内应用分类和整形。XDP 和 TC 机制共同支持了基于 eBPF 技术的 CNI 实现:服务负载均衡、网络策略,甚至节点间加密,延迟比长 iptables 链更低且更可预测。

9. 何时不应使用 eBPF

eBPF 功能强大,但并非所有问题的最佳选择。若用户空间挂载点或库调用可满足需求,保持简单即可。若事件率低且延迟不敏感,内核程序的复杂性可能得不偿失。若需长时间运行或阻塞操作 (特定可睡眠 sleepable 程序类型除外) ,应将逻辑移至用户空间。若运行在没有安装 header 头文件或 BTF 的旧内核上,可能需要升级、安装 BTF 包,或提供最小 BTF 数据才能使用 CO-RE。

10. 常见陷阱与调试方法

常见的问题可归为几类:

  • 验证器拒绝通常意味着需要更明确的边界检查、更少模糊的控制流路径,或更小/受限的循环;应避免对未信任数据进行无检查的指针运算;
  • 若系统缺少 BTF,安装内核 BTF 包或提供最小 BTF 并重新生成 vmlinux.h
  • map 资源限制通常指向 RLIMIT_MEMLOCK 或过大的 map 设置;调整大小并考虑用 LRU 变体作缓存;
  • eBPF 程序附着生命周期方面,应优先使用 BPF link 机制,确保程序在加载器退出后仍保持正常工作状态;
  • 若性能异常低,用 bpftool feature 确认 JIT 已启用,并在基准测试前开启。

11. bpf() 系统调用与底层机制

尽管使用的各种库隐藏了细节,但就 eBPF 相关的用户空间和内核空间的交互一切都通过 bpf() 系统调用进行。通常通过 libbpf 或其他包装器用通过调用 bpf() 系统调用创建 map、加载程序和建立事件附着 (attach) 。

早期跟踪代码可能调用 perf_event_open() 连接 perf event;最新的方法依赖 BPF Link 管理 eBPF 程序附着生命周期) 和环形缓冲区 ring buffer 传输流式数据到用户空间。

用户空间读取结果很简单:从缓冲区消费事件或执行 map 查找。程序和 map 可固定在 /sys/fs/bpf 的 bpffs 中,易于跨进程和重启发现与重用。随着系统扩展,BPF 到 BPF 调用帮助跨函数重构逻辑,.rodata.bss 中的全局变量允许加载时调整行为而无需重新编译。

12. 总结

eBPF 是在 Linux 内核中运行自定义逻辑的安全、高效机制。编译小型程序,验证器证明其安全,内核进行 JIT 编译,然后附着到事件以在运行时改变系统行为。map 承载共享状态,环形缓冲区 Ring Buffer 流式传输事件,BTF 和 CO-RE 提供跨主机分发的可移植性,libbpf + skeletons 骨架使加载器小巧而健壮。通过跟踪、网络和安全领域的挂载点 (XDP、TC、kprobes/fentry、LSM) ,无需修改应用代码或配置即可插桩,并结合丰富上下文实施精确策略。 从 Kubernetes 数据平面到系统调用追踪及预防性安全领域,eBPF 已从一项小众技巧演变为主流技术。