Day 049 — gRPC/Protocol Buffers実装(続き): デッドライン伝播とストリーミングRPCの設計判断

2026-09-15 🟡 上級者 / Phase 3 設計・アーキテクチャ gRPC/Protocol Buffers実装

📚 背景知識(読んでから問題へ)

Day 048では.protoが生成するコードの役割分担と、net/rpcによるRPC呼び出しの規約(ポインタ引数・エクスポート済みメソッド)を扱いました。今日はgRPCが実務で選ばれる理由の核心であるデッドライン伝播(deadline propagation)とストリーミングRPCという2つの機能を、設計判断の軸として掘り下げます。

実際のgRPCでは、クライアントがcontext.WithTimeout(ctx, 200*time.Millisecond)のようにデッドラインを設定してRPCを呼び出すと、そのデッドラインはgrpc-timeoutというHTTP/2ヘッダーに変換され、ネットワークの向こう側のサーバーにまで伝わります。サーバー側のハンドラはctx.Done()やctx.Err()をチェックすることで、「クライアントはもう結果を待っていない」ことを知り、無駄な処理を早期に打ち切ることができます。つまりgRPCのデッドラインは、クライアント側だけの「待つのをやめる」仕組みではなく、呼び出しチェーン全体(クライアント→サーバー→サーバーが呼ぶ別サービス…)に伝播する中断信号です。

一方、標準ライブラリのnet/rpcのclient.Callはcontext.Contextを一切受け取りません。同期的に呼び出し、サーバーからの応答(またはコネクションエラー)が来るまでブロックし続けます。これはgRPCが解決している「呼び出し側が待つのをやめたい」という要求に、標準ライブラリだけでは対応できないことを意味します。

もう一つの軸がストリーミングRPCです。gRPCは4種類の呼び出し形式を持ちます。

種類説明
Unary RPC1リクエスト→1レスポンス(net/rpcのclient.Callと同じ形)
Server streaming RPC1リクエスト→複数レスポンス(サーバーが継続的にデータを送り続ける)
Client streaming RPC複数リクエスト→1レスポンス
Bidirectional streaming RPC両方向で任意のタイミングでデータをやり取り

net/rpcにはストリーミングの概念自体が存在せず、Unary RPCしか表現できません。「継続的に更新を受け取りたい」という要件に対しては、Unary RPCを一定間隔で繰り返し呼ぶポーリングで近似するしかありません。

📝 問題

  1. 概念理解: gRPCのcontext.WithTimeoutによるデッドラインが「クライアント側だけで待つのをやめる」のではなく「サーバー側にも伝播する」とはどういう意味か、具体的に説明してください。また、サーバー側の処理が複数のマイクロサービス呼び出しを連鎖させている場合(A→B→C)、このデッドライン伝播がどう役立つかを1つの障害シナリオで示してください。
  2. 実装: net/rpcのclient.Callはcontext.Contextを受け取れません。これにcontext.Contextによるタイムアウトを外付けするCallWithContext関数を実装してください。要件:
    • シグネチャ: func CallWithContext(ctx context.Context, client *rpc.Client, serviceMethod string, args, reply any) error
    • ctxのデッドラインが切れる前にRPCが完了した場合は、RPCの結果(error、nil含む)をそのまま返す
    • ctxのデッドラインが先に切れた場合はctx.Err()(context.DeadlineExceeded)を返す
    • サーバー側の処理時間を人為的に変えられるSlowGreeter.SayHello(req *HelloRequest, reply *HelloReply) errorを用意し、(a) デッドライン内に完了するケース、(b) デッドラインを超過するケースの両方をmain関数内で実行して結果を表示する
  3. デバッグ・コードレビュー(設計上の欠陥の指摘): 問題2で実装したCallWithContextには、「クライアントは待つのをやめられるが、サーバー側の処理は止まらない」という、本物のgRPCのデッドライン伝播とは異なる重要な限界があります。この限界が実際のシステムでどのような問題を引き起こすか、具体的な障害シナリオ(例: 高負荷時に何が起こるか)を1つ挙げて説明してください。
  4. 設計・アーキテクチャ: 「サーバー上に新しいログ行が追加されるたびに、クライアントにリアルタイムで表示する」というログストリーミング機能を設計するとします。(a) net/rpcのUnary RPCを1秒間隔でポーリングする方式と、(b) gRPCのServer streaming RPCを使う方式を、レイテンシ・コネクションのオーバーヘッド・実装の複雑さの3観点で比較し、どちらを選ぶべきか、根拠とともに述べてください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

client.Call自体はブロッキングな同期呼び出しです。context.Contextのキャンセル/タイムアウトと組み合わせるには、client.Callを別のgoroutineで実行し、その完了をchannelで受け取りながら、ctx.Done()とselectで競争させる、という「非同期化してから待ち合わせる」パターンがGoの定石です。

ヒント2 — アプローチ

done := make(chan error, 1)のようにバッファ付きチャネルを使うと、selectでctx.Done()側が先に成立して関数がreturnした後も、後から完了するclient.Callのgoroutineがチャネルへの送信でブロックせずに済みます(バッファがないと、誰も受信しないチャネルへの送信でgoroutineがリークします)。

ヒント3 — コード骨格
func CallWithContext(ctx context.Context, client *rpc.Client, serviceMethod string, args, reply any) error {
	done := make(chan error, 1)
	go func() {
		done <- client.Call(serviceMethod, args, reply)
	}()

	select {
	case err := <-done:
		// TODO: 正常終了(RPCが先に終わった)
	case <-ctx.Done():
		// TODO: タイムアウト(ctxが先に終わった)
	}
}

サーバー側の処理時間をシミュレートするにはtime.SleepをSayHelloの中に入れ、HelloRequestにDelay time.Durationフィールドを追加してリクエストごとに変えられるようにします。

✅ 模範解答

(1) デッドライン伝播の意味と障害シナリオ:

gRPCではクライアントが設定したcontext.WithTimeoutのデッドラインは、RPC呼び出し時にgrpc-timeoutというHTTP/2ヘッダーとしてサーバーに送信されます。サーバー側のgRPCランタイムはこのヘッダーを受け取り、ハンドラに渡すcontext.Contextにも同じデッドラインを設定します。つまり「クライアントがあと何ミリ秒で諦めるか」という情報そのものがネットワークを越えてサーバーに伝わります。

📌
障害シナリオ: サービスA(フロントエンドAPI)がサービスB(検索サービス)を呼び、Bがさらにサービス C(DB集計サービス)を呼ぶ構成で、AがBに対して「500msでタイムアウト」というデッドラインを設定したとします。デッドライン伝播があれば、BはAから受け取ったコンテキスト(残り500ms)をそのままCへの呼び出しにも引き継ぎます。Cの処理に600msかかりそうだとCが自分のctx.Err()を見て気づけば、Cは無駄な集計処理を打ち切って早期にエラーを返せます。もしBがAから独立した新しいコンテキスト(デッドラインなし)でCを呼んでいたら、Aはすでにタイムアウトしてユーザーにエラーを返した後も、BとCは無駄にリソースを使い続けます。これは「クライアントが諦めた処理にサーバー側のリソースを使い続ける」という、負荷が高いときにシステム全体を悪化させるカスケード障害の典型的な原因です。デッドライン伝播は、この無駄な処理を呼び出しチェーンの各段で早期に打ち切るための仕組みです。

(2) CallWithContextの実装(完全なコード):

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net"
	"net/rpc"
	"time"
)

// SlowGreeter は、サーバー側の処理時間をリクエストごとに変えられる
// RPCエンドポイント。デッドライン内に完了するケースと超過するケースの
// 両方を1つのサーバーでシミュレートするために使う。
type SlowGreeter struct{}

type HelloRequest struct {
	Name  string
	Delay time.Duration // サーバー側の処理時間をシミュレートする
}

type HelloReply struct {
	Message string
}

func (g *SlowGreeter) SayHello(req *HelloRequest, reply *HelloReply) error {
	time.Sleep(req.Delay)
	reply.Message = "Hello, " + req.Name + "!"
	return nil
}

// CallWithContext は net/rpc の同期 Call に context.Context によるタイムアウトを
// 外付けするラッパー。client.Call を別goroutineで実行し、その完了と ctx.Done() を
// select で競争させる。
//
// 重要な限界: ctx が先にタイムアウトした場合、この関数は呼び出し元に
// ctx.Err() を返して制御を戻すが、バックグラウンドで動いている client.Call の
// goroutineとサーバー側の処理そのものは止まらない(問題3で詳述)。
func CallWithContext(ctx context.Context, client *rpc.Client, serviceMethod string, args, reply any) error {
	done := make(chan error, 1) // バッファ1: 受信者がいなくてもgoroutineがブロックしない
	go func() {
		done <- client.Call(serviceMethod, args, reply)
	}()

	select {
	case err := <-done:
		return err
	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	if err := rpc.Register(new(SlowGreeter)); err != nil {
		log.Fatal(err)
	}
	listener, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		log.Fatal(err)
	}
	defer listener.Close()

	go func() {
		for {
			conn, err := listener.Accept()
			if err != nil {
				return
			}
			go rpc.ServeConn(conn)
		}
	}()

	client, err := rpc.Dial("tcp", listener.Addr().String())
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	// ケース(a): サーバーの処理(50ms) < デッドライン(200ms) → 正常完了
	ctx1, cancel1 := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel1()
	var reply1 HelloReply
	err = CallWithContext(ctx1, client, "SlowGreeter.SayHello",
		&HelloRequest{Name: "Gopher", Delay: 50 * time.Millisecond}, &reply1)
	fmt.Printf("ケース(a) 結果=%q err=%v\n", reply1.Message, err)

	// ケース(b): サーバーの処理(200ms) > デッドライン(50ms) → タイムアウト
	ctx2, cancel2 := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel2()
	var reply2 HelloReply
	err = CallWithContext(ctx2, client, "SlowGreeter.SayHello",
		&HelloRequest{Name: "Gopher", Delay: 200 * time.Millisecond}, &reply2)
	fmt.Printf("ケース(b) 結果=%q err=%v is-deadline-exceeded=%v\n",
		reply2.Message, err, errors.Is(err, context.DeadlineExceeded))

	// デモ用: ケース(b)のバックグラウンドgoroutineがサーバー応答を受け取って
	// 終了するのを待つ(本番コードではこのSleepは不要。問題3の議論のための待機)
	time.Sleep(300 * time.Millisecond)
	fmt.Println("(ケース(b)のバックグラウンド処理も、300ms後には完了していたはず)")
}

実行結果(実測。go1.26で確認済み):

ケース(a) 結果="Hello, Gopher!" err=<nil>
ケース(b) 結果="" err=context deadline exceeded is-deadline-exceeded=true
(ケース(b)のバックグラウンド処理も、300ms後には完了していたはず)

(3) CallWithContextの設計上の限界と障害シナリオ:

CallWithContextがctx.Done()側で先にreturnしても、go func() { done <- client.Call(...) }()で起動したgoroutineは止まっていません。サーバー側のSayHelloもtime.Sleep(req.Delay)の間、律儀に処理を続け、200ms後に応答を返します。その応答は誰も受け取らない(呼び出し元はすでにctx.Err()を受け取ってreturn済み)まま、バッファ付きチャネルdoneに書き込まれて捨てられます。

これは本物のgRPCのデッドライン伝播との決定的な違いです。gRPCではクライアントのタイムアウトがサーバー側のctxにも伝わり、サーバーのハンドラが自分でctx.Err()をチェックして処理を中断できます。一方このCallWithContextは「クライアントが待つのをやめる」だけで、「サーバーに処理をやめさせる」ことは一切していません。

📌
障害シナリオ: 高負荷時に多数のクライアントが次々とタイムアウトしても、サーバー側では誰も待っていないリクエストの処理が積み上がり続けます。CPU・メモリ・DBコネクションなどのリソースが「もう誰も結果を待っていない仕事」に消費され続け、本来処理すべき新しいリクエストの処理能力を圧迫します。これはリトライと組み合わさるとさらに悪化します(クライアントがタイムアウトしてリトライすると、古い未完了の処理はサーバー側に残ったまま、新しい処理がさらに追加され、負荷が雪だるま式に増える)。この現象は一般に「メタスタブル障害(metastable failure)」や過負荷時の「retry storm」の一因として知られています。

(4) ポーリング方式 vs Server streaming RPC方式の比較:

観点(a) Unary RPCのポーリング(1秒間隔)(b) gRPC Server streaming RPC
レイテンシ新しいログ行が発生してから、次のポーリングタイミングまで最大1秒(ポーリング間隔)の遅延が発生するサーバーが新しいログ行を検知した瞬間にストリームへ書き込めるため、ほぼリアルタイム(ネットワーク遅延のみ)
コネクションのオーバーヘッドポーリングごとに新しいリクエスト(HTTP/2上では新しいストリームだが、TCP接続自体は再利用可能)を送るため、リクエストヘッダのオーバーヘッドがポーリング回数分発生する。ログがない間も一定間隔で無駄なリクエストが発生し続ける1回のRPC呼び出しで接続を確立した後は、サーバーが必要なときだけデータを送るため、無駄なリクエストが発生しない
実装の複雑さクライアント側にポーリングループとポーリング間隔の調整(バックオフなど)を実装する必要があるが、Unary RPCの範囲内で完結し理解は容易サーバー側はstream.Send(...)をループで呼ぶ設計、クライアント側はstream.Recv()をループで呼ぶ設計になり、双方でストリームのライフサイクル管理(切断検知・エラーハンドリング)が必要になり実装がやや複雑になる

選ぶべき方式: このケース(ログのリアルタイム表示)では(b) Server streaming RPCを選ぶべきです。理由は、要件が明示的に「新しいログ行が追加されるたびに」というイベント駆動での即時性を求めており、ポーリング間隔を短くすればレイテンシは改善できますが、その分無駄なリクエスト数が増えてサーバー負荷が上がるという直接的なトレードオフがあります。Server streaming RPCはこのトレードオフ自体を発生させず、「データがあるときだけ送る」という理想的な特性を持つため、実装の複雑さという追加コストを払う価値があります。逆に、更新頻度が低く(例: 1日1回のバッチ結果通知)多少のレイテンシが許容される場合は、実装の単純さを優先してポーリングで十分という判断もあり得ます。

🪜 Step-by-Step 解説

1
「同期呼び出しを非同期化してから待ち合わせる」パターンを理解する
client.Call自体はcontext.Contextを受け取らないブロッキング関数です。これに外部からタイムアウトを追加する唯一の方法は、その呼び出し自体を別のgoroutineに閉じ込め、channelを介して結果を受け取りながら、selectで「結果が来た」と「タイムアウトした」の2つのイベントを競争させることです。このパターンはGoで「timeout付きの何か」を実装する際に繰り返し登場する基本形です。
2
バッファ付きチャネルでgoroutineリークを防ぐ
done := make(chan error, 1)のバッファが重要です。selectでctx.Done()側が選ばれて関数がreturnした後、client.Callが完了してdone <- ...を実行しようとしたとき、もしチャネルがバッファなし(make(chan error))だった場合、送信操作は受信者が現れるまで永久にブロックし、そのgoroutineは二度と終了しません(goroutineリーク)。バッファ1にすることで、送信は即座に成功し、goroutineは正常に終了できます(値は誰にも読まれずチャネルに残るだけで、ガベージコレクションの対象になります)。
3
「クライアントが待つのをやめる」と「サーバーが処理をやめる」の違いを区別する
問題3で議論した通り、CallWithContextが実現しているのは前者だけです。ここが今日の設計判断の核心で、「タイムアウトを実装した」という事実だけで満足せず、「タイムアウトしたときに、実際に何が止まって何が止まらないのか」を常に確認する視点が、本番システムの設計では不可欠です。
4
ストリーミングの要否を「データの発生パターン」から判断する
問題4の設計判断は、「クライアントが定期的に確認したいだけのデータ(残高・在庫数など)」か、「サーバー側で発生したイベントを即座に知りたいデータ(ログ・通知・進捗)」かという、データの発生パターンによって決まります。前者はポーリングで十分なことが多く、後者はストリーミング(またはWebSocket・Server-Sent Eventsなど他のプッシュ型技術)が自然な選択になります。

💡 設計思想・なぜこう書くのか

📌
contextは「協調的」なキャンセルの仕組み: Goの標準ライブラリのcontext.Contextは、「キャンセル・タイムアウト・値の伝播」を関数呼び出しの境界を越えて明示的にバケツリレーする、Go特有の設計です。多くの言語ではタイムアウトはフレームワークが暗黙的に処理する「魔法」になりがちですが、Goではctxを関数の第一引数として明示的に渡すことで、「このコードパスはキャンセル可能である」という契約が型システムとコーディング規約の両方で可視化されます。今日のCallWithContextは、context.Contextをまだ知らない古い(あるいは標準ライブラリの)APIに、この契約を後付けで橋渡しする典型的なアダプタパターンです。
📌
橋渡しには限界がある: context.Contextは「呼び出し元が待つのをやめたい」という意思を伝える手段であり、それが実際に「呼び出し先の処理を止める」ところまで機能するかは、呼び出し先がそのcontextを見て自発的に協力するかどうかに完全に依存します。gRPCのようにプロトコルレベルでデッドラインを伝播し、生成コードがctx.Err()のチェックを促す設計になっているエコシステムでは、この協力が実現しやすくなります。net/rpcのような古いAPIに後付けでcontextを橋渡しする場合、この協力は自動的には得られない、という非対称性を理解しておくことが重要です。

🌐 他言語との比較

言語/エコシステム同等の概念Goとの違い
JavagRPC-JavaのDeadlineクラス、CompletableFuture.orTimeout()JavaではFutureのタイムアウトは「待つのをやめる」機能としてorTimeoutで後付けできるが、実行中のタスク自体をキャンセルするにはFuture.cancel()を明示的に呼び、タスク側が定期的にThread.currentThread().isInterrupted()を確認する協力が必要になる点は、Goのctx.Err()チェックと同じ非対称性を持つ
Pythonasyncio.wait_for()、gRPC-Pythonのcontext.cancel()asyncio.wait_forはタイムアウト時に対象のコルーチンにCancelledErrorを注入できるが、対象コルーチンがtry/except CancelledErrorで握り潰してしまうと実際には止まらない。「協力が必要」という点はGoと同型の問題
TypeScript/Node.jsAbortController/AbortSignalFetch APIなど比較的新しいAPIはAbortSignalを第一級の引数として受け取るよう設計されているが、古いコールバックベースのAPIにはcontext.Contextのように統一的に浸透しておらず、ライブラリごとにキャンセル対応の有無がまだら模様になっている点はGoのnet/rpc(非対応)とgRPC(対応)の違いに近い

🏆 実務での使いどころ

  • マイクロサービスのタイムアウト設計: サービスAがサービスBを呼ぶ際、Aが設定するタイムアウトはBが内部で使うタイムアウトより長くしてはいけません(Bのタイムアウトの方が先に発生するよう、余裕を持って短く設定する「タイムアウトの入れ子」設計)。デッドライン伝播が使えるgRPCではこれをコード側で強制しやすくなります。
  • 古いAPIへのcontext対応の後付け: 今日のCallWithContextのようなパターンは、レガシーな同期APIやサードパーティライブラリにcontext.Contextベースのキャンセルを外付けする際の定石です。ただし「本当にキャンセルできているか」を必ず検証する必要があります。
  • 観測性との組み合わせ: 本番システムでは、CallWithContextのようなラッパーでタイムアウトが発生した回数をメトリクスとして記録し、「クライアントは諦めたがサーバー側処理は続いている」という状態がどれだけ発生しているかを監視することが、問題3で述べたリソース圧迫を早期発見する手段になります。

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
context.WithTimeoutでタイムアウトを設定すれば、実行中の処理が自動的に中断されると思い込む他言語の一部のフレームワーク(スレッドの強制終了など)に慣れていると、「タイムアウト=強制停止」という直感が働くcontextのキャンセルは協調的(cooperative)な仕組みであり、実行中のコードが自らctx.Done()やctx.Err()をチェックして初めて中断が実現する。チェックしないコード(今日のSayHello内のtime.Sleepなど)はctxが終わっても止まらない
done <- client.Call(...)を送るチャネルにバッファを付け忘れ、タイムアウト発生時にgoroutineリークが起きていることに気づかない「タイムアウトしてreturnした後の話」は動作確認の際に見落とされやすく、go test -raceのような特別なツールなしでは検出しにくいselectのどちらか一方の分岐しか実行されないため、選ばれなかった側(今回はdoneチャネルへの後からの送信)が正常に完結できる状態(十分なバッファ、または受信を諦めるロジック)を用意しておく必要がある
ポーリング間隔を極端に短く(例: 10ms)すればストリーミングRPCと同等のリアルタイム性が得られると考えるレイテンシだけを見ると「間隔を短くすれば近づく」ように見えるポーリング間隔を短くするほど、実際に新しいデータがない間の無駄なリクエスト数が増え、サーバー負荷とネットワーク帯域の消費が線形に増加する。レイテンシとオーバーヘッドは直接的なトレードオフであり、ストリーミングはこのトレードオフ自体を解消する別のアプローチである

🚀 次のステップ

  • 発展: CallWithContextをさらに改良し、ctxがタイムアウトした際にサーバー側のTCPコネクション自体をclient.Close()で切断してしまう設計を試してみましょう。ただしこれは同じコネクションを共有する他の未完了呼び出しも巻き込んで失敗させるという新たな設計トレードオフを生むため、その影響も考察してみてください。
  • 次回予告: ミドルウェア設計パターン — http.Handlerのチェーン設計を扱います

🎯 自己評価

自分の回答

気づき・メモ