QUICの中身が分からないから仕様読んでみた

κeenです。先日同期と話しててQUICの中身ってあまり知らないよねってことでQUICの仕様(ドラフト)を読んだのでまとめますね。あまりまとめきれてませんが。

※ドラフトは既に古くなっているのでこのブログの内容は現行では正しくない可能性があります。というか一部既に正しくないことが判明しています。ご注意下さい

後で新しいドラフトを発見したので内容を書き換えました。とりあえずリンクを貼ってあるドラフトの内容までは反映出来ています。

背景

仕様を読む前にQUICの背景から。 HTTP/2でHTTPにストリームという概念が入りました。 1つのコンテンツ毎に順にやりとりするのではなく、複数のコンテンツを並行して通信する仕組みです。

# 今まで
[]--CCCBBBAAA-->[]

# HTTP/2
[]--ACCBCABBA-->[]

上の図でいえば例えばAのコンテンツがサーバの都合で遅くてもBやCのコンテンツが支える(Head of Line Blocking)ことなくクライアントに届きます。

ところで、この通信はTCP上で行われています。TCPは到達順序を保障するので例えば1パケット欠損したらそれ以後のパケットは(実際にはクライアントに到着しているにも関らず)待たされます(TCP Head of Line Blocking)。

[]--ACCBCABB-X-->[]

この図でいえばAのパケットが欠損してますが、HTTP/2的にはAは無視してBやCのコンテンツをユーザに届けることは可能な訳です。 この辺を改善したいというのがQUICのモチベーション。

もうちょっと言うとTCPの3way hand shakeだとかその上のTLS hand shakeだとかのオーバーヘッドの削減の目的もあります。 TCPやTLSもRTTを減らそうと努力はしていますが、もっと抜本的な解決が必要とのことです。

ということでHTTP/2に特化してTCP+TLSを置き換えるための通信プロトコルとしてUDPベースのQUICが産まれました。

ここまではよくあるQUICの説明。でも、これだけだと情報が少なくてもやもやしますよね。

  • HTTP/2に特化とはいうけどどこまで特化してるの?他のアプリケーションで使えないの?
  • どうしてTLSも統合してしまったの?分離出来なかったの?
  • UDPベースでどうやってコネクションの維持や輻輳制御してるの?
  • 上記以外でQUICに特徴はないの?

などなど。これらの疑問を解決すべくQUICのドラフトを読んでいきます。


QUIC

これを読んでいきます。Expires July 16, 2016とのこと。

イントロ

QUICはHTTP/2のストリーム分割やフローコントロール、TLSのセキュリティ、TCPのコネクションセマンティクスや信頼性、輻輳制御を提供します。

QUICはUDPベースの通信プロトコルなので、完全にユーザーランドで完結します。 これは重要な話で、レガシーなネットーワーク中間機器の上でもちゃんと通信出来ることを意味します。 仕様化する前に実証実験をする上でとても重要な性質です。

用語

  • クライアント: QUICコネクションを開始する端
  • サーバ: QUICコネクションを受け付ける端
  • エンドポイント: サーバ、またはクライアント
  • ストリーム: QUICコネクションの論理チャネル内を双方向に流れるバイト列の流れ
  • コネクション: 単一の暗号コンテキスト下のQUICエンドポイント同士のやりとり。複数のストリームを持つ。
  • コネクションID: QUICコネクションのID
  • QUIC Packet: QUICでパース可能な有効なUDPペイロード。QUICのパケットサイズとはUDPのペイロードのサイズを指す。

概要

QUIC(+HTTP/2)のTCP+TLS(+HTTP/2)に対する利点は以下を改善することです。

  • コネクション確立のレイテンシ
  • 柔軟な輻輳制御
  • Head of Line Blockingなしに多重化
  • ヘッダやペイロードが認証/暗号化されている
  • ストリーム及びコネクションフローコントーロール
  • 前方エラー訂正
  • コネクション移行

思ったより特徴ありますね。コネクション移行とか面白そう。 さて、それぞれ見ていきます。忙しい人は概要まででもそれなりに役立つでしょう。

コネクション確立のレイテンシ

通常TCP+TLSだと1-3RTT必要なのに対してQUICは多くの場合0 RTTでコネクション確立出来ます。つまり、いきなりデータを送れる。

0 RTTで接続出来ない場合、つまりハンドシェイクが必要な場合もある訳ですが、ハンドシェイクの詳細はQUIC Cryptoの方に投げられています。 さらにQUIC CryptoはTLS 1.3に置き換えられる予定なのでほぼTLS 1.3のハンドシェイクだと思っておいて良いようです。

柔軟な輻輳制御

QUICはプラガブルな輻輳制御を持っており、TCPより豊富なシグナルがあるのでTCPの輻輳制御アルゴリズムより賢く振る舞うことが出来ます。 とはいっても現状の(ドラフト時点の)GoogleではTCPのアルゴリズムを流用しており別のアプローチを実験中とのこと。

詳細はここにあるとのことでしたが、リンク切れなのか真っ白なページしかありません。

さて、シグナルが豊富とのことでしたが、1例を出すとパケットの元のものと再送されたものでシーケンス番号が異ります(私はTCPの詳細を知らないのでよく分かりませんが輻輳制御のためにシーケンス番号を振っているのでしょう)。 元と再送のものを区別出来るようになるのでTCPの曖昧性問題(というのがあるのでしょう)を解決出来るとのこと。

また、パケットを受け取ってからackを送るまでの時差と単調増加するシーケンス番号も一緒に送るのでRTTを計算することが出来ます。

最後に、ACKが256 NACKまでサポートする(らしい)のでTCPのSACKよりもリオーダリングに弾力性があり、パケロスやリオーダリングがある環境下でもパケット密度を高めることが出来るとのこと。これは後程記述があります。

この辺はTCPの輻輳制御から勉強しないと利点が分からないですね。宿題。

ストリーム及びコネクションフローコントーロール

順番が前後しますがストリームの話。そういえばHTTP/2にバックプレッシャーありましたね。

ストリームレベル、コネクションレベルでのフロー制御が出来ます。 ほぼHTTP/2と同等のストリーム制御が可能です。

ストリームレベルの制御は、まず、受け取り側がストリーム内のデータのどのオフセットまでを受け取るかを広報します。 ストリームにデータが届いたら、WINDOW_UPDATEのフレームを投げて、受け取り可能なオフセットを更新します。

コネクションレベルの制御は、ストリーム合計でのバッファを制限するために使います。 単純にストリームでやっている制御をコネクションレベルでやるだけです。

また、TCPにあるようにreceive-windowのオートチューニングもやるそうです。

この辺、HTTP/2に合わせた仕様なんですね。

多重化

TCP head of line blockingしない。因みにHTTP/2のヘッダはHPACKで圧縮して送るのでここはhead of line blockingします。

ヘッダやペイロードが認証/暗号化されている

そもそもの話、TCPは平文で通信するのでreceive-windowの更新やらシーケンス番号を上書きしたりやらの攻撃が可能です(尤も、通信の最適化のために中間機器で行うこともありますが)。

QUICは一部のヘッダを除き暗号化されています。暗号化されていない部分も受理側によって認証されるのでインジェクションを阻止出来ます。

ここでTLSも統合している理由が分かりました。認証のためにTLSが必要なんですね。

前方エラー訂正

Forward Error Correction (FEC)。シンプルなXORベースのFECをやるそうです。FECグループ内の1パケットがロスしてもFECパケットから復元出来るとのこと。すごい。

コネクション移行

TCPは4-tuple(source address, port, destinacion address, port)でコネクションを判別しますが、それだと例えばスマホが電話通信(って呼称でいいのかな?)からWifiに切り替わった時にIPが変わりますし、NAT下でポート番号が変わることもあるので突発的にコネクションが切れてしまう訳です。

QUICはクライアントがランダム生成した64bitのコネクションIDで識別します。 じゃあ、コネクションIDを被せにいったらハイジャック出来るじゃんと思えますが、TLSを前提にしているのでクライアント認証も自動でついていて、その辺には耐性があります。

パケットタイプとフォーマット

2種類の特殊パケットと2種類の通常パケットがあります。バージョンネゴシエーションパケットとパブリックリセットパケット、フレームパケットとFECパケットです。

パケットはIPの断片化を防ぐためにパスのMTU(Message Transfer Unit)に収まる必要がありますが、MTUの発見はまだWIPだそうです。 今とのころIPv6で1350byte、IPv4で1370byteを使っているとのこと。

バブリックヘッダ

全てのQUICパケットにつくヘッダです。パブリックの名の通り暗号化されません。

長さは2-19byteの間になります。

      0        1        2        3        4            8
+--------+--------+--------+--------+--------+---    ---+
| Public |    Connection ID (0, 8, 32, or 64)    ...    | ->
|Flags(8)|      (variable length)                       |
+--------+--------+--------+--------+--------+---    ---+

     9       10       11        12
+--------+--------+--------+--------+
|      QUIC Version (32)            | ->
|         (optional)                |
+--------+--------+--------+--------+

    13      14       15        16        17       18
+--------+--------+--------+--------+--------+--------+
|         Packet Number (8, 16, 32, or 48)            |
|                         (variable length)           |
+--------+--------+--------+--------+--------+--------+

軽く説明しますね。それぞれの詳しい内容は仕様を読んで下さい。

  • public flagsにパケットタイプなどが入っています。
  • コネクションIDが64bitだと過剰な場合はネゴって短かくすることも可能です。
  • パケット番号はフレームパケットに付与されます。1から始まり1づつ大きくなります。
  • パケット番号の下位64bitはTLSのnonceの一部に使われます。合理的ですね。
  • 内部的には64bitでシーケンス番号を管理するけどパケットに載せるのは48bitまで。
  • 48bit(n bit)でオーバーフローした時の曖昧性排除のために2^46個(2^(n-2)個)までしか同時にパケットを送れない
  • シーケンス番号が2^64-1に逹したらシーケンス番号のリミットでコネクションクローズが走る

パケットタイプを判別するフローチャートが載っていたので引用しますね。

Check the public flags in public header
                 |
                 |
                 V
           +--------------+
           | Public Reset |    YES
           | flag set?    |---------------> Public Reset Packet
           +--------------+
                 |
                 | NO
                 V
           +------------+          +-------------+
           | Version    |   YES    | Packet sent |  YES
           | flag set?  |--------->| by server?  |--------> Version Negotiation
           +------------+          +-------------+               Packet
                 |                        |
                 | NO                     | NO
                 V                        V
           Regular Packet         Regular Packet with
                              QUIC Version present in header

スペシャルパケット

バージョンネゴシエーションパケット

サーバからのみ送られます。

     0        1        2        3        4        5        6        7       8
+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| Public |    Connection ID (64)                                                 | ->
|Flags(8)|                                                                       |
+--------+--------+--------+--------+--------+--------+--------+--------+--------+

     9       10       11        12       13      14       15       16       17
+--------+--------+--------+--------+--------+--------+--------+--------+---...--+
|      1st QUIC version supported   |     2nd QUIC version supported    |   ...
|      by server (32)               |     by server (32)                |
+--------+--------+--------+--------+--------+--------+--------+--------+---...--+

特に順序に言及がないのでクライアントは良い感じに新しいやつ選ぶんですかね。

パブリックリセットパケット

コネクションをクローズしようとしているのが本当に正しいクライアントなのか証明するための情報が載せられます。詳細は仕様を読んで下さい。

     0        1        2        3        4         8
+--------+--------+--------+--------+--------+--   --+
| Public |    Connection ID (64)                ...  | ->
|Flags(8)|                                           |
+--------+--------+--------+--------+--------+--   --+

     9       10       11        12       13      14
+--------+--------+--------+--------+--------+--------+---
|      Quic Tag (32)                |  Tag value map      ... ->
|         (PRST)                    |  (variable length)
+--------+--------+--------+--------+--------+--------+---

通常パケット

通常パケットのペイロードは暗号化/認証されます。パブリックヘッダは暗号化されませんが認証されます。 通常パケットはPrivate Flagsから始まるプライベートヘッダを持ちます(そこからが暗号化されます)。

     0        1
+--------+--------+
|Private | FEC (8)|
|Flags(8)|  (opt) |
+--------+--------+

このあとにAEAD (authenticated encryption and associated data)、つまり認証/暗号化されたペイロードが続きます。

フレームパケット

プライベートヘッダに続いてこんな感じのデータが続きます。

+--------+---...---+--------+---...---+
| Type   | Payload | Type   | Payload |
+--------+---...---+--------+---...---+

FECパケット

プライベートヘッダに続いてこんな感じのデータが続きます。

+-----...----+
| Redundancy |
+-----...----+

QUICコネクションのライフサイクル

コネクションの確立

クライアントがバージョン付きでデータを送って、サーバが処理出来るならそのままレスポンスを返してコネクション成立です。 もし処理出来ないなら、バージョンネゴシエーションパケットを送り返して、クライアントはそこから1つバージョンを選んで再送します。サーバから通常レスポンスが返ってきたらコネクション成立です(成立するまで繰り返します)。

ダウングレード攻撃を避けるためにハンドシェイクにバージョン情報を載せたり頑張るようですが、細かいので仕様を読んで下さい。

データ転送

多くは概要で説明した通り。 暗号化ハンドシェイクも含めてストリーム内でデータをやりとりしますが、ACKはQUICパケット単位です。

シーケンス番号は輻輳制御を統合するためにコネクションを通して同じ番号空間を共有します。

詳しくは付設のドキュメントを読めと書いてますが、リンク先が真っ白です。

ストリームのライフサイクル

ストリームはデータをストリームフレームに分割してデータ転送します。 ストリームフレームは互いに順序逆転可能です。 ストリームの生成/クローズはサーバ、クライアント両方から出来ます。 ほとんどHTTP/2のストリームと同じですね。

ストリーム生成は特にネゴる必要なく簡単に可能です。ただ、ストリームIDが衝突しないようにサーバが作る時は偶数、クライアントが作る時は奇数を使います。 0は無効、1は暗号化ハンドシェイクのために予約、3はHTTPヘッダの通信のために予約とされています。ヘッダだけは順序保障が必要なんですね。 他の細かい規約は仕様を見て下さい。

一旦ストリームが開いたらデータ転送に自由に使えます。

ストリームの終了には3種類あります。

  1. 普通の終了: 双方向通信なのでFIN bitを立てて送信した後のhalf-closeとかの概念があります。
  2. 突然の終了: 何かしらのエラーがあった時などにRST_STREAMフレームを送ると強制終了出来ます。
  3. コネクションが終了した時: まあ、当然ですね。

コネクションの終了

コネクションの終了にも2種類あります。

  1. 明示的な終了: CONNECTION_CLOSEを送ることでコネクションを終了出来ます。
  2. 暗黙的な終了: タイムアウト(デフォルト30秒)したらクローズします。通常はCONNECTION_CLOSEを送りますが、モバイルで電波をonにしたくないなどの理由があればsilent closeも可能です。

それ以外に、PUBLIC_RSTでコネクションをクローズ出来ます。TCPのRST相当(だそう)です。

フレームタイプとフォーマット

前述の通りフレームパケットにはフレームが入っています。フレームタイプ毎にデータの解釈が変わります。 1フレームは必ず1パケットに収まる必要があります。

フレームタイプ

特殊フレームと通常フレームがあります。

特殊フレームは以下。

+------------------+-----------------------------+
| Type-field value |     Control Frame-type      |
+------------------+-----------------------------+
|     1fdooossB    |  STREAM                     |
|     01ntllmmB    |  ACK                        |
|     001xxxxxB    |  CONGESTION_FEEDBACK        |
+------------------+-----------------------------+

通常フレームは以下。

+------------------+-----------------------------+
| Type-field value |     Control Frame-type      |
+------------------+-----------------------------+
| 00000000B (0x00) |  PADDING                    |
| 00000001B (0x01) |  RST_STREAM                 |
| 00000010B (0x02) |  CONNECTION_CLOSE           |
| 00000011B (0x03) |  GOAWAY                     |
| 00000100B (0x04) |  WINDOW_UPDATE              |
| 00000101B (0x05) |  BLOCKED                    |
| 00000110B (0x06) |  STOP_WAITING               |
| 00000111B (0x07) |  PING                       |
+------------------+-----------------------------+

これらのタイプについて仕様に載っている順に軽く説明します。詳しくは仕様を読んで下さい。

  • STREAM: 暗黙的にstreamを作るのにもデータを送るのにも使います。
  • ACK: ackです。受け取った最大シーケンス番号とそれまでで欠損している番号のリストを送ります。前述の通り受理からACKまでの時差を入れたりと複雑なので仕様を読んで下さい。
  • STOP_WAITING: 特定以下のシーケンス番号のパケットを待たないように指示します。
  • WINDOW_UPDATE: コネクション/ストリームいずれかのウィンドウ余白を通知します。Stream ID 0がコネクションレベルのアップデートです。
  • BLOCKED: バックプレッシャーでこれ以上データを送信出来ない時に送ります。informational frameです(ほぼデバッグ用とのこと)。
  • CONGESTION_FEEDBACK: experimentalで、not usedとのこと。
  • PADDING: 0x00で埋められたデータを保持します。パケットをMTUまで埋めるのが目的なのかな?
  • RST_STREAM: ストリームの異常終了用。
  • PING: 生きてる?って訊くやつです。これを受けたらACKを返します。デフォルトで15秒毎に送ります。
  • CONNECTION_CLOSE: closeを通知するやつです。
  • GOAWAY: コネクションを止めるよ通知です。近くcloseするのでデータ送るのやめなよという通知です。新たなstreamが作れなくなります。

QUICの通信上のパラメータ

ハンドシェイクでネゴシエートすべきパラメータの列挙です。

Required

  • SFCW - Stream Flow Control Window: ストリームレベルのコントロールフローウィンドウサイズ(バイト単位)です。
  • SFCW - Connection Flow Control Window: コネクションレベルのコントロールフローウィンドウサイズ(バイト単位)です。

ほとんどルー語ですね。

Optional

  • SRBF - Socket receive buffer size in bytes: CWNDを受け取りバッファくらいに指定したい場合があるらしいのでそれ用。
  • TCID - Connection ID truncation: クライアントのエフェメラルポートが単一コネクションにしか使われないと分かっている場合に便利らしいです。
  • COPT - Connection Options are a repeated tag field: 実験的パラメータだそうです。

プライオリティ

HTTP/2のものを使うそうですが、まだ実装してないとのこと。

QUIC上のHTTP/2

いくつかHTTP/2と同じ機能を提供していますが、HTTP/2がQUICを使う時にどう統合するかのお話です。

ストリームマネジメント

QUICが代替機能を提供するのでHTTP/2レイヤで扱う必要はありません。HTTP/2のストリームIDはそのままQUICのストリームIDになります。

ヘッダー圧縮

Stream ID 3で送ることになっている(QUICの仕様でHTTP/2のヘッダを扱うことになっている)。のでそれを使います。

HTTP/2ヘッダのパース

ヘッダのパースはHTTP/2の仕様に従います。

永続コネクション

コネクションという概念がないのでHTTPにある"Connection"ヘッダが意味をなさない。ので、HTTPレベルでのコネクションハンドリングはしません。

これ、“Connection: upgrade"はどうするんですかねって思ったら次に書いてました。

HTTPでのQUICネゴシエーション

クライアントが普通のHTTPでアクセスした時にサーバはQUICを使いませんか、とネゴシエーションすることが出来ます。それが

“Alternate-Protocol: 123:quic”

です。同じホストの123ポートにQUICプロトコルでアクセスしにいきます。 中間機器がUDPをブロックすることも考えてTCPにgraceful fallbackしろ、と書かれてます。

ハンドシェイクプロトコルへの要求

ハンドシェイク自体はこのドキュメントでは扱ってませんが、ハンドシェイクが満たすべき性質を書いています。

  • 0-RTTでのコネクション確立
  • ソースアドレスのなりすまし対策
  • クライアントからソースアドレストークンが不透明なこと。トークンにいくつかのクライアント情報を埋め込むため。
  • 通信パラメータのネゴシエーション
  • 証明書の圧縮。REJも1350bytesに収めたい。
  • サーバコンフィグのアップデート

以上が仕様の主だった記述です。


まとめ

  • HTTP/2に特化とはいうけどどこまで特化してるの?他のアプリケーションで使えないの?
    → ストリームやHTTP/2ヘッダなどが統合されているのでかなり扱いづらい。
  • どうしてTLSも統合してしまったの?分離出来なかったの?
    → クライアント認証のために必要だった。TLS1.3からは分離出来る(?)
  • UDPベースでどうやってコネクションの維持や輻輳制御してるの?
    → コネクションはコネクションIDで維持。輻輳制御は別の仕様(アクセス不可)に書いてある
  • 上記以外でQUICに特徴はないの?
    → 柔軟な輻輳制御、FEC、コネクション移行など

新たに湧いた疑問

  • ロードバランシングどうするんだろう。ミドルウェアレベルだとConnection ID見て振り分けるとして、アプリケーションレベルだとRubyとかでよくあるlistenしてforkしてacceptするようなやつは破綻しないかな。そもそもQUICを使わない?
  • Connection IDが衝突したらどうなるんだろう。REJするのかな。仕様に書いてない。
  • いくつかの仕様(HTTP/2ヘッダ)とかを無視して別のアプリケーションで使えないかな。あるいは想定してないのかな。

宿題:

  • TCPについても調べる。特に輻輳制御回り。
  • QUICの輻輳制御について調べる。
  • TLS1.3との統合について調べる
Written by κeen