# eBPF 文件系统可观测性实战:opensnoop/fatrace 与文件访问追踪深度解析
> 在 Linux 系统运维中,"哪个进程在读写什么文件"是一个高频却难以回答的问题。传统的 `strace` 太重、`inotify` 太窄、`auditd` 太复杂,而 eBPF 提供了在不牺牲性能的前提下获得全知视角的可能。
## 一、为什么文件系统观测这么难
文件系统是 Linux 子系统中最复杂的一层。一个看似简单的 `open()` 系统调用,在内核中可能经过:VFS 层 → 具体文件系统(ext4/xfs/btrfs)→ 页缓存层 → 块设备层 → IO 调度器。每一层都有独立的统计和追踪点。
传统工具的困境:
| 工具 | 覆盖范围 | 性能开销 | 生产可用性 |
|------|---------|---------|-----------|
| strace | 单进程全系统调用 | 极高(50x 降速) | ❌ |
| lsof | 瞬时快照 | 低 | ⚠️ 仅快照 |
| inotify | 单目录监控 | 中 | ⚠️ 需预先设置 |
| auditd | 全系统审计规则 | 高 | ⚠️ 规则繁琐 |
| fatrace | 全系统中速事件 | 中 | ⚠️ 信息有限 |
eBPF 的优势在于:**在内核态过滤和聚合,只输出用户关心的数据**,将上下文切换和数据复制的开销降到最低。
## 二、eBPF 文件观测的技术基础
### 2.1 VFS 层追踪点
Linux 内核通过 VFS(Virtual File System)抽象层统一了所有文件系统的接口。eBPF 程序可以挂载到 VFS 层的通用函数上,从而监控所有文件系统操作:
```
vfs_open() → 文件打开
vfs_read() → 文件读取
vfs_write() → 文件写入
vfs_unlink() → 文件删除
vfs_rename() → 文件重命名
```
这些函数是所有具体文件系统(ext4、xfs、nfs)的上层入口,挂载一次即可全局覆盖。
### 2.2 tracepoint vs kprobe 的选择
文件观测中两种主要探针方式对比:
**tracepoint(推荐):**
- 内核 ABI 稳定,跨版本兼容
- 参数在编译时确定,类型安全
- 例:`syscalls:sys_enter_open`, `syscalls:sys_enter_read`
**kprobe:**
- 可以挂载任意内核函数(包括未导出)
- 跨内核版本可能失效
- 例:`kprobe:vfs_read`, `kprobe:ext4_file_read_iter`
实际生产中建议优先使用 tracepoint,只在需要追踪 ext4 内部行为等场景时使用 kprobe。
### 2.3 BPF Map 在文件观测中的角色
```
┌──────────────────────────────────────────────────┐
│ BPF Map 策略 │
├──────────────┬───────────────────────────────────┤
│ Hash Map │ 记录打开的文件描述符与进程映射 │
│ LRU Map │ 缓存热路径元数据,自动淘汰 │
│ Perf Buffer │ 向用户空间流式输出事件 │
│ Ring Buffer │ 替代 perf buffer(kernel 5.8+) │
└──────────────┴───────────────────────────────────┘
```
## 三、opensnoop:打开操作的实时显微镜
opensnoop 是 BCC 工具集中最常用的文件观测工具之一,用于实时显示系统中所有被打开的文件。
### 3.1 核心实现原理
opensnoop 挂载两个 tracepoint:
```
tracepoint:syscalls:sys_enter_open → 捕获 open() 入口
tracepoint:syscalls:sys_enter_openat → 捕获 openat() 入口
tracepoint:syscalls:sys_exit_openat → 获取返回值(fd 或错误码)
```
在内核态 BPF 程序中:
```c
// 入口:捕获文件名和标志位
SEC("tracepoint/syscalls/sys_enter_openat")
int trace_entry(struct trace_event_raw_sys_enter *ctx) {
struct event e = {};
e.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&e.comm, sizeof(e.comm));
// 从用户空间读取文件名(安全方式)
bpf_probe_read_user_str(&e.fname, sizeof(e.fname),
(void *)ctx->args[1]);
e.flags = ctx->args[2];
e.mode = ctx->args[3];
// 存入临时 map,等待出口匹配
u64 pid_tgid = bpf_get_current_pid_tgid();
start.update(&pid_tgid, &e);
return 0;
}
// 出口:获取返回的文件描述符或错误码
SEC("tracepoint/syscalls/sys_exit_openat")
int trace_exit(struct trace_event_raw_sys_exit *ctx) {
u64 pid_tgid = bpf_get_current_pid_tgid();
struct event *ep = start.lookup(&pid_tgid);
if (!ep) return 0;
ep->ret = ctx->ret; // 成功为正 fd,失败为负错误码
// 发送到用户空间
events.perf_submit(ctx, ep, sizeof(*ep));
start.delete(&pid_tgid);
return 0;
}
```
### 3.2 输出格式解读
```
TIME(s) PID COMM FD PATH
0.000 1234 nginx 15 /var/www/html/index.html
0.001 5678 python3 3 /etc/hosts
0.002 9012 bash -1 /root/.bash_history # 权限拒绝
0.003 3456 mysqld 28 /var/lib/mysql/ibdata1
```
FD 为负数表示 open 失败(如权限不足),这是排障时非常有价值的信号。
### 3.3 高级用法实战
**过滤只看失败的文件打开:**
```bash
opensnoop -x # 只显示失败的 open 调用
```
**关联特定进程:**
```bash
opensnoop -p $(pgrep -d, nginx) # 仅跟踪 nginx 进程
```
**过滤特定文件模式:**
```bash
opensnoop | grep -E '\.(conf|key|pem)' # 追踪配置和密钥文件访问
```
### 3.4 性能特征
在 8 核服务器上,opensnoop 对系统整体性能影响 < 0.3%。每秒可处理约 50 万事件,即使在高 I/O 负载下也不会造成明显延迟。内核态过滤只传递已打开文件的信息,不阻塞任何 I/O 路径。
## 四、fatrace:文件系统活动的全景视图
fatrace(File Activity Trace)提供了全系统中速文件事件报告,是排查"谁在动我的磁盘"的利器。
### 4.1 fatrace 的工作机制
fatrace 使用 Linux 的 fanotify(文件系统通知)子系统,而非 eBPF。但两者解决的问题高度互补:
```
fanotify 级别:
├── FAN_CLASS_PRE_CONTENT → 需在数据返回前决定(安全扫描)
├── FAN_CLASS_CONTENT → 可阻塞等待决定
└── FAN_CLASS_NOTIF → 纯通知(fatrace 使用此模式)
```
fatrace 挂载 FAN_MARK_FILESYSTEM 标记到整个文件系统,监控所有 mount 命名空间内的文件事件:
```c
fanotify_init(FAN_CLASS_NOTIF | FAN_CLOEXEC | FAN_NONBLOCK, O_RDONLY);
fanotify_mark(fd, FAN_MARK_ADD | FAN_MARK_FILESYSTEM,
FAN_ACCESS | FAN_MODIFY | FAN_OPEN | FAN_CLOSE |
FAN_ONDIR | FAN_EVENT_ON_CHILD, AT_FDCWD, "/");
```
### 4.2 事件类型与分类
fatrace 的事件类型前缀:
| 前缀 | 含义 | 实际场景 |
|------|------|---------|
| R | Read Access | 进程读取文件内容 |
| W | Write | 进程写入文件 |
| O | Open | 进程打开文件 |
| C | Close | 关闭文件描述符 |
输出示例:
```
nginx(1234): O /var/www/html/index.html # 打开
nginx(1234): R /var/www/html/index.html # 读取内容
nginx(1234): C /var/www/html/index.html # 关闭
python(5678): W /tmp/cache/data.json # 写入
```
### 4.3 fatrace 与 opensnoop 的协同
```bash
# 终端1:用 fatrace 快速定位活跃文件
fatrace | grep -v "\.pyc\|\.swp" > /tmp/fs_activity.log &
# 终端2:用 opensnoop 获取详细信息
opensnoop -p $(cat /tmp/fs_activity.log | awk '{print $1}' | cut -d'(' -f2 | cut -d')' -f1 | head -5)
# 或者用 eBPF 工具组合更为精确的组合
```
## 五、BPF 文件系统追踪进阶:自定义探针
当标准工具无法满足需求时,需要编写自定义 eBPF 程序。
### 5.1 追踪特定文件系统函数
追踪 ext4 文件读取的实际物理 IO:
```c
SEC("kprobe/ext4_file_read_iter")
int trace_ext4_read(struct pt_regs *ctx) {
struct file *file = (struct file *)PT_REGS_PARM1(ctx);
struct kiocb *iocb = (struct kiocb *)PT_REGS_PARM2(ctx);
struct iov_iter *to = (struct iov_iter *)PT_REGS_PARM3(ctx);
// 提取文件信息
struct inode *inode = file->f_inode;
u64 ino = inode->i_ino;
dev_t dev = inode->i_sb->s_dev;
// 请求大小
u64 count = to->count;
struct event e = {};
e.dev = dev;
e.ino = ino;
e.read_size = count;
e.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&e.comm, sizeof(e.comm));
events.perf_submit(ctx, &e, sizeof(e));
return 0;
}
```
### 5.2 页缓存命中率追踪
一个常见需求是区分"真正磁盘读取"和"页缓存命中":
```c
SEC("fentry/filemap_read")
int trace_filemap_read(struct file *file, struct kiocb *iocb,
struct iov_iter *to) {
// 检查页缓存状态
struct address_space *mapping = file->f_mapping;
xa_state state;
struct page *page;
// 如果页面已在缓存中,标记为缓存命中
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 page_addr = (u64)xa_load(&mapping->i_pages,
iocb->ki_pos >> PAGE_SHIFT);
struct cache_stat s = {};
s.pid = pid;
s.offset = iocb->ki_pos;
s.is_hit = (page_addr != 0); // 简化判断
bpf_get_current_comm(&s.comm, sizeof(s.comm));
cache_events.perf_submit(ctx, &s, sizeof(s));
return 0;
}
```
### 5.3 延迟追踪:open 到 first read 的时间
追踪从打开文件到首次读取的延迟(可用于发现"打开不用"的资源泄漏):
```c
BPF_HASH(open_times, u64, u64); // pid_tgid → timestamp
SEC("tracepoint/syscalls/sys_exit_openat")
int trace_open_exit(struct trace_event_raw_sys_exit *ctx) {
if (ctx->ret < 0) return 0; // 失败忽略
u64 pid_tgid = bpf_get_current_pid_tgid();
u64 ts = bpf_ktime_get_ns();
open_times.update(&pid_tgid, &ts);
return 0;
}
SEC("tracepoint/syscalls/sys_enter_read")
int trace_read_entry(struct trace_event_raw_sys_enter *ctx) {
u64 pid_tgid = bpf_get_current_pid_tgid();
u64 *open_ts = open_times.lookup(&pid_tgid);
if (!open_ts) return 0;
u64 delay = bpf_ktime_get_ns() - *open_ts;
if (delay > 1000000) { // > 1ms 才报告
struct latency_event e = {};
e.pid = pid_tgid >> 32;
e.open_to_read_ns = delay;
latency_events.perf_submit(ctx, &e, sizeof(e));
}
open_times.delete(&pid_tgid);
return 0;
}
```
## 六、生产环境部署实战
### 6.1 推荐的观测工具链
```
┌─────────────────────────────────────────────────────────────┐
│ 文件可观测性工具矩阵 │
├────────────────┬────────────────────────────────────────────┤
│ 场景 │ 推荐工具 │
├────────────────┼────────────────────────────────────────────┤
│ 实时文件打开 │ opensnoop (BCC/bpftrace) │
│ 全系统文件活动 │ fatrace + fanotify │
│ 慢 IO 定位 │ biolatency + biosnoop + fileslower │
│ 文件泄露检测 │ 自定义 tracepoint:syscalls/sys_enter_open │
│ 缓存命中率 │ 自定义 kprobe:filemap_read │
│ 审计与合规 │ auditd (规则精确) / bpfilter │
└────────────────┴────────────────────────────────────────────┘
```
### 6.2 性能开销对比测试
在 4 核 8G 的 Web 服务器上,使用 fio 作为基准负载:
| 观测方案 | IOPS 基线 | IOPS 开启后 | 开销 |
|---------|----------|------------|------|
| 无工具 | 85,000 | - | 0% |
| opensnoop | 85,000 | 84,700 | 0.35% |
| fatrace | 85,000 | 83,200 | 2.1% |
| biolatency | 85,000 | 84,400 | 0.7% |
| strace nginx | 85,000 | 8,200 | 90.4% |
结论:**eBPF 方案的开销可以忽略,strace 绝不适用于生产**。
### 6.3 BPFiter:安全文件审计
在生产环境中,除了性能观测,eBPF 还可用于文件安全审计:
```bash
# 使用 bpfilter + eBPF 实现不可绕过的文件访问审计
bpfadm filter add obj fileAudit.o sec .text
# 或使用 Tetragon(基于 eBPF 的安全可观测平台)
tetragon --config-file /etc/tetragon/tetragon.yaml
```
### 6.4 大规模部署注意事项
**1. BPF 程序大小限制:**
```
- 内核 5.2+:100 万指令
- 内核 4.16-5.1:4096 指令(使用 BPF-to-BPF 调用扩展)
```
**2. Map 内存限制:**
```c
// 设置 BPF map 的上界
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240); // 限制最大条目数
__type(key, u64);
__type(value, struct file_event);
} file_map SEC(".maps");
```
**3. 事件风暴防护:**
```c
// 在内核态做速率限制
static __always_inline bool rate_limit(u32 pid) {
u64 *count = bpf_map_lookup_elem(&rate_map, &pid);
if (*count > 1000) return false; // 每秒超 1000 事件丢弃
(*count)++;
return true;
}
```
## 七、故障排查实战案例
### 7.1 案例一:磁盘 io_running 飙满
**现象:** iostat 显示 `avgqu-sz` 持续 >100,但 atop 显示 IO 来自多个小进程。
**排查过程:**
```bash
# 步骤1:fatrace 看全貌
fatrace -t | head -100
# 发现大量 *.swp 和 *.pyc 频繁写入
# 步骤2:opensnoop 看谁打开了这些文件
opensnoop | grep -E '\.(swp|pyc)$'
# 定位到 Python 进程在疯狂 import
# 步骤3:bpftrace 追踪 import 路径
bpftrace -e 'kprobe:do_execveat_common { printf("%s open %s\n", comm, str(arg1)); }'
# 确认是 Python 虚拟环境路径配置错误
```
**修复:** 修正 `PYTHONPATH` 后,IO 降低 90%。
### 7.2 案例二:容器中的"幽灵读取"
**现象:** 容器 A 访问文件 X,但读取延迟高达 50ms,同一节点其他容器正常。
**排查:**
```bash
# 在宿主机使用 opensnoop 追踪容器进程
opensnoop -n $(docker inspect -f '{{.State.Pid}}' containerA)
# 结合 biolatency 看 IO 延迟分布
biolatency -m 5
# 发现 ext4_journal_start 整体偏高
```
**根因:** 容器的 overlay2 文件系统中,文件 copy-up 触发 ext4 日志同步,导致延迟飙升。
### 7.3 文件描述符泄漏追踪
```bash
# 使用 bpftrace 追踪 open 不 close 的进程
bpftrace -e '
tracepoint:syscalls:sys_enter_openat { @opens[tid] = nsecs; }
tracepoint:syscalls:sys_exit_openat /@opens[tid]/ {
@fd_latency = hist((nsecs - @opens[tid]) / 1000);
delete(@opens[tid]);
}'
```
## 八、从观测到优化:下一步展望
eBPF 文件系统可观测的未来方向:
1. **BPF File Integrity Monitor**:利用 LSM BPF 实现不可绕过的文件完整性监控,替代传统的 AIDE/Samhain
2. **IO Pattern Analysis**:在内核态分析 IO 模式(顺序/随机/混合),为存储层自动调优提供输入
3. **Page Cache Intelligence**:基于 BPF 的页缓存行为分析,指导 `vfs_cache_pressure` 和脏页比的动态调整
4. **BPF-based FUSE 加速**:在 FUSE 文件系统中使用 BPF 绕过用户态回环,降低分布式文件系统开销
## 总结
eBPF 将 Linux 文件系统观测从"事后猜测"带到了"实时透明"的新阶段。从 opensnoop 到自定义探针,从单机诊断到全集群审计,eBPF 正在重塑我们理解和优化存储子系统的方式。
关键要点回顾:
- **优先使用 tracepoint**,kprobe 仅在必要时使用
- **maps 的大小和事件频率需设限**,防止 BPF 程序自身成为负载
- **组合使用 opensnoop + fatrace + biolatency**,从不同维度还原 IO 全貌
- **内核态过滤是性能关键**,永远不要把原始事件无脑推给用户空间
> 下一篇我们将深入 eBPF 在系统调用过滤与沙箱安全领域的应用:seccomp-BPF 与 LSM BPF 的实战对比。

发表评论 取消回复