eBPF 初探 不改内核代码观测 TCP 重传
排查线上网络抖动时,最有价值的信息往往是"某次 TCP 重传发生在哪个瞬间、当时是谁的连接、耗时多少"。传统手段要么靠 tcpdump 全量抓包(成本高、隐私重),要么靠 nstat 只看计数。eBPF 可以直接在内核里挂载观测点,把重传事件带上下文吐到用户态,开销在微秒级。
一、能观测什么,从哪观测
Linux 提供的挂载方式里,排查 TCP 最常用三类:
- kprobe / kretprobe:任意内核函数出入口,通用但依赖内核符号稳定;
- tracepoint:内核显式定义的稳定观测点,如
tcp:tcp_retransmit_skb; - perf event / BPF_PROG_TYPE_SK_*:分别用于性能采样与 socket 局部观测。
TCP 的 tracepoint 已经足够覆盖大部分诊断需求,优先用 tracepoint(内核保证 ABI 稳定):
ls /sys/kernel/debug/tracing/events/tcp/
# tcp_retransmit_skb tcp_retransmit_queue_updated tcp_send_reset tcp_destroyed ...
perf trace -e tcp:tcp_retransmit_skb
二、第一个程序:统计每分钟各进程重传次数
用 bpftrace 一行起量:
sudo bpftrace -e '
tracepoint:tcp:tcp_retransmit_skb /comm != "cpu_idle"/ {
@retransmits[comm, pid] = count();
}
interval:s:60 { print(@retransmits); clear(@retransmits); }
'
输出形态:
@retransmits[order-svc, 2241]: 41
@retransmits[kube-proxy, 812]: 3
30 秒就能看到"哪个进程在重传"。这类问题过去要么靠抓包,要么靠"猜 + 重启"。
三、带连接四元组的观测
tracepoint 参数里有 socket,用它取四元组。bpftrace 版本:
tracepoint:tcp:tcp_retransmit_skb {
$skaddr = (struct sock *)arg0;
$fam = skc_family($skaddr);
$sport = skc_sport($skaddr);
$dport = skc_dport($skaddr);
if ($fam == 2) { // AF_INET
$src = ntop(skc_daddr($skaddr));
@retrans[$src, $dport] = count();
}
}
要点:内核结构体字段随版本变化,跨版本脚本要靠 CO-RE(Compile Once Run Everywhere)+ BTF 做字段重定位。生产脚本应该走 libbpf + BTF,而不是硬编码偏移。
四、用 Go 写一个长期采集器
用 cilium/ebpf,比手写 libbpf 省事得多。
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -target amd64 retrans ./retrans.c
package main
import (
"log"
"time"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
"github.com/cilium/ebpf/perf"
)
type Event struct {
Sport, Dport uint16
Saddr, Daddr uint32
Pid uint32
Comm [16]byte
}
func main() {
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal(err)
}
var objs retransObjects
if err := loadRetransObjects(&objs, nil); err != nil {
log.Fatal(err)
}
defer objs.Close()
rd, err := perf.NewReader(objs.Events, os.Getpagesize()*64)
if err != nil {
log.Fatal(err)
}
defer rd.Close()
go func() {
for {
rec, err := rd.Read()
if err != nil {
return
}
var ev Event
if err := binary.Unmarshal(rec.RawSample, &ev); err == nil {
log.Printf("retrans pid=%d comm=%s src=%s:%d -> dst=%s:%d",
ev.Pid, cstr(ev.Comm[:]), ip(ev.Saddr), ev.Sport, ip(ev.Daddr), ev.Dport)
}
}
}()
for {
time.Sleep(time.Hour)
}
}
内核侧:
//go:build ignore
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
struct { __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY); } events SEC(".maps");
SEC("tracepoint/tcp/tcp_retransmit_skb")
int tp_retrans(struct trace_event_raw_tcp_event_sk_skb *ctx) {
struct event e = {};
struct sock *sk = (void *)ctx->skaddr;
e.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&e.comm, sizeof(e.comm));
e.sport = BPF_CORE_READ(sk, __sk_common.skc_num);
e.dport = bpf_ntohs(BPF_CORE_READ(sk, __sk_common.skc_dport));
e.saddr = BPF_CORE_READ(sk, __sk_common.skc_rcv_saddr);
e.daddr = BPF_CORE_READ(sk, __sk_common.skc_daddr);
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &e, sizeof(e));
return 0;
}
char LICENSE[] SEC("license") = "GPL";
五、必须知道的安全与稳定性边界
eBPF 程序运行在内核态,写错会拖垮整机,Verifier 是唯一的保护栏,理解它的规则:
- 循环必须有界(新版内核支持有界循环,但旧内核禁止);
- 不可无界访问内存,读用户内存要
bpf_probe_read_user; - 栈 512 字节上限,大数据必须走 map;
- map 大小与类型必须静态声明;
- license 必须是 GPL 兼容才可用 helper。
运维上三条硬规矩:
- 任何 eBPF 工具在预发先跑 24 小时,观察
bpf相关 CPU 开销(/proc/sys/kernel/perf_event_max_stack与bpftool prog show里的 run_time); - 高频 tracepoint(
tcp_sendmsg、sys_enter_*)加速率限制或按 pid 过滤,否则单核被打满; - 生产用带 BTF 的 CO-RE 程序,禁止跨内核版本硬编码结构体偏移。
六、从观测到闭环
eBPF 最有价值的用法不是"抓事件",而是"按业务标签聚合"。落地形态:
tracepoint → BPF map(按 app/pod 聚合计数)→ 用户态 exporter → Prometheus
自定义指标示例:
tcp_retransmits_total{app="order-svc",pod="order-7d9"} 132
tcp_retransmits_total{app="gateway", pod="gw-3a2"} 4
有了带业务标签的重传时序,配合上层的请求延迟指标与 Trace,"这个接口 P99 抖动是网络重传导致的"就变成一句话可以证明或证伪的事实,而不是靠经验。
小结
eBPF 的价值是把内核观测从"事后抓包"变成"结构化事件流"。入门的正确顺序:先用 bpftrace 拿直觉(一两行看进程级重传计数),再上 libbpf/cilium-ebpf + CO-RE 写生产 collector,最后把 BPF map 聚合成 Prometheus 指标接入既有告警体系。全程记住两件事:优先用 tracepoint 而非 kprobe,高频事件必须限流。守住这两条,eBPF 就是一台稳定、低成本、可解释的观测机器。
评论(0)
登录 后参与评论。