GoとProtocol BuffersによるgRPCマイクロサービス実践入門:サービス間通信のパフォーマンスを劇的に最適化する

マイクロサービスにおいてREST APIがボトルネックになる瞬間

分散システムを構築し始めた当初、私たちのチームはHTTP/1.1上のRESTとJSONペイロードをデフォルトの選択肢として採用していました。クラスターが2〜3サービス程度だった頃は、すべてが安定して動作していました。しかし、システムが拡大して15以上のマイクロサービスが相互に通信し合うようになると、p99のレイテンシが12msから一気に180ms近くまで跳ね上がってしまったのです。

原因は明白でした。JSONはテキストフォーマットであるため、文字列のパースに膨大なCPUサイクルを消費します。さらに、HTTP/1.1ではHead-of-Lineブロッキング(HOLブロッキング)による待ち時間が発生し、各リクエストが空いているTCPソケットを取り合うか、3ウェイハンドシェイクを伴う高コストな接続再確立を繰り返していたのです。そこでチームは、内部ネットワークの通信を全面的にgRPC、Go、そしてProtocol Buffersへ移行することを決断しました。

本質を理解する:gRPCとProtocol Buffersの仕組み

1. Protocol Buffers(Protobuf)が解決する課題

Protobufは、Googleが開発したバイナリシリアライゼーション(データ直列化)の仕組みです。{"order_id": 101, "status": "PAID"}のような冗長なJSON文字列(約34バイト)を送信する代わりに、Protobufは.protoファイルで定義されたスキーマに基づき、わずか6〜8バイトのバイト列にエンコードします。

2. gRPCが従来のRESTよりも圧倒的に優れている理由

  • HTTP/2によるマルチプレキシング(Multiplexing):単一のTCP接続上で数百ものリクエストとレスポンスを並列に多重伝送できます。接続待ち行列によるブロッキングは完全に解消されます。
  • ネットワークペイロードを60〜80%削減:バイナリデータ化とHPACKによるヘッダー圧縮により、特にスループットが数万RPSに達する環境で大幅な帯域幅の節約を実現します。
  • 厳格なAPIコントラクト(Strict Contract):クライアントおよびサーバーのコードは.protoファイルから自動生成されます。実行時に500エラーが発生するのを待つのではなく、コンパイル時点でデータ型の不整合を検知できます。

3. gRPCにおける4つの通信方式

  • Unary RPC:一般的な1対1のリクエスト・レスポンス方式で、REST APIの呼び出しとほぼ同様の感覚で利用できます。
  • Server Streaming RPC:クライアントが1つのリクエストを送信し、サーバーが接続を維持したままリアルタイムに一連のメッセージをストリーミング配信します。
  • Client Streaming RPC:クライアントがサーバーへ複数のメッセージをストリーミング送信し、サーバーから最終的な集約結果を1つ受け取ります。
  • Bidirectional Streaming RPC(双方向ストリーミング):クライアントとサーバーの双方が独立して同時にデータを送受信します。

実践:GoによるOrder Serviceの実装

ここでは、注文作成を行うUnary RPCと、配送状況をリアルタイムに更新・通知するServer Streaming RPCという2つのワークフローを持つ、実用的な注文サービス(Order Service)を構築します。

ステップ1:必要なツールのインストール

ターミナルでprotocコンパイラとGo用コード生成プラグインをインストールします:

# protoc用Goコード生成プラグインのインストール
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

# GOPATH/binをPATHに追加
export PATH="$PATH:$(go env GOPATH)/bin"

ステップ2:プロジェクトの初期化とコントラクトファイルの作成

プロジェクトディレクトリを設定します:

mkdir -p grpc-order-demo/proto
cd grpc-order-demo
go mod init grpc-order-demo

proto/order.protoファイルにスキーマを定義します:

syntax = "proto3";

package order;
option go_package = "grpc-order-demo/proto/orderpb";

message CreateOrderRequest {
  string customer_id = 1;
  double amount = 2;
  string item_name = 3;
}

message CreateOrderResponse {
  string order_id = 1;
  string status = 2;
}

message TrackOrderRequest {
  string order_id = 1;
}

message OrderStatusUpdate {
  string order_id = 1;
  string step = 2;
  string timestamp = 3;
}

service OrderService {
  // Unary RPC: 新規注文の作成
  rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);

  // Server Streaming RPC: 注文ステータスをリアルタイムにストリーミング
  rpc TrackOrder (TrackOrderRequest) returns (stream OrderStatusUpdate);
}

ステップ3:.protoファイルをGoコードへコンパイル

プロジェクトのルートディレクトリからprotocコマンドを実行します:

mkdir -p proto/orderpb
protoc --go_out=. --go_opt=paths=source_relative \
       --go-grpc_out=. --go-grpc_opt=paths=source_relative \
       proto/order.proto

mv proto/order.pb.go proto/order_grpc.pb.go proto/orderpb/
go mod tidy

この手順が完了すると、proto/orderpb内にサーバーとクライアントの通信に必要なすべての構造体やインターフェースを含む2つのファイルが自動生成されます。

ステップ4:gRPCサーバーの実装

server/main.goを作成します。サーバーはポート:50051でリッスンし、ビジネスロジックを実装します:

package main

import (
	"context"
	"fmt"
	"log"
	"net"
	"time"

	pv "grpc-order-demo/proto/orderpb"

	"google.golang.org/grpc"
)

type orderServer struct {
	pv.UnimplementedOrderServiceServer
}

func (s *orderServer) CreateOrder(ctx context.Context, req *pv.CreateOrderRequest) (*pv.CreateOrderResponse, error) {
	orderID := fmt.Sprintf("ORD-%d", time.Now().UnixNano()%100000)
	log.Printf("[Server] %s からの注文を受領: %s ($%.2f)", req.GetCustomerId(), req.GetItemName(), req.GetAmount())

	return &pv.CreateOrderResponse{
		OrderId: orderID,
		Status:  "CONFIRMED",
	}, nil
}

func (s *orderServer) TrackOrder(req *pv.TrackOrderRequest, stream pv.OrderService_TrackOrderServer) error {
	stages := []string{"注文受付", "梱包中", "配送業者へ引渡し済み", "配達完了"}

	for _, stage := range stages {
		update := &pv.OrderStatusUpdate{
			OrderId:   req.GetOrderId(),
			Step:      stage,
			Timestamp: time.Now().Format(time.RFC3339),
		}

		if err := stream.Send(update); err != nil {
			return err
		}
		time.Sleep(800 * time.Millisecond)
	}
	return nil
}

func main() {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("ポートのバインドに失敗しました: %v", err)
	}
	s := grpc.NewServer()
	pv.RegisterOrderServiceServer(s, &orderServer{})

	log.Println("gRPCサーバーが :50051 で起動しました...")
	if err := s.Serve(lis); err != nil {
		log.Fatalf("サーバー実行エラー: %v", err)
	}
}

ステップ5:gRPCクライアントの実装

別のマイクロサービスからOrder Serviceを呼び出すシミュレーションとして、client/main.goを作成します:

package main

import (
	"context"
	"io"
	"log"
	"time"

	pv "grpc-order-demo/proto/orderpb"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
)

func main() {
	// 新しいバージョンのGo gRPCではNewClientを使用
	conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("接続に失敗しました: %v", err)
	}
	defer conn.Close()

	client := pv.NewOrderServiceClient(conn)

	// 1. タイムアウト付きでUnary RPCを呼び出し
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	res, err := client.CreateOrder(ctx, &pv.CreateOrderRequest{
		CustomerId: "CUST-9921",
		Amount:     129.50,
		ItemName:   "Keychron K2 Pro キーボード",
	})
	if err != nil {
		log.Fatalf("注文作成に失敗しました: %v", err)
	}
	log.Printf("[Client] 作成成功! 注文ID: %s | ステータス: %s", res.GetOrderId(), res.GetStatus())

	// 2. Server Streaming RPCを呼び出し
	log.Println("[Client] 注文進捗をストリーミング中...")
	stream, err := client.TrackOrder(context.Background(), &pv.TrackOrderRequest{OrderId: res.GetOrderId()})
	if err != nil {
		log.Fatalf("ストリームの開始に失敗しました: %v", err)
	}

	for {
		update, err := stream.Recv()
		if err == io.EOF {
			log.Println("[Client] 配送完了。ストリームを終了します。")
			break
		}
		if err != nil {
			log.Fatalf("ストリーム読み込みエラー: %v", err)
		}
		log.Printf("-> [%s] %s", update.GetTimestamp(), update.GetStep())
	}
}

実践の勘所:バイナリペイロードのデバッグとテスト

gRPCはバイナリデータを伝送するため、RESTのように通常のcurlでエンドポイントを手軽に検証することはできません。代わりに、CLIから直接対話できるgrpcurlの利用をおすすめします。

Protobufのペイロードをフロントエンドとの連携やデバッグログ確認のためにJSONへ変換する際、手動でデータを整形すると括弧の閉じ忘れや数値型の誤認といったミスが起こりがちです。重いIDEプラグインを追加することなく手軽に構文検証やフォーマットを行いたい場合は、toolcraft.appのJSON Formatterなどのオンラインツールを活用するとスムーズです。

gRPCを採用すべきケースと、RESTを残すべきケース

gRPCとRESTは対立するものではなく、相互に補完し合う関係にあります。大規模バックエンド開発における実際の運用経験から得られた判断基準は以下の通りです:

  1. gRPCを選択すべき場合:マイクロサービス間の内部通信(East-Westトラフィック)、レイテンシ10ms未満が求められるIoTやストリーミングシステム、複数の言語(Go、Java、Pythonなど)で一貫したスキーマを共有したいチーム開発。
  2. RESTを維持すべき場合:サードパーティ向けのパブリックAPI、Webブラウザから直接叩くクライアント向けAPI(gRPC-Webプロキシ導入のオーバーヘッドを避けたい場合)、迅速なリリースが最優先の小規模なCRUDサービス。

Goの技術スタックにProtobufとgRPCを取り入れることで、システムが数万の同時接続規模にスケールした際にも、帯域幅とレイテンシのボトルネックを根本から解消できるようになります。

Share: