Node.jsのHTTP over QUIC(HTTP/3)を試す

 · 19 min read

2020/10/20にNode.js v15がリリースされました 🎉
色々新機能や破壊的変更が加わっているので、詳しくは公式のリリースノート等をご参照ください。

Node.js v15.0.0 is here!. This blog was written by Bethany… | by Node.js | Oct, 2020 | Medium

また、Node.jsのコラボレータによる日本語のわかりやすい記事もあるのであわせてご覧ください。

まとめは以上にして本題です。本記事はv15の変更点まとめを目的とした記事ではなく、v15にて新しく追加されたQUICを用いてシンプルなHTTP/3サーバを実装してHTTP over QUIC(HTTP/3)の使用感を掴むことを目的としています。この記事を読み終えると以下のものが手に入ります。

  • Node.js v15にてQUICを使用できる開発環境
  • ファイルをホスティングするシンプルなHTTP/3サーバの実装
  • cURLで動作確認する方法

まずQUICおよびHTTP/3について軽くおさらいし、QUICモジュールを利用する環境構築を構築、HTTP/3サーバのデモコードと簡単な説明をして、最後にcURLを用いて動作確認します。仕様の詳細にはあまり触れずに実装するために必要な情報にフォーカスします。

まえおき

当記事ではNode.jsのv15.0.1を前提にコードを書いています。またQUICは登場したばかりで現在のStability indexはStability: 1 - Experimental 、しかもHTTP over QUICについてはUndocumentedです。
おそらくQUICをラップしたHTTP/3用の高レベルのAPIが今後登場するでしょうし、後方互換のない破壊的変更が予告なく加わる可能性もあります。ここで得た知識は陳腐化する前提でエッジなAPIをシュッと試したい方は読み進めてもらえればと思います。

なお、当記事ではこれらを前提に書いています。

  • Node.jsを手元でビルドしたことがある(経験がない方はビルドガイドからビルドしてみてください)
  • Node.jsでHTTP/1.1のHTTPサーバを実装したことがある

QUIC、HTTP over QUIC(HTTP/3)とは?

真面目に解説するとボリュームがありすぎるので参考になったリンクを掲載します。概論をおさえておくと以後の理解がスムーズになると思います。

少なくとも注意すべきことは、**QUIC=HTTP/3ではないということです。QUICを利用した新しいHTTPの仕様がHTTP/3です。**QUICはHTTP以外のプロトコルでも使用できるよう設計されています。
また、**Node.jsのQUICにおいても同様です。**QUICを扱う=HTTP/3を扱うではありません。Node.jsで提供されたQUIC APIはQUICそのものを扱う低レイヤのAPIです。そのためQUICを用いてHTTP/3サーバを実装するというイメージを持っておいてください。ここを混同するとドキュメントの読み方やトラブルシューティング時に混乱します。

既存のHTTP/3のサイトを試す

実装を始める前に、既存のHTTP/3のサイトで動作確認します。ユーザエージェントがHTTP/3に対応しているかどうかはこちらのサイトから確認できます。

https://http3.is

現時点ではHTTP/3のブラウザの対応状況はいまひとつです。iOS Safariでフラグつきでサポートされている、Google Chromeにてサポートされているとの情報を得ましたが、私の環境ではどちらも動作しませんでした。

https://caniuse.com/?search=quic

本記事では動作確認にcURLを利用します。cURLでHTTP/3を利用するためにcURLをソースコードからビルドする必要があり、cURL公式のDockerイメージにもHTTP/3に対応したタグがないためHTTP/3対応したビルドを配布されているcurlのHTTP/3通信をDocker上で使ってみる - Qiitaを利用させてもらいます。試しに http3.is に対してリクエストを送った結果の抜粋がこちらです。

$ docker run -it --rm ymuski/curl-http3 curl -v https://http3.is --http3
*   Trying 199.232.233.77:443...
* Sent QUIC client Initial, ALPN: h3-29,h3-28,h3-27
* Connected to http3.is (199.232.233.77) port 443 (#0)
* h3 [:method: GET]
* h3 [:path: /]
* h3 [:scheme: https]
* h3 [:authority: http3.is]
* h3 [user-agent: curl/7.73.0-DEV]
* h3 [accept: */*]
* Using HTTP/3 Stream ID: 0 (easy handle 0x55f88c209a20)
> GET / HTTP/3
> Host: http3.is
> user-agent: curl/7.73.0-DEV
> accept: */*
>
< HTTP/3 200
...
      Your browser does not support the video tag, but it does support HTTP/3!
...
* Connection #0 to host http3.is left intact

HTTP/3に対応してる旨のHTMLが返ってきました。出力からHTTP/3で通信されているのがわかります。 次にサーバを実装して、このcurlコマンドを使って動作確認をします。

Node.jsをビルドする

QUICはExperimentalな機能のためQUICを使用するにはフラグをつけてNodeをビルドし直す必要があります。よくあるExperimentalな機能とは違い--experimental-...などのフラグをnodeコマンドに渡しても動作しません。また執筆時点(2020/10/22)ではQUICに対応したDockerイメージもありません。手元でビルドするのは少しハードルが高いかもしれませんが、やることは単にフラグをつけていつも通りNode.jsをビルドするだけです。

$ cd /path/to/nodejs/node
$ ./configure --experimental-quic
$ make -j4 # コア数を指定すると早くなります
$ ./node -p -e "require('net').createQuicSocket"
[Function: createQuicSocket] # <-- 表示されたらOK
$ node -p -e "require('net').createQuicSocket"
undefined # <-- グローバルなnodeだとundefinedになる

require('net').createQuicSocketが存在していれば成功です。**以後、./nodeと書いてある場合はいまビルドしたNode.jsを実行するという意味を持ちます。**グローバルにインストールされているnodeコマンドを起動しないようご注意ください。

自己証明書の作成

QUICを使用するにはlocalhostであっても証明書が必須です。適当に自己証明書を作成しておきます。

$ mkdir .certs
$ cd .certs
$ openssl genrsa 2024 > server.key
$ openssl req -new -key server.key -subj "/C=JP" > server.csr
$ openssl x509 -req -days 3650 -signkey server.key < server.csr > server.crt
$ cd -
$ ls .certs
server.crt      server.csr      server.key

サーバを実装する

環境構築が終わったので本題です。さっそくサーバを実装します。

要件定義

今回は静的なファイルを配信するサーバを実装します。このように起動できるhttp3-serve.jsを実装します。

PORT=8888 PUBLIC_ROOT=$PWD ./node http3-serve.js

パラメータは2つです。設定値はすべて環境変数で与えます。

  • PORT: ポート番号
  • PUBLIC_ROOT: 静的ファイルを配信するルートファイル
    • ファイルが存在すればそれを200で返す。content-lengthcontent-typeヘッダも返す
    • リクエストされたパスが存在しなければ404を返す
    • リクエストされたパスがファイル以外(ex. ディレクトリ)だったら403を返す

実装

いよいよ実装です。先にコードを載せます。このjsはES Modules形式で記述しています。package.jsonに"type": "module"フィールドが設定されている前提で読んでください。

// [数字] ...と書いてあるところを順に触れていきます。

import fs from 'fs'
import fsPromises from 'fs/promises'
import path from 'path'
import { createQuicSocket } from 'net'
import { lookup } from 'mime-types'

const { PORT, DOCUMENT_ROOT } = process.env

const key = await fs.readFileSync('./.certs/server.key')
const cert = await fs.readFileSync('./.certs/server.crt')

// [1] QUICソケットの初期化
const server = createQuicSocket({
  endpoint: { port: PORT },
  server: { key, cert, alpn: 'h3-29' },
})

server.on('session', async (session) => {
  // [2] session, streamイベント
  session.on('stream', (stream) => {
    // [3] リクエストヘッダを受け取る
    stream.on('initialHeaders', (rawHeaders) => {
      const headers = new Map(rawHeaders)
      const url = new URL(headers.get(':path'), 'https://localhost')
      const requestPath = path.join(DOCUMENT_ROOT, url.pathname)
      fsPromises
        .stat(requestPath)
        .then((stats) => {
          if (!stats.isFile()) {
            // [4] レスポンスヘッダを返す
            stream.submitInitialHeaders({
              ':status': '403',
            })
            stream.end()
            return
          }

          stream.submitInitialHeaders({
            ':status': '200',
            'content-length': stats.size,
            'content-type': lookup(requestPath) || 'application/octet-stream',
          })
          // [5] レスポンスボディを返す
          fs.createReadStream(requestPath).pipe(stream)
        })
        .catch((e) => {
          stream.submitInitialHeaders({
            ':status': '404',
          })
          stream.end()
        })
    })
  })
})

await server.listen()
console.log(`The socket is listening on :${PORT}`)

[1] QUICソケットの初期化

// [1] QUICソケットの初期化
const server = createQuicSocket({
  endpoint: { port: PORT },
  server: { key, cert, alpn: 'h3-29' },
})

特にserver.alpnが重要です。単にQUICを扱うなら任意の値が指定可能ですが、HTTP/3のサーバを立てるなら値を(現バージョンにおいては)h3-29にする必要があります。

ALPN identifiers that are known to Node.js (such as the ALPN identifier for HTTP/3) will alter how the QuicSession and QuicStream objects operate internally, but the QUIC implementation for Node.js has been designed to allow any ALPN to be specified and used.

QuicSession and ALPN | Node.js v15.0.1 Documentation

createQuicSocketに指定可能な全てのオプションは公式ドキュメントをご確認ください。

createQuicSocket | Node.js v15.0.1 Documentation

[2] session, streamイベント

server.on('session', async (session) => {
  // [2] session, streamイベント
  session.on('stream', (stream) => {

QUICのセッションが開始されたときにsessionイベントが呼び出されます。コールバックの引数はQuicSessionのインスタンスです。QuicSessionが確立した後にクライアントがストリームを作成した時にstreamイベントが呼び出されます。コールバックの引数はQuicStreamのインスタンスです。基本的に1リクエストにつき1回streamイベントが呼び出されます。

QuicSessionは以下の4つの状態のいずれかをとります。このうちInitialに相当するのがsessionイベントです。Readyに相当するのが次に紹介するstreamイベントです。

  • Initial - Entered as soon as the QuicSession is created
  • Handshake - Entered as soon as the TLS 1.3 handshake between the client and server begins. The handshake is always initiated by the client
  • Ready - Entered as soon as the TLS 1.3 handshake completes. Once the QuicSession enters the Ready state, it may be used to exchange application data using QuicStream instances
  • Closed - Entered as soon as the QuicSession connection has been terminated

Client and server QuicSessions | Node.js v15.0.1 Documentation

QuicStreamはstream.Duplexを継承しており、リクエストボディはstreamから読み込めます。

[3] リクエストヘッダを受け取る

// [3] リクエストヘッダを受け取る
stream.on('initialHeaders', (rawHeaders) => {

QuicSessionが確立した後にクライアントがストリームを作成し、リクエストヘッダが届いた時に呼び出されます。rawHeadersにはこのような値が格納されています。

[
  [ ':method', 'GET' ],
  [ ':path', '/hoge' ],
  [ ':scheme', 'https' ],
  [ ':authority', 'host.docker.internal:8080' ],
  [ 'user-agent', 'curl/7.73.0-DEV' ],
  [ 'accept', '*/*' ]
]

:からはじまるヘッダは擬似ヘッダ(Pseudo-Header Fields)と呼ぶそうです。よく利用するであろう擬似ヘッダは:method:pathです。名前から察する通り:methodはHTTPメソッド、:pathはリクエストされたパス(クエリ文字列含む)が格納されています。リクエストボディが不要ならこの時点でリクエストを処理できます。

他にもヘッダに関するメソッド、イベントがありますが、使い分けは以下の通りです。

  • Informational Headers: Any response headers transmitted within a block of headers using a 1xx status code
  • Initial Headers: HTTP request or response headers
  • Trailing Headers: A block of headers that follow the body of a request or response
  • Push Promise Headers: A block of headers included in a promised push stream

QuicStream headers | Node.js v15.0.1 Documentation

[4] レスポンスヘッダを返す

// [4] レスポンスヘッダを返す
stream.submitInitialHeaders({
  ':status': '403',
})
stream.end()

streamイベントで受け取ったQuicStreamに対してsubmitInitialHeadersメソッドをコールすることでレスポンスヘッダを返せます。
HTTPステータスコードは:statusという擬似ヘッダでセットします。

ボディを返さずにレスポンスを終了する場合はendでストリームを閉じます。

[5] レスポンスボディを返す

// [5] レスポンスボディを返す
fs.createReadStream(requestPath).pipe(stream)

QuicStreamはストリームです。リクエストボディの読み取りとレスポンスボディの書き込み両方に対応するためにstream.Duplexを継承しています。
ファイルの内容をレスポンスするならReadableなストリームを作りパイプするだけです。ストリームではない文字列を書き込む場合はstream.write('...')などを使用できます。この辺はストリームの基礎的な話なので詳しくは割愛します。

駆け足ですが解説は以上です。次に作ったサーバの動作確認をします。

動作確認

最後に起動したサーバの動作確認をします。サーバが正常に起動すると以下のような出力になると思います。

$ PORT=8080 DOCUMENT_ROOT=$PWD ./node http3-serve.js 
The socket is listening on :8080
(node:10968) ExperimentalWarning: The QUIC protocol is experimental and not yet supported for production use
(Use `node --trace-warnings ...` to show where the warning was created)

curlコマンドを利用していくつかリクエストを飛ばしてみます。host.docker.internalはホスト側のlocalhostを参照するDockerの特殊なホスト名です。Linuxの方は適宜読み替えてください。

正常系

まずは正常系です。ファイルが存在しているので200が返されます。

$ echo 'Hello world' > hello.txt # サーバを起動したディレクトリで適当なファイルを作る
$ docker run -it --rm ymuski/curl-http3 curl -v 'https://host.docker.internal:8080/hello.txt' --http3
*   Trying 192.168.65.2:8080...
* Sent QUIC client Initial, ALPN: h3-29,h3-28,h3-27
* Connected to host.docker.internal (192.168.65.2) port 8080 (#0)
* h3 [:method: GET]
* h3 [:path: /hello.txt]
* h3 [:scheme: https]
* h3 [:authority: host.docker.internal:8080]
* h3 [user-agent: curl/7.73.0-DEV]
* h3 [accept: */*]
* Using HTTP/3 Stream ID: 0 (easy handle 0x562874d14a40)
> GET /hello.txt HTTP/3
> Host: host.docker.internal:8080
> user-agent: curl/7.73.0-DEV
> accept: */*
>
< HTTP/3 200
< content-length: 12
< content-type: text/plain
<
Hello world
* Connection #0 to host host.docker.internal left intact

異常系(404)

ファイルが存在しないパスにリクエストします。404が返されます。

$ docker run -it --rm ymuski/curl-http3 curl -v 'https://host.docker.internal:8080/xxx' --http3
*   Trying 192.168.65.2:8080...
* Sent QUIC client Initial, ALPN: h3-29,h3-28,h3-27
* Connected to host.docker.internal (192.168.65.2) port 8080 (#0)
* h3 [:method: GET]
* h3 [:path: /xxx]
* h3 [:scheme: https]
* h3 [:authority: host.docker.internal:8080]
* h3 [user-agent: curl/7.73.0-DEV]
* h3 [accept: */*]
* Using HTTP/3 Stream ID: 0 (easy handle 0x56541c3d9a30)
> GET /xxx HTTP/3
> Host: host.docker.internal:8080
> user-agent: curl/7.73.0-DEV
> accept: */*
>
< HTTP/3 404
* Connection #0 to host host.docker.internal left intact

異常系2(403)

最後にリクエストしたパスがファイルではない場合のテストです。403が返されます。

$ mkdir dir # サーバを起動したディレクトリでディレクトリを作成する
$ docker run -it --rm ymuski/curl-http3 curl -v 'https://host.docker.internal:8080/dir' --http3
*   Trying 192.168.65.2:8080...
* Sent QUIC client Initial, ALPN: h3-29,h3-28,h3-27
* Connected to host.docker.internal (192.168.65.2) port 8080 (#0)
* h3 [:method: GET]
* h3 [:path: /dir]
* h3 [:scheme: https]
* h3 [:authority: host.docker.internal:8080]
* h3 [user-agent: curl/7.73.0-DEV]
* h3 [accept: */*]
* Using HTTP/3 Stream ID: 0 (easy handle 0x562fe362ba30)
> GET /dir HTTP/3
> Host: host.docker.internal:8080
> user-agent: curl/7.73.0-DEV
> accept: */*
>
< HTTP/3 403
* Connection #0 to host host.docker.internal left intact

さいごに

本記事では本当に最低限の処理しかしてないので、あとはドキュメントやソースコード、QUIC、HTTP/3の仕様書を読みながらいろいろ試してみてください。Node.jsの内部実装の話や、0-RTT・Server pushなどのHTTP/3の他の機能を試す記事も書けたら書こうと思います。ただ、冒頭にも書いた通り現時点ではまだUndocumentedなAPIなので深く使い込むのは時期尚早だと思います。今後QUICをラップした高レベルのAPIがおそらく登場するので、しっかり学ぶのはそれを待ってからでも遅くないと思います。

Node.jsのコミュニティはオープンで誰でも開発・議論に参加できます。例えばドキュメントの誤字脱字やAPIに対するフィードバック、仕様と実装が乖離しているなどの何かしらの問題を見つけたらチャンスと思ってコントリビュートしてもらえればと思います。興味のある方はnodejs/nodeリポジトリからぜひ参加してみてください。

参考情報

これらの一次情報が参考になりました。

これらの二次情報も参考になりました。

  • A QUIC Update for Node.js
    • Node.jsにQUICを実装したJames Snellによる紹介記事、ただし記事内のAPIが古い
JavaScriptNode.jsHTTP/3QUIC
© 2012-2022 Leko