eBPF 助力 NAS 分钟级别 Pod 实例溯源|得物技术

一、背景

云存储 NAS 产品是一个可共享访问、弹性扩展、高可靠、高性能的分布式文件系统。 NAS 兼容了 POSIX 文件接口,可支持数千台计算节点共享访问,可挂载到弹性计算 ECS、容器实例等计算业务上,提供高性能的共享存储服务。

鉴于多主机间共享的便利性和高性能, NAS 在得物的算法训练、应用构建等场景中均成为了基础支撑。

在多业务共享的场景中,单个业务流量异常容易引发全局故障。目前,异常发生后需依赖云服务厂商 NAS 的溯源能力,但只能定位到主机级别,无法识别具体异常服务 。要定位到服务级别,仍需依赖所有使用方协同排查,并由 SRE 多轮统计分析,效率低下(若服务实例发生迁移或重建,排查难度进一步增加)。

为避免因 NAS 异常或带宽占满导致模型训练任务受阻,因此需构建支持服务级流量监控、快速溯源及 NAS 异常实时感知的能力,以提升问题定位效率并减少业务中断。

二、流量溯源方案调研和验证

NAS工作原理

NAS 本地挂载原理

在 Linux 平台上,NAS 的产品底层是基于标准网络文件系统 NFS(Network File System),通过将远端文件系统挂载到本地,实现用户对远端文件的透明访问。

NFS 协议(主要支持 NFS v3 和 v4,通常以 v3 为主)允许将远端服务挂载到本地,使用户能够像访问本地文件目录一样操作远端文件。文件访问请求通过 RPC 协议发送到远端进行处理,其整体流程如下:

文件系统访问时的数据流向示意

Linux 内核中 NFS 文件系统

NFS 文件系统读/写流程

在 Linux NFS 文件系统的实现中,文件操作接口由 nfs_file_operations 结构体定义,其读取操作对应的函数为:

ini 复制代码
//NFS 文件系统的 VFS 层实现的函数如下所示:const struct file_operations nfs_file_operations = {        .llseek           = nfs_file_llseek,        .read_iter        = nfs_file_read,        .write_iter       = nfs_file_write,        // ...};

针对 NFS 文件系统的读操作涉及到 2 个阶段(写流程类似,只是函数名字有所差异,本文仅以读取为例介绍)。由于文件读取涉及到网络操作因此这两个阶段涉及为异步操作:

两个阶段

  • 读取请求阶段: 当应用程序针对 NFS 文件系统发起 read() 读操作时,内核会在VFS层调用 nfs_file_read 函数,然后调用 NFS 层的 nfs_initiate_read 函数,通过 RPC 的 rpc_task_begin 函数将读请求发送到 NFS Server,至此向 NFS Server 发起的请求工作完成。
  • 读响应阶段: 在 NFS Server 返回消息后,会调用 rpc_task_end 和 nfs_page_read_done 等函数,将数据返回到用户空间的应用程序。

在了解 NFS 文件系统的读流程后,我们回顾一下 NFS Server 为什么无法区分单机访问的容器实例或进程实例。

这是因为 NFS 文件系统的读写操作是在内核空间实现的。当容器 A/B 和主机上的进程 C 发起读请求时,这些请求在进入内核空间后,统一使用主机 IP(如 192.168.1.2)作为客户端 IP 地址。因此,NFS Server 端的统计信息只能定位到主机维度,无法进一步区分主机内具体的容器或进程。

内核空间实现示意

方案调研和验证

进程对应容器上下文信息关联

内核中进程以 PID 作为唯一编号,与此同时,内核会建立一个 struct task_struct 对象与之关联,在 struct task_struct 结构会保存进程对应的上下文信息。如实现 PID 信息与用户空间容器上下文的对应(进程 PID 1000 的进程属于哪个 Pod 哪个 Container 容器实例),我们需基于内核 task_struct 结构获取到容器相关的信息。

通过分析内核代码和资料确认,发现可以通过 task_struct 结构中对应的 cgroup 信息获取到进程对应的 cgroup_name 的信息,而该信息中包含了容器 ID 信息,例如 docker-2b3b0ba12e92...983.scope ,完整路径较长,使用 .... 省略。基于容器 ID 信息,我们可进一步管理到进程所归属的 Pod 信息,如 Pod NameSpace 、 Pod Name 、 Container Name 等元信息,最终完成进程 PID 与容器上下文信息元数据关联。

arduino 复制代码
struct task_struct {        struct css_set __rcu                *cgroups;}
struct css_set {        struct cgroup_subsys_state *subsys[CGROUP_SUBSYS_COUNT];}
struct cgroup_subsys_state {        struct cgroup *cgroup;}
struct cgroup {  struct kernfs_node *kn;                /* cgroup kernfs entry */}
struct kernfs_node {        const char                *name;  // docker-2b3b0ba12e92...983.scope}

以某容器进程为例,该进程在 Docker 容器环境中的 cgroup 路径完整为 /sys/fs/cgroup/cpu/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-podefeb3229_4ecb_413a_8715_5300a427db26.slice/docker-2b3b0ba12e925820ac8545f67c8cadee864e5b4033b3d5004d8a3aa742cde2ca.scope 。

经验证,我们在内核中读取 task->cgroups->subsys[0]->kn->name 的值为 docker-2b3b0ba12e925820ac8545f67c8cadee864e5b4033b3d5004d8a3aa742cde2ca.scope。

其中容器 ID 字段为 docker- 与 .scope 间的字段信息,在 Docker 环境中一般取前 12 个字符作为短 ID,如 2b3b0ba12e92 ,可通过 docker 命令进行验证,结果如下:

css 复制代码
docker ps -a|grep 2b3b0ba2b3b0ba12e92        registry-cn-hangzhou-vpc.ack.aliyuncs.com/acs/pause:3.5      

NAS 上下文信息关联

NAS 产品的访问通过挂载命令完成本地文件路径的挂载。我们可以通过 mount 命令将 NAS 手工挂载到本地文件系统中。

bash 复制代码
mount -t nfs -o vers=3,nolock,proto=tcp,rsize=1048576,wsize=1048576,hard,timeo=600,retrans=2,noresvport \  3f0f3489aa-xxxx.cn-shanghai.nas.aliyuncs.com:/test /mnt/nas

执行上述挂载命令成功后,通过 mount 命令则可查询到类似的挂载记录:

bash 复制代码
5368 47 0:660 / /mnt/nas rw,relatime shared:1175 \     - nfs 3f0f3489aa-xxxx.cn-shanghai.nas.aliyuncs.com:/test \         rw,vers=3,rsize=1048576,wsize=1048576,namlen=255,hard,nolock,\     noresvport,proto=tcp,timeo=600,retrans=2,sec=sys, \     mountaddr=192.168.0.91,mountvers=3,mountport=2049,mountproto=tcp,\     local_lock=all,addr=192.168.0.92

核心信息分析如下:

shell 复制代码
# 挂载点 父挂载点 挂载设备号   目录     挂载到本机目录  协议   NAS地址5368     47       0:660     /       /mnt/nas     nfs    3f0f3489aa-xxxx.cn-shanghai.nas.aliyuncs.com:/test                maror:minor 

挂载记录中的 0:660 为本地设备编号,格式为 major:minor , 0 为 major 编号, 660 为 minor 编号,系统主要以 minor 为主。在系统的 NFS 跟踪点 nfs_initiate_read 的信息中的 dev 字段则为在挂载记录中的 minor 编号。

css 复制代码
cat /sys/kernel/debug/tracing/events/nfs/nfs_initiate_read/formatformat:              field:dev_t dev;        offset:8;         size:4;        signed:0;         ...        field:u32 count;        offset:32;        size:4;        signed:0;

通过用户空间 mount 信息和跟踪点中 dev_id 信息,则可实现内核空间设备编号与 NAS 详情的关联。

内核空间信息获取

如容器中进程针对挂载到本地的目录 /mnt/nas 下的文件读取时,会调用到 nfs_file_read() 和 nfs_initiate_read 函数。通过 nfs_initiate_read 跟踪点我们可以实现进程容器信息和访问 NFS 服务器的信息关联。

通过编写 eBPF 程序针对跟踪点 tracepoint/nfs/nfs_initiate_read 触发事件进行数据获取,我们可获取到访问进程所对应的 cgroup_name 信息和访问 NFS Server 在本机的设备 dev_id 编号。

获取cgroup_name信息

  • 进程容器上下文获取: 通过 cgroup_name 信息,如样例中的 docker-2b3b0ba12e92...983.scope ,后续可以基于 container_id 查询到容器对应的 Pod NameSpace 、 Pod Name 和 Container Name 等信息,从而定位到访问进程关联的 Pod 信息。

用户空间元信息缓存

在用户空间中,可以通过解析挂载记录来获取 DEV 信息,并将其与 NAS 信息关联,从而建立以 DevID 为索引的查询缓存。如此,后续便可以基于内核获取到 dev_id 进行关联,进一步补全 NAS 地址及相关详细信息。

对于本地容器上下文的信息获取,最直接的方式是通过 K8s kube-apiserver 通过 list-watch 方法进行访问。然而,这种方式会在每个节点上启动一个客户端与 kube-apiserver 通信,显著增加 K8s 管控面的负担。因此,我们选择通过本地容器引擎进行访问,直接在本地获取主机的容器详情。通过解析容器注解中的 Pod 信息,可以建立容器实例缓存。后续在处理指标数据时,则可以通过 container-id 实现信息的关联与补全。

三、架构设计和实现

整体架构设计

内核空间的信息采集采用 Linux eBPF 技术实现,这是一种安全且高效的内核数据采集方式。简单来说,eBPF 的原理是在内核中基于事件运行用户自定义程序,并通过内置的 map 和 perf 等机制实现用户空间与内核空间之间的双向数据交换。

在 NFS 和 RPC 调用事件触发的基础上,可以通过编写内核空间的 eBPF 程序来获取必要的原始信息。当用户空间程序搜集到内核指标数据后,会对这些原始信息进行二次处理,并在用户空间的采集程序中补充容器进程信息(如 NameSpace、Pod 和 Container 名称)以及 NFS 地址信息(包括 NFS 远端地址)。

内核eBPF程序流程

以 NFS 文件读为例,通过编写 eBPF 程序跟踪 nfs_initiate_read / rpc_task_begin / rpc_task_end / nfs_page_read_done 等关键链路上的函数,用于获取到 NFS 读取的数据量和延时数据,并将访问链路中的进程上下文等信息保存到内核中的指标缓存中。

如上图所示, nfs_initate_read 和 rpc_task_begin 发生在同一进程上下文中,而 rpc_task_begin 与 rpc_task_end 是异步操作,尽管两者不处于同一进程上下文,但可以通过 task_id 进行关联。同时, page_read_done 和 rpc_task_end 则发生在同一进程上下文中。

nfs_initiate_read 函数调用触发的 eBPF 代码示例如下所示:

scss 复制代码
SEC("tracepoint/nfs/nfs_initiate_read")int tp_nfs_init_read(struct trace_event_raw_nfs_initiate_read *ctx)    // 步骤1 获取到 nfs 访问的设备号信息,比如 3f0f3489aa-xxxx.cn-shanghai.nas.aliyuncs.com    // dev_id 则为: 660     dev_t dev_id = BPF_CORE_READ(ctx, dev);    u64 file_id = BPF_CORE_READ(ctx, fileid);    u32 count = BPF_CORE_READ(ctx, count);       struct task_struct *task = (struct task_struct *)bpf_get_current_task();
    // 步骤2 获取进程上下文所在的容器 cgroup_name 信息    // docker-2b3b0ba12e925820ac8545f67c8cadee864e5b4033b3d5004d8a3aa742cde2ca.scope    const char *cname = BPF_CORE_READ(task, cgroups, subsys[0], cgroup, kn, name);    if (cname)    {        bpf_core_read_str(&info.container, MAX_PATH_LEN, cname);    }
    bpf_map_update_elem(&link_begin, &tid, &info, BPF_ANY);}
SEC("tracepoint/nfs/nfs_readpage_done")int tp_nfs_read_done(struct trace_event_raw_nfs_readpage_done *ctx){   //... 省略}
SEC("tracepoint/sunrpc/rpc_task_begin")int tp_rpc_task_begin(struct trace_event_raw_rpc_task_running *ctx){    //... 省略}
SEC("tracepoint/sunrpc/rpc_task_end")int tp_rpc_task_done(struct trace_event_raw_rpc_task_running *ctx){   //... 省略}

用户空间程序架构

元数据缓存

NAS 挂载信息缓存

通过解析挂载记录,可以获取 DEV 信息与 NAS 信息的关联关系。以下是实现该功能的关键代码详情:

css 复制代码
scanner := bufio.NewScanner(mountInfoFile)count := 0for scanner.Scan() {    line := scanner.Text()    devID,remoteDir, localDir, NASAddr = parseMountInfo(line)
    mountInfo := MountInfo{       DevID:         devID,       RemoteDir:     remoteDir,       LocalMountDir: localDir,       NASAddr: NASAddr,    }    mountInfos = append(mountInfos, mountInfo)

※ 容器元信息缓存

通过 Docker 或 Containerd 客户端,从本地读取单机的容器实例信息,并将容器的上下文数据保存到本地缓存中,以便后续查询使用。

css 复制代码
podInfo := PodInfo{    NameSpace:     labels["io.kubernetes.pod.namespace"],    PodName:       labels["io.kubernetes.pod.name"],    ContainerName: labels["io.kubernetes.container.name"],    UID:           labels["io.kubernetes.pod.uid"],    ContainerID:   conShortID,}

数据处置流程

用户空间程序的主要任务是持续读取内核 eBPF 程序生成的指标数据,并对读取到的原始数据进行处理,提取访问设备的 dev_id 和 container_id 。随后,通过查询已建立的元数据缓存,分别获取 NAS 信息和容器 Pod 的上下文数据。最终,经过数据合并与处理,生成指标数据缓存供后续使用。

css 复制代码
func (m *BPFEventMgr) ProcessIOMetric() {    // ...    events := m.ioMetricMap    iter := events.Iterate()
    for iter.Next(&nextKey, &event) {       // ① 读取到的 dev_id 转化为对应的完整 NAS 信息       devId := nextKey.DevId       mountInfo, ok := m.mountMgr.Find(int(devId))
       // ② 读取 containerID 格式化并查询对应的 Pod 上下文信息       containerId := getContainerID(nextKey.Container)       podInfo, ok = m.criMgr.Find(containerId)             // ③ 基于事件信息、NAS 挂载信息和 Pod 上下文信息,生成指标数据缓存        metricKey, metricValue := formatMetricData(nextKey, mountInfo, podInfo)       value, loaded := metricCache.LoadOrStore(metricKey, metricValue)    }        // ④ 指标数据缓存,生成最终的 Metrics 指标并更新     var ioMetrics []metric.Counter    metricCache.Range(func(key, value interface{}) bool {       k := key.(metric.IOKey)       v := value.(metric.IOValue)
       ioMetrics = append(ioMetrics, metric.Counter{"read_count", float64(v.ReadCount),             []string{k.NfsServer, v.NameSpace, v.Pod, v.Container})         // ...       }       return true    })        m.metricMgr.UpdateIOStat(ioMetrics)}

启动 Goroutine 处理指标数据:通过启动一个 Goroutine,循环读取内核存储的指标数据,并对数据进行处理和信息补齐,最终生成符合导出格式的 Metrics 指标。

※ 具体步骤

  • 获取 NAS 信息: 从读取的原始数据中提取 dev_id ,并通过 dev_id 查询挂载的 NAS 信息,例如远端访问地址等相关数据。
  • 查询 Pod 上下文: 对 containerID 进行格式化处理,并查询对应的容器 Pod 上下文信息。
  • 生成指标数据缓存: 基于事件数据、NAS 挂载信息和 Pod 上下文信息,生成指标数据缓存。此过程主要包括对相同容器上下文的数据进行合并和累加。
  • 导出 Metrics 指标: 根据指标数据缓存,生成最终的 Metrics 指标,并更新到指标管理器。随后,通过自定义的 Collector 接口对外导出数据。当 Prometheus 拉取数据时,指标会被转换为最终的 Metrics 格式。

通过上述步骤,用户空间能够高效地处理内核 eBPF 程序生成的原始数据,并结合 NAS 挂载信息和容器上下文信息,生成符合 Prometheus 标准的 Metrics 指标,为后续的监控和分析提供了可靠的数据基础。

自定义指标导出器

在导出指标的场景中,我们需要基于保存在 Go 语言中的 map 结构中的动态数据实时生成,因此需要实现自定义的 Collector 接口。自定义 Collector 接口需要实现元数据描述函数 Describe() 和指标搜集的函数 Collect() ,其中 Collect() 函数可以并发拉取,因此需要通过加锁实现线程安全。该接口需要实现以下两个核心函数:

  • Describe() :用于定义指标的元数据描述,向 Prometheus 注册指标的基本信息。
  • Collect() :用于搜集指标数据,该函数支持并发拉取,因此需要通过加锁机制确保线程安全。
go 复制代码
type Collector interface {    // 指标的定义描述符    Describe(chan<- *Desc)       // 并将收集的数据传递到Channel中返回    Collect(chan<- Metric)}

我们在指标管理器中实现 Collector 接口, 部分实现代码,如下所示:

go 复制代码
nfsIOMetric := prometheus.NewDesc(    prometheus.BuildFQName(prometheusNamespace, "", "io_metric"),    "nfs io metrics by cgroup",    []string{"nfs_server", "ns", "pod", "container", "op", "type"},    nil,)
// Describe and Collect implement prometheus collect interfacefunc (m *MetricMgr) Describe(ch chan<- *prometheus.Desc) {    ch <- m.nfsIOMetric}
func (m *MetricMgr) Collect(ch chan<- prometheus.Metric) {    // Note:加锁保障线程并发安全    m.activeMutex.Lock()    defer m.activeMutex.Unlock()        for _, v := range m.ioMetricCounters {       ch <- prometheus.MustNewConstMetric(m.nfsIOMetric, prometheus.GaugeValue, v.Count, v.Labels...)    }

四、总结

当前 NAS 溯源能力已正式上线,以下是主要功能和视图介绍:

※ 单 NAS 实例整体趋势

支持基于环境和 NAS 访问地址过滤,展示 NAS 产品的读写 IOPS 和吞吐趋势图。同时,基于内核空间统计的延时数据,提供 P95 读写延时指标,用于判断读写延时情况,辅助问题分析和定位。

在 NAS 流量溯源方面,我们结合业务场景设计了基于任务和 Pod 实例维度的流量分析视图:

※ 任务维度流量溯源

通过聚合具有共同属性的一组 Pod 实例,展示任务级别的整体流量情况。该视图支持快速定位任务级别的流量分布,帮助用户进行流量溯源和多任务错峰使用的依据。

※ Pod 实例维度流量溯源

以 Pod 为单位进行流量分析和汇总,提供 Pod NameSpace 和 Name 信息,支持快速定位和分析实例级别的流量趋势,帮助细粒度监控和异常流量的精准定位。

在整体能力建设完成后,我们成功构建了 NAS 实例级别的 IOPS、吞吐和读写延时数据监控大盘。通过该能力,进一步实现了 NAS 实例的 IOPS 和吞吐可以快速溯源到任务级别和 Pod 实例级别,流量溯源时效从小时级别缩短至分钟级别,有效提升了异常问题定位与解决的效率。同时,基于任务流量视图,我们为后续带宽错峰复用提供了直观的数据支持。

往期回顾

1.正品库拍照PWA应用的实现与性能优化|得物技术

2.汇金资损防控体系建设及实践 | 得物技术

3.一致性框架:供应链分布式事务问题解决方案|得物技术

4.得物社区活动:组件化的演进与实践

5.从CPU冒烟到丝滑体验:算法SRE性能优化实战全揭秘|得物技术

文 / 泊明

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

相关推荐
深度学习040720 分钟前
【Linux服务器】-安装ftp与sftp服务
linux·运维·服务器
iteye_99391 小时前
让 3 个线程串行的几种方式
java·linux
渡我白衣2 小时前
Linux操作系统:再谈虚拟地址空间
linux
阿巴~阿巴~2 小时前
Linux 第一个系统程序 - 进度条
linux·服务器·bash
DIY机器人工房2 小时前
代码详细注释:通过stat()和lstat()系统调用获取文件的详细属性信息
linux·嵌入式
望获linux3 小时前
【Linux基础知识系列】第四十三篇 - 基础正则表达式与 grep/sed
linux·运维·服务器·开发语言·前端·操作系统·嵌入式软件
眠りたいです4 小时前
Mysql常用内置函数,复合查询及内外连接
linux·数据库·c++·mysql
我的泪换不回玫瑰4 小时前
Linux系统管理命令
linux
jjkkzzzz5 小时前
Linux下的C/C++开发之操作Zookeeper
linux·zookeeper·c/c++
二当家的素材网5 小时前
Centos和麒麟系统如何每天晚上2点10分定时备份达梦数据库
linux·数据库·centos