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 でつないでいる最中に、そのコマンド名を含む文字列で探すと自分に当たります。
-
Protocol Buffers。gRPC が使うデータの形式。項目名を送らず、
.protoで決めた番号で項目を区別するバイナリで送ります。 ↩ -
gRPC。Google が2015年に公開した、別のサーバーの処理を関数の形で呼ぶ仕組み。社内で使っていた Stubby という仕組みが元になっています。 ↩
-
EC2(Elastic Compute Cloud)。AWS の仮想サーバー。OS のユーザーがもらえて、ssh で入れます。 ↩
-
venv。Python の追加パッケージを、プロジェクトのフォルダの中だけに入れる仕組み。
python3 -m venv venvで作ります。 ↩ -
スタブ。クライアント側の呼び出し口。生成されたコードの中に入っていて、
stub.SayHello(...)のように自分の関数と同じ書き方で呼べます。 ↩ -
セキュリティグループ。EC2 に付ける通信の許可証。どの IP から何番ポートへの通信を通すかを決めます。1台に何枚でも付けられます。 ↩
-
-target。
terraform applyで作るものを絞るオプション。指定したものと、それが必要としているものだけを作ります。 ↩ -
yield。Python で「1件返して、そこで止まり、次に呼ばれたら続きから動く」を書くための語。
returnと違って、その場で関数が終わりません。 ↩ -
HTTP/2。Web で使う HTTP の2番目の版。1本のつなぎの中で複数のやり取りを並行して流せます。gRPC はこの上で動いています。 ↩