eBPF 实战:一次诡异的 Nginx 高延迟,我用 5 分钟在内核里找到了真凶
没有玄学,只有内核里的铁证如山。
P99延迟无故飙升,CPU空闲,网络通畅,文件I/O正常------那一刻,我怀疑自己遇到了灵异事件。
01 诡异的下午:什么都没变,服务却慢了10倍
那天下午,监控告警突然响起:线上Nginx网关的P99延迟从稳定的50ms飙升至500ms以上。
第一反应是查看常规指标:
- CPU:idle 30%以上,没爆。
- 内存:充足,无swap。
- 网络:带宽未打满,TCP重传率正常。
- 磁盘I/O:util 5%,几乎没有读写。
bash
# 传统三板斧
top # CPU空闲
free -m # 内存充足
iostat -x 1 # I/O空闲
ss -s # 连接数正常,TIME-WAIT不多
诡异之处:服务没重启,流量没突增,代码没发布,但延迟就是高了。这种"三无"问题,就像家里没开大功率电器,电表却飞转,让人无从下手。
02 常规手段失效:黑盒排查的尽头
既然外围指标正常,只能钻进内核看个究竟。此时,eBPF是我最后的希望------无需修改代码,无需重启服务,直接在内核里装"监控探头"。
第一板斧:BCC工具集,快速画像
我首先祭出BCC工具集(BPF Compiler Collection),几个命令迅速定位异常进程:
bash
# 追踪新进程创建(怀疑有短时进程耗资源)
execsnoop # 无异常,没有频繁的进程创建
# 追踪文件打开(怀疑读配置频繁)
opensnoop -n nginx # 正常,只打开几次
# 追踪慢文件系统操作(看是不是I/O慢)
ext4slower # 无慢查询,排除文件系统
一通操作下来,依旧毫无头绪。Nginx worker进程既没有疯狂开文件,也没有频繁fork,那延迟到底卡在哪里?
第二板斧:bpftrace,给内核函数上"秒表"
既然宏观工具看不清,我决定用bpftrace直接测量内核函数耗时。在Linux内核网络栈中,accept系统调用是TCP连接建立的最后一步,如果这里变慢,请求必然延迟。
写一个bpftrace脚本,测量Nginx worker进程执行accept函数的耗时:
bash
#!/usr/bin/env bpftrace
uprobe:/usr/sbin/nginx:ngx_event_accept
{
@start[pid] = nsecs;
}
uretprobe:/usr/sbin/nginx:ngx_event_accept
/@start[pid]/
{
$time = (nsecs - @start[pid]) / 1000; # 微秒
@accept_us = hist($time);
delete(@start[pid]);
}
执行脚本,等待几秒,查看直方图:
bash
bpftrace accept_latency.bt
^C
@accept_us:
[0] 3120 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1] 1023 |@@@@@@@@@@@@@@@ |
[2, 4) 321 |@@@@ |
[4, 8) 87 |@ |
[8, 16) 12 | |
[16, 32) 522 |@@@@@@@@ | <-- 居然有大量耗时超过16微秒的调用!
[32, 64) 1502 |@@@@@@@@@@@@@@@@@@@@@@@ | <-- 甚至超过32微秒!
真相初现 :正常情况下,accept只是一次内存拷贝,应该稳定在1微秒以内。但此时出现了大量超过16微秒的调用,最慢的甚至超过64微秒------accept函数本身变慢了。
03 顺藤摸瓜:谁在拖延accept?
accept慢,说明内核在接受连接时被阻塞了。继续向下追踪:在Linux内核中,accept最终会调用inet_csk_accept。我再次用bpftrace挂载这个内核函数:
bash
bpftrace -e 'kprobe:inet_csk_accept { @start[pid] = nsecs; }
kretprobe:inet_csk_accept /@start[pid]/ {
@accept_latency = hist(nsecs - @start[pid]); delete(@start[pid]); }'
结果与用户态一致:内核函数同样存在延迟尖峰。这说明,阻塞发生在内核更深处。
查阅内核源码(顺带一提,Nginx社区也有关于listen socket和eBPF的讨论),怀疑对象指向监听队列 和锁竞争 。在inet_csk_accept函数中,获取accept_queue锁的竞争可能导致进程睡眠等待。
为了验证锁竞争,我尝试挂载内核中的锁函数:
bash
# 追踪锁等待时间(理论示例,实际需根据内核版本调整)
bpftrace -e 'kprobe:spin_lock { @lock_wait = hist(nsecs - ...); }'
虽然锁的追踪较为复杂,但结合上下文,高并发下,多个worker进程争抢同一个监听套接字的队列锁,导致部分进程休眠等待 ,从而引发accept延迟。这才解释了为什么P99飙升而CPU空闲------线程在等锁,不是在跑指令。
04 破案:SO_REUSEPORT的代价
真凶锁定 :Nginx采用了SO_REUSEPORT多进程监听同一端口,内核负载均衡将新连接分发到多个worker。但在极端并发下,内核内部的队列锁(如listen socket的syn queue和accept queue锁)成为争抢热点 。当某个worker运气不佳,被调度到与其它worker同时争锁,就会进入休眠等待,导致accept调用延迟数十微秒,进而拉高整体P99延迟。
05 复盘:eBPF为何能破案?
回顾整个过程,eBPF的价值在于提供了无侵入的内核上下文观测能力:
- 全栈可见:从用户态函数(ngx_event_accept)到内核态函数(inet_csk_accept),再到潜在的锁竞争,完整追踪调用链路。
- 精准量化:不是泛泛地看CPU使用率,而是精确测量每一个关键函数的耗时分布。
- 低 overhead:生产环境也可用,只采集必要数据,不拖垮系统。
写在最后
那次故障之后,我对"黑盒"二字有了新的理解。所谓黑盒,不是内核真的不可知,而是我们手头没有合适的工具去观察它。eBPF就像一束光,照进了内核的每一个角落。
当你下次再遇到"什么都没变,但服务就是慢了"的灵异事件时,不妨问问自己:是时候看看内核里发生什么了。因为在这个时代,没有真正的黑盒,只有尚未使用的eBPF。
小提示 :如果你也遇到了类似问题,不妨从execsnoop、opensnoop、xfsslower等BCC工具开始,再用bpftrace深入函数级耗时分析。内核里没有秘密,只看你愿不愿意去寻找。