Go

Go × gRPC ストリーミング実践 & OpenTelemetry で可観測性を強化する

📦 サンプルコード リポジトリ : 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‑offgRPC の推奨設定(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

  1. ブラウザで http://localhost:16686
  2. Service ドロップダウン → chat-client** / **chat-server を選択
    • リストに無ければトレースが送信されていない → クライアントを実行してメッセージ送信後にリロード
  3. 選択したトレースを開き、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/op180 000 ns1 往復あたり ~0.18 ms
allocs/op2送受信メッセージ各 1 回の割り当て
B/op4 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 を用いた並列ストリーム負荷試験で スループット × 可観測性 を検証します。お楽しみに!

  • この記事を書いた人

ふくまる

機械設計業をしていたが25歳でエンジニアになると決意して行動開始→ 26歳でエンジニアに転職→ 28歳でフリーランスエンジニアに→ 現在、34歳でフリーランス7年目 Go案件を受注中 Go,GCPが得意分野

-Go