《BPF之巅》核心要点

原书: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.statnr_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/meminfoMemAvailable

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/meminfoCached 字段和 kswapd 活跃程度。

大文件顺序读会污染 page cache:备份操作会驱逐其他服务的热点数据。解法是 O_DIRECTposix_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_maxnet.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/statusNsPid 字段做映射。

从 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.statnr_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.15JIT 对所有架构默认启用(此前是解释执行,性能差一个数量级)
5.2指令数上限从 4096 提升到 100 万
5.3支持有界循环(此前所有循环必须手动展开)
5.7eBPF LSM 引入,可用 eBPF 实现安全策略钩子
5.8Ring 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 之巅》提供的是思维框架,不只是工具:

  • 用方法论指导分析方向,而不是随机地跑工具
  • 用分布而非平均值认识延迟的真实形态
  • 用火焰图而非数字认识性能瓶颈的代码位置
  • 用跨层关联分析打通用户态和内核态的观测断层
  • 用最小开销的方式在生产环境获取最大信息量

这五点思维方式在任何性能分析工具上都适用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不厚君

好大一块肉

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值