Go言語とHTTP2

http2 in Go 1.6; dotGo 2015 - Google スライド

2015年の5月にRFCが出たばかりのHTTP2が2016年の2月にリリース予定のGo1.6で早くも利用可能になることになっている.HTTP2の勉強も兼ねてGo言語におけるHTTP2実装を追ってみる.

以下ではまず実際にHTTP2サーバを動かしChromeで接続してみる.次に現状コードがどのように管理されているかを追う.最後に実際にコードを動かしながらHTTP2の各種機能を追う.なお参照するコードはすべて以下のバージョンを利用している(まだWIPなのでコードなどは今後変わる可能性があるので注意).

$ go version
go version devel +9b299c1  darwin/amd64

HTTP2とは?

HTTP/2に関してはスライドやブログ記事,Podcastなど非常に豊富な情報がインターネット上に存在する.そもそもHTTP2とは何か?なぜ必要なのか?などを理解したい場合は参考に挙げた記事などを参照するのがよい.

実際に使ってみる

最小限のコードでHTTP2サーバーを起動しChromeで接続してみる.

まず最新のGoをソースからビルドする(ビルドにはGo1.5.1を利用する).以下では2015年11月16日時点の最新を利用した.

$ git clone --depth=1 https://go.googlesource.com/go ~/.go/latest
$ export GOROOT_BOOTSTRAP=~/.go/1.5.1
$ cd ~/.go/latest/src && ./make.bash

現時点でGoにおけるHTTP2はover TLSが前提になっている.そのためサーバー証明書と鍵が必要になる(なければ事前にopensslコマンドやcrypto/x509パッケージなどを使って自己署名証明書をつくる).

コードは以下.

func main() {
    certFile, _ := filepath.Abs("server.crt")
    keyFile, _ := filepath.Abs("server.key")
    
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
       fmt.Fprintf(w, "Protocol: %s\n", r.Proto)
    })

    err := http.ListenAndServeTLS(":3000", certFile, keyFile, nil)
    if err != nil {
        log.Printf("[ERROR] %s", err)
    }
}

証明書と鍵を読み込んでListenAndServeTLSを呼ぶだけ.HTTP2のために特別なことをする必要はない.クライアントがHTTP2に対応していれば勝手にHTTP2が使われる.起動して接続すると以下のように「Protocol: HTTP/2.0」が確認できる(Chrome拡張のHTTP/2 and SPDY indicatorが反応しているのも確認できる).

コードの行方

現在HTTP2のコードはどのように管理されているのか? もともとはbradfitz氏によりbradfitz/http2で実装が進められていた.そしてgolang.org/x/net/http2に移動した.ちなみにGo1.5以前でもこちらのパッケージを使えばHTTP2を使うことはできる

このhttp2パッケージの位置付けはローレベルなHTTP2の実装であり普通のひとは触ることがない.Go1.6では普通のひとが触るハイレベルなインターフェースは今まで通りのnet/httpとなる.

では最新のGoのコードにはどのようにマージされたのか? まずヘッダ圧縮のHPACKhttp2/hpackという名前でサブディレクトリに別パッケージとして実装されている.これはsrc/vendor以下にvendoringされている.http2も同様にvendoringされているだけかと思ったが,こちらはnet/httpパッケージにh2_bundle.goという1つのファイルとして組み込まれている.

具体的な経緯はhttp -> http2 -> http import cycleを読むとわかるが,単純にvendoringするとnet/http->http2http2->net/httpというimport cycleが起こってしまう.これは上の例で示したようにAPIの変更なしにHTTP2を有効にするというゴールを達成するためには避けられない.

これを解決するために使われたのがbundleコマンドである.これはパッケージを別パッケージとして1つのファイルにするコマンド.以下のように使われる.

$ bundle golang.org/x/net/http2 net/http http2

これでgolang.org/x/net/http2net/httpパッケージとしてhttp2というprefixをつけて一つのファイルにまとめるいうことがおこる.

変更はどうするのか? bundleはテストを無視するため変更はテストがちゃんとあるgolang.org/x/net/http2に入り,その都度bundleしてマージとなるらしい(リリースまでは).

… というのが現状.正式なリリースまでに時間はあるのでどうなるのかはわからない.

HTTP2の機能を追う

以下ではHTTP2の主な機能がどのようにGo言語で実装されているのかを見ていく.

フレームとストリーム

HTTP1.xではリクエスト/レスポンスのフォーマットにテキストが利用されてきた.HTTP2ではフレームと呼ばれるバイナリのフォーマットが利用される.これにより転送量の低減を実現している.

フレームにはいくつかのタイプが定義されている.例えばHTTP1.xのヘッダにあたるHEADERS,HTTP1.xのBody部にあたるDATAなどがある.フレームは以下のようなフォーマットで表現される(cf. 4.HTTP Frames).

+-----------------------------------------------+
|                 Length (24)                   |
+---------------+---------------+---------------+
|   Type (8)    |   Flags (8)   |
+-+-------------+---------------+-------------------------------+
|R|                 Stream Identifier (31)                      |
+=+=============================================================+
|                   Frame Payload (0...)                      ...
+---------------------------------------------------------------+

HTTP1.xでは1つのリソースを取得するために1つのTCPコネクションが必要である.つまり3つの画像が必要であれば3つのTCPコネクションが必要である.TCPはThree-way handshakingやスロースタートにより通信のオーバーヘッドが避けられない.そのため各コネクションはなるべく並列で確立されるのが望ましい.しかし同一オリジンへの同時接続数はたいてい6つに制限されている.つまり7つ目は先の6つのどれかが完了するまでブロックされる.これに対応するためにHTTP1.x時代では画像などを別ドメインから読み込むDomain Shardingという手法が一般的にはとられてきた(cf.HTTPリクエストを減らすために).

HTTP2ではストリームという概念を導入し上記の問題を解決している.ストリームとは1つのTCPコネクション上に作られる仮想的な双方向シーケンスである.このストリームによりリクエストは多重化され複数のリソース取得も並列で実行可能になる.それぞれのリクエストとレスポンスはひとつのストリームで処理され,それぞれがユニークなIDをもつ.

Go言語ではどう実装されているか.まずFramerというstructがフレームの書き込みと読み込みを担う.そしてそれぞれのタイプのフレームの書き込みのために専用のメソッド,例えばDATAならWriteData,HEADERSならWriteHeaders,が準備されている.

実際にDATAフレームを作って中身を覗いてみる.

buf := new(bytes.Buffer)
fr := http2.NewFramer(buf, buf)

var streamID uint32 = 1<<24 + 2<<16 + 3<<8 + 4
fr.WriteData(streamID, true, []byte("Hello"))

b := buf.Bytes()
fmt.Printf("Frame: %q\n", b)

fmt.Printf("Type: %x\n", b[4:5])    // Type: 01
fmt.Printf("StremID: %x\n", b[5:9]) // StremID: 01020304
fmt.Printf("DATA: %x\n", b[9:])     // DATA: 48656c6c6f

NewFramerFramerをつくり,WriteDataでストリームのIDとともにデータを書き込む.あとは書き込まれたデータを定義に基づき覗くと中身が見れる.

ヘッダ圧縮

HTTPはステートレスなプロトコルである.そのためHTTP1.xでは1つのセッションで毎回似たようなヘッダを送る必要があり冗長である.HTTP2ではヘッダの圧縮を行う.ヘッダの圧縮にはHPACKと呼ばれる手法を用いる.HPACKはハフマン符号化と静的/動的テーブルという仕組みで圧縮を行う手法である.HPACKはHTTP2とは別にRFC 7541で仕様化されている.

Go言語ではhttp2/hpackという名前でhttp2パッケージのサブディレクトリに別パッケージとして実装されている.それぞれ実際に使ってみる.

まずハフマン符号化.ハフマン符号は文字の出現頻度の偏りに合わせてビット列を割り当てる符号化である.ここでは例としてwww.example.comという文字列をハフマン符号でEncode/Decodeしてみる.

s := "www.example.com"

fmt.Println(len(s))
fmt.Println(hpack.HuffmanEncodeLength(s))

b := hpack.AppendHuffmanString(nil, s)
fmt.Printf("%x\n", b)

var buf bytes.Buffer
hpack.HuffmanDecode(&buf, b)

fmt.Printf("%s\n", buf.String())

上の例では15バイトの文字列を12バイトに符号化できる.

次にテーブルによる圧縮.HTTP2ではよく利用するヘッダをKey-Valueの辞書としてもちそのインデックスを示すことでヘッダを表現する.テーブルは仕様として事前に定義された(Static Table Definition)静的テーブルとリクエストのやりとりの中で更新する動的テーブルがある.

まず静的テーブルにエントリがある:method GETをEncodeしてみる.

var buf bytes.Buffer
e := hpack.NewEncoder(&buf)

e.WriteField(hpack.HeaderField{
    Name:  ":method",
    Value: "GET",
})

fmt.Printf("Encoded: %x (%d) \n", buf.Bytes(), len(buf.Bytes()))

これは1バイトに圧縮される.

次に静的テーブルにエントリのない:authority www.example.comをEncodeしてみる.動的テーブルの効果をみるために2度実行する.

var buf bytes.Buffer
e := hpack.NewEncoder(&buf)

e.WriteField(hpack.HeaderField{
    Name:  ":authority",
    Value: "www.example.com",
})

fmt.Printf("Encoded: %x (%d) \n", buf.Bytes(), len(buf.Bytes()))
buf.Reset()

e.WriteField(hpack.HeaderField{
    Name:  ":authority",
    Value: "www.example.com",
})

fmt.Printf("Encoded: %x (%d) \n", buf.Bytes(), len(buf.Bytes()))

同じヘッダに対して2度Encodeを実行する.まず1度目は動的テーブルにエントリがないため14バイトにしかならない.もしテーブルにエントリがない場合は,動的テーブルにそれが追加される.そして2度目の実行時はそのテーブルが参照され1バイトに圧縮される.

優先度制御

HTTP1.xでは全てのリクエストは平等に処理される.つまり画像もCSSもJSも全て平等に処理される.HTTP2ではクライアントがリクエストに優先度を指定することができる.例えばサイトのレンダリングが必要なCSSやJSを優先的にリクエストすることができる.これによりページの描画を改善しユーザの体感速度を向上することが期待できる.

Go言語ではどうなっているのか.クライアントはPriorityParamというstructを用いて優先度の指定を行う.そしてHEADERSもしくはPRIOTIRYフレームでこれを送信する.

次にサーバーの動き.これは外からは触れない.どう処理されるかはテストコードを見るのが良い.priority_test.goを見る.

// A -> B
// move A's parent to B
streams := make(map[uint32]*stream)
a := &stream{
    parent: nil,
    weight: 16,
}
streams[1] = a

b := &stream{
    parent: a,
    weight: 16,
}
streams[2] = b

adjustStreamPriority(streams, 1, PriorityParam{
    Weight:    20,
    StreamDep: 2,
})

if a.parent != b {
    t.Errorf("Expected A's parent to be B")
}
if a.weight != 20 {
    t.Errorf("Expected A's weight to be 20; got %d", a.weight)
}
if b.parent != nil {
    t.Errorf("Expected B to have no parent")
}
if b.weight != 16 {
    t.Errorf("Expected B's weight to be 16; got %d", b.weight)
}

abというstreamがmapで管理されている.mapのkeyは各streamのIDである.そしてadjustStreamPriorityでIDが1であるaの優先度を16から20に更新し依存するストリームのIDを2に変更する.

…と優先度の更新は追えたが,この優先度をどのように使っているのかは見つけることができなかった.知ってる人がいたら教えてください.

Server Push

HTTP1.xではクライアントからリソースのリクエストがあって初めてサーバー側からそれを送ることができる.HTTP2ではServer Pushという仕組みを使い,クライアントがリクエストする前にサーバー側からクライアントにリソースを送りつけることができる.例えばサーバーがHTMLのリクエストを受けたとする.サーバーはHTMLの内容を知っているので次にCSSやJSのリクエストがクライアントから送られることを予想できる.Server Pushを使えばそれらを先に送信することができる.

kazuhoさんによるH2Oに関する発表を見ているとServer Pushは難しそう.Server Pushで送られたリソースはクライアントでキャッシュされ,クライアントはそのキャッシュを利用することになる.既にキャッシュが存在する場合にPushするのは無駄になるが,それを制御するのは難しい(H2OではCache aware-server-pushをしているとのこと).

Go言語ではどうなっているのか.Server Pushを行うときはPUSH_PROMISEというフレームをサーバーは送信する.このフレームを作成するWritePushPromiseというメソッドは準備されている.がServer Pushを行うためのハイレベルなインターフェースは現時点では見当たらなかった.“Please test Go’s HTTP/2 support”などを読む限り今後のバージョンでのリリースになりそう.

まとめ

Go1.6に予定されているHTTP2の実装を追ってみた.Server Pushのハイレベルなインターフェースを除いた基本的な機能は実装されている.そしてGo1.5以前と同じインターフェースで利用できることがわかった.

Go言語のHTTP2をすぐに使うのか? と聞かれるとまだ議論が必要であると感じた.少なくとも現時点ではTLS終端はアプリケーションの前段のnginxにある.最初はnginxなどで利用することから始めると思う.

PaaSを運用している立場からみると状況はもう少し複雑になる.例えばGoogle App EngineのようにTLSとHTTP2は全てGoogleのサーバーが面倒見るからその上のアプリケーションは何もしなくてもHTTP2が有効になりますと言うこともできる(cf. Full Speed Ahead with HTTP/2 on Google Cloud Platform).その一方でServer Pushなどはアプリケーションごとにコントロールしたいかもしれない.そういう場合にどのようにハンドルするべきなのかなど考えることは多い.

参考