gRPCの刺身
TL; DR
gRPCのリクエストを生のバイト列で送るためには、Sample Requestの章を参照
About
最近そこかしこで用いられているgRPC。
何かHTTP系のリクエストが動いているようなツール群、例えばkubectl等ではいつの間にかバックエンドがgRPCに代わっていたりします。
なんでこれに置き換わっていっているのか、中身は何なのか気になったので切り込んでみましたというのが本稿の内容になります。
Googleの公開しているSRE本の記載に拠れば、gRPCはGoogle内部で開発利用しているRPC仕様である”Stubby”のOpen Source版ということです。
gRPCは通常SDKから使うもの、プロトコルをbyteレベルで理解する必要はありません。
しかしそれでも中身を見てみたい、解析したい、生のものを味見してみたいというのが世の常かと思います(は?
公式でも最初に使い方をガイドしており、世に出回っているgRPC関連の記事はそのSDKに従ってインプリしてみたというような内容が大半です。
そこで当記事ではgRPCが実際に何をしているのか、送るデータのprotobufとは何なのか、どのようなbyte文字列を送ればgRPCサーバーとやり取りができるのか、そんなニッチなところに切り込んでみたいと思います。
Structure
gRPCは基本的にHTTP2通信の上で成り立つプロトコルとなります。
基本的に「HTTP2」の中で「gRPC形式」に則り基本的に「protobuf」の形式のメッセージをやり取りをする。というものになります。
gRPCの仕様的に例外はあるみたいなのですが、今回扱うのは上述の通り広く一般に使われているgRPC over HTTP2とします。
この中で送るべきbyte文字列を特定するため、各仕様についてみていきたいと思います。
HTTP2
source: https://datatracker.ietf.org/doc/html/rfc9113
About HTTP2
HTTP2はTCP通信であり基本的にアプリが受け取る情報はHTTP1.1と変わらないものの、平文で文字列のバイトデータが送られるわけではなく、frameという単位でHTTPヘッダーやボディ、通信の制御情報などをやり取りします。
ALPNやConnection UpgradeにおいてHTTP2を示すversion文字列はh2(with TLS(ALPN))もしくはh2c(without TLS, deprecated)のように表現されます。
またstreamという、関連するframeを双方向(bidirectional)で通信できるような概念がもとになっており、statelessなHTTPの1Request-1Responseで通信が完結することから生じる課題を解決しております。
これは、従来のHTTPではクライアントから何らかのリクエストを送り、サーバーがレスポンスを送って通信フローが完了していたのですが、それではサーバー側からデータを送りたい、非同期処理の結果が完了したら処理を進めたいといった場合に不便です。これを解消するためにPollingやSSE、Websocketといったサーバー側が主体となってアクションを起こせるような工夫が生まれてきました。
HTTP2ではプロトコルレベルでこう言った問題に対処可能となります。
さらにstreamという単位で関連のある通信をまとめることで、毎回同じヘッダーを送ったり都度TLS通信を確立したりすることから生じるオーバーヘッドがなくせるという利点もあります。
新たにstreamのstateを管理する必要が出てくるのですが、そういった管理や設定、そのstreamの中で扱われるframeの設定、Pingといった項目が従来のHTTPであるHEADERとDATAに加えられ、合計10種類のframe typeが定義されております。
HTTP2 prefare
HTTPの上に成り立っているため、その接続がHTTP/2なのかその他のHTTP/1.xやHTTP/3なのか判別し、その後それぞれのプロトコルに沿って処理を進める必要があります。
その際に通信開始のメッセージ(preface)がHTTP2では以下のように定められています。
Client -> Server, TLSと無関係な部分
素のペイロードとしては以下のhexバイト列を通信の最初に置く必要があります。
0x505249202a20485454502f322e300d0a0d0a534d0d0a0d0a
これらのhexはASCIIテーブルに当てはめれるため、置き換えると以下のような文字列になります。
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
もし何かの通信をhexdumpして上のような文字列が出現したら、そのInitiatorはHTTP2通信を開始しようとしています。
これにSETTING frame(payloadが無くてもよい)を続けざまに送ることで通信を開始します。
payloadが無い、最低限のSETTING flameはhexで表すと以下のようになります。
0x000000040000000000
| | | | |
Len Type ID
Flag
frameの構造は後述のため、そこで詳細について記載します。
payloadはemptyのためLenghは0(0x000000)
TypeはSETTING=0x04
Flagもemptyのため0x00
Reserved bitも含め、IDもSETTING frameのため0x00000000
のようになります。
Client -> Server, TLSにおいて開始する場合
TLSの拡張属性であるALPN(Application Layer Protocol Negotiation)において、"h2"という値によってTLSで保護されている通信がHTTP2であることをサーバー側に知らせます。
その後上に示したprefaceとSETTING frameを送りHTTP2通信を開始します。
Server -> Client
ServerからのprefaceとしてのレスポンスはSETTING frame(payloadが無くてもよい)となります。なのでその内容は上述の通りです。
0x000000040000000000
これをもってプロトコルの合意が取られます。
お互いに送ったSETTING frameはACKを返してもらう必要があります。
HTTP2 frame
frameのとる共通的な構造は以下となります。
HTTP Frame {
Length (24),
Type (8),
Flags (8),
Reserved (1),
Stream Identifier (31),
Frame Payload (..),
}
LengthがFrame Payloadの長さをoctet単位で表します。
Typeが全部で10種類定義されており、それぞれのTypeに応じてFlagが定義されています。
frame自体やそのFlagに応じて、それぞれの意味合いを示す値がFrame Payloadに入ります。
定義されているTypeは以下の通りです。
+---------------+------+--------------+
| Frame Type | Code | Use |
+---------------+------+--------------+
| DATA | 0x0 | HTTP Message Bodyに相当
| HEADERS | 0x1 | HTTP Message Headerに相当
| PRIORITY | 0x2 | Deprecated, frameごとのDependencyやPriorityを設定
| RST_STREAM | 0x3 | 特定のstreamをRESETするために用いる
| SETTINGS | 0x4 | h2[c] Connection全体に適用される設定を行う
| PUSH_PROMISE | 0x5 | Server側からrequestを送る際のPromise
| PING | 0x6 | Connectionにおけるround-trip timeの計測や死活監視に用いられる
| GOAWAY | 0x7 | Connectionをgraceful shutdownを行う
| WINDOW_UPDATE | 0x8 | ConnectionもしくはStream単位でWindowサイズを変更する
| CONTINUATION | 0x9 | HEADER, PUSH_PROMISE, CONTINUATIONのいずれかのpayloadを継続して送るためのframe
+---------------+------+--------------+
HTTP2 HEADER
HTTP2においてHEADER frameはそのままHTTP Headerを表し、主にあるstreamを始める際に用いますが、いくつかHTTP2から加えられた表現方法があります。
pseudo-header
まずはpseudo-header、HEADERで渡されるpayloadの中で、コロン(:)で始まるキーを持つ値になります。今までauthorityやsheme、path等としてURLに含まれていた情報は、HTTP2では以下のようにしてHEADER frameに含まれるようになります。
Requests
:method
:scheme
:authority
:path
:status
HPACK
次にHPACK。
HTTP2においてHEADER Frame情報を圧縮してやり取りするための仕様になります。
index tableをclientとserverにおいてもち、
- static table(pseudo-header含め、事前に定められている)
- dynamic table(通信固有で更新されていく、FIFO)
が定義されています。
<---------- Index Address Space ---------->
<-- Static Table --> <-- Dynamic Table -->
+---+-----------+---+ +---+-----------+---+
| 1 | ... | s | |s+1| ... |s+k|
+---+-----------+---+ +---+-----------+---+
^ |
| V
Insertion Point Dropping Point
そこに従来のkey-valueで渡すヘッダーを加え、ヘッダー情報のやり取りを行います。
HEADERの中でやり取りされるヘッダー情報は、場合によってHuffman codeを利用して圧縮されます。HPACKの中で扱われるHuffman codeは世でやり取りされるヘッダー情報の統計を基に定義されています。
ヘッダー情報の表現方法は4つあり、Payloadの頭の部分で判別がつくようになっている。頭の部分の形式でまとめると以下のようになります。
No. | representation | headings |
---|---|---|
1 | Indexed Header Field Representation | 1??????? |
2 | Literal Header Field with Incremental Indexing | 01?????? |
3 | Literal Header Field without Indexing | 0000???? |
4 | Literal Header Field Never Indexed | 0001???? |
1番目はindexのみでヘッダー情報を表現する方法。index tableに格納されている値で、特にstatic tableで事前に定義されているkey-valueペアを指定する場合に使うと思います。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| 1 | Index (7+) |
+---+---------------------------+
2番目以降は文字列のヘッダー情報を含むようになります。
ここで言っているIndexingとは、dynamic tableを更新するかどうかを示しており、いずれの表現においても相手が受け取るdecoded header listには加えられます。
2番目のwith Indexingは新たにdynamic tableにもヘッダー情報をInsertします。
3番目のwithout Indexingはdynamic tableへの変更を起こさないようにします。
4番目のNever Indexedはdynamic tableへの変更がないことは3番目と同じで、さらにhop(途中に挟まるProxy等)においてもこの形式のヘッダーはその他の形式(1~3番目)に変更しない(MUST NOT re-encode)ようにするということを示しております。
形式はヘッダーのkeyをindex形式で指定する場合とLiteral形式で指定する場合で以下の二通りに大別されます。
index形式
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| heading(2+) - Index (4+) |
+---+---+-----------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+
Literal形式(Index部分が0)
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| heading(2+) 0 |
+---+---+-----------------------+
| H | Name Length (7+) |
+---+---------------------------+
| Name String (Length octets) |
+---+---------------------------+
| H | Value Length (7+) |
+---+---------------------------+
| Value String (Length octets) |
+-------------------------------+
heading部分は上の表で表現したように01, 0001, 0000のいずれかになります。その後8bitからheadingで使ったbit数を引いた残りが、Indexを示すIntegerが入る部分になります。
またLiteral部分はstring primitiveとなっており、いずれも以下の形式となります。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
| H | String Length (7+) |
+---+---------------------------+
| String Data (Length octets) |
+-------------------------------+
Hはその値がHuffman codeで表現されるか否かを示すbitになります(H=1 - Huffman encoded, H=0 - non-encoded)
Header example
例えば、以下のようなヘッダーを送りたいとします。
:method: POST
:scheme: http
:path: /grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo
:authority: 127.0.0.1:8080
content-type: application/grpc
user-agent: grpc-go/1.13.0-dev
te: trailers
この場合、HPACKを用いてencodeすると以下のようになります。(バイト列はどの道人間が読むものではないですが、心ばかりの稼働性のため意味のある塊ごとに改行しておきます。hexでoctet毎にスペース入れてます。念のため、以下のバイト列はgrpcurlから生成してます)
00 00 64 01 04 00 00 00 01 # [HEADERS(type=0x01) frame] length of 0x000064(=100 octets: ref=SETTINGS_MAX_FRAME_SIZE), flag=0x04(END HEADERS), StreamIdentifier=0x00000001(R+31bits)
83 # "indexed header representation" index of 3 (83)=":method=POST"
86 # "indexed header representation" index of 6 (86)=":scheme=http"
45 ad # "Literal Header Field Representation" index of 5(=":path") and a value length of "ad"=1 0101101(=45 octets)
62 6b 2b 22 f6 16 5a 0a 44 98 f5 2f dc 23 a2 b9
c6 be e2 d9 dc b6 6d 2c b4 14 89 31 ea 63 71 6c
ee 5b 36 96 5a 0a 44 98 f5 64 aa 53 ff # HUFFMAN encoded value("62 6b~")of: "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo"
41 8a # "Literal Header Field Representation" index of 1(=":authority") and a value length of "8a"=1 0001010(=10 octets)
08 9d 5c 0b 81 70 dc 78 0f 03 # HUFFMAN encoded value("08 9d~") of: "127.0.0.1:8080"
5f 8b # "Literal Header Field Representation" index of 31(="content-type") and a value length of "8b"=1 0001011(11 octets)
1d 75 d0 62 0d 26 3d 4c 4d 65 64 # HUFFMAN encoded value("1d 75~") of: "application/grpc"
7a 8d # "Literal Header Field Representation" index of 58(="user-agent") and a value length of "8d"=1 0001101(13 octets)
9a ca c8 b4 c7 60 2b 85 95 c0 b4 85 ef # HUFFMAN encoded value("9a ca~") of: "grpc-go/1.13.0-dev"
40 02 74 65 # "Literal Header Field Representation" without indexed name, the name length of 2 octets and the value of non-HAFFMAN "te"(74 65)
86 4d 83 35 05 b1 1f # value length of 6 octets and the value of: "trailers"
どうでしょう、特に最初のmethodやschemeの情報がそれぞれ1byteで表されているあたりHPACKの力を感じますね、その他の文字列も元より少ないバイト数で表現されています。
HEADERのencodingさえわかってしまえば、HTTP2においては後は共通のFrame形式となるので、例示はこれだけにしておきます。
gRPCの形式
source: https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
Aboud gRPC
ここまでで解説してきたHTTP2において、gRPC形式でデータを送るためにgRPCの形式を見ていきます。
リクエストの仕様として、HEADERにおけるgRPCを示す値と、DATAにおける値の取り決めがあります。
sourceのページではABNF記法に従い、以下のように表現されています。
Request → Request-Headers *Length-Prefixed-Message EOS
Response → (Response-Headers *Length-Prefixed-Message Trailers) / Trailers-Only
片方についてみてみれば大体のことが分かりそうです。ここではRequestのHEADER部分(Request-Headers)とDATA部分(*Length-Prefixed-Message)についてもう少し見ていきます。
gRPC HEADER (Request-Headers)
夫々の定義と必須のフィールドだけ抜き出すと以下のようになります(全量はsourceのページ参照)。
Request-Headers → Call-Definition *Custom-Metadata
Call-Definition → Method Scheme Path TE [Authority] [Timeout] Content-Type [Message-Type] [Message-Encoding] [Message-Accept-Encoding] [User-Agent]
Method → ":method POST"
Scheme → ":scheme " ("http" / "https")
Path → ":path" "/" Service-Name "/" {method name}
Service-Name → {IDL-specific service name}
Authority → ":authority" {virtual host name of authority}
TE → "te" "trailers" # Used to detect incompatible proxies
Content-Type → "content-type" "application/grpc" [("+proto" / "+json" / {custom})]
Custom-Metadata → Binary-Header / ASCII-Header
Binary-Header → {Header-Name "-bin" } {base64 encoded value}
ASCII-Header → Header-Name ASCII-Value
Header-Name → 1*( %x30-39 / %x61-7A / "_" / "-" / ".") ; 0-9 a-z _ - .
ASCII-Value → 1*( %x20-%x7E ) ; space and printable ASCII
これらをまとめると、
- methodはPOST
-
te: trailers
というヘッダーは必須 -
content-type
ヘッダーの値はapplication/grpc
というPrefixを持つ - 事前に定義されていないヘッダー(Custom-Metadata)はappendする(途中や頭に入れない)
等のような制約が設けられていることが分かります。
またバイナリを含む任意のヘッダーをbase64でencodeして取り扱うことを想定していることも分かります。
protobufをgRPCメッセージとして送る場合、さらに上記の仕様で定まってくる部分があります。
https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md#appendix-a---grpc-for-protobuf
Service-Name → ?( {proto package name} "." ) {service name}
Message-Type → {fully qualified proto message name}
Content-Type → "application/grpc+proto"
gRPC DATA (*Length-Prefixed-Message)
こちらも定義は以下のようになっております。
Length-Prefixed-Message → Compressed-Flag Message-Length Message
Compressed-Flag → 0 / 1 # encoded as 1 byte unsigned integer
Message-Length → {length of Message} # encoded as 4 byte unsigned integer (big endian)
Message → *{binary octet}
視覚的に表すと以下のような感じです。
0 1 2 3 4 5 6 7 8 9 a b c d e f
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
| C | 0 | Message-Length(32) |
+---+---------------------------+-------------------------------+
| |
+-------------------------------+-------------------------------+
| | Message...
+-------------------------------+-------------------------------+
Compressed-Flagについては事前にMessage-Encodingヘッダーで圧縮方法をやり取りしておく必要があります。なければ0である必要があります。
このDATAの中で、通常protobuf形式のデータを送ります。
gRPC EOS
gRPCではリクエストごとにEOS(end-of-stream)を送ることが定義されています。
EOSはDATA frameにおいてEND_STREAM flagをセットすることで送ることができます。送るデータがこれ以上ない場合は空のDATA frameにEND_STREAM flagをセットして送ります。
これによりstatelessなAPI実行を前提としていることが分かります。
protocol buffer
source: https://protobuf.dev/programming-guides/encoding/
Aboud protobuf
protobufと訳されることが多いですが、これがgRPCの中でやり取りされる基本的なデータ型になります。
共通の形式でデータの構造体(.protoという拡張子のファイルに記述されます)を記述し、コンパイラ(protoc)によって各言語でその構造体が記述されて用いれるようになります。
通常それらのライブラリを用いるのでバイナリ形式(protobufではwire formatと呼んでいる)を意識することは少ないかと思います。
しかし本稿ではあえてwire formatを自分で作れるように切り込んでいきます。
まず、参考までにServerReflectionのprotoファイルを見てみると以下のようになっております。(コメント行省略)
// The message sent by the client when calling ServerReflectionInfo method.
message ServerReflectionRequest {
string host = 1;
oneof message_request {
string file_by_filename = 3;
string file_containing_symbol = 4;
...
ここでは、ServerReflectionRequestというmessageにおいてはfield_number=1にwire_type=LEN(string)でhostを示す値が入り、message_requestのフィールドにおいてはfield_number=3、4...のうちいずれかのfieldが入り、そのwire_typeが同じくLEN(string)で、、
というようなことが読み取れます。
wire formatの形式は以下のようになっています。
0 1 2 3 4 5 6 7
+---+---+---+---+---+---+---+---+
|msb| field number | wire type | (="varint")
+---+---+-----------------------+
| value... |
+---+---+-----------------------+
ここでvarintという形式が出てきますが、このvarintが何者かわかれば、あとは見たまんまの感じでフォーマットしていけます。
少し曲者ですが、varintとwire formatの作り方を見てみましょう。
protobuf varint
Variable-width integers、訳してvarintと呼ばれるようです。wire formatのcoreの部分ということです。
1 octet(1 byte)で区切り、1 bit目をcontinuation bit(またの名をmost significant bit (MSB))として使い、残りの7 bitsを値として扱います。そのためBase 128 Varints
とも呼ばれます。
continuation bitにより、任意のbit数で表されるIntegerを扱えます。そのためVariable-width integersということですね。
また、continuationでひとまとめの値を扱う際はlittle-endianで扱います。
そのため128以上の値、例えば150を表す場合は以下のようなアルゴリズムで変換します。(公式が紹介しているものそのまま引用)
10010110 00000001 // Original inputs.
0010110 0000001 // Drop continuation bits.
0000001 0010110 // Put into little-endian order.
10010110 // Concatenate.
128 + 16 + 4 + 2 = 150 // Interpret as integer.
protobuf wire-format
wire typeのPrimitiveとしては以下の6つの型が存在します。
ID | Name | Used For |
---|---|---|
0 | VARINT | int32, int64, uint32, uint64, sint32, sint64, bool, enum |
1 | I64 | fixed64, sfixed64, double |
2 | LEN | string, bytes, embedded messages, packed repeated fields |
3 | SGROUP | group start (deprecated) |
4 | EGROUP | group end (deprecated) |
5 | I32 | fixed32, sfixed32, float |
また形式の定義は以下のようになっています。
message := (tag value)*
tag := (field << 3) bit-or wire_type;
encoded as uint32 varint
value := varint for wire_type == VARINT,
i32 for wire_type == I32,
i64 for wire_type == I64,
len-prefix for wire_type == LEN,
<empty> for wire_type == SGROUP or EGROUP
varint := int32 | int64 | uint32 | uint64 | bool | enum | sint32 | sint64;
encoded as varints (sintN are ZigZag-encoded first)
...
len-prefix := size (message | string | bytes | packed);
size encoded as int32 varint
string := valid UTF-8 string (e.g. ASCII);
max 2GB of bytes
...
string等のサイズ上限が2GBになっているのはlen-prefixのsizeがint32のためです。
tagについて、定義内容より例えばfield=3、wire-type=2のような場合
0011000 <-(field=3<<3)
OR 0000010 <-(wire-type=2)
----------
0011010
のため、continuation bitを加えて00011010(0x1a)のようになるということが分かります。
以上より、例えばServerReflection(protoファイル)においてlist_servicesメッセージを送る場合、
// List the full names of registered services. The content will not be
// checked.
string list_services = 7;
ここから文字列はstringであればなんでもいいということなので、適当に"*"を送るとして
tagは
011 1000 <-field=7<<3
000 0010 <-wire-type=2 (LEN)
--------
0 011 1010 = 0x3a
len-prefixにおいてsize=1, string="*"となるため
0 0000001 (size=1 in varint int32) = 0x01
"*" (in ASCII) = 0x2a
なので送るべきメッセージは 0x3a012a
であるということが分かります。
Sample Request
ここまででHTTP2、gRPC、protobufをバイト文字列で再現するためにはどうすればいいのか見てきました。
ここまで来たらgRPCを呼び出す上で必要なバイト文字列が分かります。
試しに生バイト文字列で ServerReflectionサービスのServerReflectionInfoメソッド(/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo
)をlist_servicesメッセージで呼び出してみたいと思います。
Sample Request Bytes
クライアントからのリクエストの内容としては以下のようになります。
b"\x50\x52\x49\x20\x2a\x20\x48\x54\x54\x50\x2f\x32\x2e\x30\x0d\x0a\x0d\x0a\x53\x4d\x0d\x0a\x0d\x0a", #<-preface
b"\x00\x00\x00\x04\x00\x00\x00\x00\x00", #<- SETTING frame (preface)
b"\x00\x00\x64\x01\x04\x00\x00\x00\x01", #<- HEADER frame, 下はpayload
b"\x83\x86\x45\xad\x62\x6b\x2b\x22\xf6\x16\x5a\x0a\x44\x98\xf5\x2f",
b"\xdc\x23\xa2\xb9\xc6\xbe\xe2\xd9\xdc\xb6\x6d\x2c\xb4\x14\x89\x31",
b"\xea\x63\x71\x6c\xee\x5b\x36\x96\x5a\x0a\x44\x98\xf5\x64\xaa\x53",
b"\xff\x41\x8a\x08\x9d\x5c\x0b\x81\x70\xdc\x78\x0f\x03\x5f\x8b\x1d",
b"\x75\xd0\x62\x0d\x26\x3d\x4c\x4d\x65\x64\x7a\x8d\x9a\xca\xc8\xb4",
b"\xc7\x60\x2b\x85\x95\xc0\xb4\x85\xef\x40\x02\x74\x65\x86\x4d\x83",
b"\x35\x05\xb1\x1f",
b"\x00\x00\x08\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x03\x3a\x01\x2a" #<- DATA frame
DATA frameについて、HTTP2としてPayloadは8 byteの長さを持ち、\x00\x00\x00\x00\x03\x3a\x01\x2a
の部分が該当します。
gRPCの仕様より最初の \x00
によってnon-Compressionであること、続く4 bytesの \x00\x00\x00\x03
によってgRPCメッセージ部分が3 bytesであること、最後の \x3a\x01\x2a
がprotobufの章で見たように、今回送るgRPC Messageになります。
Sample Request Code
これを送る簡単なコードを書いてみました。
環境はIstioのEnvoyの中で実行することを想定しています。
#!/usr/bin/python3
import socket
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect("/etc/istio/proxy/XDS")
def hd(sock):
BUF=1024
rs_interval = 16*4+15
frame_head_size = 9 # Length(3)+Type(1)+Flag(1)+Id(4) in a octet unit
rl = []
r = sock.recv(BUF)
while r:
i = 0
while i < len(r):
# insert frame head
frame_size = int(r[i:i+3].hex(), base=16) # value length
rl = [" "] + [r[i+j:i+j+1].hex() for j in range(frame_head_size)]
print(" 0x".join(rl)[1:] +" len %d"%frame_size)
i += frame_head_size
# insert frame body
rl = [" "] + [r[i+j:i+j+1].hex() for j in range(frame_size)]
rs = " 0x".join(rl) + "\n"
for _i in range(1,len(rs),rs_interval+1):
print(rs[(_i+1):(_i+1+rs_interval)])
i += frame_size
if len(r)<BUF:
break
r = sock.recv(BUF)
# protocol handshake with h2c preface and null SETTING frame
# followed by HEADER and DATA frames
h2_msg = [
b"\x50\x52\x49\x20\x2a\x20\x48\x54\x54\x50\x2f\x32\x2e\x30\x0d\x0a\x0d\x0a\x53\x4d\x0d\x0a\x0d\x0a",
b"\x00\x00\x00\x04\x00\x00\x00\x00\x00",
b"\x00\x00\x64\x01\x04\x00\x00\x00\x01",
b"\x83\x86\x45\xad\x62\x6b\x2b\x22\xf6\x16\x5a\x0a\x44\x98\xf5\x2f",
b"\xdc\x23\xa2\xb9\xc6\xbe\xe2\xd9\xdc\xb6\x6d\x2c\xb4\x14\x89\x31",
b"\xea\x63\x71\x6c\xee\x5b\x36\x96\x5a\x0a\x44\x98\xf5\x64\xaa\x53",
b"\xff\x41\x8a\x08\x9d\x5c\x0b\x81\x70\xdc\x78\x0f\x03\x5f\x8b\x1d",
b"\x75\xd0\x62\x0d\x26\x3d\x4c\x4d\x65\x64\x7a\x8d\x9a\xca\xc8\xb4",
b"\xc7\x60\x2b\x85\x95\xc0\xb4\x85\xef\x40\x02\x74\x65\x86\x4d\x83",
b"\x35\x05\xb1\x1f",
b"\x00\x00\x08\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x03\x3a\x01\x2a"
]
s.send(b"".join(h2_msg))
hd(s)
input()
hd(s)
# close
s.close()
Responseが見やすいように出力を成形するようにしているので、少し長くなってしまっています。
こちらのコードをistio-agent(Istio managedのEnvoy)に適当にpythonが実行できる環境にして実行すると、無事以下のような出力が得られ、gRPCリクエストが成功していることが確認できます。(コメント部分は書き足しています)
0x00 0x00 0x06 0x04 0x00 0x00 0x00 0x00 0x00 len 6 #<- SETTING frame
0x00 0x05 0x00 0x00 0x40 0x00
0x00 0x00 0x00 0x04 0x01 0x00 0x00 0x00 0x00 len 0 #<- SETTING frame (ACK)
0x00 0x00 0x04 0x08 0x00 0x00 0x00 0x00 0x00 len 4 #<- WINDOW_UPDATE frame
0x00 0x00 0x00 0x08
0x00 0x00 0x08 0x06 0x00 0x00 0x00 0x00 0x00 len 8 #<- PING frame
0x02 0x04 0x10 0x10 0x09 0x0e 0x07 0x07
0x00 0x00 0x0e 0x01 0x04 0x00 0x00 0x00 0x01 len 14 #<- HEADER frame(Response)
0x88 0x5f 0x8b 0x1d 0x75 0xd0 0x62 0x0d 0x26 0x3d 0x4c 0x4d 0x65 0x64
0x00 0x00 0x71 0x00 0x00 0x00 0x00 0x00 0x01 len 113 #<- DATA frame
0x00 0x00 0x00 0x00 0x6c 0x12 0x03 0x3a 0x01 0x2a 0x32 0x65 0x0a 0x37 0x0a 0x35
0x65 0x6e 0x76 0x6f 0x79 0x2e 0x73 0x65 0x72 0x76 0x69 0x63 0x65 0x2e 0x64 0x69
0x73 0x63 0x6f 0x76 0x65 0x72 0x79 0x2e 0x76 0x33 0x2e 0x41 0x67 0x67 0x72 0x65
0x67 0x61 0x74 0x65 0x64 0x44 0x69 0x73 0x63 0x6f 0x76 0x65 0x72 0x79 0x53 0x65
0x72 0x76 0x69 0x63 0x65 0x0a 0x2a 0x0a 0x28 0x67 0x72 0x70 0x63 0x2e 0x72 0x65
0x66 0x6c 0x65 0x63 0x74 0x69 0x6f 0x6e 0x2e 0x76 0x31 0x61 0x6c 0x70 0x68 0x61
0x2e 0x53 0x65 0x72 0x76 0x65 0x72 0x52 0x65 0x66 0x6c 0x65 0x63 0x74 0x69 0x6f
0x6e
0x00 0x00 0x18 0x01 0x05 0x00 0x00 0x00 0x01 len 24 # HEADER frame(END_HEADERS & END_STREAMS, with grpc-status=0(OK) and a null grpc-message
0x40 0x88 0x9a 0xca 0xc8 0xb2 0x12 0x34 0xda 0x8f 0x01 0x30 0x40 0x89 0x9a 0xca
0xc8 0xb5 0x25 0x42 0x07 0x31 0x7f 0x00
protobufのメッセージであることを確認するために、DATAの部分を少し深堀ってみてみます。
0x00 0x00 0x00 0x00 0x6c #<- gRPC message length of 6c octets
0x12 0x03 0x3a 0x01 0x2a #<- field=2, type=2(LEN), ServerReflectionResponse.original_request="0x3a 0x01 0x2a"
0x32 0x65 #<- field=6, type=2(LEN), ServerReflectionResponse.list_services_response
0x0a 0x37 #<- field=1, type=2(LEN), ListServiceResponse.service
0x0a 0x35 #<- field=1, type=2(LEN), ServiceResponse.name
0x65 0x6e 0x76 0x6f 0x79 0x2e 0x73 0x65 0x72 0x76 0x69 0x63 0x65 0x2e 0x64 0x69
0x73 0x63 0x6f 0x76 0x65 0x72 0x79 0x2e 0x76 0x33 0x2e 0x41 0x67 0x67 0x72 0x65
0x67 0x61 0x74 0x65 0x64 0x44 0x69 0x73 0x63 0x6f 0x76 0x65 0x72 0x79 0x53 0x65
0x72 0x76 0x69 0x63 0x65 #<- ASCII "envoy.service.discovery.v3.AggregatedDiscoveryService"
0x0a 0x2a #<- field=1, type=2(LEN), ListServiceResponse.service
0x0a 0x28 #<- field=1, type=2(LEN), ServiceResponse.name
0x67 0x72 0x700x63 0x2e 0x72 0x65 0x66 0x6c 0x65 0x63 0x74 0x69 0x6f 0x6e 0x2e
0x76 0x31 0x61 0x6c 0x70 0x68 0x61 0x2e 0x53 0x65 0x72 0x76 0x65 0x72 0x52 0x65
0x66 0x6c 0x65 0x63 0x74 0x69 0x6f 0x6e #<- ASCII "grpc.reflection.v1alpha.ServerReflection"
上記コメントで示したようにデコードできます。
JSONに変換すると
{
"ServerReflectionResponse":{
"original_request": "\x3a\x01\x2a",
"list_services_response":[
{
"service":{
"name": "envoy.service.discovery.v3.AggregatedDiscoveryService"
}
},
{
"service":{
"name": "grpc.reflection.v1alpha.ServerReflection"
}
}
]
}
}
のようなレスポンスが返ってきていることが分かり、ちゃんとprotobufのプロトコルに従ってデコードできることが確認できました。
最後に
だいぶニッチな部分の記事となりました。
元々はEnvoyで自前でXDSできないかと思い、色々エンドポイントの仕様を調べ始めたのが原因でそれぞれの仕様に跨って触れれたので、調査過程でメモしていたものをまとめるつもりで書き始めました。
日常の開発でこんな生の部分に触れることはないかもしれませんが、同じようにgRPCとは何ぞやという人や、バイト文字列から仕様を理解したいという人向けに少しでも足しになれば幸いです。
ごちそうさまでした。