eBPF 是 Linux 内核中的一种经过验证的执行环境。应用程序加载程序和映射,将程序附加到内核定义的钩子,并通过文件描述符交换数据。网络处理仍然是重要用途,但跟踪、安全策略和性能分析不再依赖数据包捕获。
从数据包过滤器到 Linux 套接字过滤器 #
这条设计谱系始于 DEC 为 ULTRIX 和 DIGITAL UNIX 等系统提供的数据包过滤器。进程打开数据包过滤设备,选择接口,通过 ioctl() 安装 enfilter 指令序列,然后读取被接受的链路层数据包。过滤器使用栈式机器,并按优先级排列多个消费者。
Steven McCanne 和 Van Jacobson 设计 Berkeley Packet Filter(BPF),以降低数据包复制和解释的成本。他们 1993 年的论文描述了两项至今仍可辨认的变化:
- 基于寄存器的虚拟机决定是否接受数据包,以及应复制多少字节;
- 每个打开的
/dev/bpf实例都有自己的过滤器和数据包缓冲区,因此数据包捕获表示为设备实例,而不是 DEC 的优先级链。
经典 BPF(现称 cBPF)具有 32 位累加器 A、索引寄存器 X、16 个暂存字、数据包加载指令、条件跳转、算术运算以及返回值。Linux 将这种指令格式用于 Linux Socket Filtering(LSF),但会将过滤器直接附加到套接字,而不是要求使用 /dev/bpf。
struct sock_filter code[] = {
BPF_STMT(BPF_LD | BPF_B | BPF_ABS, 9), /* IPv4 protocol byte */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, IPPROTO_TCP, 0, 1),
BPF_STMT(BPF_RET | BPF_K, 0xffff),
BPF_STMT(BPF_RET | BPF_K, 0),
};
struct sock_fprog program = {
.len = sizeof(code) / sizeof(code[0]),
.filter = code,
};
setsockopt(fd, SOL_SOCKET, SO_ATTACH_FILTER,
&program, sizeof(program));该示例假定套接字数据以 IPv4 头部开始,因此第 9 个字节是协议字段。数据包布局取决于套接字族和钩子。对于套接字过滤器,返回值表示要保留的数据包字节数;零表示拒绝数据包。内核会在安装指令序列前对其进行检查。SO_LOCK_FILTER 可以防止过滤器被替换或移除,这使进程能够先附加过滤器,再放弃权限。libpcap 和 tcpdump 会将高级捕获表达式编译为这种指令模型。
eBPF 执行模型 #
Linux 的现代 BPF 系统调用接口在 Linux 3.18 中引入。eBPF 使用定宽 64 位指令和 11 个 64 位寄存器:
R0携带函数或程序的返回值。R1到R5传递参数,并由调用者保存。R6到R9由被调用者保存。R10是每次调用堆栈的只读帧指针。
进入程序时,R1 指向特定于程序类型的上下文,例如 xdp_md、__sk_buff 或跟踪上下文。程序返回特定于钩子的动作:XDP 程序可以放行、丢弃、重定向或发送数据包,而跟踪程序通常返回零。
解释器可以执行已接受的字节码。当架构启用 BPF JIT 时,内核会将程序转换为本机指令,并可以根据 sysctl 策略加固生成的映像。JIT 编译会改变执行成本,但不会改变验证器要求。
验证定义安全边界 #
程序附加前,验证器会符号执行其所有可能路径。它跟踪寄存器和栈槽类型、标量范围、指针来源、对齐方式、初始化状态、引用所有权和边界。因此,只有当控制流证明所请求的字节位于 data_end 之前时,类似数据包读取的代码才会被接受。
验证会强制执行以下属性:
- 不使用未初始化的寄存器或栈数据;
- 不将标量任意转换为内核指针;
- 仅使用程序类型批准的上下文字段和辅助函数;
- 内存访问有界,指针算术有效;
- 对受跟踪引用的获取和释放保持平衡;
- 执行有界,包括能够证明终止的循环。
接受程序证明的是其符合验证器模型,而不是其功能正确性。有效程序仍可能丢弃每个数据包、聚合错误的键,或增加过高的开销。当路径探索无法建立所需不变量时,复杂度限制也可能导致逻辑上安全的源代码被拒绝。
映射和辅助函数 #
映射是具有特定类型的键值存储和并发语义的内核对象。哈希表、数组、每 CPU 映射、环形缓冲区、LRU 映射、最长前缀匹配树、程序数组和存储映射服务于不同的通信模式。用户空间通过 bpf() 命令创建和操作映射,通常由 libbpf 封装:
int map_fd = bpf_map_create(BPF_MAP_TYPE_HASH, "counts",
sizeof(__u32), sizeof(__u64),
1024, NULL);
bpf_map_update_elem(map_fd, &key, &value, BPF_ANY);程序使用获准的辅助函数或 BPF kfunc 来查找映射元素、读取时间和进程标识、重定向数据包、发出环形缓冲区事件,或访问特定于钩子的数据。辅助函数的可用性和参数类型取决于程序类型。映射查找可能返回 NULL;验证器要求在解引用前进行 null 检查。
映射是共享状态,因此竞争条件仍然是应用程序需要处理的问题。每 CPU 映射可以避免计数器的跨 CPU 更新。只有在文档规定的约束允许时,才能使用原子操作或 struct bpf_spin_lock;用户空间必须显式聚合每 CPU 的值。
程序类型和附加点 #
程序类型选择上下文、返回约定、验证器操作以及允许使用的辅助函数。附加类型进一步标识钩子。常见组合包括:
| 领域 | 程序和附加示例 |
|---|---|
| 早期数据包路径 | 网络设备上的 XDP |
| 流量控制 | TC ingress/egress 上的 SCHED_CLS 或 SCHED_ACT |
| 套接字 | 套接字过滤器、reuseport 选择、套接字查找、sockops、cgroup 套接字钩子 |
| 跟踪 | tracepoint、raw tracepoint、kprobe/kretprobe、fentry/fexit、perf 事件 |
| 安全 | LSM 钩子和 cgroup 策略钩子 |
钩子选择同时决定语义和成本。在受支持的驱动程序上,XDP 会在普通网络栈的套接字缓冲区分配之前运行,而 TC 程序可以看到 skb。fentry/fexit 使用由 BTF 描述的函数边界,而 kprobe 以指令地址为目标,因此更容易受到实现变化的影响。
权限、生命周期和所有权 #
除非内核和系统策略允许所请求的操作,否则加载需要特权。现代内核可以通过 CAP_BPF、CAP_PERFMON 和 CAP_NET_ADMIN 分离权限;较旧的路径和某些操作仍使用 CAP_SYS_ADMIN。发行版内核可以通过 kernel.unprivileged_bpf_disabled 禁用非特权加载,而 lockdown、LSM、命名空间、令牌或委托策略还可以施加额外限制。应检查正在运行的内核,不要将某一种 capability 配置视为通用方案。
程序、映射、BTF 对象和链接都是通过文件描述符暴露的引用计数内核对象。关闭最后一个引用会销毁对象。在 BPF 文件系统(bpffs)中固定对象会为其提供持久路径和引用:
mount -t bpf bpf /sys/fs/bpf
bpftool prog show
bpftool map show
bpftool link showbpf_link 将附加关系独立于原始程序 FD 表示出来,并为受支持的钩子提供更安全的生命周期:关闭链接会解除程序附加,除非其他引用或固定项仍保持链接存活。在当前引导周期内,固定项会在创建进程退出后继续存在。重启后,启动代码必须重新创建内核对象;无法重新打开上一个内核实例中的对象。
不依赖无限访问的可观测性 #
跟踪程序可以测量延迟、统计事件、采样调用栈、关联调度器活动,并通过环形缓冲区导出结构化记录。CO-RE 重定位使用 BTF,使字段访问能够适配兼容的内核类型布局。运维工具仍应考虑以下问题:
- 消费者无法排空缓冲区时的事件丢失;
- 高频钩子上的单事件开销;
- 缺少适用 BTF 的内核中的符号和类型变化;
- 暴露内核或进程数据带来的隐私与安全影响。
使用 bpftool prog show、bpftool prog dump xlated、验证器日志和程序运行时统计信息,检查已加载的内容。可观测性代码运行在生产内核路径中,因此设计必须包含有界开销和明确的解除附加方案。
评论
在 GitHub 上阅读或评论(发表评论需要登录)