我一直有个看法:排查线上问题,手上工具趁手太重要了。eBPF 出现以前,想看清内核里正在发生什么,基本只能靠 strace、gdb、各种埋点日志,要么干扰业务,要么覆盖不全。后来 eBPF 技术慢慢成熟,命令行工具链也跟着爆发,我最直观的感受是:很多过去要“接监控、写工具、等发布”才能弄明白的事情,现在一条命令就能看到答案。
这篇文章就是围绕 eBPF 实用命令行工具来写的,核心是 BCC、bpftrace、bpftool 这三套东西。它们能覆盖进程执行、文件访问、TCP 连接、调度延迟、内核动态追踪这些高频排障场景。如果你是运维、SRE、后端开发,或者正在研究 Linux 内核观测,这套工具能把你的上手时间从“一个月”压缩到“一个下午”。
1. 为什么用命令行工具做 eBPF 观测?
1.1 eBPF 解决什么问题
先说清楚 eBPF 的定位。它相当于给 Linux 内核装了一个“安全探针投放器”,你写的观测程序会先通过校验器检查,然后被加载进内核虚拟执行,挂载到文件操作、系统调用、网络收发包、调度器切换等事件点上。和改内核模块不一样,它不需要重新编译、不需要重启机器,也不会因为一个 bug 直接把系统搞死。在内核全面开启 BTF 支持之后,连内核函数的签名都能直接查,这让动态观测的门槛比以前低了很多。
平时跟人解释,我只强调三个关键词:无侵入、低开销、全视角。无侵入指的是不需要改应用代码,也不强制重启服务;低开销指的是大部分探针只有在事件发生时才会执行,而不再是全局采样;全视角指的是它在内核态上下文里运行,能看到用户态工具看不到的信息,比如线程等待 CPU 的时间、socket 队列的积压情况、文件系统向块设备发起的 IO 请求。很多“一到业务高峰期就卡顿”的疑难杂症,只有在这种视角下才看得清。
1.2 手写 C 和现成命令的取舍
很多人一听到 eBPF,第一反应就是“要写 C 代码”。这话确实没错,真正的底层开发通常离不开 C 和 Clang,但面向运维和复杂应用的 eBPF 工具,一定是把探针编译、加载、事件循环、结果输出全部封装好的。手写一个观察进程执行的程序,光是 BPF 程序源码就要几十上百行,还要在用户态做 ring buffer 的读取和格式化。命令行工具存在的意义,就是把这些脏活彻底藏起来。
比如 opensnoop 背后的内核态代码有五六十行,用户态还有一整套 Python 封装,但实际用法就是一条命令。你不需要知道 bpf_map 的 key 是 u32 还是 pid_t,不需要关心 perf_event_open 怎么监听,也不需要验证你的程序能不能通过内核校验器。这也是命令行工具和普通脚本最大的区别:它对使用者的要求只有“懂场景”,不需要“懂实现”。先跑起来看到结果,再慢慢往上补原理,学习曲线要友好得多。
1.3 这套工具链适合谁用
适用人群不局限于内核开发者。SRE 和运维遇到“不知道谁在创建进程、谁在打开文件、谁的连接没有被 accept”,用现成工具最快。后端开发做性能分析,想定位某个请求为什么慢,可以用 bpftrace 看内核函数调用耗时。至于正在写自定义 eBPF 程序的人,一定离不开 bpftool,它干的事情是在内核侧“管理程序、查看 map、调试加载链路”。三类人合在一起,构成了 eBPF 命令行工具最主要的使用群体。你可以把这份内容当成从“能跑命令”到“能玩透工具”的快速路线图,下面每个工具都会给出具体命令和适合的场景。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工具栈总览:bpftool、bpftrace、BCC
2.1 bpftool:内核侧程序的“手术刀”
bpftool 是内核仓库自带的 BPF 管理工具,鲁棒性最好,版本跟着内核走。它解决的问题和其他工具完全不同:BCC 和 bpftrace 是“往内核挂探针看现象”,bpftool 是“查看和管理已经挂上去的 BPF 对象”。当你同时跑着监控 agent、网络插件、安全拦截器,或者在调试自己开发的 BPF 程序时,bpftool 几乎是最底层的那道窗口。常用命令集中在以下几行:
bash复制bpftool prog show
bpftool map show
bpftool prog dump xlated id 123
bpftool net show
prog show 列出所有已加载到内核的 BPF 程序,包括 id、类型、名称、attach 位置;map show 显示所有 map 的键值结构、大小和当前占用;prog dump xlated id 123 会把指定程序已经翻译成内核指令的样子打印出来,排查自己写的程序时尤其好用;net show 检查的是 tc、XDP 等网络路径上的挂载点。如果想用脚本处理输出,追加 -j 参数让结果变成 JSON,再接 jq 做过滤,这个组合在批量巡检时特别方便。
2.2 bpftrace:百行不如一行的追踪脚本
bpftrace 更像一门为动态追踪设计的脚本语言,语法接近 awk 和 C 的混合体。它的基本模型是探针 + 条件 + 动作:描述想监听的事件,再写事件触发后要执行的代码,剩下的交给编译器和加载器。由于语法抽象度高,很多社区里的排查案例,最后给出一行命令就能复现,这行命令背后通常就是 bpftrace。
bash复制bpftrace -e 'tracepoint:syscalls:sys_enter_openat /comm == "nginx"/ { printf("%s %d %s\n", comm, pid, str(args->filename)); }'
这个命令每触发一次 openat 系统调用,就打印进程名、PID、文件路径,整个脚本只做一件事:只关心 nginx 发起的文件打开操作。对快速验证猜想的现场排查,这种“写一行马上跑”的交互方式最舒服。如果需要统计,它同样提供 count()、sum()、hist() 等现成聚合函数,可以在一行脚本里完成“统计”“画直方图”“打印调用栈”这类高级操作。
2.3 BCC:面向运维输出的全家桶
BCC 是一套已经封装好的可执行工具集,也是“eBPF 命令行工具”这个说法最常指向的对象。安装 bcc 相关包后,命令行里会多出几十个按观测场景命名的工具,execsnoop 抓进程执行,opensnoop 抓文件打开,tcpconnect 抓主动外连,runqlat 看调度延迟,biosnoop 看块设备 IO。它把内核态 C 程序和用户态 Python 脚本封装成独立命令,每个命令对应一种观测场景,使用体验比裸写 eBPF 友好太多。
对新手最友好的地方是参数设计得比较克制。核心参数就那么几个,比如 -p 指定 PID,-t 或 -T 显示时间戳,-x 只显示失败事件。你可以把参数当成过滤器:先跑全量,看到噪声大后再加条件缩小范围。上手成本低并不说明能力弱,很多长期监控的基线数据,也正是来自 BCC 工具的定时输出。在场景没有明确之前,execsnoop、opensnoop 这种广谱工具永远是我的第一选择。
2.4 怎么选:一张对比表
| 工具 | 定位 | 上手难度 | 适合场景 |
|---|---|---|---|
| bpftool | BPF 对象管理 | 中 | 查看/管理正在运行的程序和 map |
| bpftrace | 快速动态追踪 | 中低 | 临时写一行脚本验证问题 |
| BCC | 已封装好的命令集 | 低 | 日常系统观测和常见排障 |
| 自定义开发 | libbpf / CO-RE | 高 | 需要长期运行、低开销的工具 |
选型逻辑很简单:日常排障先上 BCC,命令最全;遇到现成命令没有覆盖的场景,用 bpftrace 写几行;等同一个追踪逻辑被反复使用后,再考虑沉淀成自制程序,交给 bpftool 管理。这个顺序几乎指导了我所有项目里的工具选型,也建议你从这条路径开始,不要一上来就奔着底层 API 去。
3. BCC 全家桶实战
3.1 execsnoop 抓进程创建瞬间
execsnoop 跟踪的是 execve 系统调用。进程每次执行新程序都会触发这个事件,所以你直接能看到“谁启动了谁、命令是什么、参数有哪些”。我在生产环境遇到进程频繁重启时,第一步不是看 systemd 日志,而是先 execsnoop -T 挂几分钟,把每次重启的 PID、父子关系、时间戳全拿到手。再配合自己的发布系统记录,往往马上就能判断问题是周期任务、容器探针还是外部脚本调来的。
这里有一个非常重要的认知:进程创建看起来是“连续动作”,其实分成 fork 和 exec 两步。execsnoop 只看 exec 发生那一刻,如果你关心的是“哪个进程 fork 了一堆不 exec 新程序的线程”,应该用 BCC 里的 threadsnoop,或者用 bpftrace 直接追踪 sched_process_fork 事件。我曾经在这个细节上卡了半天,因为以为 execsnoop 看不到就等于没有新进程,结果漏掉了真正的元凶。
3.2 opensnoop 捕获文件打开
opensnoop 把 open 和 openat 调用显示在标准输出上,包括进程 PID、文件路径、返回结果。遇到“找不到文件”“权限被拒”这类问题,它能直接看到是哪条路径出了错,错误码是多少。我用它定位过一次很隐蔽的配置加载问题:应用进程在工作目录之外读了一个旧配置文件,业务日志完全没记录,opensnoop 立马把路径暴露了出来。现场信息足够的时候,我会用 -x 只显示打开失败的事件,避免在正常路径上刷屏。
如果你需要观察的不是“打开文件”,而是“只看文件是否存在”这类 stat 调用,记得用 BCC 里的 statsnoop。这类细分工具很容易被忽略,但恰恰是排查诡异路径问题的关键。eBPF 工具家族的特点就是“窄而深”,每一条系统调用路径,几乎都有对应的专用观测命令,别每次都用同一个工具硬顶。
3.3 tcpconnect 和 tcpaccept 定位外连和内连
网络连接问题的排查里,我最常用的一组搭档就是 tcpconnect 加 tcpaccept。前者监听主动外连,也就是客户端视角;后者监听被动接收,也就是服务端视角。当用户报告接口超时,我会同时在两个方向采样:客户端机器上跑 tcpconnect -t,服务端机器上跑 tcpaccept -t,再把两边的日志按时间戳对齐。如果 connect 已经发出但 accept 始终没有出现,问题大概率出在网络链路或负载均衡;如果 accept 出现但业务响应仍然慢,那就要往服务内部去追。
除了这对工具,tcplife 也很值得了解,它显示连接全生命周期,包括建立时间、关闭时间、收发字节数,适合做完整会话复盘。tcpdump 则更适合抓原始包看丢包重传,和这些 eBPF 工具结合起来,网络层的问题几乎都能覆盖。我的习惯是先用 eBPF 工具确认“连接层是否正常”,再决定要不要上包分析,避免一上来就被海量 tcpdump 输出淹没。
3.4 runqlat 量化调度延迟
调度延迟指的是线程已经处于可运行状态,但还没被调度器安排上 CPU 的等待时间。它看着很小,却是系统“发闷”的元凶:CPU 明明有余量,请求却像被堵住一样卡顿。runqlat 会把所有可运行线程的调度等待耗时,按照 log2 分桶输出直方图。执行一次 runqlat -m 10 1,可以看到最近 10 秒内的延迟分布,默认单位是毫秒。
如果直方图尾部出现大块值,说明有线程在可运行队列里等了很久。这时候去查 CPU 饥饿、cgroup cpu 配额、CPU 亲和性配置,才有明确方向。我遇到过不少次“高负载下偶发超时”的问题,从应用日志看非常随机,实际上就是某个 CPU 绑核绑坏了,单个核被中断打满,线程排队时间居高不下。runqlat 把等待时间量化出来之后,问题从“玄学”变成了“可测量”。
4. bpftrace 实战:一行脚本完成动态追踪
4.1 一行命令的语法拆解
bpftrace 的命令行参数设计得很像 awk:-e 后面跟脚本,脚本里用探针、条件、动作三段式组织。探针描述事件源,比如 tracepoint、kprobe、kfunc;条件用双斜杠 // 包裹,也可以省略;动作是 {} 内的代码,事件触发时执行。理解入口只需要记住一句话:先定义观察点,再决定看到事件后要做什么。很多人上来就啃手册,结果被各种参数劝退,其实从最简模板开始反而快。
bash复制bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s\n", comm); }'
这段脚本没有条件,动作是把触发 openat 的进程名打印出来。跑几秒钟,你就会发现系统里竟然有这么多隐藏的文件操作。下一步往 // 里加条件,比如 comm == "nginx"、pid == 1234,把关注范围逐步缩小。这个“先看全貌、再加过滤”的习惯,是 bpftrace 最重要的排查思路,直接决定现场诊断的速度。
4.2 用 tracepoint 抓系统调用
tracepoint 是内核里为观测特意预留的稳定事件,命名形如 tracepoint:子系统:事件名。用系统调用 tracepoint 做追踪最省心,因为参数结构是固定暴露的,args 对象里直接能取到每个参数。拿 sys_enter_openat 来说,可以用 str(args->filename) 拿到文件路径字符串,用 args->dfd 拿目录文件描述符。对比 kprobe 依赖寄存器猜参数,tracepoint 这种方式可靠很多。
bash复制bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %d %s\n", comm, pid, str(args->filename)); }'
上面的命令适合看“每次打开文件的一瞬间发生了什么”。如果你还关心调用结果,配合 BCC 的 opensnoop -x,可以快速锁定失败路径。tracepoint 的缺点是只覆盖内核预埋的事件,很多更细的内核行为没有现成事件可选,这时候才需要 kprobe 上场。
4.3 用 kprobe/kretprobe 抓内核函数
遇到 tracepoint 覆盖不了的目标,kprobe 就是在内核函数入口动态打补丁,kretprobe 则是在函数返回时触发,方便拿返回值。一个典型例子是统计 TCP 发送路径上的调用频次和字节数:
bash复制bpftrace -e 'kprobe:tcp_sendmsg { @calls = count(); @bytes = sum((size_t)arg2); }'
这里 arg0、arg1、arg2 分别对应函数前几个参数,具体含义要看函数原型。tcp_sendmsg 的第三个参数是发送字节数,所以用 arg2。kprobe 很灵活,但它的缺点也同样明显:内核函数名会随版本变化,一个函数可能被重构、改名或合并,脚本在两三个内核版本后就可能失效。所以能用 tracepoint 解决的,别随便用 kprobe;用 kprobe 就必须把内核版本写进注释,方便后续维护。
4.4 输出统计与直方图
有时候你不想看每一条事件记录,只想看整体分布。bpftrace 提供了强大的全局聚合函数,其中 hist() 会直接画出一张 log2 分桶直方图。想确认 vfs_read 单次读取大小的分布,可以写:
bash复制bpftrace -e 'kprobe:vfs_read { @size = hist((size_t)arg2); }'
按 Ctrl+C 退出时,bpftrace 会把所有以 @ 开头的全局统计变量一次性打印出来。看分布比看原始事件更容易发现异常:正常路径集中在几个桶,异常路径往往会在尾部拖一条长尾巴。我分析慢请求时,一般先用这种方式看入口分布,紧接着再用带 PID 过滤的 printf 版本锁定具体线程,两步操作基本能定位是“普遍变慢”还是“少数异常线程拖累”。
5. bpftool 与内核交互:查询、加载、pinning
5.1 查询正在运行的 BPF 程序
生产环境里同时跑着监控 agent、网络插件、安全软件的时候,你需要回答“这些 BPF 程序到底是谁加载的”。bpftool prog show 就是为这个问题设计的。它会把所有已加载程序的基本信息列出来,如果要脚本化处理,输出里追加 -j 转成 JSON 后用 jq 过滤,方便定位某一个标签或某个类型的程序。
bash复制bpftool prog show -j | jq '.[] | {name, id, type}'
想查看某个程序内部的指令,可以用 bpftool prog dump xlated id 123。这条命令会打印 BPF 字节码翻译后的指令流。我自己调试自定义 BPF 程序时,会拿它和源码逐段对照,很快能找到校验失败或逻辑错位的位置。dump jited 输出的是真实 CPU 指令,一般排障用不上,但做底层分析时很有价值。理解内核到底在跑什么,这一步不可跳过。
5.2 查看和刷新 BPF map
BPF map 是程序与用户态共享数据的核心结构,也是排查“数据怎么不动了”的第一现场。先用 bpftool map show 找到 map id,然后直接 dump 内容:
bash复制bpftool map dump id 7
hash map 会输出每个 key 对应的 value,数组型 map 则会按索引列出。如果发现 map 里的计数长期不增长,或者 value 从小变大后回不去,就要回到用户态确认是不是更新逻辑漏了。这里要提个醒:对高频更新的 map 反复 dump 会产生额外开销,线上操作要挑低峰期,不要直接在核心链路上反复刷。解释器再便宜,也经不住人为制造事件循环。
5.3 加载并 pin BPF 程序
对于自己开发的 BPF 程序,标准流程是开发、编译、加载、挂载、验证。用 bpftool 加载一个静态链接的 BPF 对象时,常见命令是:
bash复制bpftool prog load my_prog.o /sys/fs/bpf/my_prog
这个命令把程序加载进内核,并在 bpffs 上创建一个 pin 文件,让程序可以跨用户态进程共享。执行之前要确保 bpffs 已经挂载,通常用 mount -t bpf bpf /sys/fs/bpf。挂载之后,/sys/fs/bpf 下就能看到 pin 出来的 BPF 对象。把程序 pin 在固定路径,可以避免“用户态进程一退出程序就消失”的问题,这也是生产环境里长期运行 BPF 程序的常见做法。
6. 我在实操中踩过的坑和排查思路
6.1 权限问题与内核态限制
运行这些命令行工具,第一道门槛就是权限。eBPF 加载通常需要 CAP_BPF 或者 root,很多发行版在没有 root 的情况下会直接报 Operation not permitted。容器里跑也经常遇到同一问题,因为默认的 container 配置没给对应 capability。我的建议是先以宿主机 root 身份执行 bpftool prog show 做验证,如果这里都列不出程序,那问题很可能出在权限而非工具本身。容器场景下,可以临时用 privileged 模式来确认,但生产上这种做法要极其谨慎。
6.2 内核版本、BTF 与工具兼容
BCC 和 bpftrace 的很多高级功能依赖内核开启 CONFIG_DEBUG_INFO_BTF。内核太老,很多工具连启动都困难。你不需要背版本号,只要记住一条验证命令:
bash复制test -f /sys/kernel/btf/vmlinux && echo BTF_OK
如果这个文件不存在,说明 BTF 没开,新版工具只能退回 kprobe 或者直接报错。另一种常见问题是内核函数签名频繁变化:我在 5.10 内核上写好的 bpftrace 脚本,到 5.15 上竟然完全不打点。所以上线或换环境之前,先跑一次最小验证脚本,确认探针能触发,再进入正式排查,这个成本非常低。
6.3 手滑下错了工具版本
在很多老系统上直接用发行版的包管理器安装 BCC,装到的版本可能远旧于主线。比如 Ubuntu 20.04 自带 BCC 0.11,而当前主线已经到 0.28 以上,很多新命令和参数都没有。我的建议是优先从源码安装 BCC 和 bpftrace,并把安装目录的 bin 放到 PATH 最前面。源码编译时 BCC 会依赖 libbpf,不同版本的 cmake 选项还不太一样,最稳妥的做法是严格按官方 README 操作,不要翻过时的旧教程。装好之后立刻跑一条 execsnoop -V 或对应版本参数,确认实际生效的是你刚编译的版本。
6.4 监控工具自身开销控制
eBPF 性能好不等于没有成本。如果探针挂在一个高频调用的函数上,过滤条件又没写好,事件风暴会让 CPU 快速飙升。我踩过最惨的一次,是在一个每秒几万次调用的热路径函数里直接加了 printf,结果线上 CPU 高了三倍。原则很简单:先用过滤条件把事件量压到最低;能统计就别一条条打印;打印时把 PID 或进程名校验加上;高频探针的运行时间要设上限,我习惯用 60 秒的定时窗口来强制收尾。
这些坑听起来朴素,现场一个比一个要命。把排查流程固定下来会省很多事:先用 BCC 常用命令形成基线,再用 bpftrace 打点验证细分假设,最后用 bpftool 收尾检查 BPF 程序状态。这个组合拳打下来,系统被“看透”的速度比过去快一个数量级。我个人还有一个偏方,是把所有常用 BCC 命令整理进一个 shell 函数文件,取名叫 bt,每次只需要传参数。比如敲 bt exec 1234 就等价于 execsnoop -p 1234。这算不上高深技术,但确实让日常巡检变得顺手很多。后续如果你想把这些追踪逻辑固化成长期指标,可以继续用 bpftrace 脚本对接数据管道,不过那就是另一个关于低开销设计与可靠性的话题了。
