阅读视图

发现新文章,点击刷新页面。

从Rust模块化探索到DLB 2.0实践|得物技术

一、前言

在云原生架构高速迭代的背景下,基础设施的性能瓶颈与安全隐患成为技术演进的关键挑战。本文系统记录了团队基于Rust语言改造Nginx组件的完整技术路径:从接触Cloudflare的quiche库,引发对Rust安全特性的探索,到通过FFI实现核心逻辑的跨语言调用;从突破传统C模块开发范式自研 ngx_http_rust_module SDK ,到全面采用Pingora框架构建新一代DLB 2.0流量调度平台。

实践表明,Rust的内存安全机制与异步高并发能力可显著提升负载均衡组件的性能边界与可靠性,为超大规模流量调度场景提供全新解决方案。本技术演进过程将详述架构设计、核心模块实现及性能优化策略,为同类基础设施升级提供可复用的工程经验。

二、Nginx+Rust 的模块化探索

探索的起点源于和quiche(cloudflare开发的高效quic实现)的初次邂逅,这扇门将项目组成员引入了 Rust 语言的世界。Rust 以其卓越的内存安全、无惧并发的特性以及出色的性能潜力,迅速展示了其作为系统级编程语言的优势。这份吸引力促使我们思考:能否将 Rust 的安全与性能注入我们更广泛的基础设施中?作为核心组件的 Nginx 自然成为了探索的焦点。

我们首先聚焦于FFI(外部函数接口)技术,通过它构建Rust与C语言的交互桥梁。借助FFI,我们将核心业务逻辑以Rust实现,并将Rust代码编译为符合C-ABI规范的动态链接库。这种设计使得Nginx能够像调用原生C模块一样无缝集成Rust编写的库,在保障系统稳定性的同时提升性能。

图片

采用该方案的限流模块示例如下:

图片

鉴于单向调用模式在应用场景上的局限性,如果仅仅支持上面的单向调用流,使用的场景将大打折扣,目前Nginx 中大量的功能以三方模块的形式呈现,C module的开发难度较高,需要理解的组件概念颇多,团队尝试开发了ngx_http_rust_module模块作为一个探索期的折中方案。

图片

ngx_http_rust_module本质上是一个Rust SDK,是对传统C模块开发模式的一种现代化补充尝试。SDK层封装好的胶水function极大便利了rust module上层开发,可以实现纯Rust编码来实现业务功能,实践验证具备较高工程价值。

目前已封装的部分SDK展示以及设置响应header方法示例:

图片图片

三、全面拥抱Rust进入DLB2.0阶段

完成Nginx模块的初步探索后,团队技术路线转向Cloudflare开源的Pingora框架,该高性能Rust框架专为构建可编程、高可靠的流量调度平台而设计。

核心优势

  • 云原生架构 :通过异步任务调度消除Nginx的进程隔离瓶颈,实现CPU负载均衡与高效连接复用。
  • 性能突破 :实测每秒可处理10万请求,资源消耗降至传统方案的三分之一。
  • 协议生态 :原生支持HTTP/1-2、Websocket端到端代理。
  • 安全演进 :基于Rust内存安全特性,集成FIPS认证的加密模块解决C/C++方案的安全隐患。
  • 扩展能力 :提供可编程负载均衡API与热升级机制,满足超大规模流量调度需求。

在原型验证其技术可行性之后,团队决定在该框架骨干上构建了DLB 2.0产品体系:

图片

核心能力设计

  • 声明式配置管理
    • 提供基于YAML的声明式配置接口,显著提升配置可读性与维护效率。
    • 支持热加载机制,实现流量无损的配置更新,彻底规避传统代理重载导致的503服务中断。
  • 流量处理
    • 支持单一端口多域名TLS证书托管能力,简化HTTPS服务部署。
    • 提供与Nginx完全兼容的server/path路由匹配逻辑,确保无缝迁移。
    • 实现路径重写引擎,满足复杂流量调度需求。
    • 采用模块化Filter链设计,支持按需插拔流量处理组件。
  • 服务发现
    • 集成静态资源配置与动态DNS服务发现双模式。
    • 支持sylas注册中心。
    • 企业级监控。
    • 提供增强型访问日志。
    • 输出完全兼容DLB 1.0的监控指标(VTS格式)。
    • 保留流量录制数据规范,确保监控体系平滑升级。

每个模块的设计均遵循"高内聚低耦合"原则,在保障生产环境稳定性的前提下,为超大规模流量调度场景提供可扩展的技术支撑,后续将逐一拆解部分关键模块的技术实现细节与性能优化策略。

配置体系

静态配置

DLB 2.0在配置层面按类型拆分成多个细粒度的yaml文件,其中最核心的是 server.yaml 以及 upstream.yaml ,为了对标Nginx核心概念、这部分不引入新的术语,继续沿用 server 、 location 、 upstream 三大基础模块。

  • 通过 server 模块声明虚拟主机,支持多域名监听及端口绑定,兼容 server_name 的泛域名解析能力,同时实现单端口多域名 TLS 证书的精准匹配。
  •  location 模块完整继承 Nginx 的路径匹配逻辑(含精确匹配 = 、正则匹配 ~ 等模式),支持基于路径的请求路由与正则表达式重写规则,确保策略迁移的零成本适配。同时支持 proxy_pass 、 if 、 proxy_headers 、 return 等核心指令。
  •  upstream 动态服务发现机制支持权重负载均衡,通过 YAML 结构化配置实现后端集群的声明式管理,并与DNS的服务发现深度集成,彻底消除传统配置中硬编码 IP 的维护负担。
id"hjob.shizhuang-inc.com"  server_name: "hjob.shizhuang-inc.com"  service_in:  - "default_80"  - "default_443"  redirect: true  location:  - path: "/"    access_rule_names:    - "access_allow_d803a06f39ad4dcd8dfe517359a33a61"    - "access_deny_all"    client_max_body_size: "100M"    proxy_headers:    - "clientport:$remote_port"    - "Upgrade:$http_upgrade"    - "Connection:$http_connection"    - "Host:$host"    - "X-Forwarded-For:$proxy_add_x_forwarded_for"    - "X-Forwarded-Proto:$scheme"    proxy_pass: "http://hangzhou-csprd-hjob-8899"
 - name: "hangzhou-csprd-hjob-8899"  peers:  - server: "1.1.1.1:8899"    weight: 100    backup: false    down: false  - server: "2.2.2.2:8899"    weight: 1    backup: false    down: false  max_fails: 3  fail_timeout: "10s"  max_connections: 1000

配置解析

在DLB 2.0的配置模型中, server 、 location 、 upstream 三者构成层次化路由架构:

图片

  •  server 作为虚拟服务单元,通过 Vec 聚合任意数量的 location 路由规则。
  •  location 作为请求路径处理器,可独立关联至不同的 upstream 服务组。
  •  upstream 采用原子引用计数机制( Arc )封装配置,通过Arc::strong_count() 实时监控引用状态,避免冗余配置拷贝,基于Rust的并发安全特性,最终设计为 Arc<Mutex> 结构:
    •  Mutex 保障多线程环境下的内部可变性,支撑配置热更新**需求。
    •  Arc 维持跨线程的只读共享能力,确保访问高效性。

main thread解析完server.yaml与upstream.yaml后,将生成两个核心哈希映射:

  •  server 配置映射表:关联域名与路由规则集。
  •  upstream 线程安全容器:托管负载均衡服务组状态。
/// A map of server names to their respective configurations.#[serde(skip)]pub servers: HashMap<String, Arc<Mutex<ServerConf>>>,/// A map of upstream names to their respective configurations.#[serde(skip)]pub upstreams: HashMap<String, Arc<Mutex<UpstreamConf>>>,

运行时配置转化

上述的 ServerConf 与 UpstreamConf 面向的是用户,特点是易于理解与维护、支持YAML反序列化。

而为了专注运行时效率(比如负载均衡策略中的字符串转化为枚举类型),我们会将 UpstreamConf 转化为 RunTimeUpStream 结构, ServerConf 同理。

impl TryFrom<&UpstreamConf> for RunTimeUpStream {    type Error = Error;    fn try_from(value: &UpstreamConf) -> std::result::Result<SelfSelf::Error> {    }}

转化之后得到全局唯一的 GlobalConf :

pub static GLOBAL_CONF: Lazy<RwLock<GlobalConf>> = Lazy::new(|| {    RwLock::new(GlobalConf {        main_conf: MainConf::default(),        runtime_upstreams: HashMap::with_capacity(16),        runtime_servers: HashMap::with_capacity(16),        host_selectors: HashMap::with_capacity(16),    })});
#[derive(Default)]pub struct GlobalConf {    // main static configuration    pub main_conf: MainConf,    //one-to-one between upstreams and runtime_upstreams    pub runtime_upstreams: HashMap<String, Arc<RunTimeUpStream>>,    //one-to-one between servers and runtime_servers;    pub runtime_servers: HashMap<String, Arc<RunTimeServer>>,    //one service one host selector    pub host_selectors: HashMap<String, Arc<HostSelector>>,}

流量处理

域名匹配

如果仅有上面的 runtime_servers 这一个哈希表,还不能实现复杂的Nginx域名匹配规则,Nginx域名匹配的优先级机制包括:精确匹配>前置通配符>正则匹配(后置通配符在1.0版本未使用,暂且忽略),为了确保无缝迁移,需要提供与Nginx完全兼容的server匹配逻辑,考虑到代码可维护性,可以这样组织运行时数据:

  • 为精确域名使用HashMap,实现O(1)查找。
  • 前置通配符匹配存储为Vec,且确保最长匹配优先。
  • 正则表达式只能顺序匹配,保持Vec原顺序。

最终得到这样的结构体:

/// A struct to manage server selection based on host names.////// This struct contains three fields: `equal`, `prefix`, and `regex`./// The `equal` field is a HashMap that stores server names and their corresponding IDs/// when the server name exactly matches the host./// The `prefix` field is a Vec of tuples, where each tuple contains a prefix and its corresponding server ID./// The `regex` field is a Vec of tuples, where each tuple contains a Regex and its corresponding server ID.////// The `HostSelector` struct provides methods to insert server names and IDs,/// and to match a given host name with a server ID based on the rules defined in the struct.#[derive(Clone)]pub struct HostSelector {    pub equal: HashMap<String, String>,    pub prefixes: Vec<(String, String)>,  //原始前通配符数据    pub prefix_nested_map: NestedHashMap, // 嵌套哈希结构优化匹配效率    pub regex: Vec<(Regex, String)>,}

其中需要留意的是成员 prefix_nested_map ,为了确保最长匹配优先,我们将 prefixes: Vec<(String, String)> 转化为了 NestedHashMap 结构, NestedHashMap 为一个嵌套哈希结构,可基于域名分段实现高效检索。

#[derive(Debug, Clone)]pub struct NestedHashMap {    data: HashMap<String, NestedHashMap>, //层级域名节点    value: Option<String>, // 终端节点关联服务器ID}
impl NestedHashMap{    /// 基于域名分段实现高效检索(从右向左匹配)    pub(crate) fn find(&self, key: &str) -> Option<String> {        let tokens = key.split('.').collect::<Vec<&str>>();        let mut current_map = self;        let mut result = None;        // 遍历域名层级(如 www.example.com → [com, example, www])        for token in tokens.iter().rev() {            // 优先记录当前层级的有效值(实现最长匹配)            if current_map.value.is_some() {                result = Some(current_map.value.as_ref().unwrap());            }            // 向下一级域名跳转            let child = current_map.data.get(*token);            match child{                Some(child) => {                    current_map = child;                }                None => {                    break;                }            }        }        result.map(|value| value.to_owned())    }}

路由匹配

讲完了域名匹配,我们再深入路由匹配,在开始之前,我们先回顾一下Nginx的location指令。

Syntax:location [ = | ~ | ~* | ^~ ] uri { ... }location @name { ... }Default:—Context:server, location

 location 通常在 server{} 块内定义,也可以嵌套到 location{} 内,虽然这不是一种推荐的配置方式,但它确实是被语法规则支持的, localtion 语法主要有如下几种形式:

※  修饰符语义及优先级(依匹配精度降序排列)

  1.  = :精确匹配(Exact Match),URI必须与模式完全一致时生效(最高优先级)。
  2.  ^~ :最佳前缀匹配(Prefix Match),选中最长非正则路径后终止搜索(优先级次于=)。
  3.  ~ :区分大小写的正则匹配(Case-Sensitive Regex)。
  4.  ~* :不区分大小写的正则匹配(Case-Insensitive Regex)。
  5.  @ :内部定位块(Named Location),仅限 try_files 或 error_page 指令调用,不对外暴露。

Nginx在解析完 location 之后会进行一系列的工作,主要包括:

  • 分类:  根据location的修饰符参数标识不同的类型,同时去除name前面的修饰符
  • 排序: 对一个server块内的所有location进行排序,经过排序之后将location分为了3类
    • 通用类型,通用类型的location将建立一棵最长前缀匹配树
    • 正则类型,顺序为配置文件中定义的顺序,正则会用pcre库先进行编译
    • 内部跳转类型,顺序也为配置文件中定义的顺序
  • 拆分:将分类的3种类型拆分,分门别类的处理

其中最复杂的是最长前缀匹配树的构建,假设location规则如下,构造一棵最长前缀匹配树会经过如下几个步骤:

图片

  1. 把locations queue变化locations list,假设一个location的name是A的话,所有以A前缀开头的路由节点都会放到A节点的list里 (最长前缀匹配)。

图片

2.按照上述步骤递归初始化A节点的所有list节点,最终得到下面的list。

图片

3.在上述创建的list基础上,确定中间节点,然后从中间节点把location分成两部分,然后递归创建左右子树,最后处理list队列,list队列创建的节点会加入到父节点的tree中,最终将生成一棵多叉树。

图片

现在你应该已经明白了最长前缀匹配树的构建流程,让我们回到2.0的设计上来,这部分同样维护了三个结构分别对应精确匹配、正则匹配以及最长前缀匹配。

#[derive(Clone, Default)]#[allow(unused)]/// A struct representing a shared runtime server configuration.pub struct RunTimeServer {    /// Unique identifier for the server.    pub id: String,    /// Name of the server.    pub server_name: String,    /// Indicates whether the server should redirect requests.    pub redirect: bool,    /// A HashMap storing equal-matched locations, where the key is the path and the value is the location.    pub equal_match: HashMap<String, Arc<RunTimeLocation>>,// 精确匹配字典    /// A Vec storing regex-matched locations, where each tuple contains a Regex and the location.// 正则匹配队列    pub regex_match: Vec<(Regex, Arc<RunTimeLocation>)>,    /// The root node of the static location tree.    pub prefix_root: Option<Arc<static_location_tree::TreeNode>>,}

精确匹配、正则匹配比较简单,我们重点介绍最长前缀匹配,最长前缀匹配树的构建基本上是把Nginx代码原原本本的翻译过来,通过 create_list() 分组节点、 create_tree() 生成多叉树。通过 find_location 遍历树结构查找最长有效路径,其中路径比较函数 path_cmp() 确保按字典序定位子树,匹配成功时返回( need_stop, location ),其中 need_stop 标志是否中止搜索(模拟 ^~ 行为)。

 pub fn find_location(path: &str, node: &Arc<TreeNode>) -> Option<(bool, Arc<RunTimeLocation>)> {    let mut nodeSome(node);    let mut uri_len0;    let mut search_nodeNone;
    while let Some(current) = node {        let n = std::cmp::min(current.path.len(), path.len() - uri_len);        let node_path = &current.path[..n];        let temp_path = &path[uri_len..uri_len + n];
        match path_cmp(node_path, temp_path) {            std::cmp::Ordering::Equal => {                uri_len += n;                search_node = Some((current.need_stop, current.val.clone()));                node = current.tree.as_ref();                if uri_len >= path.len() { break; }            }            std::cmp::Ordering::Greater => node = current.left.as_ref(),            std::cmp::Ordering::Less => node = current.right.as_ref(),        }    }    search_node}

路由重写

路由重写是实现请求路径动态转换的核心能力,在语义层面,我们完全兼容Nginx的配置语义。

 regex replacement [flag] ,同时采用预编译正则引擎,在路由加载期完成规则编译。

#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum RewriteFlags {    Break,    Last,    Redirect,    Permanent,    NONE,}
pub struct RewriteRule {    pub reg_source: String,    pub reg: Regex,    pub target: String,    pub flag: RewriteFlags,}

模块化Filter链

Pingora 引擎已经将请求生命周期划分了足够细的各个阶段,为了更精细化控制同一phase执行的各个Filter,可通过自定义的 ProxyFilter trait,与 Pingora 引擎的phase关联起来。

#[async_trait]pub trait ProxyFilter: Sync + Send {    fn phase(&self) -> ProxyFilterPhase;        fn name(&self) -> ProxyFilterName;        fn order(&self) -> i32;        async fn handle(&self, _session: &mut Session, _ctx: &mut ProxyContext) -> HandleResult {        HandleResult::Continue    }}

 ProxyFilter 主要包含四个方法:

  • phase : Filter 的执行阶段, 生命周期阶段锚点,可以根据实际需要进行扩展插入更细粒度的阶段进行请求处理。
  • name : Filter的名称。
  • order : 在同一个phase内Filter的执行顺序。
  • handle : Filter 的执行逻辑,若返回的是 HandleResult::Continue ,则表示当前filter执行完成,继续执行下一个 filter,否则停止filter chain 的执行动作。
#[derive(Debug, PartialEq, Clone, EnumString)]pub enum HandleResult {    /// 表示当前filter执行完成,继续执行下一个 filter。    Continue,    /// 表示当前filter操作被中断,停止filter chain 的执行动作。    Break,}

目前我们已经实现的Filter包括但不限于:

图片

四、总结

作为《从Rust模块化探索到DLB 2.0实践》系列的第一篇,本文介绍了开发DLB 2.0的背景以及详述了DLB 2.0如何通过声明式配置管理、分层路由架构及与Nginx完全兼容的匹配逻辑,实现亿级流量调度场景下的高可用与零迁移成本。

当前成果验证了Rust在负载均衡产品中改造中的工程价值:依托线程安全的运行时结构(如 Arc<Mutex> )、高效前缀树路由( HostSelector )及最长前缀匹配,性能与可维护性均突破传统方案边界。

在后续篇章中,我们将继续深入剖析服务发现、监控与日志等核心模块,为超大规模云原生架构提供完整的参考实践。

往期回顾

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

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

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

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

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

文 / 雷泽

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

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

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

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 结构体定义,其读取操作对应的函数为: 

//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 与容器上下文信息元数据关联。

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 2b3b0ba2b3b0ba12e92        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/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 信息。
  • NAS 上下文信息获取:  通过 dev 信息,样例中的 660 ,通过挂载到本地的记录,可以通过 660 查询到对应的 NAS 产品的地址,比如3f0f3489aa-xxxx.cn-shanghai.nas.aliyuncs.com 。

用户空间元信息缓存

图片

在用户空间中,可以通过解析挂载记录来获取 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 代码示例如下所示:


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 := 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 客户端,从本地读取单机的容器实例信息,并将容器的上下文数据保存到本地缓存中,以便后续查询使用。

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 指标。

※  具体步骤

  • 获取 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() :用于搜集指标数据,该函数支持并发拉取,因此需要通过加锁机制确保线程安全。
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 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性能优化实战全揭秘|得物技术

文 / 泊明

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

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

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

❌