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)。三个后果:

  1. 为了一个结构体定义,要 #include 一堆内核头文件;
  2. 头文件路径与结构体定义每个内核版本都不同,升级就编不过;
  3. 生产机出于安全常常不允许装头文件——于是有人在代码里重定义结构体,把错位的字段带进新内核,读出一堆垃圾值。

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 太严。

关联