内核里跑了个沙盒虚拟机?eBPF凭什么3年干翻30年监控史
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的毕业和孵化项目。你手头的监控工具,底层是不是也该换一换了?
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)