我第一反应是 netstat -antp。空手而归——那连接太短了,netstat 是快照,它只拍你敲下回车的那一瞬间,连早就断了。
ss、lsof 一样。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 带给我们的,其实是一种上帝视角:
tcpdump 那种沉重的内存拷贝。bpf_probe_read_kernel,我们能直接抠出 struct sock 里的"私房字段"——比如 RTO(重传超时)、RTT(往返时间)、SACK 信息。换句话说,不用抓包,就能知道这条连接的网络质量。eBPF 它不轮询、不采样、不处理每一个包,它挂在内核 TCP 状态机的事件上——每次一个 socket 从 SYN_SENT 变成 ESTABLISHED、从 ESTABLISHED 变成 CLOSE,内核就触发一次回调,我们的程序进去读一下就走。连接再短,只要它真正发生过状态跳转,就逃不掉。
Linux 内核有个现成的 tracepoint:sock:inet_sock_set_state。顾名思义,它在一个 socket 的 TCP 状态发生变迁时触发。这恰好就是我们需要的"连接生命周期事件流"。
TCP 的状态就那十几个,记下来就是一张完整的连接生卒表:
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 的映射,就能把一个连接精确归到某个容器。
框架还是老朋友 Aya——纯 Rust 的 eBPF 工具链,不用写一行 C、不用 clang 那套复杂的构建链。
先想清楚我们要往 Map 里存什么。一个连接不是一行状态就完事,它是带时间轴和流量账本的:
// 连接 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 程序:
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 才能把"连接"和"进程"对上号——这一步是后面所有异常检测的地基;skc_v6_daddr)和把 comm(进程名)一起带出来。光有状态变迁,还差一件让数据变"厚"的事:重传。挂个 kprobe 就行:
#[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
}重传率突然升高,要么网络在抖,要么有人在暴力扫描端口——两种都值得告警。
跑起来之后,用户态程序吐出来的就是一条条连接流水:
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_sent 的 P99 时延,正常 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。
不把话说满,免得你踩坑:
uname -r 确认。你以为的网络安全,是防火墙上画的一条线;eBPF 给你的,是线后面每一个连接的名字。
本篇是 eBPF 系统安全系列第四篇。前三篇:Rust+eBPF 防勒索(故事 / 原理)、eBPF 挖隐蔽信道。关注不迷路,下篇接着拆 TLS。