QUIC 之路

QUIC (Quick UDP Internet Connections,快速 UDP 网络连接) 是一个新的默认加密的互联网传输协议,它提供了大量改进用于加速 HTTP 流量并使它更安全,以实现最终在 Web 上替换 TCP 和 TLS 的目标。这篇博文中我们将概述 QUIC 的一些关键特性,及它们如何使 Web 受益,以及在支持这个基础的新协议上的一些挑战。

QUIC-Badge-Dark-RGB-Horiz

事实上有两个协议,它们共享相同的名字: “Google QUIC”(简称 “gQUIC”)是 Google 的工程师在几年前设计的最初的协议,它在经过了几年的实验之后,现在已经被 IETF (Internet Engineering Task Force,互联网工程任务组) 采纳为标准。

“IETF QUIC”(从现在开始是 “QUIC”)已经与 gQUIC 有了相当大的区别,因此它可以被认为是一个独立的协议。从数据包的格式,到握手和 HTTP 映射,多亏了大量组织和个人的开放合作,并以使互联网更快更安全为共同目标,QUIC 已经改进了最初的 gQUIC 设计。

然而,QUIC 都提供了哪些改进呢?

内置安全性(和性能)

与现在备受推崇的 TCP 相比,QUIC 的一个更根本的改变是,它声明了默认提供安全传输协议的设计目标。QUIC 通过提供安全特性来实现这一目标,如认证和加密由传输协议自身来处理,典型地,它们由一个更高层的协议(如 TLS)来处理。

初始 QUIC 握手结合了典型的 TCP 三次握手与 TLS 1.3 握手,其中后者提供端点认证和加密参数协商。对于那些熟悉 TLS 协议的人来说,QUIC 用它自己的帧格式替换了 TLS 记录层,同时保留了相同的 TLS 握手消息。

这不仅确保连接总是认证且加密的,它还使最初的连接建立更快速:典型的 QUIC 握手只耗费客户端和服务端之间一个单独的往返来完成,对比 TCP 和 TLS 1.3 握手合起来所需的两次往返。

http-request-over-tcp-tls@2x

http-request-over-quic@2x

但 QUIC 甚至走得更远,它还加密了额外的连接元数据,这些元数据可能被中间人滥用来干扰连接。例如,当使用连接迁移时,链路上的被动攻击者可以使用包号关联多个网络路径上的用户活动(见下面)。通过加密包号,QUIC 确保它们不会被除连接的端点外的任何实体用来关联用户活动。

加密也是解决僵化问题的有效方法,它们使协议内置的灵活性(比如协商协议的不同版本号)在实践中由于实现的错误假设而无法使用(僵化正是TLS 1.3 推迟部署这么久的原因,只有在一些改变采用之后才可能,以防止僵化的中间设备错误地阻塞了新版本的 TLS 协议)。

队首阻塞

HTTP/2 带来的一个主要改进就是在相同的 TCP 连接上多路复用不同 HTTP 请求的能力。这使得 HTTP/2 应用可以并发地处理请求并更好地利用它们可用的网络带宽。

这对于当时的现状而言是巨大的改进,如果应用想要并发地处理多个 HTTP/1.1 请求,其需要应用初始化多个 TCP + TLS 连接(比如当浏览器需要同时获取 CSS 和 Javascript 资源渲染网页时)。创建新连接需要重复初始握手多次,也需要经历初始拥塞窗口爬坡,这意味着网页渲染变慢了。多路复用 HTTP 交换避免了这一点。

multiplexing.png

然而这也有不利之处:由于多个请求/响应由相同的 TCP 连接传输,它们会同等地受到丢包影响(比如由于网络拥塞),即使数据丢失只影响到一个单独的请求。这称为“队首阻塞”。

QUIC 做的更深入一些,它为多路复用提供了一流的支持,这样不同的 HTTP 流就可以被映射到不同的 QUIC 传输流上,但是,它们依然共享相同的 QUIC 连接,因此不需要额外的握手,且共享拥塞状态,QUIC 流是独立传输的,因此在大多数情况下丢包只影响一个流而不影响其它的。

可以极大地缩短呈现完整 Web 页(其中包含 CSS,Javascript,图片,和其它种类的资源)面所需的时间,特别是当穿越高度拥塞的网络,并具有很高的丢包率时。

那么容易,呃?

为了兑现承诺,QUIC 协议需要打破一些许多网络应用程序想当然的假设,这潜在地使 QUIC 的实现和部署更加困难。

QUIC 设计基于 UDP 数据报传输,这是为了简化部署并避免丢弃未知协议数据包的网络应用所带来的问题,由于大多数应用已经支持了 UDP。这也使得 QUIC 协议实现可以运行在用户空间,因此,比如,浏览器将能够实现新的协议特性并把它们带给用户而无需等待操作系统更新。

然而,尽管预期的目标是避免破坏,但它也使防止滥用和正确路由数据包到正确的端点更具挑战性。

NAT 把它们都带来了并在黑暗中约束它们

典型的 NAT 路由器可以使用传统的 4 元组(源 IP 地址和端口,目的 IP 地址和端口)追踪 TCP 连接通过它们,并通过观察网络中的 TCP SYN,ACK 和 FIN 包传输,它们可以探测新的连接何时建立何时终止。这使它们可以精确地管理 NAT 绑定的生命周期,内部 IP 地址和端口与外部 IP 地址和端口的关联。

对于 QUIC,这还不可能,因为今天四处部署的 NAT 路由器还无法理解 QUIC,因此,它们通常会退回到默认策略,即不太精确地处理 UDP 流,这通常涉及使用任意的,有时非常短的超时,这可能会影响长时间运行的连接。

当发生 NAT 重绑定时(比如由于超时),NAT 边界外的端点将看到包来自于一个不同的源端口,而不是连接最初建立时所观察到的哪个,这使得只使用 4 元组追踪连接变得不可能。

NAT-timeout-@2x

还不只是 NAT!QUIC 想要提供的一个特性称为“连接迁移”,它允许 QUIC 端点随意迁移连接到不同的 IP 地址和网络路径。比如,一个移动客户端将能够在无线数据网络和 WiFi 之间迁移 QUIC 连接,当一个已知的 WiFi 网络可用时(比如当它的用户进入他们最喜爱的咖啡店时)。

QUIC 试图通过引入连接 ID 的概念来解决这个问题:可变长度的任意不透明数据块,由 QUIC 数据包携带,它们可被用于标识一个连接。端点可以使用这个 ID 追踪它们所代表的连接而无需检查 4 元组(实践中可能有多个 IDs 标识相同的连接,比如为了避免在连接迁移被使用时链接不同的路径,但那种行为由端点控制而不是中间节点)。

然而,这也给使用 anycast 寻址和 ECMP 路由 的网络运营商带来了一个问题,即一个目标 IP 地址可能潜在地标识数百甚至数千台服务器。由于这些网络使用的边缘路由器还不知道如何处理 QUIC 流量,可能发生的一种情况是属于相同 QUIC 连接(即具有相同的连接 ID)的 UDP 数据包具有不同的 4 元组(由于 NAT 重绑定或连接迁移),它们可能最终被路由到不同的服务器,这就打破了连接。

image

为了解决这个问题,网络运营商可能需要使用更智能的 4 层负载均衡解决方案,这可以用软件实现并在无需触碰边缘路由器的情况下部署(请参考 Facebook 的 Katran 项目作为例子)。

QPACK

HTTP/2 引入的另一个好处是 首部压缩 (或 HPACK),它允许 HTTP/2 端点通过移除 HTTP 请求和响应的冗余减少大量的网络数据传输。

特别的,在其他技术中,HPACK 使用前面的请求(或响应)将要发送(或接收)的头部填充的动态表,使得端点可以在新请求(或响应)中引用前面遇到的头部,而不是再次重新传送它们。

HPACK的动态表需要在编码器(发送 HTTP 请求或响应的部分)和解码器(接收它们的部分)之间进行同步,否则解码器将无法解码它收到的数据。

基于 TCP 的 HTTP/2 的这种同步是透明的,因为传输层(TCP)负责以与发送它们相同的顺序传送 HTTP 请求和响应,所以更新表的指令可以由编码器作为请求(或响应)本身的一部分发送,这使得编码非常简单。但是 QUIC 要复杂得多。

QUIC 可以在不同的流中独立地传输多个 HTTP 请求(或响应),这意味着,虽然它负责按照单个流的顺序交付数据,但在跨多个流的顺序则无法保证。

比如,如果一个客户端通过 QUIC 流 A 发送了 HTTP 请求 A,通过流 B 发送 请求 B,可能发生的情况是,由于网络中数据包的重排序或丢失,服务器在收到请求 A 之前就收到了请求 B,且如果请求 B 被编码为引用一个请求 A 的头部,则服务器将无法解码它,因为它还没有看到请求 A。

在 gQUIC 协议中,这个问题通过简单地以相同的 gQUIC 流序列化所有的 HTTP 请求和响应头部(但不包含 bodies)来解决,这意味着无论如何,头部都会按顺序传递。这是一个非常简单的方法,它允许实现复用大量它们已有的 HTTP/2 代码,但是另一方面它加剧了 QUIC 设计用于减少的队首阻塞问题。IETF QUIC 工作组因此设计了一个新的 HTTP 和 QUIC 间的映射(“HTTP/QUIC”) 以及一个新的首部压缩方法称为 “QPACK”。

在最新的 HTTP/QUIC 映射草案和 QPACK 规范中,每个 HTTP 请求/响应交换使用它自己的双向 QUIC 流,因此没有队首阻塞问题。此外,为了支持 QPACK,每个端点创建两个额外的单向 QUIC 流,一个用于向另一个端点发送 QPACK 表更新,一个用于确认另一边收到的更新。这样,QPACK 编码器只能在解码器显式地确认动态表引用之后才能使用它。

偏转反射

基于 UDP 的 协议 的一个常见问题是它们对于反射攻击的敏感性,其中攻击者欺骗原本无辜的服务器向第三方受害者发送大量数据,通过欺骗针对服务器的数据包的源 IP 地址,使它们看起来像是来自受害者。

ip-spoofing

当服务器发送的响应恰巧比它接收的请求大时,这种攻击可能非常有效,在这种情况下,我们谈论的是“放大”。

TCP 不常遭遇这种类型的攻击是由于,在它的握手期间传输的初始数据包(SYN,SYN+ACK,…)具有相同的长度,因此它们不提供任何放大的可能性。

另一方面,QUIC 握手是非常不对称的:像 TLS 那样,在第一次传输中,QUIC 服务器通常发送自己的证书链,这可能非常大,同时客户端只需要发送一些字节(TLS ClientHello 消息嵌入在一个 QUIC 数据包中)。因此,客户端发送的初始 QUIC 数据包不得不填充到一个特定的最小长度(即使数据包的实际内容小得多)。然而,这种缓解仍然不够,因为典型的服务器响应跨越多个包,因此仍然可能比填充的客户端包大得多。

QUIC 协议还定义了一种显式的源地址验证机制,其中服务器不发送它的长长的响应,而只发送一个小得多的“重试”数据包,其中包含一个唯一的加密令牌,随后客户端将在一个新的初始数据包中向服务器回显它。这样,服务器可以更有信心,客户端没有欺骗它自己的源 IP 地址(因为它收到了重试数据包),并可以完成握手。这种缓解的缺点是它增加了初始握手的时间,从一个来回到两个。

另一种可选方案包括减少服务器到端点的响应,其中反射攻击变得不那么有效,比如通过使用ECDSA 证书(它们典型的比 RSA 证书要小得多)。我们也在实验使用现成的压缩算法,如 zlib 和 brotli,压缩 TLS 证书的机制,这个功能最初有 gQUIC 引入,但当前在 TLS 中无法使用。

UDP 性能

QUIC 的一个反复出现的问题是大量部署的已有的硬件和软件还无法理解它。我们已经看了 QUIC 如何试图解决网络中间节点的问题,如路由器,但另一个潜在的问题区域是在 QUIC 端点自身发送和接收基于 UDP 的数据的性能。过去多年来,大量的工作消失在尽可能大的优化 TCP 实现上了,包括在软件(如操作系统)和硬件(如网络接口)中构建卸载功能,但是 UDP 目前没有这些功能。

然而它只是时间问题,直到 QUIC 实现也可以利用这些能力。看看最近 在LInux上实现 UDP 通用分段卸载 的努力,这将允许应用程序在用户空间和内核空间网络堆栈之间捆绑和传输多个UDP段,代价是一个(或足够接近)调用,以及 Linux 上的零拷贝 socket 支持 的努力,它将允许应用程序避免将用户空间内存拷贝到内核空间的开销。

结论

像 HTTP/2 和 TLS 1.3 那样,QUIC 被创建来提供许多新特性,以提升网站的性能和安全性,以及其它基于互联网的属性。IETF 工作组当前被建立来在今年底之前提供 QUIC 规范的第一个版本,且 Cloudflare 的工程师也已经在努力工作,以为我们所有的客户提供 QUIC 带来的益处。

原文

坚持原创技术分享,您的支持将鼓励我继续创作!