云存储 NAS 产品是一个可共享访问、弹性扩展、高可靠、高性能的分布式文件系统。 NAS 兼容了 POSIX 文件接口,可支持数千台计算节点共享访问,可挂载到弹性计算 ECS、容器实例等计算业务上,提供高性能的共享存储服务。
鉴于多主机间共享的便利性和高性能, NAS 在得物的算法训练、应用构建等场景中均成为了基础支撑。
在多业务共享的场景中,单个业务流量异常容易引发全局故障。目前,异常发生后需依赖云服务厂商 NAS 的溯源能力,但只能定位到主机级别,无法识别具体异常服务。要定位到服务级别,仍需依赖所有使用方协同排查,并由 SRE 多轮统计分析,效率低下(若服务实例发生迁移或重建,排查难度进一步增加)。
为避免因 NAS 异常或带宽占满导致模型训练任务受阻,因此需构建支持服务级流量监控、快速溯源及 NAS 异常实时感知的能力,以提升问题定位效率并减少业务中断。
NAS 本地挂载原理
在 Linux 平台上,NAS 的产品底层是基于标准网络文件系统 NFS(Network File System),通过将远端文件系统挂载到本地,实现用户对远端文件的透明访问。
NFS 协议(主要支持 NFS v3 和 v4,通常以 v3 为主)允许将远端服务挂载到本地,使用户能够像访问本地文件目录一样操作远端文件。文件访问请求通过 RPC 协议发送到远端进行处理,其整体流程如下:
文件系统访问时的数据流向示意
Linux 内核中 NFS 文件系统
NFS 文件系统读/写流程
在 Linux NFS 文件系统的实现中,文件操作接口由 nfs_file_operations 结构体定义,其读取操作对应的函数为:
//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 文件系统的读流程后,我们回顾一下 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 与容器上下文信息元数据关联。
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 命令进行验证,结果如下:
docker ps -a|grep 2b3b0ba
2b3b0ba12e92 registry-cn-hangzhou-vpc.ack.aliyuncs.com/acs/pause:3.5
NAS 上下文信息关联
NAS 产品的访问通过挂载命令完成本地文件路径的挂载。我们可以通过 mount 命令将 NAS 手工挂载到本地文件系统中。
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 命令则可查询到类似的挂载记录:
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
核心信息分析如下:
# 挂载点 父挂载点 挂载设备号 目录 挂载到本机目录 协议 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 编号。
cat /sys/kernel/debug/tracing/events/nfs/nfs_initiate_read/format
format:
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信息
用户空间元信息缓存
在用户空间中,可以通过解析挂载记录来获取 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 远端地址)。
以 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 代码示例如下所示:
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 信息的关联关系。以下是实现该功能的关键代码详情:
scanner := bufio.NewScanner(mountInfoFile)
count := 0
for 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 客户端,从本地读取单机的容器实例信息,并将容器的上下文数据保存到本地缓存中,以便后续查询使用。
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 的上下文数据。最终,经过数据合并与处理,生成指标数据缓存供后续使用。
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 指标。
※ 具体步骤
通过上述步骤,用户空间能够高效地处理内核 eBPF 程序生成的原始数据,并结合 NAS 挂载信息和容器上下文信息,生成符合 Prometheus 标准的 Metrics 指标,为后续的监控和分析提供了可靠的数据基础。
自定义指标导出器
在导出指标的场景中,我们需要基于保存在 Go 语言中的 map 结构中的动态数据实时生成,因此需要实现自定义的 Collector 接口。自定义 Collector 接口需要实现元数据描述函数 Describe() 和指标搜集的函数 Collect() ,其中 Collect() 函数可以并发拉取,因此需要通过加锁实现线程安全。该接口需要实现以下两个核心函数:
type Collector interface {
// 指标的定义描述符
Describe(chan<- *Desc)
// 并将收集的数据传递到Channel中返回
Collect(chan<- Metric)
}
我们在指标管理器中实现 Collector 接口, 部分实现代码,如下所示:
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 interface
func (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性能优化实战全揭秘|得物技术
文 / 泊明
关注得物技术,每周更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。