Web●●●○○

gRPC

gRPC ・ じーあーるぴーしー

スキーマ定義から各言語のコードを生成し、HTTP/2上で高速に呼び合うRPCフレームワーク。

✦ 30秒で試す — 航海送るデータに、項目の名前は1文字も載っていないこの重いクライアントの要求は、どう届く?予想してみる

概要

gRPC は、Google が開発したオープンソースの RPC(Remote Procedure Call — 別マシンの関数を、あたかも手元の関数のように呼び出す仕組み)フレームワークです。サービス間のやり取りをまず .proto というスキーマファイルに「どんな関数が、どんな型の引数を受け取り、どんな型の値を返すか」として定義し、そこから各プログラミング言語のクライアント・サーバコードを自動生成します。通信路には HTTP/2 を使い、データは Protocol Buffers というコンパクトなバイナリ形式で運びます。

登場する典型的な場面は、マイクロサービス同士の内部通信です。数十のサーバが1リクエストの裏で何度も呼び合うような環境では、通信の速さと型の厳密さがそのままシステムの品質になります。Kubernetes まわりのエコシステムをはじめ、クラウドネイティブなシステムの「内側の配線」として広く使われている技術です。

なぜ生まれたか

gRPC 以前、サービス間通信の主流は REST API + JSON でした。人間が読める形式で汎用性は高いのですが、内部通信の道具としては不満が残ります。まず JSON はテキスト形式です。数値型はあるものの、1234567 という数も7文字の10進の文字の並びとして符号化され、毎回キー名の文字列も一緒に運ばれるため、パース(解析)のコストとデータサイズが大きくなります。次に、API の形が仕様書という「文書」でしか共有されず、フィールド名の綴りミスや型の食い違いは実行してみるまで分かりません。サービスが増えるほど「A チームが返す JSON の形が B チームの想定とずれていた」という事故が積み上がります。

Google は社内で長年、Stubby という独自 RPC 基盤と Protocol Buffers で、この問題を「スキーマを唯一の契約とし、コードは機械に生成させる」方針で解決してきました。その知見を HTTP/2 の標準化に合わせて再設計し、2015年にオープンソースとして公開したのが gRPC です。人間が読む仕様書ではなく、機械が検証できるスキーマを共有の中心に置く — これが gRPC の設計思想の核です。

詳細

スキーマ駆動 — .proto からコードを生成する

開発はまず .proto ファイルを書くところから始まります。ここにメッセージ(データ構造)とサービス(呼び出せる関数の一覧)を定義し、protoc というコンパイラに通すと、Go・Java・Python・TypeScript など各言語向けのコードが生成されます。サーバ側にはインターフェース(中身を実装すべき骨組み)が、クライアント側にはスタブ(ネットワーク越しの呼び出しを普通の関数呼び出しに見せかける代理オブジェクト)が手に入ります。

.proto ファイルメッセージ定義とサービス定義protocコンパイラGo サーバ骨組み実装を埋めるだけJava クライアント型付きスタブPython クライアント型付きスタブ
Protocol Buffers のコード生成フロー — スキーマが言語をまたぐ唯一の契約になる

この仕組みの効能は「型の食い違いがコンパイル時に検出される」ことです。フィールド名の綴りミスも型の不一致も、実行前にビルドが失敗して教えてくれます。多言語が混在するチームでも、.proto さえ共有すれば全員が同じ契約のもとで開発できます。

名前ではなく番号で運ぶ

Protocol Buffers のもう1つの要は、string email = 3; の 3 です。送られるバイト列には、フィールドの名前は一切載りません。各フィールドは「タグ(フィールド番号 × 8 + ワイヤ型)」「(文字列なら)長さ」「値」の並びとして符号化され、受け取った側は自分の .proto で番号を引いて、どのフィールドかを知ります。ワイヤ型は「可変長整数」「長さ付き」など値の区切り方を表す数で、これがあるおかげで、受け手は知らない番号のフィールドでも、どこまで読み飛ばせばよいかが分かります。

ここから後方互換の規則が導かれます。フィールドの追加は安全です(古いクライアントは知らない番号を読み飛ばします)。名前の変更もバイト列には影響しません。一方、公開済みのフィールドの番号を変えたり、消したフィールドの番号を別のフィールドに使い回したりすると、古いクライアントはエラーを出さないまま、値を別のフィールドとして読んでしまいます。だから消したフィールドの番号は reserved で封印し、二度と使えないようにするのが定石です。サーバ側の .proto を書き換えて、v1 のまま動く古いクライアントがどう読むかを確かめてください。

⚡ 体験: 番号で運ぶ Protocol Buffers
サーバの .proto(書き換えられる)新しい版
message User {
string=1;
int32=2;
string=3;
}
古いクライアントの .proto(v1 のまま)変えられない
message User {
string name = 1;
int32 age = 2;
string email = 3;
}
古いクライアントが読んだ結果すべて正しい
{
"name": "Sato",
"age": 28,
"email": "sato@ex.jp"
}
サーバ新しい .proto古いクライアントv1 で読むHTTP/2 の上を運ばれるバイト列 →20 バイト(同じ中身を JSON にすると 45 バイト)古いクライアントに届いたバイト列(16進)— 名札は送った側のフィールド0a045361746f#1 name101c#2 age1a0a7361746f4065782e6a70#3 email
タグ(番号 × 8 + ワイヤ型) 長さ 値 古いクライアントが読み違えた
protobuf20 B同じ中身の JSON45 B古いクライアントで正しく読めた3 / 3
○ 足したフィールドは読み飛ばされる○ 名前を変えてもバイト列は同じ○ 番号を変えると値が化ける・消える○ 消した番号を reserved で封印する

サーバの .proto を書き換えてください。番号の − +、名前(押すと改名)、✕(削除)、+ フィールドを足す。変えるたびにバイト列が組み直されて古いクライアントへ届き、v1 のまま読んだ結果が右下に並びます。

バイト列はこの部品の中で本物の protobuf の規則(タグ = 番号 << 3 | ワイヤ型、可変長整数、長さ付きの UTF-8)で符号化し、v1 の定義でデコードしています。proto3 の実装と同じく、知らない番号や型の合わない番号はエラーにせず読み飛ばします。

HTTP/2 が可能にする4つの通信方式

gRPC が HTTP/2 を土台に選んだのは、単に速いからだけではありません。HTTP/2 の「1本の接続の上に複数のストリームを多重化できる」性質を使い、gRPC は4種類の通信方式を提供します。1回の RPC が HTTP/2 のストリーム1本に対応し、その上をメッセージが片方向か両方向に何個流れるかで4つに分かれます。

サーバクライアントサーバクライアント単項 RPCサーバストリーミングクライアントストリーミング双方向ストリーミング要求 1つ応答 1つ要求 1つ応答 1応答 2応答 3 と終了の合図要求 1要求 2要求 3 と終了の合図応答 1つ要求 1応答 1要求 2要求 3応答 2

1つめは単項 RPC(Unary)で、1リクエストに1レスポンスを返す、通常の関数呼び出しと同じ形です。2つめはサーバストリーミングで、1つのリクエストに対してサーバが結果を小分けに送り続けます(大量の検索結果や進捗通知など)。3つめはクライアントストリーミングで、クライアントがデータを送り続けて最後にサーバが1つの応答を返します(センサーデータやファイルのアップロードなど)。4つめは双方向ストリーミングで、両者が独立に送り合えます(チャットやリアルタイム同期など)。REST では WebSocket などを別途持ち出す必要があった用途が、同じフレームワーク・同じ型定義のまま書けるのが強みです。

このほか実務では、応答期限をクライアントが指定するデッドライン(呼び出しのタイムアウトを、下流のサービスへの呼び出しにも残り時間として引き継ぐ仕組み)、呼び出しの連鎖ごと打ち切るキャンセル、認証情報などを運ぶメタデータ、といった分散システム向けの機能が最初から組み込まれている点も重宝されます。

REST との使い分けとブラウザの制約

gRPC と REST API は競合というより住み分けです。よくある構成は「内部のサービス間通信は gRPC、外部に公開する API は REST」というものです。内部では性能と型安全の恩恵が大きい一方、外部公開では「curl ひとつで試せる」「JSON をどの言語でも扱える」という REST の取っつきやすさが勝ります。

大きな制約として、ブラウザから gRPC を直接話すことはできません。gRPC は HTTP/2 のフレームや、応答の最後に付くトレーラー(本文のあとに送るヘッダ)を使いますが、ブラウザの JavaScript にはそのレベルの API が開放されていないためです。対策は2つあり、1つはブラウザ向けの変換プロトコルである grpc-web を使い、中間のプロキシで本来の gRPC に変換する方法。もう1つは grpc-gateway のようなツールで .proto から REST API を自動生成し、外部には REST として公開する方法です。また、バイナリ形式は人間がそのまま読めないため、デバッグには grpcurl などの専用ツールが必要になることも、導入時に押さえておきたい運用上のコストです。

さらに、gRPC は接続を長く張りっぱなしにするため、接続単位で振り分ける L4 のロードバランサでは負荷が偏りやすい、という古典的な落とし穴もあります。L4 のロードバランサは TCP の接続を開くときに1度だけ行き先を決め、その後は接続の中身(どの要求が流れているか)を見ません。すると、要求の多いクライアントの要求は全部同じサーバに流れ続け、負荷に応じてサーバを足しても、既存の接続は動かないので新しいサーバには何も来ません。要求単位で分散させるには、HTTP/2 を終端して要求ごとに振り分ける L7 のプロキシか、クライアント自身がサーバの一覧を知って要求ごとに選ぶクライアントサイドの負荷分散が必要です。この役をサイドカーのプロキシが引き受けることは、サービスメッシュ導入の動機のひとつにもなっています。要求の量とサーバの台数を変えて、振り分けの単位の違いを確かめてください。

⚡ 体験: 張りっぱなしの接続と、振り分けの単位
ロードバランサ
クライアント 11.5 件/秒クライアント 21.5 件/秒クライアント 31.5 件/秒押すと要求が3倍L4 LB接続を中継中身は見ないサーバ A0.0 件/秒サーバ B0.0 件/秒サーバ C0.0 件/秒+ サーバを足す接続を張り直す
HTTP/2 の接続(張りっぱなし)要求応答1台がさばける量
○ L4 で重いクライアントが1台に集中する○ L4 では足したサーバに要求が来ない○ L7 で要求ごとに均される

いまは L4 のロードバランサです。3つのクライアントがそれぞれ HTTP/2 の接続を1本ずつ張り、その上に要求を流しています。クライアントを押して要求を3倍にするか、「+ サーバを足す」で台数を増やしてください。

要求の量は単純化した値です(1クライアント 1.5 件/秒、重くすると 4.5 件/秒、1台がさばけるのは 3 件/秒)。サーバの数字は直近3秒に届いた要求の数から数えています。

試問

3つのクライアントが、それぞれ HTTP/2 の接続を1本ずつ張りっぱなしにして、gRPC の要求を流しています。クライアントを押すと要求が3倍(4.5 件/秒)になり、「+ サーバを足す」で台数を増やせ(最大5台)、ロードバランサは L4(接続ごとに振り分け)と L7(要求ごとに振り分け)を切り替えられます。サーバ1台がさばけるのは 3 件/秒です。

✦ 命題挑戦中

重いクライアントがいる中でサーバを4台以上に増やし、どのサーバも過負荷にせず、遊ばせもしない状態を作れ

  • 重いクライアント(4.5 件/秒)が1つ以上いる
  • サーバが 4台以上
  • どのサーバも 3 件/秒を超えず、要求の来ないサーバもない
⚡ 体験: 重いクライアントがいても、偏らせない
ロードバランサ
クライアント 11.5 件/秒クライアント 21.5 件/秒クライアント 31.5 件/秒押すと要求が3倍L4 LB接続を中継中身は見ないサーバ A0.0 件/秒サーバ B0.0 件/秒サーバ C0.0 件/秒+ サーバを足す接続を張り直す
HTTP/2 の接続(張りっぱなし)要求応答1台がさばける量

いまは L4 のロードバランサです。3つのクライアントがそれぞれ HTTP/2 の接続を1本ずつ張り、その上に要求を流しています。クライアントを押して要求を3倍にするか、「+ サーバを足す」で台数を増やしてください。

要求の量は単純化した値です(1クライアント 1.5 件/秒、重くすると 4.5 件/秒、1台がさばけるのは 3 件/秒)。サーバの数字は直近3秒に届いた要求の数から数えています。