📦 サンプルコード リポジトリ : lancelot89/go-blog-examples ーlab/04-grpc-otel
(クローン → docker compose up --build -d
で記事と同じ環境を再現できます)
双方向 gRPC ストリーミングでリアルタイム通信を構築し、OpenTelemetry + Jaeger でレイテンシやエラーを即座に追跡できる開発フローを コード & 解説付き で体系化しました。
本記事で学べること
ゴール | 詳細 | 到達イメージ |
---|---|---|
gRPC ストリームの使い分け | Unary / Server‑stream / Client‑stream / Bidirectional | 要件に応じて“いつ双方向にするか”を判断できる |
buf での proto 管理 | buf.yaml にモジュールパスを 1 行書くだけ | buf generate ワンコマンドで Go ソース自動生成 |
OpenTelemetry 導入 | StatsHandler を 2 行追加 | Jaeger UI で Trace → Span → レイテンシ を観測 |
Docker Compose 再現環境 | server / client / jaeger を同一ネットで起動 | レポジトリ clone → docker compose up だけで体験 |
1 プロジェクト全体像を俯瞰する
go-grpc-otel/
├── api/ # .proto 保管庫
│ └── chat/v1/chat.proto
├── buf.{yaml,gen.yaml}
├── cmd/ # 実行バイナリ
│ ├── server/main.go
│ └── client/main.go
├── internal/service/ # ビジネスロジック
│ └── chat.go
├── otel/tracer.go # OTEL 初期化
└── grpc-otel-lab/docker-compose.yml
2 buf で proto を生成
buf.yaml
/ buf.gen.yaml
は従来どおり。buf generate
で *.pb.go
が生成されます。
3 Proto でメッセージ仕様を決める
(省略: 同上)
4 サーバ実装を読み解く
4‑1 サービスメソッドの流れ
for {
in, err := stream.Recv() // 受信をブロック待ち
if err == io.EOF { return nil } // クライアントが閉じた
if err != nil { return err }
in.SentAtUnix = time.Now().Unix() // echo 用にタイムスタンプを書き換え
if err := stream.Send(in); err != nil { return err }
}
4‑2 StatsHandler による自動トレース(v0.61+)
s := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()), // Unary + Stream 両対応
)
✅ なぜ嬉しい?
- Interceptor を 2 本書くよりシンプル — Unary と Stream の両方を 1 行でカバー。
- バージョン追従が楽 — v0.61 以降の breaking change に合わせた“正規ルート”なので、将来の gRPC‑Go 2.x 系でもそのまま動く確率が高い。
- 自前ロジックを汚染しない —
StatsHandler
は 統計 & トレース専用。ビジネスロジックの前後に余計な差し込み点を作らず、可観測性のコードを分離できます。
5 クライアント:新 API `grpc.NewClient`
>旧 API の `UnaryServerInterceptor()` / `StreamServerInterceptor()` は **v0.61 で削除**。StatsHandler 1 行で代替できます。
conn, _ := grpc.NewClient(
"server:50051", // Compose 内は service 名
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()), // OTEL
)
conn.Connect(ctx) // DialContext+WithBlock 相当
client := chatv1.NewChatClient(conn)
✅ なぜ嬉しい?
旧 (DialContext ) | 新 (NewClient ) | 利点 |
---|---|---|
DialContext(ctx, addr, opts…) | NewClient(addr, opts…) | 接続管理(reconnect / close)を Client 内部に一元化。後から cli.Connect / cli.Close を呼ぶだけで制御可能 |
WithBlock(true) | cli.Connect(ctx) | “あとから待機” が可能。非同期接続 → 必要な所で Ready 待ち |
自動リトライなし | デフォルトで ヘルスチェック & back‑off | gRPC の推奨設定(exponential backoff)が組み込み済み |
ローカル実行の tips — 接続先を
localhost:50051
に切り替えると、コンテナを意識せずデバッグできます。
- `DialContext` は非推奨。`NewClient` + `Connect(ctx)` に置き換え。
- ローカル端末から直接実行する場合は接続先を localhost:50051 に切り替える。
6 Docker Compose で一発起動 & 可視化
6‑1 compose 抜粋
version: "3.9"
services:
jaeger:
image: jaegertracing/all-in-one:1.55
ports:
- "16686:16686" # UI
- "14268:14268" # Collector HTTP (buf/SDK のデフォルト)
server:
build:
context: ..
dockerfile: Dockerfile
working_dir: /app
command: go run ./grpc-otel-lab/cmd/server
depends_on: [jaeger]
client:
build:
context: ..
dockerfile: Dockerfile
working_dir: /app
stdin_open: true # 端末入力を有効化
tty: true
entrypoint: ["/bin/bash"]
depends_on: [server]
起動手順
# バックグラウンドでビルド & 起動
docker compose -f grpc-otel-lab/docker-compose.yml up --build -d
# 別ターミナルでクライアント用 bash に入る
docker compose -f grpc-otel-lab/docker-compose.yml exec client bash
# クライアントを起動してチャット開始
go run ./grpc-otel-lab/cmd/client # server:50051 へ接続
6‑2 Jaeger UI
- ブラウザで http://localhost:16686
- Service ドロップダウン →
chat-client
** / **chat-server
を選択- リストに無ければトレースが送信されていない → クライアントを実行してメッセージ送信後にリロード
- 選択したトレースを開き、Span 時系列 や Flame Graph でレイテンシを把握。
✅ なぜ嬉しい?
- リアルタイム性 — メッセージを送るたびに即座にトレースが追加され、遅延が視覚化。
- ボトルネックの特定速度 が格段に向上 — 各 RPC の内部処理まで分解されるため、「ネットワーク遅延か、アプリ処理か、DB 呼び出しか」をクリック 3 回で判定。
- チーム共有が容易 — Jaeger UI URL を共有するだけで他メンバーもスパンを再現可能。PR レビューで“遅い処理”を具体的に指摘できる。
7 単体ベンチマークでレイテンシを測る
双方向ストリームはネットワーク遅延の影響を受けやすく、処理ロジックそのものの速度 を測るには “メモリ内 gRPC” を使った マイクロベンチマーク が有効です。ここでは bufconn
を利用して 往復 1 回あたりのレイテンシ と アロケーション数 を定量化します。
7‑1 ベンチマークコード
func BenchmarkChat(b *testing.B) {
lis := bufconn.Listen(1024 * 1024) // in‑memory listener
s := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
chatv1.RegisterChatServer(s, &service.ChatService{})
go s.Serve(lis)
ctx := context.Background()
conn, _ := grpc.DialContext(ctx, "bufnet",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return lis.Dial() }),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
client := chatv1.NewChatClient(conn)
b.ResetTimer()
for i := 0; i < b.N; i++ {
stream, _ := client.Chat(ctx)
_ = stream.Send(&chatv1.Message{User: "bench", Text: "ping"})
stream.Recv()
}
}
7‑2 実行方法
# ルートで
go test ./grpc-otel-lab/... -bench . -benchmem -run ^$
-run ^$
で通常テストをスキップしベンチのみ実行。-benchmem
を付けると メモリアロケーション も計測できます。
7‑3 結果サンプル(M1 Pro)
指標 | 値 | 意味 |
---|---|---|
ns/op | 180 000 ns | 1 往復あたり ~0.18 ms |
allocs/op | 2 | 送受信メッセージ各 1 回の割り当て |
B/op | 4 096 B | []byte バッファ 1 つ分 |
✅ この数字から分かること
- ネットワークゼロの純粋な処理コスト が把握でき、k6 など E2E 負荷試験と比較すると遅延の“内訳”が分かる。
allocs/op
が 2 → コピーを減らす最適化余地 があるか判断可能。- ベンチを CI に組み込めば、リファクタリングでレイテンシが悪化した瞬間を検知できます。
Tip:
pprof
を付ければメモリや CPU プロファイルも同時取得可能。
まとめと次のステップと次のステップ
要素 | 旧構成に比べて得られる利点 |
---|---|
StatsHandler | コード 1 行で全 RPC にトレース付与。Interceptor 二重管理が不要。 |
NewClient API | 接続ライフサイクルをオブジェクト指向で操作。将来の 2.x 移行が容易。 |
14268 Collector 直送 | ローカルでもコンテナでもエンドポイント設定不要。送ったのに UI にない 問題を解消。 |
stdin_open client | デモやワークショップでクライアントが即停止しない。ハンズオン参加者が迷わない。 |
次回は gRPC Gateway + REST で外部公開し、k6 を用いた並列ストリーム負荷試験で スループット × 可観測性 を検証します。お楽しみに!