资讯动态

eBPF+bcc实战:从内核网络路径精准提取流量信息

发布时间:2026/10/15 6:14:57 来源:尧图企业网站定制
上一篇我们跑通了eBPFbcc的开发环境也写过最简单的hello world探针但那种“输出一个字符串”的demo距离实际解决问题还差十万八千里。这篇我直接进入正题用eBPF和bcc从内核网络路径上提取流量信息。我会尽可能把示例写得贴近真实场景而不是停留在语法演示上。文中的内容偏实操适合已经对eBPF有基础概念的网络工程师、SRE、中间件开发以及准备在生产环境做网络观测的同学。如果你是想排查性能问题的后端同学这篇文章也能帮你弄明白“网络数据在内核里到底经过哪些函数我该在哪个位置插桩”。整篇文章的核心就一句话流量观测不应只停留在抓包和ss输出很多关键信息比如连接建立前的SYN丢弃、重传、半开连接只有在内核路径上才能完整看到而eBPF给了我们一个低开销、可按需过滤的观测窗口。1. 先把“提取流量信息”这件事拆开1.1 你要提取的其实是三个层面的数据一说到“网络流量信息”很容易直接想成“拿包内容”。但实际做观测时我们需要区分三个层面因为它们对应的eBPF挂载点完全不同。第一层是连接级信息谁连接了谁、什么时候建立、什么时候断开、整个生命周期收发多少字节、有没有重传。这类信息适合在TCP协议栈的入口函数上挂探针例如tcp_sendmsg、tcp_recvmsg、tcp_set_state。第二层是报文级信息每个数据包的源地址、目的地址、端口、协议号、长度、TCP标志等头部字段。这类信息适合挂在内核收包/发包路径的函数上比如netif_receive_skb、ip_rcv、ip_output。第三层才是载荷级信息也就是应用层内容比如HTTP请求正文。这类信息的敏感性很高一般只在调试环境、受控场景下使用而且处理成本也最高。很多新手一上来就想写一个“万能抓包”的程序结果发现既读不到sk_buff里的数据又不知道怎么解析协议头。我的建议是先明确你到底要回答哪个问题如果要看“谁流量最大”连接级就够了如果要看“什么协议占满了网卡”报文级更合适只有当你确认某个连接内容异常时才需要动用载荷级提取。1.2 为什么用eBPF而不是tcpdump或ss先说tcpdump。它的优点很直观能看到包体但代价也很明显在高流量环境下容易丢包而且它工作在网络设备接口层级距离协议栈“决策”的上下文较远。你看不到一个连接是因为什么原因被关闭的也看不到它是否在accept之前就被内核丢弃。tcpdump更像是在关键路口架摄像机每一辆车都拍但不会告诉你这辆车为什么超速。再看ss、netstat这类命令。它们提供的是瞬时状态的聚合视图能看到当前连接数量和少量统计但拿不到“某条连接在过去一分钟里总共发送了多少字节”这种历史累计信息更看不到内核内部对某个包的处理路径。eBPF的思路完全不同。它在内核网络路径的指定函数上挂探针只对你关心的那部分事件做记录然后通过perf buffer或map把数据带到用户态。开销远小于抓包并且能直接访问内核数据结构比如struct sock、struct sk_buff。拿它来做流量信息提取相当于你在GPS系统里设置了“只追踪这类车辆”的规则省心且精确。1.3 为什么这个系列选择bcc裸写libbpf当然更有可控性但开发效率低一个BPF程序的加载、map创建、perf事件监听全都要手动代码。bcc的好处是它把BPF程序嵌入Python/C代码里你写出来的东西更像是“带内核对C扩展的脚本”启动快、改起来快很适合我们这种需要反复调观测逻辑的场景。再者bcc自带了不少网络观测工具可以先直接拿来用覆盖掉大部分日常需求。2. 先不要重复造轮子bcc自带工具直接用起来2.1 一张表看清bcc的网络观测工具箱我习惯把bcc自带网络工具分成三类连接建立/关闭、生命周期、传输质量。下面这张表基本覆盖了我日常最常用的几个工具名作用典型使用场景tcpconnect记录新建TCP连接事件含进程PID、进程名、目标端口排查谁在请求某个服务tcpaccept记录被动接受的连接服务端视角的连接量统计tcpconnlat输出TCP连接建立耗时即SYN到ACK的间隔定位跨机房、跨地域连接慢的问题tcplife记录连接生命周期内的收发字节数和时长找流量大户和慢连接tcpstates输出TCP状态变化路径排查连接状态卡住、半开连接tcpretrans记录TCP重传事件定位丢包重传导致的延迟抖动tcpdrop记录内核丢弃TCP包的事件看断连是不是发生在内核协议栈这些工具大部分在安装bcc后就有了常见的路径是/usr/share/bcc/tools/下直接用sudo执行即可。我也建议你先把bcc-tools软件包装上因为有些发行版默认不把tools目录打包进去。2.2 实战示例一用tcplife抓流量大户和慢连接假设我要找出当前机器上哪些连接在大量收发数据我一般会先跑一下sudo /usr/share/bcc/tools/tcplife -D -L 10.0.0.5 --max20-D会打印时间戳-L表示过滤本地IP--max控制输出条数。一个典型的输出长这样TIME PID COMM LADDR RADDR DPORT SENT RECV TIME_MS 12:34:56 1234 worker 10.0.0.5:56000 10.0.0.9:443 443 1.2K 98.4K 1200 12:34:57 1235 worker 10.0.0.5:56002 10.0.0.8:3306 3306 22K 220K 580每一行代表一个完整的连接生命周期SENT和RECV是这个连接关闭时统计到的累计字节数TIME_MS是连接存活时长。看到RECV很大但TIME_MS很短的连接基本就可以判断是突发传输看到TIME_MS很大但收发字节很少的连接就要怀疑是慢连接或一直挂着不用的空闲连接。这条命令给我最大的帮助是能在几秒内圈定流量异常范围再决定要不要进一步深挖。2.3 实战示例二用tcpstates看TCP状态机切换连接异常时光看收发字节是不够的还要看状态是怎么变的。比如一个连接卡在SYN_SENT说明客户端发出的SYN没有得到响应卡在FIN_WAIT_2可能是对端一直不关闭连接。bcc的tcpstates直接把这些状态变化打印出来sudo /usr/share/bcc/tools/tcpstates -T -P 80输出片段TIME SKADDR LADDR RADDR TCPSTATE 12:34:56 ffff9a0012340000 10.0.0.5:5000 10.0.0.9:80 SYN_SENT 12:34:56 ffff9a0012340000 10.0.0.5:5000 10.0.0.9:80 ESTABLISHED 12:35:10 ffff9a0012340000 10.0.0.5:5000 10.0.0.9:80 FIN_WAIT_1 12:35:10 ffff9a0012340000 10.0.0.5:5000 10.0.0.9:80 FIN_WAIT_2看同一个SKADDR的多行记录你就能还原出一条连接的状态生命线。我排查过的一个典型问题就是大量连接卡在SYN_SENT但ss命令又看不到这些半开连接因为连接尚未建立、没有完全挂进全局连接表。这时只有像tcpstates这样直接观测状态机变化的工具才救得了场。3. 自己动手统计每条连接的收发字节数3.1 为什么选择tcp_sendmsg和tcp_recvmsg预先工具虽好但业务上经常需要“只统计某个进程、某个目标端口的流量”这时候自写BPF探针更灵活。最常见的需求就是“按连接统计收发字节数”。用户态程序发送数据的路径基本都会走到tcp_sendmsg接收数据的路径则通常会走到tcp_recvmsg。这两个函数参数里直接带着sock指针和长度值挂在这两个函数上做计数逻辑最简单int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size) int tcp_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, ...)有人可能会问为什么不挂tcp_write_xmit因为tcp_write_xmit是真正把数据推出发送队列时才触发的函数一次sendmsg可能被拆成多个分段触发多次不适合做应用视角的字节统计。同理接收端如果挂在网卡收包路径上统计的是“到达的数据量”而应用还没读走语义也不一样。需要说明的是某些老内核上的sendpage路径可能绕过tcp_sendmsg但新内核已经把这个函数逐步收口了。面向当前主流版本部署选择tcp_sendmsg和tcp_recvmsg这个组合是稳妥的。3.2 定义map结构和核心BPF程序我写的统计脚本主要关心四元组源IP、目的IP、源端口、目的端口。但要注意TCP连接的客户端和服务端看到的源/目的方向是反的如果直接用四元组作为map key同一个连接会产生两个方向的记录这反而不利于统计连接总流量。我更推荐的做法是在key里不区分方向先把地址对和端口对排序再做key或者直接以socket指针为key再关联连接信息。这里为了演示可读性我先按“方向各记一笔”的方式实现输出时你会看到双向各自一行的结果。下面是核心BPF程序部分#include uapi/linux/ptrace.h #include net/sock.h #include net/inet_sock.h #include linux/if_ether.h struct flow_key { u32 saddr; u32 daddr; u16 sport; u16 dport; }; struct flow_val { u64 tx_bytes; u64 rx_bytes; u64 last_t; }; BPF_HASH(flows, struct flow_key, struct flow_val); int kprobe__tcp_sendmsg(struct pt_regs *ctx, struct sock *sk, struct msghdr *msg, size_t size) { struct flow_key k {}; struct flow_val zero {}; struct flow_val *v; if (sk-__sk_common.skc_family ! AF_INET) return 0; k.daddr sk-__sk_common.skc_daddr; k.dport sk-__sk_common.skc_dport; k.saddr sk-__sk_common.skc_rcv_saddr; k.sport inet_sk(sk)-inet_sport; v flows.lookup_or_try_init(k, zero); if (v) { v-tx_bytes size; v-last_t bpf_ktime_get_ns(); } return 0; } int kprobe__tcp_recvmsg(struct pt_regs *ctx, struct sock *sk, struct msghdr *msg, size_t len) { struct flow_key k {}; struct flow_val zero {}; struct flow_val *v; if (sk-__sk_common.skc_family ! AF_INET) return 0; k.daddr sk-__sk_common.skc_daddr; k.dport sk-__sk_common.skc_dport; k.saddr sk-__sk_common.skc_rcv_saddr; k.sport inet_sk(sk)-inet_sport; v flows.lookup_or_try_init(k, zero); if (v) { v-rx_bytes len; v-last_t bpf_ktime_get_ns(); } return 0; }这里有个细节skc_daddr保存的是对端地址skc_dport是对端端口注意都是网络字节序。sk-__sk_common.skc_rcv_saddr是本机源地址inet_sk(sk)-inet_sport是本机源端口。如果你输出时不做ntohs处理看到的端口会是反的我第一次跑脚本时就踩过这个坑。3.3 Python侧读取map并输出TOP连接BPF程序只负责在内核里更新map真正把结果展示出来的是Python侧。下面这段代码会周期性地遍历map按收/发总字节数排序后输出前20条#!/usr/bin/python3 import ctypes import socket import time from bcc import BPF bpf_text // 上面的C代码 b BPF(textbpf_text) class FlowKey(ctypes.Structure): _fields_ [(saddr, ctypes.c_uint32), (daddr, ctypes.c_uint32), (sport, ctypes.c_uint16), (dport, ctypes.c_uint16)] class FlowVal(ctypes.Structure): _fields_ [(tx_bytes, ctypes.c_uint64), (rx_bytes, ctypes.c_uint64), (last_t, ctypes.c_uint64)] flows b[flows] def ip_str(x): return socket.inet_ntoa(ctypes.c_uint32(x.value if hasattr(x, value) else x).value.to_bytes(4, big)) def print_top(): rows [] for k, v in flows.items(): rows.append((k, v)) rows.sort(keylambda kv: kv[1].tx_bytes kv[1].rx_bytes, reverseTrue) print(%-20s %-6s %-20s %-6s %12s %12s % (SADDR, SPORT, DADDR, DPORT, TX_B, RX_B)) for k, v in rows[:20]: saddr ip_str(k.saddr) daddr ip_str(k.daddr) sport socket.ntohs(k.sport) dport socket.ntohs(k.dport) print(%-20s %-6s %-20s %-6s %12d %12d % (saddr, sport, daddr, dport, v.tx_bytes, v.rx_bytes)) while True: print_top() time.sleep(5)提示bcc版本比较老的话遍历map要用iteritems()而不是items()。如果你的内核是5.x以上基本可以放心用新写法。实际跑的时候你去访问一次10.0.0.9上的HTTP服务很快就能看到类似这样的输出SADDR SPORT DADDR DPORT TX_B RX_B 10.0.0.5 51234 10.0.0.9 80 421000 125000003.4 怎么验证统计结果是对的写这种探针最大的恐惧是“数据看起来合理但实际是错的”。我常用的验证办法很简单用curl或wget主动下载一个已知大小的文件观察脚本里对应连接记录的RX_B是否等于文件大小。比如我用curl http://10.0.0.9:80/test.bin下载一个50MB的文件脚本里对应连接的RX_B应该接近50 * 1024 * 1024。误差来源主要是HTTP头部和连接本身的额外数据如果差异在几KB以内基本可以接受。如果你用ss -tni查同一个连接可以看到它显示的实时发送/接收队列再和我们的累计值对比也能看出脚本是否在正常工作。4. 进一步提取IP/TCP报文头信息4.1 不同hook点看到的网络世界不一样连接级字节统计解决的是“谁用了多少流量”但有时候你还要回答“流量里都是些什么协议、包有多大、去往哪个IP”。这时候就要在IP层或TCP入口挂探针了。我常用的hook点有这么几个Hook点方向你能看到什么适合做什么netif_receive_skb入方向最完整的以太网帧包括物理层信息分析驱动收包、转发路径ip_rcv入方向IP层IP头包括源/目的IP、协议、总长度按IP/协议统计入流量tcp_v4_rcvTCP入口TCP头包含端口和标志位按端口聚合TCP流量ip_output出方向IP层即将发出的IP包按IP/协议统计出流量tcp_sendmsg / tcp_recvmsg传输层连接级字节统计应用视角的流量大小netif_receive_skb离驱动最近但结构体字段解析稍复杂ip_rcv在入方向上更适合读IP头是流量分布统计的好位置。需要注意的是用kprobe挂这些函数时函数名随内核版本可能有变化最好在目标机器上先确认符号是否存在cat /sys/kernel/debug/tracing/available_filter_functions | grep ip_rcv4.2 在ip_rcv上提取IP头并统计协议分布下面脚本挂在ip_rcv上从sk_buff里读出IP头并按协议号累计包数。这样能快速知道网卡上的流量中TCP、UDP、ICMP各自占比多少。#include uapi/linux/ptrace.h #include net/sock.h #include net/sk_buff.h #include linux/if_ether.h #include linux/ip.h BPF_HASH(proto_stats, u32, u64); int kprobe__ip_rcv(struct pt_regs *ctx, struct sk_buff *skb) { struct iphdr iph; u32 proto; u64 *cnt; u64 zero 0; if (!skb) return 0; bpf_probe_read(iph, sizeof(iph), skb-head skb-network_header); proto iph.protocol; cnt proto_stats.lookup_or_try_init(proto, zero); if (cnt) { (*cnt); } return 0; }skb-network_header保存的是IP头从skb-head开始处的偏移所以skb-head skb-network_header指向的就是IP头这里必须用bpf_probe_read把它的内容拷贝到栈上的iph变量里不能直接解引用内核指针。Python侧只需要周期性读取proto_stats这个map把协议号映射成名称即可输出。运行一段时间后你会看到类似下面的统计TCP: 120345 UDP: 23890 ICMP: 67如果某个协议占比异常比如UDP突然暴增说明可能有广播风暴或者DNS类流量异常。4.3 为什么不能像用户态那样直接访问指针如果你刚接触BPF可能会奇怪为什么代码里到处是bpf_probe_read。因为在BPF程序运行的内核上下文里直接解引用指针是有风险的内核指针可能无效也可能因为地址未对齐导致验证器拒绝。bcc虽然能自动把一些简单的结构体访问转成安全读取但像skb-head skb-network_header这样先取偏移再做地址运算的场景最好显式用bpf_probe_read手动拷贝既清晰又稳定。这就像你在用户态写代码时可以直接obj-field读取但在BPF里面对的是“隔着窗户看一眼”的约束只能用手probe_read把感兴趣的内容取回来不能整个窗口怼过去。4.4 从IP头扩展到TCP头如果还想进一步看TCP头可以从iph里的ihl字段算出IP头长度然后在IP头之后读取TCP头struct tcphdr tcph {}; u8 iph_len iph.ihl * 4; bpf_probe_read(tcph, sizeof(tcph), skb-head skb-network_header iph_len);拿到tcph后就能读源端口、目的端口、SYN/FIN/ACK等标志位。口径再放宽点你还可以把目标端口聚合到map里做“每个端口占用多少包”的排行榜。5. 常见问题与排查清单5.1 编译报错找不到内核结构体字段典型报错类似bpf: Failed to load program: Invalid argument unknown field skc_daddr in sock大概率是内核头文件没有安装bcc没法解析struct sock的布局。先确认ls /usr/src/linux-headers-$(uname -r)如果目录不存在安装对应内核的linux-headers包。另外如果目标机器是某个定制内核版本比如云厂商改过结构的本地开发环境跑通的脚本到线上可能编译失败这时候要在目标机器上现编译或者退一步用tracepoint而不是kprobe。5.2 探针挂上了但map一直是空的先不要怀疑BPF程序写错按下面顺序排查。第一确认流量真的走了你hook的函数。比如你在ip_rcv上挂探针但网卡驱动走了XDP或者某些加速路径可能根本不经过这个函数。可以用tcpdump先确认流量确实存在。第二确认过滤条件没错。比如你只统计AF_INET但测试流量走的是IPv6。第三确认权限够。部分内核参数比如kernel.perf_event_paranoid过高会导致探针加载失败或事件采集不到。第四确认没有同时跑多个监控脚本导致map被反复注册或者脚本退出后旧探针没有卸载干净。5.3 计数结果翻倍我在实际调试中遇到过输出数字变成预期两倍的情况最后排查发现是同时开着两个监控脚本同一个事件被两个探针各记录了一次。bcc程序退出时会自动卸载探针但如果你在终端后台挂了一个没退干净的进程就会重复叠加。建议调试时先ps aux | grep bcc检查有没有多余的进程。5.4 性能开销怎么控制挂tcp_sendmsg和tcp_recvmsg这种高频函数在流量很大的生产环境每秒触发次数非常高如果map查询和更新太重会拖慢网络路径。我的经验是尽量在探针最前面加过滤if (sk-__sk_common.skc_dport ! bpf_htons(443)) return 0;甚至可以在进入函数前就判断目标端口只统计你关心的流量而不是全量记录。其次map的并发更新可以用BPF_PERCPU_HASH这种percpu版本减少锁竞争。最后输出展示不要用bpf_trace_printk正确做法是存入map由用户态周期性读取避免在内核里做字符串格式化。5.5 容器和云环境里的注意点在容器环境里使用eBPF观测网络需要额外小心。你的探针看到的是整个宿主机的网络命名空间而不是容器内部的连接视图。如果你只关心某个容器的流量要么通过cgroup过滤要么在用户态按PID过滤。另外某些容器运行时默认不允许挂载BPF探针需要以特权容器方式运行或者为进程单独赋予CAP_BPF、CAP_SYS_ADMIN能力。生产环境的安全策略如果在收紧还要提前评估观测对网络路径带来的轻微开销。下面是一个速查表方便你遇到问题时快速对照现象可能原因处理方式探针加载失败invalid argument函数符号不存在或受内核锁定限制查available_filter_functions改用tracepoint输出一直是空流量没经过hook点或不满足过滤条件用tcpdump确认流量简化过滤条件计数翻倍多个监控进程同时挂同一探针清理其他bcc进程map条目越来越多短连接太多找不到对应连接定期删除过期条目或用超时机制在容器里看不到流量命名空间隔离导致视图不匹配确认宿主机视角按cgroup/PID过滤我自己在实际操作中的体会是速度最快的排查路径永远是用bcc自带工具先做一轮粗筛再用自定义探针细看。很多网络异常通过tcpretrans、tcpstates就能定位到八九不离十很少需要一上来就写全自定义BPF。如果你准备把我们这篇的脚本用在自己的环境建议把map统计周期调成30秒以上再上生产因为高频打印本身就可能成为新的噪音源。后面如果继续写第三篇我打算把tracepoint、perf buffer和数据落库的细节补上届时现在这两个脚本可以直接改造成更稳定的生产版本。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑