Microservicesを高速化:なぜ私はRESTからPython + gRPCに移行したのか?

Python tutorial - IT technology blog
Python tutorial - IT technology blog

なぜREST APIをやめてgRPCに移行したのか?

システムがまだ3〜4つのサービスで構成されていた頃は、利便性のためにREST API(HTTP/1.1経由のJSON)を多用していました。しかし、サービス数が20に増えると、状況が変わりました。内部リクエストの総数が毎分150万件に達し、2つの深刻な問題が発生し始めたのです。

1つ目は、HTTP/1.1のオーバーヘッドによるレイテンシ(Latency)の急増です。2つ目は、ペイロードが重すぎることです。冗長なJSONテキストが、無駄に多くの帯域を消費していました。内部通信をgRPCに移行して6ヶ月後、システムのパフォーマンスは劇的に改善しました。バイナリ形式のメカニズムにより、ペイロードは最大60%削減されました。特に、すべてがProtocol Buffersで厳密に定義されているため、苦労して手動でAPIドキュメントを書く必要がなくなったのが大きなメリットです。

Quick Start:5分でgRPCを動かす

理論だけでは忘れやすいため、実際にコードを書いて違いを実感してみましょう。名前を送信すると挨拶を返す、シンプルなサービスを構築します。

ステップ1:ライブラリのインストール

pip install grpcio grpcio-tools

ステップ2:.protoファイルの定義

hello.protoファイルを作成します。これがサービス間の唯一の設計図(契約)となります。

syntax = "proto3";

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

ステップ3:Pythonコードの生成

クラスを手書きする代わりに、protoファイルからPythonコードを自動生成させます:

python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. hello.proto

このコマンドにより、2つのファイルが生成されます:hello_pb2.py(メッセージ定義を含む)と hello_pb2_grpc.py(接続ロジックを含む)です。

ステップ4:サーバーとクライアントの実装

server.py ファイル:

import grpc
from concurrent import futures
import hello_pb2
import hello_pb2_grpc

class Greeter(hello_pb2_grpc.GreeterServicer):
    def SayHello(self, request, context):
        # リクエストから名前を取得し、挨拶を返す
        return hello_pb2.HelloReply(message=f'こんにちは {request.name} さん、こちらはgRPCサーバーです!')

def serve():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    hello_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
    server.add_insecure_port('[::]:50051')
    print("サーバーがポート50051で起動中...")
    server.start()
    server.wait_for_termination()

if __name__ == '__main__':
    serve()

client.py ファイル:

import grpc
import hello_pb2
import hello_pb2_grpc

def run():
    with grpc.insecure_channel('localhost:50051') as channel:
        stub = hello_pb2_grpc.GreeterStub(channel)
        # サーバーにリクエストを送信
        response = stub.SayHello(hello_pb2.HelloRequest(name='itfromzero'))
    print("クライアント受信内容: " + response.message)

if __name__ == '__main__':
    run()

パワーの源:なぜgRPCは圧倒的に速いのか?

protoファイルのコンパイルが少し手間に感じるかもしれませんが、それに見合うだけの価値があります。

1. Protocol Buffers (Protobuf) による圧縮メカニズム

RESTは {"user_id": 123} をテキスト形式で送信します。一方、Protobufはフィールド名を省略し、タグ番号と値をバイナリ形式で送信します。その結果, パケットサイズは従来のJSONに比べて3〜10倍小さくなります。

2. HTTP/2のパワーを活用

RESTは通常、HTTP/1.1の逐次的な仕組みに制限されます。対照的に、gRPCはHTTP/2上で動作し、マルチプレクシング(Multiplexing)が可能です。単一の接続で同時に数百のリクエストを送信できるため、サービス数が増えた際のリクエストのボトルネックを完全に解消します。

3. 強力な型付け(Strong Typing)によるエラー防止

「サービスAが送信したデータに、サービスBが必要なフィールドが欠けている」というエラーは、バックエンド開発者にとって悪夢です。gRPCでは、間違ったデータ型を渡すと即座にエラーになります。JSONに含まれる忌々しいnull値のために、何時間もデバッグする必要はもうありません。

高度なテクニック:実践的なデータストリーミング

ストリーミングは私のお気に入りの機能です。例えば、サーバーからクライアントへ100,000行のログをプッシュする必要があるとします。クライアントに巨大なファイルのダウンロード完了を待たせる代わりに、gRPCでは1行ずつプッシュできます。

protoファイルに stream キーワードを追加するだけです:

rpc ListLogs (LogRequest) returns (stream LogResponse) {}

サーバー側では yield を使って継続的にデータをプッシュします。この手法により、双方のデバイスのRAM負荷を大幅に軽減できます。

本番環境での6ヶ月運用から得た4つの教訓

実際の導入は、Hello Worldの例よりもはるかに複雑です。私が得た教訓を紹介します:

  • Protoファイルの一元管理: protoファイルを各サービスに散在させないでください。すべての .proto ファイルを管理する専用のGitリポジトリを作成し、Git Submoduleを使用して各サービスから参照するようにしましょう。
  • ミドルウェア (Interceptors): ロギングや認証のコードを各関数に書かないでください。Interceptorsを使用して処理を共通化することで、コードを非常にスッキリさせることができます。
  • ヘルスチェックの必須化: gRPC標準のヘルスチェックプロトコルを実装しましょう。そうしないと、Kubernetesはサービスが正常に動作しているのか、ハングアップしているのかを判断できず、自動再起動が行われません。
  • タグ番号の原則: protoファイルを更新する際、タグ番号(フィールド番号)は絶対に絶対に変更しないでください。フィールドを削除する必要がある場合は reserved を使用します。古い番号を再利用すると、新旧のデータが衝突し、システムクラッシュの原因になります。

おわりに

gRPCはすべての課題に対する万能な解決策ではありません。Webフロントエンド向けであれば、依然としてRESTが最適です。しかし、マイクロサービス間の内部通信に頭を悩ませているなら、gRPCこそがパフォーマンスを次のレベルへ引き上げる鍵となります。

まずはシステム内の最も小さなサービスから試してみてください。手動でJSONを書いていた頃に戻るのが、どれほど「面倒」だったかを感じるはずです。

Share: