整体架构
NFS Profiler 基于 eBPF 技术构建,采用双模块设计实现对 NFS 请求的全链路追踪和性能分析。其中请求跟踪模块通过在 NFS 关键函数(nfs_initiate_read/write, nfs_readpage_done/writeback_done)注入 kprobe/tracepoint,实时捕获请求的生命周期事件。每个请求使用 (timestamp << 32 | pid) 生成唯一的 request_id,并存储在 BPF_MAP_TYPE_HASH 类型的 active_requests map 中,key 为进程 PID,value 为 request_id。
性能采样模块则通过挂载 perf event,以可配置的频率触发采样。采样时通过 bpf_get_stackid() 同时获取内核态(BPF_F_FAST_STACK_CMP)和用户态(BPF_F_USER_STACK)调用栈,将调用栈数据存储在 BPF_MAP_TYPE_STACK_TRACE 类型的 stack_traces map 中。每个采样点会构造包含 request_id、timestamp、pid、cpu、kernel_stack_id 和 user_stack_id 的 stack_sample 结构体,通过 BPF_MAP_TYPE_PERF_EVENT_ARRAY 类型的 cpu_profiler_events 传输到用户态。
两个模块通过 active_requests map 进行关联,确保只对活跃的 NFS 请求进行采样,避免无效采样带来的性能开销。整个系统采用 map 作为核心数据结构,实现了高效的数据存储和传输。
核心数据结构
内核态
采样数据结构 (stack_sample)
struct stack_sample {
u64 request_id; // NFS 请求唯一标识
u64 timestamp; // 采样时间戳
u32 pid; // 进程ID
u32 cpu; // CPU ID
s64 kernel_stack_id; // 内核态调用栈ID
s64 user_stack_id; // 用户态调用栈ID
};
请求跟踪 Map (active_requests)
struct {
__type(key, u32); // pid
__type(value, u64); // request_id
__uint(max_entries, 10000);
} active_requests;
- • request_id 由时间戳和 PID 组合生成
调用栈存储 Map (stack_traces)
struct {
__uint(type, BPF_MAP_TYPE_STACK_TRACE);
__uint(value_size, PERF_MAX_STACK_DEPTH * sizeof(u64));
__uint(max_entries, 10000);
} stack_traces;
- • 使用专门的 STACK_TRACE 类型 map
用户态
基础数据结构
Stack 相关结构
// 单个调用栈帧
type StackFrame struct {
FuncName string // 函数名,如 "nfs_write_page"
FileName string // 源文件名,如 "fs/nfs/write.c"
LineNumber uint // 行号,定位具体代码位置
Offset uint64 // 函数内偏移量,用于精确定位
Samples int // 该帧的采样次数
}
// 完整调用栈
type Stack struct {
Frames []StackFrame // 调用栈帧列表,从栈顶到栈底
Count int // 整个调用栈的采样计数
}
// 用于存储和索引调用栈
type StackMap struct {
stacks map[string]*Stack // key: 栈标识符, value: 栈信息
}
辅助数据结构
符号解析相关
// 符号信息
type Symbol struct {
Name string // 符号名称
Address uint64 // 符号地址
Size uint64 // 符号大小
}
// 行信息
type LineInfo struct {
File string // 源文件路径
Line uint // 行号
Func string // 函数名
}
// 地址到符号的映射缓存
type SymbolCache struct {
cache map[uint64]*Symbol
mu sync.RWMutex
}
采样数据结构
// 采样事件
type SampleEvent struct {
Timestamp uint64 // 采样时间戳
ProcessID uint32 // 进程ID
ThreadID uint32 // 线程ID
CPU uint32 // CPU编号
KernelStack []uint64 // 内核态调用栈地址
UserStack []uint64 // 用户态调用栈地址
RequestID uint64 // NFS请求ID
}
// 聚合结果
type ProfileData struct {
StartTime int64 // 采样开始时间
EndTime int64 // 采样结束时间
Samples map[string]*Stack // 聚合的调用栈数据
TotalCount int // 总采样次数
}
数据结构关系
工作流程
请求生命周期管理
- • 请求开始:
- • 在 nfs_initiate_read/write 时记录请求
- • 更新到 active_requests map
- • 请求结束:
- • 在 nfs_readpage_done/writeback_done 时清理
- • 从 active_requests map 中删除记录
性能采样流程
- • 数据传输:
- • 使用 perf event array 传输数据
堆栈展开
数据采集阶段
- • 通过 Perf Reader 持续读取内核态传来的性能事件数据
- • 维护事件读取的上下文信息,包括时间戳、CPU ID等
符号解析阶段
调用栈重建阶段
数据聚合
数据聚合阶段
数据转换阶段
输出处理阶段
核心环节
挂载性能采集代码
NewPerfEvent
函数负责在每个 CPU 核心上创建和配置性能事件采样器,将 eBPF 程序附加到这些采样器上,实现系统级的性能数据采集。
参数验证和初始化
// 检查采样率配置
simpleRate := cfg.OnCPUProfiler.SimpleRate
if simpleRate <= 200 {
log.Warningf("采样率设置过低 %d,使用默认值 200", simpleRate)
simpleRate = 200
}
// 获取 eBPF 程序
prog := coll.Programs["do_perf_event"]
if prog == nil {
return nil, fmt.Errorf("failed to find perf_event program")
}
创建性能事件实例
pe := &PerfEvent{
prog: prog,
links: make([]link.Link, runtime.NumCPU()),
attrs: make([]unix.PerfEventAttr, runtime.NumCPU()),
fds: make([]int, runtime.NumCPU()),
}
配置采样属性
attr := unix.PerfEventAttr{
Type: unix.PERF_TYPE_SOFTWARE, // 软件事件
Size: uint32(unsafe.Sizeof(unix.PerfEventAttr{})),
Config: unix.PERF_COUNT_SW_CPU_CLOCK, // CPU 时钟计数
Sample: uint64(simpleRate), // 采样频率
Sample_type: unix.PERF_SAMPLE_RAW, // 原始采样数据
Bits: unix.PerfBitDisabled | unix.PerfBitFreq, // 初始禁用,使用频率模式
}
多 CPU 核心处理
启用采样
采样率
采样率 1000 (1000Hz) 表示每秒采集 1000 次样本,也就是每 1ms 采集一次样本。
采样间隔 = 1秒 / 采样率
1000Hz -> 1s / 1000 = 1ms (每毫秒采样一次)
100Hz -> 1s / 100 = 10ms (每10毫秒采样一次)
在 eBPF 程序中:
SEC("perf_event")
int do_perf_event(struct bpf_perf_event_data *ctx) {
// 每次采样触发时执行
struct stack_sample sample = {};
sample.timestamp = bpf_ktime_get_ns(); // 记录采样时间戳
// ... 收集调用栈信息 ...
}
采样率的影响:
所以在设置采样率时需要权衡:
params := client.PyroscopeParams{
Name: "nfs.cpu",
SampleRate: 1000, // 1000Hz = 每毫秒采样一次
// ...
}
通常:
- • 1000Hz (每1ms采样) 适合需要更精确分析的场景
过滤非 NFS 流量
active_requests Map
// 内核态 eBPF 程序中的 Map 定义
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10000);
__type(key, u32); // pid
__type(value, u64); // request_id
} active_requests SEC(".maps");
过滤流程
NFS 请求记录
// 在 NFS 操作开始时记录
SEC("tracepoint/nfs/nfs_initiate_read")
int nfs_init_read(struct nfs_init_fields *ctx) {
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 timestamp = bpf_ktime_get_ns();
// 生成请求 ID
__u64 request_id = (timestamp << 32) | (pid & 0xFFFFFFFF);
// 记录活跃请求
bpf_map_update_elem(&active_requests, &pid, &request_id, BPF_ANY);
...
}
Perf Event 采样过滤
SEC("perf_event")
int do_perf_event(struct bpf_perf_event_data *ctx) {
u32 pid = bpf_get_current_pid_tgid() >> 32;
// 检查是否是活跃的 NFS 请求
u64 *request_id = bpf_map_lookup_elem(&active_requests, &pid);
if (!request_id) {
return 0; // 不是 NFS 请求,直接返回
}
// 是 NFS 请求,继续处理采样数据
...
}
请求完成清理
SEC("kprobe/nfs_readpage_done")
int kb_nfs_read_d(struct pt_regs *regs) {
...
// 处理完成后删除活跃请求记录
bpf_map_delete_elem(&active_requests, &pid);
...
}
解析堆栈信息
将函数地址转换为函数名、文件名和行数的过程是通过解析 DWARF 调试信息实现的,具体实现可以参考 Asphaltt/bpflbr[1] ,整个转换过程可以分为以下几个关键步骤:
地址定位
- • 首先通过二分查找在地址数组中找到小于等于目标地址的最大地址
- • 通过这个地址可以在符号表中查找到对应的原始函数名
函数名解析
- • 如果需要解码(demangle),会对 C++ 风格的函数名进行解码
DWARF 调试信息处理
- • 使用 DWARF Reader 定位到目标地址所在的编译单元
- • 检查是否是内联函数:
- • 识别内联函数标记(TagInlinedSubroutine)
行信息获取
文件信息处理
结果组装
这个过程依赖于编译时生成的调试信息,特别是 DWARF 格式的调试数据。DWARF 提供了程序的调试信息,包括源代码位置、变量、函数等信息的映射。整个转换过程需要处理多种特殊情况,如内联函数、地址偏移(KASLR)、路径规范化等。为了提高性能,通常会使用缓存来存储已解析的结果。
这个转换过程的准确性依赖于:
同时,这个过程也需要处理各种异常情况:
通过这个完整的转换过程,我们可以将一个内核中的函数地址准确地映射到源代码的具体位置,本文使用 Asphaltt/addr2line[2] 仓库进行信息转换。
vmlinux 调试包
# 首先查看当前内核版本
uname -r # 例如:6.5.0-21-generic
# 安装对应的调试包
sudo apt install linux-image-$(uname -r)-dbgsym
# 如果上面的命令找不到包,需要先启用 dbgsym 仓库
sudo apt install ubuntu-dbgsym-keyring
echo "deb http://ddebs.ubuntu.com $(lsb_release -cs) main restricted universe multiverse
deb http://ddebs.ubuntu.com $(lsb_release -cs)-updates main restricted universe multiverse
deb http://ddebs.ubuntu.com $(lsb_release -cs)-proposed main restricted universe multiverse" | \
sudo tee /etc/apt/sources.list.d/ddebs.list
# 更新包列表
sudo apt update
# 再次尝试安装调试包
sudo apt install linux-image-$(uname -r)-dbgsym
以上以 ubuntu 24.04 安装为实例,vmlinux 调试包的安装是为了进行内核符号解析的关键原因如下:
基本作用
具体用途
地址解析
// 没有 vmlinux 只能看到地址
0xffffffff81234567
// 有 vmlinux 可以解析为
do_sys_open+0x123 (fs/open.c:123)
KASLR 偏移计算
KASLR 基本概念
KASLR (Kernel Address Space Layout Randomization) 是一种内核安全机制:
偏移计算原理
计算方法详解
// 1. 获取编译时地址
textAddr, err := bpf.ReadTextAddrFromVmlinux(vmlinux)
if err != nil {
return fmt.Errorf("read .text addr: %w", err)
}
// 2. 获取运行时地址
stext := kallsyms.Stext() // 从 /proc/kallsyms 读取 _stext 地址
// 3. 计算 KASLR 偏移
kaslrOffset := textAddr - stext
// 示例值:
// textAddr: 0xffffffff81000000 (编译时基址)
// stext: 0xffffffff87654000 (运行时基址)
// kaslrOffset: 0xffffffff81000000 - 0xffffffff87654000
// = -0x6654000
引用链接
[1]
Asphaltt/bpflbr: https://github.com/Asphaltt/bpflbr/blob/bpflbr/internal/bpflbr/addr2line.go
[2]
Asphaltt/addr2line: https://github.com/Asphaltt/addr2line