点赞
评论
收藏
分享
举报
分享实录 | NGINX 网络协议优化(下)
发表于2023-07-04 16:34

浏览 786

原文作者:陶辉
原文链接:分享实录 | NGINX 网络协议优化(下)
转载来源:NGINX 开源社区

NGINX 唯一中文官方社区 ,尽在 nginx.org.cn

编者按——本文为 NGINX Sprint China 2022 年度线上大会的分享实录,点击这里免费观看大会完整视频回放。由于文章较长,将分为上下两篇发布。点击《分享实录——NGINX网络协议优化(上)》阅读上篇。

本次分享中,我们将讨论如何通过优化 NGINX 协议栈,可以将并发连接提升到千万级、CPS 与 RPS 提升到百万级,从而拥有一个高性能的应用级软负载均衡。

很高兴大家回到这次深潜之旅,让我们继续挖掘 NGINX 的潜力。今天我的分享包括四个部分。首先从整体上来看一下 NGINX 的协议栈如何进行优化。接着我们将按照 OSI 七层网络模型,自上而下依次讨论 HTTP 协议栈、TLS/SSL 协议栈以及 TCP/IP 协议栈。

3.TLS 协议栈优化

接下来我们再来看 OSI 表示层协议 TLS/SSL 的优化。在全栈加密的今天,绝大部分公网流量都是经由 TLS 协议加密的,而优化 TLS 除了在先进算法与兼容性、性能与安全性之间做权衡外,还要考虑系统架构约束的变化。

3.1 建立会话

先来看 TLS 会话握手,这是最消耗 CPU 性能的过程,通常单颗 CPU 核心的每秒新建数不过一千多,但更为关键的是,握手消耗的 RTT 时间更多,参见下图:

上图中右侧是以 TLS1.2 协议为例看会话建立过程的,相对于图左侧在 TCP 握手中消耗 1 个 RTT(蓝色线条)之外,右侧共消耗了 3 个 RTT(蓝色与绿色线条),这就接近 1 秒时延了。怎么解决呢?参见下图的 TLS 1.3 方案:

上图左侧,TLS1.2 通过 Client Hello、Server Hello、Client Key Exchange、Finished(或者可选的 Server Key Exchange)4 条消息在 2 个 RTT 中完成了握手。我们要分析下,为什么交换密钥不能从 2 次 RTT 降为 1 次 RTT 呢?

这其实是能做到的,只要大幅减少加密算法(在 TLS 中被称为安全套件)的数量,就可以把 Client Hello 这个协商算法的消息与 Client Key Exchange 合并为一条消息,这就变成右图中的 TLS 1. 3 握手了。

我们可以通过 ssl_protocols 指令配置 NGINX 支持的协议版本,但目前至少需要同时支持 TLS 1.2 和 TLS 1.3,因为还有很多古老的客户端不兼容 TLS 1.3 协议。

3.2 传输数据

再来看传输加密数据的过程,我们可以基于内核的 kTLS 提升性能。在介绍 kTLS 之前,咱们需要先回顾下 HTTP 缓存,这实际上是 HTTP 协议栈的优化内容,可又是 NGINX 使用 kTLS 的前置知识点,所以我放在这里简要介绍。

上图中,cache 缓存可以存放在浏览器上,这时它的属性是 private,只针对一个用户有效。缓存还可以存放在正向代理(参考科学上网)、反向代理(参考 CDN)上,此时它的属性是 public,可以被多个用户共享。缓存通过将内容放在空间上距离用户更近的位置上,降低用户下载内容的时间。

我们知道,NGINX 可以使用 Linux 等操作系统提供的零拷贝技术(参见 sendfile 指令),将磁盘上的文件不通过 worker 进程就发送到网卡上。

然而,openssl 是运行在 worker 进程上的,一旦下游客户端走的是 TLS 流量,零拷贝就失效了,因为必须把磁盘上的文件读取到 worker 进程的内存空间上,才能使用 openssl 加密文件,然后再经由内核把加密后的字节流发送到网卡。

所以,只要在内核中使用 TLS 协议加密流量,就可以继续使用零拷贝技术,如下图所示:

NGINX 1.21.4 版本开始支持 kTLS 功能,通过 ssl_conf_command Options KTLS;指令即可开启零拷贝 TLS 流量功能,具体参见这篇官方博客

https://www.nginx.com/blog/improving-nginx-performance-with-kernel-tls/。

事实上如果不使用 kTLS,在内核与 worker 进程间反复拷贝数据,造成的 CPU 消耗会越来越可观!关于这点,我们要从下图的内存发展趋势谈起:

上图中,红色的线是内存访问时延,绿色的线是内存访问带宽,黑色的线是内存容量。可以看到,从 1999 年到 2017 年,内存容量翻了 100 多倍,而访问带宽只升了 20 倍,内存访问时延则基本没有变化!

这对开发人员提出了要求:缓存的作用越来越大,但是内存拷贝是次数必须降低。因此,当我们把加密过程通过 kTLS 放到内核中,压根不跟 worker 进程接触后,就会有 10% 到 20% 的性能提升(参考官网测试数据)。

4.TCP/IP 协议栈优化

最后来看 TCP/IP 协议栈的优化。摩尔定律的失效,对 TCP/IP 协议栈的优化影响很大,如下图所示,CPU 在向多核心方向发展:

上图我们重点看绿、蓝、黑 3 条曲线。绿色曲线是 CPU 频率,从 2004 年以后基本就不变了。蓝色曲线是 CPU 单核性能,略有提升是 CPU 架构优化和缓存带来的。黑色曲线则是 CPU 核心数,它的不断增加对开发人员的要求很高。具体到 TCP/IP 协议,就是操作系统的共享协议栈设计,带来的锁竞争概率直接上升!

现代 OS 都是分时操作系统,单核心 CPU 一样可以通过微观上的串行任务,实现宏观上的并发,而且这时的并行多任务在使用自旋锁时,几乎没有锁竞争问题

然而,一旦服务器使用了 64 核等 CPU 时,微观上就会有 64 个线程并行执行,对于高负载的 NGINX 来说锁冲突概率会非常高。此时你升级 CPU,是不会带来线性性能提升的,与此同时,CPU 的 SI 软中断百分比会急剧变大。

内核协议栈的设计,除了锁竞争问题外,还会引入 3 个问题。如下图所示,内核协议栈必须通过 socket 和系统调用与 NGINX 传递消息,因此系统调用导致的上下文切换内核态与用户态间的内存拷贝硬件 NIC 网卡与协议栈协同引入的软中断,都是不可忽视的因素:

当然,上图的设计优点也很多,比如大幅减少了应用开发的难度,增强了操作系统的稳定性等。但当我们关注性能、优化协议栈时,就不得不使用诸如零拷贝、kTLS 等特性,还要不断关注软中断进程 ksoftirq 的 CPU 占用率。这里简单解释下什么是软中断,如下图所示:

上图有 6 个步骤,其中第 1、2、3 步是从网络中接收的报文复制到 sk_buffer 中,并发起硬中断通知操作系统;第 4、5 步则是操作系统收到软中断后,通过协议栈处理报文,此时 ksoftirq 进程是在工作的。

把两个步骤分开是一种异步化设计,毕竟网卡是硬件,处理报文的速度必须足够快,而 ksoftirq 则可以有延迟。第 6 步就是 NGINX 通过 epoll_wait 拿到就绪的 socket,或者经由 read 或者 write 等函数拷贝报文数据。可见,在这个过程中,软中断对我们的消耗是可观的。

那么,当服务器 CPU 核心增多时,如何解决上述问题呢?Intel dpdk 加上用户态协议栈是一条可选的路径。如下图所示,dpdk 允许用户态进程直接从网卡上读取接收到的报文,或者拷贝数据到网卡来发送报文,绕过内核协议栈:

上图是腾讯 f-stack 给出的方案,它改造了 freebsd 操作系统的 TCP/IP 协议栈,对下通过 dpdk 与网卡交互,对上则以 POSIX API 的静态库形式,在 worker 进程内为传输层之上提供服务。

可以看到,由于每个 NGINX worker 进程内的 IP、TCP 协议栈都是独立的,所以当你修改 IP 地址时,不能使用操作系统的 ifconfig 或者 nmcli 命令,而是必须执行 f-stack 封装的 ff_ifconfig 命令,而且必须为每个 worker 进程分别执行脚本(使用 -p 指定进程 ID),因此,管理每个 worker 进程的配置一致性是比较复杂的。

熟悉 NGINX 的同学都知道,所有 worker 子进程之间的地位是相同的。然而到了上图中的方案时,情况就不一样了。

在多进程架构中,dpdk 要求必须分清主次,也就是第 1 个 fork 出的 worker 子进程是主进程,它必须负责管理大页内存(huge page,dpdk 必须使用这种管理模式,当然 dpdk 无锁内存池的设计非常高明!),而其他 worker 子进程则只是使用大页内存。这种设计导致 NGINX reload 模式会出问题,因为主 worker 退出、新建这段时间内,其他 worker 进程是不能提供服务的,这样 NGINX 的“热加载”功能就要大打折扣了。

worker 进程的数量与网卡的数量并不一致,因此 worker 进程间必须通过哈希算法,各自处理网卡上收到的报文。由于每个 worker 进程绑定了一颗 CPU 核心,所以图中的 cpu 队列等价于 worker 进程处理报文的队列(dpdk 是高性能网络框架,它只针对 CPU 设计独立的报文队列)。

上图中,当蓝色报文到达网卡 1 时,会根据 TCP 四元组(在 dpdk 初始化时可通过 f-stack.conf 配置)分发到 CPU1 队列。worker0 和 worker1 都会循环获取CPU队列中的报文,但 worker0 只取 CPU0 队列,所以 worker1 进程会获取到 CPU1 队列上的蓝色报文,经由进程内的独立 TCP/IP 协议栈(无须中断、无须加锁)处理完毕后,交由 NGINX 的 epoll、各 HTTP 模块处理。

可见,这种架构去除了软中断、系统调用、内核态用户态切换,大幅减少了内存拷贝次数,即使单 worker 性能也会有不少提升。但它的最大优势是多核心 CPU,尤其是 CPU 核心达到 32、64 甚至更高时,无锁化设计带来的优势非常明显,可以轻松达到百万级 CPS(每秒新建连接数)、上亿并发连接。

当然,这种带来高性能的独立协议栈设计,还引入了一个**烦:NGINX 作为负载均衡使用时,一个会话上的客户端、上游服务器报文都必须在同一个 worker 进程内处理,这是普通的哈希算法无法做到的,如下图所示:

客户端发起的 TCP 连接由 worker1 进程处理,但 worker1 与上游服务器建立的连接,回包经由哈希算法,可能会落到 worker0 进程处理,这样数据就乱了!如何解决这个问题呢?f-stack 和 dpvs 都给出了不太完美的方案。

f-stack 在向上游发起 TCP 连接时,本地端口并不像从前一样找出一个空闲端口直接使用,而是从小到大反复测试(最大到 65535),判断 TCP 四元组经由哈希函数的结果,如果本地端口导致哈希值没有落在当前 worker 进程上,就换一个,直到符合为止(O(n)时间复杂度!)。这套解决方案优点是与硬件无关,缺点则是性能非常差!

Dpvs 的解决方案则必须使用支持 fdir(Intel® Ethernet Flow Director)的网卡。Fdir 技术允许程序基于 TCP 目的端口(其他四元组元素当然也可以)设置 CPU 队列的分发规则。

比如,worker0 进程发起的 TCP 连接本地端口只从 1-10000(实际当然不是基于整数区间,而是按二进制 bit 位规划的),而 worker1 进程的本地端口则只从 10001 到 20000,这样两个进程发送 SYN 报文时,就可以确保来自上游的 SYN+ACK 报文可以回到原 worker 进程了。

这套方案的优点是性能很好,缺点则是绑定了硬件和应用代码(预留端口),而且 dpdk 的版本还必须与网卡配套才能正常工作。

事实上 HTTP 3 协议也面临类似的问题,只是它的表现形式在于“连接复用”功能!为了解决移动设备频繁更换 IP 地址导致的 TCP 断网重连问题,HTTP 3 不再基于 TCP 四元组定义连接,而是设计了 1 个 64 位的 connection ID,只要该 ID 不变,客户端在一段时间内(例如 1 分钟)断网重连后,依然可以复用原先的连接。

然而,目前的操作系统内核只会基于 TCP 目的端口分发进程,并不知道每个 worker 进程上具体处理的 connection ID,这也是 NGINX 迟迟无法推出 HTTP3 版本的原因(解决方案是高度耦合的 eBPF 模块,因此 QUIC 分支目前仍没有合并到主干)。

最后总结一下。今天我介绍了 HTTP 协议栈、TLS/SSL 协议栈和 TCP/IP 协议栈的优化思路,最终如何应用还要根据实际的应用场景来拍板,但取舍前一定要先了解当前协议栈的性能天花板在哪。好,谢谢大家,祝大家今天下午后面的旅程一切顺利!


NGINX 唯一中文官方社区 ,尽在 nginx.org.cn

更多 NGINX 相关的技术干货、互动问答、系列课程、活动资源:

开源社区官网:https://www.nginx.org.cn/

微信公众号: https://mp.weixin.qq.com/s/XVE5yvDbmJtpV2alsIFwJg

已修改于2023-07-04 16:34
本作品系原创
创作不易,留下一份鼓励
NGINX官方账号

暂无个人介绍

关注



写下您的评论
发表评论
全部评论(0)

按点赞数排序

按时间排序

关于作者
NGINX官方账号
这家伙很懒还未留下介绍~
244
文章
21
问答
198
粉丝
相关文章