连接追踪:eBPF 实现 TCP 连接追踪

连接追踪:eBPF 实现 TCP 连接追踪 从内核探针到状态机再到生产级防坑手册一篇讲透序一次连接超时引发的“内核级”反思凌晨两点监控大屏突然爆红。Kubernetes 集群中某个 Service 的 Pod 频繁报出Connection timed out。我们迅速检查了ss -tunap看到连接状态全是ESTABLISHED查看了conntrack -L计数也正常。但业务日志无情地显示建连失败率高达 15%。传统的网络观测工具在这个瞬间变成了“盲人摸象”。它们只告诉我们“现在有什么”却无法回答“刚才这 15% 的连接经历了什么”。我们需要一套系统它必须能做到全量采集不遗漏任何一个 TCP 连接尝试哪怕 SYN 包没回。状态机还原记录每个连接在SYN_SENT-ESTABLISHED-CLOSE这条时间线上的每一毫秒。失败归因精准区分是“客户端超时”、“服务端 Backlog 满”还是“网络丢包”。基于此我们放弃了conntrack和iptables选择了一条看似崎岖、实则风光无限的路——eBPF (Extended Berkeley Packet Filter)。本文将毫无保留地公开我们实现过程中的核心代码、踩坑记录以及性能调优策略。第一部分为什么 eBPF 是“降维打击”在开始编码前我们需要从底层原理上理解为什么 eBPF 能解决传统工具解决不了的问题。1.1 传统工具的上限在哪conntrack的“盲区”conntrack依赖 Netfilter 钩子。如果数据包被iptables直接DROP了或者走了ip_forward路径且未开启 conntrack那么conntrack根本看不见这个连接。更关键的是它不关心 TCP 重传、不关心 RTT它只是一个“状态表”不是“分析仪”。tcpdump的“重负”抓包需要将数据包从内核拷贝到用户态PF_PACKET协议族在高带宽下如 10Gbps这种拷贝和上下文切换会瞬间打爆 CPU。1.2 eBPF 的“上帝视角”eBPF 程序运行在内核态但它是一个沙箱。它允许我们在不修改内核源码、不加载内核模块的情况下挂载到内核函数kprobe、跟踪点tracepoint上。零拷贝数据交换eBPF 使用Ring Buffer或Per-CPU Array与用户态通信避免了tcpdump那种沉重的内存拷贝。内核结构体读取我们可以通过bpf_probe_read_kernel直接读取struct sock中的私密字段比如RTO (重传超时)、RTT (往返时间)、Sack 信息。结论eBPF 给了我们一个“上帝视角”让我们能在 1% 的 CPU 开销下还原网络传输的每一个微观细节。第二部分原型开发——从零搭建追踪框架我们将使用C 语言编写 eBPF 内核态代码使用Golang/Python编写用户态控制面。2.1 定义超级“连接 Key”与“Value”传统的五元组在 NAT 环境下容易冲突。为了更精确我们引入Netns网络命名空间标识。// 内核态头文件 defs.h#defineTASK_COMM_LEN16// 连接唯一标识Netns 五元组structconn_key{__u32 netns;// 网络命名空间区分容器__u32 saddr;// 源 IP__u32 daddr;// 目的 IP__u16 sport;// 源端口__u16 dport;// 目的端口};// 连接详情不仅仅是状态机还要有流量和时延指标structconn_info{// ---------- 状态机 ----------__u8 state;// 当前 TCP 状态 (TCP_ESTABLISHED等)__u8 old_state;// 上一次状态 (用于状态跳转分析)// ---------- 时间轴 ----------__u64 birth_time_ns;// 连接创建时间 (内核 ns)__u64 syn_sent_time_ns;// 发出 SYN 的时间__u64 established_time_ns;// 变为 ESTABLISHED 的时间__u64 fin_time_ns;// 收到 FIN 的时间// ---------- 流量统计 (原子操作保障) ----------__u64 rx_bytes;// 接收字节数__u64 tx_bytes;// 发送字节数__u32 rx_packets;// 接收包数__u32 tx_packets;// 发送包数__u32 retransmits;// 重传次数 (读自内核)// ---------- 业务溯源 ----------__u32 pid;// 发起连接的进程 PIDcharcomm[TASK_COMM_LEN];// 进程名 (如 nginx, java)};2.2 核心挂钩点inet_sock_set_state与tcp_retransmit_skb我们不需要挂载在网卡收包函数上那样开销太大而是挂载在状态变更和重传的关键路径上。tracepoint / sock / inet_sock_set_state这是 TCP 状态机变更的“总开关”。kprobe / tcp_retransmit_skb这是探测网络质量劣化的“监听器”。重点代码状态变更捕获// tcp_tracer_kern.c#includevmlinux.h// 包含内核结构体定义#includebpf/bpf_helpers.h#includebpf/bpf_tracing.h#includebpf/bpf_core_read.h// 定义 Map (前面已定义, 此处省略 conn_map 和 events map)SEC(tracepoint/sock/inet_sock_set_state)inthandle_tcp_state_change(structtrace_event_raw_inet_sock_set_state*args){structsock*sk(structsock*)args-skaddr;structconn_keykey{0};structconn_info*info,new_info{0};__u8 newstateargs-newstate;__u8 oldstateargs-oldstate;// Step 1: 过滤掉非 TCP 或者 不需要跟踪的状态 (如 TCP_LISTEN 初期不处理)if(sk-sk_protocol!IPPROTO_TCP)return0;// Step 2: 构建 Key (使用 BPF CO-RE 辅助宏读取)key.netnsBPF_CORE_READ(sk,sk_net.netns);key.saddrBPF_CORE_READ(sk,__sk_common.skc_rcv_saddr);key.daddrBPF_CORE_READ(sk,__sk_common.skc_daddr);key.sportBPF_CORE_READ(sk,__sk_common.skc_num);key.dportbpf_ntohs(BPF_CORE_READ(sk,__sk_common.skc_dport));// Step 3: 查询或创建连接信息infobpf_map_lookup_elem(conn_map,key);if(!info){// 只有状态为 SYN_SENT 或 SYN_RECV 时才创建防止无效 entryif(newstateTCP_SYN_SENT||newstateTCP_SYN_RECV){new_info.birth_time_nsbpf_ktime_get_ns();new_info.statenewstate;new_info.old_stateoldstate;// 记录当前进程 PIDnew_info.pidbpf_get_current_pid_tgid()32;bpf_get_current_comm(new_info.comm,sizeof(new_info.comm));if(newstateTCP_SYN_SENT){new_info.syn_sent_time_nsnew_info.birth_time_ns;}bpf_map_update_elem(conn_map,key,new_info,BPF_NOEXIST);}return0;}// Step 4: 关键状态转换处理 (核心逻辑)// 4.1 握手成功: SYN_RECV - ESTABLISHEDif(newstateTCP_ESTABLISHEDoldstateTCP_SYN_RECV){info-established_time_nsbpf_ktime_get_ns();info-old_stateoldstate;info-statenewstate;// 推送“连接建立”事件到 Ringbufstructconn_event*ebpf_ringbuf_reserve(events,sizeof(*e),0);if(e){e-typeEVENT_ESTABLISHED;e-keykey;e-handshake_duration_nsinfo-established_time_ns-info-syn_sent_time_ns;bpf_ringbuf_submit(e,0);}bpf_map_update_elem(conn_map,key,info,BPF_EXIST);return0;}// 4.2 连接关闭: ESTABLISHED - CLOSE 或 FIN_WAITif(newstateTCP_CLOSE(oldstateTCP_ESTABLISHED||oldstateTCP_CLOSE_WAIT)){info-old_stateoldstate;info-statenewstate;__u64 duration_nsbpf_ktime_get_ns()-info-birth_time_ns;// 推送“连接关闭”事件structconn_event*ebpf_ringbuf_reserve(events,sizeof(*e),0);if(e){e-typeEVENT_CLOSED;e-keykey;e-duration_nsduration_ns;// 从这里读取最终流量统计e-rx_bytesinfo-rx_bytes;e-tx_bytesinfo-tx_bytes;bpf_ringbuf_submit(e,0);}// 删除 Map 条目释放内存bpf_map_delete_elem(conn_map,key);return0;}// 4.3 其他状态更新 (如 FIN_WAIT1, TIME_WAIT 等)info-old_stateoldstate;info-statenewstate;bpf_map_update_elem(conn_map,key,info,BPF_EXIST);return0;}重点代码重传追踪 (探测网络问题)SEC(kprobe/tcp_retransmit_skb)intBPF_KPROBE(tcp_retransmit_skb_entry,structsock*sk,structsk_buff*skb){structconn_keykey{0};structconn_info*info;// 构建 Keykey.netnsBPF_CORE_READ(sk,sk_net.netns);key.saddrBPF_CORE_READ(sk,__sk_common.skc_rcv_saddr);key.daddrBPF_CORE_READ(sk,__sk_common.skc_daddr);key.sportBPF_CORE_READ(sk,__sk_common.skc_num);key.dportbpf_ntohs(BPF_CORE_READ(sk,__sk_common.skc_dport));infobpf_map_lookup_elem(conn_map,key);if(info){// 原子增加重传计数__sync_fetch_and_add(info-retransmits,1);// 如果重传次数突增立即推送告警事件if(info-retransmits%50){// 每 5 次重传告警一次structconn_event*ebpf_ringbuf_reserve(events,sizeof(*e),0);if(e){e-typeEVENT_RETRANSMIT;e-keykey;e-retrans_countinfo-retransmits;bpf_ringbuf_submit(e,0);}}}return0;}第三部分用户态协同——高效的数据消费与沉淀内核态负责生产数据用户态负责消费和展示。这里我们使用 Go 语言配合cilium/ebpf库。3.1 Ring Buffer 的高效读取机制用户态程序必须快速消费 Ringbuf否则内核会因为缓冲区满而丢弃事件。packagemainimport(bytesencoding/binaryfmtlognetosos/signalsyscalltimegithub.com/cilium/ebpfgithub.com/cilium/ebpf/linkgithub.com/cilium/ebpf/ringbufgithub.com/cilium/ebpf/rlimit)// 结构体定义必须与内核态严格对齐typeConnKeystruct{Netnsuint32Saddruint32Daddruint32Sportuint16Dportuint16}typeConnEventstruct{Typeuint32Key ConnKey HandshakeDurationNsuint64DurationNsuint64RxBytesuint64TxBytesuint64RetransCountuint32}funcmain(){// 1. 加载 eBPF 程序 (从嵌入式对象文件加载)objs:bpfObjects{}iferr:loadBpfObjects(objs,nil);err!nil{log.Fatalf(加载 eBPF 对象失败: %v,err)}deferobjs.Close()// 2. 挂载 Tracepoint 和 Kprobetp,err:link.Tracepoint(sock,inet_sock_set_state,objs.HandleTcpStateChange,nil)iferr!nil{...}defertp.Close()kp,err:link.Kprobe(tcp_retransmit_skb,objs.TcpRetransmitSkbEntry,nil)iferr!nil{...}deferkp.Close()// 3. 打开 Ring Buffer Readerrd,err:ringbuf.NewReader(objs.Events)iferr!nil{...}deferrd.Close()log.Println(eBPF 追踪器已启动等待事件...)// 4. 事件循环gofunc(){for{record,err:rd.Read()iferr!nil{iferrors.Is(err,ringbuf.ErrClosed){return}log.Printf(读取事件出错: %v,err)continue}varevent ConnEventiferr:binary.Read(bytes.NewBuffer(record.RawSample),binary.LittleEndian,event);err!nil{log.Printf(解析事件失败: %v,err)continue}// 根据事件类型进行处理switchevent.Type{case1:// EVENT_ESTABLISHEDfmt.Printf([✅ 握手成功] %s:%d - %s:%d | 耗时: %.2fms\n,intToIP(event.Key.Saddr),event.Key.Sport,intToIP(event.Key.Daddr),event.Key.Dport,float64(event.HandshakeDurationNs)/1e6)case2:// EVENT_CLOSEDfmt.Printf([ 连接关闭] %s:%d - %s:%d | 存活: %.2fs | 流量: %d B\n,intToIP(event.Key.Saddr),event.Key.Sport,intToIP(event.Key.Daddr),event.Key.Dport,float64(event.DurationNs)/1e9,event.RxBytesevent.TxBytes)case3:// EVENT_RETRANSMITfmt.Printf([⚠️ 严重重传] %s:%d - %s:%d | 重传次数: %d\n,intToIP(event.Key.Saddr),event.Key.Sport,intToIP(event.Key.Daddr),event.Key.Dport,event.RetransCount)}}}()// 5. 优雅退出sigCh:make(chanos.Signal,1)signal.Notify(sigCh,syscall.SIGINT,syscall.SIGTERM)-sigCh}funcintToIP(ipuint32)string{ipBytes:make([]byte,4)binary.LittleEndian.PutUint32(ipBytes,ip)returnnet.IP(ipBytes).String()}第四部分生产级“避坑”终极手册这部分是文章真正的“干货”所在。以下是我们上线过程中踩过的 5 个大坑及其解决方案。4.1 坑位一BPF_MAP_TYPE_HASH的“大锁”竞争现象高并发下QPS 10万压测发现 eBPF 程序本身消耗低但bpf_map_update_elem和bpf_map_lookup_elem偶尔延迟飙升。原因标准 Hash Map 在多核写入时内部持有自旋锁。高频的锁竞争会导致内核态的软中断Softirq积压。解法引入Per-CPU 分片 Map或使用BPF_MAP_TYPE_PERCPU_HASH。Per-CPU Map 为每个 CPU 核心维护一份独立的数据彻底消除锁竞争。代码调整// 将原来的 conn_map 改为 PERCPU_HASHstruct{__uint(type,BPF_MAP_TYPE_PERCPU_HASH);__uint(max_entries,100000);__type(key,structconn_key);__type(value,structconn_info);}conn_mapSEC(.maps);// 注意此时查询返回的是一个 Per-CPU 数据指针读取时无需加锁但需要注意数据可能被当前 CPU 独占。4.2 坑位二bpf_probe_read导致的EXTABLE异常现象内核日志偶尔报BPF program caused exception: invalid memory access甚至导致宿主机短暂卡顿。原因struct sock中的指针可能为空或者指向无效内存。直接使用BPF_CORE_READ虽然安全但在某些老旧内核上对嵌套指针的读取依然有风险。解法在读取前显式检查指针是否为NULL并对sk_net这类带偏移的结构体使用安全的bpf_core_read宏。// 安全读取示例structnet*net_ptrBPF_CORE_READ(sk,sk_net.netns);if(net_ptrNULL)return0;__u32 netnsBPF_CORE_READ(sk,sk_net.netns.net);4.3 坑位三Map 内存泄漏与 OOM Killer现象服务运行三天后cat /proc/meminfo | grep Slab发现 Slab 内存持续上涨最终 OOM。原因TCP_CLOSE状态没有覆盖所有连接消亡路径。比如TCP_ABORTRST 包或者TCP_SYN_SENT由于超时而未进入 CLOSE导致 Map 条目永久残留。解法必须在用户态实现兜底清理协程根据last_update字段主动删除 10 分钟以上未更新的连接。// 在状态变更钩子中更新 last_updateinfo-last_update_nsbpf_ktime_get_ns();用户态清理逻辑funccleaner(objs*bpfObjects){ticker:time.NewTicker(2*time.Minute)forrangeticker.C{varkey ConnKeyvarnextKey ConnKey// 遍历 Mapfor{// 实际代码用迭代器// 取出 info, 如果 time.Now() - info.last_update_ns 10分钟, 则 bpf_map_delete_elem}}}4.4 坑位四Ringbuf 背压导致内核丢包现象我们观察到bpf_ringbuf_reserve频繁返回NULL导致大量事件丢失。原因用户态业务处理比如写 ES 或 Kafka太慢导致 Ringbuf 写指针追上了读指针。解法增大 Ringbuf 容量max_entries 1 26(64MB)。用户态批量处理不要在for循环中单条处理而是攒够一批如 100 条再批量写入下游。降级策略如果bpf_ringbuf_reserve失败直接跳过该事件不阻塞内核路径。4.5 坑位五容器环境下 Netns 识别偏差现象Kubernetes 中Pod IP 是 10.0.0.x但在宿主机上看连接信息里的 netns 是根命名空间导致无法关联到具体 Pod。解法在 eBPF 中读取sk-sk_net.netns时需要配合用户态/proc/[pid]/ns/net的 Inode 号进行映射。确保我们在conn_key里存的是netns的 Inode 号。第五部分数据分析与故障定界有了上述数据我们终于可以回答开篇的问题那 15% 的连接失败到底怎么回事通过我们导出的 MySQL/ClickHouse 数据我们进行了如下建模5.1 握手失败率监控 (SLI)( 总 SYN_SENT 次数 - 总 ESTABLISHED 次数 ) / 总 SYN_SENT 次数如果这个指标突增代表网络层或对端应用层存在不可达。5.2 慢连接追踪 (Latency SLO)统计established_time_ns - syn_sent_time_ns的P99分位数。我们发现那 15% 超时的连接其 P99 达到了惊人的 5 秒正常是 50ms。通过kprobe/tcp_retransmit_skb数据我们确认了是公网出口交换机偶发丢包导致 TCP 进入指数退避重传。5.3 应用层“孤儿连接”发现通过过滤state TCP_ESTABLISHED但duration 3600s且rx_bytes 0的连接我们发现了一批由于代码 bug 导致未正确调用close()的僵尸连接它们占用了大量本地端口资源。总结与展望通过这一套基于 eBPF 的 TCP 连接追踪系统我们从“被故障牵着走”转变为“提前发现隐患”。这不仅仅是一次技术实现更是可观测性理念的落地。回顾核心要点选型理性不盲目追新eBPF 在“深度”与“性能”之间找到了完美平衡。状态机是灵魂对 TCP 状态流转不熟悉写出的代码全是坑。生产级思维必须考虑 Per-CPU 并发、Ringbuf 反压、Map 容量水位和兜底回收。未来演进方向引入BPF LSM在连接建立前拦截并检查安全性。结合Grafana Pyroscope绘制分布式调用链与网络时延的火焰图。如果您正在为复杂的网络问题头疼不妨按照本文的思路试一试。在 eBPF 的世界里内核不再是黑盒而是你手中的一张活地图。全文完