2024年,CNCF报告显示eBPF相关项目贡献者增长超300%,超过一半的云原生可观测性产品底层都换上了eBPF引擎。我上个月在生产集群里把Prometheus Node Exporter换成Cilium Hubble那一套,CPU开销直接从8%降到了不到1%——这数字让我自己都愣了。

传统可观测性到底卡在哪里

老派监控无非两条路:内核模块和旁路采集。内核模块一崩全系统跟着挂,写个Hello World都得从头编译内核;旁路采集靠轮询/proc文件系统,每次read都是一次上下文切换。我见过一个高频交易系统因为监控探针每秒扫300次/proc/net/tcp,硬生生把延迟从50μs拖到200μs。

更恶心的是动态插桩。想在生产环境加个追踪点?对不起,重启服务、重新编译、重新部署。Dtrace算是救过场,但那是Solaris亲儿子,Linux上永远是二等公民。

问题的根儿其实就一个:内核空间和用户空间之间有堵墙,你既想窥探内核的隐私又不想把它搞崩,这在eBPF之前基本是鱼与熊掌。

eBPF怎么做到"零侵入又零风险"

eBPF全称extended Berkeley Packet Filter,本质是在内核里塞了一个安全的虚拟机。你写的C代码会被LLVM编译成eBPF字节码,加载前先过一遍verifier——它会像编译器一样做静态分析,确保你的程序不会死循环、不会越界访问、不会把内核搞挂。

真正骚的是它的挂载点机制。eBPF程序可以挂到kprobe、tracepoint、uprobe、XDP、TC等上百个hook上。哪怕是一个正在运行的进程,你也能动态地把探针插到某个函数入口,不需要重启、不需要改一行代码、不需要加载内核模块。

它的运行模型也很有意思:eBPF程序本身不能阻塞、不能调用任意内核函数,只能通过helper函数和外界交互。BPF map是它和用户态共享数据的通道,本质上就是个驻留在内核的键值存储,支持哈希表、数组、环形缓冲区等多种数据结构。

从零写一个TCP重传监控工具

理论说再多不如跑一段。我们用bcc(BPF Compiler Collection)写一个统计TCP重传的工具,Python绑C,50行代码搞定。

先装依赖(测试环境:Ubuntu 22.04, Linux 5.15, bcc 0.28):

sudo apt install bpfcc-tools linux-headers-$(uname -r)

bcc最爽的地方是把eBPF的C代码直接嵌在Python字符串里,编译加载一气呵成:

#!/usr/bin/env python3
from bcc import BPF
import time

# eBPF程序,C语言编写,编译后注入内核
bpf_code = """
#include <uapi/linux/ptrace.h>
#include <net/sock.h>
#include <bcc/proto.h>

// BPF map:用哈希表按进程名聚合重传次数
BPF_HASH(retrans_count, u32, u64);

// 挂到内核tcp_retransmit_skb函数入口
int trace_retransmit(struct pt_regs *ctx) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    u64 *val, zero = 0;

    val = retrans_count.lookup_or_init(&pid, &zero);
    if (val) {
        (*val)++;
    }
    return 0;
}
"""

# 加载eBPF程序,绑定kprobe
b = BPF(text=bpf_code)
b.attach_kprobe(event="tcp_retransmit_skb", fn_name="trace_retransmit")

print("正在监控TCP重传... Ctrl+C 退出\n")
print(f"{'PID':<8} {'重传次数':<12} {'进程名'}")
print("-" * 45)

while True:
    try:
        time.sleep(2)
        for pid, count in b["retrans_count"].items():
            try:
                comm = open(f"/proc/{pid.value}/comm").read().strip()
            except:
                comm = "unknown"
            print(f"{pid.value:<8} {count.value:<12} {comm}")
        b["retrans_count"].clear()
    except KeyboardInterrupt:
        print("\n监控已停止")
        break

跑起来后随便传个大文件触发几次丢包,终端就会实时打印各进程的TCP重传次数。全程没有改任何应用代码,没有重启服务,verifier在加载时已经帮你检查过安全性。

核心要点三个:attach_kprobe把函数挂到内核函数入口;BPF_HASH声明一个在内核和用户态之间共享的哈希表;lookup_or_init保证原子性,高并发下不会竞态。

跟传统方案硬碰硬比一比

我在同一台云服务器上跑了对比测试(2核4G,Ubuntu 22.04,内核5.15,压测用iperf3模拟10%丢包率):

指标 eBPF (bcc) systemtap Node Exporter
CPU开销 0.3% 2.1% 7.8%
内存占用 8MB 47MB 120MB+
部署方式 一行命令加载 需安装内核调试符号 独立daemon进程
动态插桩 支持 支持 不支持
安全隔离 verifier强校验 依赖开发者水平 N/A
内核版本要求 ≥4.4 需要debuginfo 无限制

eBPF的安全模型是它跟systemtap拉开差距的根本原因。systemtap是把内核模块生成逻辑交给了用户自己,写崩了就是panic;eBPF的verifier在加载阶段就做了DAG可达性分析、寄存器状态追踪、边界检查,理论上不可能出现内核崩溃。

Node Exporter属于传统旁路方案,虽然稳定但代价摆在那里——轮询意味着固定开销,哪怕系统闲时也在空转。eBPF是事件驱动的,只有事件触发才执行,天然低开销。

踩过的坑和最佳实践

先说几个我真金白银踩出来的坑。

第一,eBPF程序有指令数上限。老内核(5.1之前)最多4096条指令,复杂逻辑根本写不下。现代内核放宽到100万条,但verifier复杂度随之飙升,加载一个千行级别的eBPF程序可能要等好几秒。

第二,helper函数不是随便调的。每个helper都有白名单,比如bpf_get_current_comm()在socket filter类型的eBPF程序里根本不可用。这坑我查了一下午才发现是程序类型不对。

第三,不要在生产环境直接用bcc。bcc依赖LLVM/Clang运行时编译,一套下来几百MB。正确姿势是用libbpf + CO-RE(Compile Once Run Everywhere),一次编译到处运行,依赖降到几十KB。

最佳实践三板斧:能用tracepoint就不用kprobe,因为tracepoint是内核的稳定ABI承诺,kprobe在版本之间可能改名;尽量用per-CPU map避免加锁;一定要用bpftool检查你的eBPF程序到底挂载成功了没——bpftool prog list看一眼,比什么都靠谱。

eBPF从2014年合并进内核到现在十几年年,从最初的一个网络包过滤器进化成了内核可编程的操作系统。它的三个开源项目——Cilium(网络)、Falco(安全)、Pixie(可观测性)——已经成了CNCF的毕业和孵化项目。你手头的监控工具,底层是不是也该换一换了?

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐