Tech Learning Daily

2026-10-01 (Thu) — 第 76 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Network 🌿 基礎 ⏱ 約 7 分

同じ API 呼び出しなのに、なぜ gRPC は REST よりずっと速いと言われるのか?

あなたも Rails の render json: @user や Go の json.Marshal で、API のレスポンスを当たり前のように JSON の文字列として返してきたはずだ。ではマイクロサービス間の通信で gRPC が使われているのを見かけたとき、わざわざ JSON をやめてバイナリ形式に切り替えることで、具体的に何が速くなっているのか説明できるだろうか。

🎯 3 行まとめ
  • protobufはJSONと違いフィールド名を一切送らず、.protoで決めたフィールド番号だけを送るバイナリ形式なので、送信側と受信側が同じ.protoを共有していなければそもそもデコードできない。
  • gRPCはHTTP/2の上で動き、1本の接続の中で複数のRPCを並行してやり取りできるほか、ステータスやメタデータを本文ではなくHTTP/2のトレーラーで送る。
  • 速さの正体はHTTP/2のmultiplexingとprotobufの直列化コストの低さという別々の要因の組み合わせであり、どちらか一方だけでは説明できない。

REST + JSON の「当たり前」が持つコスト

Railsのrender json: @userやGoのjson.MarshalでAPIレスポンスを返すとき、送られるのは{"name":"Alice","age":30}のような文字列だ。JSONは人が読んでそのまま意味が分かる自己記述的な形式であり、curlで叩いてすぐ中身を確認できる手軽さが広く使われる理由になっている。

ただしこの読みやすさには対価がある。nameやageというフィールド名の文字列は、送信側と受信側がすでにどんな構造かを知っていても、レスポンスのたびに毎回まるごと送られる。社内のマイクロサービス同士が高頻度にJSONをやり取りするような場面では、このフィールド名の繰り返しと、文字列をパースしてオブジェクトに変換するコストが無視できなくなってくる。

Protocol Buffers: フィールド名の代わりに番号だけを送る

gRPCが標準で使う直列化形式がProtocol Buffers(protobuf)だ。.protoファイルでメッセージの構造をmessage Person { string name = 1; int32 age = 2; }のように定義し、protocコンパイラがこの定義からGo・TypeScript・Rubyなど各言語のコードを生成する。ここで各フィールドに振った1や2という番号が、JSONにおける"name"という文字列の代わりに通信路を流れる実体になる。

protobufのバイナリ表現は、フィールド番号とデータ型の種別(wire type)を1つの整数に詰め込んだ「タグ」((フィールド番号 << 3) | wire_type)と、その後に続く値だけで構成される。フィールド名の文字列はどこにも登場しない。デコードする側は、受け取った数値が何というフィールドなのかを、自分が持っている.protoの定義を見て初めて知ることができる。送信側と受信側が同じ.protoを共有していない限り、このバイナリ列は数字の並びにしか見えない。

🍱 たとえるなら

protobufのやり取りは、二人が番号表を事前に共有してから送る暗号文に似ている。送り手は「1番:太郎、2番:30」のように番号だけを書いた紙を渡し、受け手は自分が持つ同じ番号表を見て「1番は名前、2番は年齢」と当てはめて読む。番号表を持たない第三者がその紙を拾っても、ただの数字の並びにしか見えない。JSONの手紙ならname: 太郎とその場に書いてあるので誰でも読めるが、その読みやすさのために「name」という文字そのものを毎回書く手間がかかっている。

gRPCはHTTP/2の上でRPCを運ぶ

.protoにはrpc GetUser(UserRequest) returns (UserResponse)のようにメソッドそのものも定義でき、protocはこれを普通の関数呼び出しのように書けるスタブコードとして生成する。この呼び出しの実体は、HTTP/2の1本のストリームに対応する1回のリクエスト/レスポンスのやり取りだ。HTTP/2は1本のTCP接続の中で複数のストリームを並行して運べるため、複数のRPCを同時に発行してもアプリ層でブロックし合わない。

バイナリにしさえすれば速くなる、と考えたくなる。しかし実際に効いているのは、protobufの直列化コストの低さとHTTP/2のmultiplexingという別々の要因の組み合わせであり、ペイロードが小さく呼び出し頻度も低いAPIでは、体感できる差にならないこともある。

gRPCはさらに4種類のRPCをサポートする。クライアントが1つ送って1つ受け取るunary、サーバー側から複数のメッセージを順に受け取るserver streaming、クライアント側から複数送ってから1つの応答を受け取るclient streaming、両方が独立して送受信を続ける双方向streamingだ。

Unary:
 Client ──req──▶ Server
 Client ◀─res── Server

Server streaming:
 Client ──req──▶  Server
 Client ◀─msg1─── Server
 Client ◀─msg2─── Server

双方向 streaming:
 Client ◀──msg──▶ Server
(両方が独立して送受信を続ける)

ステータスの伝え方もRESTと異なる。gRPCは成功・失敗を示すステータスコードと付随するメタデータを、レスポンス本体を送り終えた後にHTTP/2の「トレーラー」(trailing metadata)として送る。RESTでエラー内容をJSON本文に混ぜて返す場合と違い、本体のメッセージ形式自体をエラー用に変える必要がない。

地味だが壊れやすい設定: フィールド番号とdeadline

protobufのフィールド番号は、デコードの唯一の手がかりであるぶん、後から自由に振り直せない。あるフィールドを削除してその番号を別の新しいフィールドに再利用すると、まだ新しい定義に更新していない古いクライアントが、その番号を古い定義のまま解釈してしまい、型が食い違えばデコードエラー、たまたま型が同じなら無言で違う意味の値として読み込んでしまう。これを防ぐため、削除した番号は予約して二度と使わない欠番として残す。

message Person {
  string name = 1;
  reserved 2;        // 削除した age の番号は再利用しない
  string email = 3;
}

deadline(呼び出しの最大待ち時間)についても明示が必要になる。gRPCはデフォルトでdeadlineを設定せず、クライアントが明示的に指定しない限り応答を実質無期限に待ち続ける可能性がある。Goならcontext.WithTimeoutで呼び出しごとに期限を渡すと、期限を超えた呼び出しはDEADLINE_EXCEEDEDエラーで打ち切られ、障害が起きたサービスへの呼び出しが積み重なって自分のプロセスまで引き倒されるのを防げる。

💼 実務でどう出会うか

自分からgRPCサーバーを書く機会がなくても、知らないうちに触れていることは多い。Kubernetesのクラスタ状態を保持するetcdは、v3 API以降のすべてのRPC(KV・Watch・Leaseなど)をgRPCサービスとして定義しており、kubectlやkube-apiserverはそのgRPCクライアントとして動いている。社内のGoマイクロサービス間通信で直接出会うほか、ブラウザからバックエンドを呼ぶ構成ではgRPC-Web経由になることもある。逆に不特定多数のクライアントを想定する社外向け公開APIでは、全員に.protoを事前共有させることが難しく、今でもJSON + RESTが主流だ。

⌨️ 手を動かす(5 分)

同じデータをprotobufのバイナリ形式とJSONで表現したとき、実際に何バイト変わるのかを手元で測る。

brew install protobuf

cat > person.proto <<'EOF'
syntax = "proto3";
message Person {
  string name = 1;
  int32 age = 2;
}
EOF

printf 'name: "Alice" age: 30' | protoc --encode=Person person.proto | wc -c
printf '{"name":"Alice","age":30}' | wc -c

protoc --encodeはテキスト形式の入力をprotobufのバイナリに変換して標準出力に書き出すので、1つ目のwc -cはちょうど9バイトになる(タグ1バイト+長さ1バイト+"Alice"5バイトの7バイトと、タグ1バイト+値1バイトの2バイトの合計)。2つ目のJSON文字列は25バイトあり、"name"や"age"という文字列を一切含まない分だけprotobufが小さくなっていることが確認できる。

🙅 よくある誤解
  • gRPCが速いのはバイナリだから — バイナリ化による直列化コストの低減とHTTP/2のmultiplexingは別の要因で、両方が揃って初めて体感できる差になる。小さいペイロードや低頻度の呼び出しでは差がほとんど出ないこともある。
  • ブラウザからもfetchで直接gRPCサーバーを呼べる — ブラウザのfetch/XHRはHTTP/2の生のストリーム制御やトレーラーを扱うAPIを提供していないため、gRPC-Webという形式に変換するEnvoyなどのプロキシを経由する必要がある。
  • .protoのフィールド番号は後から自由に振り直せる — wire formatはフィールド番号だけを手がかりにデコードするため、番号を変更・再利用すると既にデプロイ済みのクライアントが別のフィールドとして誤読してしまう。
📖 用語ミニ辞典
Protocol Buffers(protobuf)
Googleが開発したバイナリ直列化形式で、.protoファイルでスキーマを定義する。
.proto / protoc
RPCとメッセージの構造を定義するスキーマファイルと、それから各言語のコードを生成するコンパイラ。
ワイヤーフォーマット(wire format)
フィールド番号とwire typeだけを並べ、フィールド名を一切含まないprotobufのバイナリ表現。
トレーラー(trailing metadata)
gRPCがレスポンス本体を送り終えた後にHTTP/2のトレーラーとして送るステータスコードやメタデータ。
gRPC-Web
ブラウザからgRPCサービスを呼ぶために、Envoyなどのプロキシを介してHTTP/2の生のストリームをブラウザが扱える形式に変換する仕組み。
🔗 もっと深く