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 程序运行在一个受约束的虚拟机里,它要进内核,必须先过两道关:
- 验证器(Verifier):在加载时静态分析字节码,证明:
- 程序一定会终止(不允许无界循环,直到 Linux 5.3 才支持有界循环)
- 所有内存访问都在边界内(不能越界读内核内存)
- 不会解引用未初始化的指针
- 只调用白名单内的 helper 函数
- JIT 编译器:验证通过后,eBPF 字节码被即时编译成原生机器码,运行效率接近手写 C。
这带来的直接好处:加载 eBPF 程序不会导致内核崩溃。最坏情况不过是验证失败、加载被拒。这就是为什么 eBPF 能安全地跑在生产内核上,而内核模块一般不敢碰。
2.2 挂载点(Attach Points):你能"挂"在哪里
eBPF 程序的威力来自它能挂在内核的各种事件源上。理解这些挂载点是写对程序的前提:
| 挂载点 | 说明 | 典型用途 |
|---|---|---|
| kprobe | 挂在内核函数入口(动态插桩) | 追踪任意内核函数,如 tcp_connect |
| kretprobe | 挂在内核函数返回处 | 测量函数耗时、获取返回值 |
| uprobe | 挂在用户态函数/二进制地址 | 追踪 malloc、redis 命令、任意应用 |
| 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():操作 Mapbpf_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 | 专用脚本语言 | 临时排障、一行命令、探索 | 极简,开箱即用 | 不适合复杂逻辑和常驻服务 |
| BCC | Python + C | 快速原型、脚本化工具 | 生态成熟,例子多 | 依赖 LLVM 运行时,体积大,启动慢 |
| libbpf + CO-RE | C(编译为 .o) | 生产级、嵌入式、高性能 | 编译一次到处运行,零运行时依赖 | 上手门槛高 |
| cilium/ebpf | Go | 写常驻 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) 的破局点:
- 内核开启
CONFIG_DEBUG_INFO_BTF,编译出一个描述所有内核类型的vmlinux.h(一个巨大的自包含头文件)。 - eBPF 程序编译时用
bpf_core_read()宏,配合 BTF 信息记录"我要读的是task->pid这个语义字段,而不是第 N 个偏移"。 - 加载时,
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;
}
编译(需要 clang 和 bpftool 生成的 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 读 .o → NewCollection 实例化 → 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 都是这个形态。
上线前的检查清单:
- 内核版本 ≥ 5.8,且
ls /sys/kernel/btf/vmlinux存在(CO-RE 前提)。 ulimit -l或 systemdLimitMEMLOCK已放开。- 探针有“熔断开关”:异常时秒级卸载,绝不拖累业务。
- 给 map 设合理
max_entries,并对 hash map 使用 LRU 变体,防止内存无限增长。 - 灰度:先在小流量节点跑 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 不是又一个"可观测性工具",它是内核本身变成了可编程的可观测性平台。它的核心价值可以浓缩成三句话:
- 零侵入:不改代码、不重启、不重编译,直接观察内核和应用的最底层行为。
- 事件级而非采样级:你抓得到那一次 P99 尾巴上的慢请求,而不是靠运气采样。
- 内核内聚合:低开销的本质是把计算下沉到数据产生的地方。
2026 年的生态已经非常成熟,作为一线开发者,你不需要从零造轮子:
- 即开即用的工具:
bpftrace、bcc-tools(含execsnoop、opensnoop、biolatency、offcputime、tcpretrans等几十个现成工具),排障时先用它们。 - 安全可观测性:Cilium Tetragon 基于 eBPF 做运行时威胁检测与阻断,无需改应用。
- 持续剖析:Parca、Pixie 用 eBPF 做零侵入的 CPU/内存持续剖析和自动链路追踪。
- 云原生网络:Cilium 用 eBPF 直接取代 kube-proxy,性能与可观测性双提升。
- 标准化:OpenTelemetry 社区已有 eBPF 相关的 exporter/collector 方向,eBPF 正在成为可观测性数据的"第一手源头"。
给普通后端工程师的学习路径建议:
- 先装
bpftrace,把它的例程跑一遍,建立"内核事件"的直觉。 - 用
bcc-tools解决实际问题,体会"零侵入排障"的爽感。 - 学
libbpf-bootstrap模板,写出一个 CO-RE 的 eBPF 程序。 - 用
cilium/ebpf+bpf2go把它包成一个能部署的 Go Agent,对接你们现有的 Prometheus/OTel。
当你第一次用一行 bpftrace 命令,在没有改任何代码、没有重启任何服务的情况下,把一个困扰团队三天的延迟毛刺定位到"某次直接内存回收"时,你就会明白:eBPF 不是锦上添花,而是现代后端工程师的"透视眼"。越早装上这双眼睛,你在生产排障时的世界就越清晰。
参考资料与工具:Linux 内核 Documentation/bpf/、Cilium 官方文档、Brendan Gregg 的 eBPF 性能分析工具集、libbpf-bootstrap 仓库、cilium/ebpf Go 库。本文所有代码均在开启 BTF 的现代内核(≥5.8)上验证过核心逻辑。