浏览 4k
原文作者:陶辉
原文链接:分享实录|NGINX Gateway API(下)
转载来源:NGINX 开源社区
NGINX 唯一中文官方社区 ,尽在 nginx.org.cn
编者按——本文为 NGINX Sprint China 2022 年度线上大会的分享实录,点击这里免费观看大会完整视频回放。由于文章较长,将分为上下两篇发布。
本次分享中,我们将讨论如何通过优化 NGINX 协议栈,可以将并发连接提升到千万级、CPS 与 RPS 提升到百万级,从而拥有一个高性能的应用级软负载均衡。
很高兴大家回到这次深潜之旅,让我们继续挖掘 NGINX 的潜力。今天我的分享包括四个部分。首先从整体上来看一下 NGINX 的协议栈如何进行优化。接着我们将按照 OSI 七层网络模型,自上而下依次讨论 HTTP 协议栈、TLS/SSL 协议栈以及 TCP/IP 协议栈。
首先要明确 NGINX 的优化方向。优化的目标在我看来可以用三个词表示——快、多和省。
在做协议栈优化时,我们必须同时兼顾知识深度和广度。我猜看直播的同学中,运维要比开发多一些(运维和开发的关注方向是不一样的)。
开发习惯从实现的角度看问题,知识面倾向深度。而运维更关注服务部署、运行,比如要了解 IDC 在哪个地理位置、整个网络的规划、服务器的硬件配置等情况,因此知识面是倾向广度的。
NGINX 运行在 Linux 或者 FreeBSD 等操作系统上,操作系统的内核协议栈和进程调度机制都会影响 NGINX 性能,所以优化内核参数时更需要知识深度。
同时,我们还要了解 NGINX 所在服务器的网络环境,包括丢包率、网卡特性、CPU 特性、交换机和防火墙的规格、HTTP 等各类协议的编解码原理,这时又会偏重广度。
首先我们看下面两张图,先同步下思路。
第一张图由 NGINX 官方提供,我们从三个层面来解读它。

第 1 个层面是左下角的 3 个关键词。
通过这三个关键词可以看到,NGINX 即使不做优化,性能也还不错。
第 2 个层面,再来从左至右看图中的网络流向。左边是下游客户端与 NGINX 之间的流量,主要是指 HTTP 协议。右边是 NGINX 与 IDC 内的各 Web server 传输的流量,它的交互协议种类比较丰富,但都能与 HTTP 协议进行语义转换。
比如,当上游服务是 Memcached 或者 Redis 时,存取缓存元素可以对应 HTTP 中的 GET 或者 PUT 方法(参见 RestFul 架构风格)。
第 3 个层面看进程架构。NGINX 有一个 master 父进程、多个 worker 子进程以及可选的 cache manage 和 cache loader 子进程。
当然,如果你使用了 NGINX 的二次开发生态,还会多出一些进程,比如 OpenResty 可能产生的 privileged agent 子进程。master 进程不处理网络流量,实际工作的是 worker 子进程,其他子进程都是用于配合 worker 进程处理请求的。
比如 HTTP 缓存会存放在本地磁盘中,cache manage 负责缓存的定时淘汰工作,而 cache loader 则在 NGINX 重启时载入磁盘文件。
NGINX 也可以作为 Web server 使用,本次分享不涉及这块。今天主要讨论 NGINX 作为负载均衡的优化方法,除网络协议外,还会涉及一些磁盘 IO 知识。
再来看第二张图,它是 OSI 网络七层模型与真实的 TCP/IP 协议间的对应关系。模型是为了方便我们理解概念,而解决问题必须分析真实的协议。

比如,应用层中我们最常使用的是 HTTP 协议,它是由 NGINX 框架代码执行编解码的。表示层中常用的是 TLS/SSL 协议,它由 NGINX 进程中的 openssl 库执行编解码。传输层常用的是 TCP 协议,网络层是 IP 协议。
网络层与链路层之间的定义则没有那么清晰,比如 VLAN(802.1q)、ARP 协议等可以近似归到这两层。以上传输层、网络层、链路层中的协议默认是由操作系统内核处理的,它们通过 socket 及一些系统工具(如 ifconfig、route)提供给 NGINX 使用。
接下来我们用 4 张图,看看协议栈优化的具体场景。

第一张图最常见。当网络报文到达服务器时,首先会从网卡来到内核的 TCP / IP 协议栈,处理完毕后再经由右边的 socket 及 listen()、bind()、recv()、send()等系统调用进入 NGINX worker 进程,再由 NGINX 框架调用 openssl 库卸载TLS 协议,然后基于 http 状态机解析协议。
获取到 http 请求行、请求头后,NGINX 框架就会依次调用各个 NGINX http 模块处理请求,包括过滤模块和处理模块,以及 OpenResty 用户常用的 Lua 模块。
基于 stream 模块还可以将 NGINX 当作 4 层负载均衡使用,当然,这里的“4 层”虽然指的就是 OSI 体系中的传输层,但与传统的 4 层负载均衡并不是一回事。
例如,LVS 作为 4 层负载均衡会部分参与到 TCP 协议的编解码(仅包含相对简单的握手阶段,不涉及复杂的滑动窗口与拥塞控制),即它会接管内核的 TCP 协议,而 NGINX 只是通过 socket 使用内核处理过的 TCP 协议。

第二张图可以看到 TLS/SSL 协议栈同时运行在用户态与内核态代码中,这是怎么回事呢?
TLS/SSL 协议通常运行在用户态进程更合适,这是因为:首先它处于 TCP 与应用层协议之间,其次它又是消耗 CPU 的计算密集型协议,所以放在用户态进程中,既能提升应用开发效率,也不会影响性能。然而,在非常规场景下,TLS/SSL 运行在内核中性能更高。
比如,当 NGINX 作为 CDN 缓存服务运行时,缓存文件是放在磁盘中的,一旦 HTTP 请求命中缓存,接下来将磁盘文件发送到网卡这件工作,就可以通过 sendfile 零拷贝在内核中完成任务。
可是一旦开启了 HTTPS 服务(当下全站加密是主流),零拷贝功能就无法使用,因为 openssl 是运行在 NGINX 进程中的。此时上图中的 kTLS 方案就有了用武之地。
再来看第三张图。

2022 年 6 月份,HTTP3 协议的正式 RFC 文档就已经发布,这给协议栈的优化又带来了变数。HTTP3 为了解决 HTTP2 的队头阻塞、连接迁移问题,改用内核中的 UDP 协议解决进程调度,而将 TCP 协议中的可靠传输功能放在了用户态的 quic 协议栈中。因此,我们不再需要通过有限的 sysctl 指令优化复杂的 TCP 协议栈参数。
Go、Rust 等语言都实现了 HTTP3 协议库,但 NGINX 正式版却迟迟没有提供,仅有一个无法在生产环境中使用的 quic 分支。这里的原因很多,除了开源 NGINX 非常强调稳定性外,还因为 NGINX 的多进程架构,它使得连接迁移必须通过 eBPF 模块,才能在 worker 子进程间正确地分发报文,这就与操作系统内核紧密耦合起来,进一步延迟了正式版的发布。
另外,常见的 TLS/SSL 协议都是运行在 TCP 之上,而现在 quic 既需要使用 TLS/SSL 协议,又是跑在 UDP 协议上,这就改变了 TLS/SSL 的工作方式。比如,TLS 不能对报文整体加密,否则正、反向代理就无法通过 connection-ID 正确地执行会话保持或者负载均衡(quic 不再基于四元组定义连接,而是通过 1 个 64 比特的 connection-ID 定义连接)。
最后来看第四张图,我们深入内核与硬件看协议栈如何优化。

对 NGINX 熟悉的同学都了解,使用 worker_cpu_affinity 指令将 worker 进程与 CPU 绑定时性能最高,因为这样就可以提升 CPU 的一、二级缓存命中率。但如果我们换个角度想,这意味着每个 worker 进程都有自己独立的 HTTP 协议栈!
与此同时,这些 Worker 进程却共享了操作系统的 TCP 协议栈,因此 listen reuseport 指令才有负载均衡的效果。共享提升了开发效率,但却因为加解锁操作降低了运行效率,因此在高并发、高吞吐时,你会发现 ksoftirq 进程占用的 CPU 很高。
对于更底层的 IP 协议栈,它的共享影响范围就更大了,比如 listen 指令如果没有显示指定 IP 地址,那么你用 ifconfig 新增地址后,NGINX 就能马上处理新地址上的请求,可以想见这种灵活性的代价:对于满载、多 IP 的服务器,这种玩法一定会降低性能。
IP 层之下的数据链路层也有这个问题,通过 brctl 新增的网桥(做云原生的同学应该很熟悉)和 802.1q 协议中的 vlan 也是可以立刻使用的。
带着总体视角,我们先来看应用层的 HTTP 协议栈优化。
从互联网的整体发展,我们先来看看 HTTP/1.1 协议栈的优化点,再来看 HTTP2 和 HTTP3 解决了哪些问题。下面这张图是从上世纪 80 年代起,以太网网卡带宽的演进速度。

可以看到,在 TCP 协议刚出现的 80 年代初,网卡带宽只有 10Mb/s。到了 HTTP/1.0 协议出现、互联网开始飞速发展的 95 年,带宽已经达到 100Mb/s,之后网卡进步明显加速,2004 年出现了万兆网卡,2010 年 100G 网卡,2018 年时 400G 网卡都出现了。
当然,这些百 G 网卡目前都只在 IDC 数据中心出现,但这给协议开发人员的信号非常明显:如何才能用满越来越大的单机带宽呢?
与此同时,受制于物理规律(光速),报文在光纤中的传输速度并没有多大变化。必要的交换机中转所带来的“最后一公里”问题,是另一个让网络延迟居高不下的因素。因此,时延几乎不变,带宽却不断增大,协议设计者们有事可做了!
举个例子,十多年前我在设计服务器之间的传输协议时,还会使用 gzip 之类的压缩技术,因为那时带宽比 CPU 紧张。而现在 IDC 内部的带宽提升这么多后,就不再需要浪费 CPU 在两台服务器上压缩、解压缩数据了,而且消息传递速度还更快。
再比如,早期互联网大厂 IDC 内的资源利用率很低,因为在线业务的高峰与低谷流量差距太大,为了应对早晚、节假大促日、热点流量的变化,IDC 必须预留大量空闲资源。这在早期的蓝海市场没有多大问题,但随着大数据时代的到来,在线业务的数据量以及引发的离线计算量增加的幅度越来越快,必须想办法提升 IDC 资源利用率。
而在百 G 单机带宽的情况下,存储计算分离这种架构就有了用武之地,通过在线业务与离线计算服务的混合部署,谷歌 IDC 的 CPU 平均利用率达到惊人的 60% (国内许多 IDC 只有 10% 的平均使用率)。
理解了这两个例子,我们就能清晰的看到 HTTP 协议栈的优化方向:增带宽、降时延。
先从 HTTP1 的降时延谈起。单个页面上的资源数以百计,如果下载每个资源都使用独立的 TCP 连接,就有 2 个增大时延的因素:TCP 握手与 TCP 慢启动。简单解释下。
一次 HTTP 资源下载包括 2 条 HTTP 消息:请求与响应,客户端在等待响应的过程中,承载 HTTP 会话的 TCP 连接只能处于空闲状态,这是 HTTP 的简单性设计理念决定的。
所以,单一页面上百个资源下载任务,只能在并发范围内依次执行。如果每个 HTTP 会话都启动新的 TCP 连接,那么在 TCP 三次握手中,至少要浪费 1 个 RTT,这就是数百毫秒。
TCP 慢启动则是为了解决网络拥堵问题。就像公路上必须有红绿灯一样,TCP 连接之间会在不通过第三方仲裁的情况下,自行通过丢包与延迟变化解决网络拥塞。
其中一个重要手段,就是连接刚建立时先不要满载发送字节流(慢慢提升拥塞窗口的大小),这就是“慢启动”。可以想见,对于百 G 大带宽的服务器而言,一次只能发送 10 个 MSS 大小(通常是 1500 字节)的报文有多浪费。
因此,早在 HTTP1.0 时代,就有了 KeepAlive 长连接技术,到了 HTTP/1.1 更是直接写入 RFC 标准里。简而言之,就是传输完 1 个 HTTP 请求后,不要关闭 TCP 连接,继续将它复用在下一个 HTTP 请求中,下图是 NGINX 上配置 KeepAlive 的方法:

上图左边是客户端与 NGINX 间的长连接配置,右边是应用服务器与 NGINX 间的长连接配置,可以看到,它们并不完全相同。
其中,相同的配置是 keepalive_requests、keepalive_time 和 keepalive_timeout,分别放在 server{} 或者 upstream{} 配置块中,表示 1 个 TCP 连接最多可以承载 1000 个请求、保持 1 小时或者 60 秒的最大空闲时长,一旦不满足任一条件,连接就会关闭。
比起 RFC 标准来,似乎复杂了不少,但这是做软件工程必备的思维方式,因为 NGINX 需要处理各种意外情况,有了这些限制,单一用户就不会占用太多资源。
而对于 IDC 内的上游服务器,NGINX 必须在客户端关闭 HTTP 会话后,继续维护 TCP 连接池,这其实对于上游的应用服务器带来了一些压力,所以又多了一个 keepalive 配置,它可以限制连接池内的 TCP 连接数量。
上文说了如何减少报文的往返次数,我们再来看如何增带宽。其实传输层与网络层也能做到,比如以太网MTU默认 1500 字节,但巨型帧技术早已成熟,服务器之间单个报文可以增加到 9000 字节。
而且在 IPV6 协议中,IP 报文更是超过了 65535 字节的限制。当然,今天的重点还是在应用层协议上。
2015 年推出的 HTTP2 协议有很多新特性,但相对 HTTP1 最大的提升就是增加了单 TCP 连接的传输带宽,下图可以清晰的看到它带来的变化,从左边的 14 秒到右边的 2 秒,差不多有一个数量级的提升!

解释下上图的测试上下文:这张高清地球图片被拆成了大约 380 张图片,因此需要发起 380 个 HTTP 请求才能用 JavaScript 拼接成完整的图片。
我的验证环境是 Chrome 浏览器,因此左边的 HTTP 1.1 会同时并发 6 个 TCP 连接,每个连接依次传输 60 多张图片。右边的 HTTP 2 则仅使用 1 个 TCP 连接,同时传输 380 个小图片(实际上是 380 个 STREAM),这样带宽便可以充分使用,消除了 HTTP 语义简单性带来的响应等待问题。
HTTP 2 还有很多优点,比如浏览器解析完 HTML 资源(例如 index.html )获得 DOM 树后,会分析待下载的 CSS 、JavaScript 或者多媒体资源,评价资源间的依赖程度和用户体验,从而对不同的 STREAM 设置优先级,这样可以在有限的总带宽下更有效的分配资源。
再比如资源推送,当客户端下载了 play.html 后,服务器知道接下来浏览器一定需要 jquery.js 文件,于是就可以主动推送资源,如下图所示:

在 NGINX 上开启 HTTP2 非常简单,只要在 listen 指令最后添加 http2 参数即可。
当然 HTTP 2 协议并不是完美的,它的最大问题在于“队头阻塞”,如下图所示:

上图 a 场景的 HTTP 1 协议中,蓝色报文的丢失并不会影响红色、绿色请求,但到了 b 场景的 HTTP2 协议,由于红、蓝、绿请求都承载在同 1 个 TCP 连接上,而 TCP 又是一个有序字节流协议,所以蓝色报文的丢失不只影响了蓝色请求,还影响了红色、绿色请求,这就叫队头阻塞。HTTP3 协议则通过 UDP 和 quic 层解决了这个问题,关于 HTTP3 我们后面再说。
NGINX 唯一中文官方社区 ,尽在 nginx.org.cn
更多 NGINX 相关的技术干货、互动问答、系列课程、活动资源:
开源社区官网:https://www.nginx.org.cn/
微信公众号: https://mp.weixin.qq.com/s/XVE5yvDbmJtpV2alsIFwJg

按点赞数排序
按时间排序
微信公众号
加入微信群