eBPF拦截文件写操作的可行性调研

原创 吴就业 283 1 2024-06-28

本文为博主原创文章,未经博主允许不得转载。

本文链接:https://wujiuye.com/article/876b598fee8e4ebbabe8a0f1c1fe0e34

作者:吴就业
链接:https://wujiuye.com/article/876b598fee8e4ebbabe8a0f1c1fe0e34
来源:吴就业的网络日记
本文为博主原创文章,未经博主允许不得转载。

调研的目的/需求背景

在自定义k8s持久卷csi驱动,并将一个大的nfs文件系统分割成多个小的持久卷(PV)的背景下,如何解决每个持久卷只允许使用到卷的容量上限(例如50GB)是个待功克的技术难题。

whiteboard_exported_image

方案调研

方案一:eBPF拦截vfs_write

vfs_write是linux虚拟文件系统写操作的一个系统函数,所有对文件的写操作必然都经过这个系统函数。

eBPF可以在Linux操作系统内核中运行沙盒程序,用于安全有效地扩展内核的功能,在无需更改内核源代码或加载内核模块的情况下,能过插入我们的hook逻辑。

用于验证的demo案例:

//go:build ignore
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
#include <asm-generic/errno.h>

// ......

SEC("kprobe/vfs_write")
int kprobe_vfs_write(struct pt_regs *ctx) {
    // .......
    struct dir_cfg *value = bpf_map_lookup_elem(&dir_allow_write_map, &key);
    if (value && value->disable == 1) {
        bpf_printk("not allow write by root path = /%s\n", root_path);
        return -EPERM;
    }
    // ........
    return 0;
}

char __license[] SEC("license") = "Dual MIT/GPL";

调研结论:我们无论reutrn -1还是0还是1,都无法阻止vfs_write的执行,原因是我们无法在eBPF程序中Blocking系统函数的调用。就好比AOP的逻辑只能打打日记。

方案二:eBPF+nfs协议拦截写操作

所有挂盘写操作,最终都是通过nfs协议走网络通信写到nfs文件系统,是否可以通过eBPF拦截网络出口流量数据包?然后根据nfs v3协议采用的rpc通信协议和xdr编解码协议来解码出nfs数据包,对写操作的数据包进行改写或丢包处理。

nfs协议:https://www.ietf.org/rfc/rfc1813.txt

Nfs的rpc通信协议:https://linux-nfs.org/wiki/index.php/NetworkTracing

通过tcpdump抓包分析后,发现写文件的流程是:

  1. 打开一个文件写:LOOKUP -> WRITE -> COMMIT
  2. 创建一个文件写:LOOKUP -> CREATE -> WRITE -> COMMIT

LOOPUP的过程是一步步获取文件句柄。

举例:假如我们需要打开/data/logs/log.txt文件写。

那么需要经过三次LOOPUP才能拿到要的的文件句柄,分别是:

  1. 调用LOOKUP查询.(当前)目录下的data目录,获取data目录的文件句柄。
  2. 调用LOOKUP查询data目录下的logs目录,获取logs目录的文件句柄。
  3. 调用LOOKUP查询logs目录下的log.txt文件的文件句柄。

img

调研的结论:由于nfs协议我们只能拿到写文件的文件句柄,无法直接得出这个文件是哪个目录下的。想通过ebpf拦截nfs网络数据包来拦截写,因为要实现知道一个文件句柄是哪个目录下的,需要花很大的成本代价来缓存文件元数据信息,花大成本只为了实现实现一个拦截功能,这条路肯定也走不通。

方案三:eBPF xdp或tc直接匹配IP丢包

最坏的情况下,是否可以限制只允许一个pod挂一个pv(持久卷),我们能够通过IP协议层数据包获取源ip对应的pod,再查pod挂的pv,如果这个pv已经到了写入限制,那么就可以在ebpf拦截直接丢包。

由于eBPF xdp挂载点只能拦截入口网络数据包,也就是说,拦截write,实际已经调nfs服务器完成了写,只是拦截了响应,这可能会造成数据的不一致?

所以优先考虑了另一种方案,利用tc实现拦截出口流量并匹配源Pod ip丢包。tc是Traffic Control的缩写,即流量控制,是 Linux 内核中用于流量控制的一套功能强大的工具和框架、是内核的一个子系统。参考文献:[译] 深入理解 tc ebpf 的 direct-action (da) 模式(2020)

该方案存在局限:tc不能控制nfs读写,也就是说,如果对某个pod丢包处理的话,会影响读和写。但正常来说,一个磁盘的用满,应该只能影响写入而不能影响读、也不能影响删除。

在k8s DemainSet pod上验证使用tc丢包方案的可行性,得出结论:由于pv是由csi驱动的node组件这个pod挂载的,只有这个pod安装了nfs的client,能够与nfs文件系统交互,然后node组件是将这个目录挂载到应用pod的挂载点而已,实际我们在应用pod往磁盘写数据,最终网络上是通过csi这个node组件的nfs client去写到nfs文件系统的。所以我们无法拦截应用Pod的,只能拦截csi node组件的pod。

即:所有基于IP的拦截方案都行不通。tc这个方案也走不通。

方案四:eBPF LSM拦截vfs_write

LSM是Linux Security Module的缩写,即Linux 安全模块,它是 Linux 内核的一个基于钩子的框架,用于在Linux内核中实现安全策略和强制访问控制。

Linux 5.7版本引入了eBPF LSM(简称LSM BPF)。eBPF LSM是LSM的一个扩展,允许开发者编写eBPF程序来定义和执行安全策略。

因为这个钩子允许我们返回非0以Blocking掉系统调用,所以可以用来实现拦截文件写入的需求。从vfs_write这个系统函数的源码我们找到了file_permission这个HOOK。

file_permission钩子用于在访问打开的文件之前检查文件权限,该钩子的定义如下:

img

参数说明:

但是vfs_read也调用这个hook,open等其它地方也会调用这个hook,需要能区分是哪个系统函数调用的,这个方案理论上才能跑通。

img

使用 eBPF Linux 安全模块实时修补 Linux 内核中的安全漏洞这篇文章介绍了可以从rax寄存器中拿到系统调用的编号。然后从Searchable Linux Syscall Table这篇文档找到了对应write系统调用的rax值 = 1。

img

用于验证的bpf代码:

//go:build ignore

#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

#define X86_64_WRITE_SYSCALL 1
#define WRITE_SYSCALL X86_64_WRITE_SYSCALL

char __license[] SEC("license") = "Dual MIT/GPL";

SEC("lsm/file_permission")
int BPF_PROG(handle_file_permission,struct file *file, int mask, int ret) {
    struct pt_regs *regs;
    struct task_struct *task;
    int syscall;

    task = bpf_get_current_task_btf();
    regs = (struct pt_regs *) bpf_task_pt_regs(task);
    // In x86_64 orig_ax has the syscall interrupt stored here
    syscall = regs->orig_ax;
    if (syscall != WRITE_SYSCALL){
        return 0;
    }

    // todo 实现根据文件路径返回-1

    struct dentry *dentry = BPF_CORE_READ(&file->f_path,dentry);
    const unsigned char *filename;
    filename = BPF_CORE_READ(dentry,d_name.name);
    char filename_str[256];
    int name_len = bpf_probe_read_kernel_str(filename_str, sizeof(filename_str), filename);
    // 简单判断先验证方案
    if (name_len>8 && filename_str[0]=='t'&& filename_str[1]=='e') {
        bpf_printk("block write...%s",filename_str);
         return -1;
    }

    return 0;
}

验证结果:

用vim写文件失败:

img

ebpf程序输出的日记:

img

调研结论

调研的几种方案,无论从性能还是可行性方面的考虑,采用eBPF LSM实现Blocking文件写操作都是最优的解决方案。


调研过程输出的相关文章

#eBPF

声明:公众号、CSDN、掘金的曾用名:“Java艺术”,因此您可能看到一些早期的文章的图片有“Java艺术”的水印。

文章推荐

利用eBPF LSM实现Blocking写文件操作

LSM hook允许我们返回非0以Blocking掉系统调用,file_permission这个hook可以用来Blocking vfs_write。

看图理解linux内核网络流量控制工具tc(Traffic Control)

tc是Linux内核中用于流量控制的一套功能强大的工具和框架、是内核的一个子系统。本篇通过画图理解tc涉及的概念,并通过实验理解这些概念,以及了解tc命令怎么用。

一个稍微复杂的ebpf-go学习案例:给vfs_write写操作挂个hook

借助eBPF,给vfs_write写操作挂个hook,可以用这个hook实现一些可观察需求。本案例仅供参考学习,可以把案例中的vfs_write改成其它的。

ebpf-go c结构体和go结构体的映射

使用ebpf-go,假如bpf map的value需要用到结构体,而value由go程序写入,c程序读,value结构体在c中声明,那么怎么生成对应的go结构体呢?

踩坑记,如何使用ebpf-go,实战案例:拦截vfs_read函数获取文件名输出

新手入门调研学习使用ebpf-go的笔记, 记录了笔者在实现demo案例过程中踩的坑,很详细。

如何使用bpftrace追踪系统调用性能问题

所以,这个案例的用途是:在read函数调用之前,记录时间戳,在read函数return前计算方法执行耗时,将结果保存到`us`全局变量。使用`@`声明的全局变量,会在bpftrace脚本进程结束时输出到控制台。