eBPF 网络性能追踪实战:三次握手延迟异常的全链路定位复盘
一、异常的 300ms:TCP 握手为什么比 ping 慢了 10 倍
一次线上服务迁移后,新机房的 P50 连接建立延迟从旧机房的 8ms 飙升到 320ms。奇怪的是,同机房内的 ping 延迟只有 0.3ms,网络看起来没有问题。ssh 连接、scp 传文件也都正常。问题只在应用层的 TCP 连接建立上。
传统的tcpdump抓包能确认握手确实发生了延迟,但无法回答"延迟发生在内核的哪个环节"。TCP 握手涉及内核协议栈中至少 6 个关键函数(tcp_v4_rcv → tcp_v4_do_rcv → tcp_rcv_state_process → tcp_v4_conn_request → tcp_rcv_synsent_state_process → tcp_finish_connect),仅凭抓包无法定位是哪一个环节在磨蹭。
eBPF 的 kprobe 能力可以在这个场景做到函数级的时间追踪。通过在内核 TCP 握手的关键函数上挂载 kprobe,记录每个函数的进入和退出时间戳,可以精确量化每个环节的耗时。
二、eBPF kprobe 追踪方案的实现
使用 BCC(BPF Compiler Collection)框架编写 eBPF 追踪程序,在 TCP 握手的 6 个关键函数上挂载 kprobe 和 kretprobe:
#!/usr/bin/env python3 # tcp_handshake_trace.py —— eBPF 追踪 TCP 握手各阶段延迟 from bcc import BPF import time bpf_code = """ #include <uapi/linux/ptrace.h> #include <net/sock.h> // 定义事件结构:记录每个连接的握手各阶段时间戳 struct handshake_event { u64 pid; // 进程 ID u32 saddr; // 源 IP(网络字节序) u32 daddr; // 目标 IP u16 sport; // 源端口 u16 dport; // 目标端口 u64 tcp_v4_rcv_enter; // tcp_v4_rcv 进入时间 u64 tcp_v4_rcv_exit; // tcp_v4_rcv 退出时间 u64 state_process_enter; // tcp_rcv_state_process 进入 u64 state_process_exit; // tcp_rcv_state_process 退出 u64 conn_request_enter; // tcp_v4_conn_request 进入 u64 finish_connect_enter; // tcp_finish_connect 进入 }; // BPF_PERF_OUTPUT:将事件从内核态推送到用户态 BPF_PERF_OUTPUT(events); // BPF_HASH:在内核态存储中间状态(连接 → 时间戳映射) BPF_HASH(start_time, u64, struct handshake_event); // kprobe: 在 tcp_v4_rcv 函数入口处记录时间戳 int kprobe__tcp_v4_rcv(struct pt_regs *ctx, struct sk_buff *skb) { u64 pid_tgid = bpf_get_current_pid_tgid(); struct handshake_event evt = {}; evt.pid = pid_tgid >> 32; evt.tcp_v4_rcv_enter = bpf_ktime_get_ns(); // 纳秒级时间戳 // 从 sk_buff 中提取连接四元组 struct tcphdr *tcp = skb->hdr.tcp; // 实际需要更复杂的偏移计算 struct iphdr *ip = skb->hdr.ip; evt.saddr = ip->saddr; evt.daddr = ip->daddr; evt.sport = tcp->source; evt.dport = tcp->dest; start_time.update(&pid_tgid, &evt); return 0; } // kretprobe: 在 tcp_v4_rcv 函数返回处记录退出时间 int kretprobe__tcp_v4_rcv(struct pt_regs *ctx) { u64 pid_tgid = bpf_get_current_pid_tgid(); struct handshake_event *evt = start_time.lookup(&pid_tgid); if (evt) { evt->tcp_v4_rcv_exit = bpf_ktime_get_ns(); // 计算耗时并提交事件 u64 latency = evt->tcp_v4_rcv_exit - evt->tcp_v4_rcv_enter; // 只上报延迟 > 1ms 的异常事件,减少用户态处理压力 if (latency > 1000000) { // 1ms = 1,000,000ns events.perf_submit(ctx, evt, sizeof(*evt)); } start_time.delete(&pid_tgid); } return 0; } """ b = BPF(text=bpf_code) def print_event(cpu, data, size): event = b["events"].event(data) latency_ms = (event.tcp_v4_rcv_exit - event.tcp_v4_rcv_enter) / 1e6 print(f"PID={event.pid} {event.saddr}->{event.daddr}:{event.dport} " f"latency={latency_ms:.2f}ms") # 注册事件回调,打印延迟超过 1ms 的连接 b["events"].open_perf_buffer(print_event) print("追踪 TCP 握手中... Ctrl+C 退出") while True: b.perf_buffer_poll()三、根因定位:netfilter conntrack 表的锁竞争
eBPF 追踪的结果揭示了延迟热点:280ms 的延迟全部集中在tcp_v4_rcv函数内部。进一步缩小范围后发现,函数内部的nf_conntrack_in调用(连接跟踪表的查找)是耗时元凶。
连接跟踪表(conntrack table)默认使用全局自旋锁保护。在新机房场景下,大量短连接(每秒约 3 万个)同时进行连接跟踪的创建和查找,导致锁竞争白热化。旧机房未复现此问题是因为连接速率远低于新机房(每秒约 3000 个),锁竞争不明显。
修复方案分为两步。第一步是直接关闭不需要的 conntrack(确认系统没有使用 iptables NAT 规则后):
# 关闭连接跟踪 —— 仅适用于无 NAT 需求的纯路由场景 iptables -t raw -I PREROUTING -j NOTRACK iptables -t raw -I OUTPUT -j NOTRACK第二步是调整 conntrack 哈希表大小以减少锁竞争(适用于无法关闭 conntrack 的场景):
# 扩大 conntrack 哈希桶数量 —— 减少 hash 冲突和锁竞争 # 默认值通常为 8192,调整为 262144(按最大连接数的 1/4 估算) echo 262144 > /sys/module/nf_conntrack/parameters/hashsize # 增大最大连接跟踪条目数 echo 1048576 > /proc/sys/net/netfilter/nf_conntrack_max四、排查工具对比
| 方法 | 定位精度 | 对系统影响 | 学习成本 |
|---|---|---|---|
tcpdump抓包 | 毫秒级,端点级 | 高(磁盘 I/O) | 低 |
| 应用层日志打点 | 毫秒级,应用级 | 低 | 低 |
ss -s查看队列 | 状态级,无延迟 | 极低 | 低 |
| eBPF kprobe | 纳秒级,函数级 | 极低 | 高 |
关闭 conntrack 前后的延迟对比:
| 指标 | 优化前 | 优化后 | 改善 |
|---|---|---|---|
| TCP 握手 P50 | 320ms | 1.2ms | -99.6% |
| TCP 握手 P99 | 850ms | 3.5ms | -99.6% |
tcp_v4_rcv耗时 | 280ms | 0.08ms | -99.97% |
| 连接建立成功率 | 92% | 99.8% | +8.5% |
五、总结
eBPF 在内核网络排障中的核心价值:
- 函数级精度是传统工具的盲区:tcpdump 只能看到端点间的数据包交换,无法知晓内核内部哪个函数在耗时。kprobe 填补了这个盲区;
- 纳秒级时间戳是找根因的基础:280ms 的延迟分散在 tcp_v4_rcv 内部,只有纳秒级采样才能区分 nf_conntrack_in 和其他子函数的耗时占比;
- 过滤低延迟事件减少数据洪流:eBPF 程序中添加延迟阈值过滤(只上报 >1ms 的事件),将用户态的数据处理量降低 2~3 个数量级,适合生产环境的持续运行;
- conntrack 是高频短连接场景的常见陷阱:每秒 3 万级别的连接跟踪足以压垮默认配置的锁机制,无 NAT 需求时直接关闭是最简方案。
适用边界:eBPF kprobe 方案要求内核版本 >= 4.9(BCC 支持)且启用了 CONFIG_DEBUG_INFO_BTF。CentOS 7 等较老内核需要额外编译 BTF 信息。