3

降低20%链路耗时,Trip.com APP QUIC应用和优化实践

 2 years ago
source link: https://www.51cto.com/article/706585.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

作者简介:竞哲,携程资深后端开发工程师,关注网络协议、RPC、消息队列以及云原生等领域。

QUIC 全称 quick udp internet connection,即“快速 UDP 互联网连接”(和英文 quick 谐音,简称“快”),是由 google 提出的使用 udp 进行多路并发传输的协议,是HTTP3的标准传输层协议。近几年随着QUIC协议在IETF的标准化发展,越来越多的国内外大厂开始在生产环境对QUIC进行落地,以此提升某些业务场景下的服务性能。

Trip.com App作为一个面向国际化的App,承载了大量海外用户的请求,这些请求需要从海外回源到上海,具有链路长、网络不稳定等特点。在这样的背景下,我们尝试使用QUIC对App的传输链路进行了优化,并与目前的TCP传输链路进行对比实验。结果表明,QUIC协议的落地降低了Trip.com App大约20%的链路耗时,大大提升了用户的体验。

二、QUIC简介

简单来说,QUIC是基于UDP封装的安全的可靠传输协议,通过TLS1.3来保证传输的安全性,并通过协议标准来保证基于UDP传输的可靠性。目前QUIC协议已经成为新一代HTTP协议HTTP3的标准底层协议。QUIC在整个网络协议栈中的位置如下所示:

86421a001f985bf9412339d847170a9e83de97.png

相较于TCP协议,QUIC主要具备以下优势:

27da6df21d5954a4d81793dc8408985dfae37e.jpg

三、QUIC服务端落地实践

QUIC的落地需要客户端与服务端的共同支持,客户端我们使用了Google的开源Cronet网络库,服务端基于Nginx的官方quic分支。为了满足我们的业务与部署场景,客户端与服务端都对原生的代码进行了一些改造。本文主要介绍服务端的改造内容以及整体架构。

3.1 服务端整体架构

服务端的整体分层如图所示:

892e24b205d2854ead02262fce5d5edfd7f662.png

  • AX是公网的负载均衡器,通过配置可以做到将相同客户端ip+port的数据包转发到下游的同一台实例上。
  • Nginx Stream层是基于Nginx实现的数据包转发层,主要用来支持连接迁移的功能。
  • Nginx QUIC即QUIC服务端,基于Nginx的官方quic分支开发改造,用来接收QUIC数据包,并解析出Http请求转发到公司的API Gateway上。

为了充分利用CPU的性能,提升整个系统的高可用性,Nginx Stream和Nginx QUIC都是集群多进程部署。这就为请求转发、实现连接迁移和0-RTT带来了一系列问题。下面针对遇到的问题以及我们的解决方案进行简单的介绍。

3.2 集群多进程部署

由于我们在多进程部署Nginx QUIC服务端时采用reuseport的形式监听端口,所以在介绍多进程部署之前,先简单介绍Linux系统的reuseport机制和Nginx的进程模型。

所谓reuseport,简单理解就是允许多个套接字对同一ip+port进行监听。Linux在接收到数据时,会根据四元组转发数据到相应的套接字,即来源于同一个客户端的数据总会被分发给相同的套接字。Nginx基于这个特性,在启动时,对于配置reuseport的端口,会在创建与进程数量一致的套接字,监听同一端口,并为每个进程分配其中的一个套接字。这样便实现了多进程监听同一端口的功能,并且来源于同一源ip+port的数据总是会被分发给同一进程。

有了以上理论基础,我们来看集群多进程部署时,我们的系统会出现什么问题。由于Nginx Stream层主要用来支持连接迁移,所以此处暂时先忽略NginxStream的存在,即服务的架构为AX直连Nginx QUIC集群。

由于AX为四层负载均衡器,所以在对UDP数据包进行转发时,可能将隶属于同一QUIC请求的多个UDP包转发到不同的下游实例中,这就会导致请求无法被处理。其次,即使所有的数据包都被AX转发到同一台Nginx QUIC实例上,由于Nginx QUIC实例为多进程部署,如果多个数据包的源ip+port不同,也会被分发到不同的Nginx进程中。以上两种情况都会导致正常的请求无法被处理,整个系统完全无法正常工作。

幸运的是,经过调研我们发现AX可以通过配置将来源于同一源ip+port的数据包通过同一出口ip+port转发到同一台服务器实例上,服务端实例通过reuseport机制就可以做到将数据分发给同一进程。这样如果客户端的ip+port不发生变化,其请求就会被一直转发到同一台服务端实例的同一进程中。

以上方案只是针对客户端ip+port不发生变化的场景,但在移动网络的环境下,客户端网络变化是十分正常的事情。当客户端网络发生变化时,客户端就需要与服务端进行重新握手建立连接,这样就会带来额外的时延,影响用户体验。

虽然QUIC有对连接迁移的支持,Nginx也对连接迁移的功能进行了实现,但都是针对端对端的场景,在我们集群多进程部署的场景下无法正常工作。为此,我们对代码以及部署架构进行了一些定制化改造以支持集群多进程部署场景下的连接迁移。

3.3 连接迁移

连接迁移是QUIC的一个重大特性。指的是当客户端或服务端的ip+port发生变化时,两端可以保证连接不中断继续通信。大多数情况下都是客户端ip+port变化导致的连接迁移,服务端的ip+port基本不会改变。本文中讨论的也是客户端ip+port发生变化的场景。

在以TCP作为传输层协议的网络链路中,当用户的网络发生变化,如移动网与wifi的切换,客户端需要重新与服务端建立连接才可以继续通信。由于TCP建立连接需要三次握手,这就需要消耗额外的RTT(Round-Trip Time,往返时延)。尤其在弱网或者跨地区调用的场景下,建立连接需要消耗更多的时间,这对用户来说体验非常差。

而由于QUIC基于UDP协议,UDP并没有连接的概念,因此便可以在QUIC协议中通过connectionId来标识一个唯一的连接。当四元组发生改变时,只要connectionId “不变”,便可以维持连接不断,从而实现连接迁移的功能。

3.3.1 Nginx-QUIC连接迁移功能的实现

为了利于大家对Nginx连接迁移功能实现的理解,先简单介绍一下QUIC的握手过程中涉及到的数据包类型。客户端在首次与服务端建立连接时,首先会发送一个Initial包。服务端收到Initial包后返回Handshake包,客户端收到Handshake包后,便可以正常发送应用请求数据。此处我们省略了握手过程中的ACK、加解密过程,精简后的握手流程如图所示:

077a89071482d162c6d8775d12d1b24847110a.png

对QUIC握手流程有了大致的了解之后,我们来看Nginx是如何实现连接迁移功能的。

客户端发起与服务器建立连接的请求时,会在Initial包中携带一个由客户端随机生成的dcid。Nginx服务端收到Initial包后,会随机生成一个cid在Handshake包中返回,后续客户端发送的数据包都会以这个服务端返回的cid作为dcid。握手完成后,Nginx会生成多个cid保存在内存中,并在握手完成后将这些cid发送给客户端作为备用,cid的个数取决于客户端与服务端 active_connection_id_limit 参数的限制。

当客户端主动发起连接迁移时,会从备用的cid集合中取出一个作为后续数据包的dcid。Nginx收到携带新dcid的非探测包后,感知到客户端发生了迁移,会对新的客户端地址进行验证,当验证通过后,后续数据包都会发送给新的客户端地址,也就完成了整个连接迁移的流程。

以上对连接迁移的实现,仅仅在端对端且服务器单进程部署的场景下可以很好地工作。但是,在实际的生产环境中,由于引入了AX等负载均衡设备,而且服务端为多机多进程部署,因此当客户端网络环境发生变化时,新的请求数据包可能会被转发到新的服务器进程中。由于新的服务器进程并不包含此客户端连接迁移所需的上下文,就会导致客户端迁移失败。

解决上述问题的思路就是引入一个中间的LB层,这层的主要作用是解析出udp包中的dcid,然后根据dcid转发,把连接迁移前后的数据包转发到同一服务器的同一进程上。

此处我们基于Nginx搭建了Nginx Stream层,作为QUIC的LB层来实现数据包转发。由于连接迁移后,客户端会使用全新的dcid来发送数据包,为了使得Nginx Stream层通过新dcid的也可以路由到迁移前的机器上,我们还对服务端生成客户端dcid的逻辑进行了改造,在dcid中包含了服务端的ip+port信息。这样,当Stream层的机器收到迁移后的数据包时,便可以根据dcid中的ip+port信息将其转发到客户端迁移之前的服务端机器上。

加入Nginx Stream层之后的服务端整体架构如图所示:

259bcec13f0510793a28230da8cb32b31f870d.png

以上方案只是实现了将客户端连接迁移前后的请求转发到同一台服务器上,但无法保证请求被转发到服务器的同一进程中。试想,当客户端发生连接迁移时,AX可能会将客户端迁移后的数据包发送到跟之前不同的Nginx Stream实例上,虽然Nginx Stream可以通过dcid中的ip+port信息将数据包转发到同一台Nginx QUIC实例上,但对于Nginx QUIC来说,源ip+port为Nginx Stream的ip+port,源ip+port发生了改变。由于Nginx多进程分发请求依赖了操作系统的reuseport机制,而Linux的reuseport是根据四元组进行请求分发的,因此源ip+port的改变就可能会导致请求在服务端被分发到与迁移前不同的Nginx进程中,从而导致连接迁移失败。

为了解决这个问题,就要求NginxStream可以将数据包准确地转发到指定机器的指定进程中。我们进行了调研,总结下来大致分为两种解决方案:

  • 一是修改操作系统reuseport的分发机制,使其根据dcid将数据分发到指定进程。
  • 二是使不同的进程监听不同的端口,这样就可以通过将数据包转发到不同端口,从而转发到具体的进程。

第一种方案需要修改Linux内核代码,成本较高,并且dcid在整个系统中属于QUIC的应用层数据,我们认为不应该在操作系统代码中嵌入应用层的数据逻辑,因此最终选择了多端口的解决方案。

3.3.2 基于监听多端口的连接迁移方案

为了降低NginxStream配置文件复杂度和系统的整体维护成本,以及未来支持服务端的平滑无损升级,我们让每个进程监听了两种不同的端口:

  • 第1种是监听端口listening port,主要接收客户端的initial或0-RTT包,用来建立连接,每个进程的listening port相同;
  • 第2种是worker port,用来接收客户端第一个包之后的所有数据包,每个进程的workerport不同。

基于此方案,由于不同进程监听了相同的listening port,因此在建立连接时,由Linux根据四元组进行请求分发,从而实现进程间的负载均衡。一旦客户端与服务端建立了连接,后续此客户端所有请求报文中的dcid都包含了此服务器的ip+worker port信息,因此后续请求(包括连接迁移后的请求)都会被Nginx Stream层转发到同一服务器的同一进程进行处理,从而实现了多进程场景下对连接迁移功能的支持。

完整的连接迁移过程如下图:

d2b159f380b6a2f61e748632f190420356493e.png

可以看到,整个连接迁移过程对Nginx Stream来说是透明的,它只负责解析dcid中的ip+port信息,并进行数据包的转发。Stream层并不需要感知服务端worker port的存在,仅仅在收到initial或0-RTT包时,需要根据dcid的hash值将其转发到Nginx QUIC机器的listening port上,因此在Stream机器的配置文件中,只需要配置Nginx QUIC的listening port。这样,如果Nginx QUIC需要修改单机的进程数,Nginx Stream层无需做任何改动。

而在Nginx QUIC端,只需在Nginx现有进程模型的基础上,为每个进程额外分配一个worker port,并在为客户端生成的dcid中包含ip+worker port的信息即可。

3.4 0-RTT实现

QUIC的另一个重要特性是支持0-RTT建立连接,它通过UDP+TLS1.3构建安全的互联服务。对于TLS1.3来说,如果是首次建立连接需要1-RTT,非首次可以实现0-RTT。

当客户端与服务端第一次建立连接时,服务端会依赖加密层的TLS会生成加密的New Session Ticket返回给客户端,并将解密的ticket_key保存在本地。客户端在发起0-RTT请求时,会在报文的PSK中带上NewSession Ticket。服务端收到报文时,通过ticket_key对PSK中的信息进行解密。如果解密成功,则可以恢复之前的session,后续直接进行安全通信。如果失败,则需要重新握手建立TLS连接。

0-RTT实现示意图如下:

f8ae59024a85f18b401746420974d01e63c621.png

可以看到,服务端支持0-RTT的关键就是需要包含恢复session所需的ticket_key。但是,nginx中生成的ticket_key是保存在进程的上下文中的,由于nginx中各个进程的上下文是相互独立的,因此不同进程间的ticket_key无法共享。

并且0-RTT握手时数据包中的dcid由客户端随机生成,不包含路由信息,所以Nginx Stream层也无法通过dcid将0-RTT请求转发到之前已经与客户端建连的进程中。这就导致如果服务端进程无法解密收到的0-RTT数据包,就会导致0-RTT握手失败,客户端需要重新发起1-RTT握手请求。

为了解决这个问题,我们引入了Redis,使得服务端集群中的各机器各进程共享ticket_key。一个进程在生成ticket_key之前,先去查找redis是否已经存在ticket_key,如果已经存在直接查询出来作为当前进程ticket_key,如果不存在直接生成并且存入Redis。这样可以保证在各个进程中的ticket_key相同,最终的效果是如果A进程加密的session,0-RTT时被转发到了B进程,因为A、B进程的ticket_key相同,B进程也可以解密session,完成连接的建立。

改造后的0-RTT流程如下所示:

e626f2b67a7a2db17bc9084bd0d40ad168057b.png

QUIC协议由于其强大的拓展性与灵活性,以及弱网环境下呈现出来的优势,近几年逐渐被各大厂商应用在生产环境中。我们针对IBU业务海外流量大的特点,基于Nginx官方的quic分支进行改造,在生产环境实现了多机多进程环境下0-RTT、连接迁移等功能,并取得了Trip.com App请求总体平均耗时减少20%的良好效果。后续我们也会紧跟社区步伐,将更多QUIC的优秀特性更新集成到我们的服务中,推动QUIC协议在携程更好地落地。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK