本篇文章帶大家了解一下QUIC協(xié)議,并以QUIC協(xié)議為例,來聊聊如何學(xué)習(xí)網(wǎng)絡(luò)協(xié)議,希望對(duì)大家有所幫助!
在之前發(fā)布的關(guān)于 s2n-quic 的文章中,有讀者問我如何學(xué)習(xí)像 QUIC 這樣的網(wǎng)絡(luò)協(xié)議。對(duì)于大部分互聯(lián)網(wǎng)從業(yè)者來說,雖然大家每天都在跟網(wǎng)絡(luò)打交道,但很少有人會(huì)(需要)關(guān)心 HTTP 之下的網(wǎng)絡(luò)協(xié)議的細(xì)節(jié),大部分時(shí)候,了解個(gè)大概,知道如何使用就可以了。如果你對(duì) QUIC 一點(diǎn)概念都沒有,那么,下面這個(gè)圖能幫助你很好地了解 QUIC 在 HTTP/3 生態(tài)中的地位:
那么,如果你就是要詳盡地了解一下 QUIC 的知識(shí),該如何入手呢?
作為一個(gè)曾經(jīng)的網(wǎng)絡(luò)協(xié)議和網(wǎng)絡(luò)設(shè)備的開發(fā)者,我自己的心得是:從 RFC 入手,輔以 wireshark 抓包,來快速掌握目標(biāo)協(xié)議。
對(duì)于 QUIC 而言,我們首先需要閱讀的是 RFC9000。協(xié)議的閱讀是非常枯燥的事情,需要一定的耐心,如果英文不太好,可以用 google translate 將其翻譯成中文,快速瀏覽一番(泛讀)。第一遍閱讀主要了解里面的主要概念,以及主要流程。
之后,我們就可以撰寫使用 QUIC 協(xié)議的程序,然后通過 wireshark 抓包,通過研究實(shí)際的報(bào)文,對(duì)比 RFC 協(xié)議中的內(nèi)容(精讀),來更深入地理解協(xié)議的本質(zhì)。
我們還是以上一篇文章中的代碼為基礎(chǔ),構(gòu)建 echo 客戶端和服務(wù)器。為方便大家閱讀,我把代碼也貼上來。感興趣的同學(xué)可以自行 clone 我的 repo,并運(yùn)行 client/server 代碼。
客戶端代碼(見 github: tyrchen/rust-training/live_coding/quic-test/examples/client.rs):
服務(wù)端代碼(見 github: tyrchen/rust-training/live_coding/quic-test/examples/server.rs):
這兩段代碼構(gòu)建了一個(gè)最簡單的 echo server。我們可以使用 wireshark 監(jiān)聽本地 loopback 接口下的 UDP 包,進(jìn)行抓包。要注意的是,對(duì)于 QUIC 這樣使用了 TLS 協(xié)議的流量,即便抓到了包,可能只有頭幾個(gè)包可讀,后續(xù)的包都是加密內(nèi)容,無法閱讀。因此,我們?cè)跇?gòu)建 client/server 時(shí),需要想辦法把服務(wù)器和客戶端之間協(xié)商出來的 session key 抓取下來,供 wireshark 解密使用。一般 SSL/TLS 庫都會(huì)提供這個(gè)功能。比如對(duì)于 Rustls,我們可以在 tls config 中使用 key_log。如果你仔細(xì)看上面 server 的代碼,會(huì)看到這句:
let config = Builder::new() .with_certificate(CERT_PEM, KEY_PEM)? .with_key_logging()? # 使能 keylogging .build()?;
使用了 key_log 后,在啟動(dòng) server 的時(shí)候,我們只需要指定 SSLKEYLOGFILE 就可以了:
SSLKEYLOGFILE=session.log cargo run --example server
在抓包完成后,打開 wireshark 的 preference,選擇 TLS 協(xié)議,把 log 的路徑放進(jìn)去就可以了:
以下是一次完整的客戶端和服務(wù)器的交互的抓包,我們看到,所有 “protected payload” 都被正常顯示出來了:
因?yàn)槲覀兊?echo client 只做了最簡單的動(dòng)作(只開了一個(gè) bidirectional stream),所以通過這個(gè)抓包,我們重點(diǎn)可以研究 QUIC 協(xié)議建立連接的過程。
客戶端發(fā)送的首包
我們看客戶端發(fā)的第一個(gè)報(bào)文:
這個(gè)報(bào)文包含了非常豐富的信息。首先,和 TCP 握手不同的是,QUIC 的首包非常大,有 1200 字節(jié)之多(協(xié)議要求 UDP payload at least 1200 bytes),包含 QUIC 頭,一個(gè) 255 字節(jié)的 CRYPTO frame,以及 890 字節(jié) PADDING frame。從 header 可以看到,這個(gè) QUIC 包的類型是 Initial。
QUIC 報(bào)文類型
對(duì)于 QUIC 包來說,Header 是明文,之后的所有 frame payload 都是密文(除了頭幾個(gè)包)。我們看到這個(gè)首包是一個(gè) Long Header 報(bào)文,在 RFC9000 的 17.2 節(jié)中,定義了 Long Header Packet:
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), Type-Specific Payload (..), }
感興趣的可以自行去閱讀 RFC 相應(yīng)的章節(jié)。對(duì)于 Long Header 報(bào)文,有如下幾種類型:
既然有 Long Header packet,那么就有 Short Header packet,Short Header packet 目前的版本只有一種:
1-RTT 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 (8..), }
為什么需要 connection id?
在我們捕獲的這個(gè)報(bào)文頭中,我們看到有 Source Connection ID(SCID)和 Destination Connection ID(DCID)這個(gè)新的概念。你也許會(huì)好奇:QUIC 不是基于 UDP/IP 的協(xié)議么?底層的協(xié)議已經(jīng)有五元組(src ip / src port / dst ip / dst port / protocol)來描述一個(gè)連接(connection),為什么還需要 connection id 這樣一個(gè)新的概念?
這是為了適應(yīng)越來越多的移動(dòng)場(chǎng)景。有了 QUIC 層自己的 connection id,底層網(wǎng)絡(luò)(UDP/IP)的變化,并不會(huì)引發(fā) QUIC 連接的中斷,也就是說,你從家里開車出門,即便手機(jī)的網(wǎng)絡(luò)從 WIFI(固網(wǎng)運(yùn)營商分配給你的 IP)切換到蜂窩網(wǎng)絡(luò)(移動(dòng)運(yùn)營商分配給你的 IP),整個(gè) UDP/IP 網(wǎng)絡(luò)變化了,但你的 QUIC 應(yīng)用只會(huì)感受到細(xì)微的延遲,并不需要重新建立 QUIC 連接。
從這個(gè)使用場(chǎng)景來看,QUIC 底層使用無連接的 UDP 是非常必要的。
首包中就包含了 TLS hello?
我們接下來看看 CRYPTO frame:
可以看到,QUIC 在建立連接的首包就把 TLS Client Hello 囊括在 CRYPTO frame 中。并且使用的 TLS版本是 1.3。在 Client Hello 的 extension 中,QUIC 協(xié)議使用了一個(gè) quic_transport_parameters 的 extension,用來協(xié)商 QUIC 自己的一些初始值,比如支持多少個(gè) stream,這個(gè)連接中可以最多使用多少個(gè) active connection id 等等。
QUIC 支持哪些 frame?
現(xiàn)在我們已經(jīng)見到了兩種 Frame:CRYPTO 和 PADDING。下表中羅列了 QUIC 所有支持的 frame:
服務(wù)器的回包
我們來看 server 的回包:
這里有一些新東西。首先,一個(gè) UDP 包內(nèi)部可以包含若干個(gè) QUIC payload,我們看到 server 回復(fù)了一個(gè) QUIC Initial 報(bào)文和一個(gè) QUIC Handshake 報(bào)文。在 Initial 報(bào)文中,我們看到了一個(gè) ACK frame,可見 QUIC 雖然構(gòu)建于 UDP,但在 QUIC 協(xié)議內(nèi)部構(gòu)建了類似 TCP 的確認(rèn)機(jī)制。
我們之前看到,在 Initial 報(bào)文的 CRYPTO frame 中,客戶端發(fā)送了 TLS Client Hello,同樣的,服務(wù)器在 Initial 報(bào)文的 CRYPTO frame 中發(fā)送了 TLS Server Hello。這個(gè)我們就略過不看。
在 Handshake 報(bào)文中:
服務(wù)器發(fā)送了自己的證書,并結(jié)束了 TLS handshake。
客戶端結(jié)束 Handshake
我們?cè)倏吹谌齻€(gè)包,客戶端發(fā)送給服務(wù)器結(jié)束 TLS 握手:
這個(gè)包依舊包含兩個(gè) QUIC 報(bào)文,其中第一個(gè)就是一個(gè) ACK frame,來確認(rèn)收到了服務(wù)器的 Server Hello 那個(gè) QUIC 報(bào)文;第二個(gè)包含一個(gè) ACK frame,確認(rèn)服務(wù)器的 Handshake,隨后有一個(gè) CRYPTO frame 結(jié)束客戶端的 TLS handshake。
TLS 握手結(jié)束之后,客戶端和服務(wù)器就開始應(yīng)用層的數(shù)據(jù)交換,此刻,所有數(shù)據(jù)都是加密的。
客戶端發(fā)送一個(gè) “hello” 文本
在我們的 echo client/server 代碼中,客戶端連接到服務(wù)器后,就可以等待用戶在 stdin 的輸入,然后將其發(fā)送到服務(wù)器。服務(wù)器收到客戶端數(shù)據(jù),原封不動(dòng)發(fā)回,客戶端再將其顯示到 stdout 上。在這個(gè)過程的前后,客戶端和服務(wù)器間有一些用于連接管理的 QUIC 報(bào)文,比如 PING。我們就略過,只看發(fā)送應(yīng)用層數(shù)據(jù)的報(bào)文。下圖是客戶端發(fā)送的包含 “hello” 文本的報(bào)文:
可以看到,這里 QUIC 報(bào)文是個(gè) Short Header packet,除了 ACK frame 外,它還有一個(gè) STREAM frame。這個(gè) stream 的 stream ID 最低兩位是 00,代表是客戶端發(fā)起的,雙向的 stream。由于使用了兩位來表述類型,所以 QUIC 的 stream 有如下類型:
我們看 STREAM frame 的 length(6) 和 Data(68 65 6c 6c 6f 0a)。Data 里的內(nèi)容如果用 ASCII 表示,正好是 “hello<LF>”,它的長度是 6 個(gè)字節(jié)。
服務(wù)器回復(fù) “hello” 文本
最后是服務(wù)器 echo back:
這個(gè)和上面的報(bào)文如出一轍,就不解釋了。
賢者時(shí)刻
相信通過上面對(duì)照著 wireshark 抓包進(jìn)行的 QUIC 簡介,能讓你對(duì) QUIC 協(xié)議有一個(gè)初步的認(rèn)識(shí)。上篇文章,我們說 QUIC 支持多路復(fù)用,并且解決了傳輸層隊(duì)頭阻塞的問題。通過這篇文章的介紹,你能回答以下兩個(gè)問題么?
-
QUIC 通過哪個(gè) frame 類型來做多路復(fù)用的?
-
QUIC 如何解決傳輸層隊(duì)頭阻塞的?