多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

深入浅出 eBPF:内核可编程沙盒的实践指南与避坑要点

深入浅出 eBPF:内核可编程沙盒的实践指南与避坑要点 开头部分可以这样写任何一个在 Linux 内核和网络领域泡过几年的人最近几年一定躲不开 eBPF 这个词。早期它是 BPFBerkeley Packet Filter的延伸用来高效抓网络包今天它已经变成内核里的可编程沙盒可以在不修改内核源码、不加载内核模块的前提下动态注入并运行用户定义的字节码完成追踪、观测、安全过滤、性能调优甚至网络转发这些事。这篇文章我想从一个实际操作者的角度把 eBPF 到底是什么、它能干什么、怎么上手、有哪些坑讲清楚适合刚接触这个概念的后端工程师、运维和 SRE也适合那些想评估要不要在系统里引入 eBPF 做可观测性的架构师。eBPF 这个名字网络上讲得很多但大多一上来就丢一堆架构图术语满天飞。它本质上不是什么魔法就是在内核里预留了一个安全运行用户代码的虚拟机。你可以把它理解成给操作系统装了一个安全插件系统只不过这个插件运行在内核态并且所有动作都要先通过审核越界就拒绝执行。1. 从抓包工具到内核编程框架的演化1.1 远古BPF做的事情要理解 eBPF最好先看看它的爸爸 BPF。我第一次接触 BPF 还是很多年前用 tcpdump 抓包的时候在命令行写到tcpdump -i eth0 port 80这个表达式最终会被编译成一段 BPF 指令下发到内核的套接字过滤点。它干的事很纯粹让内核在拷贝整个包到用户态之前先用一段逻辑判断这个包我要不要是端口 80 的就留下不是的就丢弃。那个年代的 BPF 只有两个寄存器、寥寥几十条指令能做的也就是过滤网络包算力非常有限。但好处是安全工作内核里限制死了一系列约束比如不允许循环、不允许任意内存访问指令格式固定任何人都可以安全地把这段字节码塞进内核。这套机制从设计原则上就为后来的 eBPF 铺好了路。1.2 eBPF到底改了什么eBPF 口头上叫扩展 BPF其实已经是重写了一套架构。它不再局限于抓包而是把 Linux 内核里各种事件点kprobe、uprobe、tracepoint、perf event、cgroup hook 等都暴露成可挂载钩子。用户程序在这些钩子上附加 eBPF 程序就能在内核发生某个事件前或发生后立刻执行一段你在用户态编写、但在内核态运行的逻辑。这里有个关键eBPF 程序不是以源码形式直接跑在内核里的。它先被 clang/LLVM 编译成 BPF 字节码经过内核的 verifier 验证确保不会越界、不会死循环、不会破坏内核稳定性再交给一个内置的解释器或者 JIT 编译成机器码执行。所以 eBPF 的能力是安全地扩展内核而代价是要遵守它的规则。1.3 为什么大家现在都在提它内核社区推动 eBPF 的原因其实很现实传统的内核模块开发门槛高接口不稳定模块写错直接宕机而且不同内核版本之间兼容性让人头疼。相比之下eBPF 程序通过 verifier 验证后运行期间即使逻辑有问题也只会拒绝执行或者退出不会把系统搞挂。再加上数据面和观测面都有大量现成框架比如 XDP、tc、cilium、Falco 这些生态滚起来之后大家发现做网络加速、做安全监控、做性能剖析原来绕不开的底层内核逻辑现在都能用 eBPF 优雅解决自然就火了。2. 核心组成拆解加载、验证与运行2.1 前端工具链是怎么把代码变成字节码的我经常在项目里把 eBPF 程序写成一个 C 文件然后用 clang 编译成目标文件。安装好 llvm 和内核头文件后一条命令大致长这样clang -O2 -g -target bpf -c bpf_prog.c -o bpf_prog.o其中的-target bpf会让编译器产出 BPF 目标架构的 ELF 文件-O2可以优化掉不必要的访存操作-g保留调试信息便于后续 map 和 trace 点分析。生成的.o文件里面包含了大头是程序指令、小头是各种 map 定义和许可证信息的内容。此时这段代码还是静态的字节码需要靠某个加载器把它放进内核。实际的加载器可以是 bpf() 系统调用的直接封装、libbpf 库、bpftool 工具或者像 bcc 这种更上层的 Python 框架。拿 libbpf 来说它在用户态负责解析 ELF找到其中的 eBPF 程序段创建 map然后调用bpf(BPF_PROG_LOAD)让内核验证并加载。这中间还有一个 ABI 命名的讲究ELF 文件里的 section 名称比如SEC(xdp)后面的 xdp决定了这段程序挂到什么类型上。2.2 内核里的 verifier 究竟会查什么verifier 是所有 eBPF 程序必须过的鬼门关。它逐条模拟执行字节码跟踪每一条指令前后寄存器里值的范围、指针的合法性和可能指向的对象类型。如果发现一处可能访问未初始化内存或者生命周期错误地使用了指针就拒绝加载并打印拒绝原因。常见被拒情况我帮你总结几类访问越界比如 map 查找结果没有判空就直接解引用循环次数不可预测虽然现在支持有限循环但循环边界需要能在验证时算出来否则拒绝栈上局部变量使用超出范围给定的 helper 函数受限制比如有些 helper 只能在某个程序类型里调用这些规则很烦但它也保证了任意用户都不容易写出一段搞瘫内核的代码。一段 eBPF 程序挂了最多丢掉这次事件的处理不会导致内核 panic这是设计上最值钱的特性。2.3 JIT 编译与运行时内存模型字节码被验证通过之后内核会优先尝试 JIT 编译把 BPF 指令翻译成当前 CPU 架构的原生指令缓存下来执行。相比解释执行JIT 的性能通常能提升数倍尤其在网络热路径上差别非常明显。可以通过sysctl net.core.bpf_jit_enable开启或者确认状态多数现代发行版默认已经打开具体可以用sysctl net.core.bpf_jit_enableeBPF 程序自己不能直接访问内核任意内存它必须显式通过 helper 函数来读取数据。比如要获取当前进程的 PID需要调用bpf_get_current_pid_tgid()要拿到当前 CPU 编号可以读bpf_get_smp_processor_id()。程序和一个用户态进程之间通过 map 结构来通信map 可以在 eBPF 程序中写入然后从用户态读取实现统计、状态同步和数据导出。3. 最适合入手的三大应用场景3.1 内核观测与性能追踪我最早做 eBPF 项目就是从 profiling 开始的。传统排查高 CPU 问题经常用 perf但 eBPF 做出来的程序更加灵活因为你可以在具体的函数入口、返回点挂载 kprobe实时统计一次调用的耗时分布或者抓取调用栈并进行聚合。比如做一个简单的函数延迟直方图可以先取调用开始的 kprobe 拿到时间戳存到 map 里再在 kretprobe 里拿结束时间把差值加进直方图 buckets。这一套逻辑用 bcc 的 Python 接口写起来非常快。你可以在几十行脚本里统计内核函数的耗时分布、进程级别的 I/O 字节数、TCP 重传原因这些东西以往要反复去解析 trace 文件现在可以精确到具体事件采样而且对业务影响极小。反正我一直觉得观测类应用是 eBPF 所有能力里最容易收获效益的方向。3.2 网络包处理与转发加速网络方向是 eBPF 最火热的落地场景。XDPeXpress Data Path在网络驱动早期位置挂钩可以在报文进入内核协议栈之前就做丢弃、转发或修改而 tc 层的 eBPF 也能替代很多 iptables 逻辑。轻量级负载均衡、防火墙过滤、DDoS 防护这些以前要么靠 DPU 硬件加速要么靠内核协议栈硬抗现在用 eBPF 在软件层面就能做到接近硬件线速的转发。写一个简单 XDP 程序丢弃目标是特定 IP 的包伪代码看起来就是SEC(xdp) int xdp_drop(struct xdp_md *ctx) { void *data (void *)(long)ctx-data; void *data_end (void *)(long)ctx-data_end; // 解析以太网头、IP头 if (data sizeof(struct ethhdr) data_end) return XDP_PASS; struct ethhdr *eth data; if (eth-h_proto htons(ETH_P_IP)) { struct iphdr *ip data sizeof(struct ethhdr); if (ip-daddr target_ip) { return XDP_DROP; } } return XDP_PASS; }真正做产品化的时候往往要配合 map 下发布过滤规则而不是写死 IP这也要注意 XDP 程序在回报 XDP_DROP 后网卡直接丢弃报文对 CPU 的消耗比上层 iptables 小得多。3.3 安全审计与运行时防护安全方向也是 eBPF 价值密度很高的地方。通过挂载 tracepoint 或者 kprobe 监听 execve、文件打开、网络连接创建等系统调用eBPF 程序可以实时记录进程行为甚至基于规则阻止非法进程启动。很多运行时安全产品比如一些容器安全软件内部就是这样工作的。我在某容器平台的项目里用 eBPF 收集容器内异常 execve 调用结合 map 保存白名单哈希一旦发现未授权执行命令就立刻导出事件到用户态做告警。整套方案不用改动业务代码也不侵入容器环境落地成本相对传统 agent 方式低很多非常适合安全团队快速搭建检测能力。4. 从零写一个最小 eBPF 观测程序4.1 环境准备与编译先说下我习惯的环境搭配Linux 内核 5.4 及以上发行版装好clang、llvm、libbpf-dev、linux-tools。很多旧内核虽然也有 eBPF 支持但像 BPF CO-RECompile Once, Run Everywhere这类能力需要 5.x 内核和新的 libbpf所以尽量用新一点的内核省得被低版本的特性限制折腾。最简单的 hello-world 式 eBPF 观测程序是统计进程执行 execve 系统调用的次数。这个程序如果不想引入太多依赖可以直接用 bcc 的 Python 接口写但完整理解链路我还是建议用 libbpf C 来写一遍下面给个可用版本的核心片段。#include linux/bpf.h #include bpf/bpf_helpers.h char LICENSE[] SEC(license) GPL; struct event { __u32 pid; char comm[TASK_COMM_LEN]; }; struct { __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY); __uint(max_entries, 64); __type(key, int); __type(value, __u32); } events SEC(.maps); SEC(tracepoint/syscalls/sys_enter_execve) int trace_execve(void *ctx) { struct event e {}; e.pid bpf_get_current_pid_tgid() 32; bpf_get_current_comm(e.comm, sizeof(e.comm)); bpf_perf_event_output(ctx, events, BPF_F_CURRENT_CPU, e, sizeof(e)); return 0; }这里SEC(tracepoint/syscalls/sys_enter_execve)表示程序挂载在 execve 系统调用入口的 tracepoint 上内核执行到该点会调用此段逻辑。结构体里用events这个 PERF_EVENT_ARRAY map 把事件数据发送到用户态用户态程序通过 perf buffer 收到通知并读取内容。4.2 用户态加载逻辑用户态用 libbpf 加载上面的 BPF 对象时常规步骤是struct bpf_object *obj; obj bpf_object__open_file(execve_trace.o, NULL); bpf_object__load(obj); struct bpf_program *prog bpf_object__find_program_by_name(obj, trace_execve); bpf_program__attach(prog);然后创建 perf buffer 回调。这一步如果只是想快速验证直接用bpftool就能完成加载和挂载bpftool prog load execve_trace.o /sys/fs/bpf/execve_trace type tracepoint bpftool prog attach pinned /sys/fs/bpf/execve_trace tracepoint/syscalls/sys_enter_execve不过实际产品环境里还是要用 libbpf 的工程化流程管理程序的加载、附加、注销和 map 的读写。4.3 运行验证与结果解析程序跑起来之后可以在另一个终端执行几条ls或者date命令然后用bpftool prog show查看附加状态或者从用户态程序打印事件。每执行一次 execve你都会看到对应的 pid 和进程名。这个小例子虽然简单它已经包括了 eBPF 程序编写、编译、加载、挂载、map 通信和用户态解包的全链路能跑通这个后续深入就有骨架了。5. 工具选型解析bcc、libbpf 还是 bpftrace5.1 bcc 适合快速原型bccBPF Compiler Collection通过 Python/Lua 脚本把 C 代码嵌入运行时编译写起来快方便调试和验证想法。它的问题是每次运行都要在目标机器上调用 clang 编译依赖较重部署稍微麻烦而且脚本方式和大型项目的工程化风格不太搭。我个人的经验是用它做交互式排查和写一次性脚本特别顺手比如查某个 PID 的 syscall 调用频率几行代码就能出结果。5.2 libbpf 适合生产级落地libbpf 就是把 eBPF 程序预编译成.o文件用户态加载器只负责 open/load/attach。它天然配合 CO-RE可以针对不同内核版本运行不需要在目标机器装完整的编译器。生产上如果需要分发 agent 到大量机器建议走这个方向。缺点是前期的构建链路复杂一点需要管理内核头文件、BTF 信息和编译流程。5.3 bpftrace 适合瞬时诊断bpftrace 是一种高级脚本语言语法像 awk表达力足够强悍适合在线上快速定位问题比如统计所有进程打开文件的次数、追踪某个 PID 后续系统调用序列。我自己很少拿它做长期运行的任务但它是排查疑难杂症的瑞士军刀。可以简单看一个例子bpftrace -e tracepoint:syscalls:sys_enter_openat { [comm] count(); }一条命令就能统计当前各进程执行 openat 的频次非常直接。5.4 选型时值得注意的几个点选型不是越底层越好。如果你的目标是快速回答到底谁在写这个文件bpftrace 几秒钟搞定别去写 C如果目标是做一个生产环境的监控组件不要用 bcc 脚本跪在每台机器上编译最好用 libbpf 做静态二进制分发如果只是想熟悉内核机制建议三种都过一遍理解他们之间的异同。一般团队没有精力维护完整 libbpf 工程时采用 bpftrace 做诊断、bcc 做脚本化的思路也可以。6. 在实践中踩过的坑与排查技巧6.1 verifier 拒绝加载时怎么定位我踩得最多的坑就是忽略 verifier 的报错。它虽然提示信息写得比以前友好但依然让人头大。遇到R0 invalid mem access inv或者invalid indirect read from stack这类报错建议按下面步骤排查先看是不是 helper 函数的参数类型不对比如把 64 位的 pid_tgid 直接当成 int 传给了需要__u32的 helper再看 map 查找结果是否判空未判空就用指针几乎必挂检查有没有访问超出 data_end 的包内容XDP/RX 程序里尤其容易犯直接打印简化后的验证日志用bpftool prog load加-d参数能看见更细的跟踪过程一个技巧如果程序逻辑复杂、实在找不到用二分法逐步注释代码缩小到具体哪一行指针分析出错再对着报错去改。这比瞎猜有效率得多。6.2 map 同步和并发更新导致的性能陷阱eBPF map 是高效的但并发更新时可能遇到竞争。PER_CPU 类型的 map 可以规避大多数统计计数场景的锁竞争但如果你需要精确的全局总量最终聚合放到用户态做。在用 hash map 保存每个连接状态时最好预估好 map 容量否则 key 频繁增删会导致哈希扩容在高吞吐下有明显性能抖动。还有一点容易被忽略BPF_MAP_TYPE_LRU_HASH 有淘汰策略可以用来限制内存但如果把重要的安全规则放进去可能在压力下被淘汰掉导致过滤失效。这种坑在安全性要求高的场景是致命的规则类数据不要放到 LRU map 里。6.3 Tracepoint 与 kprobe 的选择权衡kprobe 可以探测任意内核函数灵活度高但因为是插桩可能受内核函数重命名、优化内联影响tracepoint 是内核官方暴露的稳定事件兼容性好但可能缺少你想要的细粒度参数。我的建议是能选 tracepoint 优先 tracepoint只有功能覆盖不了再考虑 kprobe。另外 uprobe 可以跟踪用户态动态库函数比如调查一个用户态函数调用频率也很有用只是需要注意进程地址空间变化带来的偏移兼容问题。6.4 内核版本差异与 BTF 的救赎低版本内核上开发好的 eBPF 程序放到高版本跑可能就挂在 struct layout 变化上。解决这个问题靠 CO-RE 机制编译时只记录字段偏移的 BTF 信息运行时根据目标内核的 BTF 自动重定位。这就要求编译环境带有内核的 BTF 文件并且加载时目标内核/sys/kernel/btf/vmlinux存在。如果目标机器上没有这个文件会导致加载失败。检查方式ls -l /sys/kernel/btf/vmlinux如果文件不存在先升级内核或者手动开启 CONFIG_DEBUG_INFO_BTF。这个配置是让 eBPF 程序跨版本运行的基石。7. 性能优化与稳定性设计心得7.1 尽量让逻辑简单短路eBPF 程序在内核热路径里执行多一条指令延迟就多一点。观测类型的程序能提前返回就提前返回尽量减少 helper 调用。拿网络过滤场景来说其实很多包在解析完 L2/L3 头部后就能决定放行或丢弃没必要去查更多 map 或者做复杂匹配。把最想匹配的规则放在前面命中后提前 return收益显著。7.2 利用批量事件减少用户态开销从 eBPF 到用户态的数据传输是常见瓶颈。inflight 事件很多时PERF_EVENT_ARRAY 可能成为热点。可以用 ring buffer mapBPF_MAP_TYPE_RINGBUF替代它批量拷贝数据丢失可控性能更好。如果数据量特别大考虑在 eBPF 侧先聚合比如先做直方图、计数只把汇总结果定期上报而不是每个原始事件都上报。7.3 保证主路径没有动态分配eBPF 程序不允许调用普通内核内存分配所有内存分配必须在 helper 允许的范围内或者通过 map 预分配。在 XDP 场景里做转发还需要预分配足够的 DMA 内存页动态分配不现实。这个设计约束本身也让 eBPF 程序的性能可预测。编写时多利用栈上局部变量将中间数据控制在 512 字节栈范围内减少不必要的 map 操作。7.4 高流量下怎么保护用户态分析端eBPF 可以把事件喂给用户态但用户态处理不过来时需要背压或丢事件。可以用 map 的丢弃策略、perf buffer 的 wakeup 策略来平衡实时性和开销。不要试图让用户态程序无限缓冲内存耗尽比丢几个采样点严重得多。一个实际项目里为了让延迟排行尽量准确我采用先聚合请求耗时到 map再每秒导出一次聚合表的方式用户态压力瞬间降了一个量级。8. 当前生态和未来延伸8.1 网络、可观测与安全都在围绕它重做现在你随便看一个云原生网络项目几乎都有 eBPF 的影子。容器网络里做数据路径、策略隔离很多已经用 eBPF 实现可观测性领域的开源方案也大量押注在 eBPF 上安全领域更不用提Linux 上的运行时安全检测工具基本都是 eBPF 的形态。这说明它已经从一个小众调试手段变成系统软件基础设施的一部分。8.2 我看到的局限与值得留意的方向eBPF 也不是万能的。可编程性的边界始终存在复杂的流状态管理、大包重组这类工作它不一定比内核协议栈更高效verifier 的严格限制也决定了不是所有逻辑都能搬到内核里map 容量、内存占用、以及内核版本特性差异都是迫在眉睫的工程问题。大规模使用还依赖完善的 CO-RE 构建链和告警平台这块各团队的能力参差不齐。从我个人的角度后续值得重点关注两个方向一是 BPF 指令集和验证器的持续演进让开发者写代码时更少遇到边界限制二是与硬件卸载结合把一部分 eBPF 程序卸载到网卡或 DPU 上执行进一步提升网络性能。这个领域变化很快每隔几个月就有新 helper 函数和新能力进内核保持跟进的最好方式就是实际跑一个小程序亲手验证文档里说的新特性。9. 追问与实战建议9.1 新手最容易忽略的定位很多人一开始就绕到复杂的网络程序里被指针、数据头解析和 verifier 轮番折磨。我建议新手先做观测类程序因为数据不涉及修改内核行为不危险也不能把包丢了即便有 bug 也无非错过几个事件。先玩转 map、perf event、tracepoint再进网络与安全领域会顺很多。9.2 从一个小项目开始而不是配置大框架不要第一天就尝试搭建完整的 eBPF agent 平台。我推荐从统计某台机器上进程执行 execve 的次数或者追踪一个容器的 syscall 序列这种小任务开始跑通之后再逐渐堆功能。在某公司的一次内部分享里有个同学用 bpftrace 一条命令定位了线上日志激增的原因比以往逐台排查节省了半天时间这种小而实际的价值才是 eBPF 最打动人的地方。9.3 常用资料和跟进节奏官方文档永远第一优先没事翻一翻内核源码里的samples/bpf和tools/testing/selftests/bpf那里有大量可运行的例子。其次多看看别人分享的真实生产案例尤其关注失败经验。理论基础到了之后实际操作遇到卡点基本都是因为内核版本特性不熟悉或者 map 操作方式不对解决方式只能是多跑多试、把报错吃透。我自己这几年的体会是eBPF 不只是一个工具更是一套理解内核运行逻辑的思维框架。以前我觉得内核是个不可变的黑盒系统出问题只能靠开 trace、反复刷日志。用它之后我开始敢在关键路径上插一脚去看看到底发生了什么这种能力对排查复杂系统问题帮助特别大。如果有条件拿一台测试机把前面那个 execve 例子运行一遍再试着加一个 map 统计各进程执行次数。跑通了之后你自然会开始琢磨是不是还能追踪某个端口的数据是不是还能过滤恶意域名是不是还能给内核加一个轻量流量监控。那时候再回头看我前面写的这些内容应该就只剩实践这一件事需要自己去做了。
返回列表