QUIC协议的原理及分析

God_onload 2022-01-19 21:02:29

目录

 

QUIC的基本原理

简介

为什么需要QUIC

QUIC 核心特性

连接建立延时低

改进的拥塞控制

基于 stream 数据流的流量控制

没有队头阻塞的多路复用

加密认证的报文

连接迁移

QUIC简明配置和测试

QUIC源代码分析

数据包

首部

Client代码

echo.go代码


QUIC的基本原理

简介

Quic 全称 Quick UDP Internet Connection,“快速 UDP 互联网连接”,(和英文 quick 谐音,简称“快”)是由 Google 提出的使用 udp 进行多路并发传输的协议。TCP/IP协议族是互联网的基础。其中传输层协议包括TCP和UDP协议。与TCP协议相比,UDP更为轻量,但是错误校验也要少得多。这意味着UDP往往效率更高(不经常跟服务器端通信查看数据包是否送达或者按序),但是可靠性比不上TCP。通常游戏、流媒体以及VoIP等应用均采用UDP,而网页、邮件、远程登录等大部分的应用均采用TCP。

QUIC基于UDP的传输层协议,提供像TCP一样的可靠性,很好地解决了当今传输层和应用层面临的各种需求,包括处理更多的连接,安全性,和低延迟。

不支持在 Docs 外粘贴 block

QUIC协议架构图

为什么需要QUIC

大部分的互联网流量传输只使用了几个网络协议。使用 IPv4 进行路由,使用 TCP 进行连接层面的流量控制,使用 SSL/TLS 协议实现传输安全,使用 DNS 进行域名解析,使用 HTTP 进行应用数据的传输。

随着移动互联网快速发展以及物联网的逐步兴起,网络交互的场景越来越丰富,网络传输的内容也越来越庞大,用户对网络传输效率和 WEB 响应速度的要求也越来越高。

一方面是历史悠久使用广泛的古老协议,另外一方面用户的使用场景对传输性能的要求又越来越高。

古老的TCP协议存在这样几个问题:

  • 协议历史悠久导致中间设备僵化;
    • 由于 TCP 协议和知名端口及选项使用的历史太悠久,中间设备已经依赖于这些潜规则,所以对这些内容的修改很容易遭到中间环节的干扰而失败。
  • 依赖于操作系统的实现导致协议本身僵化;
    • TCP 是由操作系统在内核协议栈层面实现的,应用程序只能使用,不能直接修改。虽然应用程序的更新迭代非常快速和简单。但是 TCP 的迭代却非常缓慢,原因就是操作系统升级很麻烦。
  • 建立连接的握手延迟大;
    • 两个握手延迟(建立TCP连接,TLS协议安全传输)
  • 队头阻塞
    • 队头阻塞主要是 TCP 协议的可靠性机制引入的。TCP 使用序列号来标识数据的顺序,数据必须按照顺序处理,如果前面的数据丢失,后面的数据就算到达了也不会通知应用层来处理。

QUIC 核心特性

连接建立延时低

0RTT 建连是 QUIC 相比 HTTP2 最大的性能优势。

这里的0RTT有两层含义:

  • 传输层 0RTT 就能建立连接;
  • 加密层 0RTT 就能建立加密连接。

不支持在 Docs 外粘贴 block

图 HTTPS建立过程

比如上图是 HTTPS 的一次完全握手的建连过程,需要 3 个 RTT。

不支持在 Docs 外粘贴 block

而 QUIC 呢?由于建立在 UDP 的基础上,同时又实现了 0RTT 的安全握手,所以在大部分情况下,只需要 0 个 RTT 就能实现数据发送,在实现前向加密的基础上,并且 0RTT 的成功率相比 TLS 的 Sesison Ticket 要高很多。

改进的拥塞控制

TCP 的拥塞控制实际上包含了四个算法:慢启动,拥塞避免,快速重传,快速恢复。

QUIC 协议当前默认使用了 TCP 协议的 Cubic 拥塞控制算法,但进行了一些改进。

  • 可插拔
    • 能够非常灵活地生效,变更和停止。
  • 单调递增的 Packet Number
    • QUIC 同样是一个可靠的协议,它使用 Packet Number 代替了 TCP 的 sequence number,并且每个 Packet Number 都严格递增,也就是说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。
  • 不允许 Reneging
    • QUIC 在协议层面禁止 Reneging,一个 Packet 只要被 Ack,就认为它一定被正确接收,减少了这种干扰。
  • 更多的 Ack 块
    • Quic Ack Frame 可以同时提供 256 个 Ack Block,在丢包率比较高的网络下,更多的 Sack Block 可以提升网络的恢复速度,减少重传量。
  • Ack Delay 时间

QUIC的RTT时间演示图

  • QUIC的计算公式: RTT = timestamp2-timestamp1-Ack Delay

基于 stream 数据流的流量控制

数据流(Streams)在QUIC中提供了一个轻量级、有序的字节流的抽象化。

QUIC中有两种基本的数据流类型:

  • 从发起者到对等端(Peer)的单向数据流。

  • 双向均可发出数据的双向数据流。

连接端点的任意一方都可以建立这两种数据流,数据流之间可并行、交错地传输,并且可以被取消。

通过QUIC发送数据需要建立一个或多个数据流。

QUIC 实现流量控制的原理比较简单:

  • 通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。
  • 通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。

QUIC 的流量控制和 TCP 有点区别,TCP 为了保证可靠性,窗口左边沿向右滑动时的长度取决于已经确认的字节数。如果中间出现丢包,就算接收到了更大序号的 Segment,窗口也无法超过这个序列号。

QUIC的流量控制图

但 QUIC 不同,就算此前有些 packet 没有接收到,它的滑动只取决于接收到的最大偏移字节数。

没有队头阻塞的多路复用

QUIC 的多路复用和 HTTP2 类似。在一条 QUIC 连接上可以并发发送多个 HTTP 请求 (stream)。但是 QUIC 的多路复用相比 HTTP2 有一个很大的优势。

QUIC 一个连接上的多个 stream 之间没有依赖。这样假如 stream2 丢了一个 udp packet,也只会影响 stream2 的处理。不会影响 stream2 之前及之后的 stream 的处理。

QUIC的多路复用图

加密认证的报文

TCP 协议头部没有经过任何加密和认证,所以在传输过程中很容易被中间网络设备篡改,注入和窃听。比如修改序列号、滑动窗口。这些行为有可能是出于性能优化,也有可能是主动攻击。

QUIC 的 packet 可以说是武装到了牙齿。除了个别报文比如 PUBLIC_RESET 和 CHLO,所有报文头部都是经过认证的,报文 Body 都是经过加密的。

这样只要对 QUIC 报文任何修改,接收端都能够及时发现,有效地降低了安全风险。

如下图所示,红色部分是 Stream Frame 的报文头部,有认证。绿色部分是报文内容,全部经过加密。

连接迁移

什么叫连接迁移呢?就是当其中任何一个元素发生变化时,这条连接依然维持着,能够保持业务逻辑不中断。

比如大家使用手机在 WIFI 和 4G 移动网络切换时,客户端的 IP 肯定会发生变化,需要重新建立和服务端的 TCP 连接。

针对 TCP 的连接变化,MPTCP其实已经有了解决方案,但是由于 MPTCP 需要操作系统及网络协议栈支持,部署阻力非常大,目前并不适用。所以从 TCP 连接的角度来讲,这个问题是无解的。

那 QUIC 是如何做到连接迁移呢?很简单,任何一条 QUIC 连接不再以 IP 及端口四元组标识,而是以一个 64 位的随机数作为 ID 来标识,这样就算 IP 或者端口发生变化时,只要 ID 不变,这条连接依然维持着,上层业务逻辑感知不到变化,不会中断,也就不需要重连。由于这个 ID 是客户端随机产生的,并且长度有 64 位,所以冲突概率非常低。

QUIC简明配置和测试

QUIC协议目前已经有了很多开源实现,主要有谷歌官方支持的Chromium,从 chromium 剥离的一个 QUIC 协议部分proto-quic,微软支持的MsQuic和由go语言开发的quic-go。因为quic-go社区活跃且较为轻量,所以本文中配置和使用QUIC协议使用qg使用quic-go。

运行环境:

windows下的wsl

Ubuntu20.04

quic-go运行需要golang环境,所以先搭建golang(quic-go官方文档要求版本Go1.16.x和Go1.17.x)

  • 从https://golang.org/dl/下载Go1.17.6Linux版本压缩包
  • 解压到工作区
  • 配置环境变量
  • 查看go版本

下载并安装项目

$ git clone https://github.com/lucas-clemente/quic-go.git

编译服务端可执行文件

$ cd example

$ go build main.go

运行服务端main文件

-tcp表示浏览器第一次访问时仍然是要通过TCP进行的,如果不带浏览器将无法访问。

-qlog显示运行信息

可以看到,此时服务器开始监听客户端请求,端口是127.0.0.1:6121

注意:

此时可能会存在一个问题

Failed to sufficiently increase receive buffer size .意思是没有获得足够的缓存空间。

官方文档中说:

Experiments have shown that QUIC transfers on high-bandwidth connections can be limited by the size of the UDP receive buffer. This buffer holds packets that have been received by the kernel, but not yet read by the application (quic-go in this case). Once this buffer fills up, the kernel will drop any new incoming packet.

实验表明,高带宽连接上的 QUIC 传输可能会受到 UDP 接收缓冲区大小的限制。该缓冲区保存内核已接收但应用程序尚未读取的数据包(在本例中为quic-go)。一旦这个缓冲区填满,内核将丢弃任何新的传入数据包。

Therefore, quic-go tries to increase the buffer size. The way to do this is an OS-specific, and we currently have an implementation for linux, windows and darwin. However, an application is only allowed to do increase the buffer size up to a maximum value set in the kernel. Unfortunately, on Linux this value is rather small, too small for high-bandwidth QUIC transfers.

因此,quic-go 尝试增加缓冲区大小。做到这一点的方法是特定于操作系统的,我们目前有针对linux,windowsdarwin的解决方案。但是,应用程序只允许将缓冲区大小增加到内核中设置的最大值。不幸的是,在 Linux 上,这个值相当小,对于高带宽 QUIC 传输来说太小了。

我们可以通过一条指令来增加内核的最大缓冲区大小:

sudo sysctl -w net.core.rmem_max=2500000

一定要加sudo进入管理员模式,不然会报错。

此命令会把最大缓冲区大小设为大概2.5MB。

编译客户端可执行文件并执行

$ cd example/client

$ go build main.go

使用./main -v -insecure -keylog ssl.log

即可访问支持quic协议的网站。

在打开服务端进程的情况下,我们用客户端来测试,访问支持QUIC的网站https://127.0.0.1:6121/demo/tile

从6121端口返回了一个图片,这个端口就是我们的服务端。并且可以看到消息头中的Proto(版本)字段为HTTP/3,表示本次传输使用的是HTTP3协议。

在命令行中测试完后,再在浏览器中测试一下,

进入Firefox浏览器,首先在地址栏输入about:config进入开发者设置,把HTTP3的选项都设为true,这时浏览器就会支持HTTP3协议的传输了。

输入网址https://127.0.0.1:6121/demo/tile

可以看到这时用的是HTTP3的协议,响应头带了alt-svc,以告诉浏览器服务器支持HTTP3服务。

我们在服务端也可以看到,接收到了一个请求

测试客户端

使用

./main -v -insecure -keylog key.log https://quic.rocks:4433/

访问测试网站,可以看见最后成功输出了网页的内容 “You have successfully loaded quic.rocks using QUIC!”,使用的协议为HTTP/3,并且错误代码为0x100,即未发生错误。

浏览器同样,打开开发者调试工具可以看到这个网页用的是HTTP3协议传输的。

测试echo

cd example/echo

go run echo.go

如图所示,客户端发送了一个请求想要服务端发送‘Foobar’,服务器接收到了请求并发送,客户端接收到‘Foobar’。这表示我们的项目可以正常使用。

QUIC源代码分析

数据包

quic的数据包是通过UDP数据报进行传输的,一个数据报中可以包含一个或多个quic数据包。quic数据包编号被分为三个空间:

  • Initial:所有初始包

  • Handshake:所有握手包

  • Application data:所有 0-RTT 和 1-RTT 加密的数据包

quic-go有三种类型的包:Initial,Handshake以及Protected payload即Application data。

首部

quic首部分为两种:Long header 和 Short Header,通过第一个有效字节的最高位来区分。首部当中有部分字段是于版本有关的,本文将以quic-29为基础进行分析。

Long header的定义如下:

Long Header Packet {
    Header Form (1) = 1,
    Fixed Bit (1) = 1,
    Long Packet Type (2),
    Type-Specific Bits (4),
    Version(32),
    Destination Connection ID Length (8),   
    Destination Connection ID (0..160),
    Source Connection ID Length (8),
    Source Connection ID (0..160),
}

Long Header Packets的类型包括四种:Initial,0-RTT,Handshake,Retry。

Short Header的定义如下:

Short Header Packet {
    Header Form (1) = 0,
    Fixed Bit (1) = 1,
    Spin Bit (1),
    Reserved Bits (2),
    Key Phase (1),
    Packet Number Length (2),
    Destination Connection ID (0..160),
    Packet Number (8..32),
    Packet Payload (..),
}

在版本协商以及1-RTT密钥传输完成后,quic就会使用Short Header Packet来传输数据。

Client代码

在example的client代码中,通过http3.RoundTripper建立了一个中间件,之后将roundTrippe传递给http.Client建立了一个http客户端,并以此来发起http请求。

roundTripper := &http3.RoundTripper{

        TLSClientConfig: &tls.Config{
    
        RootCAs: pool,

        InsecureSkipVerify: *insecure,

        KeyLogWriter: keyLog,

    },
    
        QuicConfig: &qconf,

}

efer roundTripper.Close()

hclient := &http.Client{

    Transport: roundTripper,

}

rsp, err := hclient.Get(addr)

http3.RoundTripper的实现中,将请求又交给了RoundTripOpt函数来处理。该函数中首先判断请求是否合法,如果不合法就关闭请求,合法就会通过cl, err := r.getClient(hostname, opt.OnlyCachedConn)来获取quic客户端。

而在getClient函数中,通过hash表来获取quic client,如果不存在就会通过newClient函数建立新client。

当获取到client之后,就会通过client.RoundTrip函数发起请求。

而在client.RoundTrip中,在发起请求之前,会调用authorityAddr来确保源地址不是伪造的。当第一次发送请求时会调用dial函数进行握手,如果使用0rtt请求,就立即发送请求,否在当握手完成后通过doRequest发出请求。

echo.go代码

下面来分析一下echo.go的源代码

main函数中,启动一个服务器,在客户端打开的第一个流上显示数据,然后与客户端连接,发送消息,并等待接收。

echoServer函数中,启动一个服务器,它在客户端打开的第一个流上显示所有数据。

listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)创建了一个监听器来监听客户端的请求,当有请求时,就会接收这个请求并且建立新的连接。

clientMain函数,发送请求并打印,当建立连接并接收到服务器回传的消息后,打印信息并关闭。

generateTLSConfig函数为服务器设置一个简单的 TLS 配置。

package main

import (
        "context"
        "crypto/rand"
        "crypto/rsa"
        "crypto/tls"
        "crypto/x509"
        "encoding/pem"
        "fmt"
        "io"
        "log"
        "math/big"

        quic "github.com/lucas-clemente/quic-go"
)

const addr = "localhost:4242"

const message = "foobar"

// We start a server echoing data on the first stream the client opens,
// then connect with a client, send the message, and wait for its receipt.
func main() {
        go func() { log.Fatal(echoServer()) }()

        err := clientMain()
        if err != nil {
                panic(err)
        }
}

// Start a server that echos all data on the first stream opened by the client
func echoServer() error {
        listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)
        if err != nil {
                return err
        }
        sess, err := listener.Accept(context.Background())
        if err != nil {
                return err
        }
        stream, err := sess.AcceptStream(context.Background())
        if err != nil {
                panic(err)
        }
        // Echo through the loggingWriter
        _, err = io.Copy(loggingWriter{stream}, stream)
        return err
}

func clientMain() error {
        tlsConf := &tls.Config{
                InsecureSkipVerify: true,
                NextProtos:         []string{"quic-echo-example"},
        }
        session, err := quic.DialAddr(addr, tlsConf, nil)
        if err != nil {
                return err
        }

        stream, err := session.OpenStreamSync(context.Background())
        if err != nil {
                return err
        }

        fmt.Printf("Client: Sending '%s'\n", message)
        _, err = stream.Write([]byte(message))
        if err != nil {
                return err
        }

        buf := make([]byte, len(message))
        _, err = io.ReadFull(stream, buf)
        if err != nil {
                return err
        }
        fmt.Printf("Client: Got '%s'\n", buf)

        return nil
}

// A wrapper for io.Writer that also logs the message.
type loggingWriter struct{ io.Writer }

func (w loggingWriter) Write(b []byte) (int, error) {
        fmt.Printf("Server: Got '%s'\n", string(b))
        return w.Writer.Write(b)
}

// Setup a bare-bones TLS config for the server
func generateTLSConfig() *tls.Config {
        key, err := rsa.GenerateKey(rand.Reader, 1024)
        if err != nil {
                panic(err)
        }
        template := x509.Certificate{SerialNumber: big.NewInt(1)}
        certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
        if err != nil {
                panic(err)
        }
        keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
        certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})

        tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
        if err != nil {
                panic(err)
        }
        return &tls.Config{
                Certificates: []tls.Certificate{tlsCert},
                NextProtos:   []string{"quic-echo-example"},
        }
}

 

作者:NP197郭骁 

...全文
1044 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

571

社区成员

发帖
与我相关
我的任务
社区描述
软件工程教学新范式,强化专项技能训练+基于项目的学习PBL。Git仓库:https://gitee.com/mengning997/se
软件工程 高校
社区管理员
  • 码农孟宁
加入社区
  • 近7日
  • 近30日
  • 至今

试试用AI创作助手写篇文章吧