使用 eBPF 追踪 Linux 系统调用延迟的完整实践

系统调用是用户态程序与内核交互的唯一入口,它的延迟直接决定了应用程序的响应速度。当数据库查询变慢、Web 服务超时、或者某个后台任务莫名其妙地卡顿,问题往往出在系统调用层面——可能是某个 read() 等待磁盘 I/O 太久,也可能是 futex() 在锁竞争中被反复唤醒。传统的排查手段要么依赖 strace(开销巨大,生产环境基本不可用),要么靠猜。eBPF 改变了这个局面:它让你以极低开销在生产环境中精确测量每一次系统调用的耗时,并且拿到完整的调用上下文。

这篇文章将从原理到代码,完整走一遍用 eBPF 追踪系统调用延迟的实践路径。

为什么传统工具不够用

strace 的工作方式是 ptrace 系统调用,每次目标进程进入或退出系统调用时都要被中断两次,上下文切换开销极大。在高频系统调用的场景下,strace 可能让目标进程慢几十倍甚至上百倍,这在生产环境是不可接受的。

perf trace 基于 perf 子系统,开销比 strace 小,但它本质上仍然是采样和事件记录的思路,在高并发场景下容易丢事件,而且很难做精细的延迟聚合分析。

eBPF 的优势在于:程序直接在内核中执行,不需要上下文切换;通过 kprobe/tracepoint 挂载到内核函数上,可以精确捕获每次系统调用的进入和退出;数据通过高效的 ring buffer 或 perf buffer 传到用户态。开销通常在个位数百分比以内,完全可以跑在生产环境。

核心原理:挂载点与数据结构

追踪系统调用延迟的核心逻辑非常直观:在系统调用入口记录一个时间戳,在出口再记录一个时间戳,两者相减就是延迟。

Linux 内核提供了两个理想的挂载点:

  • raw_syscalls:sys_enter:所有系统调用的统一入口 tracepoint,参数中包含系统调用号和寄存器值。
  • raw_syscalls:sys_exit:所有系统调用的统一出口 tracepoint,包含系统调用号和返回值。

选择 tracepoint 而不是 kprobe 挂载 sys_enter/sys_exit,是因为 tracepoint 是内核稳定 ABI,不会因为内核版本变化导致偏移量失效。对于生产环境工具,稳定性至关重要。

数据结构方面,我们需要一个以线程 ID(TID)为键的哈希表,在 sys_enter 时存入时间戳和系统调用号,在 sys_exit 时取出并计算差值。eBPF 提供了 BPF_MAP_TYPE_HASH 来满足这个需求。

用 BCC 快速实现

BCC(BPF Compiler Collection)提供了 Python 前端,适合快速原型开发。下面是一个追踪系统调用延迟并输出慢调用的完整脚本:

from bcc import BPF
import time

bpf_text = r"""
#include <uapi/linux/ptrace.h>

struct val_t {
    u64 ts;
    u64 syscall_nr;
};

struct data_t {
    u32 pid;
    u64 delta;
    u64 syscall_nr;
    char comm[16];
};

BPF_HASH(start, u32, struct val_t);
BPF_PERF_OUTPUT(events);

TRACEPOINT_PROBE(raw_syscalls, sys_enter) {
    u32 tid = bpf_get_current_pid_tgid();
    struct val_t val = {};
    val.ts = bpf_ktime_get_ns();
    val.syscall_nr = args->id;
    start.update(&tid, &val);
    return 0;
}

TRACEPOINT_PROBE(raw_syscalls, sys_exit) {
    u32 tid = bpf_get_current_pid_tgid();
    struct val_t *valp = start.lookup(&tid);
    if (valp == NULL)
        return 0;

    u64 delta = bpf_ktime_get_ns() - valp->ts;
    // 只输出超过 1ms 的调用,减少噪音
    if (delta < 1000000) {
        start.delete(&tid);
        return 0;
    }

    struct data_t data = {};
    data.pid = bpf_get_current_pid_tgid() >> 32;
    data.delta = delta;
    data.syscall_nr = valp->syscall_nr;
    bpf_get_current_comm(&data.comm, sizeof(data.comm));

    events.perf_submit(args, &data, sizeof(data));
    start.delete(&tid);
    return 0;
}
"""

b = BPF(text=bpf_text)

syscall_names = {}
with open("/usr/include/x86_64-linux-gnu/asm/unistd_64.h") as f:
    for line in f:
        parts = line.split()
        if len(parts) == 3 and parts[0] == "#define":
            syscall_names[int(parts[2])] = parts[1].replace("__NR_", "")

def print_event(cpu, data, size):
    event = b["events"].event(data)
    name = syscall_names.get(event.syscall_nr, str(event.syscall_nr))
    print(f"{event.comm.decode():16s} pid={event.pid:<8d} "
          f"syscall={name:20s} latency={event.delta / 1e6:.3f} ms")

b["events"].open_perf_buffer(print_event)
print("Tracing syscall latency > 1ms... Ctrl-C to exit.")
while True:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        break

这段代码的关键点:

  1. BPF_HASH(start, ...) 以 TID 为键存储入口时间戳。用 TID 而非 PID 是因为同一进程的不同线程可能同时发起系统调用,必须区分。
  2. args->idsys_enter 中拿到系统调用号,在 sys_exit 中通过之前存储的值获取。
  3. 阈值过滤 放在 eBPF 程序内部(delta < 1000000),避免把所有系统调用都传到用户态,大幅降低开销。
  4. bpf_get_current_comm 获取进程名,方便定位问题进程。

运行这个脚本,你会看到类似这样的输出:

postgres          pid=1842     syscall=read                 latency=12.450 ms
nginx             pid=3105     syscall=epoll_wait           latency=3.210 ms
python3           pid=7821     syscall=futex                latency=1.870 ms

从“能看到”到“能分析”

上面的脚本适合实时观察,但生产环境更需要的是聚合统计和长期监控。几个改进方向:

按系统调用类型聚合延迟分布。BPF_MAP_TYPE_HISTOGRAM 记录每种系统调用的延迟分布,而不是逐条输出。BCC 提供了 BPF_HISTOGRAM 宏,可以按系统调用号分桶统计。

关联调用栈。sys_enter 时用 bpf_get_stackid 抓取用户态调用栈,这样不仅知道哪个系统调用慢,还能知道是代码的哪一行触发的。配合 -fno-omit-frame-pointer 编译选项效果最好。

过滤特定进程或容器。 通过 cgroup ID 过滤,可以只追踪某个容器内的系统调用,这在 Kubernetes 环境中非常实用。eBPF 程序可以读取 bpf_get_current_cgroup_id(),用户态传入目标 cgroup ID 进行匹配。

导出到监控系统。 将聚合数据定期导出为 Prometheus 指标,结合 Grafana 做长期趋势分析。当 P99 延迟突然升高时触发告警,比事后排查高效得多。

生产环境注意事项

内核版本要求。 完整的 tracepoint 和 map 支持需要 Linux 4.9+,建议使用 5.4 或更高版本。RHEL 8 系列(4.18 内核)经过大量 backport,也能满足大部分需求。

权限。 加载 eBPF 程序需要 CAP_BPFCAP_PERFMON(或 root)。在容器中运行时,需要 --privileged 或精细的 capability 配置。

哈希表清理。 如果某个线程在 sys_enter 之后异常退出(比如收到信号),sys_exit 不会触发,哈希表中会残留条目。长时间运行需要在用户态定期清理过期条目,或者使用 BPF_MAP_TYPE_LRU_HASH 自动淘汰。

性能开销。 即使做了阈值过滤,在高频系统调用场景下(比如每秒百万次),哈希表的读写本身也会带来开销。建议先用采样模式(比如每 100 次采样 1 次)评估影响,再决定是否全量追踪。

总结

eBPF 让系统调用延迟追踪从“事后猜测”变成了“实时精确测量”。核心思路只有三步:在 sys_enter 记录时间戳,在 sys_exit 计算差值,通过 map 和 perf buffer 把数据传出来。BCC 让原型开发变得简单,而生产部署需要关注聚合、过滤和开销控制。

当你下一次面对“服务变慢了但不知道慢在哪里”的问题时,不妨从系统调用延迟入手——它往往能直接指向根因,无论是磁盘 I/O 瓶颈、锁竞争、还是网络等待。

未经允许不得转载:任鹏个人博客 » 使用 eBPF 追踪 Linux 系统调用延迟的完整实践

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏