编程 eBPF 深度实战:用内核级可编程能力重构生产可观测性——从 bpftrace 一行定位延迟毛刺,到 libbpf/Go 自研生产级探针的完整指南(2026)

2026-07-08 02:44:47 +0800 CST views 18

eBPF 深度实战:用内核级可编程能力重构生产可观测性--从 bpftrace 一行定位延迟毛刺,到 libbpf/Go 自研生产级探针的完整指南(2026)

关键词:eBPF、可观测性、零侵入、bpftrace、BCC、libbpf、CO-RE、cilium/ebpf、内核探针、生产排障

如果你做过线上排障,一定遇到过这种场景:监控大盘一切正常,P99 延迟却每隔几分钟就抖一下;日志里没有半点线索;APM 采样把那一次请求"采样丢了";你想在代码里加一行计时,但那是别人的服务,重启要走变更流程,灰度要三天。

传统可观测性三件套--日志(Logs)、指标(Metrics)、链路追踪(Traces)--本质上是用户态视角:它们只能看到应用程序"愿意告诉你"的东西。应用没打点,你就看不见;采样率低,你就抓不到;内核帮你干的那些脏活累活(调度、网络栈、文件系统、内存回收),用户态工具基本是瞎的。

eBPF(extended Berkeley Packet Filter)是这几年来唯一真正改变这一格局的技术。它让你把一个沙盒化的小程序直接塞进 Linux 内核,在内核事件发生的那一刻(系统调用、函数入口/返回、网络包、调度决策)就地采集、过滤、聚合数据,完全不需要改一行业务代码、不需要重启进程、不需要重新编译内核

这篇文章我们不谈"eBPF 是什么"的名词解释,而是从一个真实排障者的视角,把 eBPF 从"一行命令"到"自研生产级探针"的完整能力链讲透:它凭什么能零侵入?内核是怎么保证这段不受信任的代码不把系统搞崩的?工具链怎么选?代码怎么写?生产环境跑起来到底有多大开销?最后用一个"200ms 延迟毛刺"的真实案例,演示如何用 eBPF 把生产环境的黑盒变成玻璃缸。


一、背景:为什么传统可观测性在排障时"够不着"

1.1 用户态与内核态之间有一道墙

一个最简单的 read() 系统调用,从你的 Go/Java 代码到数据真正落到网卡,中间要穿过:

  • 用户态运行时(GC、协程调度、缓冲)
  • 系统调用陷入内核(上下文切换)
  • VFS 层
  • 具体文件系统(ext4/xfs)
  • 块设备层、IO 调度器
  • 页缓存、内存回收(直接内存回收会同步卡住你的进程)
  • TCP/IP 协议栈
  • 网卡驱动、软中断(softirq)、NAPI 轮询

任何一层出了幺蛾子,你的应用层指标都会表现为一个模糊的"变慢了",但你根本不知道是哪一层。传统的 strace 能看系统调用,但开销大到生产环境基本不能用;perf 能采样,但需要符号、需要权限、且是统计视角而非逐事件;应用内的埋点则完全依赖开发者的先验假设--你预想不到的地方,永远没有埋点。

1.2 采样与重现的困境

线上问题有两个经典特征:偶发难重现

  • APM 的链路追踪为了性能,通常只采样 1%~10% 的请求。那个 P99 尾巴上的慢请求,大概率被采样丢了。
  • 日志级别开到 DEBUG 能看见细节,但全量 DEBUG 日志本身就会把磁盘 IO 打满,制造新的问题。
  • 很多毛刺只在特定负载组合下出现,你根本没法在测试环境稳定复现。

eBPF 的解法不是"采样"也不是"埋点",而是在内核事件源头上做事件级(event-level)采集,并且把过滤和聚合也放在内核里完成--只把"已经算好的结果"抛给用户态。这意味着你可以对每一次系统调用、每一个网络包做判断,而开销依然可控。

1.3 eBPF 不是新东西,但 2020 年之后它才真正可用

eBPF 的内核雏形在 2014 年(Linux 3.18)就进了主线,但真正让它从"网络过滤玩具"变成"通用内核可编程基础设施"的,是几个关键里程碑:

  • Linux 4.x:kprobe/uprobe、tracepoint、perf event 支持逐步完善
  • Linux 5.7:引入 BPF-LSM,eBPF 能挂在安全钩子上做运行时策略执行
  • Linux 5.8+ 的 BTF(BPF Type Format):让 eBPF 程序能"编译一次,到处运行"(CO-RE),告别了过去针对不同内核版本重写偏移量的噩梦
  • cilium/ebpf、libbpf-bootstrap、bpf2go 等用户态工具链的成熟:让 eBPF 从"内核黑客的玩具"变成普通后端工程师也能用的东西

到 2026 年,eBPF 已经是云原生可观测性、安全(Cilium Tetragon、Falco)、网络(Cilium 直接取代 kube-proxy)的底层事实标准。作为一线开发者,不懂 eBPF,你在排障时的"视力"就少了一整层。


二、核心概念:eBPF 到底是怎么工作的

2.1 eBPF 虚拟机与"安全沙盒"

eBPF 程序不是内核模块(LKM)。内核模块拥有 ring 0 的全部权限,一个空指针就能让整个系统 panic。eBPF 程序运行在一个受约束的虚拟机里,它要进内核,必须先过两道关:

  1. 验证器(Verifier):在加载时静态分析字节码,证明:
    • 程序一定会终止(不允许无界循环,直到 Linux 5.3 才支持有界循环)
    • 所有内存访问都在边界内(不能越界读内核内存)
    • 不会解引用未初始化的指针
    • 只调用白名单内的 helper 函数
  2. JIT 编译器:验证通过后,eBPF 字节码被即时编译成原生机器码,运行效率接近手写 C。

这带来的直接好处:加载 eBPF 程序不会导致内核崩溃。最坏情况不过是验证失败、加载被拒。这就是为什么 eBPF 能安全地跑在生产内核上,而内核模块一般不敢碰。

2.2 挂载点(Attach Points):你能"挂"在哪里

eBPF 程序的威力来自它能挂在内核的各种事件源上。理解这些挂载点是写对程序的前提:

挂载点说明典型用途
kprobe挂在内核函数入口(动态插桩)追踪任意内核函数,如 tcp_connect
kretprobe挂在内核函数返回处测量函数耗时、获取返回值
uprobe挂在用户态函数/二进制地址追踪 mallocredis 命令、任意应用
tracepoint内核预置的稳定追踪点稳定 API,如 syscalls:*sched:*tcp:*
fentry/fexit挂在内核函数入口/出口(需 BTF)比 kprobe 更准、更快、更稳定
perf_event硬件/软件性能计数器CPU cycle、cache miss、page fault
XDP网卡驱动最早的数据包处理点高性能网络、DDoS 防护
BPF-LSM安全模块钩子运行时访问控制

经验法则:能用 tracepoint 就用 tracepoint(稳定、有文档、结构体字段清晰);需要更细粒度且没有对应 tracepoint 时再用 kprobe;追求极致性能且内核够新(≥5.5,配 BTF)时用 fentry/fexit。uprobe 是追踪第三方应用(比如你没法改源码的 Redis、Nginx)的杀手锏。

2.3 Maps:eBPF 程序与用户态之间的"共享内存"

eBPF 程序运行在内核态,你的控制逻辑在用户态,两者怎么通信?答案是 Map(映射)。Map 是内核与用户态之间高效的键值存储,也是 eBPF 程序之间共享状态的唯一方式。

常用 Map 类型:

  • BPF_MAP_TYPE_HASH:通用哈希表,做"按 PID 聚合"最好用
  • BPF_MAP_TYPE_ARRAY:定长数组,做全局计数器
  • BPF_MAP_TYPE_PERCPU_HASH/ARRAY:每 CPU 一份,避免多核竞争(高并发采集必备)
  • BPF_MAP_TYPE_RINGBUF:环形缓冲区(Linux 5.8+),推荐的事件上报通道,比老的 perfbuf 吞吐更高、内存更省
  • BPF_MAP_TYPE_STACK_TRACE:内核栈追踪
  • BPF_MAP_TYPE_LRU_HASH:带 LRU 淘汰的哈希,防止内存无限增长

一个核心设计思想:能放在内核里算的,绝不全量抛给用户态。比如"统计每个进程分配了多少内存",正确做法是 eBPF 程序在内核里就把 bytes 累加到 alloc_by_pid[pid] 这个 hash map 里,用户态只需要每隔几秒来读一次聚合结果。这跟 MapReduce 的思想一模一样。

2.4 Helper 函数:eBPF 程序能调用的"系统调用"

eBPF 程序不能直接调用任意内核函数,只能通过白名单 helper,例如:

  • bpf_ktime_get_ns():纳秒级时间戳(测耗时用)
  • bpf_get_current_pid_tgid() / bpf_get_current_comm():拿当前进程信息
  • bpf_map_lookup_elem() / bpf_map_update_elem():操作 Map
  • bpf_probe_read_kernel() / bpf_probe_read_user():安全地从内核/用户空间读内存(验证器要求所有跨空间读都走这两个 helper)
  • bpf_ringbuf_reserve() / bpf_ringbuf_submit():往 ringbuf 写事件
  • bpf_perf_event_output():往 perfbuf 输出

记住一条铁律:eBPF 程序里任何读别人内存的操作,都必须通过 bpf_probe_read_* 系列 helper。直接解引用内核指针会被验证器拒绝。


三、架构分析:一次 eBPF 采集的完整生命周期

3.1 端到端数据流

┌─────────────────────────────────────────────────────────┐
│                       Linux 内核                          │
│                                                           │
│   事件源 (syscall / kprobe / tracepoint / XDP)            │
│        │                                                  │
│        ▼                                                  │
│   eBPF 程序 (验证器已通过, JIT 成本地机器码)              │
│        │  1 在内核内就地过滤、聚合                         │
│        ├──▶ 写入 Map (hash/array/percpu)  ←──┐           │
│        └──▶ 提交事件到 RingBuf      ←────────┤           │
│                                              │           │
└──────────────────────────────────────────────┼──────────┘
                                               │ 3 用户态读取
                                               ▼
┌─────────────────────────────────────────────────────────┐
│   用户态 Agent (Go / Python / Rust)                       │
│   2 周期读 Map 聚合结果 / 消费 RingBuf 事件               │
│        │                                                  │
│        ▼                                                  │
│   导出: Prometheus / OpenTelemetry / 日志 / 告警          │
└─────────────────────────────────────────────────────────┘

关键点:过滤和聚合尽量发生在 1(内核态),用户态 2 只做"收尾"和"导出"。这是 eBPF 高性能和低开销的根本原因。

3.2 工具链选型:BCC、bpftrace、libbpf、cilium/ebpf 怎么选

工具链语言适合场景优点缺点
bpftrace专用脚本语言临时排障、一行命令、探索极简,开箱即用不适合复杂逻辑和常驻服务
BCCPython + C快速原型、脚本化工具生态成熟,例子多依赖 LLVM 运行时,体积大,启动慢
libbpf + CO-REC(编译为 .o)生产级、嵌入式、高性能编译一次到处运行,零运行时依赖上手门槛高
cilium/ebpfGo写常驻 Agent、对接云原生Go 生态好,类型安全,易部署需要 Go 工具链

我的建议:

  • 临时排障 / 探索:用 bpftrace,一行命令解决 80% 的"到底发生了什么"问题。
  • 写个能长期跑的生产探针:用 libbpf(C,编译成 .o)+ cilium/ebpf(Go,负责加载和读取)。这是 2026 年最主流、最干净的生产架构。

3.3 CO-RE:eBPF 的"一次编译,到处运行"

过去写 eBPF 最痛苦的事:不同内核版本里,同一个内核结构体的字段偏移不一样(struct task_struct 在不同版本字段顺序不同)。老办法是 bcc 在加载时现场用 LLVM 编译并读取本机内核头文件--这要求生产机上装一整套内核头文件和 clang,体积大、启动慢、易碎。

CO-RE(Compile Once - Run Everywhere) 的破局点:

  1. 内核开启 CONFIG_DEBUG_INFO_BTF,编译出一个描述所有内核类型的 vmlinux.h(一个巨大的自包含头文件)。
  2. eBPF 程序编译时用 bpf_core_read() 宏,配合 BTF 信息记录"我要读的是 task->pid 这个语义字段,而不是第 N 个偏移"。
  3. 加载时,libbpf 根据目标机器实际的 BTF 自动重写偏移。于是同一个 .o 文件可以在 CentOS 的 5.4 内核和 Ubuntu 的 6.8 内核上都能跑。

这就是为什么现在生产环境几乎不用 BCC 的运行时编译,而是用 clang -target bpf 预编译 + libbpf 加载。


四、代码实战

下面从易到难,逐步上代码。所有示例都在现代内核(≥5.8,开启 BTF)上可运行。

4.1 bpftrace 一行流:先解决 80% 的"发生了什么"

bpftrace 的语法类似 awk,是排障第一武器。

例子 1:谁在疯狂建 TCP 连接?

# 按进程名统计 tcp_connect 调用次数
sudo bpftrace -e 'kprobe:tcp_connect { @[comm] = count(); }'

例子 2:定位文件 IO 延迟(openat 直方图)

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_openat { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_openat /@start[tid]/ {
    @open_us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}'

例子 3:找出 TCP 重传(网络抖动的头号嫌疑)

sudo bpftrace -e 'tracepoint:tcp:tcp_retransmit_skb { @[comm, pid] = count(); }'

例子 4:哪个进程在做直接内存回收(会同步卡住业务!)

sudo bpftrace -e 'tracepoint:vmscan:mm_vmscan_direct_reclaim_begin { @[comm, pid] = count(); }'

这几行命令不需要写任何 C,不需要提前编译,敲下去立刻出结果。线上遇到"不明原因的卡顿",先跑这几个,往往几秒钟就锁定方向。

4.2 BCC Python:脚本化追踪文件打开

当你需要更灵活的过滤和输出时,用 BCC 写个 Python 脚本。下面这个程序追踪所有 openat 系统调用,打印"谁、读了什么文件":

from bcc import BPF

prog = """
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>

struct data_t {
    u32 pid;
    u32 uid;
    char comm[TASK_COMM_LEN];
    char fname[256];
};

BPF_PERF_OUTPUT(events);

int trace_open(struct pt_regs *ctx, int dfd, const char __user *filename, int flags) {
    struct data_t data = {};
    data.pid = bpf_get_current_pid_tgid() >> 32;
    data.uid = bpf_get_current_uid_gid();
    bpf_get_current_comm(&data.comm, sizeof(data.comm));
    bpf_probe_read_user_str(&data.fname, sizeof(data.fname), (void *)filename);
    events.perf_submit(ctx, &data, sizeof(data));
    return 0;
}
"""

b = BPF(text=prog)
b.attach_kprobe(event=b.get_syscall_fnname("openat"), fn_name="trace_open")

def print_event(cpu, data, size):
    event = b["events"].event(data)
    print(f"{event.pid:6d} {event.comm.decode():16s} {event.fname.decode()}")

b["events"].open_perf_buffer(print_event)
print("Tracing openat()... Ctrl-C to stop")
while True:
    b.perf_buffer_poll()

注意点:

  • bpf_probe_read_user_str必须的--不能直接解引用用户态指针 filename
  • perf_submit 走的是 perfbuf(老通道)。新项目建议改用 ringbuf,见后文。
  • BCC 会在加载时用本机 clang 现场编译这段 C,所以运行机需要装 bpfcc-tools、内核头文件和 clang。

4.3 libbpf + CO-RE:生产级「进程内存分配排行榜」

现在我们上生产级写法。先写一个 CO-RE 的 eBPF 程序(C),用 kmem:kmalloc tracepoint 统计每个进程分配了多少字节,结果存进 hash map。

// alloc.bpf.c -- 用 clang -target bpf 预编译
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "GPL";

// 按 PID 聚合的内存分配总量(字节)
struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 16384);
    __type(key, __u32);    // pid
    __type(value, __u64);  // 累计分配字节数
} alloc_by_pid SEC(".maps");

SEC("tracepoint/kmem/kmalloc")
int handle_kmalloc(struct trace_event_raw_kmalloc *ctx)
{
    __u32 pid = bpf_get_current_pid_tgid() >> 32;
    if (pid == 0)                       // 跳过内核线程
        return 0;

    __u64 size = ctx->bytes_alloc;      // CO-RE 自动解析字段偏移
    __u64 *val = bpf_map_lookup_elem(&alloc_by_pid, &pid);
    if (val) {
        __sync_fetch_and_add(val, size);
    } else {
        __u64 init = size;
        bpf_map_update_elem(&alloc_by_pid, &pid, &init, BPF_ANY);
    }
    return 0;
}

编译(需要 clangbpftool 生成的 vmlinux.h):

# 生成 vmlinux.h(只需在有 BTF 的内核上做一次)
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

# 编译为 BPF 目标文件
clang -O2 -g -target bpf -c alloc.bpf.c -o alloc.bpf.o

这就是 CO-RE 的精髓:ctx->bytes_alloc 用的是 BTF 语义字段,编译出的 alloc.bpf.o 可以在任何有 BTF 的内核上被 libbpf 自动重定位偏移,无需在目标机装内核头文件或 clang

4.4 Go + cilium/ebpf:加载探针并导出 Top N

用 Go 加载上面编译好的 .o,周期读取 map,输出内存分配最多的进程,并对接 Prometheus 风格的输出:

package main

import (
    "fmt"
    "log"
    "sort"
    "time"

    "github.com/cilium/ebpf"
    "github.com/cilium/ebpf/link"
    "github.com/cilium/ebpf/rlimit"
)

type allocator struct {
    PID   uint32
    Bytes uint64
}

func main() {
    // 生产环境应通过 systemd 设置 memlock 限制,这里仅作演示
    if err := rlimit.RemoveMemlock(); err != nil {
        log.Fatalf("remove memlock: %v", err)
    }

    // 加载预编译的 eBPF 对象
    spec, err := ebpf.LoadCollectionSpec("alloc.bpf.o")
    if err != nil {
        log.Fatalf("load spec: %v", err)
    }

    coll, err := ebpf.NewCollection(spec)
    if err != nil {
        log.Fatalf("new collection: %v", err)
    }
    defer coll.Close()

    // 挂载到 kmem:kmalloc tracepoint
    tp, err := link.Tracepoint("kmem", "kmalloc", coll.Programs["handle_kmalloc"], nil)
    if err != nil {
        log.Fatalf("attach tracepoint: %v", err)
    }
    defer tp.Close()

    allocMap := coll.Maps["alloc_by_pid"]

    ticker := time.NewTicker(5 * time.Second)
    for range ticker.C {
        top := topAllocators(allocMap, 5)
        fmt.Println("=== Top memory allocators (last 5s) ===")
        for _, a := range top {
            fmt.Printf("pid=%-7d  %8.2f MB\n", a.PID, float64(a.Bytes)/1024/1024)
        }
    }
}

func topAllocators(m *ebpf.Map, n int) []allocator {
    var (
        key   uint32
        value uint64
    )
    result := make([]allocator, 0)
    iter := m.Iterate()
    for iter.Next(&key, &value) {
        result = append(result, allocator{PID: key, Bytes: value})
    }
    sort.Slice(result, func(i, j int) bool { return result[i].Bytes > result[j].Bytes })
    if len(result) > n {
        result = result[:n]
    }
    return result
}

这段代码就是生产 eBPF Agent 的骨架:rlimit 放开 → LoadCollectionSpec.oNewCollection 实例化 → link.Tracepoint 挂载 → 周期 Iterate 读 map → 输出/导出。cilium/ebpf 是纯 Go 实现,编译出一个静态二进制就能部署,不依赖任何运行时。

4.5 进阶:用 RingBuf 上报「进程启动」事件流

Map 适合"聚合结果",但如果你要的是逐条事件流(比如"每次有新进程启动就上报"),用 BPF_MAP_TYPE_RINGBUF 更合适。eBPF 侧:

struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 1 << 24); // 16MB
} events SEC(".maps");

struct exec_event {
    __u32 pid;
    char comm[16];
};

SEC("tp/sched/sched_process_exec")
int on_exec(struct trace_event_raw_sched_process_exec *ctx)
{
    struct exec_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
    if (!e)
        return 0;
    e->pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(&e->comm, sizeof(e->comm));
    bpf_ringbuf_submit(e, 0);
    return 0;
}

Go 侧消费:

rd, err := ring.NewReader(coll.Maps["events"], 64*1024 /* perCPU buffer */)
if err != nil { log.Fatal(err) }
go func() {
    var e execEvent
    for {
        record, err := rd.Read()
        if err != nil { continue }
        // 把 record.RawSample 解析成 execEvent,交给 OTel/Prometheus
    }
}()

RingBuf vs PerfBuf:RingBuf 是 Linux 5.8 引入的新通道,多个 CPU 共享一个环形缓冲区,内存利用率更高、写入更快、读取端只需一个消费者;PerfBuf 是每 CPU 一个独立缓冲区,旧但兼容老内核。新项目无脑选 RingBuf。

4.6 uprobe 实战:追踪第三方应用,不改它一行代码

kprobe 挂的是内核函数,uprobe 挂的是用户态二进制里的函数或地址。这意味着你可以观测任何你拿不到源码、也不能改的服务--Redis、Nginx、你依赖的某个闭源 SDK,甚至是你自己服务的某个没打点的内部函数。

下面用 BCC 给一个正在运行的进程的 malloc 调用挂 uprobe,统计它分配的内存大小分布:

from bcc import BPF

prog = """
#include <uapi/linux/ptrace.h>

BPF_HASH(start, u32);
BPF_HISTOGRAM(alloc_size);

// malloc 的入参 size 在第一个寄存器(x86-64 的 di)里
int malloc_enter(struct pt_regs *ctx, size_t size) {
    u32 pid = bpf_get_current_pid_tgid();
    start.update(&pid, &size);
    return 0;
}

int malloc_return(struct pt_regs *ctx) {
    u32 pid = bpf_get_current_pid_tgid();
    size_t *sz = start.lookup(&pid);
    if (sz) {
        alloc_size.increment(*sz);
        start.delete(&pid);
    }
    return 0;
}
"""

b = BPF(text=prog)
# 挂到目标二进制 malloc 的入口和返回
b.attach_uprobe(name="libc", sym="malloc", fn_name="malloc_enter")
b.attach_uretprobe(name="libc", sym="malloc", fn_name="malloc_return")

print("Tracing malloc in libc... Ctrl-C to stop")
try:
    while True:
        b.perf_buffer_poll()
except KeyboardInterrupt:
    b["alloc_size"].print_linear_hist("malloc size (bytes)")

关键点:

  • attach_uprobe(name="libc", sym="malloc", ...) 让 BCC 自动从 libc.so 的符号表里找到 malloc 的地址并挂上;如果想追踪自定义二进制,把 name 换成二进制路径即可。
  • attach_uretprobe 是返回钩子,用来在 malloc 返回时读取之前存的 size 并累加直方图。
  • uprobe 的代价比 kprobe 略高(涉及用户态地址解析),但相比改应用重发版本,依然是零成本的神仙手段。

如果你用 cilium/ebpf 加载预编译的 uprobe 程序,挂载侧写成:

up, err := link.OpenExecutable("/lib/x86_64-linux-gnu/libc.so.6")
if err != nil { log.Fatal(err) }
l, err := up.Uprobe("malloc", coll.Programs["malloc_enter"], nil)
if err != nil { log.Fatal(err) }
defer l.Close()

uprobe 在排障里最常见的用法,是给那些"明明代码里没打点、但你又特别想知道它干了啥"的第三方库函数挂钩子--比如某次 JSON 序列化为什么这么慢、某次 DNS 解析卡了多久。


五、真实排障案例:一个 200ms 的 HTTP 延迟毛刺

光看示例不过瘾,我们来走一遍真实流程。假设你负责的某个 HTTP 服务,P99 偶尔跳到 200ms,但平均值只有 8ms,日志和 APM 都看不到原因。

第一步:确认是不是网络层的问题。

# 看 TCP 重传(如果是网络抖动,这里会有计数)
sudo bpftrace -e 'tracepoint:tcp:tcp_retransmit_skb { @[comm, pid] = count(); }'

如果几乎没有重传,排除网络。

第二步:看是不是内核在"直接内存回收"卡住了进程。

sudo bpftrace -e 'tracepoint:vmscan:mm_vmscan_direct_reclaim_begin { @[comm, pid] = count(); }'

直接内存回收(direct reclaim)是经典的"应用啥也没干,却卡了几百毫秒"的元凶--当内存紧张、页缓存不够时,分配内存的进程会被同步拖去回收内存。如果这里命中,说明是内存压力,不是你代码的问题。

第二步补充:排除调度延迟(run queue latency)。 有时进程本身没在干活,只是在 CPU 运行队列里排了很久的队。用 runqlat 思路(bcc-tools 自带)看排队延迟分布:

sudo /usr/share/bcc/tools/runqlat 5  # 每 5 秒打印一次运行队列等待延迟直方图

如果 runqlat 显示有明显的毫秒级排队,说明是CPU 资源争抢(隔壁容器在抢核、或你的 CPU limit 设得太低),而不是你的代码慢。

第三步:逐系统调用看耗时分布。

sudo bpftrace -e '
tracepoint:syscalls:sys_enter_* { @start[tid] = nsecs; }
tracepoint:syscalls:sys_exit_* /@start[tid]/ {
    @us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}'

直方图会告诉你,是 read 慢(下游依赖慢)、futex 慢(锁竞争)、还是 write 慢(刷盘/网络写)。

第四步:如果怀疑是锁竞争,用 offcputime 思路--看进程"不在 CPU 上"的时间花在哪。

eBPF 工具集(bcc tools / bpftrace 脚本)里有现成的 offcputime,它能把"进程被调度走、等到重新上 CPU"这段时间的调用栈打印出来。很多"应用层看不懂的延迟"其实是在等一把锁、或等 IO 完成、或在运行队列里排队。这部分信息,APM 永远给不了你。

结论:用 eBPF,我们从"服务变慢了"一路定位到具体的内核子系统(内存回收 / 锁 / 网络 / 调度),中间没有重启任何进程,没有改一行业务代码,没有损失采样精度。这就是 eBPF 相对于传统可观测性的降维打击。

5.1 第二个案例:Nginx 偶发 502,但后端其实很快

某次线上 Nginx 偶发 502,上游服务(Go)自己的 P99 只有 15ms,完全正常。传统日志看不出问题。用 eBPF 看 TCP 层的"连接建立→首个字节"耗时:

# 统计每个目标 IP:Port 的 TCP 连接建立延迟
bpftrace -e '
kprobe:tcp_connect { @start[tid] = nsecs; }
kretprobe:tcp_connect /@start[tid]/ {
    @conn_us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}'

结果发现有一批连接建立延迟高达数百毫秒。进一步用 tcpretrans 发现大量重传,再用 tcpdump 配合确认--根因是某个中间代理的 conntrack 表被打满,新建连接被丢包重传。Nginx 502 只是表象,真正的锅在网络层。这种"应用层完全正常、但连接层在重传"的问题,没有 eBPF,几乎不可能在分钟级定位到。


六、性能优化与生产落地

"在内核里跑代码,会不会把生产搞挂?"这是所有人第一个担心的问题。答案是:用对了,开销可以低到可忽略(通常 < 1%~2% CPU)。下面是关键的优化和避坑清单。

6.1 把聚合放在内核,别全量上抛

这是第一原则,前面反复强调。要"每个进程分配了多少内存",就在内核累加进 map;要"慢请求有哪些",就在内核用 bpf_ktime_get_ns() 算好耗时,只把超过阈值的事件 bpf_ringbuf_submit。用户态只做收尾,不做全量计算。

6.2 用 Per-CPU Map 消除锁竞争

多核机器上,所有 CPU 同时往同一个 hash map 写会触发自旋锁。把 BPF_MAP_TYPE_HASH 换成 BPF_MAP_TYPE_PERCPU_HASH,每个 CPU 一份副本,用户态读取时再求和。高并发采集场景这是必选项。

struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_HASH);
    __uint(max_entries, 16384);
    __type(key, __u32);
    __type(value, __u64);
} alloc_by_pid SEC(".maps");

6.3 RingBuf 优于 PerfBuf

如前所述,RingBuf 共享缓冲、吞吐更高、内存更省。新内核(≥5.8)优先用 RingBuf。

6.4 验证器限制与避坑

  • 不能有无界循环:Linux 5.3+ 才支持有界 for 循环,且循环次数必须能被验证器静态证明。
  • 栈空间只有 512 字节:需要大数组/缓冲区时,用 Map(BPF_MAP_TYPE_PERCPU_ARRAY)当"堆"来用,而不是在栈上开大数组。
  • 所有跨空间读必须走 helper:读内核内存用 bpf_probe_read_kernel,读用户内存用 bpf_probe_read_user,否则验证器直接拒绝。
  • 死代码/未初始化变量:验证器要求每条路径上的变量都初始化过,否则拒绝加载。

6.5 权限与内核要求

  • eBPF 程序需要 CAP_BPF + CAP_SYS_ADMIN(或干脆以 privileged 容器运行)。生产环境通常通过 systemd 单元或 Kubernetes SecurityContext 配置。
  • 必须放开 RLIMIT_MEMLOCK(或用 rlimit.RemoveMemlock())。
  • CO-RE 需要内核开启 CONFIG_DEBUG_INFO_BTF(主流发行版 2021 年后的内核都默认开了)。
  • fentry/fexit、BPF-LSM 等新特性需要较新内核(≥5.5 / ≥5.7)。
  • 建议内核 ≥ 5.8,能完整享受 RingBuf + CO-RE + fentry 的现代能力。

6.6 开销实测参考

以一个"挂载在 12 个 syscall tracepoint、采集全量系统调用耗时直方图"的探针为例,在 16 核生产机上的典型观测:

场景额外 CPU 占用对业务 P99 影响
仅挂载、低流量< 0.3%不可测
中等流量(~5k syscall/s/核)~1%< 0.5ms
极高流量(~50k syscall/s/核)+ RingBuf~2-3%1~2ms

结论:在正常流量下,一个设计合理的 eBPF 探针开销在 1% 以内,远低于多加一个 APM SDK 或开全量 DEBUG 日志的代价。

6.7 部署形态:从脚本到常驻 Agent

临时排障用 bpftrace 一行命令;长期观测则需要把探针做成常驻服务。推荐的两种形态:

  • systemd 单元:把 cilium/ebpf 编译出的静态二进制用 systemd 管起来,配置 MemoryDenyWriteExecute=no、授予 CAP_BPF/CAP_SYS_ADMIN 能力,并放开 memlock 限制。
  • Kubernetes DaemonSet:以 privileged(或仅授予 CAP_BPF + CAP_SYS_ADMIN)的 DaemonSet 跑在每个节点,采集节点级内核事件,按 Pod/Namespace 维度打标签后上报 Prometheus/OTel。Cilium、Tetragon、Pixie 都是这个形态。

上线前的检查清单

  1. 内核版本 ≥ 5.8,且 ls /sys/kernel/btf/vmlinux 存在(CO-RE 前提)。
  2. ulimit -l 或 systemd LimitMEMLOCK 已放开。
  3. 探针有“熔断开关”:异常时秒级卸载,绝不拖累业务。
  4. 给 map 设合理 max_entries,并对 hash map 使用 LRU 变体,防止内存无限增长。
  5. 灰度:先在小流量节点跑 24 小时,确认开销与稳定性后再全量。

6.8 排障速查表(保存下来,线上直接抄)

现象先跑的命令
进程莫名卡顿bcc/tools/offcputime -p <pid> 10
网络慢/丢包bpftrace -e 'tracepoint:tcp:tcp_retransmit_skb { @[comm,pid]=count() }'
磁盘 IO 慢bcc/tools/biolatency 1
谁在疯狂读文件bcc/tools/opensnoop
谁在疯狂建连bcc/tools/execsnoop(新进程)/ kprobe:tcp_connect 计数
内存压力tracepoint:vmscan:mm_vmscan_direct_reclaim_begin
CPU 排队bcc/tools/runqlat 5
某函数耗时kprobe/fentry@start/@us=hist()

七、总结与展望

eBPF 不是又一个"可观测性工具",它是内核本身变成了可编程的可观测性平台。它的核心价值可以浓缩成三句话:

  1. 零侵入:不改代码、不重启、不重编译,直接观察内核和应用的最底层行为。
  2. 事件级而非采样级:你抓得到那一次 P99 尾巴上的慢请求,而不是靠运气采样。
  3. 内核内聚合:低开销的本质是把计算下沉到数据产生的地方。

2026 年的生态已经非常成熟,作为一线开发者,你不需要从零造轮子:

  • 即开即用的工具:bpftracebcc-tools(含 execsnoopopensnoopbiolatencyoffcputimetcpretrans 等几十个现成工具),排障时先用它们。
  • 安全可观测性:Cilium Tetragon 基于 eBPF 做运行时威胁检测与阻断,无需改应用。
  • 持续剖析:ParcaPixie 用 eBPF 做零侵入的 CPU/内存持续剖析和自动链路追踪。
  • 云原生网络:Cilium 用 eBPF 直接取代 kube-proxy,性能与可观测性双提升。
  • 标准化:OpenTelemetry 社区已有 eBPF 相关的 exporter/collector 方向,eBPF 正在成为可观测性数据的"第一手源头"。

给普通后端工程师的学习路径建议:

  1. 先装 bpftrace,把它的例程跑一遍,建立"内核事件"的直觉。
  2. bcc-tools 解决实际问题,体会"零侵入排障"的爽感。
  3. libbpf-bootstrap 模板,写出一个 CO-RE 的 eBPF 程序。
  4. cilium/ebpf + bpf2go 把它包成一个能部署的 Go Agent,对接你们现有的 Prometheus/OTel。

当你第一次用一行 bpftrace 命令,在没有改任何代码、没有重启任何服务的情况下,把一个困扰团队三天的延迟毛刺定位到"某次直接内存回收"时,你就会明白:eBPF 不是锦上添花,而是现代后端工程师的"透视眼"。越早装上这双眼睛,你在生产排障时的世界就越清晰。


参考资料与工具:Linux 内核 Documentation/bpf/、Cilium 官方文档、Brendan Gregg 的 eBPF 性能分析工具集、libbpf-bootstrap 仓库、cilium/ebpf Go 库。本文所有代码均在开启 BTF 的现代内核(≥5.8)上验证过核心逻辑。

推荐文章

PHP 微信红包算法
2024-11-17 22:45:34 +0800 CST
php常用的正则表达式
2024-11-19 03:48:35 +0800 CST
程序员茄子在线接单