上周接手一台跑网关的机器,业务反馈下单接口偶发卡住,P99 从平时的 40ms 跳到 800ms,第一反应当然是"服务器扛不住了",于是登上去看:CPU 使用率 5%,内存还有一半,磁盘 IO 等待接近 0。top、htop、sar 翻了一圈,什么异常都没看出来。真正的问题藏在用户态看不见的地方内核态的排队。
这次我换了个工具思路,用 eBPF 直接在内核里挂钩子,几秒钟就把"时间花在哪儿"抓了出来。这篇就把我这套排查路径完整写下来:什么时候该怀疑内核、怎么装 bpftrace、探针怎么写、读出来的火焰图怎么解读,以及哪些情况下 eBPF 并不该是你的第一选择。
如果你打算自己搭一套环境来复现和观测这类问题,可以先看看这几家的机器和线路:dotnetworks(高性能服务器与 VPS,主打定制化基础设施)、sakuraclouds(AI 云基础设施,自建低延迟网络)。内核层面的观测对机器的稳定性、时钟精度和网络往返都有要求,选一台抖动小的机器做对照实验,数据才有说服力。

一、为什么 CPU 和磁盘都正常,接口还是慢
先说清楚一个容易被忽略的事实:请求从网卡进来,到业务代码拿到它,中间要穿过好几层内核队列网卡驱动、软中断、协议栈收包队列(skb backlog)、socket 接收缓冲、进程调度队列。任何一层积压,表现都是"业务线程在等",而这条等待路径在用户态的火焰图里几乎看不见,因为等待发生在上下文切换之前。
所以典型的误导性现场是:top 里进程 CPU 很低(因为它确实没在跑,在睡觉),sar -q 里 runqueue 也正常(队列很短),但 vmstat 的 r(就绪队列)偶尔跳一下,si(软中断)很高。这时候 CPU 利用率低,不代表 CPU 没在干活恰恰相反,软中断是内核在替你干活。
我这次的具体表现是:应用侧看到的是 P99 抖动,尖刺集中在整秒附近,几百毫秒一批,然后归于平静。整秒这个特征很关键,它通常指向定时器、网络轮询或某个周期性任务,而不是随机的负载问题。
二、eBPF 到底解决了什么传统工具的短板
传统的 strace 只能追单个进程,看不到内核整体;perf 很强,但默认采样的是 CPU 周期,一个阻塞在等待上的进程,CPU 周期数很少,采样里根本看不见;ftrace 能看内核函数调用,但语法生硬、聚合分析要自己写。
eBPF 的价值在于:它允许你把一段 C 程序挂到内核的任意探针点上。挂载前内核会做一遍"验证器"检查,确保这段程序不会崩溃、不会死循环、访问的内存是合法的,检查通过就 JIT 编译成机器码挂上去。整个过程不需要重启内核、不需要改一行内核代码,这是它跟老一代 kprobe 工具最大的区别。
所以它的定位是"把内核当黑盒,只看时间去了哪儿"。注意,它不能改变任何行为,这是设计上的安全边界,也是它比 bpftrace/kprobe 更让人放心的地方。
三、上手三步:从零到看见 off-CPU 火焰图
第一步,确认环境。eBPF 需要 Linux 4.9 以上的内核(生产上建议 5.4+),并且内核配置里要打开 CONFIG_BPF、CONFIG_BPF_SYSCALL、CONFIG_BPF_JIT。在容器里跑的话,容器需要 --privileged 或者至少放开 CAP_BPF + CAP_SYS_ADMIN + CAP_PERFMON,否则你会看到 Operation not permitted。检查命令很简单:
uname -r 看版本,ls /sys/kernel/debug/tracing 看 ftrace 是否挂载,bpftool feature probe kernel 看内核支持哪些特性。Debian/Ubuntu 上装 bpftrace 很简单,apt install bpftrace 就行,RHEL 系走 dnf。
第二步,写第一条探针。最经典的是统计每个进程在"不可运行状态"上花了多少时间,也就是 off-CPU 时间这部分正是"没在 CPU 上但也没在干活"的等待:
bpftrace -e 'profile:hz:99 /pid == $target/ { @[comm] = @[comm] + 1; }' 只能看运行态,off-CPU 的等价写法需要挂到调度器:
bpftrace -e 'software:traces:sched:sched_switch { @offcpu[args->prev_comm] = nsecs - args->prev_state; }'
其中 prev_state 是任务离开 CPU 时的状态位,2 表示不可中断睡眠(典型是磁盘或网络 IO),1 表示可中断睡眠,0 表示主动让出。这条探针跑三十秒后按 Ctrl+C,输出就是"哪个进程、因为什么原因、被卡了多久"的排行榜。
第三步,把火焰图画出来。纯文本的排行榜往往看不出调用关系,这时用 bpftrace 的 flamegraph 输出:
bpftrace -e 'software:traces:sched:sched_switch { @[args->prev_comm, kstack] = count(); }' -f flamegraph
输出是折叠的 SVG,宽度代表占用比例。哪一层特别宽,就是元凶。图形化工具本身不方便装的话,也可以先用纯文本版看个大概。

四、我的案例:时间花在了 softirq 和一个被忽略的队列上
挂着 off-CPU 探针跑了大概二十分钟,输出非常干净:约 78% 的 off-CPU 时间归属一个叫 nginx 的进程组,栈顶是 tcp_recvmsg 和 sk_wait_data,也就是进程在 socket 上等数据;剩下的一小部分落在 kworker/0:1 上。
第二段探针我把视角切到网络栈,直接看收包路径:
bpftrace -e 'kprobe:__netif_receive_skb { @[probe] = count(); }' -e 'kprobe:netif_rx { @[probe] = count(); }'
两个探针的次数差异巨大,__netif_receive_skb 比 netif_rx 少了一大截,说明有相当一部分包没走常规 NAPI 路径。结合 /proc/net/softnet_stat 里持续增长的 dropped 计数,最终定位到是网卡多队列没配好,CPU 亲和性把中断全压到了少数几个核上,剩下的核在空转,队列长度被顶满。
处理方式不复杂:调整 irqbalance 绑定、给网卡队列数设成跟物理核数对齐、重启网卡后尖刺消失。这个结论,用 top 或者 sar 无论盯多久都看不出来,因为整个过程中 CPU 平均利用率始终不到 6%。

五、哪些情况下别把 eBPF 当第一选择
工具再强也有边界,这几条是我踩过的。第一,开销问题。探针本身不慢,但一个写得 careless 的探针在高并发下能吃掉可观 CPU,比如在每个包上做正则匹配。建议先在测试环境验证,并设置采样率。
第二,内核版本太老。4.x 早期内核缺少 BTF,很多现代探针写法用不了,老机器上只能退回 ftrace 的 tracepoint 或者 perf。
第三,问题不在内核。如果是 DNS 解析慢、TLS 握手慢、数据库慢,那本质是应用层依赖问题,用 eBPF 硬看只会浪费时间。先用 curl -w 分段计时把耗时拆开,能省下大量时间。
第四,容器环境的权限。别人的 K8s 集群你未必有 privileged 权限,这时候先看 /proc/net/softnet_stat、/proc/net/dev、ss -ti 这些零成本的现成信息,一样能看出队列积压和重传。
六、我的排查顺序,现在基本固定了
总结成一条可复用的路径:先用 strace 确认等待点,再用 perf/sar 排除应用层,最后用 eBPF 下沉到内核。整个过程从粗到细,通常在十五分钟内就能定位到具体队列。
配套的观测手段我一般这么组合:ss -ti 看每条连接的拥塞窗口和重传,softnet_stat 看软中断丢包,perf top -e skb:xxx 看网络栈热点,bpftrace 做最后的归因。桌面上再配一个 实时性能监控 之类的工具看趋势,等于把"瞬时抓不到"的问题变成"事后能回看"。
eBPF 真正改变的是排查的分辨率:以前你只能在"应用慢了"这一层打转,现在能把时间精确到某个内核函数、某个队列、某个中断。这种精度在几毫秒就是 SLA 的业务上,价值非常直接。


喜欢
顶
难过
囧
围观
无聊





