eBPF 零侵入可观测性:无需改代码也能拿网络内核数据

eBPF 零侵入可观测性:无需改代码也能拿网络内核数据

一、你想知道 Pod 之间的真实延迟,但不想在每个服务里加埋点代码

传统可观测性三板斧都有侵入性:

  • 日志:需要log.Info(),格式要统一
  • Metrics:需要引入 Prometheus client,定义指标
  • Tracing:需要在请求链路里透传 trace-id,每个中间件都要加

改代码意味着:改了几十个服务、发版、灰度、验证。如果一个服务是第三方镜像或者是别人团队维护的,你根本改不了。

eBPF 的思路:我不改你的代码,我在内核里挂个钩子,你的网络包、系统调用、CPU 采样我全能看到。对应用进程透明。

二、eBPF 可观测性的内核级架构

eBPF 钩子分类:

钩子类型挂载点能抓到什么典型应用
kprobe/kretprobe内核函数入口/出口系统调用参数、返回值文件 I/O 监控
tracepoint内核静态追踪点网络、调度、内存事件TCP 重传监控
uprobe/uretprobe用户态函数Go/Python 函数调用慢查询定位
XDP网卡驱动层最原始的包数据DDoS 防护
TC (Traffic Control)内核协议栈进出流量负载均衡
socket filtersocket 层套接字数据连接级监控
perf eventCPU PMUCPU 周期、缓存命中性能分析火焰图

三、实际 eBPF 程序示例

追踪 HTTP 请求延迟(基于内核 kprobe)

// http_trace.bpf.c #include <linux/bpf.h> #include <bpf/bpf_helpers.h> #include <bpf/bpf_tracing.h> #include <bpf/bpf_endian.h> // 事件结构:传给用户态的数据 struct http_event { __u64 timestamp_ns; // 请求开始时间 __u64 duration_ns; // 请求耗时 __u32 pid; // 进程 ID __u32 tid; // 线程 ID __u32 status_code; // HTTP 状态码 __u32 data_len; // 响应体长度 char method[8]; // GET/POST char path[128]; // 请求路径 char comm[16]; // 进程名 }; // BPF Map:内核态→用户态环形缓冲区 struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 256 * 1024); } events SEC(".maps"); // BPF Map:记录请求开始时间(key=goroutine_id) struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 10240); __type(key, __u64); // goroutine ptr __type(value, __u64); // start timestamp } start_time SEC(".maps"); // kprobe:挂载在 tcp_sendmsg(Go 写 HTTP 响应最终调用这里) SEC("kprobe/tcp_sendmsg") int trace_tcp_sendmsg(struct pt_regs *ctx) { __u64 pid_tgid = bpf_get_current_pid_tgid(); __u32 pid = pid_tgid >> 32; __u64 goid = get_goroutine_id(ctx); // 从 Go runtime 获取 // 只追踪目标进程 if (pid != TARGET_PID) return 0; __u64 ts = bpf_ktime_get_ns(); bpf_map_update_elem(&start_time, &goid, &ts, BPF_ANY); return 0; } // kretprobe:tcp_sendmsg 返回时计算延迟 SEC("kretprobe/tcp_sendmsg") int trace_ret_tcp_sendmsg(struct pt_regs *ctx) { __u64 goid = get_goroutine_id(ctx); __u64 *start_ts = bpf_map_lookup_elem(&start_time, &goid); if (!start_ts) return 0; __u64 end_ts = bpf_ktime_get_ns(); __u64 duration = end_ts - *start_ts; bpf_map_delete_elem(&start_time, &goid); // 写入环形缓冲区给用户态读取 struct http_event *event; event = bpf_ringbuf_reserve(&events, sizeof(*event), 0); if (!event) return 0; event->timestamp_ns = *start_ts; event->duration_ns = duration; event->pid = bpf_get_current_pid_tgid() >> 32; bpf_get_current_comm(&event->comm, sizeof(event->comm)); bpf_ringbuf_submit(event, 0); return 0; } char LICENSE[] SEC("license") = "GPL";

用户态 Go 程序:读取 eBPF 事件并导出指标

// main.go - 使用 cilium/ebpf 库 package main import ( "bytes" "encoding/binary" "log" "net/http" "github.com/cilium/ebpf" "github.com/cilium/ebpf/link" "github.com/cilium/ebpf/ringbuf" "github.com/cilium/ebpf/rlimit" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) // HTTPEvent 对应 eBPF 中的 struct http_event type HTTPEvent struct { TimestampNs uint64 DurationNs uint64 PID uint32 TID uint32 StatusCode uint32 DataLen uint32 Method [8]byte Path [128]byte Comm [16]byte } var ( httpRequestDuration = prometheus.NewHistogramVec( prometheus.HistogramOpts{ Name: "ebpf_http_request_duration_seconds", Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5}, }, []string{"method", "path", "comm"}, ) ) func main() { prometheus.MustRegister(httpRequestDuration) // 移除内存限制(加载 eBPF 需要) if err := rlimit.RemoveMemlock(); err != nil { log.Fatal(err) } // 加载编译好的 eBPF 字节码 objs := &bpfObjects{} if err := loadBpfObjects(objs, nil); err != nil { log.Fatal("loading eBPF objects:", err) } defer objs.Close() // 挂载 kprobe kp, err := link.Kprobe("tcp_sendmsg", objs.TraceTcpSendmsg, nil) if err != nil { log.Fatal("attaching kprobe:", err) } defer kp.Close() krp, err := link.Kretprobe("tcp_sendmsg", objs.TraceRetTcpSendmsg, nil) if err != nil { log.Fatal("attaching kretprobe:", err) } defer krp.Close() // 从 ring buffer 读取事件 rd, err := ringbuf.NewReader(objs.Events) if err != nil { log.Fatal("creating ringbuf reader:", err) } defer rd.Close() log.Println("eBPF HTTP tracer started") // 事件处理循环 go func() { var event HTTPEvent for { record, err := rd.Read() if err != nil { log.Printf("reading from ringbuf: %v", err) continue } if err := binary.Read( bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event, ); err != nil { continue } method := cstring(event.Method[:]) path := cstring(event.Path[:]) comm := cstring(event.Comm[:]) duration := float64(event.DurationNs) / 1e9 httpRequestDuration.WithLabelValues( method, path, comm, ).Observe(duration) } }() // 暴露 Prometheus 指标 http.Handle("/metrics", promhttp.Handler()) log.Fatal(http.ListenAndServe(":2112", nil)) } func cstring(b []byte) string { if i := bytes.IndexByte(b, 0); i != -1 { return string(b[:i]) } return string(b) }

四、边界分析与架构权衡

边界问题方案
内核版本要求eBPF 需要 Linux 4.x+检查内核版本,降级使用 perf_event
Go 函数参数获取Go ABI 不标准使用 uretprobe + 寄存器分析
eBPF 程序大小限制最大 1M 指令(5.2+)拆分多个 eBPF 程序
HTTPS 流量无法看到加密内容在应用层用 uprobe 抓取
容器网络命名空间eBPF 挂载需要 PID namespace在宿主机上挂载 cgroup/skb 级别
Map 大小限制Ring buffer 满了丢数据加大 buffer,采样降级
Verifier 拒绝复杂逻辑可能通不过简化循环,使用 bounded loops

权衡:eBPF vs 传统 APM

eBPF 优势

  • 零代码改动(最大的卖点)
  • 覆盖所有应用(包括第三方)
  • 性能开销极低(< 1% CPU)
  • 可以看到内核级数据(TCP 重传、网络延迟等)

eBPF 劣势

  • 调试困难(eBPF 程序在内核里崩了很难排查)
  • 缺乏业务上下文(只知道系统调用,不知道业务含义)
  • 语言绑定不完整(不同语言的函数调用约定不同)
  • HTTPS 流量盲区(需要应用层配合解密)

推荐组合:eBPF 做网络/系统层监控 + OpenTelemetry 做应用层 Tracing。一个看底层,一个看业务,互为补充。

五、总结

eBPF 零侵入可观测性不是银弹,但它在不需要改代码这个维度上确实碾压传统方案。适合的场景:

  • 已有服务不想动代码:用 eBPF 快速建立可观测性
  • 第三方/开源组件:你控制不了它的代码
  • 网络/存储问题排查:需要内核级数据的场景
  • 安全审计:记录所有系统调用和行为

常用的 eBPF 可观测性工具:Cilium Hubble(网络)、Pixie(K8s 全栈)、Falco(安全)、bpftrace(调试)。

一句话总结:如果你还只在应用层加埋点,你已经漏掉了 50% 的可用信息。