理系学生日記

おまえはいつまで学生気分なのか

gRPC Unary RPC で HTTP/2 の通信を追ってみる

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 フレーム

順に、ヘッダ、レスポンスボディ、トレーラですね。

f:id:kiririmode:20190502013903p:plain

ここでのポイントは、 HEADERS フレーム上で End Stream フラグが立っているということですね。 これにより、サーバ側も stream 1 を閉じて良い、と主張していることがわかります。 結果としてサーバ/クライアント双方ともに stream 1 はもう使わなくてよいということがプロトコル上で確認が取れました。結果、当該 stream を closed 状態へ遷移させ、この stream 上での通信は終了します。

この他

PING および WINDOW_UPDATE フレームも送受されているんですが、このあたりはコネクション管理用のフレームなので、またいつか…。