在Linux系统中,copy_file_range系统调用允许两个文件描述符之间直接在内核态完成数据复制,绕开了传统的read加write模式,减少了一次数据写入用户态缓冲区的往返。对于NFS、CephFS以及本地文件系统之间的大文件传输,这个接口能显著降低CPU开销。但问题也随之而来:当我们在一台繁忙的服务器上发现大量磁盘读写时,如何确认是哪个进程在调用copy_file_range、拷贝的源文件和目标文件分别是什么、每次拷贝了多少字节?这正是eBPF发挥价值的地方,尤其是bpf_get_current_task这个辅助函数,能够让我们在追踪点上下文中拿到完整的进程信息。
一、copy_file_range的工作原理与追踪点选择
copy_file_range的原型定义在头文件unistd.h中,它接收源文件描述符、源偏移、目标文件描述符、目标偏移以及请求长度五个关键参数,返回实际复制的字节数。内核在实现上会优先尝试文件系统提供的remap_file_range回调,例如XFS和Btrfs在块设备相同时可以直接操纵extent实现近乎零成本的克隆;如果文件系统不支持,则退化为内核内部缓冲区中转的通用路径。
从追踪角度看,有两个典型挂载点可选:一是系统调用入口sys_call_tracepoint或kprobe挂载__x64_sys_copy_file_range,二是直接kprobe挂载vfs_copy_file_range。前者能拿到用户态传入的原始参数,后者位于VFS层,能看到经过合并、偏移调整后的实际操作。实践中建议两者结合,在入口处记录请求字节数,在返回处用kretprobe捕获实际完成的字节数,两者之差往往能反映短拷贝问题,比如源文件读到文件末尾导致返回值小于请求值。
另外需要注意,glibc包装函数在内核返回错误时会设置errno,而在eBPF侧我们看到的是裸的系统调用返回值,例如-EBADF(-9)表示文件描述符非法。做字段对账时这个差异容易造成困惑,务必在用户态分析脚本里做一层转换。
二、bpf_get_current_task的作用与字段访问
bpf_get_current_task是eBPF的一个辅助函数,它返回指向当前进程task_struct的指针。有了这个指针,配合BPF CO-RE(Compile Once, Run Everywhere)技术,我们可以直接读取进程的comm字段获取进程名、tgid获取进程组ID,还能进一步访问files结构体间接推导文件信息。相比bpf_get_current_pid_tgid,它能拿到更丰富的上下文,比如线程组关系、进程凭据等。
下面是一段典型的kprobe代码,展示如何在copy_file_range调用点采集信息。这里使用BPF核心重定位宏读取字段,保证在不同内核版本间的兼容性:
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
struct event {
u32 pid;
u32 tgid;
char comm[16];
u64 fd_in;
u64 fd_out;
u64 count;
u64 ret_bytes;
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");
SEC("kretprobe/vfs_copy_file_range")
int BPF_KRETPROBE(trace_copy_ret, long ret)
{
struct event *e;
struct task_struct *task;
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 0;
/* 通过辅助函数获取当前进程的task_struct */
task = (struct task_struct *)bpf_get_current_task();
if (task) {
BPF_CORE_READ_INTO(&e->pid, task, pid);
BPF_CORE_READ_INTO(&e->tgid, task, tgid);
bpf_probe_read_kernel_str(e->comm, sizeof(e->comm),
task->comm);
}
e->ret_bytes = (u64)ret;
bpf_ringbuf_submit(e, 0);
return 0;
}
char LICENSE[] SEC("license") = "GPL";这段代码有几个关键点值得展开。第一,直接对task->comm取地址传给bpf_probe_read_kernel_str,因为task指针来自辅助函数返回值,校验器允许直接解引用部分字段,但在某些老内核上仍建议统一走BPF_CORE_READ封装。第二,ringbuf比perf buffer更适合高频事件场景,copy_file_range可能被批量循环调用,ringbuf的无锁设计能减少事件丢失。第三,License段必须声明GPL,否则内核会拒绝加载调用了GPL-only辅助函数的程序,而bpf_get_current_task恰好属于GPL-only集合。
三、获取源文件与目标文件路径
光有进程名还不够,分析文件拷贝时最关心的是源文件和目标文件的路径。fd本身只是个数字,要还原成路径需要通过task_struct的files指针找到文件描述符表,再定位到struct file,最后从f_path中拼接出dentry路径。这段旅程涉及多个指针跳转,手写代码容易出错,libbpf提供了BPF_CORE_READ链式读取宏来简化:
SEC("kprobe/vfs_copy_file_range")
int BPF_KPROBE(trace_copy_entry, struct file *file_in, loff_t pos_in,
struct file *file_out, loff_t pos_out, size_t count)
{
struct file *f;
/* vfs层直接把file结构体作为参数传入,省去fd查找 */
bpf_probe_read_kernel(&f, sizeof(f), &file_out->f_path.dentry->d_inode);
/* 也可以借助BPF_FS_RINGBUF助手按dentry逐级回溯父目录 */
return 0;
}实际工程中更推荐挂载vfs_copy_file_range的入口,因为该函数的参数已经是struct file指针,跳过了fd到file的转换这一步。若必须从fd出发,可以借助BPF迭代器逐级读取dentry的d_name和d_parent,直到遇到根dentry为止,把各段名称反向拼装即得到完整路径。注意循环必须有明确的退出上限,通常限制在40层目录深度,否则BPF校验器会因无法证明循环可终止而拒绝加载。
拿到路径之后,可以结合返回字节数做聚合分析。比如用BPF_MAP_TYPE_HASH以源路径加目标路径为键累计总拷贝量,在用户态定期输出统计报表,就能直观看到哪个备份任务、哪对文件在产生最大的IO压力。对于NFS场景,还能顺带观察copy_file_range是否因服务端不支持而退化为通用路径,表现为服务端版本协商日志与本地追踪到的调用次数不匹配。
四、常见问题与性能注意事项
第一类问题是符号名在不同内核上的差异。较新的内核中copy_file_range的vfs入口可能被内联或重命名,建议先用bpftool工具或直接查看/proc/kallsyms确认目标符号存在。如果找不到vfs_copy_file_range,可以退而挂载系统调用表入口,通过pt_regs手动解析参数寄存器,x86_64上系统调用参数依次位于di、si、dx、r10、r8寄存器。
第二类问题是追踪开销。kretprobe在函数高频调用时会产生可观的额外开销,特别是应用层以64KB为单位循环调用copy_file_range的场景。可以考虑两个优化方向:一是改用fentry和fexit挂载点,配合BTF启用后开销远低于kprobe;二是在内核态直接做聚合,只在ringbuf中输出周期性摘要而非逐次事件,将事件量压缩几个数量级。
第三类是容器环境的PID混淆。在命名空间隔离的容器里,bpf_get_current_task读到的pid是内核全局视图的PID,而容器内ps看到的往往是命名空间内的PID。做告警关联时,需要将内核态获取的tgid与宿主机侧的容器运行时信息做映射,或者额外读取task_struct中的nsproxy相关字段换算命名空间内PID,避免排查时对不上进程。
总体而言,bpf_get_current_task配合copy_file_range追踪,为文件拷贝行为提供了内核级、低开销、无需修改应用的观测能力。无论是审计敏感文件流转,还是定位备份任务打满磁盘的元凶,这套方案都比传统的strace或auditd更加精细高效,值得纳入运维工具箱。
eBPFbpf_get_current_taskcopy_file_range修改时间:2026-08-31 07:34:37