GoとgRPCで構築する超高速マイクロサービス:理論から実践まで

Development tutorial - IT technology blog
Development tutorial - IT technology blog

なぜモノリスでは不十分なのか?gRPCは何のために登場したのか?

数行のコードを修正するたびに、巨大なモノリス(Monolith)全体をデプロイするのは、すべてのバックエンドエンジニアにとって悪夢です。それがマイクロサービスへと移行する理由です。しかし、システムが細分化されると、サービス間の「やり取り」が増え、パフォーマンスに関する新たな課題が生じます。

問題はどこにあるのでしょうか? 多くの開発者は依然としてJSONを用いたREST APIを使用しています。JSONは可読性が高い一方で、テキスト形式であるため重くなりがちです。コンピュータは、そのパッケージング(シリアライズ)と解析(デシリアライズ)に多くのCPUリソースを消費します。

以前、秒間約5,000リクエストを処理する決済システムを担当したことがあります。当時はJSONのオーバーヘッドが大きすぎて、内部サービス間のレイテンシが過去最高に達していました。gRPCに移行したところ、遅延は即座に40%減少し、パケットサイズは以前の1/3にまで縮小しました。Goの優れた並行処理能力と組み合わせることで、高負荷システムにとって完璧なペアとなります。

gRPCの強みを支える2つの柱

1. Protocol Buffers (Protobuf) – 小さくても強力

Protobufを、厳格な契約書のように考えてみてください。データ構造を一度定義すれば、ツールがGo、Python、Javaなどのコードを自動生成してくれます。バイナリ形式で転送されるため、データは非常に軽量で、変換時の遅延もほとんどありません。

2. HTTP/2 – データの高速道路

古いHTTP/1.1とは異なり、HTTP/2はマルチプレクシング(Multiplexing)をサポートしています。1つの接続で同時に数十のリクエストを送信できるため、前のリクエストが終わるまで次が待たされる「ヘッドオブラインブロッキング(Head-of-line blocking)」の問題が解消されます。

実践:シンプルな計算サービスの構築

素早く理解するために、2つの数値を加算するサービスを作成します。シンプルですが、実務で最も重要なステップをすべて網羅しています。

ステップ 1: 環境構築

マシンにGoがインストールされている必要があります。次に、コード生成に必要なprotocコンパイラとプラグインをインストールします:

go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

ステップ 2: .protoファイルの設計

calculator.protoファイルを作成します。ここでクライアントとサーバー間の通信方法を定義します。

syntax = "proto3";

package calculator;

option go_package = "./pb";

message SumRequest {
    int32 a = 1;
    int32 b = 2;
}

message SumResponse {
    int32 result = 1;
}

service CalculatorService {
    rpc Sum(SumRequest) returns (SumResponse);
}

以下のコマンドを実行して、pbディレクトリにGoのコードを自動生成します:

mkdir pb
protoc --go_out=. --go-grpc_out=. calculator.proto

ステップ 3: サーバーの実装

計算ロジックを実装します。server/main.goファイルを作成します:

package main

import (
	"context"
	"log"
	"net"

	"google.golang.org/grpc"
	pb "your-module-path/pb"
)

type server struct {
	pb.UnimplementedCalculatorServiceServer
}

func (s *server) Sum(ctx context.Context, req *pb.SumRequest) (*pb.SumResponse, error) {
	return &pb.SumResponse{Result: req.A + req.B}, nil
}

func main() {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("ポートエラー: %v", err)
	}

	s := grpc.NewServer()
	pb.RegisterCalculatorServiceServer(s, &server{})

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

ステップ 4: テスト用クライアントの作成

作成したサービスを呼び出すためのclient/main.goファイルを作成します:

package main

import (
	"context"
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials/insecure"
	pb "your-module-path/pb"
)

func main() {
	conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials()))
	if err != nil {
		log.Fatalf("接続できません: %v", err)
	}
	defer conn.Close()

	c := pb.NewCalculatorServiceClient(conn)
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	r, err := c.Sum(ctx, &pb.SumRequest{A: 10, B: 20})
	if err != nil {
		log.Fatalf("関数呼び出しエラー: %v", err)
	}

	log.Printf("結果: %d", r.GetResult())
}

gRPC運用における「血の教訓」

いくつかの実プロジェクトを経て、運用で苦労しないための3つの重要なポイントをまとめました:

  • 標準エラーコードの使用: 汎用的なエラーを返すだけでは不十分です。codes.InvalidArgumentcodes.NotFoundを使用して、クライアントが何をすべきか正確に伝えましょう。
  • 常にデッドライン(Deadline)を設定する: マイクロサービスでは、1つのサービスが停止すると連鎖的にシステム全体がダウンする可能性があります。すべてのリクエストにタイムアウト(例:500ms)を設定し、システムの回復力を高めましょう。
  • リクエストのトレース: 10個のサービスが互いに呼び出し合う場合、JaegerやOpenTelemetryがなければ、どこでエラーが発生したか特定できません。初日からこれらを導入しましょう。

コードを書く前に.protoファイルを確定させる「デザインファースト」の手法を取り入れることで、チームの生産性が30%向上しました。データ型が最初に合意されるため、フィールドが文字列か数値かで揉めることもなくなります。

終わりに

GoとgRPCはシステムを高速化するだけでなく、コードをよりプロフェッショナルで明快なものにします。RESTに比べて初期設定に少し手間はかかりますが、帯域幅と速度のメリットはそれ以上に価値があります。スムーズなシステム構築を!

Share: