☰
eBPF+BCC内核级网络流量提取实战:高性能抓包与精准过滤
2026/10/15 1:24:57 网站建设 项目流程

1. 为什么要在内核里抓网络流量

做后端运维和性能分析的朋友,大概率都经历过这样的场景:线上某台机器网络抖动,丢包率飙升,但你登上服务器用tcpdump一抓,发现流量大得吓人,抓下来的 pcap 文件几个 G,用 Wireshark 打开直接卡死。更麻烦的是,tcpdump这种基于 libpcap 的工具在高流量场景下本身就会成为瓶颈,抓包这个动作反而加剧了系统负载。

我最早接触 eBPF 就是因为这个痛点。当时有个服务在高峰期偶发连接超时,但日志里什么都看不出来,netstat的统计又太粗,根本定位不到是哪个连接、哪个端口出了问题。后来用 eBPF 写了个小工具,直接在内核里按条件过滤,只把关心的那部分连接信息捞出来,问题很快就定位了。

这篇文章要聊的,就是怎么用 eBPF 配合 BCC(BPF Compiler Collection)这套工具链,从内核层面提取网络流量信息。核心解决的问题有三个:第一,高性能,eBPF 程序跑在内核态,不需要把每个包都复制到用户态,开销极小;第二,精准过滤,可以在内核里就按 PID、端口、协议等条件筛选,只拿你真正需要的数据;第三,低侵入,不需要改内核源码、不需要加载内核模块,编译出来的字节码经过验证器校验后挂载到内核钩子上就行。

适合谁来读?如果你是有一定 Linux 基础的后端开发、SRE、运维工程师,或者对系统可观测性感兴趣的安全研究人员,这篇文章应该能给你一套可以直接抄作业的方案。我会从原理讲到实操,把踩过的坑和调优经验都摊开来说。即使你之前没写过 eBPF 程序,跟着走一遍也能跑起来。

2. eBPF 与 BCC 的核心机制拆解

2.1 eBPF 到底在内核里做了什么

要理解 eBPF 抓网络流量的原理,得先搞清楚它的执行模型。传统的抓包方式,比如 libpcap,是在内核里注册一个钩子,每来一个包就复制一份到用户态,用户态程序再决定要不要、怎么处理。这个"复制到用户态"的动作在高流量下就是灾难,内存拷贝和上下文切换的开销会指数级上升。

eBPF 的思路完全不同。你写的 C 代码会被编译成 eBPF 字节码,加载到内核后,由内核里的 JIT 编译器翻译成机器码直接执行。这个程序挂载在网络栈的特定钩子点上,比如kprobe(内核函数入口)、tracepoint(内核静态追踪点)、XDP(eXpress Data Path,网卡驱动层)等。当数据包经过这些钩子时,eBPF 程序在内核态就地执行过滤和统计逻辑,只有符合条件的结果才通过 BPF Map 或 perf buffer 传回用户态。

这个模型的关键优势在于:过滤逻辑前置到了内核态。假设你只关心 80 端口的 TCP 包,那 eBPF 程序可以在内核里直接判断,不是 80 端口的包连碰都不碰,用户态完全无感知。这跟tcpdump先全量抓再过滤有本质区别。

另一个重要概念是BPF Map。这是内核态和用户态之间的共享数据结构,支持哈希表、数组、环形缓冲区等多种类型。eBPF 程序在内核里把统计结果写进 Map,用户态程序通过文件描述符读取。这种设计避免了频繁的系统调用,数据交换效率很高。

2.2 BCC 工具链解决了什么工程问题

直接写 eBPF 程序其实挺痛苦的。你得手写字节码或者用 clang 编译,然后处理加载、验证、Map 创建、perf buffer 读取这一整套流程,代码量大且容易出错。BCC 就是来填这个坑的。

BCC 提供了一套 Python 前端(也支持 C++ 和 Lua),让你可以用 Python 写用户态逻辑,用内嵌的 C 代码写内核态 eBPF 程序。BCC 在运行时负责把 C 代码编译成字节码、加载到内核、创建 Map、处理 perf 事件回调。你只需要关注业务逻辑本身。

举个例子,用 BCC 写一个追踪 TCP 连接建立的工具,核心代码可能就几十行。BCC 会自动处理kprobe的挂载、tcp_connect函数的参数解析、事件回传等细节。这对快速原型开发和日常运维来说,效率提升非常明显。

不过 BCC 也有它的代价。运行时编译需要 clang 和内核头文件,首次加载有几百毫秒的延迟;Python 前端的性能不如纯 C 的 libbpf;部署时依赖比较多。所以 BCC 更适合开发调试和内部工具,如果要嵌入到生产系统的高频路径里,可能得考虑用 libbpf + CO-RE 的方案。但对于我们这篇文章要聊的"提取网络流量信息"这个场景,BCC 的灵活性和开发效率是更优选择。

2.3 网络流量提取的钩子点选择

在内核网络栈里,可以挂 eBPF 程序的钩子点很多,选哪个直接决定了你能拿到什么信息、性能如何。我整理了一个对比表,方便你根据场景选择:

钩子点类型典型位置能拿到的信息性能开销适用场景
kprobe内核函数入口,如tcp_connect函数参数、寄存器值中等追踪特定内核函数调用
kretprobe内核函数返回返回值、出参中等追踪函数执行结果
tracepoint内核静态追踪点,如sock:inet_sock_set_state预定义的结构化数据低稳定的状态变更追踪
XDP网卡驱动层原始数据包极低高性能包处理、DDoS 防护
tc (traffic control)网络协议栈入口/出口数据包、元数据低流量分类、QoS

对于"提取网络流量信息"这个目标,最常用的组合是kprobe + tracepoint。kprobe 挂在tcp_connect、tcp_sendmsg、tcp_recvmsg这些函数上,可以拿到连接的五元组、进程 PID、发送/接收字节数;tracepoint 用sock:inet_sock_set_state追踪 TCP 状态机变化,能精确知道连接何时建立、何时关闭。

如果你要抓的是原始数据包内容,那 XDP 或 tc 更合适,但这两个钩子点写起来复杂得多,而且 XDP 需要网卡驱动支持。对于大多数"我想知道谁在跟谁通信、传了多少数据"的需求,kprobe + tracepoint 的组合已经足够了。

注意:kprobe 挂载的内核函数名在不同内核版本间可能变化,比如tcp_connect在较新内核里参数结构有调整。生产环境部署前一定要在目标内核版本上验证。

3. 从零搭建一个流量提取工具

3.1 环境准备与依赖安装

先说一下我的测试环境:Ubuntu 20.04,内核 5.4,这是比较主流且稳定的组合。eBPF 对内核版本有要求,建议至少 4.9 以上,要完整体验 BCC 的功能最好用 5.x 内核。

安装 BCC 工具链,不同发行版命令不一样:

# Ubuntu/Debian sudo apt-get install bpfcc-tools linux-headers-$(uname -r) python3-bpfcc # CentOS/RHEL sudo yum install bcc-tools kernel-devel-$(uname -r) # 从源码编译(推荐,版本更新) git clone https://github.com/iovisor/bcc.git mkdir bcc/build && cd bcc/build cmake .. && make -j$(nproc) && sudo make install

装完之后验证一下:

sudo python3 -c "from bcc import BPF; print('BCC ready')"

如果报错找不到bpf模块,大概率是 Python 路径问题,检查python3-bpfcc是否装到了当前 Python 环境。我踩过一次坑,系统里有多个 Python 版本,pip 装的和 apt 装的混在一起,折腾了半天才发现是路径不对。

还需要确认内核配置里 eBPF 相关选项是开的:

grep -E "CONFIG_BPF|CONFIG_BPF_SYSCALL|CONFIG_BPF_JIT" /boot/config-$(uname -r)

这几个都应该是y或m。如果CONFIG_BPF_JIT没开,eBPF 程序会以解释模式运行,性能差很多。

3.2 第一个 eBPF 程序:追踪 TCP 连接建立

咱们从最简单的开始:追踪所有 TCP 连接的建立事件,输出源 IP、目标 IP、目标端口和发起进程的 PID。

#!/usr/bin/env python3 from bcc import BPF from socket import inet_ntop, AF_INET from struct import pack # 内核态 eBPF 程序 bpf_text = """ #include <uapi/linux/ptrace.h> #include <net/sock.h> #include <bcc/proto.h> struct connect_event_t { u32 pid; u32 saddr; u32 daddr; u16 dport; char comm[16]; }; BPF_PERF_OUTPUT(connect_events); int kprobe__tcp_connect(struct pt_regs *ctx, struct sock *sk) { struct connect_event_t event = {}; event.pid = bpf_get_current_pid_tgid() >> 32; bpf_get_current_comm(&event.comm, sizeof(event.comm)); // 从 sock 结构体提取地址信息 event.saddr = sk->__sk_common.skc_rcv_saddr; event.daddr = sk->__sk_common.skc_daddr; event.dport = sk->__sk_common.skc_dport; connect_events.perf_submit(ctx, &event, sizeof(event)); return 0; } """ b = BPF(text=bpf_text) def print_event(cpu, data, size): event = b["connect_events"].event(data) saddr = inet_ntop(AF_INET, pack("I", event.saddr)) daddr = inet_ntop(AF_INET, pack("I", event.daddr)) dport = event.dport >> 8 | (event.dport & 0xff) << 8 # 网络字节序转主机序 print(f"PID {event.pid} ({event.comm.decode()}): {saddr} -> {daddr}:{dport}") b["connect_events"].open_perf_buffer(print_event) print("追踪 TCP 连接建立,Ctrl+C 退出...") while True: try: b.perf_buffer_poll() except KeyboardInterrupt: exit()

这段代码的核心逻辑:kprobe__tcp_connect这个函数名是 BCC 的约定,前缀kprobe__表示把它挂到tcp_connect内核函数的入口。函数第二个参数struct sock *sk就是内核传给tcp_connect的 socket 结构体指针,我们从里面读地址和端口。

BPF_PERF_OUTPUT定义了一个 perf 事件通道,内核态通过perf_submit把数据推给用户态。用户态用open_perf_buffer注册回调,perf_buffer_poll循环等待事件。

跑起来之后,你在另一个终端curl一个网址,就能看到对应的连接事件。这个工具虽然简单,但已经能解决"谁在连谁"的问题了。

3.3 进阶:统计每个进程的网络吞吐

光知道连接建立还不够,实际排查问题时更关心"哪个进程在疯狂发数据"。下面这个工具统计每个进程的 TCP 发送和接收字节数。

#!/usr/bin/env python3 from bcc import BPF import time bpf_text = """ #include <uapi/linux/ptrace.h> #include <net/sock.h> #include <linux/sched.h> struct key_t { u32 pid; char comm[16]; }; BPF_HASH(send_bytes, struct key_t); BPF_HASH(recv_bytes, struct key_t); int kprobe__tcp_sendmsg(struct pt_regs *ctx, struct sock *sk, struct msghdr *msg, size_t size) { struct key_t key = {}; key.pid = bpf_get_current_pid_tgid() >> 32; bpf_get_current_comm(&key.comm, sizeof(key.comm)); u64 *val = send_bytes.lookup(&key); if (val) { *val += size; } else { send_bytes.update(&key, &size); } return 0; } int kprobe__tcp_recvmsg(struct pt_regs *ctx, struct sock *sk, struct msghdr *msg, size_t len) { struct key_t key = {}; key.pid = bpf_get_current_pid_tgid() >> 32; bpf_get_current_comm(&key.comm, sizeof(key.comm)); u64 *val = recv_bytes.lookup(&key); if (val) { *val += len; } else { recv_bytes.update(&key, &len); } return 0; } """ b = BPF(text=bpf_text) print("统计进程网络吞吐,每 3 秒刷新,Ctrl+C 退出...") try: while True: time.sleep(3) print("\n%-8s %-16s %12s %12s" % ("PID", "COMM", "SEND(KB)", "RECV(KB)")) send_map = b["send_bytes"] recv_map = b["recv_bytes"] for k, v in send_map.items(): recv_val = recv_map.get(k, 0) print("%-8d %-16s %12.2f %12.2f" % ( k.pid, k.comm.decode(), v.value / 1024, recv_val / 1024 )) send_map.clear() recv_map.clear() except KeyboardInterrupt: exit()

这里用了BPF_HASH定义哈希表,key 是 PID + 进程名,value 是累计字节数。tcp_sendmsg和tcp_recvmsg是内核里 TCP 发送和接收数据的主函数,挂在这两个函数上能拿到每次调用的数据长度。

有个细节要注意:tcp_recvmsg的len参数是请求接收的长度,实际接收到的可能小于这个值。更精确的做法是挂kretprobe拿返回值,但那样代码会复杂一些。对于粗略的吞吐统计,用len已经够用了。

实操心得:BPF_HASH的 key 结构体里如果有 char 数组,BCC 会自动处理对齐和填充。但如果你自己用 libbpf 写,得手动保证结构体对齐,否则 lookup 会失败。这个坑我踩过,排查了很久才发现是 padding 问题。

4. 常见问题与排查技巧实录

4.1 程序加载失败怎么办

eBPF 程序加载失败是最常见的问题,报错信息往往很模糊。我整理了一个排查清单:

报错信息可能原因解决方法
Operation not permitted权限不足或内核锁定用 root 运行,检查/proc/sys/kernel/unprivileged_bpf_disabled
Invalid argument内核函数名不存在或参数不匹配用bpftrace -l 'kprobe:tcp_*'确认函数名
Cannot allocate memoryBPF Map 太大或内存不足减小 Map 大小,检查ulimit -l
No such file or directory内核头文件缺失安装linux-headers-$(uname -r)
Permission denied被 SELinux/AppArmor 拦截临时设为 permissive 模式测试

最常见的是内核函数名对不上。不同内核版本里,同一个功能的函数名可能变了,或者参数结构体有调整。比如tcp_connect在 5.6 内核里参数从struct sock *sk变成了struct sock *sk, struct sk_buff *skb。解决办法是去/proc/kallsyms里搜函数名,或者用bpftrace的-l参数列出所有可用的 kprobe 点。

4.2 数据丢失与性能调优

用 perf buffer 传数据时,如果事件产生速度超过用户态消费速度,就会丢事件。BCC 会在退出时打印丢失计数,比如Lost 1234 events。这个问题在高流量场景下很常见。

解决办法有几个。第一,增大 perf buffer 的页数:

b["connect_events"].open_perf_buffer(print_event, page_cnt=64)

默认是 8 页,每页 4KB,64 页就是 256KB 的缓冲区。但也不能无限增大,会占用过多内存。

第二,在内核态做更严格的过滤,减少事件数量。比如只追踪特定端口的连接:

if (sk->__sk_common.skc_dport != htons(80)) { return 0; }

第三,如果事件量实在太大,考虑用BPF_RINGBUF替代 perf buffer。ring buffer 是内核 5.8 引入的新机制,支持多消费者、内存效率更高,BCC 较新版本已经支持。

还有一个容易被忽略的点:eBPF 程序本身的执行时间。如果程序里做了复杂计算或者大量 Map 操作,会拖慢内核网络栈。验证器对指令数有限制(早期是 4096 条,现在放宽到 100 万条),但即使没超限,过长的程序也会影响性能。保持 eBPF 程序精简,复杂逻辑放到用户态处理。

4.3 容器环境下的特殊处理

现在大部分服务都跑在容器里,eBPF 在容器环境下有几个坑。

首先是 PID 命名空间。bpf_get_current_pid_tgid()返回的是宿主机视角的 PID,不是容器内的 PID。如果你在容器里跑这个工具,看到的 PID 跟容器内ps显示的对不上。解决办法是读取/proc/[pid]/status里的NSpid字段做转换,或者用bpf_get_ns_current_pid_tgid()这个 helper(需要内核 4.18+)。

其次是权限。容器默认没有CAP_BPF和CAP_SYS_ADMIN,eBPF 程序加载会失败。要么给容器加--privileged,要么精细地加--cap-add=BPF --cap-add=SYS_ADMIN。生产环境建议用后者,最小权限原则。

最后是内核头文件。容器镜像里通常没有内核头文件,BCC 运行时编译会失败。解决办法是在宿主机上编译好字节码,或者用 CO-RE(Compile Once, Run Everywhere)方案,把 BTF 信息打包进去。BCC 较新版本支持BPF(text=..., cflags=["-target", "bpf"])配合预编译,但配置起来比较麻烦。

注意:在 Kubernetes 环境里部署 eBPF 工具,建议用 DaemonSet 在每个节点跑一份,而不是在业务 Pod 里跑。这样权限和内核头文件的问题都好解决,而且能拿到节点级别的全局视图。

5. 几个可以直接用的实战场景

5.1 定位异常外联连接

安全排查时经常需要知道"这台机器在跟哪些外部 IP 通信"。用 eBPF 可以实时监控所有新建连接,过滤掉内网地址,只显示外网连接:

# 在 print_event 回调里加过滤 def print_event(cpu, data, size): event = b["connect_events"].event(data) daddr = inet_ntop(AF_INET, pack("I", event.daddr)) # 过滤内网地址 if daddr.startswith("10.") or daddr.startswith("192.168.") or daddr.startswith("172."): return print(f"外联: PID {event.pid} ({event.comm.decode()}) -> {daddr}:{dport}")

这个工具跑上一天,基本能把机器的外联行为摸清楚。如果发现某个不认识的进程在连奇怪的 IP,那就值得深入查一下了。

5.2 排查连接超时问题

连接超时是后端最常见的故障之一。用 eBPF 追踪 TCP 状态机变化,能精确知道连接卡在哪个状态:

// 挂到 sock:inet_sock_set_state tracepoint TRACEPOINT_PROBE(sock, inet_sock_set_state) { if (args->protocol != IPPROTO_TCP) return 0; struct event_t event = {}; event.oldstate = args->oldstate; event.newstate = args->newstate; event.saddr = args->saddr; event.daddr = args->daddr; event.dport = args->dport; events.perf_submit(args, &event, sizeof(event)); return 0; }

TCP 状态码里,SYN_SENT表示已发 SYN 等回复,ESTABLISHED表示连接建立,TIME_WAIT表示主动关闭后的等待。如果大量连接卡在SYN_SENT,说明对端没响应或者网络不通;如果频繁出现TIME_WAIT,可能是短连接太多,需要考虑连接池。

5.3 统计各服务的网络延迟

结合 kprobe 和 kretprobe,可以测量 TCP 连接建立的耗时:

BPF_HASH(start_time, u32, u64); int kprobe__tcp_connect(struct pt_regs *ctx, struct sock *sk) { u32 pid = bpf_get_current_pid_tgid() >> 32; u64 ts = bpf_ktime_get_ns(); start_time.update(&pid, &ts); return 0; } int kretprobe__tcp_connect(struct pt_regs *ctx) { u32 pid = bpf_get_current_pid_tgid() >> 32; u64 *tsp = start_time.lookup(&pid); if (tsp) { u64 delta = bpf_ktime_get_ns() - *tsp; // 把 delta 写入直方图或输出 start_time.delete(&pid); } return 0; }

这个模式叫"入口记录时间戳,出口算差值",是 eBPF 做延迟测量的标准套路。bpf_ktime_get_ns()返回的是纳秒级单调时钟,精度足够。注意要在出口把 Map 里的记录删掉,否则会内存泄漏。

6. 写在最后的一些经验

eBPF 这套东西刚上手时确实有点门槛,内核态和用户态的边界、各种 helper 函数的用法、不同内核版本的兼容性,都是需要花时间熟悉的。但一旦跑通第一个程序,后面的路就顺了。

我个人的建议是,先从 BCC 自带的工具集开始用起。execsnoop、opensnoop、tcpconnect、tcplife这些现成的工具已经覆盖了大部分日常排查场景,用熟了再去看它们的源码,理解 eBPF 程序是怎么写的。BCC 的tools/目录下有几十个示例,每个都是很好的学习材料。

另外,调试 eBPF 程序时,bpf_trace_printk()是最简单的输出方式,但它有性能问题且输出格式受限,只适合开发阶段临时用。正式工具应该用 perf buffer 或 ring buffer。还有,写完 eBPF 程序后一定要在目标内核版本上测试,我在 5.4 上跑得好好的程序,换到 4.19 上就因为函数签名不同直接加载失败。

最后分享一个我常用的技巧:用bpftrace做快速验证。它是 eBPF 的高级封装,一行命令就能完成很多事,比如bpftrace -e 'kprobe:tcp_connect { printf("%s -> %s\n", comm, ntop(args->sk->__sk_common.skc_daddr)); }'。在写正式的 BCC 程序之前,先用 bpftrace 验证钩子点和数据结构是否正确,能省很多时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询