专栏名称: Linux内核之旅
Linux内核之旅
目录
相关文章推荐
Linux内核之旅  ·  eBPF 治理 NFS 存储实践-4 ·  4 天前  
Linux内核之旅  ·  公平CFS调度类:SCHED_NORMAL、 ... ·  2 周前  
Linux内核之旅  ·  公平调度类的延伸:EEVDF ·  1 周前  
Linux内核之旅  ·  调用栈过深导致的火焰图错误该如何解决 ·  5 天前  
51好读  ›  专栏  ›  Linux内核之旅

eBPF 治理 NFS 存储实践-4

Linux内核之旅  · 公众号  · linux  · 2025-01-06 10:45

正文

整体架构

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
};
  • • 包含完整的请求上下文信息
  • • 通过 stack_id 关联实际的调用栈数据
  • • 支持内核态和用户态调用栈的追踪

请求跟踪 Map (active_requests)

struct {
   __type(key, u32);   // pid
   __type(value, u64); // request_id
   __uint(max_entries, 10000);
} active_requests;
  • • 使用 PID 作为 key,快速定位请求
  • • request_id 由时间戳和 PID 组合生成
  • • 支持最多 10000 个并发请求的跟踪

调用栈存储 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
  • • 支持最大 127 层调用栈深度
  • • 高效存储和检索调用栈信息

用户态

基础数据结构

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 时记录请求
    • • 生成唯一的 request_id
    • • 更新到 active_requests map
  • 请求结束
    • • 在 nfs_readpage_done/writeback_done 时清理
    • • 从 active_requests map 中删除记录
    • • 确保资源及时释放

性能采样流程

  • 触发机制
    • • 通过 perf event 定时触发
    • • 支持可配置的采样频率
    • • 低开销的采样策略
  • 采样处理
    • • 检查是否为活跃 NFS 请求
    • • 获取内核态和用户态调用栈
    • • 构造完整的采样数据
  • 数据传输
    • • 使用 perf event array 传输数据
    • • 支持高效的批量传输
    • • 保证数据完整性

堆栈展开

数据采集阶段

  • • 通过 Perf Reader 持续读取内核态传来的性能事件数据
  • • 对接收到的原始数据进行解析和验证
  • • 维护事件读取的上下文信息,包括时间戳、CPU ID等
  • • 处理数据读取过程中的异常情况

符号解析阶段

  • • 加载并解析内核符号表信息
  • • 构建地址到符号名的映射关系
  • • 维护符号解析缓存,提高查询效率
  • • 处理动态加载模块的符号解析

调用栈重建阶段

  • • 将原始地址序列转换为可读的函数调用序列
  • • 构建完整的调用栈帧信息
  • • 补充源代码文件和行号信息
  • • 处理内联函数和编译优化的特殊情况

数据聚合

数据聚合阶段

  • • 基于调用栈特征进行分组统计
  • • 计算各调用路径的采样频率
  • • 维护时间窗口内的聚合数据
  • • 生成中间统计结果

数据转换阶段

  • • 将聚合数据转换为目标格式
  • • 生成火焰图所需的数据结构
  • • 处理数据压缩和编码
  • • 准备元数据信息

输出处理阶段

  • • 根据配置选择输出目标
  • • 构造 Pyroscope 客户端请求
  • • 处理数据发送和重试逻辑
  • • 记录输出过程的日志信息

核心环节

挂载性能采集代码

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 核心处理

  • • 为每个 CPU 核心:
    • • 打开性能事件
    • • 附加 eBPF 程序
    • • 存储相关资源

启用采样

  • • 遍历所有已创建的性能事件
  • • 通过 ioctl 启用采样

采样率

采样率 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();  // 记录采样时间戳
   // ... 收集调用栈信息 ...
}

采样率的影响:

  1. 1. 更高的采样率 (如1000Hz):
    • • 优点:能捕获更细粒度的性能数据
    • • 缺点:系统开销更大,数据量更大
  2. 2. 更低的采样率 (如100Hz):
    • • 优点:系统开销小,数据量小
    • • 缺点:可能会漏掉一些短时间的性能问题

所以在设置采样率时需要权衡:

params := client.PyroscopeParams{
   Name:       "nfs.cpu",
   SampleRate: 1000,  // 1000Hz = 每毫秒采样一次
   // ...
}

通常:

  • • 100Hz (每10ms采样) 适合常规监控
  • • 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++ 风格的函数名进行解码
  • • 处理特殊前缀(如 "__dl__Z")
  • • 如果解码失败,则使用原始函数名

DWARF 调试信息处理

  • • 使用 DWARF Reader 定位到目标地址所在的编译单元
  • • 检查是否是内联函数:
    • • 遍历 DWARF 条目
    • • 检查地址范围
    • • 识别内联函数标记(TagInlinedSubroutine)

行信息获取

  • • 创建行信息读取器(LineReader)
  • • 使用 SeekPC 定位到目标地址
  • • 获取行信息条目(LineEntry)
  • • 对于内联函数有特殊处理:
    • • 检查前一个条目
    • • 处理行号为 0 的情况
    • • 可能需要查看下一个条目

文件信息处理

  • • 从行信息条目中获取文件信息(File 字段)
  • • 获取文件名(Name 属性)
  • • 处理文件路径:
    • • 相对路径转绝对路径
    • • 应用构建目录前缀
    • • 规范化路径

结果组装

  • • 将所有信息组装成一个完整的条目:
    • • 原始地址
    • • 解析后的函数名
    • • 规范化后的文件路径
    • • 行号
    • • 是否是内联函数的标记

这个过程依赖于编译时生成的调试信息,特别是 DWARF 格式的调试数据。DWARF 提供了程序的调试信息,包括源代码位置、变量、函数等信息的映射。整个转换过程需要处理多种特殊情况,如内联函数、地址偏移(KASLR)、路径规范化等。为了提高性能,通常会使用缓存来存储已解析的结果。

这个转换过程的准确性依赖于:

  • • 内核编译时是否包含了调试信息
  • • vmlinux 文件的可用性
  • • 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 调试包的安装是为了进行内核符号解析的关键原因如下:

基本作用

  1. 1. 符号信息
    • • 包含完整的内核符号表
    • • 保存了函数名和变量名
    • • 未经过压缩和优化的原始信息
  2. 2. 调试信息
    • • 包含 DWARF 格式的调试数据
    • • 提供源代码级别的调试能力
    • • 保存了行号和文件信息

具体用途

地址解析

// 没有 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