GoにおけるgRPC実践入門:Protocol Buffers、ストリーミング、インターセプターで実現する高性能マイクロサービス

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

午前2時14分のインシデント:ブラックフライデーの大崩壊

午前2時14分、スマートフォンのアラートがけたたましく鳴り響きました。チェックアウトクラスターが504 Gateway Timeoutを連発し、社内サービスメッシュのp99レイテンシは18msから4,200msへと急上昇。注文処理は完全に停止し、監視ダッシュボードは一面真っ赤に染まっていました。

設計書の上では、教科書通りの構成でした。エッジのAPI Gatewayが下流のOrder Service、Payment Gateway、そしてInventory Workerと通信する構成です。しかし、Goサービス間の通信はすべてHTTP/1.1経由の内部REST APIに依存しており、通信のたびにJSONペイロードのシリアライズとデシリアライズを繰り返していました。

そこへ毎秒12,000リクエストのフラッシュセールが襲来しました。ソケットは瞬く間にキューに滞留し始めました。JSONボディを処理するたびにリフレクション、文字列のメモリ割り当て、新規TCPハンドシェイクが発生。シリアライズのオーバーヘッドによってInventory Workerは窒息状態に陥りました。上流サービスでは数分でアイドルソケットプールが枯渇し、連鎖的なトラフィックの完全麻痺へと発展したのです。

根本原因分析:JSONとHTTP/1.1が破綻した理由

インシデント対応の最中、pprofを用いてヒープとCPUのプロファイルを取得しました。システム崩壊を招いた原因は、明確に3点ありました。

  • JSONシリアライズのオーバーヘッド: JSONは人間には扱いやすいものの、マシンにとっては高コストです。Goの標準パッケージencoding/jsonはランタイムで重いリフレクションを使用します。高負荷時、サービスの全CPUサイクルの35%が、ネストされた構造体のパック/アンパックやマップキーのパースだけに消費されていました。1回のリクエストにつき、1つのチェックアウトペイロードだけでヒープ上に42 KB(180回のメモリアロケーション)が割り当てられていたのです。
  • HTTP/1.1のHead-of-Line Blocking(先頭ブロック): 内部REST呼び出しのたびに、完全なレスポンスが返るまでTCPコネクションが専有されました。下流に向けて3,000件の並列呼び出しが走った際、コネクションプールは上限である512ソケットに即座に到達。Goのワーカーはビジネスロジックを実行する代わりに、新規TLSハンドシェイクの確立に45ms〜120msも足止めされることになりました。
  • 曖昧なスキーマとサイレントエラー: サービス間のデータ型の不一致により、検出しにくいアンマーシャルエラーが発生していました。これにより高コストなリトライループが誘発され、すでに過負荷状態だったシステムにさらなる追い打ちをかける結果となりました。

アーキテクチャの評価:3つの選択肢

ポストモーテム(事後検証)において、プラットフォームチームは次の3つの選択肢を議論しました。

1. 高速JSONコード生成ツールの導入

HTTP/1.1のKeep-Aliveプール拡張とともに、easyjsonやjson-iteratorなどのライブラリを検討しました。これによりアロケーション数は約40%削減できたものの、トランスポート層の根本的な問題には手が付けられませんでした。HTTP/1.1のHead-of-Line Blockingやコネクション枯渇は依然として残り、まるでヒビの入ったエンジンブロックにレーシングステッカーを貼るような対症療法にすぎませんでした。

2. 非同期メッセージブローカーの導入

別のエンジニアからは、社内の全通信をApache KafkaやRabbitMQ経由にする案が出ました。領収書メールの送信など、疎結合なバックグラウンド処理において非同期メッセージキューは威力を発揮します。しかし、クレジットカード決済前の在庫確保のように即時レスポンスが必須なユースケースでは、運用負荷が大幅に増えるだけでなく、Pub/Subによる15ms〜30msのオーバーヘッドが上乗せされてしまいます。

3. gRPCによるコアサービス間通信

圧倒的な勝者となったのはgRPCでした。gRPCはHTTP/2上でネイティブに動作し、単一の持続的TCPコネクションを介して数百の並行RPCを多重化(マルチプレクス)できます。また、Protocol Buffers(Protobuf)がスキーマを効率的で強い型付けのGoコードへと直接コンパイルします。バイナリシリアライズは極めて高速で、ランタイムでのリフレクションを完全に排除できます。

実装:Goによる本番品質のgRPC構築

レイテンシのスパイクを解消するため、内部RPCをgRPCへと移行しました。厳格なProtobufコントラクト、テレメトリ用のUnaryインターセプター、リアルタイムな在庫フィード用の双方向ストリーミングを導入しました。

ステップ1:Protocol Buffersによる厳格なコントラクト定義

まずは.protoファイルで明確なスキーマ定義を作成します。これにより、チーム間で曖昧さのない、厳密に型付けされたコントラクトが確立されます。

syntax = "proto3";

package order.v1;
option go_package = "order/v1;orderv1";

service OrderService {
  // Unary RPC:単一リクエスト・単一レスポンス
  rpc CreateOrder (CreateOrderRequest) returns (CreateOrderResponse);

  // サーバーサイドストリーミング:注文イベントをリアルタイムに追跡
  rpc StreamOrderUpdates (OrderSubscriptionRequest) returns (stream OrderStatusUpdate);
}

message CreateOrderRequest {
  string customer_id = 1;
  repeated string item_ids = 2;
  double total_amount = 3;
}

message CreateOrderResponse {
  string order_id = 1;
  string status = 2;
  int64 timestamp = 3;
}

message OrderSubscriptionRequest {
  string order_id = 1;
}

message OrderStatusUpdate {
  string order_id = 1;
  string status = 2;
  string details = 3;
}

公式のGoプラグインを指定してprotocを実行し、Goのスタブコードを生成します。

protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    order.proto

ステップ2:テレメトリとパニック復帰のためのUnaryインターセプター

本番環境で素のgRPCサーバーをそのまま公開してはいけません。インターセプターはミドルウェアとして機能し、すべてのRPC呼び出しに対して構造化ロギング、メトリクス収集、パニックリカバリを一元的に処理します。

package main

import (
	"context"
	"log/slog"
	"os"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

func LoggingAndRecoveryInterceptor(logger *slog.Logger) grpc.UnaryServerInterceptor {
	return func(
		ctx context.Context,
		req interface{},
		info *grpc.UnaryServerInfo,
		handler grpc.UnaryHandler,
	) (resp interface{}, err error) {
		start := time.Now()

		defer func() {
			if r := recover(); r != nil {
				logger.Error("RPC内でパニックを捕捉", "method", info.FullMethod, "panic", r)
				err = status.Errorf(codes.Internal, "内部サーバーエラー")
			}
		}()

		resp, err = handler(ctx, req)
		duration := time.Since(start)

		st, _ := status.FromError(err)
		logger.Info("RPCリクエストを処理完了",
			"method", info.FullMethod,
			"duration_ms", duration.Milliseconds(),
			"code", st.Code().String(),
		)

		return resp, err
	}
}

ステップ3:ストリーミングエンドポイントを備えたサーバーの構築

ミドルウェアとストリーミングハンドラーを組み込み、注文サービスを構築する手順は以下の通りです。

package main

import (
	"context"
	"fmt"
	"log/slog"
	"net"
	"os"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	pb "order/v1/orderv1"
)

type OrderServer struct {
	pb.UnimplementedOrderServiceServer
}

func (s *OrderServer) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.CreateOrderResponse, error) {
	if req.CustomerId == "" {
		return nil, status.Error(codes.InvalidArgument, "customer_idは必須です")
	}

	return &pb.CreateOrderResponse{
		OrderId:   "ord-98214",
		Status:    "CONFIRMED",
		Timestamp: time.Now().Unix(),
	}, nil
}

func (s *OrderServer) StreamOrderUpdates(req *pb.OrderSubscriptionRequest, stream pb.OrderService_StreamOrderUpdatesServer) error {
	stages := []string{"PACKING", "DISPATCHED", "DELIVERED"}

	for _, stage := range stages {
		update := &pb.OrderStatusUpdate{
			OrderId: req.OrderId,
			Status:  stage,
			Details: fmt.Sprintf("注文が%sステージに進みました", stage),
		}

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

	return nil
}

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		logger.Error("リスンに失敗しました", "error", err)
		os.Exit(1)
	}

	server := grpc.NewServer(
		grpc.UnaryInterceptor(LoggingAndRecoveryInterceptor(logger)),
	)

	pb.RegisterOrderServiceServer(server, &OrderServer{})
	logger.Info("gRPCサーバーがポート50051で待受中")
	if err := server.Serve(lis); err != nil {
		logger.Error("サーバーが予期せず終了しました", "error", err)
	}
}

ステップ4:本番向けクライアントコネクションプールの構築

クライアント側では、シングルトン接続を維持し続けます。なお、最近のGo gRPCリリースではgrpc.Dialが非推奨となっているため、代わりにgrpc.NewClientを使用してください。

package main

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

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	pb "order/v1/orderv1"
)

func main() {
	// grpc.NewClientを使用(最近のgrpc-goリリースではgrpc.Dialは非推奨)
	conn, err := grpc.NewClient("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("接続のセットアップに失敗しました: %v", err)
	}
	defer conn.Close()

	client := pb.NewOrderServiceClient(conn)

	// Unary呼び出しに明示的なタイムアウトを設定
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	resp, err := client.CreateOrder(ctx, &pb.CreateOrderRequest{
		CustomerId:  "cust-441",
		ItemIds:     []string{"sku-101", "sku-202"},
		TotalAmount: 149.99,
	})
	if err != nil {
		log.Fatalf("CreateOrderに失敗しました: %v", err)
	}
	log.Printf("注文作成完了: id=%s status=%s", resp.OrderId, resp.Status)

	// 同一の多重化コネクション上で注文の更新状況をストリーミング受信
	streamCtx, streamCancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer streamCancel()

	stream, err := client.StreamOrderUpdates(streamCtx, &pb.OrderSubscriptionRequest{OrderId: resp.OrderId})
	if err != nil {
		log.Fatalf("StreamSubscriptionに失敗しました: %v", err)
	}

	for {
		update, err := stream.Recv()
		if err == io.EOF {
			break
		}
		if err != nil {
			log.Fatalf("ストリーム読み込みエラー: %v", err)
		}
		log.Printf("ステータス更新: [%s] %s", update.Status, update.Details)
	}
}

成果:本番ベンチマークメトリクス

OrderクラスターとInventoryクラスターをgRPCへ移行した後、毎秒15,000リクエストの負荷テストを再実施しました。その効果は劇的でした。

  • ネットワーク転送量(Wire Footprint): Protobufのフィールドタグとバイナリパッキングのおかげで、平均注文ペイロードが1,240バイトから395バイトへと68%削減されました。
  • メモリフットプリント: RPCあたりのヒープアロケーションが42 KBから3.8 KBへと激減。ガベージコレクタ(GC)の停止時間を74%短縮しました。
  • CPU使用率: 同一のワークロード下において、注文ワーカー全体のCPU使用率が78%から49%へと大幅に低下しました。
  • p99レイテンシ: メッシュ内のp99レイテンシは盤石の2.4msを記録。通常負荷時の18msから大幅に短縮され、かつてのような4,000ms超のスパイクも完全に抑え込みました。

もし内部サービスで今なおプールされていないHTTP/1.1接続を使い、生のJSONをやり取りしているなら、次の大規模トラフィック急増で破綻するのは時間の問題です。GoでgRPCへ移行すれば、現代のクラウドプラットフォームに真に求められる高スループット、予測可能なレイテンシ、そして強固なスキーマ安全性を手に入れることができます。

Share: