原书:BPF Performance Tools · Brendan Gregg · Addison-Wesley · 2019
中译:人民邮电出版社
篇幅:880 页,17 章 + 附录
这本书不是入门教程,也不是内核源码解析。它的核心问题只有一个:系统出了问题,你到底能看多深?答案是:有了 BPF,几乎没有看不到的地方——前提是你知道该怎么看。

一、为什么需要 BPF
传统工具有三个根本性的架构局限,不是实现质量问题,是设计问题:
- 静态接口:/proc、vmstat 只能看到内核开发者预先决定要暴露的数据,遇到新问题没有选择。
- 开销致命:strace 用 ptrace 实现,每个系统调用产生两次上下文切换,高频场景开销放大 10~100 倍,生产环境根本不能用。
- 观测断层:用户态和内核态之间没有工具能打通,"这条 SQL 为什么慢"这种跨层问题,传统工具各看一层,无法串联。
eBPF 的解法:在不修改内核、不重启系统的前提下,把自定义分析逻辑动态注入内核任意位置,以原生机器码执行,开销极低。
eBPF 是唯一选项的场景
- 生产环境偶发毛刺:不能开 strace,perf 采样到问题瞬间的概率极低,只有 eBPF 能在正常时低开销运行、问题发生时自动记录详细信息。
- 性能回归但找不到热点:20ms 消失在 Off-CPU 时间里,On-CPU profiling 什么都看不到,差分火焰图是唯一答案。
- 容器资源争用:单个容器视角看不到宿主机全局,eBPF 在宿主机内核层同时观测所有容器。
eBPF 的开销模型
总开销 = 触发频率 × 单次执行时间
每秒几百次(TCP 连接、数据库查询)→ 开销可忽略
每秒数十万次(malloc、网络包) → 累积可能达到 5%~10% CPU
解法:内核态做最大化过滤,使用采样而非全量追踪。
二、eBPF 架构:核心组件
eBPF 程序的生命周期
编写(受限 C 或 bpftrace)
→ 编译成 BPF 字节码
→ Verifier 静态检查
→ JIT 编译成原生机器码
→ Attach 到事件源
→ 事件触发时在内核原地执行(无上下文切换)
安全由 Verifier 保证,性能由 JIT 保证,功能边界由 helper 函数集合定义。
Verifier:保守的静态分析
Verifier 用"抽象解释"枚举所有执行路径,追踪每个寄存器的类型、值域和来源。它是保守的——无法在有限步骤内证明安全就拒绝,即使逻辑上显然安全也会拒绝。
遇到 Verifier 拒绝,不要和它"争论",而是改写代码让它能证明安全:显式加 NULL 检查、显式加边界检查、把复杂指针算术拆成简单步骤。
10 个独立 if-else 分支就有 1024 条路径,"小但分支复杂"的程序比"大但结构简单"的程序更难通过 Verifier。
eBPF Maps:选型决定性能
- HASH:有锁,高并发同一 key 会竞争 → 用 PERCPU_HASH 替代(无锁,用户态汇总)
- ARRAY:无锁原子操作,适合固定维度计数器
- RINGBUF(5.8+):高频事件传输首选,比 PERF_EVENT_ARRAY 内存效率更高,且保证顺序
- PROG_ARRAY:存放其他 BPF 程序,用 tail_call 实现链式调用,绕过指令数限制
Helper 函数关键细节
bpf_probe_read_kernel/bpf_probe_read_user:带异常处理的安全读取,地址无效不会 panic,两者不能混用(内核地址和用户地址 page table 不同)。bpf_get_stackid:调用栈采集开销最高,在高频事件上使用比简单计数高 10 倍以上。
BTF 与 CO-RE
传统 BCC 程序需要在目标机器实时编译,必须安装 kernel headers + LLVM,300 台机器等于编译 300 次。
BTF 把内核类型信息嵌入内核镜像,CO-RE 程序提前编译,加载时 libbpf 自动调整字段偏移量,一个二进制可以在不同内核版本正确运行。这是 eBPF 工具链从"实验性"走向"生产可用"的关键。
三、探针体系
稳定性谱系(从低到高)
kprobe → uprobe → USDT → Tracepoint → PMU 硬件事件
生产环境长期监控用 Tracepoint 和 USDT,临时诊断用 kprobe/uprobe,微架构分析用 PMU。
kprobe:实现机制与两个陷阱
机制:把目标函数第一条指令替换成 int3 断点,触发时调用 eBPF 程序,执行完恢复原指令。
- 陷阱一:内联函数无法追踪。编译器内联后没有独立函数入口,是根本限制。
- 陷阱二:kretprobe 有 maxactive 限制,高并发函数需要调大,否则记录为 missed。
Tracepoint:静态跳转机制
未激活时是一条 nop(约 0.3ns),激活时通过 jump label 替换成 jmp。比 kprobe 更高效(不走 int3 中断路径),且有稳定性承诺。
block 三件套配合使用可完整追踪 I/O 生命周期:
block_rq_insert
→ block_rq_issue(之差 = 队列等待)
→ block_rq_complete(之差 = 设备处理)
USDT:semaphore 机制是核心
探针未激活时是 NOP,激活时 semaphore 加 1,应用程序检查 semaphore 大于 0 才执行参数准备代码。这保证了未激活时完全零开销,可以放在高频路径上。
PMU:IPC 是判断 CPU 效率的核心指标
IPC(每时钟周期执行指令数):
- IPC 接近 3~4:CPU 在高效计算
- IPC 低于 1:大量时钟周期在等待
等什么:LLC 即末级缓存缺失(需等约 300 个时钟周期)、分支预测失败(损失 15~20 个周期)、数据依赖停顿。
CPU 利用率 80% + IPC 低于 1:加 CPU 无效,根本问题是内存访问模式(数据局部性差)。
四、工具选择:bpftrace 与 BCC
决策框架
临时诊断 → bpftrace(启动快,语法简洁)
需要调用栈 + 火焰图 → bpftrace + flamegraph.pl
复杂用户态处理或写入数据库 → BCC 或 libbpf
生产环境分发 → libbpf + CO-RE(避免安装 LLVM)
bpftrace 变量语义:混淆会静默产生错误数据
@name:存在 BPF Map 中,跨事件跨 CPU 持久。$name:存在栈上,事件结束后消失,512 字节上限。@name[key]:关联数组,以 key 分组。
追踪函数延迟的标准模式:
kprobe:sys_read {
@start[tid] = nsecs; // 必须用 tid,不能用 pid(多线程会覆盖)
}
kretprobe:sys_read /@start[tid]/ {
@lat = hist(nsecs - @start[tid]);
delete(@start[tid]); // 必须清理,否则 Map 无限增长
}
过滤器在内核态执行
/condition/ 在内核态判断,不满足则直接跳过,不产生用户态开销。把过滤条件写进过滤器而不是在 action 里 if 判断,在高频事件上差异显著。
BCC 两层架构原则
- C 层(内核态):做最小化数据收集,过滤事件,受 Verifier 约束
- Python 层(用户态):做数据解释和复杂处理,无限制
把复杂逻辑塞进 C 层是常见设计错误。
BCC 的根本问题
需要在目标机器实时编译,必须安装 kernel headers + LLVM。CO-RE 是长期替代方向,新项目优先用 libbpf + CO-RE。
五、系统性能分析方法论
性能分析是科学过程
先提出假设,再选择工具。不是先跑一堆工具再从数据里找模式——数据里总能找到看起来异常的东西,没有假设框架无法区分根因和表象。
四种常见认知偏差
- 路灯效应:只测容易测的地方(CPU 利用率),忽视证据指向的地方。USE 方法系统性对抗这个偏差。
- 确认偏差:只寻找支持已有假设的证据。主动寻找能否定假设的证据。
- 近期偏差:把最近的变更当首要嫌疑人。问自己:排除最近变更,还有什么其他可能原因?
- 相关性与因果性混淆:CPU 高和磁盘 I/O 高同时出现,不代表前者导致后者。用时间序列判断哪个先升高。
USE 方法
对每个资源(CPU、内存、磁盘、网络)检查三个维度:Utilization(利用率)、Saturation(饱和度/队列长度)、Errors(错误数)。
执行顺序:先扫描错误 → 再检查饱和度 → 最后看利用率。利用率高是症状,不一定是根因。
延迟分析:永远看分布,不看平均值
p99 通常比 p50 高出一个数量级,平均值在两者之间,代表不了任何人的真实体验。
直方图形态含义:
- 双峰 → 两类性质不同的请求(如 page cache 命中 vs 未命中)
- 均匀分布 → 等待随机时间的资源(如 HDD 旋转等待)
60 秒快速扫描流程
uptime
dmesg -T | tail
vmstat 1 5
mpstat -P ALL 1
iostat -xz 1
ss -s
扫描结束后确认资源瓶颈,再用 eBPF 深挖,不要跳过这步直接用 eBPF 工具乱扫。
六、CPU 性能分析
CFS 调度器:理解 vruntime
CFS 总是调度 vruntime 最小的进程。高优先级进程(nice 值小)的 vruntime 推进慢,因此更频繁被调度。CPU 不饱和时 nice 值没有任何效果,只有 CPU 饱和时才有意义。
CPU 时间六种状态的诊断含义
- user 高 → On-CPU profiling
- sys 高 → 系统调用频率过高(syscount 工具)
- softirq 高 → 网络流量大,检查网卡中断是否均衡到多个 CPU(RSS/RPS)
- hardirq 高 → 某设备产生异常高频中断
- iowait → I/O 压力的充分不必要条件:看到 iowait 高说明有 I/O 等待,但看不到 iowait 高不能说明没有 I/O 等待(其他 CPU 都在忙时 iowait 显示为 0)
- steal(%st)高 → 宿主机超卖 CPU,虚拟机内的任何 CPU 优化都无效
调度延迟的三类来源
所有 CPU 都在忙:等待时间 ≈ 时间片 × (可运行任务数 / CPU 核数 - 1)
CPU 亲和性限制(容器化环境特别常见):cgroup CPU throttling——容器配额耗尽后即使宿主机有空闲 CPU 也必须等待,这是"调度延迟高但 CPU 整体利用率不高"的容器化特有根因。诊断:看 /sys/fs/cgroup/.../cpu.stat 的 nr_throttled / nr_periods 比例。
NUMA 负载均衡失败:调度器迁移任务带来的内存访问变远,权衡可能做错。
On-CPU Profiling 的采样局限
采样频率 99Hz 而非 100Hz:避免与内核 100Hz 定时器产生共振(采样偏向周期性工作)。
火焰图正确阅读:平顶宽帧是热点(CPU 时间终点),窄高塔不是热点(只是调用链中间节点),横轴是字母序不是时间序,颜色无含义(差分火焰图除外)。
Off-CPU 分析
On-CPU 时间 + Off-CPU 时间 = 总墙上时间。服务延迟 100ms 但 On-CPU 只有 10ms,另外 90ms 在 Off-CPU 里——这时 On-CPU profiling 什么都看不到,需要 offcputime。
Off-CPU 调用栈里包含 io_schedule → I/O 等待;包含 mutex 相关 → 锁等待;包含 try_to_free_pages → 内存直接回收(延迟毛刺的常见根因)。
硬件计数器:IPC 低的三类原因
- LLC 缺失(最常见):每次需等 ~300 个时钟周期,内存密集型随机访问 IPC 只有 0.3~0.5
- 分支预测失败:每次冲刷流水线损失 15~20 个周期
- 数据依赖停顿:前一条指令结果还没出来,后续指令无法执行
虚拟化环境的特殊问题
offcputime 无法区分"任务主动阻塞"和"vCPU 被宿主机抢占",两者都是 Off-CPU 时间。先看 %st(steal time),不为零时 Off-CPU 分析结果不可完全信任。
虚拟机的 NUMA 拓扑信息可能是"虚假的本地内存"——vCPU 映射到宿主机不同 NUMA 节点,但虚拟机看到的都显示为本地内存。
七、内存性能分析
先建立正确的内存模型
free 显示"空闲内存"接近零完全正常——Linux 把空闲内存都用作 page cache。真正的可用内存看 /proc/meminfo 的 MemAvailable。
VSZ(虚拟地址空间)不代表物理内存占用,RSS 包含共享库(所有进程 RSS 加起来会超过物理内存),准确的独占内存是 PSS(/proc/PID/smaps)。
三条水位线:压力从何时开始
WMARK_HIGH→ kswapd 休眠,无回收WMARK_LOW→ 唤醒 kswapd 后台异步回收,对应用几乎无感WMARK_MIN→ direct reclaim(直接回收):分配调用方自己负责回收,分配被阻塞数十毫秒
直接回收是延迟毛刺的根本原因。回收期间可能持有内核锁,其他线程也被阻塞,一个内存问题导致整个服务实例的大面积延迟。核心原则:永远不要让系统触及 WMARK_MIN。
swappiness 的常见误解
swappiness=0 不是禁止 swap,而是"优先回收文件页"。内存真的不够时仍然可能使用 swap。真正禁止 swap 需要 swapoff -a。
Redis 关掉 swap 而非仅设低 swappiness:数据被 swap 到磁盘后延迟从微秒跳到毫秒,与其带病运行不如让 OOM killer 杀掉后快速重启。
三类内存问题的快速区分
- RSS 持续单调增长 → 内存泄漏
- 总空闲内存有但大内存分配失败 → 内存碎片
- swap 活跃 + kswapd 常驻 + I/O 升高 → 内存压力
内存压力的恶性循环
内存不足
→ kswapd 驱逐 page cache
→ 再次访问文件需从磁盘读
→ 磁盘 I/O 升高
→ iowait 升高
→ 吞吐量下降
→ 请求堆积
→ 内存占用继续增加
最终表现可能是磁盘忙,很多人去查磁盘,根因是内存不足。
完整信号链:si/so 不为零 → page cache 命中率下降 → 磁盘 I/O 升高但无明显业务 I/O 增加 → 大量 major page fault → 结论:加内存。
free() 不等于归还操作系统
glibc malloc 在 free() 后把内存标记为"可重用",不立刻归还内核,进程 RSS 不会下降,这经常被误判为内存泄漏。区分方法:真正泄漏的 RSS 持续单调增长;分配器保留的 RSS 稳定不再增长。
使用 jemalloc(Redis 默认)分析内存时,应该用 jemalloc 自带的 heap profiling,不能用通用的 eBPF 追踪 malloc()。
NUMA:CPU 利用率和内存都正常但延迟高
访问本地内存约 60~100ns,访问远端内存约 150~300ns,差 2~3 倍。numastat -p PID 看进程内存分布,numactl --membind 强制绑定到同一 NUMA 节点。
透明大页的隐藏代价
- CoW 代价放大 512 倍:fork() 时复制一个 2MB 大页比 4KB 小页高 512 倍,Redis 的 BGSAVE 大量 fork,导致内存消耗翻倍和延迟毛刺。
- 碎片化压缩开销:申请 2MB 大页需要 512 个连续物理页,内存碎片化后 kcompactd 持续消耗 CPU。
生产建议:Redis/Memcached 明确关闭;数据库设为 madvise 模式;Java 用静态大页(-XX:+UseHugeTLBFS);不频繁 fork 的计算密集型应用可以开启。
OOM Killer:是最后手段不是运维工具
频繁触发 OOM 说明系统设计有问题。应该在 WMARK_LOW 阶段(kswapd 活跃、swap 使用率上升)就采取行动,而不是等 OOM 触发。
oom_score_adj 设为 -1000 让关键服务免疫,正数让不重要进程优先被杀。
slab 内存:内核内存泄漏的常见藏身处
slab 缓存不在普通进程内存统计里,且在常规版本的 top 和 free 输出中被隐形合并(通常塞进 used 或 buff/cache 中)。大量容器会导致 nf_conntrack 内存暴涨(每个 network namespace 有独立的 conntrack 缓存)。用 slabtop 实时查看各 slab 缓存大小。
八、文件系统性能分析
最重要的认知:逻辑 I/O 与物理 I/O 是两件事
read()不一定发生磁盘 I/O(可能在 page cache)write()几乎一定不立刻发生磁盘 I/O(写入 page cache 就返回)fsync()才会强制触发磁盘 I/O
VFS 层延迟高而块设备层延迟正常 → 问题在文件系统或 VFS 层;两层延迟都高 → 问题在磁盘硬件。
page cache 的三个关键点
命中率是动态的:刚重启 cache cold,读延迟接近磁盘;预热后延迟降低 100 倍。重启后短时间性能差是正常现象,不是服务本身的问题。
内存压力会驱逐 page cache:文件系统变慢的根因可能是内存不足。排查时同时看 /proc/meminfo 的 Cached 字段和 kswapd 活跃程度。
大文件顺序读会污染 page cache:备份操作会驱逐其他服务的热点数据。解法是 O_DIRECT 或 posix_fadvise(POSIX_FADV_DONTNEED)。
写语义的三种选择
write():微秒级,断电可能丢失write()+fsync():HDD 约 10ms,SSD 约 100μs,断电不丢失O_SYNC模式:效果同上,但按每次 write() 粒度落盘
数据库写性能瓶颈几乎都在 fsync 上,追踪 fsync 延迟分布是诊断数据库写问题的第一步。
元数据操作:被忽视的延迟来源
单目录下百万个文件时,遍历目录(getdents64)代价极高,还会污染 dentry cache。解法是按哈希值拆分目录,不是调优内核参数。
ext4 三种日志模式
- writeback(性能最高)→ ordered(默认,崩溃后文件内容一致)→ journal(最安全,每次写操作实际写两次磁盘)
jbd2 线程持续高负载 → 写频率超过 journal 处理能力。
九、磁盘 I/O 性能分析
HDD 和 SSD 的不同分析模型
HDD:机械设备,饱和指标是队列长度(avgqu-sz),不是利用率。I/O 调度器的电梯算法对顺序 I/O 效果好,随机 I/O 几乎无效。
SSD/NVMe:利用率才是有意义的指标。NVMe 支持 65535 个并行命令队列。但 SSD 有写放大问题:必须先擦除一个大块才能写入,长期写压力大的 SSD 因 GC 干扰会出现延迟毛刺。
延迟拆解:最重要的分析框架
总延迟 = 队列延迟(软件排队)+ 设备延迟(磁盘真正工作)
总延迟高但设备延迟正常 → I/O 调度器或提交速率问题,换磁盘没用
设备延迟本身高 → 磁盘硬件瓶颈
很多运维看到 I/O 延迟高就换更快的磁盘,但根因是队列堆积时换磁盘完全无效。
I/O 大小分布比延迟更重要
- 大量 4KB 随机写 → 不是磁盘问题,是上层没有做 I/O 合并
- MySQL InnoDB 的 16KB 随机写 → 正常现象(InnoDB 刷脏页)
- 日志大量 4KB → 程序频繁 write+fsync,批量写或减少 fsync 频率
多设备特殊行为
RAID 5/6 写惩罚:每次小随机写实际产生 4 次底层 I/O(读旧数据 + 读旧校验 + 写新数据 + 写新校验),写密集型负载在 RAID 5/6 上性能差的根本原因。
云盘的双峰延迟分布:一部分 I/O 快(宿主机本地缓存),一部分慢(跨网络访问存储集群),是云存储的正常特征。
Noisy neighbor:通过延迟时间序列发现
eBPF 精确记录每次 I/O 的时间戳和延迟,延迟毛刺每隔 10 分钟出现一批 → 往往对应其他租户的定时任务,不是自己的问题。
十、网络性能分析
先拆解延迟构成
局域网 RTT 约 0.1~1ms,跨城约 5~20ms,跨洲约 100ms。如果请求延迟 200ms 而 RTT 是 1ms,199ms 花在了别的地方,不是网络的问题。
TCP 连接队列:悄悄丢请求
- SYN 队列满 → SYN Flood 攻击场景
- Accept 队列满 → 应用 accept() 变慢时发生,新连接被悄悄丢弃,客户端看到连接超时
典型雪崩:大量请求涌入 → 数据库变慢 → accept() 变慢 → Accept 队列满 → 新连接被丢 → 客户端重试 → 进一步恶化。
TCP 重传的精细分类
- 快速重传(3 个重复 ACK)→ 链路质量问题,延迟增加 1 个 RTT,高吞吐场景下一定量属正常
- 超时重传(RTO,约 200ms)→ 严重丢包或连接断开,对应用延迟影响最大,是严重网络问题信号
两种类型对应不同根因,必须区分,不能都叫"重传问题"。
socket 缓冲区:比带宽更早触碰的上限
吞吐量上限 = 接收窗口大小 / RTT
128KB 缓冲区 + 10ms RTT = 100Mbps 上限
就算有 10Gbps 网卡也只能跑到 100Mbps
跨洲大文件传输跑不满带宽,根因通常在这里。调大 net.core.rmem_max 和 net.ipv4.tcp_rmem。
网络栈各层的丢包点
- 网卡层(RX ring buffer 满)→ ethtool 调大 ring buffer 或配置 RSS
- 软中断层(backlog 堆积)→ 考虑 RPS
- TCP 层(Accept 队列满)→ 增大 somaxconn 和 listen backlog
- 应用层(读取太慢)→ 应用层优化
netstat -s 只能看统计,无法关联到具体进程和连接,eBPF 可以在每个丢包点记录完整五元组和进程信息。
XDP:在最早时机做决策
传统 iptables 在网络栈中间层执行,数据包已经经历了 DMA、中断、协议解析。XDP 在网卡驱动收包之后、网络栈之前执行,XDP_DROP 只需几十纳秒,无需分配 sk_buff,单核可处理数千万 pps。"越早做决策越好"是网络性能优化的通用原则。
十一、应用层与语言运行时追踪
帧指针缺失:应用层追踪的根本障碍
-fomit-frame-pointer 让编译器把 rbp 当通用寄存器,省一个寄存器带来 5%~10% 性能提升,代价是 eBPF 无法回溯调用栈,火焰图出现大量 [unknown] 帧。
各语言解法:
- C/C++:加
-fno-omit-frame-pointer(Fedora 38+ 已默认启用) - Go:1.12+ 默认保留,无需处理
- Java:JIT 代码不符合帧指针约定,需要 async-profiler
- Python:需要 USDT 探针或 py-spy
- Node.js:需要
--perf-basic-prof生成符号映射
安全点偏差:GC 语言特有的采样失真。JVM 只在安全点处理 SIGPROF 信号,导致采样总在安全点发生,而安全点分布不均匀,某些循环体里很少有安全点。火焰图看起来正常但实际是失真的——你看到的"热点"可能只是安全点密集的地方。
async-profiler 用 AsyncGetCallTrace API 在任意时刻采集调用栈,从根本上消除安全点偏差。这是为什么生产环境 Java 性能分析必须用 async-profiler 而不是 JVM 内置 profiler。
JVM 执行层次对追踪的影响
解释器
→ C1 编译(约 1500 次执行后触发)
→ C2 编译(约 10000 次后触发)
C2 会积极内联方法,内联后方法在调用栈里消失。如果某个方法在火焰图里占用很多 CPU 时间但没有明显热点,可能是内联了高代价的被调用者。
async-profiler 的混合火焰图同时包含 Java 调用栈和内核调用栈,是诊断"Java 服务 I/O 慢"的关键工具。
Go 的 goroutine 问题
Go 是 M:N 调度(M 个 goroutine 运行在 N 个 OS 线程上),一个 goroutine 在不同时刻可能对应不同的 tid。用 tid 无法正确追踪特定 goroutine,需要从 runtime.g 结构体读取 go id。
goroutine 阻塞时不是 OS 睡眠,而是被 Go 调度器摘下让系统线程运行其他 goroutine。offcputime 追踪不到 goroutine 级别的阻塞,需要 go tool trace。
异步代码的根本挑战
eBPF 采集的是物理调用栈,无法恢复异步代码的逻辑因果链。async/await 代码的调用栈是断裂的,能看到"现在 CPU 在做什么",但看不到"是哪个请求触发的"。eBPF 和 APM 分布式追踪(Jaeger/Zipkin)是互补而非替代的关系。
数据库追踪:USDT 的最佳实践
MySQL USDT 探针覆盖查询层、解析层、存储引擎层和锁层。query__start / query__done 之差是真实执行时间(微秒级精度),远超慢查询日志(毫秒级,有阈值过滤)。
真正有价值的是跨层追踪:同时追踪 MySQL USDT 和内核块 I/O 事件,区分慢查询的三种根因(全表扫描大量 I/O、锁等待、InnoDB buffer pool 命中率低),三种情况的解法完全不同。
十二、安全监控
eBPF 安全监控的核心优势:不可绕过性
传统用户态监控代理可以被攻击者 kill 进程、卸载模块、伪造 /proc 数据。eBPF 程序在内核中运行,用户态进程无法直接终止,不在被监控程序的信任边界之内,也不受 namespace 隔离影响。
四种威胁模型对应不同检测思路
- 已知攻击模式 → 规则匹配(但攻击者可以绕开已知规则)
- 异常行为 → 基线偏离检测(能发现未知攻击,但需要持续维护基线)
- 内核漏洞利用 → 追踪
commit_creds/prepare_kernel_cred - 容器逃逸 → 追踪 namespace 异常变化和
switch_task_namespaces
关键检测点
commit_creds 追踪:一个非 root 进程调用后变成了 root,是强烈的异常信号(可以通过调用栈区分正常的 sudo 路径和漏洞利用路径)。
进程树维护:单个 execve 事件不够,在内核态维护以 PID 为 key 的 Map 记录完整祖先链。"nginx 创建了 bash 创建了 python 创建了 curl"比单独看"curl 连接了某 IP"更有诊断价值。
TLS 加密流量:在 SSL_write/SSL_read 上插桩,在加密之前/解密之后读取明文。
误报是实际运营的最大挑战
大量误报导致安全团队开始忽视告警,比没有监控系统更危险。
减少误报的关键:上下文过滤(同一事件在不同环境危险程度不同)、事件关联(单个事件可信度低,一分钟内依次执行 whoami + id + cat /etc/passwd + wget 才是高可信度告警)。
eBPF 自身也是攻击面
Verifier 历史上有多个严重漏洞,允许本地权限提升。
防御措施:
- 将
kernel.unprivileged_bpf_disabled设为 1,只允许特权用户加载 BPF 程序 - 保持内核更新
- 不给容器 CAP_BPF 或 CAP_SYS_ADMIN 能力
十三、火焰图体系
火焰图的统计本质
火焰图不是精确测量,是统计估算。帧的宽度 = 采样到这个帧的频率 ≈ CPU 时间比例。
火焰图看不到的东西:时间顺序(横轴是字母序不是时间序)、单次慢操作(只发生一次的采样到概率极低)、调用次数(宽度代表总时间,100 万次调用但每次只有 10ns 在火焰图里很窄)。
On-CPU 火焰图:四个常见误读
- 平顶宽帧是热点,窄高塔不是热点(只是调用链中间节点)。
- 颜色在标准 On-CPU 火焰图里无含义(差分火焰图除外)。
- 底部宽帧不是问题所在(所有路径都经过它)。
[unknown]帧 ≠ 某个特定函数的性能问题,它代表调用栈回溯失败。
锯齿状热点:函数被反复调用每次时间短,需要优化调用频率而不是函数本身。
On-CPU + Off-CPU 是互补的一对
- On-CPU:CPU 在做什么(适合高 CPU 利用率场景)
- Off-CPU:为什么任务不在 CPU 上(适合延迟高但 CPU 不忙的场景)
两者合起来才是任务时间的完整图景。
Off-CPU 顶层帧含义:io_schedule → I/O 等待;mutex_lock → 锁等待;try_to_free_pages → 内存直接回收。
差分火焰图:性能回归定位的核心工具
宽度是两个基准的平均宽度,颜色编码变化量:红色 = 增加(性能变差),蓝色 = 减少(性能改善),颜色深浅 = 变化幅度。
- 宽帧但颜色浅 → 本来就是热点,本次变更影响不大
- 窄帧但颜色深 → 本次变更引入的新热点,重点关注
重要限制:两次采样的负载必须一致,否则差分结果混入了负载变化的影响,难以区分代码变更和负载因素。
Flame Charts(时间顺序火焰图)
横轴是真实时间轴,适合分析 GC 停顿(时间段内的"空白")、定时任务干扰、请求突发处理的时序。标准火焰图把所有采样叠加,看不到这类随时间变化的特征。
生成流水线的三个陷阱
- JIT 语言(Java/Node.js)必须在程序运行时采集符号映射,不能事后离线解析(程序结束后 JIT 代码地址已不存在)。
- 两次采样时长不同时做差分分析,需要先归一化(除以各自总采样次数),再做差分。
- idle 帧是噪声,生成业务火焰图前要过滤掉。
十四、容器与 Kubernetes 的特殊考量
eBPF 对 namespace 的穿透性
eBPF 程序运行在宿主机内核,视角是宿主机视角,不受任何 namespace 隔离影响,可以同时观测所有容器的事件。这是 eBPF 监控比 sidecar 模式效率高得多的根本原因——一个宿主机级别的 eBPF 程序覆盖所有容器,资源消耗不随 Pod 数量线性增加。
按容器过滤数据的关键:cgroup ID
bpf_get_current_cgroup_id() 返回当前进程的 cgroup ID,与 Kubernetes 的 cgroup 路径对应(/sys/fs/cgroup/kubepods/QoS/pod<Pod-UID>/<Container-ID>/),从路径里提取 Pod UID 和容器 ID 实现按容器过滤。
PID 双重身份
宿主机 PID(eBPF 工具看到的)和容器内 PID(kubectl exec 进去看到的)是两套,从 /proc/宿主机PID/status 的 NsPid 字段做映射。
从 Pod 名称找宿主机 PID:kubectl get pod 获取 UID → 找 cgroup 路径 → 读 cgroup.procs → 得到宿主机 PID → 用于 BPF 过滤。
CPU Throttling:容器化最容易被误判的性能问题
cgroup CPU bandwidth control:每个周期(默认 100ms)分配 quota 毫秒的配额,配额耗尽后即使宿主机有空闲 CPU 也必须暂停等待下一个周期。
节点 CPU 利用率 30% + 容器请求延迟几百毫秒,原因就在这里。
诊断:看 /sys/fs/cgroup/.../cpu.stat 的 nr_throttled / nr_periods 比例。
调优选项:
- 增加 limit(如果节点有余量)
- 把 period 从 100ms 缩小到 10ms(单次暂停时间缩短,改善 p99 尾延迟)
- 把 request 和 limit 设成相同值(Guaranteed QoS,调度优先级更高)
Overlay 文件系统的 copy-up 陷阱
容器第一次写入镜像层文件时,overlayfs 先把文件从下层复制到上层再写入。大文件首次写入延迟会异常高。eBPF 追踪到的文件路径是宿主机视角的 overlay 路径,需要通过 /proc/PID/mountinfo 映射回容器内路径。
短生命周期容器的三种追踪方案
- 始终运行的宿主机级别 eBPF 程序,按 cgroup ID 记录所有容器的历史数据。
- 基于 execve 事件的动态 attach,发现特定程序名时立刻触发详细追踪。
- 在容器镜像里预先埋入 USDT 探针,宿主机 BPF 程序随时可激活。
十五、BPF 的局限性与适用边界
Verifier 拒绝程序的四类常见原因及避免方法
- 未经 NULL 检查就解引用 Map 查找的返回值 → 显式加
if (!v) return 0 - 数组下标 Verifier 无法静态证明在合法范围 → 显式加边界检查
- 栈溢出(超过 512 字节)→ 把大型数据放在 per-CPU Map 里
- 分支过多导致路径数爆炸(10 个 if-else = 1024 条路径)→ 精简分支或拆分子程序
"大但结构简单"的程序比"小但分支复杂"的程序更容易通过 Verifier。
eBPF 程序的调试手段
bpf_printk():输出到 trace_pipe,调试用,高频事件上开销不可忽视,不适合长期开启。- Verifier 错误信息:
R1 type=map_value_or_null→ 需要 NULL 检查;invalid mem access inv→ 访问了无效内存区域。 log_level=2:输出每条指令的 Verifier 状态,详细但输出量巨大。bpftool prog dump xlated id <prog_id>:输出字节码,结合 Verifier 错误信息里的指令编号定位问题。
关键版本里程碑
| 版本 | 关键特性 |
|---|---|
| 4.15 | JIT 对所有架构默认启用(此前是解释执行,性能差一个数量级) |
| 5.2 | 指令数上限从 4096 提升到 100 万 |
| 5.3 | 支持有界循环(此前所有循环必须手动展开) |
| 5.7 | eBPF LSM 引入,可用 eBPF 实现安全策略钩子 |
| 5.8 | Ring Buffer 引入,CO-RE 趋于成熟 |
| 6.0+ | eBPF Token,更细粒度权限控制 |
工具选择的精确边界
- strace → 只适合调试环境,生产环境绝对不用(可能让服务吞吐量降低 10 倍以上)
- perf → 硬件 PMU 采样、一次性调用栈采集、perf annotate(精确到指令级别)
- ftrace → 内核函数调用关系和时序、无需编程的一次性内核追踪
- valgrind → C/C++ 内存错误调试,能精确到源码行号和错误类型(BPF 无法提供此粒度)
- eBPF → 需要自定义聚合逻辑、跨层关联分析、生产环境低开销长期监控
选择标准:能用 perf 或 ftrace 回答问题吗?如果能,不要用 eBPF——简单工具够用时不必增加复杂度。
学习曲线的真实组成
BPF 追踪什么、看到什么数据、数据意味着什么,都需要内核知识。没有内核知识,工具的输出只是一堆不知所云的数字。
最高效的学习路径:从 bpftrace 单行命令开始 → 读 BCC 工具源码(每个工具只有几十到几百行)→ 先用 perf/ftrace 做分析再用 BPF 重新实现,理解 BPF 真正额外提供了什么。
十六、书的局限与补充阅读
主要局限
书写于 2019~2020 年,CO-RE 当时未完全成熟,书中大量工具基于 BCC,而生产环境正在向 CO-RE/libbpf 迁移。新项目优先用 CO-RE 版本。
书是广度优先,每个子系统都覆盖,但没有一个覆盖到极致。
补充阅读
- brendangregg.com:书的很多内容来源于此,且持续更新
- libbpf-bootstrap:CO-RE 开发起点,各类型 BPF 程序的示例
- Cilium 官方文档:eBPF 在生产 Kubernetes 网络中的完整实践
- BCC 工具源码:学习 BPF 解决实际问题的最好教材
- 《性能之巅》(同作者):讲"系统性能全貌和方法论",本书讲"如何用 BPF 实现",配合阅读互补
总结
《BPF 之巅》提供的是思维框架,不只是工具:
- 用方法论指导分析方向,而不是随机地跑工具
- 用分布而非平均值认识延迟的真实形态
- 用火焰图而非数字认识性能瓶颈的代码位置
- 用跨层关联分析打通用户态和内核态的观测断层
- 用最小开销的方式在生产环境获取最大信息量
这五点思维方式在任何性能分析工具上都适用。

1322

被折叠的 条评论
为什么被折叠?



