一团乱的存储
在 PaaS 搭建初期,针对 Kubernetes 持久化存储选择使用 NFS Server 作为后端存储。随着 PaaS 平台在公司内部推广,迁移的服务越来越多,使用 NFS 作为 PV 存储日志、配置、临时数据的服务越来越多。伴随着产品线的调整,大量服务被废弃,更多的服务被创建。整个 NFS 内的数据空间也指数上涨,垃圾文件的回收就变成非常棘手的事情。针对前面提到的问题,是不是可以通过 eBPF 技术抓取到容器的 NFS 访问请求,从而拿到服务和 NFS 目录之间的关联关系,以及读写频次、高频读取文件等信息,为后续的治理工作进行前期数据的采集。
尝试解决
目标
1. 使用 cgroup ID 或者 cgroup Name 作为标识,降低后期数据匹配的难度。
2. NFS 请求挂载点需要精确,能够准确反映服务连接 NFS 信息。
容器
思路1: docker api
出于 k8s yaml 工程师的习惯思维,每个节点 DS 先起一堆 agnet,先在 agent 内部构建一个本地缓存,通过 docker client 将 PID--DockerID--Pod 的对应关系进行绑定,以 PID 作为 KEY 存储到缓存。通过 eBPF 挂载点拿到 PID,然后扔到 BPF_MAP_TYPE_PERF_EVENT_ARRAY
队列。用户态消费队列,拿到 PID 之后本地缓存查询,命中拿到一组对应关系,再以 Pod 为纬度进行 NFS 访问信息的汇总。最后再通过 prometheus exporter 暴露给 prometheus 或者 VictoriaMetrics。
上面的思路问题在于太重了,依赖 docker client ,有可能有的云已经使用 containerd ,不能每种 runtime 都适配一遍吧。
思路2: eBPF helper function
1.
bpf_get_current_cgroup_id
这个函数允许 eBPF 程序获取当前任务所运行的 cgroup ID,但是该函数仅支持获取 cgroup v2 下的进程 cgroup ID,cgroup v1 下如果通过该函数获取到的 cgroup ID 为固定值,不同操作操作系统下的值会不一样。
cgroup v1 | cgroup v2 |
1.
bpf_get_current_task
用于获取当前执行任务的task_struct
指针。task_struct
是 Linux 内核中的一个结构体,表示进程或线程的状态和信息。每个进程在内核中都有一个对应的task_struct
结构体,包含了进程的各种属性和控制信息。可以通过task_struct
结构体中sched_task_group->css.cgroup->kn->name
获取到当前任务的 cgroup name。Event Pid: 919224, Cgroup ID: 4294967297 , Cgroup path: docker.service
Event Pid: 919232, Cgroup ID: 4294967297 , Cgroup path: docker.service
Event Pid: 919239, Cgroup ID: 4294967297 , Cgroup path: docker.service
Event Pid: 919246, Cgroup ID: 4294967297 , Cgroup path: cri-docker.service
Event Pid: 919241, Cgroup ID: 4294967297 , Cgroup path: docker-7235dedbdf81108b9716aa63300c8ded9d98e6e6e01bd7d00776ce6c9a3380c2.scope
Event Pid: 919251, Cgroup ID: 4294967297 , Cgroup path: docker-7235dedbdf81108b9716aa63300c8ded9d98e6e6e01bd7d00776ce6c9a3380c2.scope2.
rpc_task
是在 Linux 内核中用于管理远程过程调用(Remote Procedure Call, RPC)任务的结构体。rpc_task->tk_owner
可以获取到当前请求属于进程 PID 号,可以通过/proc/{pid}/cgroup
文件的内容描述了特定进程(由<pid>
指定)在系统的控制组(cgroup)层次结构中的位置。内容的解释如下:
行 1
1:name=systemd:/kubepods.slice/kubepods-besteffort.slice/kubepods-besteffort-pod0ae41708_2065_452e_ac7b_76cb5bf987a1.slice/docker-78ac8451459639bcbf6bb89f1aa26024ce0e9ea4892772ad5b2116a631948ab0.scope
解释:
• 层次结构 ID (
1
):• 这是该 cgroup 层次结构的唯一标识符,系统中每个不同的层次结构都有唯一的 ID。
• 子系统 (
name=systemd
):•
name=systemd
指定了这个层次结构与systemd
子系统相关联。systemd
是一个用于初始化系统和管理系统服务的系统管理守护进程,这里它用来管理 cgroup。• cgroup 路径 (
/kubepods.slice/kubepods-besteffort.slice/kubepods-besteffort-pod0ae41708_2065_452e_ac7b_76cb5bf987a1.slice/docker-78ac8451459639bcbf6bb89f1aa26024ce0e9ea4892772ad5b2116a631948ab0.scope
):• 这个路径表示进程在
systemd
子系统中的 cgroup 层次结构。•
kubepods.slice
: 通常用于标识 Kubernetes 管理的 Pod 的 cgroup 层次结构。•
kubepods-besteffort.slice
: 这一层表明该 Pod 属于 Kubernetes 的 "besteffort" QoS 类,这意味着它的资源请求和限制最小,是优先级最低的。•
kubepods-besteffort-pod0ae41708_2065_452e_ac7b_76cb5bf987a1.slice
: 这是 Pod 的唯一标识符,由 Pod 的名称或 UUID 组成。•
docker-78ac8451459639bcbf6bb89f1aa26024ce0e9ea4892772ad5b2116a631948ab0.scope
: 这表明该进程是由 Docker 容器启动的,78ac8451459639bcbf6bb89f1aa26024ce0e9ea4892772ad5b2116a631948ab0
是该 Docker 容器的 ID。
说明:
这部分内容显示了进程被管理在一个 Kubernetes Pod 中,该 Pod 的 QoS 类别是 "besteffort",并且进程是由 Docker 容器管理的。
行 2
0::/
解释:
• 层次结构 ID (
0
):•
0
通常表示根 cgroup 层次结构。• 子系统:
• 没有特定的子系统(标识为空)。
• cgroup 路径 (
/
):• 表示该进程处于根 cgroup 中,通常是默认的 cgroup,可能没有具体的资源限制。
NFS
挂载点
kprobe/nfs4_read_done_cb
nfs4_read_done_cb
是一个用于处理 NFSv4 读取操作完成回调的内核函数。这个函数的主要职责是处理读取操作的结果,包括处理成功的读取和错误情况。以下是这个函数的关键步骤和逻辑:
1. 获取服务器信息:
struct nfs_server *server = NFS_SERVER(hdr->inode);
这行代码从读取头部信息 (
hdr
) 中获取关联的 NFS 服务器信息。2. 追踪读取操作:
trace_nfs4_read(hdr, task->tk_status);
使用跟踪机制记录读取操作的信息和状态 (
task->tk_status
),以便于调试和性能监控。3. 错误处理:
if (task->tk_status < 0) {
struct nfs4_exception exception = {
.inode = hdr->inode,
.state = hdr->args.context->state,
.stateid = &hdr->args.stateid,
};
task->tk_status = nfs4_async_handle_exception(task,
server, task->tk_status, &exception);
if (exception.retry) {
rpc_restart_call_prepare(task);
return -EAGAIN;
}
}如果
task->tk_status
小于 0,表示读取操作出现了错误。函数创建一个nfs4_exception
结构体来保存错误信息,包括 inode、状态和状态ID。nfs4_async_handle_exception
函数用于处理该异常,如果异常需要重试,调用rpc_restart_call_prepare
准备重新执行 RPC 调用,并返回-EAGAIN
表示需要重试。4. 更新租约:
if (task->tk_status > 0)
renew_lease(server, hdr->timestamp);如果读取操作成功 (
task->tk_status
大于 0),则更新与 NFS 服务器的租约信息,这通常是为了维持客户端对文件的访问权。5. 返回值:
return 0;
最后,函数返回 0,表示读取操作完成。
kprobe/nfs_file_read
nfs_file_read
函数在 NFS 文件系统上读取文件数据。它首先检查读取是否为直接 I/O,如果是,则调用 nfs_file_direct_read
。如果不是,则启动 I/O 读取,并通过 nfs_revalidate_mapping
验证文件缓存。接着,使用 generic_file_read_iter
执行实际的读取操作。如果读取成功,更新 NFS 统计数据。最后,结束 I/O 读取操作并返回读取的字节数或错误代码。
kprobe/nfs_readpages
nfs_readpages
是一个用于从 NFS 服务器读取多个页面的函数。它尝试从缓存中读取数据,如果缓存未命中则执行异步读取。该函数处理从文件的多个页面中读取数据,并负责管理读取操作的上下文和统计数据。通过缓存和异步 I/O 提高读取性能,并在必要时处理错误和重试逻辑。
kprobe/nfs_file_write
nfs_file_write
函数负责在 NFS 上执行文件写入操作。它首先检查文件密钥超时和直接 I/O 模式,处理追加模式或文件大小验证,清除无效映射,然后执行通用写入检查和操作。函数根据挂载标志进行数据同步或等待操作,处理写入错误并更新统计数据。最后,确保在处理交换文件时不会写入,并返回适当的错误或成功状态。
kprobe/nfs_readpage_done
nfs_readpage_done
函数是 RPC 回调,用于处理读取操作的完成状态。该函数首先调用 NFS_PROTO(inode)->read_done
检查读取是否成功,如果失败则返回错误状态。成功时,函数更新 NFS 统计数据并记录读取的字节数。如果任务状态是 -ESTALE
(过期),函数会将 inode 标记为过期并标记需要重新验证。函数最终返回 0 表示操作完成。
eBPF 代码
这段 eBPF 代码主要用于追踪 NFS(Network File System)中某个特定回调函数 nfs4_read_done_cb
的调用情况,并收集一些相关信息。下面是对这段代码的详细分析:
头文件和许可证
#include <common.h>
#include <vmlinux.h>
#include <bpf_helpers.h>
#include <bpf_core_read.h>
#include <bpf_endian.h>
#include <bpf_tracing.h>
char __license[] SEC("license") = "Dual MIT/GPL";
• 代码包含了多种头文件,这些文件提供了 eBPF 的辅助函数和结构体定义。
•
__license
声明了该 eBPF 程序的许可证为 "Dual MIT/GPL"。
数据结构定义
struct rpc_task_fields {
u64 pid;
int status;
int owner_pid;
char cgroup_name[256];
};
•
rpc_task_fields
结构体用于存储从 eBPF 程序中收集到的信息,包括:•
pid
: 当前进程的 PID。•
status
: RPC 任务的状态。•
owner_pid
: 任务所有者的 PID。•
cgroup_name
: 进程所在的控制组名称。
struct rpc_task_fields *unused_event __attribute__((unused));
•
unused_event
是一个指向rpc_task_fields
结构体的指针,带有__attribute__((unused))
标记,表示这个变量可能未被使用。
BPF 映射定义
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
} rpc_task_map SEC(".maps");
• 定义了一个 BPF 映射
rpc_task_map
,类型为BPF_MAP_TYPE_PERF_EVENT_ARRAY
。这个映射用于存储 eBPF 程序输出的事件数据,可以被用户空间读取。
eBPF 程序
SEC("kprobe/nfs4_read_done_cb")
int kprobe_nfs4_read_done_cb(struct pt_regs *regs) {
• 这段代码定义了一个 eBPF kprobe 程序,附加到内核函数
nfs4_read_done_cb
上。这意味着每次调用该函数时,eBPF 程序都会被触发。
收集数据
struct rpc_task_fields event = {};
int owner_pid;
u64 id = bpf_get_current_pid_tgid();
u32 pid = id >> 32; // PID is higher part
event.pid = pid;
• 获取当前进程的 PID 和 TGID(线程组 ID),并存储在
event.pid
中。
struct rpc_task *task = (struct rpc_task *)PT_REGS_PARM1(regs);
int status = BPF_CORE_READ(task, tk_status);
• 从函数参数中获取
rpc_task
结构体指针,并读取任务的状态tk_status
。
owner_pid = BPF_CORE_READ(task, tk_owner);
event.status = status;
event.owner_pid = owner_pid;
• 读取任务的所有者 PID (
tk_owner
) 并存储在event.owner_pid
中,同时记录任务的状态。
struct task_struct *cur_tsk = (struct task_struct *)bpf_get_current_task();
const char *name = BPF_CORE_READ(cur_tsk, sched_task_group, css.cgroup, kn, name);
bpf_probe_read_str(&event.cgroup_name, sizeof(event.cgroup_name), name);
• 获取当前任务的
task_struct
结构体,并读取其控制组名称。使用bpf_probe_read_str
将控制组名称读入event.cgroup_name
。
输出事件
bpf_perf_event_output(regs, &rpc_task_map, BPF_F_CURRENT_CPU, &event, sizeof(event));
• 使用
bpf_perf_event_output
函数将收集到的事件数据输出到rpc_task_map
,供用户空间读取。
丢失的内核函数
错误
eBPF 代码编译之后,生产环境能够正常运行,但是在有些节点启动会报下面的错误:
# ./nfs_read
2024/07/26 06:37:19 opening kprobe: creating perf_kprobe PMU (arch-specific fallback for "nfs4_read_done_cb"): token __x64_nfs4_read_done_cb: not found: no such file or directory
分析
出现这个错误是因为 eBPF 程序试图在系统中注册一个 kprobe(内核探针)到名为 nfs4_read_done_cb
的函数,但无法找到这个函数或其对应的内核符号。
在异常节点查询 cat /proc/kallsyms | grep nfs4_read_done_cb
确实没有对应的挂载点,不光当前挂载点,上一章节涉及所有 NFS 挂载均找不到。
root@node1:~# cat /proc/kallsyms | grep nfs4_read_done_cb
root@node1:~#
正常可以启动的节点查询结果如下:
root@node1:~# cat /proc/kallsyms | grep nfs4_read_done_cb
ffffffffc0eb75d0 t nfs4_read_done_cb [nfsv4]
修复
1. Issue 1[1]:nfsslower crashes on kernel 5.10.0: cannot attach kprobe, probe entry may not exist #3438 该 issue 遇到问题类似,@chenhengqi[2] 提示试试手动加载 nfs 内核模块,执行
modprobe nfs
之后,发现nfs_file_read
、nfs_readpages
、nfs_file_write
、nfs_readpage_done
可以正常挂载,但是nfs4_read_done_cb
依然不存在。2. 由于开发机器上没有
nfs4_read_done_cb
函数,只能在开发机上编译完,拿到线上环境进行测试,整个过程比较繁琐。所以尝试分析了线上开发机的区别,内核版本、操作系统版本都是一样的,只有开发机上没有真正挂载 NFS。尝试在开发机部署 NFS server ,同时挂载 NFS 目录到本地。挂载成功之后,再查询nfs4_read_done_cb
就存在了,具体原因还没有找到,这里先标记下,后面单独开篇文章分析。root@node1:~# cat /proc/kallsyms | grep nfs4_read_done_cb
ffffffffc0eb75d0 t nfs4_read_done_cb [nfsv4]
消失的结构体
错误
eBPF 代码在开发机测试正常之后,准备部署到生产环境,生产环境报下面的错误:
# ./nfs_read
2024/07/26 15:31:52 loading objects: field KprobeNfs4ReadDoneCb: program kprobe_nfs4_read_done_cb: load program: bad CO-RE relocation: invalid func unknown#195896080 (12 line(s) omitted)
分析
这195896080
是0xbad2310
十六进制的(表示“bad relo”),是 libbpf 用来标记 CO-RE 重定位失败的指令的常量。某些内核中某些字段缺失的情况并不罕见。如果 BPF 程序尝试使用 BPF_CORE_READ()
读取缺失字段,则会导致 BPF 验证期间出错。同样,当获取主机内核中不存在的枚举器(或类型)的枚举值(或类型大小)时,CO-RE 重定位将失败。通过代码的调试发现尝试使用 BPF_CORE_READ()
读取 rpc_task->tk_owner
数据的时候报错。
1. 先检查编译时候加载的头文件,
vmlinux_x86
和vmlinux_arm
中都是存在rpc_task
结构体定义的,否则编译的时候应该就会失败。2. 检查开发环境 BTF 文件导出 vmlinux 头文件,发现存在
rpc_task
结构体。3. 检查线上环境 BTF 文件导出 vmlinux 头文件,发现缺失
rpc_task
结构体,有问题操作系统为 Alibaba Cloud Linux release 3 (Soaring Falcon) ,内核版本 5.10.134-16.3.al8.x86_64。4. 检查 KY10 sp3 内核版本 4.19以及 ubuntu 22.04 内核版本 5.15 BTF 文件,均发现存在
rpc_task
,能够正常抓取 nfs 读取信息。
总结,基于上面的分析,应该是有问题的内核编译过程中裁剪了一些内核模块,需要尝试重新编译 5.10 版本内核试试。
修复
1. 下载 5.10 内核[3] ,需要安装一些编译需要的工具,跟着 make 文件的信息安装就行,这里编译前置操作就不赘述。执行 make menuconfig
检查内核默认参数,发现 NFS 相关编译配置为 M
。 M
表示将该功能或模块编译为内核模块。这些模块在系统启动后可以动态加载和卸载。这种方式的优点是灵活性高,可以根据需要加载或卸载模块,而无需重新启动系统。 Y
表示将特定的内核功能或模块编译成内核的一部分。这意味着该功能将静态链接到内核映像中,在系统启动时自动加载。对于使用 M
选项编译的内核模块,这些模块的类型信息通常不会包含在内核主 BTF 中,因为 BTF 通常与内核映像一起生成和分发,而模块是动态加载的。这意味着:基于上面的分析,需要调整内核编译参数,将 NFS 相关的模块编译模式改成 Y
,具体修改参考下图:
默认参数 | 调整参数 |
• 模块元数据的缺失:在 BTF 中,通常不包含动态加载的内核模块的类型信息。因此,eBPF 程序在访问这些模块的数据结构时,可能会遇到困难,因为这些结构的元数据不在 BTF 中。
• 模块特定的 BTF:有时,模块也可以有自己的 BTF 文件,这些文件可以与模块一起分发,以便 eBPF 程序在加载这些模块时可以使用这些元数据。然而,这并不是普遍做法,也需要额外的支持和配置。
2. 执行 make -j$(nproc)
进行内核编译,编译的时候会出现 BTF 生成失败的问题,原因是 pahole 版本太高导致,5.10 版本内核使用 pahole 1.22 版本即可。
3. 编译成功之后,在内核目录下得到 vmlinux
700M 大小文件,使用 bpftool btf dump file /path/to/vmlinux format c > vmlinux.h
命令生成头文件,检查头文件发现 rpc_task
已经存在,说明新编译的 BTF 文件中包含 NFS 模块的元数据。
1. 修改用户态 go 代码,对于
/sys/kernel/btf/vmlinux
原始 BTF 不存在rpc_task
定义的节点,读取新编译 BTF ,代码如下:func main() {
// 从命令行获取 BTF 文件路径
btfPath := flag.String("btf", "", "path to BTF file")
flag.Parse()
fmt.Printf("btfPath: %s", *btfPath)
// Load pre-compiled programs and maps into the kernel.
objs := bpfObjects{}
var opts *ebpf.CollectionOptions
if len(*btfPath) != 0 {
spec, err := btf.LoadSpec(*btfPath)
if err != nil {
log.Fatalf("loading BTF: %v", err)
}
opts = &ebpf.CollectionOptions{
Programs: ebpf.ProgramOptions{
KernelTypes: spec,
},
}
}
if err := loadBpfObjects(&objs, opts); err != nil {
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()
}
引用链接
[1]
Issue 1: https://github.com/iovisor/bcc/issues/3438[2]
chenhengqi: https://github.com/chenhengqi[3]
下载 5.10 内核: https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.10.134.tar.xz