開発
RFC7540(HTTP/2)を読んでnode-http2にプルリクエストを出してみたよ
yahata
こんにちは。CTOの八幡です。
久しぶりに会社ブログを書いています。
去年はHTTP/2の話題が非常に盛り上がった年でしたね。
というわけで今回は、HTTP/2通信デバッガであるh2iのご紹介をしたいと思います。
また手前味噌ですが、しばらく前にnode-http2(HTTP/2サーバのNode.js実装)にプルリクエストがマージされたので、その話も兼ねて(むしろ本題?)。
h2iとは?
h2iはGo製のHTTP/2通信デバッガです。
HTTP/2は通信内容がバイナリ形式のため「リクエストヘッダを手書きしてデバッグできないよ〜」という問題が言われていました。
しかし、h2iを使うとHTTP/1.1形式のリクエストヘッダを手書きすると自動でHTTP/2の通信として送信してくれるため、手書きデバッグが捗ります。
http2/h2i at master · bradfitz/http2
h2iとnode-http2を使ってみよう
準備するもの
必要環境は下記の通り。インストール手順は割愛します。
手順
まずはh2iをインストールします。
% go get github.com/bradfitz/http2/h2i
% h2i
Usage: h2i <hostname>
-insecure
Whether to skip TLS cert validation
-nextproto string
Comma-separated list of NPN/ALPN protocol names to negotiate. (default "h2,h2-14")
検証用のリポジトリを作ります。
% mkdir example-http2
% cd example-http2
% npm init
node-http2をインストールします。
% npm i -S http2
[email protected] node_modules/http2
HTTP/2のサーバを立ち上げる際に利用する自己署名証明書(通称:オレオレ証明書)を作ります。ここは本題ではないので適当に。
参考:オレオレ証明書をopensslで作る(詳細版) – ろば電子が詰まっている
% openssl genrsa 2048 > ssl/server.key
% openssl req -new -key ssl/server.key > ssl/server.csr
% openssl x509 -days 3650 -req -signkey ssl/server.key < ssl/server.csr > ssl/server.crt
さて、準備が整ったのでサーバを立ち上げてみましょう。
下記のコードで “Hello world!” を返すHTTP/2サーバが立ち上がります。
% cat server.js
"use strict";
let fs = require("fs");
let http2 = require("http2");
let options = {
key: fs.readFileSync('./ssl/server.key'),
cert: fs.readFileSync('./ssl/server.crt')
};
http2.createServer(options, (request, response) => {
response.end('Hello world!');
}).listen(8080);
% node server.js
別のコンソールからh2iを使ってサーバに接続してみましょう。
h2iコマンドにホストを指定するとコネクションが確立され、h2iのプロンプトが返ってきます。
% h2i -insecure localhost:8080
Connecting to localhost:8080 ...
Connected to [::1]:8080
Negotiated protocol "h2"
[FrameHeader SETTINGS len=0]
h2i>
ではとりあえず単純なGETリクエストを投げてみて、 “Hello world!” が返ってくることを確認してみましょう。
まず最初にSETTINGSフレームを送信し、それからHEADERSフレームでGETリクエストを送信します。
h2iでは、HEADERSフレームを送信する際にHTTP/1.1形式でリクエストヘッダを記述することができ
とってもヒューマンフレンドリーな形でHTTP/2のリクエストをこねることができます。
h2i> SETTINGS
Sending: []
[FrameHeader SETTINGS flags=ACK len=0]
h2i> HEADERS
(as HTTP/1.1)> GET / HTTP/1.1
(as HTTP/1.1)> Host: example.com
(as HTTP/1.1)>
Opening Stream-ID 1:
:authority = example.com
:method = GET
:path = /
:scheme = https
[FrameHeader HEADERS flags=END_HEADERS stream=1 len=25]
:status = "200"
date = "Sat, 20 Feb 2016 18:22:00 GMT"
[FrameHeader DATA flags=END_STREAM stream=1 len=12]
"Hello world!"
h2i>
“Hello world!” がうまく返ってきていますね。やった!
h2iを使った基本的な検証の流れはこんな感じです。
HTTPの通信内容を手でデバッグする際はぜひ参考にしてみてください。
さて、h2iの基本的な話はここまでです。
ここから先はHTTP/2のRFCの話になるので、とりあえずh2iの使い方を知りたいだけであれば読み飛ばしていただいて構いません。
node-http2にプルリクエストを出すに至るまで
GETリクエストの送り方が分からない
一番最初にnode-http2とh2iを触った時、まずGETリクエストの送り方が分からず困っていました。
どうやらHEADERSフレームを送ると良さそうというのはどうにか分かったのですが、それでも下記のようにうまくレスポンスが返ってきませんでした。
% h2i -insecure localhost:8080
Connecting to localhost:8080 ...
Connected to [::1]:8080
Negotiated protocol "h2"
[FrameHeader SETTINGS len=0]
h2i> HEADERS
(as HTTP/1.1)> GET / HTTP/1.1
(as HTTP/1.1)> Host: example.com
(as HTTP/1.1)>
Opening Stream-ID 1:
:authority = example.com
:method = GET
:path = /
:scheme = https
[FrameHeader GOAWAY len=8]
Last-Stream-ID = 0; Error-Code = NO_ERROR (0)
h2i>
どうしてGOAWAYが返ってくるんだろう。。
RFCを読んで動かない原因を探る
にっちもさっちも行かなくなってしまったので、RFC7540を読んでみることにしました(RFCとは?)。
RFC7540の3-5節によると、
In HTTP/2, each endpoint is required to send a connection preface as
a final confirmation of the protocol in use and to establish the
initial settings for the HTTP/2 connection.
(HTTP/2 では、各エンドポイントはプロトコルの使用の最終確認と、HTTP/2 コネクションの初期設定を確立するために、コネクションプリフェイスを送信する必要があります。)
と書いてあります。つまり、ネゴシエーションが完了したらまずコネクションプリフェイスを送信する必要があるということが分かりました。
しかし、実はh2iを使って自前でコネクションプリフェイスを送信することはできません。
そこでh2iの実装を読んでみたところ、コンソールに “Negotiated protocol” を出力した後で内部的にコネクションプリフェイスを送信しているようでした(h2iソースコードの当該部分)。
そうなると最初にSETTINGSフレームを送るのが良さそうです。
まず最初に空のSETTINGSフレームを送信して、それからHEADERSフレームを送ることで、前述のとおりうまく “Hello world!” のレスポンスが返ってくるようになりました!
気になったらプルリクエスト
一件落着と思いきや、ここでまた3-5節を読み返していて気づきました。
That is, the connection preface starts with the string “PRI *
HTTP/2.0\r\n\r\nSM\r\n\r\n”). This sequence MUST be followed by a
SETTINGS frame (Section 6.5), which MAY be empty.
(つまり、コネクションプリフェイスは “PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n” で始まります。この配列の後には、SETTINGS フレーム (6.5節) が続かなければなりません (MUST)。この SETTINGS フレームは空であってもよいものとします (MAY)。)
つまり、コネクションプリフェイスの直後にはSETTINGSフレームを送信しなければいけない (MUST) ということですが、
先程のリクエストではこれに違反しているにも関わらず、エラー無し(NO_ERROR)でGOAWAYフレームが返ってきていました。
ここは単なるNO_ERRORではなく、何かエラーコードを返すべきではないでしょうか?
というわけで、コネクションプリフェイスの直後のフレームがSETTINGSフレームでなければ
PROTOCOL_ERRORが返ってくるようにコードを修正してプルリクエストを送ってみました。
こちらの変更は1週間ほど経ってv3.3.0にマージされました。やったね!
下記の通りPROTOCOL_ERRORが返ってくるようになっています。
% h2i -insecure localhost:8080
Connecting to localhost:8080 ...
Connected to [::1]:8080
Negotiated protocol "h2"
[FrameHeader SETTINGS len=0]
h2i> HEADERS
(as HTTP/1.1)> GET / HTTP/1.1
(as HTTP/1.1)> Host: example.com
(as HTTP/1.1)>
Opening Stream-ID 1:
:authority = example.com
:method = GET
:path = /
:scheme = https
[FrameHeader GOAWAY len=8]
Last-Stream-ID = 0; Error-Code = PROTOCOL_ERROR (1)
h2i>
マージされたプルリクをまた見返してみて
ところが。もう一度RFCを読み返してみると、
確かにコネクションプリフェイスの直後にはSETTINGSフレームを送信しなければいけない (MUST) とは書かれていますが、
違反した場合のエラーコードがPROTOCOL_ERRORであるべきとは書かれていないように見えます。
Unknown or unsupported error codes MUST NOT trigger any special
behavior. These MAY be treated by an implementation as being
equivalent to INTERNAL_ERROR.
(不明または未対応のエラーコードは、特別な反応を引き起こしてはなりません (MUST NOT)。これらは、実装では INTERNAL_ERROR と等価として扱ってもよいものとします (MAY)。)
もしかするとPROTOCOL_ERRORではなくINTERNAL_ERRORが返ってくるようにするべきだったのかもしれません。
どうしてもここが気になったので、他のHTTP/2実装がどう振る舞うのか検証してみることにし、有名どころのh2o/h2oを動かしてみました。
すると予想に反して、h2oはそもそもコネクションプリフェイスの直後にいきなりHEADERSフレームを受け付けているようでした。
% ./h2o -c h2o.conf & # h2oサーバを起動する
...
% h2i -insecure localhost:8081
Connecting to localhost:8081 ...
Connected to [::1]:8081
Negotiated protocol "h2"
[FrameHeader SETTINGS len=12]
[MAX_CONCURRENT_STREAMS = 100]
[INITIAL_WINDOW_SIZE = 16777216]
h2i> HEADERS
(as HTTP/1.1)> GET / HTTP/1.1
(as HTTP/1.1)>
Opening Stream-ID 1:
:authority =
:method = GET
:path = /
:scheme = https
[FrameHeader HEADERS flags=END_HEADERS stream=1 len=63]
:status = "404"
server = "h2o/1.8.0-alpha1"
date = "Sat, 20 Feb 2016 19:04:32 GMT"
content-type = "text/plain; charset=utf-8"
content-length = "9"
[FrameHeader DATA flags=END_STREAM stream=1 len=9]
"not found"
h2i>
うーん。これはそもそもRFCと異なる挙動なんではないでしょうか?
しかしh2oほど活発なHTTP/2実装がこういう挙動になっているということは、私の解釈が何か違っているのかもしれません。
ひとまずh2oのリポジトリにissueを送ってみました。
The first frame after connection preface · Issue #795 · h2o/h2o
今はissueの返答待ちですが、何かご存知の方がいらっしゃれば情報をいただけると幸いです。
まとめ
少し脇道に逸れてしまいましたが、h2iとnode-http2を使ったHTTP/2通信検証方法の簡単なご紹介でした。
来るHTTP/2の世界に向けて、皆さんもh2iで素敵なHTTP/2ライフを!
参考資料
- molnarg/node-http2: An HTTP/2 client and server implementation for node.js
- http2/h2i at master · bradfitz/http2
- h2o/h2o: H2O – the optimized HTTP/1, HTTP/2 server
- RFC 7540 – Hypertext Transfer Protocol Version 2 (HTTP/2)
- RFC7540 日本語訳
- curlにおけるhttp2 | http2 explained
- HTTP/2時代のウェブサイト設計
- HTTP/2のNode.js実装node-http2を読む – Qiita
- オレオレ証明書をopensslで作る(詳細版) – ろば電子が詰まっている