RPC と REST — API 設計の選択を、ワイヤー上のバイト列から考え直す
RPC と REST の違いを、HTTP/2 フレームと Protobuf のバイト列から、契約とスキーマ進化、エラー設計、キャッシュ、認証認可、可観測性、運用の落とし穴まで比較します。
「内部サービス間は gRPC、公開 API は REST」という結論は、おそらく正しいでしょう。ただ、その理由を説明できる人は多くありません。「gRPC のほうが速いから」で止まると、公開 API に gRPC を選んで CDN が使えなくなったり、社内の 1ms 未満のホップに Protobuf を導入しても何も改善しなかったりします。
以下では、RPC と REST を設計思想、ワイヤー上のバイト列、スキーマ進化、エラー設計、運用の順に比べます。それぞれの層で、どの制約を受け入れると何が手に入り、何を失うのかを、方式を選ぶときに使える細かさで説明します。
役所の窓口で考える
- RPC は「担当者に電話して用件を頼む」方式です。「田中さん、この申請通しておいて」。双方が手続き名を知っているので、話は早く進みます。ただし、担当者や手続き名が変わると、電話をかける側も全員が直す必要があります。電話が途中で切れると、頼んだ処理が実行済みかどうかも分かりません。
- REST は「書類棚に対して所定の様式で読み書きする」方式です。棚の場所(URL)と操作(GET / PUT / DELETE)の意味がどこでも共通なので、中間業者を含めて誰でも扱えます。コピー機(CDN)が写しを取って配ってもよく、受付(プロキシー)が中身を見て仕分けることもできます。代わりに、「この申請を取り消す」のような動詞を名詞に翻訳する手間がかかります。
つまり、選択を左右するのは速さよりも「意味論をどこに置くか」です。RPC はアプリケーションの中に、REST はプロトコルの中に置きます。前者で得られるのは表現力、後者で得られるのは中間層の再利用です。
1. 「RPC と REST のどちらか」という問いは、そのままでは比較になっていない
1.1 比較の対象がずれている
- RPC (Remote Procedure Call) はプログラミングモデルです。ネットワーク越しの処理を、ローカル関数の呼び出しのように書きます。gRPC・JSON-RPC・Thrift・Cap'n Proto はいずれもこの考え方の実装です。
- REST (Representational State Transfer) は Roy Fielding が 2000 年の博士論文で定義したアーキテクチャスタイルです。ある制約を課すと、どんな性質(スケーラビリティー・進化の独立性・可視性)が得られるかを述べた理論で、実装技術ではありません。
片方はプログラミングモデル、もう片方は制約の集合で、抽象度が違います。そのまま並べても議論は噛み合わないので、問いを次のように言い換えます。
| 問い | 実際に比較しているもの |
|---|---|
| 「RPC か REST か」 | API を「手続きの集合」としてモデル化するか、「リソースの集合」としてモデル化するか |
| 「gRPC か REST API か」 | Protobuf/HTTP2 という具体的なスタックか、JSON/HTTP という具体的なスタックか |
| 「速いのはどっち」 | ワイヤーフォーマットと多重化方式の性能特性 |
この 3 つは独立した軸です。REST のリソースモデルに従いながら Protobuf を運ぶこともできますし(Google の API はそうしています)、gRPC の上でリソース指向の設計をすることもできます。同じ理由で、「HTTP + JSON だから REST」という推論も、ほぼ成り立ちません。
1.2 世の中の「REST API」の大半は RPC over HTTP である
POST /api/getUserProfile、POST /api/sendEmail、GET /api/searchOrders?q=...。こうしたエンドポイントは、URL に手続き名が入っていて、HTTP メソッドが意味を持たず、レスポンスに次の遷移先も入っていません。これは HTTP をトランスポートとして使った RPC であって、REST ではありません。
この設計自体は悪くなく、多くのチームにとって妥当な選択です。問題は、REST だと思い込んでいるせいで、キャッシュ・中間層・統一インターフェースといった REST の利点を取り損ねることです。名前と実態が食い違ったままだと、設計上のトレードオフが議論されません。
1.3 Richardson 成熟度モデル — どこまで HTTP の意味論を使うか
Leonard Richardson が 2008 年に提示し、Martin Fowler が 2010 年に広めた 4 段階のモデルは、この食い違いを測る物差しとして今も使えます。
- Level 0: HTTP を、バイト列を運ぶトンネルとして使います。XML-RPC、SOAP、そして実質的に単一の
/graphqlエンドポイントで動く GraphQL がここに属します。 - Level 1: URL がリソースを識別します。ただし操作はボディーの中で指定します。
- Level 2: HTTP メソッドとステータスコードが意味論を担います。実務で「REST API」と言う場合はほとんどがここを指し、キャッシュ・冪等性・中間層の恩恵もここから得られます。
- Level 3: HATEOAS(Hypermedia as the Engine of Application State)。レスポンスが、次に可能な遷移をリンクとして含みます。
Fielding 自身は 2008 年のブログ記事「REST APIs must be hypertext-driven」で、Level 3 に達していないものを REST と呼ぶことに強く反対しました。ただ実務では、Level 2 までで利益の大部分は得られるというのが、この 20 年の経験から出た答えだと言えます。
2. RPC の系譜 — 40 年続く「リモート呼び出しをローカルに見せる」試み
1984 年の Birrell と Nelson の論文は、分散計算を作りやすくするために、リモート呼び出しの意味論をローカル呼び出しにできるだけ近づけることを設計原則にしました。この原則に沿ったシステムは大規模な失敗を何度も経て、そのたびに透過性を諦める方向へ修正されてきました。
2.1 Birrell & Nelson (1984) — Cedar での実装
Andrew Birrell と Bruce Nelson が Xerox PARC で書いた Implementing Remote Procedure Calls(ACM TOCS 2(1), 1984)は、Cedar というプログラミング環境向けに作った RPC パッケージの設計判断と性能を報告した論文です。RPC の考え自体は、論文が書くとおり少なくとも 1976 年から文献で議論されており、Nelson の博士論文(1981 年)が設計の選択肢を詳しく検討していました。現代の RPC が持つ要素は、この実装の時点でほぼ揃っていました。
- スタブ (stub) — クライアント側の代理関数。引数を梱包して送ります。Lupine というツールが、インターフェース定義から自動生成しました。
- マーシャリング / アンマーシャリング — 言語の値とバイト列を相互に変換します。
- バインディング — サービス名から実体を解決します(現在のサービスディスカバリー)。
- 呼び出し意味論 — 論文が保証したのは、呼び出しが戻れば手続きはちょうど 1 回実行されていること、例外が返れば 1 回実行されたか 1 回も実行されていないかのどちらかであること(利用者にはどちらか分かりません)です。at-most-once と呼ばれる意味論にあたります。
最後の点は 40 年経っても変わっていません。ネットワーク越しの呼び出しが「ちょうど 1 回実行された」ことは、冪等キーやトランザクショナルアウトボックスのような追加の仕組みなしには保証できません。
2.2 Sun RPC / XDR — RPC が「インフラ」になった
1980 年代後半、Sun Microsystems の ONC RPC(RFC 1050 → RFC 1831 → RFC 5531)が NFS の基盤として広く普及します。ここで導入された XDR (External Data Representation, RFC 4506) は、言語に依存しない型付きバイナリー表現の直接の祖先です。IDL(Interface Definition Language)でインターフェースを書いてコードを生成するという、.proto と protoc の関係と同じ構図が、すでにここにあります。
2.3 分散オブジェクトの時代 — CORBA / DCOM / Java RMI
1990 年代は、オブジェクトをネットワーク越しに透過的に扱う方向へ振れました。CORBA(OMG, 1.0 は 1991 年、IIOP は 2.0 から)、Microsoft の DCOM(1996)、Java RMI(JDK 1.1, 1997)です。
いずれも大規模には定着しませんでした。技術的な複雑さもありますが、主な理由は §3 で扱う「透過性の嘘」です。ローカルのオブジェクト参照と同じ見た目の呼び出しが、ネットワーク分断で何も返さないまま数十秒ハングします。こうした経験が、アーキテクトを RPC から遠ざけました。
2.4 XML-RPC → SOAP → WS-* — HTTP に乗ったが、HTTP の意味論は使わなかった
1998 年の XML-RPC、1999〜2000 年の SOAP は、ファイアウォールを通せるという理由で HTTP を選びました。しかし使ったのは POST 1 つだけで、意味論はすべて XML エンベロープの中にありました。Richardson Level 0 の典型です。
その上に WS-Security、WS-ReliableMessaging、WS-Transaction……と仕様が積み上がり(俗に WS-*)、実装間の相互運用性が破綻していきました。REST が支持を集めた最大の理由は、この複雑さへの反動だったと考えられます。
2.5 gRPC 以降 — 透過性を捨てた RPC
2015 年、Google は社内の Stubby を再設計した gRPC を公開します(1.0 は 2016 年、2017 年 2 月に CNCF へ。執筆時点でもまだ incubating です)。同年に Facebook が GraphQL を公開し、2018 年に Twitch が Twirp、2022 年に Buf が Connect を出しました。TypeScript では tRPC も使われています。
1990 年代の分散オブジェクトと違い、現代の RPC はリモート呼び出しであることを型と API で意識させます。
- デッドラインがプロトコルの語彙に含まれています(
grpc-timeout)。必須ではありませんが、全呼び出しにデッドラインを設定する規律を組織として強制しやすくなります。 - 呼び出しが失敗しうることが型に現れます(Go なら
(resp, error)、Rust ならResult)。 - ストリーミングは別の型として明示されます。
3. RPC が繰り返し失敗した理由 — 「透過性の嘘」
3.1 A Note on Distributed Computing (1994)
Jim Waldo らが Sun Labs で書いたこの短い論文(TR-94-29)は、分散システム設計で最も重要な文献のひとつです。著者らは、ローカルとリモートの区別を消す試みが原理的に失敗する理由を 4 つ挙げました。
| 差異 | 何が問題になるか | 現代の対処 |
|---|---|---|
| レイテンシー | ローカル呼び出しは ns、リモートは µs〜ms。10^3〜10^6 倍の差は「量の差」ではなく「質の差」 | デッドライン伝播、バッチ化、非同期 API |
| メモリーアクセス | ポインターは相手のアドレス空間で意味を持たない | 値渡し(マーシャリング)を強制、参照を型で表現しない |
| 部分故障 | ローカルなら呼び出し元と先は同時に死ぬ。リモートは片方だけ死ぬ、あるいは生死不明になる | タイムアウト、リトライ + 冪等性、サーキットブレーカー |
| 並行性 | リモートオブジェクトは複数クライアントから同時に叩かれる | ステートレス化、楽観ロック(ETag / version) |
4 つのうち最も厄介なのは部分故障です。レスポンスが返ってこないとき、リクエストが届かなかったのか、処理されたあとでレスポンスが失われたのかを、クライアントは区別できません。そのため「リトライしてよいのか」という問いが生まれ、冪等性の設計が必要になります。
3.2 分散コンピューティングの 8 つの誤謬
L. Peter Deutsch が 1994 年頃に 7 つをまとめ、James Gosling が 8 つ目を加えたリストです。RPC の設計を評価するチェックリストとして今も使えます。
- ネットワークは信頼できる
- レイテンシーはゼロである
- 帯域は無限である
- ネットワークは安全である
- トポロジーは変化しない
- 管理者は 1 人である
- 転送コストはゼロである
- ネットワークは均質である
REST の「ステートレス」「キャッシュ可能」「階層化システム」という制約は、これらの誤謬が招く問題にアーキテクチャの構造で対処します。RPC 側は、同じ問題への対策をフレームワークの機能(デッドライン、リトライポリシー、ロードバランサー統合)として実装しました。
3.3 それでも RPC が残る理由
透過性が嘘だと分かっても RPC が使われ続けるのは、「呼び出し」が人間にとって自然な抽象だからです。
// これは読める
user, err := userClient.GetUser(ctx, &pb.GetUserRequest{Id: 42})
// これは「読めるが、意味を組み立て直す必要がある」
resp, err := http.Get("https://api.example.com/v1/users/42")
if err != nil { /* ネットワーク層の失敗 */ }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { /* HTTP 層の失敗を自分で分類する */ }
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { /* ... */ }差は行数だけではありません。前者では、型・エラー・デッドライン・リトライ・トレースの伝播がすべてフレームワークの責務です。後者では、それを毎回アプリケーションが書きます(あるいは書き忘れます)。RPC の利点は速さよりも、こうした規律を自動化できることにあります。
4. REST の本質 — 6 つの制約で何が得られるか
REST は機能ではなく、禁止事項の集合です。禁止を受け入れる見返りに、自分で書いていない中間層が働いてくれます。
4.1 6 つのアーキテクチャ制約
Fielding の論文は、Web がなぜスケールしたのかを逆算して、次の制約を抽出しました。
| 制約 | 内容 | 得られるもの |
|---|---|---|
| クライアント・サーバー | UI とデータストレージの分離 | 独立した進化 |
| ステートレス | リクエスト間でサーバーがセッション状態を持たない | 水平スケール、任意のノードへルーティング可能 |
| キャッシュ可能性 | レスポンスがキャッシュ可否を自己申告する | ネットワーク往復の削減、CDN |
| 統一インターフェース | すべてのリソースが同じ操作体系に従う | 汎用クライアント・汎用中間層 |
| 階層化システム | クライアントは直接の相手が終端かどうかを知らない | プロキシー・LB・ゲートウェイの挿入が自由 |
| コードオンデマンド(任意) | サーバーがコードを送って動作を拡張できる | JS の配信。唯一の任意制約 |
ステートレスは誤解されがちです。「サーバーがデータベースを持ってはいけない」という意味ではなく、「リクエスト間のセッション状態をサーバーのメモリーに置かない」という意味です。そのため、ロードバランサーは任意のノードにリクエストを振り分けられます。
4.2 統一インターフェースの 4 つの下位制約
REST の中核でありながら、最も無視されている部分です。
- リソースの識別 — URI がリソースを一意に指します。
- 表現を通じた操作 — クライアントはリソースそのものではなく表現(JSON, XML, HTML)を受け取り、表現を送り返して更新します。
- 自己記述的メッセージ — メッセージ単体で処理方法が分かります(
Content-Type,Cache-Controlなど)。 - HATEOAS — アプリケーション状態の遷移をハイパーメディアが駆動します。
実務への影響が最も大きいのは 3 番目です。メッセージが自己記述的なので、ドメイン知識を持たないプロキシー・CDN・WAF・ブラウザーも処理に参加できます。gRPC のボディーが Protobuf の不透明なバイト列であることの代償は、ここに現れます。
4.3 HATEOAS はなぜ普及しなかったのか
理屈の上では、クライアントはエントリーポイントだけを知っていればよく、あとはレスポンスに含まれるリンクをたどります。サーバーは URL 構造を自由に変えられます。
しかし実務では、ほぼ定着しませんでした。理由は次のとおりです。
- クライアントは結局ハードコードする。リンクをたどる汎用クライアントを書くより、
/users/{id}と直接書くほうが安く済みます。 - 型付きクライアント生成と相性が悪い。OpenAPI からコードを生成すると、URL は生成時に固定されます。
- リンクの意味(link relation)を理解するにはドメイン知識が要る。
rel="cancel"が何をするかは、機械には分かりません。
例外的に成功しているのは、ページネーション(next / prev リンク)と非同期ジョブ(202 Accepted + Location でポーリング先を返す)です。どちらもクライアントが URL を組み立てられない場面なので、ハイパーメディアが役に立ちます。この 2 つに絞って採用するのが現実的でしょう。
リンクの表現には既存の形式がいくつかあります(HTTP ヘッダーなら RFC 8288 の Link、ボディーなら HAL・JSON:API・Siren)。独自形式を作る前に、これらを検討するとよいでしょう。
4.4 リソース指向と手続き指向 — 「動詞」をどう置くか
実務で最も頻繁に設計が衝突するのはここです。cancelOrder、sendEmail、reindexSearch は手続きであって、リソースではありません。
REST 側の定石は 3 つあります。
| 手法 | 例 | 使いどころ |
|---|---|---|
| 状態を表す属性の更新 | PATCH /orders/42 に status: "cancelled" | 状態遷移が単純で、副作用が少ない |
| アクションをリソース化 | POST /orders/42/cancellations | アクション自体に ID・履歴・監査ログが要る |
| カスタムメソッド | POST /v1/orders/42:cancel | 上記に収まらない。Google API 設計ガイド (AIP-136) の流儀 |
2 番目は命令を名詞に変換する方法で、多くの要求をまとめて満たせます。「注文をキャンセルする」を「キャンセル要求というリソースを作る」と読み替えると、リトライ時の冪等性(同じ ID で作れば重複しない)、非同期処理(202 Accepted を返して後で完了)、監査(誰がいつキャンセルしたか)をどれも自然に表現できます。
一方 gRPC では、この問題は起きません。CancelOrder(CancelOrderRequest) returns (CancelOrderResponse) と書けば済みます。表現力だけを比べれば、RPC のほうが素直です。
4.5 コレクションの設計 — ページネーションと部分取得
リソース指向でも手続き指向でも、最初に問題が出やすいのは一覧を返す API です。この設計はプロトコルの選択とは独立に効きます。
オフセット方式 vs カーソル方式| 観点 | オフセット(?offset=100&limit=20) | カーソル(?page_token=xxx&page_size=20) |
|---|---|---|
| 実装の容易さ | 簡単(OFFSET / LIMIT) | 面倒(ソートキーの符号化が要る) |
| 深いページの性能 | 悪い。DB は捨てる行も読む | 良い。インデックスで直接シーク |
| 挿入・削除への耐性 | 弱い。行がずれて重複・欠落する | 強い。位置ではなく値で指す |
| 「N ページ目へ飛ぶ」 | できる | できない |
| 総件数 | ページ番号 UI とは相性がよい。ただし大規模データでの正確な COUNT(*) はやはり高コスト | 高コスト(別クエリー、あるいは概算) |
「ページ 2 に同じ項目がもう一度出る」というバグの原因は、ほぼ常にオフセット方式です。一覧が更新されうるなら、カーソル方式を既定にしてください。
カーソルを実装するときは、次の 3 点を守ります。
- 安定なソートキーを使う。
created_atだけでは同時刻の行の順序が定まらないので、(created_at, id)のように一意なタイブレーカーを必ず添えます。 - トークンは不透明にする。Base64 は URL-safe な運搬形式として便利ですが、エンコードしただけでは中身を読めてしまいます。「内部形式は契約の一部ではない」と明記し、必要なら署名・暗号化するか、サーバー側に保存したハンドルを返します。構造を公開すると、クライアントが独自にトークンを組み立て始め、内部のソート順を変えられなくなります。
- トークンにクエリー条件を埋め込む。ページングの途中でフィルターやソート順が変わると結果が壊れるので、トークンに条件を持たせて不一致を検出します。
Google API 設計ガイドは、これを page_size / page_token / next_page_token という名前で標準化しています。gRPC でも REST でも同じ形が使えます。
message ListUsersRequest {
int32 page_size = 1; // 上限はサーバが決める(クライアントの言い値を信じない)
string page_token = 2; // 不透明。前回の next_page_token をそのまま返す
}
message ListUsersResponse {
repeated User users = 1;
string next_page_token = 2; // 空文字列 = 最終ページ
}部分取得(partial response)も同じ層の設計です。全フィールドを返すと重いが、専用エンドポイントは作りたくない、という要求に、各方式は次のように対応します。
| 方式 | 手段 | 難点 |
|---|---|---|
| REST | ?fields=id,name、JSON:API の sparse fieldsets | 構文が非標準。キャッシュキーが分岐する(Vary 相当の考慮が要る) |
| gRPC | google.protobuf.FieldMask をリクエストに含める | サーバー側で真面目に実装しないと単なる飾りになる |
| GraphQL | クエリーの選択セットそのもの | 仕組みが最初から中核にある |
FieldMask は読み取りの絞り込みだけでなく、更新時の意味論にも使えます。UpdateUser に update_mask を付けると、「マスクに含まれないフィールドは変更しない」ことを明示できます。REST の PATCH と PUT の違いを型で表したものと考えると分かりやすいでしょう。AIP-134 流の部分更新では、FieldMask が標準の仕組みです。また、proto3 の暗黙的プレゼンス(§5.4)では「未設定」と「ゼロ値」を区別できないため、ゼロ値への更新を正しく扱いたいフィールドには optional などの明示的プレゼンスも併用します。
5. ワイヤーの上で何が起きているか
「gRPC は速い」の中身は、(a) ヘッダー圧縮、(b) 多重化、(c) スキーマによるメタ情報の削除、の 3 つに分けられ、それぞれ独立に評価できます。
5.1 REST / JSON over HTTP/1.1
ユーザー 1 件を取得する、ごく普通のリクエストです。
GET /v1/users/42 HTTP/1.1
Host: api.example.com
Accept: application/json
Accept-Encoding: gzip, br
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
User-Agent: acme-client/1.4.2
Traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60
ETag: "a1b2c3d4"
Vary: Accept-Encoding
Content-Length: 36
{"id":42,"name":"Ada","active":true}ボディーは 36 バイトですが、リクエストヘッダーは JWT・トレースコンテキスト・User-Agent が乗ると数百バイトになり、1KB を超えることもあります。HTTP/1.1 にはヘッダー圧縮がないため、この数百バイトがリクエストのたびに、圧縮されないまま再送されます(TLS で暗号化はされますが、圧縮はされません)。マイクロサービス間の細かい呼び出しでは、ペイロードよりヘッダーのほうが大きいのが普通です。
さらに HTTP/1.1 は、1 コネクションで 1 リクエストずつしか処理できません(パイプラインは事実上使われていません)。並列度はコネクション数で稼ぐしかなく、多くのブラウザー実装は同一オリジンあたりのコネクションを 6 本程度に制限しています。
5.2 gRPC over HTTP/2 — フレームの構造
gRPC は HTTP/2 の上に薄く乗った RPC プロトコルです。同じ呼び出しは次のようになります。
この呼び出しで流れる代表的なヘッダー(必須のものと、よく使われる任意のもの)はこうなります。
:method = POST
:scheme = https
:path = /users.v1.UserService/GetUser
:authority = api.example.com
te = trailers
content-type = application/grpc+proto
grpc-timeout = 1S
grpc-encoding = identityこのうち仕様上の必須は :method / :scheme / :path / content-type と te: trailers で、grpc-timeout や grpc-encoding は任意です。これらのヘッダーには、3 つの設計判断が表れています。
:pathがサービス名とメソッド名そのもの。/{package}.{Service}/{Method}という固定形式で、URL はリソースではなく手続きを指します。te: trailersが必須。gRPC は最終的な成否をトレーラー(ボディーの後に来るヘッダー)で返します。仕様はこのヘッダーを、トレーラーを扱えない中間装置を検出する目印として要求しています。トレーラーは gRPC が HTTP/2 を前提にする理由のひとつにすぎず、多重化・フロー制御・ストリーム単位のキャンセルも同じくらい重要です。grpc-timeoutでデッドラインが伝播する。値は最大 8 桁の正整数と単位(H時 /M分 /S秒 /mミリ秒 /uマイクロ秒 /nナノ秒)の組で、1Sや980mと書きます。1000msのような書き方は無効です。REST には対応する仕組みがなく、実務では gRPC の最も大きな利点のひとつです(§7.5)。
5.3 なぜトレーラーが必要なのか
HTTP のステータスコードはレスポンスの先頭で送られます。ところがサーバーストリーミングでは、200 OK でストリームを開始したあと、100 件目を送った時点でデータベースが落ちることがあります。その時点では、送信済みのステータス行を書き換えられません。
gRPC は、ボディーの後に送るトレーラーでこの問題を解決しました。
grpc-status: 13
grpc-message: internal error while streaming rows一方、HTTP/1.1 のトレーラー(Transfer-Encoding: chunked で送るもの)は対応する実装がまばらで、当てにできません。そのため gRPC には HTTP/2 が必要になります。
5.4 Protobuf のエンコーディングを 1 バイトずつ見る
Protobuf の符号化そのものは単純です。すべてのフィールドが「タグ (フィールド番号 << 3 | ワイヤー型)」 + 「値」という形をとります。
| ワイヤー型 | 名前 | 用途 |
|---|---|---|
| 0 | VARINT | int32/int64/uint/bool/enum |
| 1 | I64 | fixed64, sfixed64, double |
| 2 | LEN | string, bytes, 埋め込みメッセージ, packed repeated |
| 3 / 4 | SGROUP / EGROUP | 非推奨(group、歴史的遺物) |
| 5 | I32 | fixed32, sfixed32, float |
Protobuf を 1 バイトずつ組み立てる
GetUser のレスポンスにあたる User{ id: 42, name: "Ada", active: true } を Protobuf でエンコードし、最後に gRPC の 5 バイトのプレフィクスを付けるまでを追います。同じ意味の JSON とバイト数を並べて比べます。
メッセージ 0 B / 合計 0 B
同じ意味の JSON
{"id":42,"name":"Ada","active":true}ただし HTTP/2 のフレームヘッダー (9 B/フレーム) と HPACK 圧縮後のヘッダーは別途乗ります。
Protobuf にはフィールド名も型名も入りません。入るのは「フィールド番号 + ワイヤー型 + 値」の三つ組だけです。両端が同じスキーマ (.proto) を持つ前提でこうしたメタ情報を省く点が、フィールド名を毎回送る JSON との違いです。
この符号化から、実務上の帰結が 3 つ導かれます。
- フィールド番号 1〜15 はタグが 1 バイトに収まります(16〜2047 は 2 バイト)。頻出フィールドや繰り返しフィールドの要素には、小さい番号を割り当てるのが定石です。
- proto3 の「暗黙的プレゼンス」では、ゼロ値のフィールドはワイヤーに出ません。
0/false/""は「送らない」ことで表現されます。そのため、「未設定」と「明示的にゼロ」を区別できません。区別が必要なら、proto3 のoptional(protoc 3.15 以降は既定で有効)を付けて明示的プレゼンスにするのが現在の定石です。ラッパー型(google.protobuf.Int32Valueなど)は、古いツールチェーンや JSON のnullとの相互運用が必要な場合に限って使う選択肢になりました。 - 直列化は決定論的ではありません。フィールドの出力順序や未知フィールドの扱いは、実装やバージョンによって変わります。したがって、Protobuf のバイト列をハッシュして署名や冪等キーに使ってはいけません。実際に事故が起きやすい落とし穴です。
5.5 サイズを実際に数える
「Protobuf は JSON より小さい」は正しいのですが、どれだけ小さくなるかはデータの形で大きく変わります。数値が主体なら大幅に小さくなり、長い自然文が主体ならほとんど変わりません。
同じデータ、違うワイヤー表現
ユーザー一覧レスポンスを JSON と Protobuf で符号化したときのバイト数を、それぞれの符号化規則を適用して正確に数えています。
Protobuf / JSON (最小化)
47%
1 レコードあたり (最小化した JSON)
85.1 B
1 レコードあたり (Protobuf)
40.0 B
先頭レコード
{"id":1,"name":"u1aaaaaa","email":"u1aaaaaa@example.com","active":true,"score":100}注意: この比較は「ボディーのバイト数」だけです。gzip / brotli で圧縮すると差は大きく縮みます — JSON で繰り返されるキー名はよく圧縮できるからです。ネットワーク帯域だけが理由なら、Protobuf に移る前にまず圧縮を有効にしてください。一方、圧縮しても残る差は、CPU コスト (パース時間) と、スキーマがもたらす保証 (型・プレゼンス・後方互換性のチェック) です。
ただし、gzip / brotli をかけると差は大きく縮みます。JSON の冗長性の大半は繰り返されるキー名にあり、これは圧縮アルゴリズムが得意とするパターンです。帯域だけが理由なら、Protobuf を導入する前に Content-Encoding: br を有効にするほうが、移行のコストをかけずにほぼ同じ効果を得られることがあります。
圧縮しても残る差は次の 2 つです。
- CPU コスト — JSON のパースには文字列走査・エスケープ処理・数値変換が伴い、長さ前置きのバイナリーより本質的に重い処理です。
- スキーマがもたらす保証 — 型・プレゼンス・後方互換性のチェックは、バイト数とは別の利点です。長期的には、こちらの価値のほうが大きいと言えます。
5.6 ブラウザーの壁 — gRPC-Web と Connect
ブラウザーの fetch / XMLHttpRequest は HTTP/2 のフレームを直接制御できず、トレーラーを読むことも、リクエストのフロー制御を操作することもできません。したがって、ブラウザーから素の gRPC は話せません。
対策は 2 つあります。
- gRPC-Web — トレーラーをボディーの末尾に特別なフレームとして埋め込む変種です。gRPC-Web を直接話せるサーバーか、ブラウザーと gRPC サーバーの間に置く変換プロキシー(Envoy の
grpc_webフィルターなど)が必要です。クライアントストリーミングと双方向ストリーミングは使えません。 - Connect — Buf が策定したプロトコルです。
POST /package.Service/Methodにapplication/jsonまたはapplication/protoを載せるだけなので、単項呼び出しは curl でそのまま叩けます。同じサーバーが gRPC・gRPC-Web・Connect の 3 プロトコルを同時に話せます。
# Connect なら、生成コードもプロキシもなしでデバッグできる
curl -X POST https://api.example.com/users.v1.UserService/GetUser \
-H "Content-Type: application/json" \
-d '{"id": 42}'curl で叩けるかどうかは、障害調査、サポート対応、外部パートナーへの説明のいずれにも影響し、運用コストを大きく左右します。
6. 契約 — IDL とスキーマ進化
6.1 .proto は「単一の真実」になれる
syntax = "proto3";
package users.v1;
message User {
int32 id = 1;
string name = 2;
string email = 3;
bool active = 4;
}
message GetUserRequest { int32 id = 1; }
message GetUserResponse { User user = 1; }
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}このファイルから、Go・TypeScript・Java・Python のクライアントとサーバーのスタブが生成されます。このとき、契約違反はコンパイルエラーになります。サーバーがフィールドを削除すればクライアントのビルドが失敗するので、CI で気づけます。
6.2 OpenAPI は多くの場合「記述」にとどまり、実装を「駆動」しない
OpenAPI(3.1 は JSON Schema 2020-12 に整合)でも、原理的には同じことができます。スキーマファーストで運用しているチームもあります。しかし現実には、次のパターンが多く見られます。
| 運用 | 何が起きるか |
|---|---|
| コードファースト(実装のアノテーションから OpenAPI を生成) | 仕様が実装に追随するので嘘はつかない。ただし破壊的変更を止める力がない |
| スキーマファースト(OpenAPI からサーバー/クライアントを生成) | gRPC と同等の規律。ただしツールチェーンの成熟度は言語差が大きい |
| 後付けドキュメント(手書きの OpenAPI) | ほぼ確実に実装と乖離する。最悪の選択肢 |
「gRPC か REST か」という議論の多くは、実際には「スキーマを強制するかどうか」の議論です。REST でもスキーマファーストにし、CI で破壊的変更を検出すれば、差は大きく縮まります。
6.3 後方互換性の規則
Protobuf の互換性ルールは、API を進化させるときの規則としてそのまま参考になります。
| 変更 | 互換性 | 注意 |
|---|---|---|
| フィールドの追加(新しい番号) | ✅ 安全 | 旧クライアントは未知フィールドとして保持または無視 |
| フィールドの削除 | ⚠️ 条件付き | 番号を必ず reserved にする。再利用は致命的 |
| フィールド名の変更 | ✅ ワイヤー的には安全 | ただし JSON マッピングと生成コードは壊れる |
| フィールド番号の変更 | ❌ 破壊的 | 別のフィールドとして解釈される |
| 型の変更 | ⚠️ 条件付き | int32/int64/uint32/uint64/bool はワイヤー互換だが、範囲外の値が切り詰められて意味が変わりうるので「安全」ではない。sint32/sint64 は相互互換だが他の整数型とは非互換 |
optional の追加 | ⚠️ 注意 | プレゼンス意味論が変わる |
| enum 値の追加 | ⚠️ 注意 | 旧クライアントは未知の値を受け取る。既定値 0 を UNSPECIFIED にしておく |
既存フィールドを oneof へ移動 | ⚠️ 条件付き | 明示的プレゼンスを持つ単一フィールド(または extension)を新しい oneof に移す場合のみ安全。既存の oneof への移動や、複数フィールドの移動は非安全 |
repeated ↔ 単一 | ⚠️ 条件付き | スカラーの packed 表現が絡むと壊れる。メッセージ型なら比較的安全だが意味論は変わる |
message User {
reserved 3; // 削除したフィールド番号を封印
reserved "email"; // 名前も封印
int32 id = 1;
string name = 2;
}もうひとつの落とし穴は ProtoJSON(Protobuf の正規 JSON マッピング)です。grpc-gateway・Connect・protojson 経由で JSON を出している場合、「ワイヤー互換だから安全」とは言えません。
- フィールド名の変更は、ワイヤー上は無害でも JSON のキーが変わるので破壊的です。どうしても改名するなら、
json_nameで旧名を固定します。 - enum は既定で名前(文字列)として出力されます。そのため、値の改名は JSON 側では破壊的変更です。
- enum の値追加も JSON では危険です。名前で直列化されるので、旧パーサーが未知の名前を見て失敗することがあります。全クライアントが enum を整数として出力する設定に揃えている場合に限り、値の追加は JSON ワイヤー上も安全になります。未知の数値をどう表現するかは、言語ランタイムごとに異なります。
JSON API 側では、対応する規則は次のとおりです。
- 追加は安全です。ただし、クライアントが未知フィールドで例外を投げる実装だと壊れます(Jackson の
FAIL_ON_UNKNOWN_PROPERTIESが既定で有効、など)。未知フィールドを無視する寛容な読み取りを明示的に設定しておきます。 - 削除と型変更は破壊的です。
"id": 42を"id": "42"にする変更は、動的型付け言語ではエラーにならないまま挙動を壊します。 - enum 相当の文字列に新しい値を足す変更も、破壊的になりえます。
6.4 バージョニング — 3 つの流儀
| 方式 | 例 | 評価 |
|---|---|---|
| URL パス | /v1/users, /v2/users | 最も一般的。可視性が高く、ルーティングが単純。粒度が粗い(API 全体が一斉に上がる) |
| メディアタイプ | Accept: application/vnd.acme.user+json;version=2 | REST 原理主義的には正しい。ただし CDN のキャッシュキーや WAF が対応しづらい |
| フィールドレベル進化 | 追加のみ、削除しない | Protobuf の既定戦略。バージョンという概念自体を消す |
Protobuf のパッケージ名に users.v1 と入れるのは、フィールドレベル進化で吸収しきれない変更が起きたときだけ、パッケージごと新設するという戦略です。実質的には URL パス方式と同じですが、新設が必要になる頻度がはるかに低いのが利点です。
6.5 契約の共有範囲に気をつける
.proto を全社で 1 つのリポジトリーに集約するのは、一見すると理想的です。しかし実際には密結合を生みます。あるチームが common.proto を変更すると、全社のビルドが影響を受けます。
現実的な指針は次のとおりです。
- サービスごとに
.protoを所有する。共通型(Money,Timestampなど)は最小限に留め、既存のgoogle.protobuf.*やgoogle.type.*を優先します。 - スキーマレジストリーで配布する(Buf Schema Registry など)。コピー&ペーストも巨大なモノレポも避けます。
- CI に破壊的変更検出を入れる(
buf breaking)。gRPC の運用で最も費用対効果の高い設定です。
レジストリーは単なる置き場所ではなく、ガバナンスの仕組みとして設計してください。最低限、次の 5 点を決めます。
| 論点 | 決めること |
|---|---|
| 所有権 | モジュール(≒サービス)ごとに 1 チームが所有し、そのチームだけが push できる |
| 破壊的変更ポリシー | BSR 側の breaking-change check(Enterprise の専用/プライベート BSR インスタンスで利用可)は、有効にすると FILE(既定)または WIRE_JSON を強制します。ローカルの buf breaking なら PACKAGE / WIRE も選べます。安定版パッケージは後方互換を既定にし、避けられない破壊的変更は新パッケージ(v2)として出す |
| レビュー | 公開 API に相当する .proto の変更は、消費者側のレビュアーを必須にする |
| 成果物 | 生成コードはレジストリーから配布する。各チームが protoc を手元で回してバージョンがばらつく状態を避ける |
| 例外の出し方 | 破壊的変更をどうしても通す手続き(誰が承認し、どう告知し、いつ実行するか)を明文化する |
最後の項目が抜けたレジストリー運用は、いずれ形骸化します。「絶対に壊せない」ルールは、壊す必要が生じた瞬間に迂回されるからです。迂回路を正規の手続きとして用意しておくほうが、結果的にルールは守られます。
6.6 廃止の作法 — API のライフサイクル
追加は簡単ですが、削除は困難です。削除の手順を最初に決めておかないと、API は増える一方になります。廃止を機械可読に伝える手段は、層ごとに用意されています。
| 層 | 手段 | 効果 |
|---|---|---|
| HTTP | Deprecation ヘッダー(RFC 9745。廃止が有効になる日時で、過去でも未来でもよい)+ Sunset ヘッダー(RFC 8594、提供停止日時)+ Link の rel="deprecation" | クライアントがログ・アラートを自動化できる |
| OpenAPI | deprecated: true | 生成クライアントに警告が出る |
| Protobuf | option deprecated = true;(フィールド・メソッド・メッセージ単位) | 生成コードにコンパイラー警告が付く |
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse) {
option deprecated = true; // 生成コードに警告が出る
}
}そして、最も欠かせないのが利用状況の観測です。古いエンドポイントを誰がまだ呼んでいるかを、呼び出し元の識別子(User-Agent、API キー、クライアント ID)とともにメトリクス化してください。これがないと、廃止日を決めることも、決めた日に実行することもできません。廃止できない API は、実質的に永久に保守し続けることになります。
6.7 契約を守らせる — CI に置くべき検査
スキーマがあっても、CI で検査しなければただのドキュメントです。契約が壊れたことに本番で気づくか、CI で気づくかで、契約という仕組みの価値がほぼ決まります。
検査は 3 層に分かれます。
| 層 | 何を検出するか | 道具の例 |
|---|---|---|
| 構文・スタイル | 命名規約違反、必須コメント欠落 | buf lint、Spectral(OpenAPI) |
| 破壊的変更 | フィールド削除、番号変更、型変更 | buf breaking(前バージョンと比較)、oasdiff |
| 振る舞い | 「スキーマは合っているが挙動が違う」 | 消費者駆動契約テスト(Pact など)、生成クライアントのコンパイル検査 |
上の 2 層は自動化が容易で、少ない手間で多くの問題を検出できます。.proto を採用したのに buf breaking を CI に入れていないなら、gRPC の主要な利点の半分を捨てています。
3 層目は、スキーマでは守れない部分を扱います。スキーマが守れるのは形だけです。次のような変更はスキーマの上では問題がなくても、消費者を壊します。
- 今まで必ず入っていた省略可能フィールドが、入らなくなった。
- ソート順が変わった。ページネーションの安定性が失われた。
- 「空配列」を返していたところが
nullを返すようになった。 - エラーコードが
NOT_FOUNDからPERMISSION_DENIEDに変わった(情報漏洩対策としては正しいが、クライアントの分岐は壊れる)。
消費者駆動契約テスト(consumer-driven contract testing)は、消費者側が自分の依存している具体的な期待をテストとして書き、提供者側の CI がそれを検証する方式です。代表的な道具は Pact ですが、道具よりも、提供者は全機能ではなく、実際に使われている部分を守ればよいという考え方が重要です。
現実的な最小構成は次のとおりです。
- 提供者の CI で
buf breaking(または OpenAPI の差分検査)を必須にする。 - 消費者の主要なリクエスト/レスポンス例をゴールデンファイルとして提供者のリポジトリーに置き、変更時に差分レビューを強制する。
- 重要な消費者については、生成クライアントを提供者の CI でコンパイルする。
この構成で、事故の大半は本番前に止められます。
7. エラー設計 — ここが実務の分かれ目
エラーが人間向けの文字列だけで、機械が分岐できる構造になっていなければ、リトライもフォールバックも書けません。
7.1 HTTP ステータスコードの意味論
REST の強みは、エラーの大分類がプロトコルに組み込まれていることです。LB、CDN、SDK のリトライ層といった中間層が、中身を知らずに判断できます。
| コード | 意味 | リトライ |
|---|---|---|
| 400 | リクエストが構文的に不正 | ❌ 不可(直さないと同じ) |
| 401 / 403 | 未認証 / 権限なし | ❌(401 はトークン更新後なら可) |
| 404 | リソースなし | ❌ |
| 409 | 状態の競合 | ⚠️ 状態次第 |
| 412 | 前提条件失敗(If-Match 不一致) | ⚠️ 再読み込み後 |
| 422 | 構文は正しいが意味的に処理不能 | ❌ |
| 429 | レート制限 | ✅ Retry-After に従う |
| 500 | サーバー内部エラー | ⚠️ 冪等な操作のみ |
| 503 | 一時的に利用不可 | ✅ Retry-After |
よくある誤用が 2 つあります。
- 常に 200 を返し、ボディーの
{"error": ...}で失敗を伝える。中間層とクライアント SDK のリトライ機構がまったく働かなくなります。GraphQL の実行時エラーが 200 +errorsになりがちなのは単一エンドポイント設計の帰結ですが、通常の HTTP API がそれを真似る理由はありません。 - 4xx と 5xx の取り違え。バリデーションエラーを 500 で返すと、監視のエラー率が汚染され、無意味なリトライが発生し、SLO が壊れます。4xx はクライアント側の原因、5xx はサーバー側の原因という区別を必ず守ってください。
7.2 エラーボディーの標準 — RFC 9457
エラーの詳細をどう構造化するかには標準があります。RFC 9457 Problem Details for HTTP APIs(RFC 7807 の後継)です。
{
"type": "https://api.example.com/problems/insufficient-funds",
"title": "Insufficient funds",
"status": 409,
"detail": "Account 42 has a balance of 30 USD; 50 USD required.",
"instance": "/accounts/42/transfers/8f2b",
"balance": 30,
"required": 50
}Content-Type: application/problem+json で返します。type は機械可読な安定した識別子で、クライアントはこれを見て分岐します。detail は人間向けの説明なので、これを見て分岐してはいけません。文言を変えただけで壊れるからです。
7.3 gRPC のエラーモデル
gRPC は 17 個の固定ステータスコード(0〜16)を持ちます。HTTP より粒度が細かく、リトライを判断する材料として使いやすい設計です。
| コード | 名前 | 典型的な意味 |
|---|---|---|
| 0 | OK | 成功 |
| 1 | CANCELLED | 呼び出し側が中断 |
| 2 | UNKNOWN | 不明なエラー。別のエラー空間から変換された場合も含む |
| 3 | INVALID_ARGUMENT | 引数が不正(状態に依存しない) |
| 4 | DEADLINE_EXCEEDED | デッドライン超過 |
| 5 | NOT_FOUND | 対象なし |
| 6 | ALREADY_EXISTS | 既に存在 |
| 7 | PERMISSION_DENIED | 権限なし |
| 8 | RESOURCE_EXHAUSTED | クォータ超過・レート制限 |
| 9 | FAILED_PRECONDITION | 前提状態が満たされない(リトライしても無駄) |
| 10 | ABORTED | 競合により中断(読み直して書き直すなど、上位レベルでのリトライが有効) |
| 11 | OUT_OF_RANGE | 範囲外 |
| 12 | UNIMPLEMENTED | 未実装 |
| 13 | INTERNAL | 内部エラー |
| 14 | UNAVAILABLE | 一時的に利用不可(リトライしてよい) |
| 15 | DATA_LOSS | 回復不能なデータ損失・破損 |
| 16 | UNAUTHENTICATED | 未認証 |
9 / 10 / 11 の区別は、gRPC のエラー設計で最も価値のある部分です。公式ガイダンスでは、FAILED_PRECONDITION はシステム状態が直るまでクライアントがリトライすべきでないもの、ABORTED は読み直して書き直すといった上位レベルでのリトライが有効なもの、OUT_OF_RANGE は状態が変われば成功しうるもの、と説明されています。この 3 分類があれば、クライアントのリトライロジックの大部分は機械的に書けます。ただし、コードだけで自動リトライの可否が決まるわけではありません。非冪等な操作では、UNAVAILABLE であってもリクエストが届いていた可能性が残るからです(§7.4)。
構造化された詳細は google.rpc.Status の details に載せます。
// google/rpc/error_details.proto が標準の型を提供する
// ErrorInfo, RetryInfo, QuotaFailure, BadRequest, PreconditionFailure, ...
message Status {
int32 code = 1;
string message = 2;
repeated google.protobuf.Any details = 3;
}RetryInfo では何秒後にリトライすべきかを、QuotaFailure ではどのクォータに引っかかったかを、BadRequest ではどのフィールドがなぜ不正かを、機械可読な形で返せます。REST 側で同じことをするには、RFC 9457 の拡張メンバーを使って自分で定義することになります。
具体的に見比べると、両者は思ったより近いことが分かります。バリデーションエラーを返す場合は次のようになります。
// REST — RFC 9457 + 拡張メンバー "errors"
{
"type": "https://api.example.com/problems/validation-error",
"title": "Your request is not valid.",
"status": 422,
"errors": [
{ "detail": "must be a positive integer", "pointer": "#/amount" },
{ "detail": "must be a valid ISO 4217 code", "pointer": "#/currency" }
]
}// gRPC — code = INVALID_ARGUMENT, details に BadRequest を載せる
// (抜粋。現在の FieldViolation には reason / localized_message もある)
message BadRequest {
message FieldViolation {
string field = 1; // "amount"
string description = 2; // "must be a positive integer"
}
repeated FieldViolation field_violations = 1;
}構造はほぼ同じです。違うのは、標準の型が最初から用意されているか、チームで決めるかという点だけです。RFC 9457 では、拡張メンバーはそれぞれの問題タイプが定義します。上の例の errors と pointer も、RFC の例に出てくる架空の問題タイプが定めた名前です。そのため、組織として 1 つに決めて全 API で守る必要があります。決めずに始めると、サービスごとに errors / violations / fieldErrors が乱立し、クライアント側の共通ハンドラーが書けなくなります。
7.4 リトライと冪等性 — RPC の急所
§3.1 の部分故障が、ここで実際の問題になります。タイムアウトしたリクエストは、実行されたかどうか分かりません。
REST は HTTP のメソッド意味論(RFC 9110)でその一部を解決しています。
- 安全 (safe):
GET,HEAD,OPTIONS,TRACE。状態を変えません。 - 冪等 (idempotent): 上記に加えて
PUT,DELETE。何度実行しても結果は同じです。 - 既定では冪等と定義されない:
POST,PATCH。ただし、個々のリソース設計や冪等キーによって、実質的に冪等にすることはできます。
そのため、汎用の HTTP クライアントは「通信失敗でレスポンスを読めなかった冪等メソッドはリトライの候補にし、非冪等メソッドは追加の知識なしに自動リトライしない」と判断できます。この知識はプロトコルに組み込まれているので、アプリケーション側で書く必要がありません。
POST を冪等にしたいときは、クライアントが生成したキーを使います(Stripe などが広めた Idempotency-Key ヘッダーです。IETF HTTPAPI WG の Internet-Draft として標準化作業中で、まだ RFC にはなっていません)。
POST /v1/payments HTTP/1.1
Idempotency-Key: 8f2b1c40-3d2e-4f9a-9b1e-6c7a0d5e2f13
Content-Type: application/json
{"amount": 5000, "currency": "JPY"}冪等キーを機能させるには、サーバー側に次の 4 つの規律が必要です。
- キーのスコープ: 「エンドポイント × 認証主体 × キー」の組で一意にします。グローバルに一意とすると、別テナントのキーと衝突して事故が起きます。
- リクエストの指紋: ボディーの指紋(ハッシュ)を一緒に保存し、同じキーで異なるボディーが来たら
422 Unprocessable Contentで拒否します(Idempotency-Key draft の規定)。最も悪いのは、黙って古い結果を返すことです。 - レスポンスの再生: 初回の応答(ステータスとボディー)を保存し、再送に対しては保存した応答をそのまま返します。
- TTL と進行中状態: 24 時間程度で失効させ、処理中に再送が来たら
409 Conflictを返して二重実行を防ぎます。
gRPC 側では、メソッド名から冪等性を推論できません(CreateUser が冪等かどうかは名前からは分かりません)。そのため、サービス設定でリトライポリシーを明示します。
{
"methodConfig": [{
"name": [{ "service": "users.v1.UserService", "method": "GetUser" }],
"retryPolicy": {
"maxAttempts": 4,
"initialBackoff": "0.1s",
"maxBackoff": "2s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE", "RESOURCE_EXHAUSTED"]
}
}]
}リトライには必ず指数バックオフとジッターを入れ、全体の試行回数にも上限を設けてください。素朴なリトライは、部分的な障害を全体障害に増幅します(リトライストーム)。gRPC のリトライスロットル(retryThrottling)は、この増幅を抑えるための仕組みです。この増幅現象についてはLLM ロードバランシングの記事でも扱いました。
7.5 デッドライン伝播 — REST に対応物がない仕組み
gRPC のクライアントはデッドラインを設定し、それが grpc-timeout ヘッダーで下流に自動伝播します。
Client (deadline 1000ms)
└─ Gateway 残り 980ms → grpc-timeout: 980m
└─ OrderSvc 残り 940ms → grpc-timeout: 940m
└─ DB 残り 900msデッドラインがないシステムでは、クライアントがとうに諦めた処理を、下流がいつまでも続けます。障害時にこれが起きると、無駄な処理でリソースが枯渇し、回復が遅れます。
ただし、伝播は自動で完結するわけではありません。受け取った呼び出しコンテキスト(Go の ctx、Java の Context)を下流の呼び出しにそのまま渡して、初めて機能します。ワーカープールに処理を投げる、context.Background() で作り直す、非同期のファイアアンドフォーゲットにする、といった操作はいずれも、気づかないうちにデッドラインの鎖を断ち切ります。コンテキストを引き回す規律が一緒に必要です。
REST でも同じことは実現できますが、自分で作る必要があります。標準ヘッダーは存在しないので、X-Request-Deadline のような独自ヘッダーを定義し、全サービスのミドルウェアで解釈・減算・伝播を実装します。ミドルウェアの合成には、ミドルウェアチェーンパターンで扱った構造がそのまま使えます。
8. 通信パターン — 形の違い
REST が持つのは request/response の 1 形態だけで、それ以外は回避策で補います。gRPC は 4 つの形態を型として持ちます。
8.1 gRPC の 4 つの形態
service Chat {
rpc Send(Msg) returns (Ack); // 単項 (unary)
rpc Subscribe(Filter) returns (stream Event); // サーバストリーミング
rpc Upload(stream Chunk) returns (Summary); // クライアントストリーミング
rpc Session(stream Msg) returns (stream Msg); // 双方向
}いずれも HTTP/2 の 1 ストリームにマップされ、順序保証とフロー制御(WINDOW_UPDATE)が効きます。キャンセルは RST_STREAM で即座に下流まで伝わります。
8.2 REST 側の対応物
| 用途 | REST での実現 | 難点 |
|---|---|---|
| サーバー → クライアントの継続的通知 | SSE(Server-Sent Events、text/event-stream) | 単方向のみ。HTTP/1.1 ではコネクションを占有 |
| 双方向 | WebSocket | HTTP から別プロトコルへアップグレード。もはや REST ではない |
| 大きなアップロード | チャンク転送、マルチパート、PATCH の追記 | 再開・進捗の標準がない |
| 非同期ジョブ | 202 Accepted + Location + ポーリング | ポーリング間隔とスケールのトレードオフ |
| イベント通知 | Webhook | 到達保証・署名検証・リトライを自作する必要がある |
なお、LLM のトークンストリーミングでは、今のところ SSE が事実上の標準です。OpenAI 互換 API や AG-UI は SSE をそのまま使います。MCP は少し事情が違い、初期の「HTTP+SSE」トランスポートは Streamable HTTP に置き換えられました。現在の MCP では、SSE は単一の POST に対してサーバーが複数のメッセージを返すときに選べる応答形式であり、トランスポートそのものではありません。いずれの場合も、ブラウザーから直接扱え、HTTP のインフラをそのまま使える点が採用の理由になっています。
8.3 ストリームをメッセージキューの代わりにしてはいけない
gRPC の双方向ストリーミングには耐久性がありません。
- コネクションが切れれば、メッセージは消えます。
- サーバーが再起動すれば、全ストリームが切れます。
- バックプレッシャーはありますが、永続バッファーはありません。
- クライアントが遅いと、サーバー側にメモリーが溜まります。
Pub/Sub が必要なら、Kafka、NATS、マネージドな Pub/Sub サービスを使うべきです。gRPC ストリームが適するのは、そのコネクションが生きている間だけ意味があるデータに限られます。ライブテレメトリー、進行中のジョブの進捗、対話中のトークンなどです。
8.4 「おしゃべりな API」問題
RPC 的な設計では、細かいメソッドが増えがちです。
GetUser(42) → GetUserOrders(42) → GetOrderItems(o1), GetOrderItems(o2), ...1 回の呼び出しが 1ms でも、100 回呼べば 100ms です。N+1 問題は、データベースへのクエリーと同じく RPC の呼び出しでも起きます。対処は 3 つあります。
- バッチメソッドを用意する(
BatchGetOrders)。Google API 設計ガイドの標準パターンです。 - 集約層(BFF: Backends for Frontends)を置く。クライアントに近い場所で 1 回の呼び出しにまとめます。
- クライアントに選ばせる(GraphQL、あるいは REST の
?fields=/?expand=)。
8.5 フロー制御とバックプレッシャー
ストリーミングを使うなら、速い送り手と遅い受け手の問題は避けられません。ここは、gRPC と REST で実際に差が出る数少ない機能面の違いです。
HTTP/2 は 2 階層のフロー制御ウィンドウを持ちます。コネクション単位とストリーム単位です。受け手は自分が処理できた分だけ WINDOW_UPDATE を返し、送り手はウィンドウを使い切ると送信をブロックします。つまり、バックプレッシャーがトランスポートに組み込まれており、遅い受け手は自動的に送り手を減速させます。
ただし、その効き方はランタイムの実装とバッファー設定に依存します。多くの gRPC 実装では、アプリケーションが Recv / read を進めないと、最終的に HTTP/2 のウィンドウが戻らず、送信側に圧力がかかります。よくある壊し方は次の 2 つです。
- 受信ループで即座に内部キューへ積む。トランスポートから見ると消費済みなのでウィンドウが開き続け、バックプレッシャーが消えてアプリのメモリーが溢れます。バッファーは有界にし、満杯になったら読むのを止めてください。
- 送信側でノンブロッキング送信をキューに積み続ける。Go の
stream.Sendはブロックしますが、言語によってはバッファーに積むだけの API があり、同じことが起きます。
REST 側では、SSE も標準の WebSocket API も、イベント単位のアプリケーションレベルのバックプレッシャーを持ちません。WebSocket の bufferedAmount はローカル送信キューの観測には使えますが、自動的に送信を絞ってはくれません(WebSocketStream はバックプレッシャーを扱えますが、まだ標準的な選択肢とは言えません)。SSE を HTTP/2 で運べば、トランスポート層のフロー制御は効きます。いずれの場合も、購読レートの指定、ack、サンプリング、間引きといったイベントを絞る仕組みは自分で設計する必要があります。
ストリーミングでは、送れるだけ送る動作を既定にしないでください。遅い消費者 1 つがサーバーのメモリーを使い果たし、他の全クライアントに影響するのが典型的な障害の形です。有界バッファーと明示的な切断ポリシー(一定時間追いつかない購読者は切断する)を組み合わせてください。
9. パフォーマンス — 「速い」の中身を分解する
直列化フォーマットがレイテンシーに占める割合は、ペイロードの大きさや、RTT・バックエンド処理の長さによって変わります。§9.1 のラボの既定値(RTT 1.5ms、バックエンド処理 8ms、40KB の JSON)で計算すると、直列化とパースにかかるのは全体約 10ms のうち約 0.3ms で、割合は約 3% です。フォーマットを選ぶ前に、どこで時間がかかっているかを測ってください。
9.1 レイテンシー予算を分解する
レイテンシー予算はどこに消えるのか
「gRPC は REST より N 倍速い」というベンチマークが何を測っているのかを分解します。スライダーを動かして、直列化コストが全体に占める割合を確かめてください。
短縮率: 4.6%(10.14 ms → 9.68 ms)
フォーマットを替えても短縮は 5% 未満です。時間の大半を占めるのは、RTT やバックエンド処理などフォーマットで変わらない項です。まず DB とネットワーク経路を調べてください。
前提: 直列化スループットを JSON 250 MB/s、Protobuf 1200 MB/s とし、送信側の直列化と受信側のパースの 2 回分を計上しています (いずれも桁の目安で、ランタイムとスキーマ形状で大きく変わります)。転送は帯域だけの単純モデルで、TCP の輻輳制御・スロースタート・HTTP/2 のフロー制御ウィンドウは無視しています。実測ではこれらが効くため、大きなレスポンスでは転送項がここより大きくなりがちです。ヘッダーのバイト数 (HPACK による圧縮) も計上していません。
スライダーを動かすと分かるとおり、RTT とバックエンド処理が支配的な領域では、フォーマットを変えても総レイテンシーはほとんど変わりません。「gRPC は REST の 7 倍速い」といったベンチマークは、たいてい次のいずれかの条件で測られています。
- ローカルホスト(RTT ≈ 0)で測っている。ネットワークの項が消え、直列化の比重が不自然に上がります。
- バックエンド処理が空(エコー)である。支配的な項が消えます。
- ペイロードが巨大である。転送とパースが支配的になります。
- HTTP/1.1 側でコネクションを再利用していない。ハンドシェイクのコストが上乗せされます。
- ヘッダーが大きい小さなリクエストが多数ある。HTTP/2 の HPACK が効きます(この差は本物です)。
最後の条件は現実にもよくあります。マイクロサービス間で JWT 付きの小さなリクエストを毎秒数万回送る状況では、HPACK によるヘッダー圧縮の効果は無視できません。ただし、これは gRPC が速いのではなく HTTP/2 が効いているのであり、REST を HTTP/2 で運べば同じ効果が得られます。
9.2 実際に効く順序
現場で効果が大きい順に並べると、おおむね次のようになります。
- 呼び出し回数を減らす(バッチ化、キャッシュ、N+1 の除去)。桁違いに効きます。
- コネクションを再利用する(keep-alive、コネクションプール)。ハンドシェイクには RTT の数倍かかります。
- 圧縮を有効にする。JSON なら大きく効きます。
- HTTP/2 以降を使う。多重化とヘッダー圧縮が効きます。
- ペイロードを削る(不要なフィールドを返さない)。
- 直列化フォーマットを変える。検討するのはここまで来てからです。
実際には、6 番から始めるプロジェクトが少なくありません。測定が要らず、技術選定として決めやすいからです。しかし、費用対効果は最も低い選択です。
9.3 スループットとテール
平均レイテンシーではなく p99 を見ると、別の要素が効いてきます。
- GC 圧: JSON のパースは短命オブジェクトを大量に生みます。Protobuf の生成コードは事前に確保した構造体に直接書き込むので、アロケーションが少なくて済みます。高スループット環境では、これが p99 に効くことがあります(GC の記事も参照)。
- HTTP/2 の TCP レベルの HoL(Head-of-Line)ブロッキング: パケットロスが起きると、1 本の TCP 接続上の全ストリームが止まります。HTTP/3 (QUIC) はこれを解消しますが、gRPC over HTTP/3 の対応状況は実装によって差があります。
- コネクション数と輻輳制御: HTTP/2 は接続数を減らすので輻輳ウィンドウの立ち上がりが速い一方、1 本が詰まったときの影響範囲は大きくなります。
9.4 まともなベンチマークの条件
「gRPC vs REST」のベンチマークは、条件が書かれていなければほとんど意味がありません。自分で測るときも、他人の数字を読むときも、次の点を確認してください。
- 両者とも同じ HTTP バージョンか。HTTP/2 の gRPC と HTTP/1.1 の REST を比べているなら、測っているのは主に HTTP のバージョン差です。
- コネクションは温まっているか。計測前にウォームアップしているか、TLS ハンドシェイクを毎回含めていないかを確認します(含めるなら、そのことを明記します)。
- JIT やランタイムは温まっているか。JVM・.NET・V8 は、最初の数千リクエストが遅くなります。
- 圧縮の有無を揃えているか。JSON に gzip をかけずに Protobuf と比べるのは公平ではありません。
- ペイロードは現実的か。数値だけの 20 バイトのメッセージと、長い自然文を含む 200KB のレスポンスとでは、結論が逆になります。サイズの分布ごと再現してください。
- バックエンド処理が入っているか。エコーサーバーでのベンチマークは直列化のマイクロベンチマークであり、API のベンチマークではありません。
- 負荷生成側が飽和していないか。クライアントの CPU が上限に張り付いていると、測っているのはクライアントの性能です。
- 平均ではなく分布を見ているか。p50・p99・p99.9 を出してください。平均だけの比較では、テールで起きる違いがすべて隠れます。
- コーディネーテッド・オミッションを避けているか。前の応答を待って次を送る閉ループの負荷生成器は、サーバーが詰まった瞬間に送信も止まるため、テールレイテンシーを構造的に過小評価します。
このリストを満たしていない数字からは、その環境ではそうだったということ以上は分かりません。
9.5 圧縮 — 効く場所と、逆効果になる場所
§5.5 では、まず圧縮を試すよう勧めました。ただし、圧縮もどこでも効くわけではなく、仕組みも REST と gRPC で異なります。
| 層 | REST | gRPC |
|---|---|---|
| 交渉 | Accept-Encoding / Content-Encoding(RFC 9110) | grpc-accept-encoding / grpc-encoding |
| 単位 | レスポンス全体 | メッセージ 1 件ごと(フレーミングの圧縮フラグで示す) |
| 混在 | 不可(1 レスポンス 1 方式) | メッセージごとに「圧縮する/しない」を切り替え可能。ただし圧縮方式自体はその RPC の grpc-encoding で決まり、同一ストリーム内で方式を自由に変えられるわけではない |
| ヘッダー | HTTP/1.1 は非圧縮 / HTTP/2 は HPACK | HPACK(HTTP/2 なので常に効く) |
gRPC はメッセージ単位で圧縮するので、ストリーミングでは大きなメッセージだけをその RPC で選ばれた方式で圧縮し、小さな制御メッセージは圧縮フラグを 0 にしたまま送れます。
小さいメッセージでは、圧縮が逆効果になります。gzip の最小ラッパーは 10 バイトのヘッダーと 8 バイトのトレーラーで、そこに deflate 本体のオーバーヘッドも加わります。数十バイトのメッセージは、圧縮するとかえって大きくなることが珍しくありません。さらに CPU 時間とアロケーションも増えるため、高頻度で小さな RPC では圧縮を切ったほうが速くなります。多くの実装が、一定サイズ以上だけを圧縮する閾値を持っているのはこのためです。
もうひとつ、セキュリティー上の注意があります。攻撃者が一部を制御できる内容と秘密情報を同じ圧縮コンテキストに入れると、圧縮率の変化から秘密を推定されるおそれがあります(CRIME / BREACH 系の攻撃)。BREACH の著者らによれば、HTTP 圧縮されたレスポンスのボディーにユーザー入力の反射と秘密情報(CSRF トークンなど)が同居していると、秘密を 1 文字ずつ推定できます。攻撃者に必要なのは、被害者のリクエストに推測した文字列を混ぜることと、暗号化された応答の長さを観測することです。著者らが挙げる対策には、そうしたレスポンスの圧縮を無効にする、秘密情報とユーザー入力を別のレスポンスに分ける、秘密情報をリクエストごとに乱数でマスクする、などがあります。
実務的な既定値は次のとおりです。
- 1KB 未満は圧縮しない(閾値はワークロードに合わせて調整します)。
- テキスト主体の大きなレスポンスには brotli(
br)を使う(静的なコンテンツは高い圧縮レベル、その場で圧縮する動的なレスポンスは低めのレベル)。 - 既に圧縮済みのバイト列(画像・動画・zip)は再圧縮しない。
- gRPC の内部通信では、まず無圧縮で測る。ペイロードが小さければ、圧縮しないほうが速いことが多くあります。
10. キャッシュ — REST だけが持つ最大の優位
HTTP キャッシュを使えば、その分のコードを書かずに済みます。gRPC にはこの層がありません。
10.1 何が無償で手に入るのか
Cache-Control を正しく返すだけで、次の層がすべて協調して働きます。
これらの層はアプリケーションのドメインをまったく知らないまま、レスポンスの自己記述だけで動きます。§4.2 の「自己記述的メッセージ」という制約が、実際の利益になる場面です。
主要なディレクティブ(RFC 9111)は次のとおりです。
Cache-Control: public, max-age=300, stale-while-revalidate=60
ETag: "v3-a1b2c3"
Vary: Accept-Encoding, Accept-Languagepublic/private: 共有キャッシュ(CDN)に置いてよいかを示します。ユーザー固有の認証付きレスポンスにpublicを付けると、情報漏洩になります。stale-while-revalidate(RFC 5861): 期限切れのコピーをすぐに返しつつ、裏で更新します。テールレイテンシーの改善によく効きます。Vary: キャッシュキーに含めるリクエストヘッダーです。指定が漏れると、言語・圧縮方式・認可条件の異なるレスポンスが混ざる事故に直結します。
10.2 条件付きリクエストと楽観ロック
ETag は帯域の削減に加えて、更新時の競合検出にも使えます。
GET /v1/users/42
→ 200 OK, ETag: "v3"
# 別のクライアントが先に更新した場合
PUT /v1/users/42
If-Match: "v3"
→ 412 Precondition Failedこれは、分散システムにおける楽観的並行制御そのものです。gRPC で同じことをするなら、リクエストメッセージに version フィールドを持たせ、サーバーで比較する処理を自分で書きます。REST ではヘッダー 1 つで済みます。
10.3 gRPC 側の現実
gRPC は POST を使い、ボディーは不透明なバイナリーで、レスポンスに標準のキャッシュ意味論がありません。そのため、次のようになります。
- CDN は事実上使えません。
- 中間キャッシュは効きません。
- キャッシュは、クライアント内かサービス内(Redis など)に自分で作ります。
この非対称性が、公開 API で、読み取りが中心で、キャッシュできるなら REST が強いという結論の技術的な根拠です。逆に、内部向けで書き込みが中心、毎回最新が必要な場合は、キャッシュの利点がなくなり、gRPC の欠点にもなりません。
11. 運用 — ここで多くのチームがつまずく
gRPC の導入コストの大半は、プロトコルそのものではなく周辺の仕組みにあります。
11.1 ロードバランシングの落とし穴
gRPC の導入で最もよく起きる問題です。
gRPC は 1 本の HTTP/2 コネクションを長時間維持し、その上に全リクエストを多重化します。L4 ロードバランサー(TCP レベル)はコネクションを分散するので、コネクションが張られた後はそのチャネル上のリクエストが同じバックエンドに送られ続けます。トラフィックの大半が少数のクライアントから来る構成では、バックエンドを 10 台に増やしても負荷が 1 台に偏ることがあります(クライアント数が多ければコネクション単位でならされるので、症状は緩和されます)。
対処は 3 つあります。
| 手法 | 内容 | 注意点 |
|---|---|---|
| L7 プロキシー | Envoy / linkerd / NGINX が HTTP/2 を終端し、リクエスト単位で分散 | ホップが 1 つ増える |
| クライアントサイド LB | クライアントが全バックエンドに接続し、自分で選ぶ(gRPC の round_robin、xDS 統合) | サービスディスカバリーが必要 |
| コネクション寿命の上限 | サーバー側で MAX_CONNECTION_AGE(+ grace)を設定し、定期的に再接続させる | 上 2 つの補助。単体では不十分 |
クライアントサイド LB を選ぶ場合、実装の細部が挙動を決めます。
- 名前解決の方式。gRPC の既定リゾルバーは DNS で、解決結果を無期限にキャッシュしない代わりに、頻繁に再解決もしません。Kubernetes の通常の
Service(ClusterIP)は単一の仮想 IP を返すため、クライアントにはバックエンドが 1 つしかないように見え、L4 分散に逆戻りします。Headless Service(clusterIP: None)にして全 Pod IP を返すのが定石です。 - 負荷分散ポリシー。既定は
pick_first(最初に接続できた 1 台を使い続ける)です。分散させたいなら、明示的にround_robinを選ぶ必要があります。クライアントサイド LB にしたのに負荷が偏る場合、原因はたいていこれです。 - xDS。Envoy 由来の制御プレーンプロトコルで、重み付け、ゾーン優先、アウトライア検出といった負荷分散ポリシーを、サイドカーなしで gRPC クライアントに配れます。サービスメッシュを入れずに済ませたい場合の選択肢です。
- キープアライブと接続寿命。
KEEPALIVE_TIMEは死んだ接続を検出するための設定で、MAX_CONNECTION_AGEは生きている接続を再分散するための設定です。
新しいバックエンドを追加してもトラフィックが流れないのも、同じ原因です。既存のコネクションは自然には張り替わらないため、MAX_CONNECTION_AGE などで定期的に入れ替えないと、既存クライアントのトラフィックが新しいバックエンドへ移るまで長い時間がかかります。
HTTP/1.1 の REST でこの問題が目立たないのは、コネクションが短命で、リクエストごとに再分散される機会が多いからです。ただし、REST を HTTP/2 で運べば同じ問題が起きます。gRPC 固有の問題というより、長寿命の多重化コネクションに共通する問題です。
11.2 可観測性とデバッグ
| 作業 | REST | gRPC |
|---|---|---|
| 手で 1 回叩く | curl | grpcurl / buf curl(+ リフレクション有効化) |
| パケットを読む | 平文(TLS 終端後) | Protobuf のデコードにスキーマが必要 |
| ログにボディーを残す | そのまま読める | デコードして構造化する必要あり |
| ブラウザーの DevTools | そのまま見える | gRPC-Web でも読みづらい |
| 既存の APM / WAF | 標準対応 | 対応が限定的なことが多い |
サーバーリフレクションを有効にしておくと、grpcurl が実行時にスキーマを取得できるので、デバッグがずっと楽になります(現在の正式名は grpc.reflection.v1.ServerReflection です。互換性のために、非推奨の grpc.reflection.v1alpha.ServerReflection を併せて公開しているサーバーやツールもまだ多くあります)。ただし、外部に公開する本番エンドポイントでは API 表面がすべて見えてしまうので、無効化を検討してください。内部環境では原則として有効にする、というのが実務的な落としどころです。
メトリクスとトレースの命名規約も、プロトコル選択と同じくらい運用を左右します。ここを誤ると、可観測性基盤そのものが壊れます。
- ラベルにはテンプレートを使い、実際の ID を入れない。
/v1/users/{id}を使い、/v1/users/8f2b...は使いません。gRPC なら、pkg.Service/Methodがそのまま安定した名前になります。実際の ID をラベルに入れると、時系列データベースのカーディナリティーが爆発してダウンします。これは REST 側で起こりやすい事故です(gRPC は通常、定義済みメソッドの有限集合に収まるので安全側ですが、未知メソッドのパスや独自ラベルで高カーディナリティーを作らない設計は同じく必要です)。 - ステータスのラベルを正規化する。HTTP には
2xx/4xx/5xxの粒度と正確なコードの両方があり、gRPC にはステータスコード(現行の OpenTelemetry ではrpc.response.status_code、旧来の gRPC メトリクスではgrpc_code)があります。移行期に両方が混在するなら、ダッシュボードで合算できる形に揃えておいてください(§13.4 の対応方針がここでも役立ちます)。 - OpenTelemetry のセマンティック規約に合わせる。
http.routeのような安定した HTTP 属性や、rpc.system.name/rpc.methodといった RPC 属性を、独自の命名で上書きしないでください(rpc.systemとrpc.serviceは非推奨になり、rpc.methodが完全修飾のサービス名/メソッド名を持つ形に移りつつあります。RPC 側の規約はまだ Release Candidate なので、変更への追随が必要です)。既製のダッシュボードとアラートがそのまま使えるかどうかは、ここで決まります。 - 同じトレースコンテキストで全シグナルをつなぐ。入口で W3C Trace Context(
traceparent)を受け取るか採番し、ログにはTraceId/SpanIdを、メトリクスにはエグゼンプラーのtrace_id/span_idを、トレースには span を残します。遅いリクエストを見つけてから、その 1 本のログに 1 クリックでたどり着けるかが、障害対応にかかる時間を左右します。
W3C Trace Context(traceparent / tracestate)は、REST では HTTP ヘッダー、gRPC ではメタデータとして運びます。メタデータの中身は同じ HTTP/2 ヘッダーなので、OpenTelemetry の伝播器はどちらでもほぼそのまま使えます。ただし、gRPC のメタデータには、キー名を小文字にすること、-bin サフィックス付きのキーだけがバイナリー値を運べること、という制約があります。
11.3 認証と認可 — プロトコルと直交する設計
認証と認可の設計は、プロトコル選択とほぼ独立に決めるべきです。「gRPC だからこの認証方式」という発想は、たいてい後で困ります。とはいえ、REST と gRPC では現れ方が違います。
- 認証(誰か): gRPC はチャネル資格情報(TLS / mTLS)と呼び出し資格情報(トークン)を型として分けているので、「接続は mTLS で、呼び出しごとに OAuth トークンを載せる」構成を素直に書けます。REST では
Authorizationヘッダー 1 つに集約するのが通例で、mTLS はインフラ層の設定として別に管理されがちです。 - 認可(何をしてよいか): ここには構造的な差が出ます。
| 認可の粒度 | REST | gRPC |
|---|---|---|
| エンドポイント単位 | URL パターン(/admin/*)でゲートウェイに書ける | /pkg.Service/Method 単位でインターセプターやプロキシーに書ける |
| オブジェクト単位 | 「この注文はこのユーザーのものか」はアプリ内で判定 | 同じくアプリ内 |
| フィールド単位 | レスポンス整形で落とす | 同上(FieldMask と組み合わせると整理しやすい) |
どちらの方式でも、オブジェクト単位の認可にアプリケーションやドメインの文脈が必要な点は変わりません。OPA・Cedar・Zanzibar 型のシステムでポリシーの判定を集約することはできますが、対象オブジェクトの所有関係や状態を渡し、結果を強制する責務はアプリケーション側に残ります。ゲートウェイは粗い門番(未認証のリクエストを落とす、管理 API を遮断する)に留め、細かい判断はドメインロジックの近くに置くのが定石です。
サービス間認証には、3 つの選択肢があります。
- mTLS: 相互 TLS です。トランスポート層で相手を証明します。SPIFFE / SPIRE のようなワークロード ID 基盤と組み合わせると、証明書の配布と更新を自動化できます。サービスメッシュを導入する主な動機のひとつです。
- ベアラートークン(JWT): アプリ層で運びます。デバッグしやすく、ゲートウェイをまたぐ構成にも強い方式です。ただし、トークンが漏れれば誰でも使えるので、寿命を短くし、
aud(対象サービス)を必ず検証してください。 - 両方: mTLS でどのワークロードかを、JWT でどのエンドユーザーの代理かを表します。大規模環境での標準的な構成です。
見落とされやすい落とし穴もあります。エンドユーザーのアクセストークンを、下流サービスへそのまま転送しないでください。渡すべきなのは、下流サービスを aud に指定し、必要最小限のスコープだけを持つ短命の委譲トークンです。元のトークンの audience が広すぎたり、下流側の aud 検証が甘かったりすると、あるサービスが意図された委譲経路を越えて権限を再利用できてしまいます(混乱した代理人型の事故)。OAuth 2.0 Token Exchange(RFC 8693)や、同等にスコープを絞ったトークンを発行する内部 STS を挟むのが正攻法です。
WAF や API ゲートウェイでは、JSON なら中身を検査できます。Protobuf は、ディスクリプターを渡さない限り検査できません。規制産業では、これが要件になることがあります。
11.4 ブラウザーの安全境界 — CORS と CSRF
公開 API は、性能より先にここでつまずきます。ブラウザーから呼ばれる可能性があるなら、同一オリジンポリシーを前提に設計しなければなりません。
| 方式 | プリフライトの発生 | CSRF の危険 |
|---|---|---|
REST(application/json) | ほぼ常に発生(Content-Type: application/json は「単純リクエスト」ではない) | Cookie 認証なら要対策。SameSite + トークン |
| REST(フォーム形式 / 単純リクエスト) | 発生しない | 危険。単純リクエストはプリフライトなしで飛ぶ |
GraphQL(POST + JSON) | 発生する | Cookie 認証 + GET クエリー許可は特に注意 |
| gRPC-Web / Connect | 発生する(カスタムヘッダーのため) | Authorization ヘッダー方式なら Cookie に依存しないぶん安全側 |
実務上の要点は 2 つです。
- Cookie ではなく
Authorizationヘッダーで認証すると、ブラウザーが自動で付与しないため、CSRF の大半は構造的に起きなくなります。Cookie を使うなら、SameSite=Lax/Strictと CSRF トークンを組み合わせてください。 Access-Control-Allow-Origin: *と資格情報は併用できません。Access-Control-Allow-Credentials: trueを返すなら、オリジンを明示的に列挙する必要があります。ここを緩めた設定は、そのまま情報漏洩の経路になります。
11.5 ネットワーク中間装置
古いプロキシー、企業のファイアウォール、一部のクラウド LB は、HTTP/2(特に平文の h2c や長時間ストリーム)で問題を起こすことがあります。外部との境界を越える通信ほど、どこでも通るという REST/JSON の性質の価値が上がります。
12. 第三極 — GraphQL / Connect / tRPC
RPC と REST の 2 択で考えると視野が狭くなります。実際の選択肢は、誰が取得内容を決めるかという軸で並びます。
12.1 GraphQL — クライアントが形を決める
GraphQL(Facebook 発、2015 年公開)は、単一エンドポイントに対してクエリー言語を投げる方式です。過剰取得(over-fetching)と過少取得(under-fetching)を、クライアント側の宣言で解決します。
query {
user(id: 42) {
name
orders(last: 3) { id total items { sku } }
}
}典型的な GraphQL API は単一エンドポイントが中心なので、Richardson の分類では Level 0 に近い運用になります(ただし、GraphQL-over-HTTP 仕様では query に GET を使うことも許されており、mutation は POST を使います)。得られるものと失うものは次のとおりです。
| 得るもの | 失うもの |
|---|---|
| クライアント主導の柔軟な取得、1 往復での集約 | HTTP キャッシュ(実質的に単一 URL + POST のため) |
| 強い型システムとイントロスペクション | ステータスコードだけで表せるエラー意味論(実行時エラーは多くが 200 + errors 配列。構文エラーやメソッド誤りは 4xx を使う) |
| フロントエンドの自律性 | クエリー複雑度の制御が必須(深さ制限・コスト計算) |
| スキーマ結合(Federation)による分散所有 | サーバー側の N+1 対策(DataLoader)が事実上必須 |
キャッシュの問題には、Persisted Query(クエリーをハッシュ化して事前登録し、GET /graphql?sha256Hash=... で呼び出す)という緩和策があり、これで CDN キャッシュを使えるようになります。CDN の背後で GraphQL を公開するなら、必須の設定と考えるべきでしょう。
GraphQL が適しているのは BFFです。多様なクライアント(Web / iOS / Android)が異なる形のデータを必要とし、バックエンドが多数に分かれている状況で最もよく効きます。
12.2 Connect — 「curl で呼べる gRPC」
Buf の Connect は、gRPC の型安全性を保ったまま、HTTP との親和性を取り戻す設計です。
- 同一のサーバーが、gRPC / gRPC-Web / Connect の 3 プロトコルを同時に扱います。
- 単項呼び出しは
POST /pkg.Service/Method+application/jsonで、curl から直接呼べます。 - ブラウザーからプロキシーなしで動きます。
.protoとコード生成はそのまま使えます。
「gRPC を入れたいが、ブラウザー対応とデバッグのしやすさが心配だ」という懸念に、そのまま応える選択肢です。内部 API を新しく作るなら、素の gRPC より先に検討する価値があります。
12.3 tRPC — 単一言語スタックでの最適解
tRPC は TypeScript 専用で、コード生成も IDL も持ちません。サーバーのルーター定義から型が推論され、クライアントがその型を直接 import します。
// server
const appRouter = router({
getUser: publicProcedure
.input(z.object({ id: z.number() }))
.query(({ input }) => db.user.find(input.id)),
});
export type AppRouter = typeof appRouter;
// client — 生成物なしで型が効く
const user = await trpc.getUser.query({ id: 42 });成立する条件は厳しいです。クライアントとサーバーが同じ TypeScript のモノレポにあり、同時にデプロイされる必要があります。この条件を満たすなら開発体験は非常に良いのですが、別言語のクライアント、独立したデプロイ、外部公開のいずれかが必要になった時点で成り立たなくなります。
12.4 各方式の位置づけ
次の表の記号は、ここまでの各節の説明に基づく筆者の評価で、✅ は対応していること、❌ は対応していないことを表します。HTTP キャッシュの列は、ブラウザーや CDN などの標準の HTTP キャッシュが応答を保存できるかを示します。△ は、保存できるのが GET で送る一部の呼び出しに限られることを表します(Connect では副作用のないメソッドを GET で呼ぶ場合、tRPC では query、GraphQL では Persisted Query)。ブラウザーの列は、変換プロキシーなしでブラウザーから呼べるかを示します。
| 方式 | 誰が形を決めるか | スキーマ | HTTP キャッシュ | ブラウザー | ストリーミング |
|---|---|---|---|---|---|
| REST (Level 2) | サーバー | OpenAPI(任意) | ✅ 完全 | ✅ | SSE / WebSocket |
| gRPC | サーバー | .proto(必須) | ❌ | プロキシー必須 | ✅ 4 形態 |
| Connect | サーバー | .proto(必須) | △ 限定的 | ✅ | ✅ |
| GraphQL | クライアント | SDL(必須) | △ Persisted Query で部分的 | ✅ | Subscription |
| tRPC | サーバー | TS の型(暗黙) | △ query のみ | ✅ | ✅ |
| JSON-RPC 2.0 | サーバー | なし(任意) | ❌ | ✅ | トランスポート依存 |
表の最後の JSON-RPC 2.0 は、MCP (Model Context Protocol) が採用しています。2026 年時点で最も新しいプロトコルのひとつが、1990 年代風の素朴な RPC を選んだことになります。理由は、人間と LLM の両方が読み書きしやすく、実装のハードルが非常に低いことだと考えられます。RPC を選ぶ理由が、性能から読みやすさと実装のしやすさへ移ったと見るのが適切です。
13. 実務の判断基準
決め手は技術的な優劣よりも、境界の性質(誰と誰の間の通信か)です。
13.1 決定木
13.2 境界の性質で考える
さらに単純化すると、判断の軸は契約の両側を同じ組織が同時に変更できるかという一点に集約されます。
| 境界 | 変更の同期 | 推奨 | 理由 |
|---|---|---|---|
| 同一チーム内のサービス間 | 同時にデプロイ可能 | gRPC / Connect / tRPC | 型契約の恩恵が最大、破壊的変更のコストが低い |
| 社内の別チーム間 | 調整が必要 | gRPC / Connect + スキーマレジストリー | 破壊的変更検出が価値を持つ |
| 社内 → 社外パートナー | 数か月の猶予が必要 | REST + OpenAPI | 相手の技術スタックを選べない |
| 公開 API | 永久に壊せない | REST + OpenAPI + 明示的バージョニング | 汎用性・キャッシュ・可視性 |
| ブラウザー ↔ 自社サーバー | 同時デプロイ可能なことが多い | REST / GraphQL / Connect | ブラウザーの制約が支配的 |
ここで Conway の法則が関わってきます。API の形は、その両側にいるチームの関係を反映すべきです。密結合を許せる関係なら型契約の強い方式が有利で、疎結合でなければならない関係ほど、壊れにくく誰でも読める方式が有利になります。
13.3 併用が現実解
二者択一である必要はありません。実務で最も多いのは次の構成です。
.proto に HTTP マッピングのアノテーションを書けば、1 つの定義から gRPC と REST の両方を提供することもできます。
import "google/api/annotations.proto";
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse) {
option (google.api.http) = { get: "/v1/users/{id}" };
}
}これを grpc-gateway に渡すと、REST/JSON のリバースプロキシーが生成されます。ただし、頼りすぎないでください。自動生成された REST API は、URL 設計、エラー表現、キャッシュヘッダーのいずれも Protobuf のメソッドをそのまま写したものになりがちで、Level 2 の REST として質が高いとは限りません。公開 API にするなら、生成物をそのまま出さず、意識して設計してください。
13.4 移行のしかた — 一斉切り替えをしない
既存の REST API を gRPC に移す(あるいはその逆の)とき、ビッグバン移行はほぼ必ず失敗します。実務的な手順は、「絞め殺しの木 (strangler fig)」パターンです。
- 境界にファサードを置く。クライアントから見える URL とレスポンスの形は一切変えません。ここが変わらないことが、移行の前提条件です。
- 新規のエンドポイントから先に新方式で書く。既存部分を書き換えるより、増える部分を新方式にするほうが安全で、レビューもしやすくなります。
- エンドポイント単位で移す。1 つ移すたびに、旧経路と新経路のレスポンスの差分を、本番トラフィックの複製で比較します(シャドウ / ダークトラフィック)。
- 旧経路の呼び出しをメトリクス化する。ゼロになったら削除します。§6.6 で述べた、利用状況を観測しなければ廃止できないという原則がここで効きます。
- Connect を中継点に使う。同一のサーバーが gRPC / gRPC-Web / Connect を同時に扱えるため、サーバーを先に移し、クライアントは後から順次移せます。移行期間中に両方を維持するコストが最も低い選択肢です。
移行で最も見落とされやすいのは、エラー意味論の対応方針です。HTTP ステータスと gRPC コードの標準的な対応は、NOT_FOUND ↔ 404、FAILED_PRECONDITION ↔ 400、ABORTED ↔ 409 です。既存 REST の意味論を残すなら、その例外を明示的に書いておきます。この対応を移行の初日に決めておかないと、移行の途中でクライアントのリトライ挙動が気づかないうちに変わります。
14. アンチパターン集
実際に事故につながったもの、つながりやすいものを挙げます。
- 常に HTTP 200 を返す。中間層とクライアント SDK のリトライ・サーキットブレーカーがすべて無効化されます。
- バリデーションエラーを 500 で返す。エラー率の監視が汚染され、SLO が壊れ、無意味なリトライが走ります。
GETに副作用を持たせる。プリフェッチ、クローラー、ブラウザーのリロードによって、意図せず実行されます。- 認証付きレスポンスに
Cache-Control: publicを付ける。CDN が他人のデータを配信します。実際に起きた事故が複数あります。 Varyの指定漏れ。言語別・圧縮方式別のレスポンスが混ざります。- 公開 API に素の gRPC を使う。相手にツールチェーンを強制することになり、curl でのサポート対応もできなくなります。
- Protobuf のフィールド番号を再利用する。古いクライアントが送った値が、新しい意味で解釈され、データ破損の直接の原因になります。必ず
reservedにしてください。 - Protobuf のバイト列をハッシュして冪等キーにする。直列化は決定論的ではないため、同じメッセージから別のハッシュが生まれます。
- デッドラインを設定しない gRPC 呼び出し。無期限に待ち続け、リソースを枯渇させます。すべての呼び出しにデッドラインを設定してください。
- L4 LB の背後に gRPC を置く。負荷が偏り、スケールアウトが効きません(§11.1)。
- gRPC ストリームをメッセージキューの代わりに使う。切断でメッセージが消えます。
- OpenAPI を後付けで手書きする。実装と食い違い、ドキュメントがあるのに内容が間違っているという最も悪い状態になります。
- ページネーションなしでコレクションを取得する。プロトコルに関係なく、いずれ必ず障害になります。
.protoを全社共有の 1 つのリポジトリーに集約する。1 つの変更で全社のビルドが止まります。- スキーマを CI で検査しない。
buf breakingや OpenAPI の差分検査がなければ、契約はただのドキュメントに戻ります。 - ストリームの受信を無制限のキューに流し込む。バックプレッシャーを解決したことにはならず、問題をトランスポートからアプリのヒープへ移しただけです。
- すべてを無条件に圧縮する。小さい RPC や圧縮済みのバイト列では、CPU を余分に使ったうえに、サイズまで増えることがあります。
15. まとめ
- RPC は「呼び出し」の抽象です。40 年かけて透過性を諦める方向に進化し、現代の gRPC はデッドライン、型、ストリーミングを明示的に扱います。得られるのは表現力と、規律の自動化です。
- REST は「制約」の集合です。統一インターフェースとステートレス性を守る見返りに、自分が書いていない中間層(CDN、プロキシー、ブラウザー、汎用クライアント)が働いてくれます。
- 性能差の中身は 3 つに分解できます。ヘッダー圧縮と多重化は HTTP/2 の効果、メタ情報の削除はスキーマの効果です。REST を HTTP/2 で運べば最初の 2 つは手に入ります。
- 本当の差は、エラー、キャッシュ、契約に現れます。デッドライン伝播と破壊的変更の検出では gRPC が強く、キャッシュと可視性では REST が強い、という関係です。
- 判断は境界の性質で決まります。契約の両側を同時に変更できるなら型契約の強い方式を、できないなら壊れにくく誰でも読める方式を選びます。
- ロードバランシング、可観測性、認証認可、ブラウザー境界を設計しないまま方式だけを決めても、本番ではうまくいきません。ロードバランシングの落とし穴は長寿命の HTTP/2 多重化コネクションの問題なので、REST を HTTP/2 で運んでも起きます(§11.1)。
15.1 本番投入前のチェックリスト
方式を決めたあとで、実際に見落とされやすいのは次の項目です。
gRPC / Connect を選んだなら- すべての呼び出しにデッドラインが設定されている(§7.5)
- LB がリクエスト単位で分散している。または
round_robin+ headless Service(§11.1) -
MAX_CONNECTION_AGEを設定し、スケールアウトが効くことを確認した(§11.1) - CI に
buf breakingが入っている(§6.7) - リフレクションは内部のみ有効、外部公開では無効(§11.2)
- リトライポリシーに指数バックオフ + ジッター + スロットルがある(§7.4)
- ストリームの受信バッファーが有界で、遅い購読者の切断方針がある(§8.5)
- メトリクスのラベルが低カーディナリティー(§11.2)
- OpenAPI が単一の真実(スキーマファースト、または実装からの自動生成)になっており、CI で差分検査されている(§6.2 / §6.7)
- 一覧エンドポイントにカーソルページネーションがある(§4.5)
- エラーが RFC 9457 で、
typeが安定した識別子になっている(§7.2) - リトライされうる非冪等操作に
Idempotency-Keyとサーバー側の 4 規律がある(§7.4) -
Cache-ControlとVaryが正しく、認証付きレスポンスがpublicになっていない(§10.1) - 4xx / 5xx を取り違えていない(§7.1)
- 廃止予定に
Deprecation/Sunsetと利用状況メトリクスがある(§6.6) - タイムアウトが全ホップで設定・伝播されている(§7.5)
この選択は、一度決めたら終わりというものではありません。内部を gRPC にしつつ外部との境界に REST ゲートウェイを置く、GraphQL の BFF を挟む、といった構成はごく普通です。
境界ごとに受け入れた制約を、たとえば「この境界では契約の強制とデッドライン伝播が必要で、キャッシュは不要だから gRPC」のように説明できるなら、その判断はおそらく正しいでしょう。
参考文献
- A. D. Birrell and B. J. Nelson, "Implementing Remote Procedure Calls", ACM Transactions on Computer Systems, 2(1):39–59, 1984.
- R. T. Fielding, Architectural Styles and the Design of Network-based Software Architectures, Ph.D. dissertation, University of California, Irvine, 2000. オンライン版
- J. Waldo, G. Wyant, A. Wollrath, S. Kendall, "A Note on Distributed Computing", Sun Microsystems Laboratories TR-94-29, 1994.
- R. T. Fielding, "REST APIs must be hypertext-driven", 2008.
- M. Fowler, "Richardson Maturity Model", 2010.
- RFC 9110 — HTTP Semantics / RFC 9111 — HTTP Caching / RFC 9112 — HTTP/1.1
- RFC 9113 — HTTP/2 / RFC 9114 — HTTP/3 / RFC 7541 — HPACK
- RFC 9457 — Problem Details for HTTP APIs
- RFC 5531 — ONC RPC v2 / RFC 4506 — XDR
- gRPC over HTTP/2 プロトコル仕様
- gRPC ステータスコードとその使い分け
- Protocol Buffers — Encoding / Proto Best Practices
- Google Cloud API Design Guide / AIP-136 Custom methods / AIP-158 Pagination / AIP-134 Standard methods: Update(FieldMask)
- Connect protocol — Buf
- GraphQL 仕様 / GraphQL over HTTP
- RFC 9745 — The Deprecation HTTP Response Header Field / RFC 8594 — The Sunset HTTP Header Field
- The Idempotency-Key HTTP Header Field — IETF HTTPAPI WG Internet-Draft(RFC ではない)
- gRPC Compression / gRPC Load Balancing
- Buf — breaking change detection / BSR breaking change checks
- OpenTelemetry — HTTP semantic conventions / RPC semantic conventions
- W3C Trace Context / OpenTelemetry Metrics Data Model — Exemplars
- RFC 8693 — OAuth 2.0 Token Exchange
- SPIFFE Overview
- Pact — 消費者駆動契約テスト
- RFC 8288 — Web Linking / RFC 5861 — HTTP Cache-Control Extensions for Stale Content
- Y. Gluck, N. Harris, A. Prado, "BREACH: Reviving the CRIME Attack", 2013. breachattack.com
- tRPC — Response Caching / Connect — Get Requests and Caching