首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >使用 eBPF追踪 TCP连接

使用 eBPF追踪 TCP连接

作者头像
不吃草的牛德
发布2026-07-24 12:31:28
发布2026-07-24 12:31:28
1220
举报
文章被收录于专栏:RustRust

上周排查一个奇怪的问题:客户报凌晨经常出现服务连接不上,之前一直正常的。前几天刚开始偶尔连不上,但没有影响生产线就没在意,这两天影响生产线了。经过排查这一台内网机器,半夜偶发对外发包,防火墙规则明明只允许它访问两个固定地址。可到了凌晨,监控图上就是有一道连向某个陌生公网 IP 的流量。

我第一反应是 netstat -antp。空手而归——那连接太短了,netstat 是快照,它只拍你敲下回车的那一瞬间,连早就断了。

sslsof 一样。iptables 加日志?那点突发流量被埋在正常请求里,日志淹没不说,开启 conntrack 日志对性能的影响也不小。至于上服务网格做 sidecar 抓流量,那是给自己的服务用的,系统进程、agent、临时脚本它一个都管不到。

后来我用 eBPF 把这台机器所有的 TCP 连接状态变化全量记录了下来。第二天一看,真相一目了然:是某个定时任务拉起的临时脚本,访问了一个早已废弃的回调地址。从生到死,二十几秒,传统工具全扑了个空。

这篇就讲讲怎么用 eBPF 干这件事,以及它能给你带来什么。

传统方案为什么看不见

先说清楚"看不见"的根因,才能理解 eBPF 为什么行。

netstat/ss 这类工具读的是内核里的 socket 哈希表当前状态。它们是快照,不是事件流。一个只存在几秒的连接,你没在它活着的时候去查,它就不存在过。而生产环境的连接动辄成千上万,你想靠"高频轮询"抓短连接?CPU 先扛不住。

有人会说:那用 conntrack 啊,它不是专门记连接的吗?恰恰相反,conntrack 的盲区更隐蔽。它依赖 Netfilter 钩子,如果数据包被 iptables 直接 DROP,或者走了 ip_forward 而没开 conntrack,它根本看不见这个连接。更关键的是,它只是一个"状态表",不是"分析仪"——它不关心 TCP 重传、不关心 RTT,你问它"刚才那 15% 的建连为什么失败",它答不上来。

tcpdump 呢?抓包要把数据包从内核拷贝到用户态(PF_PACKET 协议族),在高带宽(比如 10Gbps)下,这种内存拷贝加上下文切换能瞬间打爆 CPU。为了看连接,把机器看垮了,不值当。

eBPF 带给我们的,其实是一种上帝视角

  • 沙箱式安全执行。eBPF 程序跑在内核态,但加载前会被 Verifier 严格校验——它证明你的程序不会死循环、不会越界访问,所以内核才敢直接跑,不用改内核源码、不用加载内核模块(LKM 那种动不动就 panic 的东西),挂到 kprobe 或 tracepoint 上就能跑。
  • 零拷贝数据交换。eBPF 用 Ring BufferPer-CPU Array 跟用户态通信,数据留在内核态的环形区,用户态来取,没有 tcpdump 那种沉重的内存拷贝。
  • 直接读内核结构体。通过 bpf_probe_read_kernel,我们能直接抠出 struct sock 里的"私房字段"——比如 RTO(重传超时)RTT(往返时间SACK 信息。换句话说,不用抓包,就能知道这条连接的网络质量。

eBPF 它不轮询、不采样、不处理每一个包,它挂在内核 TCP 状态机的事件上——每次一个 socket 从 SYN_SENT 变成 ESTABLISHED、从 ESTABLISHED 变成 CLOSE,内核就触发一次回调,我们的程序进去读一下就走。连接再短,只要它真正发生过状态跳转,就逃不掉。

核心钩子:sock:inet_sock_set_state

Linux 内核有个现成的 tracepoint:sock:inet_sock_set_state。顾名思义,它在一个 socket 的 TCP 状态发生变迁时触发。这恰好就是我们需要的"连接生命周期事件流"。

TCP 的状态就那十几个,记下来就是一张完整的连接生卒表:

代码语言:javascript
复制
TCP_ESTABLISHED = 1   已建立
TCP_SYN_SENT    = 2   主动发起
TCP_SYN_RECV    = 3   收到 SYN
TCP_FIN_WAIT1   = 4
TCP_FIN_WAIT2   = 5
TCP_TIME_WAIT   = 6
TCP_CLOSE       = 7   已关闭
TCP_CLOSE_WAIT  = 8
TCP_LAST_ACK    = 9
TCP_LISTEN      = 10  监听中
TCP_CLOSING     = 11

只要捕获 oldstate → newstate 的转变,再配上四元组和时间戳,一个连接的"出生证明"就有了。

两个钩子要配合着用:

  • tracepoint/sock/inet_sock_set_state:TCP 状态机变更的"总开关",抓住连接的生与死。
  • kprobe/tcp_retransmit_skb:重传监听器。重传意味着网络在抖,或者有人在暴力扫端口。

另外提一句容器场景:光有四元组不够,NAT 环境下五元组容易冲突,而且你没法区分"这是哪个 Pod 连的"。所以连接 Key 里要带上 netns(网络命名空间) 的 inode 号——配合用户态 /proc/[pid]/ns/net 的映射,就能把一个连接精确归到某个容器。

用 Rust + Aya 把它挂上去

框架还是老朋友 Aya——纯 Rust 的 eBPF 工具链,不用写一行 C、不用 clang 那套复杂的构建链。

先想清楚我们要往 Map 里存什么。一个连接不是一行状态就完事,它是带时间轴和流量账本的:

代码语言:javascript
复制
// 连接 Map 的 Value:状态机 + 时间轴 + 流量统计 + 溯源
struct ConnInfo {
    state: u8,            // 当前 TCP 状态
    old_state: u8,        // 上一次状态(用于状态跳转分析)
    birth_ns: u64,        // 连接创建时间
    syn_sent_ns: u64,     // 发出 SYN 的时间
    established_ns: u64,  // 变为 ESTABLISHED 的时间
    rx_bytes: u64,        // 接收字节数
    tx_bytes: u64,        // 发送字节数
    retransmits: u32,     // 重传次数(直接读内核)
    pid: u32,             // 发起连接的进程
    comm: [u8; 16],       // 进程名,如 nginx
}

注意这里连接表我用的是 PerCpuHashMap,不是普通 HashMap——高并发下普通 Hash Map 内部有自旋锁,多核写入会抢锁、软中断积压。Per-CPU 给每个核一份独立数据,彻底消锁。这是后面生产避坑要展开的第一条。

核心 tracepoint 程序:

代码语言:javascript
复制
use aya::programs::raw_tracepoint::RawTracePointContext;

// 从 raw tracepoint 上下文取出第一个参数:struct sock *
#[inline]
unsafe fn get_sk(ctx: RawTracePointContext) -> *const sock {
    let args = ctx.as_ptr() as *const *const core::ffi::c_void;
    *args as *const sock
}

#[tracepoint(name = "sock:inet_sock_set_state")]
fn track_conn(ctx: RawTracePointContext) -> u32 {
    let sk = unsafe { get_sk(ctx) };
    if sk.is_null() { return 0; }

    let common = unsafe { &(*sk).__sk_common };
    let family = common.skc_family;
    if family != AF_INET as u16 { return 0; }   // 先管 IPv4

    let daddr  = common.skc_daddr;          // 目标 IP(网络字节序)
    let saddr  = common.skc_rcv_saddr;      // 本机 IP
    let dport  = u16::from_be(common.skc_dport);
    let sport  = common.skc_num;            // 本机端口(主机序)
    let net    = unsafe { (*sk).sk_net.net };          // possible_net_t.net,RCU 指针
    let netns  = if net.is_null() { 0 } else { unsafe { (*net).ns.inum } }; // 容器归属
    let newstate = unsafe { *(ctx.as_ptr().add(5)) as u8 };
    let oldstate = unsafe { *(ctx.as_ptr().add(4)) as u8 };
    let pid = bpf_get_current_pid_tgid() >> 32;   // 哪个进程干的
    let now = bpf_ktime_get_ns();

    // SYN_RECV -> ESTABLISHED:算出握手耗时,推一条"建连"事件
    // ESTABLISHED/CLOSE_WAIT -> CLOSE:算出存活时长,带走最终流量统计
    // 其余状态只更新 Map 里的 state / old_state

    let evt = ConnEvent {
        ts: now, pid, netns,
        saddr, daddr, sport, dport,
        oldstate, newstate,
    };
    // 丢给用户态的 Rust 程序去落盘/分析
    unsafe { RB.output(&evt, 0); }
    1
}

几个关键点说一下,免得你照抄踩坑:

  • skc_daddr / skc_rcv_saddr__be32,网络字节序,用户态输出时要 ntohl 转成点分十进制;
  • skc_dport 是网络字节序的 __be16,用 u16::from_be 转;而 skc_num 是主机序的本地端口,直接拿来用;
  • • 读内核结构体里的指针(比如 sk_net.net)前,先判空。老旧内核上嵌套指针可能无效,裸解引用会抛异常、严重时拖慢宿主机。Aya 的 Ptr 包装加显式 null check 能挡掉大部分;
  • bpf_get_current_pid_tgid() >> 32 取的是 PID(高 32 位是 pid,低 32 位是 tgid),拿到 PID 才能把"连接"和"进程"对上号——这一步是后面所有异常检测的地基;
  • • 代码是精简示意,生产里要处理 IPv6(skc_v6_daddr)和把 comm(进程名)一起带出来。

光有状态变迁,还差一件让数据变"厚"的事:重传。挂个 kprobe 就行:

代码语言:javascript
复制
#[kprobe(name = "tcp_retransmit_skb")]
fn track_retransmit(ctx: KProbeContext) -> u32 {
    let sk = unsafe { get_sk_from_kprobe(ctx) };  // 第一个参数即 struct sock *
    // 用 四元组 + netns 在 ConnInfo Map 里定位对应连接,
    // 原子 +1 重传计数(__sync_fetch_and_add 语义)
    // retransmits 突增(比如每 5 次)就推一条告警事件
    1
}

重传率突然升高,要么网络在抖,要么有人在暴力扫描端口——两种都值得告警。

数据长什么样

跑起来之后,用户态程序吐出来的就是一条条连接流水:

代码语言:javascript
复制
14:02:11.332  pid=1823(nginx)  10.0.1.7:51234 → 10.0.3.9:6379   SYN_SENT
14:02:11.334  pid=1823(nginx)  10.0.1.7:51234 → 10.0.3.9:6379   ESTABLISHED  (握手 2.1ms)
14:02:40.901  pid=1823(nginx)  10.0.1.7:51234 → 10.0.3.9:6379   CLOSE_WAIT
02:13:07.115  pid=2941(cron-helper) 10.0.1.7:40112 → 203.0.113.8:443  SYN_SENT
02:13:07.118  pid=2941(cron-helper) 10.0.1.7:40112 → 203.0.113.8:443  ESTABLISHED  (握手 3.0ms)
02:13:22.440  pid=2941(cron-helper) 10.0.1.7:40112 → 203.0.113.8:443  CLOSE  (存活 15.3s, tx=1.2KB, 重传 0)

那台凌晨发包的机器,就是第二行这种——cron-helper 这个临时脚本,连了一个早该下线的公网地址。白天你查 netstat 当然什么都看不到,它早关了。

它能挖出什么

这才是重点。连接流水本身不值钱,值钱的是它能喂给上面的分析层:

1. 全量连接画像,零埋点。 不碰应用代码、不上 sidecar、不依赖业务配合,内核态事件触发才跑,开销极低。系统里到底谁在连谁,一张图铺开。

2. 揪出 C2 信标。 恶意程序的典型特征是"周期性地连一个陌生外网 IP"。把连接流水按目标 IP + 间隔聚类,周期性外联一眼就能挑出来——这是传统基于特征的安全软件很难发现的。

3. 发现不该存在的连接。 微服务 A 按理只该访问数据库和缓存,结果流水里出现它直连另一个服务的端口。要么是配置写错,要么是越权访问甚至横向移动。连接地图让"越界"无所遁形。

4. 网络健康度。 重传率、连接时长分布、TIME_WAIT 堆积,直接反映这台机器的网络质量,排查抖动比 ping 有用得多。

5. 事后取证。 出事后回放"当时这台机器到底连过谁",比翻半秃的日志靠谱。

6. 失败归因,而不只是"有连接"。 流水攒够了,就能算 SLI 了:握手失败率 = (SYN_SENT 次数 − ESTABLISHED 次数) / SYN_SENT 次数,突增说明网络层或对端不可达;统计 established − syn_sentP99 时延,正常 50ms 的连接突然飙到 5 秒,配合重传数据基本能锁定是"公网出口丢包导致指数退避"还是"服务端 backlog 满了";再过滤 ESTABLISHED 且存活 > 1 小时且 rx_bytes = 0 的连接,还能揪出一堆因为代码没调 close() 而泄漏本地端口的僵尸连接。

它和前面那篇是配套的

如果你看过我上篇《用 eBPF 挖隐蔽信道》,那篇讲的是怎么从进程间通信里找出异常。但那一切的前提,是"每个连接都被记下来了"。本文就是那块地基——先把所有 TCP 连接全量落到流水,隐蔽信道检测、C2 检测、横向移动检测才有的吃。

换句话说:连接追踪是"看见",异常检测是"看懂"。先能看见,才谈得上看懂。

生产避坑手册

原理讲完,说点能救命的。下面这几条是真正上线才会撞上的坑,每一条都值钱:

坑一:普通 Hash Map 的锁竞争。 连接表用 HashMap,QPS 上十万后,map_update / map_lookup 偶尔延迟飙升——多核写入抢同一把自旋锁,软中断积压。解法:用 PerCpuHashMap,每核一份数据,零锁竞争。注意读的时候要做多核聚合(把各核的值加起来)。

坑二:读内核结构体读到空指针。struct sock 里嵌套指针可能为空,直接解引用会抛异常甚至短暂卡宿主机。解法:读之前显式判空,嵌套结构体用安全读取宏(Aya 的 Ptr / CO-RE 读取),别裸奔。

坑三:Map 内存泄漏 → OOM。TCP_CLOSE 覆盖不了所有消亡路径——RST 异常中止、或 SYN_SENT 超时没走到 CLOSE,Map 条目就永久残留。跑三天 Slab 内存涨上去,OOM Killer 找上门。解法:给每条连接记 last_update,用户态起个兜底清理协程,主动删掉 10 分钟没动静的条目。

坑四:Ringbuf 背压丢事件。ringbuf_reserve 频繁返回 NULL,事件丢一片——用户态消费太慢(比如同步写 ES / Kafka),写指针追上了读指针。解法:把 Ringbuf 容量调大到 64MB、用户态攒批(比如 100 条一写)、预留失败降级(reserve 失败就跳过,绝不阻塞内核路径)。

坑五:容器里 netns 对不上。 K8s 里 Pod IP 是 10.0.0.x,但宿主机上看到的是根命名空间,连不到具体 Pod。解法:连接 Key 里存 netns 的 inode 号,再和用户态 /proc/[pid]/ns/net 映射,才能把连接精确归到 Pod。

几个实打实的局限

不把话说满,免得你踩坑:

  • 只看得见本机 socket。 想覆盖整个集群,得每台机器都部署一个 eBPF agent,再用中心化平台汇总。
  • 加密流量拿不到明文 payload。 但四元组 + 进程 + 时序,已经足够做行为分析了——我们盯的是"谁连谁、连多久、多频繁",不是内容。
  • 内核版本有门槛。 raw tracepoint 较老内核也支持,但建议 4.18+,5.x 更稳;生产前先 uname -r 确认。
  • 高并发下 ring buffer 可能丢事件。 主频高的机器要调大 ring buffer,或改用 per-CPU 结构,否则高峰期漏记连接。

一句话收尾

你以为的网络安全,是防火墙上画的一条线;eBPF 给你的,是线后面每一个连接的名字。


本篇是 eBPF 系统安全系列第四篇。前三篇:Rust+eBPF 防勒索(故事 / 原理)、eBPF 挖隐蔽信道。关注不迷路,下篇接着拆 TLS。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-07-24,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Rust火箭工坊 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 上周排查一个奇怪的问题:客户报凌晨经常出现服务连接不上,之前一直正常的。前几天刚开始偶尔连不上,但没有影响生产线就没在意,这两天影响生产线了。经过排查这一台内网机器,半夜偶发对外发包,防火墙规则明明只允许它访问两个固定地址。可到了凌晨,监控图上就是有一道连向某个陌生公网 IP 的流量。
    • 传统方案为什么看不见
    • 核心钩子:sock:inet_sock_set_state
    • 用 Rust + Aya 把它挂上去
    • 数据长什么样
    • 它能挖出什么
    • 它和前面那篇是配套的
    • 生产避坑手册
    • 几个实打实的局限
    • 一句话收尾
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档