gRPC でいろいろとトラブルが起きており、そのトラブルシュートをしようと思っています。 しかし、gRPC は HTTP/2 の上で動作するプロトコルであり、HTTP/2 の RFC を見ても、イマイチ HTTP/2 が見えてこない。HTTP/2 のプロトコルが見えないと、gRPC の挙動が正しいのかどうかもわからない。 というわけで、gRPC を通して HTTP/2 の挙動を知ろうというコーナーです。
HTTP/2 の特徴
HTTP/2 の RFC 他を読み、ぼくの理解している HTTP/2 の特徴をいくつか挙げてみます。
Stream 多重化
HTTP/1.0 の時代は、HTTP でのやり取りには、必ず新しい Connection が必要でした。 HTTP/1.1 では、keep-alive によって Connection を使い回せるようになりましたが、HOL (head-of-line blocking) の問題は解決されませんでした。HTTP/1.1 の pipelining は基本使われていないよね…。)
HTTP/2 では、1 つの Connection の上に、Stream という概念を導入されました。 Stream は、サーバ・クライアントが行う独立したやり取りを抽象化したもので、1 つの TCP Connection の上で多重化が可能です。つまり、1 本の TCP Connection の上で同時並行して複数の Stream を用いた通信を行うことができます。言い換えれば、1 つの TCP Connection の上で同時並行したサーバ・クライアント間の通信が可能になるということです。 これら Stream は、その ID によって区別されます。
Stream は状態を持っている
これまで、HTTP といえば Stateless、というものでした。しかし、HTTP/2 の Stream は状態を持ちます。
以下が RFC 7540 に記載されている Stream の状態ですが、TCP の状態遷移とよく似た形で状態が定義されていることがわかります。
この状態遷移を起こすのが、Stream を流れる Frame です。Frame は、HTTP/2 で定義されたメッセージの種類を表現したものと思ってください。例えば HEADERS
フレームは、HTTP/1 における HTTP ヘッダを送信するメッセージです。他にも、HTTP BODY は基本的に DATA
フレームでやり取りされたりします。
+--------+ send PP | | recv PP ,--------| idle |--------. / | | \ v +--------+ v +----------+ | +----------+ | | | send H / | | ,------| reserved | | recv H | reserved |------. | | (local) | | | (remote) | | | +----------+ v +----------+ | | | +--------+ | | | | recv ES | | send ES | | | send H | ,-------| open |-------. | recv H | | | / | | \ | | | v v +--------+ v v | | +----------+ | +----------+ | | | half | | | half | | | | closed | | send R / | closed | | | | (remote) | | recv R | (local) | | | +----------+ | +----------+ | | | | | | | | send ES / | recv ES / | | | | send R / v send R / | | | | recv R +--------+ recv R | | | send R / `----------->| |<-----------' send R / | | recv R | closed | recv R | `----------------------->| |<----------------------' +--------+
通信の流れを追ってみる
まずは、簡単な Unary RPC を持つ golang の gRPC サーバを用意しました。 さらに、以下のような gRPC クライアントも合わせて用意します。このクライアントでは、毎秒 1 回、Unary RPC を呼び出すようにしています。
この gRPC クライアントを使い、netstat とパケットキャプチャで、大まかな通信の流れを追ってみます。
func main() { // Set up a connection to the server. conn, err := grpc.Dial(address, grpc.WithInsecure()) if err != nil { log.Fatalf("did not connect: %v", err) } defer conn.Close() c := greeter.NewGreeterClient(conn) // Contact the server and print out its response. name := defaultName if len(os.Args) > 1 { name = os.Args[1] } done := make(chan interface{}) ticker := time.NewTicker(time.Second) defer ticker.Stop() time.AfterFunc(10*time.Second, func() { close(done) }) for { select { case <-done: return case t := <-ticker.C: log.Println("Current time: ", t) ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() r, err := c.SayHello(ctx, &greeter.HelloRequest{Name: name}) if err != nil { log.Fatalf("could not greet: %v", err) } log.Printf("Greeting: %s", r.Message) } } }
netstat
watch -n1 'netstat -an -p tcp | grep 50050'
でコネクションを追ってみます。
結局、ESTABLISHED は 2 本見えて、サーバ (ポート 50050 を LISTEN) とクライアントを結ぶコネクションが確立されていることがわかります。
Every 1.0s: netstat -an -p tcp | grep 50050 tcp6 0 0 ::1.50050 ::1.49700 ESTABLISHED tcp6 0 0 ::1.49700 ::1.50050 ESTABLISHED tcp46 0 0 *.50050 *.* LISTEN
wireshark
Connection Preface
まず最初の HTTP/2 のメッセージは、Connection Preface でした。 クライアントからサーバに対して、以下のようなメッセージが送られています。
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
これが Connection Preface と呼ばれるものです。「サーバが HTTP/2 に対応している」ということをクライアントが既に知っている場合は、クライアントはサーバにこの Connection Preface を送信することでコネクションを確立します。
SETTINGS フレーム
次のやり取りは SETTINGS フレームでした。
これはサーバ → クライアント、および、クライアント → サーバそれぞれの方向で送られています。さらに、それらに対する ACK (ACK
Flag が true になっている)としての SETTINGS
フレームも返送されています。
本来の SETTINGS
フレームの目的は通信用設定パラメータをクライアント/サーバ間でやり取りすることです。例えば Stream の最大多重度がその一例です。しかし、今回はサーバ、クライアント双方ともに、その設定パラメータは空でした。
なお、SETTINGS
フレームが使用している Stream の ID は常に 0 です。ID 0 の Stream は、コネクション管理用として定義されています。
リクエスト送信
そしてついにクライアントからサーバへのリクエストです。
HEADERS
フレームDATA
フレーム
で構成されます。
HEADERS
フレームは HTTP Method や path、Content-Type といったものが含まれています。 Stream ID: 1
という表示からわかるように、ついに Stream が開始されました。
HEADERS
フレームはその送受信によって、stream を open
状態に遷移させるという効果も持ちます。
注目すべきは、DATA
フレームです。 DATA
フレームはその名前の通りデータを送るための Frame ですが、 End Stream
のフラグが true になっています。
これが意味するところは、「クライアントはもう Stream に送るデータがない」、つまりクライアント側はもう Stream を Close して良いということを示しています。
このように、リクエストを送信する Frame で Stream を閉じようとするのは、Unary RPC であるからこそでしょう。例えば Client Streaming RPC では、こうはいかないはずです。
End Stream
フラグを立てた Frame の送信により、 Stream は half close
な状態に遷移します。この後 Server も同フラグを立てた Frame を送信することで、当該 Stream は closed
になります。
レスポンス受信
レスポンスは以下のフレームで構成されています。
HEADERS
フレームDATA
フレームHEADERS
フレーム
順に、ヘッダ、レスポンスボディ、トレーラですね。
ここでのポイントは、 HEADERS
フレーム上で End Stream
フラグが立っているということですね。
これにより、サーバ側も stream 1 を閉じて良い、と主張していることがわかります。
結果としてサーバ/クライアント双方ともに stream 1 はもう使わなくてよいということがプロトコル上で確認が取れました。結果、当該 stream を closed
状態へ遷移させ、この stream 上での通信は終了します。
この他
PING
および WINDOW_UPDATE
フレームも送受されているんですが、このあたりはコネクション管理用のフレームなので、またいつか…。