上一篇我们把 bcc 环境跑通、把“hello world”级别的探针挂上去的时候,其实还处于一种“能看到内核里有东西在动”的状态。但真正要解决生产问题,比如某个突发流量到底来自哪个进程、连接建立失败发生在哪一步、内核丢弃的包流向了哪里,光靠 hello world 是不够的。这一篇我直接给出用 eBPF&bcc 提取内核网络流量信息的三个实用模块:TCP 连接建立事件抓取、按进程统计收发字节数、以及把采集结果加工成可读报表的落地处理。每一步都带完整的可运行代码,同时把我踩过的挂载点选型、内核版本差异、采样区间设计这些坑一并说明白。
这篇内容适合两类读者:一类是已经装好 bcc、想往业务方向迈一步的开发者,另一类是平时做网络故障排查、被 tcpdump 的“全量抓包”搞得痛苦不堪的运维同学。你能从这篇文章里拿走的,不是“看一眼内核在干什么”的玩具,而是一套可以接着扩展的采集框架。
1. 从“跑通环境”到“真正抓到东西”,这一步差在哪里
先说清楚一个很多人绕了很久的弯子:eBPF 程序能挂的位置很多,但“能挂”和“挂对了”是两码事。它不像 tcpdump 那样,只要绑定到网卡上、等着数据包从 netfilter 钩子过一遍就完事。eBPF 给你的是“站在内核网络栈里面看数据流”的能力,你可以拦在最前面(XDP),站在中间看协议栈处理(tc、tracepoint),也可以等数据已经进到 socket 之后再去算账(kprobe 挂在 tcp_sendmsg、tcp_recvmsg 上)。
选挂载点这件事,本质上取决于一个问题:你要的数据,它的“语义边界”在哪里。
比如你想统计“哪个进程产生了多少流量”。这个问题的答案只可能在协议栈的上层出现,因为进程 PID 不在网络包里,它是 socket 的附带属性。所以你会选择 kprobe 挂 tcp_sendmsg,或者用 tracepoint 里带 sock 指针的钩子。
而如果你想统计“这台机器每秒钟吞进来多少包、丢了多包”,那你根本不需要关心进程是谁,只要在 sk_buff 经过的关键函数上按包计数就行。这时候选 XDP 或者 tc ingress 反而更稳,因为它们能覆盖所有入站路径,包括那些还没来得及被某个 socket 接收就断掉的包。
下面这张对照表是这些年实践下来我总结的选型参考:
| 挂载点 | 可见数据深度 | 能否关联进程 | 开销 | 典型用途 |
|---|---|---|---|---|
| XDP | 网卡驱动后、协议栈处理前的原始帧 | 否 | 最低 | 高流量场景的丢包计数、DDoS 特征拦截 |
| tc ingress/egress | 协议栈入口/出口的 sk_buff | 否(较困难) | 低 | 按方向统计包量和字节量、限流 |
| tracepoint | 协议栈各环节事件,带明确语义参数 | 部分可以 | 中 | 连接状态变化、接收包事件、丢包事件 |
| kprobe/kretprobe | 内核函数入口和返回点,能拿函数参数 | 是 | 中 | 按进程追踪发送/接收字节,连接建立与关闭 |
| 用户态程序 | socket 层以下基本无感知 | 是 | 高 | 应用内埋点,不在本次讨论范围 |
第一次写 eBPF 网络探针的人,最常犯的错误就是一上来就找 XDP,觉得“离网卡越近越厉害”。实际上你如果想知道“是哪个 nginx worker 在疯狂发包”,XDP 根本回答不了你,因为它看不见 skb 关联的 sock 结构。反过来,如果你只是要做全机粒度的流量趋势,挂在 tcp_sendmsg 上也没错,但开销会比 XDP 高一截,因为每个包都要经过好几次函数调用。
所以“提取内核网络流量信息”这个目标,第一步不是写代码,而是把挂载点选对。接下来的内容我会按“连接事件”和“字节统计”两个大方向展开,它们各自需要不同的挂载点组合。
1.1 连接事件为什么值得特殊看待
连接建立这件事,在网络监控里有着特殊的地位。因为一个 TCP 连接的开始时间、源地址、目的地址、对应的进程 PID,这几个信息一旦错过就补不回来。你从 netstat 或者 ss 里看到的是连接“当下”的状态,但建立那一刻的上下文早就没了。而在 eBPF 里,你可以在tcp_v4_connect()被调用的入口处和返回处各挂一个探针,把四次握手之前的“发起意图”完整记录下来。
传统做法是抓包然后靠 SYN 包去拼连接四元组。问题是:如果 SYN 重传、内核直接拒绝分配 socket,或者程序自己悄悄 connect 失败了,你在用户态抓包看到的是残缺片段。而内核函数探针能告诉你:某个 PID 在什么时间、想连谁、最后有没有连上。这种“意图级”的数据,只有在内核函数这一层能拿到。
1.2 字节统计的难点不在加法,在采样区间
按进程统计收发字节数,听起来就是给每个 PID 建一个计数器,见一次加一次,没什么难度。但我实际用下来,真正的坑都集中在“采样区间”上:eBPF 程序内聚的计数器只会单向增长,你每隔五秒去读一次,它给你的是从探针加载以来的累计值。如果不做差值计算,第一次输出之后的所有数据都会被“总量”淹没。
解决方案有两种方向。一种是每隔固定周期读取 map,然后把当前值减去上一周期的值,再更新“上一周期值”;另一种更简洁,是在用户态读完 map 后直接clear(),让内核计数器从零重新开始。我下面给的例子用的是第二种,逻辑直白,也省得在用户态还得维护一堆快照。注意,这对频率要求很高的场景会有微小的统计窗口误差,但作为第一版完全够用。
2. 动手写探针前,先把内核网络路径和挂载点理清楚
我见过太多人照抄网上的 eBPF 例子,改个函数名就以为自己写会了,结果线上换了个内核版本,探针直接加载失败,连报错都看不懂。原因就是他不知道这段代码为什么要挂在这个函数上。所以这一节我用最短篇幅,把内核网络路径的“主干道”画出来,不涉及细节实现,只说明白每个挂载点对应的观察位置。
2.1 收包路径:从网卡到 socket 的流水线
数据从网线进来,经过网卡 DMA 到内存,然后驱动把数据包封装成sk_buff结构,交给netif_receive_skb()。这一步之后,skb 进入协议栈,ip_rcv 做 IP 层处理,再往上送到 tcp_v4_rcv 做 TCP 层处理,最终把数据放进对应 socket 的接收队列里。用户态的 read/recv 调用,本质上是把数据从内核 socket 接收队列拷贝到用户缓冲区。
也就是说,你的观察点可以在:
- XDP 钩子:位于驱动和
netif_receive_skb之间,时机最早,开销最小,但只能看到裸帧,无法知道这个包最后属于谁。 - tracepoint 的 net:netif_receive_skb:能拿到 skb 指针,但拿不到用户态进程 PID。
- kprobe 挂在
tcp_recvmsg:这是应用进程真正把数据从内核往用户态拷贝的时刻,此时能拿到 PID,但注意这只是“应用读走”的数据,不是“内核收到”的数据量。
这两者之间的差值实际上非常有价值:如果内核收包很快,而应用读取很慢,那么tcp_recvmsg统计到的字节数会明显小于网卡实际收到的字节数。前者代表应用消费速率,后者代表网络到达速率。做容量规划时,这两个指标要分开看。
2.2 发包路径:send 之后发生了什么
用户态调用send/写,最终落到内核的tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size)。这个函数拿到 size 参数的那一刻,就是我们在“发送字节数”统计里的理想采样点。往前,tcp_sendmsg会处理分段、排队,然后交给 IP 层,最终通过dev_queue_xmit进入网卡发送队列。
在发包方向,如果你关心的是“进程视角的发送量”,挂在tcp_sendmsg上是合理的;如果你关心的是“出网卡的实际字节量”,那就得挂在更底层,比如 tc egress 或 XDP 的 TX 侧。两者之间还隔着 TCP GSO/GRO、分片、TCP 头开销等,所以数值对不上是正常的,不用慌张。
挂载点选错有个很典型的症状:你统计到的数据跟网卡流量监控工具的数据对不上,就开始怀疑探针写错了。其实多半不是写错,是你在不同的观察高度看同一个数据流。可以把这几个高度记成一个口诀:进程层看 tcp_sendmsg/tcp_recvmsg,协议栈层看 tracepoint,包处理层看 tc/XDP。
3. 抓TCP连接建立事件:kprobe/kretprobe的完整实例
这一节直接上代码。我们要做的事情:当任意进程在内核里发起tcp_v4_connect()时,记录进程 PID、进程名、源 IP、源端口、目的 IP、目的端口,并在连接函数返回时判断这次 connect 是否成功。为了拿到返回时的结果,我们需要 kprobe + kretprobe 成对使用。
先解释一下这里的核心难点:目的 IP 和目的端口是在connect()函数的参数sockaddr里传进来的,但 eBPF 程序在 kprobe 入口拿到的struct pt_regs *ctx里,只有寄存器。想要直接从参数里解析sockaddr,需要额外的数据结构定义,而且在函数入口处,socket 指针sk也未必已经准备好。实践里更稳的做法是:入口处只把sk指针存到哈希表里,返回处再通过sk指针读取inet_sock结构里的地址信息。
下面是一个完整可运行的脚本,我用 bcc 的 Python 接口编写。需要提前说明:这个例子只覆盖 IPv4。IPv6 的tcp_v6_connect逻辑类似,差别在地址读取部分,我放到后面的扩展里讲。
#!/usr/bin/env python3 import socket import struct from bcc import BPF bpf_text = """ #include <uapi/linux/ptrace.h> #include <net/sock.h> #include <net/inet_sock.h> struct connect_evt { u32 pid; u32 saddr; u32 daddr; u16 sport; u16 dport; int ret; char comm[TASK_COMM_LEN]; }; BPF_HASH(currsock, u32, struct sock *); BPF_PERF_OUTPUT(connect_events); int trace_connect_entry(struct pt_regs *ctx, struct sock *sk) { u32 pid = bpf_get_current_pid_tgid() >> 32; currsock.update(&pid, &sk); return 0; } int trace_connect_return(struct pt_regs *ctx) { u32 pid = bpf_get_current_pid_tgid() >> 32; struct sock **skpp = currsock.lookup(&pid); if (skpp == 0) { return 0; } struct connect_evt evt = {}; evt.pid = pid; bpf_get_current_comm(&evt.comm, sizeof(evt.comm)); evt.ret = PT_REGS_RC(ctx); struct sock *sk = *skpp; struct inet_sock *inet = inet_sk(sk); evt.saddr = inet->inet_saddr; evt.daddr = inet->inet_daddr; evt.sport = inet->inet_sport; evt.dport = inet->inet_dport; connect_events.perf_submit(ctx, &evt, sizeof(evt)); currsock.delete(&pid); return 0; } """ bpf = BPF(text=bpf_text) bpf.attach_kprobe(event="tcp_v4_connect", fn_name="trace_connect_entry") bpf.attach_kretprobe(event="tcp_v4_connect", fn_name="trace_connect_return") def ip_str(addr): # __be32 在内存里的字节序已经是网络序,转回点分十进制即可 return socket.inet_ntoa(socket.htonl(addr).to_bytes(4, "big")) def event_handler(cpu, data, size): evt = bpf["connect_events"].event(data) print(f"{evt.comm.decode():<16} pid={evt.pid:<6} " f"{ip_str(evt.saddr)}:{socket.ntohs(evt.sport)} -> " f"{ip_str(evt.daddr)}:{socket.ntohs(evt.dport)} " f"ret={evt.ret}") bpf["connect_events"].open_perf_buffer(event_handler, page_cnt=256) while True: try: bpf.perf_buffer_poll() except KeyboardInterrupt: break这段代码里有几个值得啰嗦两句的点。
第一,PT_REGS_RC(ctx)在 kretprobe 里返回的是被探测函数的返回值。对tcp_v4_connect来说,返回 0 表示三次握手已经发起,负的错误码对应不同的失败原因。你会看到-EINVAL、-EHOSTUNREACH、-EPIPE这类值,排查的时候可以直接拿错误码去对内核错误定义表。
第二,evt.saddr = inet->inet_saddr这段代码,是在“连接发起成功”之后读取的。如果你在函数入口还没进入协议栈处理就去读,很多字段是空的。这就是为什么 entry 只存指针、return 才取字段——这一条经验适配绝大多数 kprobe + kretprobe 的网络探针组合。
第三,open_perf_buffer的page_cnt参数。默认值只有 8 页,在高并发的连接创建场景下,perf buffer 会被打满,丢事件的时候你会看到用户态静默丢失,没有任何报错。我习惯把page_cnt调到 256,再配合加载时设置BPF_PERF_OUTPUT的perf_submit限速逻辑,基本上能稳住每秒几万次连接采集。
把脚本跑起来之后,随便在另一台机器上发起一点连接,输出大概是:
curl pid=2031 10.0.0.11:52480 -> 93.184.216.34:443 ret=0注意源端口那一列,内核在 connect 时分配的临时端口肯定不是 443,如果你看到输出的源端口和目标端口一模一样,说明读inet_sport和inet_dport的时机或者字段搞混了,去检查一下结构体定义。
3.1 连接失败类事件的补充思路
上面的探针只统计了发起 connect 的进程,但被动连接(服务端 accept)和连接失败后的重传,是另一套事件。如果你想采集“谁连不进来”或者“服务端 accept 了哪些连接”,需要在inet_csk_accept()或者它的用户态入口accept4系统调用挂钩。这个方向的坑在于服务端进程接收连接时,sk的地址信息要从sk_acceptq队列里取,代码比客户端连接跟踪稍微绕一点。
我目前的建议是:第一版先做客户端连接跟踪,把四元组加 PID 的连接库先攒起来。等你在线上真的需要回答“这个连接到底建没建起来”时,再往被动方向扩展。
4. 按进程统计收发字节数:带你踩过“采样区间”这个坑
连接事件是离散的,数据量小,perf buffer 完全扛得住。“按进程统计收发字节数”则完全相反,它是高频累积型的数据,每个数据包都会触发一次计数。这一类需求千万不要一个包往 perf buffer 里送一个事件,而是应该在 BPF 的 map 里做聚合,用户态定时轮询。下面这个例子,每隔 N 秒打印一次当前周期内收发 Top 进程。
#!/usr/bin/env python3 import time import sys from bcc import BPF bpf_text = """ #include <uapi/linux/ptrace.h> #include <net/sock.h> BPF_HASH(send_bytes, u32, u64); BPF_HASH(recv_bytes, u32, u64); int trace_sendmsg(struct pt_regs *ctx, struct sock *sk, struct msghdr *msg, size_t size) { u32 pid = bpf_get_current_pid_tgid() >> 32; u64 zero = 0; u64 *val = send_bytes.lookup_or_init(&pid, &zero); *val += size; return 0; } int trace_recvmsg(struct pt_regs *ctx, struct sock *sk, struct msghdr *msg, size_t len, int flags, int *addr_len) { u32 pid = bpf_get_current_pid_tgid() >> 32; u64 zero = 0; u64 *val = recv_bytes.lookup_or_init(&pid, &zero); *val += len; return 0; } """ bpf = BPF(text=bpf_text) bpf.attach_kprobe(event="tcp_sendmsg", fn_name="trace_sendmsg") # tcp_recvmsg 在不同内核版本上签名有变化,先试标准版 try: bpf.attach_kprobe(event="tcp_recvmsg", fn_name="trace_recvmsg") except Exception: print("[warn] 未找到 tcp_recvmsg,尝试 tcp_recvmsg_locked") bpf.attach_kprobe(event="tcp_recvmsg_locked", fn_name="trace_recvmsg") interval = int(sys.argv[1]) if len(sys.argv) > 1 else 5 def format_bytes(n): if n >= 1024**3: return f"{n / 1024**3:.1f}G" if n >= 1024**2: return f"{n / 1024**2:.1f}M" if n >= 1024: return f"{n / 1024:.1f}K" return f"{n}B" while True: time.sleep(interval) combined = {} for k, v in bpf["send_bytes"].items(): pid = k.value combined.setdefault(pid, {"send": 0, "recv": 0, "comm": ""}) combined[pid]["send"] += v.value for k, v in bpf["recv_bytes"].items(): pid = k.value combined.setdefault(pid, {"send": 0, "recv": 0, "comm": ""}) combined[pid]["recv"] += v.value print(f"\n--- {time.strftime('%H:%M:%S')} 最近 {interval}s ---") for pid, data in sorted(combined.items(), key=lambda kv: kv[1]["send"] + kv[1]["recv"], reverse=True)[:10]: print(f"pid={pid:<7} 发送={format_bytes(data['send']) if data['send'] else '0':>10} " f"接收={format_bytes(data['recv']) if data['recv'] else '0':>10}") bpf["send_bytes"].clear() bpf["recv_bytes"].clear()这个脚本跟连接跟踪最大的不同,就是数据完全留在地图里,用户态只做定时读取和清空。哪怕你几小时不读,map 里的累计值也只会缓慢增长,而不会像 perf buffer 那样打满丢事件。代价是隐私的“时间分辨率”只能到你的轮询周期,这个要心里有数。
实际跑的时候注意几个现象。
一是短连接场景会漏统计。比如一个进程每次只传几十字节就断开,它的 PID 很快消失,你在下一轮读取时那个 PID 可能已经不存在了。但这不影响 Top 排名,因为大数据量的长连接 PID 会稳定存在。如果必须精确到每个短连接,建议改为连接跟踪方案,把字节计数挂在inet_csk_accept和tcp_close附近。
二是 sendfile、io_uring 之类的高效 IO 路径不一定经过tcp_sendmsg。它们在 socket 有splice或直接走页缓存优化的路径,你只挂tcp_sendmsg的话会漏记一部分。这是 ebpf 字节统计的通病,解决方案是把splice相关的tcp_splice_read、do_sendfile这些函数也一并挂上,根据返回值加到对应 PID 上。第一版可以先忽略这部分误差,但文档里必须写清楚。
三是lookup_or_init的用法。网上很多老代码还在用lookup然后判断是否为 NULL、再update,那套写法在并发更新下会出现丢计数。lookup_or_init是 bcc 帮你封装好的原子语义,能用就尽量用。
4.1 tcp_recvmsg 的签名兼容问题
我在这段脚本里故意加了个 try/except,就是因为tcp_recvmsg这个函数在不同内核版本的命名和签名不一样。早期版本它有 nonblock 参数,后来被移除;再后来一些发行版把函数改成 static,甚至直接在反汇编里被内联掉了,kprobe 根本找不到。上个月我在某台老内核机器上跑,就是靠 fallback 到tcp_recvmsg_locked才正常的。
遇到这种情况,一条非常实用的排查路径:先看你的内核里这个函数到底叫什么、是不是可探测的。执行:
cat /sys/kernel/debug/tracing/available_filter_functions | grep tcp_recvmsg grep " tcp_recvmsg" /proc/kallsymsavailable_filter_functions 里列出来的才是 ftrace/kprobe 能挂的函数。如果/proc/kallsyms里有符号但 available_filter_functions 里没有,多半是被内核的-fpatchable-function-entry或黑名单限制住了,这时候不要硬挂 kprobe,换成 tracepoint 或者回退方案更划算。
5. 数据到用户态后怎么落地成看得懂的信息
探针采集到的原始数据只有我们自己看得懂,要想成为“信息”,还得做一层加工。这一节讲的不是简单的 print,而是我在实际项目里经常用到的一套“采集-聚合-导出”套路。
5.1 时间戳对齐:内核时间不等于墙上时间
eBPF 里拿到的时间戳默认是bpf_ktime_get_ns(),它是自系统启动以来的单调时钟,不是你的挂钟时间。如果你要跟业务日志做关联分析,就必须把它转成可读时间。一个简单可靠的办法是在用户态程序启动时记录“墙上时间”和“内核单调时间”的差值,然后把内核时间戳加上这个偏移量。
import time from bcc import BPF bpf = BPF(text=bpf_text) phr = dict() # 在用户态取一次对应关系 seconds_since_boot = time.clock_gettime(time.CLOCK_BOOTTIME) wall_clock = int(time.time() * 1e9) offset_ns = wall_clock - seconds_since_boot * 1e9之后每个事件的ts_ns + offset_ns就能跟 host 本地的 wall clock 对齐。注意CLOCK_BOOTTIME和bpf_ktime_get_ns的起点不完全一致(BOOTTIME 包含 suspend 时间,而 bpf 的 ktime 一般基于 monotonic),实际偏差通常是微妙级到毫秒级,调试够用。要更精确就用CLOCK_MONOTONIC和 bcc 里的bpf_ktime_get_ns配对。
5.2 聚合报表:把 top 连接和 top 进程输出成表格
拿本章第三节的连接事件来举例,我不建议直接把每一行事件都打印出来。连接事件虽然离散,但数量也不小,人眼扫不过来。更合理的做法是在用户态维护一个“连接字典”,键是四元组加 PID,值是“次数+开始时间+结束时间”,定期输出一份汇总表。
伪代码如下:
stats = {} # (pid, sip, sport, dip, dport) -> count def event_handler(cpu, data, size): evt = bpf["connect_events"].event(data) key = (evt.pid, evt.saddr, evt.sport, evt.daddr, evt.dport) stats[key] = stats.get(key, 0) + 1每隔一分钟,把stats按 count 降序,输出前二十条。这个思路本质上就是轻量的“连接画像表”,在对流量突增做快速定位时,比对着抓包文件大海捞针高效得多。
5.3 输出格式与外部系统对接
打印到终端只适合调试。要复用这套采集能力,建议在用户态把数据转成 JSON 或者 CSV,然后做两件事:一是本地落盘,二是通过管道或者 HTTP 上报到采集服务。对接外部系统时,注意处理 map 里读出来的原始__be32地址,在 JSON 里应该是字符串格式的点分十进制,千万不要直接把整型丢出去,否则下游统计 IP 段时会很痛苦。
6. 线上踩坑实录:加载失败、丢数据、权限问题的排查参考
这部分把我这两年帮人看 eBPF 脚本时遇到的最高频问题整理成速查表,基本覆盖了“探针写对了却跑不起来”的九成情况。
| 现象 | 原因 | 排查与解决 |
|---|---|---|
加载报invalid argument | 内核版本与 bcc 版本不匹配,或 C 代码里引用了不被当前内核支持的结构字段 | 先跑bcc -h看工具自检,再用uname -a对比内核版本;注意tcp_sendmsg等函数签名随小版本变化 |
提示no such file or directory找不到函数 | kprobe 目标函数名在当前内核不可探测(黑名单、static、内联等) | 用available_filter_functions确认;换 tracepoint 或调整探测函数 |
报Permission denied | 当前用户缺少 BPF 权限,或容器未开 CAP_BPF/CAP_SYS_ADMIN | 确认以 root 运行,容器场景需在启动时打开相应 capabilities |
| perf buffer 丢事件但无报错 | page_cnt 太小或事件频率超出处理能力 | 调大open_perf_bufferpage_cnt,并输出bpf.perf_buffer_poll()的丢包计数辅助判断 |
| 统计数字明显偏小 | 采样路径不全,比如 sendfile/io_uring 绕过 tcp_sendmsg;或多核下 map 更新竞争 | 核对统计口径,补齐相关 kprobe 点;必要时用BPF_HASH的>>>原子操作而非“读-改-写” |
| map 里的值比预期大很多 | 用户态读取后未清零,或忘了做差值 | 按固定周期clear(),或自行维护上次快照 |
| 服务重启后统计全丢 | eBPF 程序随加载它的进程退出而卸载 | 用BPF_TRACEPIN或结合bpf pinned map进共享文件,让数据跨进程持久 |
| 大量小包导致 CPU 飙升 | 探针挂点太高,每个包都触发 kernel 路径开销 | 优先移到 tc/XDP 层;或采用随机采样(每个包以 1/N 概率计数) |
这里特别提一句权限问题,容器里跑 eBPF 探针,不是随便--privileged就能解决的。很多容器运行环境默认丢掉了CAP_BPF和CAP_PERFMON,即使你是 root,加载 eBPF 内核也会被 LSM 拦下来。排查这类问题,看dmesg | tail -50最直接,它会把拒绝原因打印出来,比你在用户态猜半天强得多。
我对“数据丢失”这个问题的态度是:先确认丢不丢,再決定要不要补。在 perf buffer 场景下,bcc 的open_perf_buffer默认在 Python 回调时会把丢包计数放到某个全局变量里,你可以定期读bpf["events"].lost_cnt一类字段。如果丢包率低于 0.1%,很多分析场景可以接受;一旦高于 1%,就要考虑降频或换 ringbuf(老的 perf buffer 在高并发下竞争比 ringbuf 严重)。有条件的直接上BPF_RINGBUF_OUTPUT,这是我在新版 bcc 里的首选,它把“采样丢失”这个问题从根本上减轻了一个数量级。
7. 这套方案后续还能往哪些方向扩展
写到这里,基础框架已经齐了:一个连接事件探针、一个字节统计探针、一套用户态聚合处理。把这些组合起来,其实已经能回答很多线上问题。比如某个时间点流量暴涨,你可以先用字节统计脚本锁定是哪个 PID,再用连接事件探针拿到它的全部连接目标,基本能做到五分钟内定位。
后续有三个方向我试过、也推荐你探索。第一个是结合进程执行事件,把“进程启动时间”和“连接发生时间”做关联,这样你看到的不是不可追溯的 PID,而是“某个程序的第 n 次运行”。这个需要挂sched_process_exec,然后在用户态维护进程生命周期表,复杂度上去一截,但是值得做。
第二个方向是把字节统计从 TCP 扩展到 UDP 和 ICMP。UDP 的收发函数是udp_sendmsg和udp_recvmsg,逻辑几乎一样,直接把 kprobe 点替换掉就能用。不过 UDP 没有连接概念,四元组要靠 msg 里的 sockaddr 自己解析,比 TCP 复杂一些。
第三个方向是把数据接进现有的监控体系。bcc 脚本只管“取数”,你可以替换输出端,把聚合结果写成 Prometheus 的文本暴露格式,或者直接发到 ClickHouse 之类的地方。这一步一旦打通,这套 eBPF 采集方案就从“临时调试工具”变成“长期可观测性资产”了。
最后说一点我自己的体会:eBPF 网络监控最值钱的地方,未必是你写出来的那些统计数字,而是它让你真正站在内核视角重新理解了应用流量。以前我用 tcpdump 抓包,看到的是“包”的海洋;现在用这些探针,看到的是一串串有因有果的“事件”。当你不再把网络栈当黑盒时,排查问题的心态会完全不一样。希望这篇第二篇能帮你把这条路上最泥泞的一段趟过去。