11.7 eBPF 与内核态观测
一句话
eBPF 是内核里一个受验证的虚拟机:C 代码编成 BPF 字节码,验证器静态证明它不会崩内核,再 JIT 成本地指令挂到事件上按事件触发执行——它让「不改内核、不重启、不装模块」的观测成立,代价是512 字节栈、只能调白名单 helper、内核版本与权限门槛,以及观测本身的开销。
先破除六个误区
| 误区 | 现实 |
|---|---|
| eBPF 是「更安全的内核模块」,能改内核行为 | 它是只读观测 + 有限干预:不能任意读内存、不能无限循环、只能调该程序类型允许的 helper。改内核状态必须走 helper(bpf_send_signal、bpf_override_return),不是随便写内存 |
| eBPF 是 KVM 那种虚拟机 | 指令集极简:11 个 64 位寄存器 + 512 字节栈 + 一个 PC,没有 MMU、没有中断、没有文件系统。它只能算一段过滤/统计逻辑,不能跑完整程序 |
| 装上 BCC 就能用 | 还差四样:内核 ≥ 4.9、debugfs 已挂载、BTF(5.2+,CO-RE 必需)、root 或 CAP_BPF/CAP_PERFMON。容器里再叠一层 cgroup 与镜像问题 |
| eBPF 能替代 perf / pprof / strace | 各管一段:pprof 看应用内、perf 看 on-CPU 采样、eBPF 看事件与 off-CPU、strace 看单个进程的 syscall 序列(时序、非聚合)。补盲区,不替代 |
| 用 BCC 写才是最省事的 | BCC 在运行时用 LLVM 编译,还要内核头文件。生产机常常两样都没有——分发要用 libbpf + CO-RE,一次编译到处跑 |
| eBPF 是「旁观者」,不影响被测系统 | 钩子本身就是负载:挂在高频函数或每个 syscall 上,CPU 上升 10%–30% 是常见量级(经验区间,非基准),还要算上 ring buffer 拷贝 |
一条数据流:从 C 到内核里的机器码
eBPF 程序不是「启动后一直跑」,而是编译 → 加载 → 验证 → JIT → 挂到事件 → 事件触发才执行。
hello.bpf.c
│ clang -g -O2 -target bpf -c
▼
BPF 字节码(.o,一个 ELF 文件,含 SEC() 段)
│ bpf(BPF_PROG_LOAD, insns, prog_type) ← 用户态发起
▼
verifier:建 DAG 查不可达指令 → 逐条模拟 → 越界/循环/指针类型全部证明
│ 不通过 → EACCES/EPERM,程序根本不进内核
▼
JIT:编译成本地机器指令(x86/arm64),cache 起来
│ 挂载:perf_event_open + ioctl(PERF_EVENT_IOC_SET_BPF) 或
│ bpf(BPF_LINK_CREATE) / netlink(XDP、tc)/ BPF_PROG_ATTACH(cgroup)
▼
事件发生 → 内核直接调用这段机器码 → 结果写 map / ringbuf → 用户态读走
bpf() 只有三个参数,所有能力都从它出来:
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
cmd | 干什么 | 谁用 |
|---|---|---|
BPF_PROG_LOAD | 把字节码交给内核(含 prog_type、insn_cnt、insns、prog_name) | 所有加载器 |
BPF_MAP_CREATE / BPF_MAP_UPDATE_ELEM / BPF_MAP_LOOKUP_ELEM | 建 map、写 map、读 map | 用户态与 eBPF 程序之间的唯一数据通道 |
BPF_OBJ_PIN / BPF_OBJ_GET | 把 map 或程序钉在 /sys/fs/bpf,跨进程共享 | 常驻 agent、bpftool 加载 |
BPF_PROG_ATTACH / BPF_LINK_CREATE | 挂到 cgroup / 建立可返回的 link | Cilium、运行时安全 |
🔑 加载成功 ≠ 生效。
BPF_PROG_LOAD只把程序放进内核,还得单独一步把它绑到事件上。忘了绑,bpftool prog list能看到程序,却一条数据都不出——这是新手第一个「明明加载了却没输出」。
想确认这四步真的发生了,用 strace 跟一个 BCC 程序(原书实测输出,Ubuntu 21.10 / 5.13.0-22):
sudo strace -v -f ./hello.py 2>&1 | grep -E 'bpf\(|kprobe/type|perf_event_open|PERF_EVENT_IOC_SET_BPF'
# bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_KPROBE, insn_cnt=13, prog_name="hello_world", ...}) = 4
# openat(AT_FDCWD, "/sys/bus/event_source/devices/kprobe/type", O_RDONLY) = 5
# perf_event_open({type=0x6, config1=0x7f275d195c50, ...}, -1, 0, -1, PERF_FLAG_FD_CLOEXEC) = 5
# ioctl(5, PERF_EVENT_IOC_SET_BPF, 4) = 0
type=6 是从 /sys/bus/event_source/devices/kprobe/type 读来的事件编号,config1 里装的是被 hook 的函数地址——kprobe/uprobe 的挂载全部走 perf_event 这套老接口,这也是为什么很多环境要先调 perf_event_paranoid。
虚拟机里有什么:寄存器、栈、helper、map
| 模块 | 规格 | 后果 |
|---|---|---|
| 寄存器 | 11 个 64 位:R0 返回值,R1–R5 调用参数,R6–R9 被调用者保存,R10 只读栈帧 | 函数调用最多 5 个参数、1 个返回值 |
| 栈 | 512 字节,且不能动态增长 | 字符串只能定长截断(如 ARGSIZE 64 × TOTAL_MAX_ARGS 5) |
| 指令数 | 5.2 前上限 4096 条,5.2 起 1,048,576 条 | 老内核写复杂逻辑要拆成多个程序 + tail call |
| 循环 | 5.3 前完全禁止,之后只允许「编译器能证明有界」的循环 | 按数据长度动态循环写不出来,只能固定上界 + 提前退出;老内核用 #pragma unroll 展开 |
| 能否睡眠 | 绝大多数程序类型不允许;5.10 起少数类型可声明 BPF_F_SLEEPABLE(fentry/fexit/LSM) | 不能做磁盘/网络等待,只能打点、聚合、读已有内存 |
| map | 每个程序最多访问 64 个 | 大数据结构必须放 map,不能放栈 |
| helper | 白名单,按程序类型不同 | 想调的内核函数不在表里 = 做不到 |
栈只有 512 字节带来一个非常具体的后果:任何「读一个长度未知的字符串」都得先定上界。
#define ARGSIZE 64 /* 单个参数最多留 64 字节 */
#define TOTAL_MAX_ARGS 5
char argv[TOTAL_MAX_ARGS * ARGSIZE];
int ret = bpf_probe_read_user_str(&data->argv[data->args_size], ARGSIZE, (void *)ptr);
if (ret < 0 || ret > ARGSIZE)
return -1;
data->args_size += (ret - 1); /* helper 返回的长度含结尾 '\0',不减 1 每个参数都会多算一字节 */
⚠️
bpf_probe_read_user_str返回的是含\0的长度。不减 1,拼接出来的 argv 中间就多空一格——这种 bug 在输出里表现为「参数之间莫名多一个空格」,很难往 helper 语义上想。
为什么不能直接解引用指针
eBPF 内部只有寄存器和栈,没有自己的地址空间映射。ctx->args[1] 是个用户态地址,内核态直接读它可能缺页、可能越界、可能读到别的进程的页。所以内核只允许通过 helper 读:
| helper | 读哪 | 典型用法 |
|---|---|---|
bpf_probe_read_user / _str | 用户态内存 | 读 syscall 参数、argv 指针数组 |
bpf_probe_read_kernel / _str | 内核态内存 | 读 sk_buff、task_struct 字段 |
bpf_probe_read(旧) | 两者皆可,5.5 起拆成上面两族 | 老代码兼容 |
bpf_get_current_comm / _pid_tgid / _uid_gid | 当前进程元信息 | 打标签、过滤 |
bpf_ktime_get_ns | 单调时钟(纳秒) | 算耗时 |
bpf_ringbuf_reserve/submit | 事件通道(5.8+) | 首选输出方式 |
bpf_trace_printk | 写 trace_pipe | 只配 demo,生产别用 |
helper 在读取前做边界检查、并禁止缺页中断,失败返回负值——这是「安全」的具体含义,也是你必须检查返回值的理由。
钩子全景:程序类型决定挂哪、能调什么、开销多大
内核 5.13 的 bpf_prog_type 枚举有 30 项(含「未定义」的 UNSPEC),实际常用的就下面这些,按三大类分:
| 类别 | 类型 | 触发位置 | 典型用途 | 开销 | 稳定性 |
|---|---|---|---|---|---|
| 跟踪 | kprobe / kretprobe | 任意内核函数入口/返回 | 函数耗时、参数、调用链 | 中(每调用一次触发) | 差(函数名与参数布局随版本变) |
| 跟踪 | tracepoint | 内核预置稳定点 | 调度、syscall、TCP 事件 | 低 | 好(稳定 ABI) |
| 跟踪 | perf_event / raw_tracepoint | 性能事件、原始跟踪点 | 采样、低层事件 | 低 | 中 |
| 跟踪 | uprobe / uretprobe / USDT | 用户态函数入口/返回、静态探针 | 应用函数耗时、语言 runtime | 中高(断点) | 中(依赖符号与 DWARF) |
| 跟踪 | fentry / fexit(5.5+) | 函数入口/返回(BTF + trampoline) | 比 kprobe 便宜的函数跟踪 | 低 | 中 |
| 网络 | XDP(4.8+) | 驱动刚收包,sk_buff 还没分配 | DDoS 清洗、四层 LB | 极低(可线速、可卸载) | 好 |
| 网络 | sched_cls / sched_act(TC) | 协议栈内、网卡队列收发 | 限速、NAT、容器网络策略 | 低–中 | 好 |
| 网络 | sock_ops / sk_msg / sk_skb | 套接字建连/收发/重定向 | 同机转发、加速 | 低 | 中 |
| 网络 | cgroup_skb / cgroup_sock | cgroup 内所有进程 | 容器级网络管控 | 低 | 好 |
| 其他 | lsm(5.7+) | LSM 钩子 | 运行时安全拦截 | 低 | 中 |
| 其他 | struct_ops / ext | 替换内核函数指针、扩展程序 | 拥塞控制、定制内核行为 | 依实现 | 差 |
查当前内核到底支持哪些,别猜:
sudo bpftool feature probe | grep program_type
# eBPF program_type kprobe is available
# eBPF program_type tracepoint is available
# eBPF program_type xdp is available
# eBPF program_type lsm is NOT available ← 编译配置没开,装了内核也没用
从问题到钩子:五张常见考卷
| 问题 | 用什么钩子 | 为什么是它 |
|---|---|---|
read 到底卡在哪 | kprobe 打 vfs_read 入口/返回 + 耗时直方图 | 只有函数级别能拆出「进入/返回」两段;IO 路径的分层见 11.3 文件系统与 IO 模型 (阻塞 / 非阻塞 / 异步) |
| 进程为什么 CPU 不高但很慢 | off-CPU:sched_switch 进出打点 + 阻塞栈 | 阻塞时不在 CPU 上,采样类工具看不见;调度语义见 11.1 进程、线程与调度 |
| 谁在发大量 TCP 重传 | tracepoint tcp_retransmit_skb + 进程栈 | 重传是事件不是状态,计数器只给总量,给不出发送方 |
| 哪个容器在扫端口 | cgroup 维度统计 connect 失败 | 只有 cgroup 程序能把「一组进程」当过滤维度 |
| 延迟 P99 的尾部在哪 | 内核↔用户态全链路耗时分布(入口/出口各打一枪) | 平均值掩盖尾部,必须看直方图而不是看总量 |
🔑 选钩子的顺序是「事件还是状态 → 有稳定跟踪点吗 → 要不要按容器过滤」,不是「哪个技术更酷」。上表五行覆盖了 90% 的线上排查。
XDP:为什么它最快,代价是什么
| 模式 | 加载写法 | 跑在哪 | 前提 |
|---|---|---|---|
| 通用(skb) | ip link set dev eth0 xdpgeneric obj xdp.o sec xdp | 内核里走通用路径,已经分配了 sk_buff | 任何网卡都能用,测试用 |
| 原生(native) | ip link set dev eth0 xdp obj xdp.o sec xdp | 驱动收包早期,xdp_buff | 驱动支持;容器 veth 通常只能通用模式 |
| 卸载(offload) | ip link set dev eth0 xdpoffload obj xdp.o sec xdp | 网卡固件 | 网卡支持(Netronome 等少数) |
XDP 程序必须返回五个结果码之一,不是「返回 0」:
| 返回码 | 含义 |
|---|---|
XDP_PASS | 交回协议栈正常处理(不关心的包一律走这条) |
XDP_DROP | 当场丢弃(DDoS 清洗、ACL) |
XDP_TX | 从收到它的那块网卡发回去 |
XDP_REDIRECT | 重定向到别的网卡 / CPU / AF_XDP 套接字 |
XDP_ABORTED | 出错丢弃并记录 tracepoint(排错用) |
🔑 XDP 并没有「绕过内核协议栈」,它只是在协议栈之前处理;
XDP_PASS的包照常走完整协议栈。所谓「绕过」是XDP_REDIRECT直接换设备。
代价也很具体:struct xdp_md 只有 6 个 u32(data/data_end/data_meta + ingress_ifindex/rx_queue_index/egress_ifindex),没有 sk_buff,报文要自己按偏移手撕,每次访问前都要 data + 头长 > data_end 越界检查,否则验证器直接拒绝加载。
TC:能看 sk_buff、能双向、不挑网卡
| 维度 | XDP | TC |
|---|---|---|
| 位置 | 驱动收包最早处 | 协议栈内,网卡队列前后 |
| 数据结构 | xdp_buff(原始线性区) | sk_buff(已解析) |
| 方向 | 只收 | 收 + 发 |
| 网卡要求 | 驱动支持(否则退化成通用模式) | 任意网卡,含 veth |
| 能做的事 | 丢、改、转发 | 限速、NAT、策略、重定向 |
# 接收方向:GRO 之后、协议栈处理之前;发送方向:协议栈处理之后、GSO 之前
sudo tc qdisc add dev eth0 clsact
sudo tc filter add dev eth0 ingress bpf da obj tc-example.o sec ingress
sudo tc filter add dev eth0 egress bpf da obj tc-example.o sec egress
一行数据验证「快在哪」:三种转发路径的实测
同一台机器、两个后端容器、wrk -c100 -t2 -d10s(原书实测,Ubuntu 21.10 / 5.13.0-22 / Docker 20.10):
| 方案 | 请求/秒 | 平均延迟 | 相对基线 |
|---|---|---|---|
| Nginx 反向代理(用户态转发) | 13,798 | 7.53 ms | 基线 |
套接字层转发(SOCK_OPS + SK_MSG + SOCKHASH) | 15,301 | 6.88 ms | +10.8%,延迟 −8.6% |
XDP 转发(改包 + XDP_TX) | 18,049 | 6.37 ms | +30.8%(比套接字方案再 +18%) |
差距的来源不是「eBPF 更快」,而是路径更短:用户态转发要把数据搬到 Nginx 再搬回去;套接字转发在内核里直接交给对端套接字;XDP 在 sk_buff 分配之前就把包改好发回去。这条结论的落地形态是 Cilium/Katran 的数据面,网络侧的系统设计见 7.8 网络模型。
编程接口:数据怎么从内核态带出来
eBPF 程序不能 printf、不能 malloc、不能阻塞。所有输出只有三条路:
| 通道 | 机制 | 何时用 | 代价 |
|---|---|---|---|
bpf_trace_printk → trace_pipe | 写内核调试文件 | 只配 Hello World | 极大:每事件一次格式化 + 全局混流,无法按程序区分 |
BPF_MAP_TYPE_PERF_EVENT_ARRAY + bpf_perf_event_output | 每 CPU 一个环形缓冲,用户态轮询 | 老代码、需要每 CPU 语义 | 每 CPU 缓冲 = 内存 × CPU 数;轮询有延迟 |
BPF_MAP_TYPE_RINGBUF(5.8+)+ bpf_ringbuf_output | 单共享环形缓冲,mmap 给用户态 | 新代码首选 | 环满即丢,必须处理 reserve 失败 |
| 普通 map(hash/array/percpu) | 聚合计数、直方图,用户态定期读 | 统计类需求 | 没有逐事件明细 |
map 类型选型(不用记全,记这五个):
| 需求 | map 类型 | 关键点 |
|---|---|---|
| 按 key 聚合(PID、五元组、容器 ID) | HASH | 大小要预估,满了 update 失败 |
| 高频计数、不怕 CPU 间不一致 | PERCPU_HASH / PERCPU_ARRAY | 无锁但内存 × CPU 数,读时要自己求和 |
| 有界数组、索引即 CPU/队列号 | ARRAY | 一次分配,max_entries 固定 |
| 输出事件(新) | RINGBUF | 单缓冲,5.8+ |
| 输出事件(旧) | PERF_EVENT_ARRAY | 每 CPU 一个 |
| 套接字重定向 | SOCKHASH | value 是 int 形式的 socket,不是指针 |
💡
PERCPU_*的取舍本质是缓存局部性:每个 CPU 只写自己那份,不跨核同步;用户态读的时候要逐 CPU 求和。缓存行与 NUMA 的代价见 11.6 CPU Cache 与 NUMA。
map 的生命周期有个反直觉的点:没有「删除 map」的系统调用。map 由用户态的文件描述符持有,close(fd) 即销毁;想让程序退出后还留着(比如给 bpftool 或另一个进程看),得 BPF_OBJ_PIN 钉到 /sys/fs/bpf:
sudo bpftool map create /sys/fs/bpf/stats_map type hash key 2 value 2 entries 8 name stats_map
sudo bpftool map update name stats_map key 0xc1 0xc2 value 0xa1 0xa2
sudo bpftool map dump name stats_map
# key: c1 c2 value: a1 a2
# Found 1 element
rm /sys/fs/bpf/stats_map # 删文件 = 取消 pin = 引用计数归零后销毁
BTF 与 CO-RE:为什么不该再把内核头文件带上生产机
老办法(BCC)要在目标机上现场编译,必须装 linux-headers-$(uname -r)。三个后果:
- 为了一个结构体定义,要
#include一堆内核头文件; - 头文件路径与结构体定义每个内核版本都不同,升级就编不过;
- 生产机出于安全常常不允许装头文件——于是有人在代码里重定义结构体,把错位的字段带进新内核,读出一堆垃圾值。
BTF 解决前两个,CO-RE 解决第三个:
# 内核 5.2+ 且开了 CONFIG_DEBUG_INFO_BTF 时,/sys/kernel/btf/vmlinux 里有完整类型信息
sudo bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
有了 vmlinux.h,程序只 include 它一个;libbpf 在加载时根据目标机的 BTF 重写结构体偏移(CO-RE 的 BPF_CORE_READ / __builtin_preserve_access_index),一份字节码适配多个内核版本。
| 能力 | 最低门槛 | 默认开启的发行版 |
|---|---|---|
| 稳定跑 eBPF | 内核 4.9 | 大部分 2017 年后的发行版 |
| BTF / CO-RE | 内核 5.2 + CONFIG_DEBUG_INFO_BTF=y | Ubuntu 20.10+、Fedora 31+、RHEL 8.2+、Debian 11+ |
| ringbuf | 内核 5.8 | Ubuntu 20.10+ |
| 有界循环 | 内核 5.3 | 同上 |
CAP_BPF(拆权限) | 内核 5.8 | 同上 |
老内核想用 CO-RE,靠 BTFHub (opens new window) 补 BTF——能用,但多一层维护。
三层工具:一行命令 → 一个脚本 → 一个可分发二进制
选哪个工具由「要不要分发、目标机能不能装编译器」决定;工具在整条剖析链路里的位置见 15.9 性能剖析工具链 (火焰图 / perf / eBPF)。
第一层:bpftrace,验证假设用
# 1) 有哪些点可挂?没输出说明 debugfs 没挂或权限不够
sudo bpftrace -l 'tracepoint:syscalls:*' | head -5
sudo bpftrace -l 'tracepoint:syscalls:*' | wc -l # 数百个
# 2) 这个点有哪些字段?(-lv = list + verbose)
sudo bpftrace -lv tracepoint:syscalls:sys_enter_execve
# tracepoint:syscalls:sys_enter_execve
# __syscall_nr: int64
# filename: const char *
# argv: const char *const *
# 3) 谁在 exec 什么?join() 把字符串数组拼成一行
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve,tracepoint:syscalls:sys_enter_execveat
{ printf("%-6d %-8s", pid, comm); join(args.argv); }'
# 157286 zsh ls --color=tty
# 157289 zsh git rev-parse --git-dir
# 4) 块设备 IO 大小分布(等价于 biolatency)
sudo bpftrace -e 'tracepoint:block:block_rq_complete { @kb = hist(args.nr_sector * 512 / 1024); }'
# 5) 用户态:bash 每次执行命令都会调 readline(),返回值才是命令行
sudo bpftrace -e 'uretprobe:/usr/bin/bash:readline { printf("User %d executed \"%s\" command\n", uid, str(retval)); }'
三个语法要点,踩一次就记住:
- tracepoint 有字段名(
args.filename),kprobe 没有,只能用arg0…argN,参数含义得回内核源码查; sys_enter_*/sys_exit_*分别是入口与出口,retval只在kretprobe/uretprobe里可用;- 多个探针用逗号并列,
/条件/是过滤器,@[key]是聚合 map。
第二层:BCC,写复杂一点的脚本
#!/usr/bin/env python3
from bcc import BPF
b = BPF(src_file="hello.c") # BCC 调 LLVM 现场编译 + 加载
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world") # do_sys_openat2 就是 openat 的内核实现
b.trace_print() # 读 /sys/kernel/debug/tracing/trace_pipe
C 侧最小程序(BCC 风格,没有 SEC()、没有 license 行,都由 BCC 代管):
int hello_world(void *ctx)
{
bpf_trace_printk("Hello, World!");
return 0;
}
输出走 trace_pipe,格式是「进程-PID [CPU] 选项 时间戳 函数名 内容」,字段由 /sys/kernel/debug/tracing/trace_options 控制。
⚠️
bpf_trace_printk只配 demo:它每条事件都做一次格式化并写全局 trace 文件,所有 eBPF 程序混在同一个流里。生产要么换 ringbuf,要么换perf_submit。
第三层:libbpf + CO-RE,能分发的工具
内核态(openat.bpf.c):
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
struct event {
__u32 pid;
__u32 uid;
char comm[16];
char fname[128];
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24); /* 16 MB,必须是页大小的整数倍 */
} events SEC(".maps");
char LICENSE[] SEC("license") = "Dual BSD/GPL";
SEC("tracepoint/syscalls/sys_enter_openat")
int tracepoint__syscalls__sys_enter_openat(struct trace_event_raw_sys_enter *ctx)
{
struct event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 0; /* 环满就丢:eBPF 里不能阻塞,丢事件好过拖慢内核 */
e->pid = bpf_get_current_pid_tgid() >> 32; /* 高 32 位是 TGID,即用户态看到的 PID */
e->uid = bpf_get_current_uid_gid();
bpf_get_current_comm(&e->comm, sizeof(e->comm));
/* ctx->args[1] 是用户态指针,绝不能直接解引用 */
bpf_probe_read_user_str(&e->fname, sizeof(e->fname), (void *)ctx->args[1]);
bpf_ringbuf_submit(e, 0);
return 0;
}
编译与脚手架(-target bpf 出字节码,gen skeleton 把字节码塞进头文件):
clang -g -O2 -target bpf -D__TARGET_ARCH_x86_64 -I. -c openat.bpf.c -o openat.bpf.o
sudo bpftool gen skeleton openat.bpf.o > openat.skel.h
用户态:openat_bpf__open_and_load() → openat_bpf__attach() → ring_buffer__new() + ring_buffer__poll()。skel.h 里已经内嵌字节码,最终只分发一个静态链接的二进制(-static -lbpf -lelf -lz),目标机不需要 LLVM,也不需要内核头文件。
⚠️ 加载时报
Failed to load and verify BPF skeleton,八成是目标机没开 BTF——这不是代码问题,是内核编译选项问题。
⚠️ Go 用
cilium/ebpf,Rust 用libbpf-rs/aya:它们只写用户态(加载、挂载、读 map、读 ringbuf),内核态那段 C 仍然要用 clang 编。纯语言实现的库(不依赖 libbpf)功能通常滞后于内核。
内核跟踪实战:从「看不懂栈」到「定位到一行代码」
这是本节最值钱的一段方法,它把「eBPF 能采什么」变成「怎么用采到的东西破案」。原书案例在 Ubuntu 21.10 / 5.13.0-22 上完整跑通:
① 用栈反推链路。 网络问题的起点不是猜函数,而是抓丢包:kfree_skb() 对应异常丢包(consume_skb() 是正常结束)。
sudo bpftrace -e 'kprobe:kfree_skb /comm=="curl"/ { printf("kstack: %s\n", kstack); }'
第一轮输出全是收尾型栈(udpv6_destroy_sock → sk_common_release → inet_release → __sock_release → …),因为同一个函数被多处调用——所以必须再加一层过滤,把范围收窄到目标流量。
② 过滤到具体流量。 从 sk_buff 里取 IP 头(dropwatch.bt):
#include <linux/skbuff.h>
#include <linux/ip.h>
#include <linux/socket.h>
#include <linux/netdevice.h>
kprobe:kfree_skb /comm == "curl"/
{
$skb = (struct sk_buff *)arg0;
$iph = (struct iphdr *)($skb->head + $skb->network_header);
if ($iph->protocol == IPPROTO_TCP) {
printf("SKB dropped: %s->%s\n",
ntop(AF_INET, $iph->saddr), ntop(AF_INET, $iph->daddr));
printf("%s\n", kstack);
}
}
⚠️ 直接跑会报
ERROR: Unknown struct/union: 'struct sk_buff'和Unknown identifier: 'AF_INET'——bpftrace 编译字节码时找不到类型定义。补上那四个#include就好(BTF 之后的新版能省掉)。
③ 读栈定位到链。 造一个丢包现场(iptables -I OUTPUT -d <ip> -p tcp --dport 80 -j DROP),curl 报 Connection timed out after 1000 milliseconds,脚本抓到:
SKB dropped: 192.168.1.129->39.106.233.176
kfree_skb+1
__ip_local_out+219 ← 最后一个调 kfree_skb 的
ip_local_out+29
__ip_queue_xmit+367
__tcp_transmit_skb+2237
tcp_connect+1009
__ip_local_out() 末尾调 nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT, ...)——nf 就是 netfilter,等价于 iptables 的 OUTPUT 链。
④ 用偏移反查源码行。 栈里的 +219 是相对函数起始的偏移:
./scripts/faddr2line /usr/lib/debug/boot/vmlinux-$(uname -r) __ip_local_out+219
# nf_hook at include/linux/netfilter.h:256
# (inlined by) __ip_local_out at net/ipv4/ip_output.c:115
nf_hook 是 static inline,所以它不出现在栈里,偏移落在内联展开处——这就是「栈里少一层」的解释。
⑤ 收尾确认。 sudo iptables -nvL OUTPUT 看到 1 pkts 180 bytes DROP tcp dpt:80,命中自己建的规则,删掉即刻恢复。
这条方法可以复用到任何「不知道东西丢在哪」的场景:先抓末端函数的调用栈 → 按流量过滤 → 用偏移查源码 → 回原机制验证。
容器与安全:eBPF 为什么成了运行时的眼睛
容器共享内核(6.7 容器存储与网络),namespace/cgroups/Capabilities 只划边界,不告诉你边界内发生了什么。eBPF 补的正是这一段:
| 能力 | 手段 | 代表 |
|---|---|---|
| 分析诊断(不改内核、保留现场) | 动态跟踪 + 特征检测 | Tracee |
| 事件监测(不漏偶发) | kprobe/uprobe/tracepoint/USDT 逐事件触发 | Falco |
| 策略执行(实时阻断) | LSM 钩子内联决策、bpf_send_signal()(5.3+)杀进程、XDP 在协议栈前丢包 | KubeArmor、Cilium |
旧机制(AppArmor / Seccomp / Capabilities)的硬伤是策略要预先写:从发现隐患到策略生效有延迟,而攻击者的第一个动作往往就是「在容器里起一个计划外的进程」。所以 execve 是最该盯的点,难点在于区分容器内外——只打 pid + comm 分不清主机还是容器(主机 8964 bash ls,容器 9018 bash ls 长得一样)。
区分靠 task_struct->nsproxy:pid_ns_for_children->ns.inum 是 PID namespace 号,uts_ns->name.nodename 是主机名。
#include <linux/sched.h>
#include <linux/nsproxy.h>
#include <linux/utsname.h>
#include <linux/pid_namespace.h>
实测判别:容器内是 4026532533 + bash,主机上是 4026531836 + ubuntu.localdomain,容器那个号与容器内 lsns 的 pid 行一致。要拦就 signal(9),但必须 bpftrace --unsafe——bpftrace 默认禁止发信号,因为误杀能让整批进程起不来。
容器里的 uprobe 还有一个路径坑:/proc/<pid>/root 指向该进程所在容器的根文件系统,所以探针文件要写宿主视角的路径。
PID=$(docker inspect -f '{{.State.Pid}}' bash) # 拿到容器在宿主上的 PID
sudo bpftrace -l "uprobe:/proc/$PID/root/usr/bin/bash:*"
sudo bpftrace -e "uretprobe:/proc/$PID/root/usr/bin/bash:readline { printf(\"%s\n\", str(retval)); }"
🔑 安全侧的铁律:别让普通用户和普通容器跑 eBPF。收掉
CAP_BPF、CAP_PERFMON、CAP_SYS_ADMIN,否则「观测工具」就是最好的逃逸工具。
生产落地:指名 → 可抄 → 可验 → 可退
⚠️ 实测口径:本机是 macOS(Darwin 24.6 / arm64),没有 Linux 内核、没有 BPF 权限,本节命令没有在本机跑过。标「原书实测」的输出来自原专栏作者在 Ubuntu 21.10 / 内核 5.13.0-22 的真实终端;其余命令给出复现步骤,未跑过的在这里一律不编造输出。
指名:快速验证用 bpftrace(Ubuntu 19.04+、RHEL/CentOS 8 的仓库自带);写复杂脚本用 BCC(0.24+);要分发二进制用 libbpf + CO-RE(libbpf ≥ 1.0);Go 项目用 cilium/ebpf;只做检查与调试用内核自带的 bpftool;开箱的运行时安全用 Tetragon / Falco,网络数据面用 Cilium;常开采集再做持续聚合用 Parca / Pyroscope(链路见 50.8 持续性能剖析)。内核基线定在 5.10 或 5.15 LTS(4.9 能跑,但 BTF/CO-RE/ringbuf 都没有,工具链会一路卡)。
可抄(Ubuntu 20.10+):
sudo apt-get update
sudo apt-get install -y clang llvm libelf-dev libbpf-dev bpftool \
bpfcc-tools libbpfcc-dev bpftrace \
linux-tools-$(uname -r) linux-headers-$(uname -r)
可验(三条命令,按顺序失败定位):
# ① debugfs 挂了吗?eBPF 的跟踪点与 trace 输出都依赖它
mount | grep -E 'debugfs|tracefs'
# debugfs on /sys/kernel/debug type debugfs (rw,relatime)
# ② 内核支持哪些程序类型?(原书实测输出格式)
sudo bpftool feature probe | grep program_type | head -3
# eBPF program_type socket_filter is available
# eBPF program_type kprobe is available
# eBPF program_type tracepoint is available
# ③ 真挂一个探针,5 秒内应该有输出
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("%s %s\n", comm, str(args.filename)); }'
# Attaching 1 probe...
# bash /usr/bin/ls
可退:
| 对象 | 卸载方式 | 注意 |
|---|---|---|
| bpftrace / BCC | Ctrl-C,程序随进程退出卸载 | 别在生产 shell 里裸跑忘关;加 -c 'sleep 10' 或包 timeout 10 |
| XDP | sudo ip link set dev eth0 xdp off | 加错网卡会断网,先在容器/测试机验证 |
| TC | sudo tc filter del dev eth0 ingress + sudo tc qdisc del dev eth0 clsact | 顺序反了会报 No such file |
| 固定程序/映射 | rm /sys/fs/bpf/<name> | 引用计数归零后内核自动回收,bpftool 没有 unload 子命令 |
| 观测放大 | 采样(profile:hz:99)、按 PID/cgroup 过滤、限时 | 先记基线,再对比,别拿两次不同负载的图下结论 |
容器里的权限(挂不上的第一原因):
| 需求 | 最小配置 |
|---|---|
| kprobe / tracepoint / perf event | --cap-add BPF,PERFMON(5.8+ 拆分后的能力;旧内核仍是 SYS_ADMIN) |
| 挂 tc / XDP | --cap-add NET_ADMIN |
| 读跟踪点与 trace 输出 | -v /sys/kernel/debug:/sys/kernel/debug:ro |
| 跨进程共享程序/map | -v /sys/fs/bpf:/sys/fs/bpf |
| 非 root 用 perf_event_open | 宿主 sysctl kernel.perf_event_paranoid=1(值 ≥ 2 时非 root 被拒,发行版常设 2–4) |
--privileged 能一把过,但它等于把主机交给容器:观测工具反而成了逃逸面。
反模式
| 反模式 | 后果 |
|---|---|
生产上随手把 kprobe 挂到 vfs_read/tcp_sendmsg 这类热点 | CPU 上升 10%–30%,且很难定位到是探针导致 |
| 不采样、不限流、全量统计 | ring buffer / perf buffer 溢出丢事件,统计值偏低还被当成业务变化 |
用 bpf_trace_printk 当生产日志 | 每条事件一次格式化和全局文件写,开销可能比采集本身还大 |
| 依赖未导出符号或具体参数布局的 kprobe | 内核一升级就挂不上,或者读到错位数据而不报错 |
| 把「BCC + 运行时编译 + 内核头文件」带上生产机 | 生产禁止装 headers,程序直接起不来;改成 libbpf + CO-RE |
容器里图省事 --privileged | 观测面变成逃逸面,等于把主机权限交给容器 |
裸跑 bpftrace -e ... 挂在生产 shell,没有超时 | 忘了关就跑一整夜,第二天背一口性能锅 |
| 拿 eBPF 当业务逻辑(鉴权、限流、ACL 的唯一实现) | 验证器限制 + 没有单测 + 内核升级即失效,改不动也回不了滚 |
| 只采 CPU 火焰图就断言「服务不慢」 | profile:hz 只看得见 on-CPU,看不见等锁/等 IO/等下游的 off-CPU 时间 |
arm64 机器上照抄 -D__TARGET_ARCH_x86_64 | 结构体偏移与寄存器取参全错,加载能过、数据全乱 |
三条铁律
🔑 优先 tracepoint / fentry,其次 kprobe。前者是稳定接口(有字段名、跨版本可用),后者依赖函数名与参数布局,升级即坏。
🔑 先定「看完这条数据要做什么决策」,再选钩子。观测是生产负载:采样率、过滤条件、退出时间三件事没写清楚,不要上生产。
🔑 新工具用 libbpf + CO-RE 编译分发,别把 LLVM 和内核头文件带上生产机。BCC 留在开发机上快查。
自检
Q1 bpftool prog list 能看到程序、编号也在,但一条数据都不出。最可能漏了哪一步?
▸ 答案:漏了挂载。BPF_PROG_LOAD 只把字节码交给内核并验证,程序还在等事件;要再走 perf_event_open + ioctl(PERF_EVENT_IOC_SET_BPF)(kprobe/uprobe)、BPF_PROG_ATTACH(cgroup)、netlink(XDP/tc)或 BPF_LINK_CREATE 才会被事件触发。判断方法:bpftool prog show id N 看 run_time/run_cnt 是否为 0——注意 5.8+ 这两个统计要先 BPF_ENABLE_STATS 打开,否则它一直是 0。
Q2 为什么 eBPF 程序不能直接解引用 ctx->args[1],必须走 bpf_probe_read_user_str?
▸ 答案:eBPF 只有寄存器和 512 字节栈,没有自己的地址空间映射;直接读用户地址可能缺页、越界、读到别人的页。helper 会做边界检查、禁止缺页中断,失败返回负值。这也解释了为什么「读未知长度字符串」必须先定上界再截断。
Q3 内核 5.3 之前不能写循环,官方推荐的绕法是什么?为什么这限制了「用 eBPF 写业务逻辑」?
▸ 答案:验证器必须静态证明程序会终止,所以只能有编译器能证明上界的循环;5.3 前完全禁止,只能用 #pragma unroll 让编译器展开成直线代码。业务逻辑天然有「按数据长度循环」,展开不了、栈也只有 512 字节,所以才说 eBPF 适合「观测 + 轻量拦截」,不适合当通用运行时。
Q4 生产机不允许安装 linux-headers,为什么仍然能跑 eBPF 观测?
▸ 答案:靠 BTF + CO-RE。内核 5.2+ 开了 CONFIG_DEBUG_INFO_BTF 后,类型信息就在 /sys/kernel/btf/vmlinux,用 bpftool btf dump ... format c 生成 vmlinux.h;libbpf 在加载时按目标机 BTF 重写结构体偏移。字节码随二进制分发,目标机既不需要 LLVM,也不需要头文件。老内核靠 BTFHub 补 BTF。
Q5 服务「CPU 不高但请求很慢」,用 eBPF 该采什么?为什么 CPU 火焰图答不了?
▸ 答案:采 off-CPU:在 sched_switch 的进出(或 offcputime、runqlat)打点,统计「任务不在 CPU 上」的时间并带上阻塞栈,能区分等锁、等 IO、等网络、等调度。profile:hz:99 这类采样只取正在执行的栈,进程阻塞时根本不采样,所以在图上「看起来不忙」——这恰恰是最常见的误判。
Q6 同一台机器,为什么 XDP 转发比套接字转发再快一截?代价是什么?
▸ 答案:XDP 在驱动刚收包、sk_buff 还没分配时就处理,连协议栈的一次完整遍历都省了;套接字转发虽然也在内核里,但仍要进到套接字层。代价:XDP 只有 xdp_md(6 个 u32),报文得自己按偏移解析且每次访问都要越界检查;发送方向不支持(只能收);需要驱动支持,容器 veth 通常只能退化成通用模式;改包必须重算 checksum。
Q7 bpftrace 脚本报 ERROR: Unknown struct/union: 'struct sk_buff',问题出在哪?
▸ 答案:编译期没有类型定义,不是语法错。bpftrace 要把脚本编成字节码,结构体字段偏移必须在编译时确定;BTF 之前只能靠 #include <linux/skbuff.h> 等头文件提供。新版 bpftrace(0.9.3+ 起支持 BTF)配合 CO-RE 可以省掉 include,但旧环境仍要显式引入。
Q8 容器里跑观测,kprobe 和 uprobe 的行为差异在哪?哪个更容易「什么都没采到」?
▸ 答案:kprobe 挂在全局内核函数上,所有进程(含所有容器)都会命中,容易采多;uprobe 按文件插桩,命中同一二进制的所有进程,要区分容器必须按 PID/PID namespace 过滤,且容器内路径要看 /proc/<pid>/root。真正「一条都没有」通常不是钩子问题,而是缺 CAP_BPF/CAP_PERFMON、debugfs 没挂进容器、或 perf_event_paranoid 太严。