本文介绍了小红书接入团队自研的高性能七层网关,基于 Rust 语言设计,实现了高性能的负载均衡、TLS卸载、QUIC/HTTP3 等能力。从系统架构、请求处理、模块拓展、TLS硬件卸载等方面介绍了所提出的 ROFF 的新特性,通过对比实验表明与 Nginx 的优势所在。
随着小红书自建机房的逐步完善和上量,亟需对标各大云厂商所提供的 TLS 软硬件卸载、负载均衡,QUIC/HTTPS 等能力。小红书接入层团队自研高性能网关 ROFF,基于 Rust 语言实现了 Keyless TLS 硬件加速,支持更丰富的负载均衡类型、服务发现、动态变配,模块插件拓展,保障小红书自建场景接入能力的高效、稳定运行。
ROFF 网关基于 Rust 语言开发完成,该语言具备不劣于 C/C++ 语言的性能以及极低的内存占用,并且以内存安全著称,奠定了网关绝对稳定的基础。Rust 语言已经运用于 Android 和 Fuchsia OS 等场景,并在 Android 的Rust 代码中发现的内存安全漏洞为零。
目前,ROFF 网关已经在小红书机房承接了主站的核心流量,上线至今无一次线上崩溃事故发生。其整体架构如下图所示,具备下列特性:
· 内存安全与高性能:安全与性能的考量是内存管理亘古的话题,C/C++ 语言将内存交给程序员管理而引入不安全的代码和资源的浪费,Java 等语言使用垃圾回收机制减轻程序员的负担却又带来了性能上的损耗。不同以往,Rust 语言使用所有权和借用机制对资源进行管理,严格要求一块内存在同一时刻只能被一个变量拥有所有权,在编译阶段即可以保证内存安全和并发安全。同时,其零成本抽象的能力让程序员可以使用高级编程概念(如泛型、模版、集合等)时不会增加运行时开销,而仅仅是增加编译成本。
· 丰富的代理能力:ROFF 支持多种类型的负载均衡方式,并对后端节点进行主动健康检查,及时摘除亚健康节点保持请求转发的健康性。通过连接复用和主线程检查从线程同步的方式,保证健康检查任务不影响请求的处理,从而保障网关的性能。同时,考虑到云原生的应用场景,我们还内置了 EDS 服务发现的 Discovery 能力,不再需要旁路部署服务发现组件。
· TLS 硬件卸载加速:ROFF 结合自建机房的情况,深度定制 Rustls 库实现 TLS 的 Keyless 硬件卸载方案提高卸载速度,大大提升了 HTTPS 的处理能力。
· 更稳定的热重载和热升级:ROFF 支持动态变配和热重载两种方式进行配置变更。动态变配支持无需重启服务,就可以无损更新现有模块配置。热重载和热升级支持程序运行时自动替换现有的工作进程。我们基于 Unix Domain Sockets (UDS) 实现的文件描述符转移,使得 ROFF 在二进制文件变更期间可以保持现有连接不断,进一步保障网关请求处理的稳定性。同时,ROFF 支持模块状态保留能力,在升级为新进程时可以获得旧进程的插件状态信息(如限流插件的统计信息等),以保证升级前后各模块的状态不丢失。
· 易于拓展的模块开发:ROFF 以模块开发作为设计理念,并充分发挥 Rust 宏的拓展能力,简化用户开发模块的流程。同时我们借鉴 Gin/Koa 的洋葱模型,将 HTTP 请求处理过程转化为过滤器 HttpFilter 调用链的执行,并提供多达 30 余个过滤器供用户自定义请求处理方式。
· 全面的可观测性建设:ROFF 实现了请求全链路的日志和监控,同时,为了监控后端节点的健康状态,健康检查模块支持返回网页,可视化展示健康检查结果。
· 不止网关:类似 Openresty 用 Lua 脚本语言扩展 Nginx。ROFF 将集成 Deno 库,以实现基于V8 引擎的 JavaScript 运行时环境,为用户提供强大的脚本拓展能力,复用整个 NPM/JSR 生态和完整的 JavaScript 能力,实现更加复杂和丰富的网关扩展。
3.1 进程/线程模型
Nginx 采用主从 (Master-Worker) 多进程架构,Master 进程管理所有的 Worker 进程的生命周期,Worker 进程用以接收和处理请求。由于 Worker 进程拥有独立的连接池和文件描述符表,因此,对于动态变配,健康检查等需要进程间数据共享的场景时,只能依赖于进程间通信机制如 mmap 等。这不仅带来了更大的通信开销,更多的性能损失,也增加了开发和维护的复杂度。
ROFF 选择了主从多线程的架构方案,如下图所示,将启动一个 Master 进程负责监听程序的关闭、重启、配置更新等信号,启动一个 Worker 进程,其中 Worker 进程将运行多个 worker 线程以接收和处理请求。这种方式不仅减小了worker 线程间数据通信的成本,提高连接池连接复用率,也为热重载和热升级提供了便利。
主从双进程模式:延续了 Nginx 对于平滑重启和热重载的设计精髓,使用 Master 进程管理 Worker 进程的生命周期。但与之不同的是,ROFF 中的 Master 进程成为了真正意义上永久运行的监控进程。Nginx 强依赖 fork 系统调用,使得主从进程的二进制文件必须保持一致,而升级服务需要重新创建一个新的 Master 进程并 fork 出多个 Worker 进程替换旧进程。在Roff的双进程模式中,Master 进程采用 fork-then-exec 模型孵化 Worker 进程,其自身仅专注于 Worker 进程的管理和替换,不会对 Master进程产生影响,可以有效监听旧进程的状态。这套方案类似 Envoy 的热重启脚本 [1],ROFF 将这些功能编译到一起,只需要配置项即可开箱使用,也作为缓存进程,在 reload 阶段产生的多进程间共享数据。
多工作线程方案:不同于 Nginx 使用多个工作进程的方案,我们使用一个工作进程取而代之,并在这个工作进程中引入 main 线程与 worker 线程的概念。main 线程负责配置解析、动态变配、健康检查等多个工作,并在需要时将变更的配置信息同步到其他 worker 线程,worker 线程则仅仅专注于请求的处理。main 线程在执行完初始化工作后,也会执行 worker 线程的工作,接收并处理请求,以此充分利用系统资源。
Master 进程作为“永久启动”的监控进程,将以管理者和监控者身份对 Worker 进程进行生命周期管理、状态监控和保存等操作,具备如下的能力:
热重载/热升级:当 Master 进程收到重载 SIGHUP 或者升级 SIGUSR2 的信号时,会根据新配置或新的二进制文件启动新的 Worker 进程,并通知旧 Worker 进程退出。得益于 Master 进程的常驻,可以有效监听新旧 Worker 进程的启动和退出状态,对于出错的情况也能及时回退。
Cache 共享:ROFF 将 Cache 存放到 Master 进程,Worker 进程启动时从 Master 进程拉取缓存数据并周期性同步。
插件状态保留:当 Worker 进程被替换后,该进程所记录的一些插件状态,例如限流数据等信息将会被清空。ROFF 提供 CacheHelper 功能,插件状态将会被保留在 Master 进程中,并在新 Worker 进程启动时下发给该进程以初始化。这种方式可以防止插件前后状态不一致导致的严重错误。
Unix 服务器:基于 Unix Socket 通信实现 HTTP 服务器,相比于使用信号控制或者进程间通信机制,Unix 服务器使进程间可以使用更加规范、可拓展且高效的通信和控制手段传递数据。
Worker 进程是真正处理请求的程序,在升级以及配置变更时,将会使用新的 Worker 进程替代旧进程,而 Master 进程不会有任何变化。为了进一步提升系统的性能,保护后端节点不会承载冗余的连接,我们又将工作线程拆分为 main 线程 和 worker 线程。我们将工作线程间共用的数据,例如监听配置、后端节点信息、健康检查信息等都交给 main 线程初始化和管理。当发生配置变更、服务发现新的后端节点、后端节点健康检查信息更新等情况时,将由 main 线程把数据通过线程间通信机制同步到其余 worker 线程。具体而言,main 线程具备如下的能力:
初始化工作:在 main 线程启动时,将解析配置文件,创建监听配置并启动多个 worker 线程。同时,将初始化各个模块,支持模块从控制面获取必要的初始化信息。
动态变配:接收动态变配请求,并将重新初始化的配置信息同步到其余 worker 线程,如果发现初始化错误,也可以及时回滚,阻止错误的传播。
健康检查和服务发现:执行周期性健康检查和服务发现任务,将获取的最新的节点状态和配置信息同步到其余 worker 线程。
监听并处理请求:完成程序初始化工作后,main 线程也会执行 worker 线程的逻辑,即与其他 worker 线程一样,接收并处理请求。
Unix 服务器:main 线程在启动时将运行一个 Unix 服务器用于与 Master 进程进行通信,并接收相应的控制信息。
3.2 热重载/热升级
对于网关来说,变更配置文件或者升级二进制文件是常态,对于线上的机器执行热重载/热升级等操作是不可避免的。为了避免影响线上服务的正常处理,在升级期间期望影响最小化,因此 Nginx 提供了热重载操作,但是其也仅仅只能保证旧进程处理完现有连接后退出。这种方案依旧会导致连接的不稳定以及旧进程回收时间过长等问题。
因此,引入 Unix Domain Sockets (UDS) 方案进行文件描述符 File Descriptor (FD) 转移,以增加系统的安全性和灵活性。每个使用 SO_REUSEPORT 的 socket 连接有自己单独的监听队列。进程退出时,处于半打开 Half-Opened 的连接都会被关闭,从这些连接的客户端视角看则是连接报错,服务下线。而当监听的连接 FD 被转移到新进程后,新进程继续持有 FD 的引用计数,即使旧进程的 FD 被释放,连接还是会被新进程从监听队列里取出处理,一次保证连接不断。
如下图所示,旧进程可以有选择的将处于监听状态的 FD 拷贝副本到新进程,新进程对比配置文件判断哪些 FD 可以直接复用(效果等同于 pidfd_getfd 系统调用,但此方法有最低内核版本 5.6 要求)。进一步的,我们在 Master 进程和 Worker 进程均启动了基于 Unix 的服务器,其在本地创建一个以“roff__unix_server_.socket” 文件作为通道的 HTTP 服务器。相比于使用系统信号控制进程,启动 Unix 服务器可以在进程间传递更多的数据,支持更丰富的进程控制操作。
为了证明使用 FD 转移进行热重启/热升级的有效性,我们做了如下的对比实验。使用同一配置文件启动 Nginx 和 ROFF 服务,并使用相同的路由进行压测,在压测期间将进行多次的服务热重启。这里仅关注压测报告中是否出现相关的 socket 错误:
左图为 ROFF 的测试结果,在压测期间并未产生任何的 socket 错误,所有请求都能在热重启期间正常的转发和处理。
右图为 Nginx 的测试结果,红框标注了热重启期间产生的 read 和 timeout 错误。即使 Nginx 对旧工作进程有优雅退出的设计,允许其处理完现有连接主动断连,但这也导致了频繁重启时连接不稳定以及旧进程回收时间长等问题。
结果表明 ROFF 在热重载/热升级阶段使用 FD 转移的能力,可以保证现有连接不断,避免了现有连接丢失以及频繁重新监听的开销等问题。
3.3 HTTP 请求处理
3.3.1 可拓展请求处理流程
下图展示了 ROFF 中 Worker 进程请求处理以及转发的流程,其中动态变配、服务发现以及健康检查只在 main 线程中完成,通过线程间通信机制将数据同步到各个 worker 线程。得益于 Rust 的 trait 特性,我们将四层和七层处理分别抽象为了 InboundHandler 和 OutboundHandler 方法,以便于后续对多种协议的支持和扩展。在四层 TCP/UDP 的基础上,提供多种协议(TCP、QUIC、Unix)的监听方式。
图中绿色加粗线条为 HTTP 请求的处理过程,请求到达时,将匹配 HttpInboudHandler 方法处理请求。在连接建立后,读取请求头中的 URL 和 Host 等信息以匹配合适的路由块,以确定后续需要执行的模块。OutboundHandler 方法定义了多种协议内容的处理方式,这里将匹配HttpOutboundHanler,以 HTTP 协议处理请求。
我们将 HTTP 协议的处理流程描述为 HttpFilter 调用链的形式,将请求处理过程根据不同的功能分为多个过滤器 Filter 方法。例如我们定义"pre_access_filter"为连接建立后读取请求头之前的第一个过滤器,"early_request_filter"为接收完请求头后的第一个过滤器,等等。通过这种划分方式,我们允许模块在 HTTP 请求处理的不同过程中介入,以实现例如限流、trace 等能力。
3.3.2 灵活的HTTP过滤器调用链
在 HTTP 请求处理的过滤器调用链实现上,我们讨论了如下的几种现有方案,并结合各自的优缺点给出我们的方案:
Nginx 的过滤器模型和 JavaScript-Koa、Golang-Gin洋葱模型类似,通过 next 方法在编译时确定为一个单向的调用链表。调用方可以在代码的任意位置调用 next 方法,获取返回结果并进行后续处理。洋葱模型能提供更多细粒度控制能力。
Envoy 采用 enum 返回状态,对于每一个过滤器函数返回不同的状态枚举值来控制调用链的执行过程,是应该继续还是停止。不能细粒度控制过滤器的执行顺序。
ROFF 本身的过滤器机制继承自 Pingora,而后使用过程宏为每个过滤器生成新的函数签名,添加 Next 函数参数,以此提供更加丰富的过滤器流程控制能力。
举例来说,对于 request_filter 过滤器函数,在 Pingora 的函数原型如下所示,ROFF 为其添加了 Next 参数。
// Pingora
async fn request_filter(&self, session: &mut Session, ctx: &mut Self::CTX) -> Result<bool>;
// Roff
async fn request_filter(&self, session: &mut HttpServerSession, ctx: &mut RequestSession, next: RequestFilterNext<'_>) -> Result<bool>;
Next 参数给予我们操作流程的可能性,设计了如下的方法以支持自定义过滤器执行顺序:
"next#call().await":顺序执行该过滤器的下一个钩子。
"next#ingore_call("B",...).await":忽略指定模块 B 的该过滤器钩子。
"next#ingore_many_call(["B","C"],...).await":忽略多个模块 B、C 的该过滤器钩子。
"next#call_to_end(...).await":忽略剩余的所有钩子,直接执行该该过滤器的最后一个默认钩子。
下图展示了使用 Next 参数跳过指定模块处理的案例,其中 keyless-tls 模块需要读取 builtin-tls 模块的 TLS 证书/私钥。由于 keyless-tls 模块本身也具备 TLS 处理能力,和 builtin-tls 模块的能力冲突,因此需要跳过 builtin-tls 模块的该过滤器钩子。图中绿色线条为实际的处理流程受 next.call_ignore 控制,虚线线条为正常流程。
3.4 高效的模块扩展
功能拓展是网关软件不可避免的话题,不同的行业或业务需要针对特定的需求开发不同的额外功能。更低的开发成本,更安全的功能扩展,将决定了软件生态能否健康长久的发展。Nginx 将自己的服务模块化,当收到请求时,通过匹配配置文件中的相应路由确定需要执行的指令,然后依次执行指令所对应的模块完成请求处理。这些模块通常包括 Core 模块、Events 模块、HTTP 模块、Stream 模块和三方模块如 Lua 模块等。得益于这种模块化架构,Nginx 搭建了丰富的生态系统,来自社区的拓展,例如缓存、压缩、认证、流量统计等,拓宽了其应用场景。
和 NGINX 类似,ROFF 从两个方面保证了用户可以快速的进行功能的拓展:指令解析和模块开发。
3.4.1 复杂指令解析
ROFF 具备强大的复杂指令解析能力,以及更加便捷的配置获取,便于用户在自定义模块时为配置文件加入更多的指令。如下图所示,我们将一条指令定义为包含参数 Arguments、属性 Properties 以及孩子节点 Children 的 Directive 类型,其中的子指令可以再次嵌套指令以实现更加复杂的指令形式。利用 Rust 的泛型推断能力,可以快速的将用户配置转化成 Directive 类型以供用户解析和使用。而在 Nginx 中,对于复杂的多级嵌套块需要频繁调用ngx_conf_parse 方法解析配置。
在 ROFF 中,类似 Nginx 中的 http 块、server 块和 location 块也可被视为多个 Directive 指令结构。以 http 块为例,可以视为一个指令名称为"http"的 Directive,其中嵌套的子指令即为 server 块。在解析配置文件时,用户只需要根据 ROFF 提供的指令名称来判断当前的指令是否为该模块需要使用的配置,通过作用域信息来细粒度控制指令的作用范围。
first_arg会自动推导类型并转换,不需要 ngx_conf_set_str_slot。
#[any_conf]
struct Conf {
statsd_endpoint: Option<SocketAddr>,
prometheus_remote_write: Option<Url>,
prometheus_pushgateway: Option<Url>,
prometheus_addr: Option<SocketAddr>,
}
#[http_module]
#[async_trait(?Send)]
impl Module for StatsModule {
fn parse_directive(
&mut self,
ctx: &ParseContext,
cmd: &Directive,
_conf: &mut BoxAnyConf
) -> anyhow::Result<()> {
fn parse(cmd: &Directive, conf: &mut StatConf) -> anyhow::Result<()> {
match &*cmd.name {
"statsd_endpoint" => {
conf.statsd_endpoint = cmd.first_arg()?;
}
"prometheus_remote_write" => {
conf.prometheus_remote_write = cmd.first_arg()?;
}
"prometheus_pushgateway" => {
conf.prometheus_pushgateway = cmd.first_arg()?;
}
"prometheus_addr" => {
conf.prometheus_addr = cmd.first_arg()?;
}
_ => {}
}
Ok(())
}
match &**ctx {
// TOP_BLOCK/HTTP_MAIN/HTTP_SRV/HTTP_LOC/HTTP_UPSTREAM
ConfLevel::Top => {
parse(cmd, &mut self.conf)?;
}
_ => {}
}
Ok(())
}
}
3.4.2 模块系统
Nginx 将请求的处理过程划分为了 11 个阶段,不同阶段又定义了不同的模块,其中有七个阶段开放给用户的自定义模块介入。但是大部分自定义模块都专注于 NGX_HTTP_CONTENT_PHASE 扩展。Nginx 中需要手动通过全局的 ngx_http_top/next_foo_filter 串联自定义的过滤器。
// set next filter
ngx_http_next_body_filter = ngx_http_top_body_filter;
// replace top body filter to our filter
ngx_http_top_body_filter = ngx_http_helloworld_response_body_filter;
ROFF 吸收了这种模块化和使用过滤器链式处理内容的思想,不再单独区分请求处理的多个阶段,而是将全部过滤器都集成到 HttpFilter 中,累计 31 个过滤器供用户自定义模块介入。从连接的建立,到请求处理、再至响应的返回,用户只需要明确在哪个过滤器插槽中实现自定义功能即可。下图展示了 Nginx 与 ROFF 在处理流程上划分的差异,ROFF 只展示了主要的几个流程。
用户将自定义的 listen_accepted 操作插入处理流程中的示例:
// insert a listen_accepted
ctx.filter.hook_listen_accepted.insert(Self::name(), Rc::new(self.clone()));
下面给出一个创建"example"指令的案例,该指令将用户的请求直接返回"Hello World"信息。左图为 Nginx 中实现的逻辑,需要定义模块的指令设置,配置解析流程,模块上下文等等,其中充斥着函数指针和内存的操作,这无疑增大了安全隐患。右图为 ROFF 中开发模块的流程,我们提供了模块的基本实现,用户只需要关心核心逻辑即可。其中指令解析部分,暴露给用户更加直观的处理方式,不需要考虑配置偏移等与内存打交道的设置。在对 Filter 过滤器开发时,只对 request_filter 插槽做实现即可完成介入。借助 Rust 的 async/await,ROFF 中大量 hook 原生支持 async,可以将请求转发到其他控制面实现复杂逻辑。
3.5 Keyless-TLS 硬件卸载
在互联网中,为了提高信息传递的安全性,通常使用 SSL/TLS 对 HTTP 协议的数据内容进行加密,也就是所谓的 HTTPS 协议。在使用 HTTPS 协议通信时,除了需要建立 TCP 连接、发送报文外,还需要进行 SSL 通信过程。这个额外的通信过程需要对传输的数据进行加密和解密计算操作,这是一项计算密集型任务,会大量消耗服务器的计算资源。因此,通常在网络架构中,使用专用设备进行 SSL/TLS 的加解密过程,并将解密后的流量转发给后端服务器。这种 SSL 卸载的方式可以减轻内网服务器的负担,加快网络通信速度,同时统一的证书管理和加密设置,可以使得网站具有更强的安全特性和防护措施。
SSL 卸载主要分为硬件卸载和软件卸载,硬件卸载专注于使用 QAT 等加速卡来进行加解密计算,软件卸载则使用服务器自身的计算资源进行操作。典型的软件卸载案例则是使用 Nginx 作为负载均衡服务器,并将所有的 HTTPS 流量在本机卸载为 HTTP 流量后转发给后端服务器。如下图所示,我们对 Nginx 使用软卸载的方式进行了基准测试,当使用 HTTPS 访问服务器时,所能承载的 QPS 相比使用 HTTP 访问时下降了一半左右。
因此,我们也在探索使用硬件卸载方案,加快 TLS 握手速度,降低 CPU 占用,从整体角度降本增效。同时,我们也需要支持软件卸载的能力,以此作为硬件卸载的兜底方案。OpenSSL 是一个功能全面历史悠久的用于处理 SSL/TLS 加解密库,但是其结构复杂庞大,跨平台开发难度大,深度定制成本高。在 ROFF 中选择使用 Rustls 库作为 OpenSSL 库的替代,其在性能和内存使用上都优于后者。
在硬件卸载方面,我们将 SSL 通信过程中最为耗时的非对称加密操作卸载到硬件加速卡上,也即是业界采用的 Keyless 硬件卸载方案。
基于 Rust 天然内存安全的特性并且还能保持与 C/C++ 语言同样的性能优势,我们开发了基于 ROFF 的 TLS 硬件卸载方案,重写 Rustls 部分函数实现 TLS-QAT 的硬件卸载加速,Keyless 卸载失败能自动切换到OpenSSL 兜底卸载部分 TLS 流量,以保障服务的可靠性和稳定性。
3.6 解决长尾延迟问题
长尾延迟( long-tail latency )是指重计算的情况下核心任务不均匀导致部分请求积压,请求延迟上涨。解决长尾延迟普遍采用 tokio 的 multiple worker 运行时,一些共享变量需要额外的 Sync 约束,进而引入过多互斥锁开销。
采用 Thread Per Core 能避免开销但不得不引入长尾延迟。ROFF 的核心逻辑重 IO 转发不会积压请求,可能存在重计算的逻辑都在模块。
为解决上述两个问题,ROFF 除了自身的 worker 线程,还会额外创建 tokio 的 work-stealing 运行时供模块选择性优化。
/// Thread per core model causes long-tail latency. gateway just a IO forwarder, there no compute intensive task in core function except third party modules.
///
/// We start another tokio work stealing scheduler here, module should submit compute intensive task to this multiple thread runtime
///
/// **Don't call tokio::spawn directly, it will spawn on current thread scheduler.**
///
/// See https://blog.cloudflare.com/keepalives-considered-harmful/
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
if let Some(h) = HANDLE.get() {
h.spawn(future)
} else {
log::warn!("spawn task on current thread scheduler, which maybe not your intention");
tokio::spawn(future)
}
}
3.7 HTTP3/QUIC 支持
ROFF 使用 Pingora 构建 HTTP 处理能力,但目前 Pingora 不支持 HTTP/3 能力 [2]。小红书大量流量依赖 HTTP/3,于是我们为 ROFF 开发了 HTTP/3 能力。当前 Rust 的 QUIC/HTTP3 生态:
Quiche: QUIC+HTTP/3
s2n-quic/quinn: QUIC
h3: HTTP/3
libnghttp3-dev: HTTP/3
我们为 Quiche 开发了 tokio 支持,但使用发现 Quiche 与 OpenSSL/BoringSSL 强绑定关系,ROFF 的 ssl_backend: "rustls" 指令需要支持 rustls,最后放弃了Quiche。h3 库处于早期版本不建议生产使用。为了能承接小红书的核心流量不出问题,ROFF 为 nghttp3 C 库开发了 Rust 的 FFI 和 tokio 异步支持。借助 nghttp3 [3],我们获得了能稳定用于线上的 Rust HTTP/3 库,最终 HTTP/3 协议栈如下:
3.8 配置文件
最初支持 KDL/JSON/YAML,随着功能越来越多指令越来越复杂,JSON/YAML无法对齐KDL的表达能力,反而带来配置膨胀,难以阅读的问题。
为了能减少 Nginx 开发者的理解成本,ROFF 只采用 KDL 文件格式并兼容大多数 Nginx 指令和变量。格式如下:
// config.kdl
master_process true
statsd_endpoint "127.0.0.1:9125"
http {
proxy_read_timeout "60s"
ssl_backend "rustls"
ssl_certificate "../certificates/www.example.org.full.cert.pem"
ssl_certificate_key "../certificates/www.example.org.key.pem"
include "./upstream.kdl"
include "./servers.kdl"
server {
listen "tcp://0.0.0.0:443" default-server=true
listen "tcp://[::]:443" default-server=true
listen "udp://0.0.0.0:443" default-server=true
listen "udp://[::]:443" default-server=true
http2 true
ssl true
http3 "on"
server_name "_"
add_header "Access-Control-Allow-Methods" "GET, PUT, OPTIONS, POST, DELETE"
location "/" {
proxy_pass "http://backend"
}
location "/header" {
add_header "Remote-Addr" "$remote_addr"
proxy_set_header "URI" "$request_uri"
return 200
}
}
}
ROFF 已经完成了开发以及系统性测试,经过与 Nginx 的对比实验验证了其在性能上的优势。目前已经在小红书如下场景灰度运行中:
已在自建机房灰度并承接了主站的核心流量。得益于 Rust 语言严格的静态检查,作为一个全新的项目,自上线以来无一例内存安全问题。
ROFF 已经支持容器化部署并作为自建机房对象存储的接入网关,承接了所有对象存储的入口流量。
相比于云厂商七层 LB,自建网关 ROFF 成本能够降低 80%左右。
4.1 性能对比
与此同时,我们建立了自动化压测工具,对每次的升级迭代都与 Nginx 进行及时的性能对比。
我们在同一台机器上分别运行 ROFF 以及 Nginx,并使用相同的配置文件进行压测实验,实验结果如下图所示。其中,左图为 HTTP 测试结果,右图为 HTTPS 测试结果,结果表明在相同环境下,ROFF 所能承接的流量与 Nginx 基本无异(注意Y轴数值)。
ROFF 大量使用范型、动态分发、async 语法等现代语法特性,且性能和 Nginx 相当,原因有几点:
C 语言和 Rust 语言性能同一梯队,语言能提供给编译器的信息越多,编译器能做更多的优化。由于 Rust 语法要求更严格,给编译器提供的信息更多,编译器能做比 C/C++ 更激进的优化。最近一个很热门的话题 [4]。
Rust 自带 Cargo 包管理,最大限度复用社区已有生态。比如 Nginx 使用 C 语言实现的 HTTP 解析器,而 Rust 解析库 httparse [5] 已经用上了 SIMD。
开发过程中借鉴了很多 Nginx 的设计,不断的压测找到薄弱点。
总的来说,ROFF 的线程模型更像 Envoy,架构/模块设计更像 Nginx。
4.2 未来规划
后续会持续灰度公网 HTTP/3,实现更多 Nginx 的指令并开源。集成 Deno 库,实现类似 OpenResty 的扩展能力。
基于Deno扩展网关边界
即使 Nginx 有如此丰富的社区以及高度可自定义的模块开发,但是由于其开发成本大,一部分受众将目光转向了 Nginx+Lua 的模式。因此,诞生了 OpenResty。用户可以直接使用 Lua 脚本动态扩展功能而无需重新编译服务器。目前主要应用在API网关等场景中。LuaJIT 所提供的高效的脚本执行能力,使得网关更容易处理动态内容以及更加复杂的逻辑,并且 Lua 开发更加灵活。但是,使用 Lua 作为 Nginx的扩展脚本可能也是无奈之举,在脚本语言中 JavaScript 的强大扩展能力以及更丰富的社区生态 [6],更适用于 Web 服务的建设。由 Google 开源的 V8 引擎已经具备了极强的性能。
后续 ROFF 将考虑集成 Deno 库:Rust 开发的高效安全的 JavaScript 运行时环境,使得 ROFF 可以使用 JavaScript 脚本极大的扩展网关的边界能力。之所以选择 Deno 库,主要是其具备如下的优点:
由 Rust 编写,ROFF 与 Deno 的 rust-runtime 零开销调用。
基于 V8 引擎的 JavaScript 运行时相比 Lua 具备超强的性能。
JavaScript 的单线程 EventLoop 非常契合我们的线程模型,每个 worker 线程都能运行 JavaScrip t的 EventLoop。
复用整个 JavaScript 的繁荣生态,不再编写精简版的 JavaScript,而是能使用任何 NPM 包。
Deno 库不依赖 node_modules,直接嵌入到二进制。
4.3 与 Pingora 的关系
非常感谢 Pingora 提供了与 Nginx 类似的框架能力。
ROFF 最初将 Pingora 作为 Cargo 依赖使用,好处是更新 Pingora 版本就可以享受到上游的新特性或是 bug-fix。但是在开发过程中,遇到了一些问题使得 ROFF 不得不自行维护部分 Pingora 代码。
Pingora模块在过滤器之后。先有了不同的过滤器,再注册模块,但模块只是多了几个 hook 点,并不支持定义指令解析、HTTP 变量等能力、线程启动退出Hook。当然这也是因为 Pingora 定位是 HTTP 框架,而不是完整的网关程序 [7]。
ROFF 使用 Thread Per Core 的线程模型配合 Thread Local 存放数据,只有很少的地方有资源竞争。Pingora 默认 tokio work-stealing,很多位置需要 Send + Sync 约束。
开发过程中碰到了很多问题,只能 hack 绕过。导致 ROFF 维护了大量与 Pingora 的胶水代码。久而久之造成 ROFF 自有代码和 Pingora 的严重割裂(比如 tcp_connect,ROFF 和 Pingora 都维护一套)。所以在一次重构中,我们 fork 了其 HTTP 转发逻辑,大量重写确保 Pingora 和 ROFF 的架构契合度。
Pingora 不支持半途终止 HttpFilter 迭代的特性,部分模块依赖 [8]。
灰度流量时线上代码死循环,等不及上游修复 [9]。
很多功能会对上游引入 breaking change。Pingora 没有对外暴露 keepalive_timeout、proxy_read_timeout等指令。
Pingora 的 HTTP/3 支持缓慢。对小红书来说 HTTP/3 是刚需,不可能等到上游实现,只能对 Pingora 进行修改。
轩宇(姚剑鹏)
接入网关方向负责人。
萧炎(吴伟超)
接入网关方向研发。
露卡(陆于洋)
接入网关方向研发。
长恭(陈凯)
接入网关方向研发。
Envoy hot restart script:
https://github.com/envoyproxy/envoy/blob/main/restarter/hot-restarter.py
Pingora HTTP3/QUIC Support:
https://github.com/cloudflare/pingora/issues/95
Are we have Rust bindings?
https://github.com/ngtcp2/nghttp3/issues/281
如何看待 Rust 写的 PNG 解码器比 C 实现更快?
https://www.zhihu.com/question/6568018545/answer/55004999007、https://www.zhihu.com/question/6568018545/answer/55165637634
Rust http parse:
https://github.com/seanmonstar/httparse/blob/master/src/simd/mod.rs
为什么选择 javascript 而不是 lua:
https://www.zhihu.com/question/395593519/answer/2738722877
Pingora is Not an Nginx Replacement:
https://navendu.me/posts/pingora/
Is it possible to terminate iteration early in HttpModule:
https://github.com/cloudflare/pingora/issues/491#issuecomment-2556159561
Pingora bug:
https://github.com/cloudflare/pingora/issues/475
往期精彩内容指路
添加小助手,了解更多内容
微信号 / REDtech01
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...