0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめてのgRPC

0
Last updated at Posted at 2026-09-20

1. この記事でやること

先に、やってみて分かったことを2つ書きます。

分かったこと
1 .proto に stream と1語足すだけで、返事が「全部そろえて1回」から「1件ずつ」に変わる。受け取る側は for 文で回すだけになる
2 コードを生成した側より、動かす側の Protocol Buffers1 が古いと、起動した時点で止まる。サーバーに入れる Python の版まで合わせに行くことになった

gRPC2 のサーバーを AWS の EC23 に1台立てて、自宅の iMac から呼び、返ってきた文字列を画面に出すところまでやりました。そのあと同じ処理を REST でも書いて、書き方とメッセージの大きさを比べています。AWS 側の部品は はじめてのTerraform と同じく Terraform で作りました。

やった順番は、.proto を書く → コードを生成する → サーバーとクライアントを書く → EC2 で動かす → ストリーミングを足す → REST と比べる → 消す、です。かかった時間とお金の数字は、途中に挟むと話が切れるので、9章にまとめて置きました。

前提を先に書いておきます。

  • Terraform と AWS CLI のインストール・AWS の認証・リージョン(東京)の設定は、前回までの記事で済ませてあります
  • 作業フォルダも前回と同じ ~/Documents/aws-terraform-study で、terraform init 済みです
  • iMac 側の Python は 3.14.7、EC2 は Amazon Linux 2023 です
  • 専門用語の説明は巻末にまとめました。本文の脚注番号を押すと説明へ飛び、説明の末尾の記号で元の場所へ戻れます

2. gRPC とは何か

gRPC は、別のサーバーにある処理を呼ぶための仕組みです。Google が社内で使っていた Stubby という仕組みを作り直して、2015年に公開したものです。

2-1. これができるようになる

できること
1 別のサーバーにある処理を、自分の関数を呼ぶのと同じ書き方で呼べる。URL もヘッダーも JSON も書かない
2 渡す値と返る値の形を .proto という1本のファイルに決めておくと、サーバー側とクライアント側のコードが自動で出てくる。項目名を打ち間違えたら、動かす前に分かる
3 言語をまたげる。同じ .proto から Python・Go・Java・C++ のコードが出るので、Python で書いたサーバーを Go のクライアントから呼べる
4 返事を「1件ずつ流す」形が1語で書ける(6章でやります)

呼び出し側のコードは実際にこの1行です。

reply = stub.SayHello(hello_pb2.HelloRequest(name="三橋"))

2-2. どういうところで使われているか

使われ方 実物
サーバー同士の通信 Kubernetes が設定の保管に使う etcd、通信を中継する Envoy
機械学習の推論サーバー TensorFlow Serving・NVIDIA Triton が gRPC の受け口を持っています
スマホアプリと自社のサーバーの間 通信量が小さいほうが有利な場面
社内のサービス同士 言語がバラバラでも .proto 1本で揃えられます

苦手なところもあります。

  • ブラウザから直接は呼べません。gRPC-Web という別の仕組みを間に挟むことになります
  • curl で中身を見られません。データがバイナリなので、目で読める形ではありません

この2つがあるので、外向けの Web API は今も REST が多く使われています。gRPC が向くのは、自分たちが両側を持っているサーバー同士の通信です。

2-3. REST との違い

見るところ gRPC REST
呼び方 関数を呼ぶ(stub.SayHello(...)) URL とメソッドを組み立てる(POST /sayhello)
データの形 Protocol Buffers のバイナリ JSON などの文字列
決めごとの置き場 .proto に書く。ここからコードが生成される 決まった置き場は無い(OpenAPI などを別に用意する)

3. .proto を書いて、コードを生成する

gRPC の土台になるファイルは3本です。自分で書くのは .proto 1本で、残りの2本は生成されます。

3-1. hello.proto を書く

まず ~/Documents/aws-terraform-study/hello.proto を書きます。中身は3つだけです。呼べる関数の一覧(service)と、渡す値(message HelloRequest)と、返る値(message HelloReply)です。

syntax = "proto3";

package hello;

// 呼べる関数の一覧(サービス)
service Greeter {
  // 名前を渡すと、あいさつの文字列が返る
  rpc SayHello (HelloRequest) returns (HelloReply);
}

// 渡す値
message HelloRequest {
  string name = 1;
}

// 返る値
message HelloReply {
  string message = 1;
}

string name = 1; の = 1 で引っかかりました。name に 1 を入れているように見えますが、これは代入ではなく項目の通し番号です。バイナリにしたときに「1番の項目」という印を付けるための番号で、後から項目を足すときは 2、3 と増やしていきます。この番号があるおかげで、項目名そのものを送らずに済みます。

3-2. コードを生成する

生成には grpcio-tools を使います。iMac の Homebrew の Python は pip での直接の追加を断るので、作業フォルダの中に venv4 を作ってそこへ入れました。

cd ~/Documents/aws-terraform-study
python3 -m venv venv
venv/bin/pip install grpcio grpcio-tools

生成はこの1本です。

venv/bin/python3 -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. hello.proto

protoc というコマンドを別に入れる記事が多いのですが、grpcio-tools を入れると python3 -m grpc_tools.protoc の形で同じものが打てます。Homebrew を触らずに済みました。版はこれで確かめられます。

venv/bin/python3 -m grpc_tools.protoc --version

3-3. 出てきた2本

ファイル 中身
hello_pb2.py HelloRequest と HelloReply のクラス定義
hello_pb2_grpc.py サーバー側が継承する土台と、クライアント側が使う呼び出し口

19行の .proto から141行が出てきました。この2本は手で編集しません。直したいときは .proto を直して、生成し直します。

4. サーバーとクライアントを書く

4-1. server.py

生成された GreeterServicer を継承して、関数の中身を書きます。通信の受け付けは grpc.server(...) が全部やるので、自分で書くのは SayHello の1つだけです。

"""gRPC サーバー。hello.proto の SayHello を実装して 50051 番で待つ(EC2 で動かす側)。"""
from concurrent import futures

import grpc

import hello_pb2
import hello_pb2_grpc


# 生成された GreeterServicer を継承して、関数の中身だけ書く
class Greeter(hello_pb2_grpc.GreeterServicer):
    def SayHello(self, request, context):
        return hello_pb2.HelloReply(message=f"こんにちは、{request.name} さん")


def main():
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=2))
    hello_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
    server.add_insecure_port("0.0.0.0:50051")
    server.start()
    print("gRPC サーバー起動。50051 番で待ち受け中(Ctrl+C で停止)", flush=True)
    server.wait_for_termination()


if __name__ == "__main__":
    main()

50051 番は gRPC の公式サンプルが使っている番号です。insecure_port は暗号化なしという意味で、学習用なのでこのままにしました。

4-2. client.py

呼ぶ側です。つなぎ先は環境変数で受け取るようにしました。EC2 のパブリック IP は作り直すたびに変わるので、コードに直接書くと毎回直すことになります。

"""gRPC クライアント。環境変数 GRPC_HOST のサーバーへ SayHello を1回呼ぶ(iMac で動かす側)。"""
import os

import grpc

import hello_pb2
import hello_pb2_grpc


def main():
    # つなぎ先は環境変数で渡す(EC2 のパブリック IP は毎回変わるため直書きしない)
    host = os.environ.get("GRPC_HOST", "localhost")
    with grpc.insecure_channel(f"{host}:50051") as channel:
        stub = hello_pb2_grpc.GreeterStub(channel)
        reply = stub.SayHello(hello_pb2.HelloRequest(name="三橋"), timeout=10)
    print(reply.message)


if __name__ == "__main__":
    main()

stub5 を作って stub.SayHello(...) を呼ぶ、これだけです。URL も、ヘッダーも、JSON への変換も出てきません。

5. EC2 で動かす

作るものはこの形です。

5-1. セキュリティグループを足す

EC2 に付ける通信の許可証がセキュリティグループ6です。ssh 用のものは前回までに作ってあるので、50051 番だけを通すものを別に作って、2枚目として重ねます。

# gRPC の 50051 番を自宅からだけ通す
resource "aws_security_group" "grpc" {
  name        = "aws-study-grpc-sg"
  description = "Allow gRPC from my IP"

  ingress {
    from_port   = 50051
    to_port     = 50051
    protocol    = "tcp"
    cidr_blocks = [var.my_ip]
  }
}

EC2 側では、2つを並べて指定します。

vpc_security_group_ids = [aws_security_group.study.id, aws_security_group.grpc.id]

EC2 には許可証を何枚も付けられます。ssh 用と gRPC 用を1枚にまとめず、別々のまま重ねられるので、あとで gRPC だけやめるときに1行外すだけで済みます。var.my_ip には自宅のグローバル IP が入ります(この記事では例として 203.0.113.45 を使います)。

5-2. apply で EC2 だけ作る

terraform plan を打つと、11個作ると出ました。前回の学習の最後に terraform destroy で全部消しているので、Terraform から見ると main.tf に書いてあるものは全部「まだ無いもの」です。この中には前回の記事で作った RDS も入っていて、そのまま apply すると一緒に立ち上がります。

今回使うのは EC2 だけなので、-target7 で絞ります。

terraform apply -target=aws_instance.study

指定したのは EC2 本体1つですが、6個できました。-target は、指定したものが必要としているものを連れてきます。鍵、セキュリティグループ2枚、IAM のロール、インスタンスプロファイル、そして EC2 本体です。RDS と S3 は EC2 が必要としていないので、作られませんでした。

5-3. コードを送って起動する

scp で4本送ります。

scp -i ~/.ssh/aws-study-key.pem hello.proto hello_pb2.py hello_pb2_grpc.py server.py ec2-user@203.0.113.45:~/

EC2 側で venv を作って、必要なものを入れます。ここで2回止まりました。詳しくは10章に書きましたが、結論だけ先に書くと、入れるものは2つ、そして Python の版を合わせる必要がありました。

sudo dnf install -y python3.14
python3.14 -m venv venv
venv/bin/pip install grpcio protobuf

起動します。ssh を抜けても動き続けてほしいので nohup を付けて、後ろで動かします。

nohup venv/bin/python3 server.py > server.log 2>&1 &

待ち受けているかは ss で見られます。

ss -ltnp

*:50051 の行に LISTEN と出て、プロセスが python3 だと分かれば起動できています。

5-4. iMac から呼ぶ

iMac に戻って、client.py を打ちます。

cd ~/Documents/aws-terraform-study
GRPC_HOST=203.0.113.45 venv/bin/python3 client.py
こんにちは、三橋 さん

EC2 の中で動いている関数の戻り値が、iMac の画面に出ました。ここが到達点です。

6. 1件ずつ返す

ここまでは「呼んだら1回返る」形でした。gRPC には、返事を1件ずつ流す形もあります。.proto の returns の前に stream と書くだけです。

  // 名前を渡すと、あいさつが5回、1秒ごとに1つずつ返る
  // returns の前に stream と書くと「1件ずつ返す」形になる
  rpc SayManyHellos (HelloRequest) returns (stream HelloReply);

書き足して生成し直すと、hello_pb2.py は40行のまま変わらず、hello_pb2_grpc.py だけが101行から146行に増えました。渡す値と返る値の形は1つも変えていないので、増えたのは呼び出し口のほうだけです。

サーバー側は return の代わりに yield8 を使います。1秒あけるので import time も足します。

    # stream を付けた関数は return ではなく yield で1件ずつ送る
    def SayManyHellos(self, request, context):
        for i in range(1, 6):
            yield hello_pb2.HelloReply(message=f"{i} 回目のこんにちは、{request.name} さん")
            time.sleep(1)

受け取る側は for 文で回します。戻り値は「全部の返事」ではなく、1件ずつ取り出せる入れ物です。

        t0 = time.time()
        # 戻り値は「全部の返事」ではなく、1件ずつ取り出せる入れ物
        for reply in stub.SayManyHellos(hello_pb2.HelloRequest(name="三橋"), timeout=30):
            print(f"{time.time() - t0:5.2f} 秒後 | {reply.message}")

打つと、1秒おきに1行ずつ増えていきます。

 0.05 秒後 | 1 回目のこんにちは、三橋 さん
 1.05 秒後 | 2 回目のこんにちは、三橋 さん
 2.05 秒後 | 3 回目のこんにちは、三橋 さん
 3.05 秒後 | 4 回目のこんにちは、三橋 さん
 4.05 秒後 | 5 回目のこんにちは、三橋 さん

5件そろうのを待たずに、1件目が届いています。全部そろえてから返す作りだと、最初の行が出るまで5秒待つことになります。処理の途中経過を出したいときや、返す件数が多いときに効きます。

7. 同じ処理を REST で書いて比べる

同じ「名前を渡すとあいさつが返る」を REST でも書きました。Python 標準の http.server だけで書いて、EC2 に追加で入れるものは無しです。

7-1. rest_server.py

"""REST サーバー。gRPC と同じ処理を URL と JSON で提供する(Python 標準の http.server だけで書く)。"""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        if self.path != "/sayhello":
            self.send_error(404)
            return
        # 本文を読んで JSON として解釈する(gRPC では生成コードがやってくれる部分)
        length = int(self.headers.get("Content-Length", 0))
        request = json.loads(self.rfile.read(length))

        body = json.dumps(
            {"message": f"こんにちは、{request['name']} さん"}, ensure_ascii=False
        ).encode("utf-8")

        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        pass


if __name__ == "__main__":
    print("REST サーバー起動。8080 番で待ち受け中", flush=True)
    HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()

gRPC で書かずに済んでいたものが、ここで全部出てきます。URL の判定、Content-Length を見て本文を読む処理、JSON への変換と戻し、ステータスコード、ヘッダー2本です。生成されたコードがこれを肩代わりしていたことが、書いてみて分かりました。

7-2. curl で呼ぶ

8080 番のセキュリティグループを足します。ここで1つ分かったことがありました。

Plan: 1 to add, 1 to change, 0 to destroy.

動いている EC2 に、許可証を後から足せます。作り直しではなく update in-place で終わり、パブリック IP も変わりませんでした。かかったのは2秒です。

curl -X POST -d '{"name": "三橋"}' http://203.0.113.45:8080/sayhello
{"message": "こんにちは、三橋 さん"}

gRPC ではできなかった「curl で確かめる」が、REST ではできます。

7-3. メッセージの大きさを測る

同じ内容が何バイトになるかを測りました。gRPC は SerializeToString() の長さ、JSON は文字列のバイト数です。

grpc_req = hello_pb2.HelloRequest(name=name).SerializeToString()
json_req = json.dumps({"name": name}, ensure_ascii=False).encode("utf-8")
gRPC REST(JSON)
送る値 8バイト 18バイト
返る値 33バイト 46バイト

返る値の日本語の本文そのものが31バイトなので、付け足しは gRPC が2バイト、JSON が15バイトです。JSON は項目名の message と、かっこと引用符がそのまま乗ります。gRPC は項目名を送らず、3-1 で書いた番号だけを送るので、付け足しが2バイトで済みます。

もう1つ、JSON 側には落とし穴がありました。json.dumps は何も指定しないと、日本語を \u3053\u3093... という書き方に置き換えます。この既定のままだと返る値は76バイトになり、2倍以上でした。上のコードで ensure_ascii=False を付けているのは、これを止めるためです。

測ったのはメッセージ本体だけで、HTTP のヘッダーや HTTP/29 のフレームは含んでいません。

7-4. 書き比べた結果

見るところ gRPC REST(http.server)
呼び出し側の書き方 stub.SayHello(HelloRequest(name="三橋")) URL・メソッド・ヘッダー・json.dumps を自分で組む
サーバー側の書き方 生成された関数の中身を1つ埋める URL の判定・本文の読み取り・JSON の解釈・ステータス・ヘッダーを自分で書く
行数(同じ処理) 26行(+生成物141行) 31行(生成物なし)
1件ずつ返す stream と1語足すだけ 素の http.server では書けない
追加で入れるもの grpcio・protobuf・grpcio-tools 無し(標準ライブラリだけ)
つなぎ先の確認 curl では見られない curl 1本で見える

自分で書く行数はほとんど同じでした。違いは、同じ行数で書いている中身が違うところです。gRPC 側の26行は全部あいさつを作る処理で、REST 側の31行は通信の組み立てが大半を占めます。

8. 消す

EC2 が要るのは 7-3 までです。使わなくなった時点で消します。

terraform destroy

消えたことは自分の目で確かめます。

aws ec2 describe-instances --filters "Name=instance-state-name,Values=running,stopped" --query 'Reservations[].Instances[].[InstanceId,State.Name]' --output text
aws ec2 describe-volumes --query 'Volumes[].[VolumeId,Size,State]' --output text

どちらも何も返らなければ、課金されているものは残っていません。EC2 は「停止」ではディスク代が止まりません。消すなら destroy まで要ります。

9. かかった時間とお金

本文で後回しにした数字を、ここに全部並べます。

時間

何に 実測
terraform apply(EC2 とセキュリティグループなど6個) 25秒(うち EC2 本体が15秒)
terraform destroy(7個) 55秒(うち EC2 本体が30秒)
セキュリティグループを1枚足す(update in-place) 2秒

立てるのに25秒、消すのに55秒でした。EC2 は消すほうが時間がかかります。前回の RDS は作るのに5分57秒・消すのに1分51秒だったので、順番が逆でした。

お金

何に 概算
EC2(m7i-flex.large) 1時間 約18.5円
ディスク 30GB 1日 約14円

今回、apply から destroy の完了まで6分24秒で、約2円でした。始める前は「2時間で約37円」と見積もっていたのですが、1時間かからずに全部終わったので、18分の1で済んでいます。

作る前に見積もりを出しておくと、終わったときに「思ったより安かった・高かった」が分かります。安く済んだ理由がはっきりしていると、次に同じことをやるときの目安にもなります。

10. つまずいたところ

つまずき 分かったこと
1 EC2 で grpcio だけ入れて server.py を打つと ModuleNotFoundError: No module named 'google' 生成された hello_pb2.py が google.protobuf を読む。grpcio は protobuf に依存していないので、pip install protobuf を別に打つ
2 protobuf を入れても VersionError: gencode 7.35.1 runtime 6.33.6 生成した側より動かす側が古いと起動しない。Amazon Linux 2023 の既定の python3 は 3.9 で、pip が入れられるのは protobuf 6.33.6 まで。sudo dnf install -y python3.14 で iMac と同じ版にそろえて通った
3 pkill -f server.py を打ったら ssh が切れた(終了コード 255) -f はコマンド行の全文で探すので、server.py という文字を含む ssh 自身の行にも当たる。名前が完全一致するものだけ見る pgrep -x python3 に変えた

2番が一番時間を取りました。エラーの文面には版の数字が2つ並んでいるだけで、どちらが手元でどちらがサーバーかは書いてありません。gencode が生成したコード側、runtime が動かす側です。サーバーの Python を新しくする方向で直しましたが、逆に iMac 側を古い版に合わせて生成する道もあります。

3番は、止めたつもりが自分の接続を止めていた形です。pkill -f の -f は便利ですが、ssh でつないでいる最中に、そのコマンド名を含む文字列で探すと自分に当たります。

  1. Protocol Buffers。gRPC が使うデータの形式。項目名を送らず、.proto で決めた番号で項目を区別するバイナリで送ります。 ↩

  2. gRPC。Google が2015年に公開した、別のサーバーの処理を関数の形で呼ぶ仕組み。社内で使っていた Stubby という仕組みが元になっています。 ↩

  3. EC2(Elastic Compute Cloud)。AWS の仮想サーバー。OS のユーザーがもらえて、ssh で入れます。 ↩

  4. venv。Python の追加パッケージを、プロジェクトのフォルダの中だけに入れる仕組み。python3 -m venv venv で作ります。 ↩

  5. スタブ。クライアント側の呼び出し口。生成されたコードの中に入っていて、stub.SayHello(...) のように自分の関数と同じ書き方で呼べます。 ↩

  6. セキュリティグループ。EC2 に付ける通信の許可証。どの IP から何番ポートへの通信を通すかを決めます。1台に何枚でも付けられます。 ↩

  7. -target。terraform apply で作るものを絞るオプション。指定したものと、それが必要としているものだけを作ります。 ↩

  8. yield。Python で「1件返して、そこで止まり、次に呼ばれたら続きから動く」を書くための語。return と違って、その場で関数が終わりません。 ↩

  9. HTTP/2。Web で使う HTTP の2番目の版。1本のつなぎの中で複数のやり取りを並行して流せます。gRPC はこの上で動いています。 ↩

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?