本記事は、gRPC 素人の筆者(一応、IT技術者)が一念発起、AWS で Connect を実行するまでの記録です。Connect は Node.js や Kotlin などもサポートしますが今回は、Go を用います、シリアライズから始めて一歩ずつ進めていき最後に AWS の ALB(Application Load Balancer)のバックエンドで動作を確認します。参考程度に処理速度も計測します。AWS の環境構築手順には触れません。
gRPC については、次の素晴らしい解説がとても参考になりました。
Protocol Buffers でシリアライズ
プログラミングでは、名前や年齢、電話番号といったデータを構造体やクラス、連想配列などでメモリ上に展開するが、それをファイルに保存したり他のアプリケーションへ送るには、シリアライズ(直列化)が必要。シリアライズとは、連続するバイト列でデータを表現すること。
シリアライズには、大きくテキスト型とバイナリ型の二種類ある。
テキスト型のシリアライズ
テキスト型のシリアライズは、人間が意味を読み取り易くかつ互換性が高い一方で、サイズが大きくなりがちで、一般論としてあまり効率的ではない。
テキスト型のシリアライズには、CSV(Comma Separated Values)や XML(Extensible Markup Language)、JSON(JavaScript Object Notation)などがある。
CSV の例
name,age,phoneNumber
Yamada,20,090-123-4567
XML の例
<Users>
<User>
<Name>Yamada</Name>
<Age>20</Age>
<PhoneNumber>090-123-4567</PhoneNumber>
</User>
</Users>
JSON の例
[
{
"name": "Yamada",
"age": "20",
"phoneNumber": "090-123-4567"
}
]
バイナリ型のシリアライズ Protocol Buffers
バイナリ型のシリアライズは、効率的な一方で、32ビットと64ビットの違いやエンディアン(バイトを並べる順序)の違いなど、互換性に課題がある。このためバイナリ型のシリアライズは固有の実装となりがちだが、プログラム言語やプラットフォームに依存しない方法の1つに Protocol Buffers (略して Protobuf)がある。
Protobuf は、最初にデータ構造(プロト)を定義する。
プロト定義の例
syntax = "proto3";
package user.v1;
message User {
string name = 1;
int32 age = 2;
string phone_number = 3;
}
syntax
に Protocol Buffers Languageバージョン(proto2
または proto3
)を指定する。省略すると ptoro2
項目名の後ろの =1
, =2
, =3
は、代入ではなく項目の一意な識別番号
プロトを定義すると各種言語向けにバイナリデータにアクセスするためのインターフェイスコードを生成できる。
Python 用のインターフェイスコードを生成する例
$ mkdir gen/
$ protoc --proto_path=. --python_out=gen user/v1/user.proto
$ ls gen/user/v1
user_pb2.py
生成されたインターフェイスコードを用いてシリアライズできる。
シリアライズでファイルを読み書きする Python のコード例
from gen.user.v1 import user_pb2 # インターフェイスコードをインポート
# データ作成
u1 = user_pb2.User()
u1.name = "John Doe"
u1.age = 20
u1.phone_number = "555-4321"
# ファイルへ書き込み
with open("user.dat", "wb") as f:
f.write(u1.SerializeToString())
# ファイル読み込み
u2 = user_pb2.User()
with open("user.dat", "rb") as f:
u2.ParseFromString(f.read())
print(u2)
実行
$ python main.py
name: "John Doe"
age: 20
phone_number: "555-4321"
JSON と比るとファイルサイズは半分以下に
$ ls -l
58 user.json
22 user.dat
HTTP/2
HTTP/2 の大きな特徴の1つはストリーム。単一のTCPコネクション上で複数の HTTPリクエストと HTTPレスポンスをやりとり(多重化)できる。
gRPC(gRPC Remote Procedure Calls)
gRPC は、バイナリ型のシリアライズ Protobuf と HTTP/2 を組合わせることができ、効率的かつ高度な通信を実現できる。
基本の関数呼び出し(1つのリクエストに1つのレスポンスを返す Unary RPC)はもちろんのこと
こんなことや(1つのリクエストに複数のレスポンスを返す Server Streaming RPC)
あんなこと(複数のリクエストに1つのレスポンスを返す Client Streaming RPC)
そんなこと(n 対 m の Bidirectional Streaming RPC)までできる。
優れた gRPC だが、あまり人に易しくないという側面がある。
バイナリ型のシリアライズはテキストのように編集できず、Curl コマンドや WEBブラウザの JavaScript からも手軽に呼べない。Protobuf のプロト定義はシンプルで分かり易いが、その代わりインターフェイスコードの管理が必要。
Connect
2022 年10月にバージョン 1.0 がリリースされた Connect なら、そんな gRPC の煩わしさから解放してくれるかもしれない。
ローカルPC 検証環境
- Ubuntu 20.04.5 LTS / WSL2
- go 1.20.1
- curl 7.68.0
以下、Connect ドキュメントの Getting Started を参考に進める。
大まかな作業の流れは次図の通り。
まずプロト定義を作成し Linter でチェック。問題なければ Bufプラグインでインタフェースコードを生成。そのコードをインポートし connect-goサーバと connect-go クライアントを実装。コード生成は、ツール protoc ではなく Protobuf API 管理ツールの Buf CLI を用いる。
Buf CLI インストール
GitHub リリースページからバイナリをダウンロードし /usr/local/bin/
に配置
$ curl -sSL https://github.com/bufbuild/buf/releases/download/v1.17.0/buf-Linux-x86_64 -o buf
$ chmod +x buf
$ sudo mv buf /usr/local/bin/
$ buf --version
1.17.0
プロト定義
プロト定義を配置するフォルダ proto/user/v1
を作成
$ mkdir -p proto/user/v1
プロト定義を記述
syntax = "proto3";
package user.v1;
option go_package = "example/gen/user/v1;userv1";
message User {
string name = 1;
int32 age = 2;
string phone_number = 3;
}
message GetUserRequest {
string user_name = 1;
}
message GetUserResponse {
User user = 1;
}
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
}
上記は、メッセージの型 User
を扱うサービス UserService
に User
を返すメソッド GetUser()
を定義している。
buf.yaml ファイル作成
プロト定義のルート階層に proto/buf.yaml
を作成
$ buf mod init -o proto/
version: v1
breaking:
use:
- FILE
lint:
use:
- DEFAULT
Linter
プロト定義を Linter でチェック。指摘がなければ何も表示されない
$ buf lint proto/
コード生成
コード生成プラグインを制御する buf.gen.yaml
を記述。
次の buf.gen.yaml
は3つのプライグインを実行する。
- Python のインターフェイスコード生成
- Go のインターフェイスコード生成
- connect-goのクライアントスタブ、サーバースタブ生成
version: v1
plugins:
- plugin: buf.build/protocolbuffers/python
out: gen
- plugin: buf.build/protocolbuffers/go
out: gen
opt: paths=source_relative
- plugin: buf.build/bufbuild/connect-go
out: gen
opt: paths=source_relative
Buf リモートプラグインのリストは、Buf Schema Registry, BSR を参照
この時点でのフォルダ構成は次の通り
.
├── buf.gen.yaml
└── proto/
├── buf.yaml
└── user/
└── v1/
└── user.proto
プラグインを実行しコード生成
$ buf generate proto/
フォルダ gen/
以下にコードが生成される。
フォルダ構成は次のようになった。
.
├── buf.gen.yaml
├── gen/
│ └── user/
│ └── v1/
│ ├── user.pb.go
│ ├── user_pb2.py
│ └── userv1connect/
│ └── user.connect.go
└── proto/
├── buf.yaml
└── user/
└── v1/
└── user.proto
-
gen/user/v1/user.pb.go
には、gRPC メソッドGetUser()
の引数と戻り値に使用する構造体GetUserRequest
とGetUserResponse
が定義されている -
gen/user/v1/userv1connect/user.connect.go
には、サーバの HTTPハンドラ インタフェースUserServiceHandler
とクライアントのコンストラクタNewUserServiceClient
が定義されている
connect-go を実装
connect-go サーバ
生成されたコードを import
し connect-go サーバを実装
package main
import (
"context"
"log"
"net/http"
"github.com/bufbuild/connect-go"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
userv1 "example/gen/user/v1" // generated by protoc-gen-go
"example/gen/user/v1/userv1connect" // generated by protoc-gen-connect-go
)
type UserServer struct{}
func (s *UserServer) GetUser(
ctx context.Context,
req *connect.Request[userv1.GetUserRequest],
) (*connect.Response[userv1.GetUserResponse], error) {
res := connect.NewResponse(&userv1.GetUserResponse{
User: &userv1.User{
Name: req.Msg.Name,
Age: 21,
PhoneNumber: "090-123-4567",
},
})
return res, nil
}
func main() {
svr := &UserServer{}
mux := http.NewServeMux()
path, handler := userv1connect.NewUserServiceHandler(svr)
mux.Handle(path, handler)
http.ListenAndServe(
"0.0.0.0:8080",
// Use h2c so we can serve HTTP/2 without TLS.
h2c.NewHandler(mux, &http2.Server{}),
)
}
上記は、gRPC のメソッド GetUser()
を実装したもの。構造体 UserServer
に HTTPハンドラ インタフェース UserServiceHandler
(この場合、GetUser()
)を実装する。
HTTPハンドラ インタフェース UserServiceHandler
...
type UserServiceHandler interface {
GetUser(context.Context, *connect_go.Request[v1.GetUserRequest]) (*connect_go.Response[v1.GetUserResponse], error)
}
...
connect-go クライアント
生成されたコードを import
し connect-go クライアントを実装
package main
import (
"context"
"log"
"net/http"
userv1 "example/gen/user/v1"
"example/gen/user/v1/userv1connect"
"github.com/bufbuild/connect-go"
)
func main() {
httpClient := http.DefaultClient
client := userv1connect.NewUserServiceClient(
httpClient,
"http://localhost:8080",
)
res, err := client.GetUser(
context.Background(),
connect.NewRequest(&userv1.GetUserRequest{Name: "Mike"}),
)
if err != nil {
panic(err)
}
log.Println(res.Msg.User)
}
上記は、gRPC のメソッド GetUser()
を呼び出している。
動作確認
Go プロジェクトを初期化
$ go mod init example
$ go mod tidy
connect-go サーバを起動
$ go run ./cmd/server/main.go
connect-go クライアントを実行。GetUser() を呼び出せることを確認
$ go run cmd/client/main.go
name:"Mike" age:21 phone_number:"090-123-4567"
Curlコマンドでも GetUser() を呼び出せることを確認
$ curl --header "Content-Type: application/json" --data '{"name": "Jane"}' http://localhost:8080/user.v1.UserService/GetUser
{"user":{"name":"Jane", "age":21, "phoneNumber":"090-123-4567"}}
パッケージ net/http との互換性
connect-go は、Golang 標準パッケージ net/http と互換があり、サードパーティのライブラリを駆使しやすいとのことなので試します。
connect-goサーバに次を実装し呼び出せることを確認する。
- JSON を返す HTTPヘルスチェックエンドポイント
- 静的ファイルを返すエンドポイント
- サードパーティの chi ルーター に差し替え
package main
import (
...
+ "github.com/go-chi/chi/v5"
...
)
...
func main() {
svr := &UserServer{}
// chiルーター
- mux := http.NewServeMux()
+ mux := chi.NewRouter()
// gRPCエンドポイント
path, handler := userv1connect.NewUserServiceHandler(svr)
- mux.Handle(path, handler)
+ mux.Handle(path+"*", handler)
// HTTPヘルスチェックエンドポイント
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _, _ = w.Write([]byte(`{"status": "ok"}`))
+ })
// 静的ファイルを返すエンドポイント
+ mux.Handle("/", http.FileServer(http.Dir("static")))
http.ListenAndServe( ... )
static/index.html
を作成
Welcome to connect-go demo!
connect-go サーバ実行
$ go mod tidy
$ go run ./cmd/server/main.go
次を確認
- gRPCエンドポイントを connect-goクライアントで呼び出せる
- gRPCエンドポイントを Curl で呼び出せる
- HTTPヘルスチェックエンドポイントを Curl で呼び出せる
- 静的ファイル
index.html
を Curl で取得できる
# gRPCエンドポイント
$ go run cmd/client/main.go
name:"Mike" age:21 phone_number:"090-123-4567"
# gRPCエンドポイント
$ curl --header "Content-Type: application/json" --data '{"name": "Jane"}' http://localhost:8080/user.v1.UserService/GetUser
{"user":{"name":"Jane","age":21,"phoneNumber":"090-123-4567"}}
# ヘルスチェックエンドポイント
$ curl http://localhost:8080/health
{"status": "ok"}
# 静的ファイル
$ curl http://localhost:8080/
Welcome to connect-go demo!
いずれも問題なく呼び出せた。
なお、以後の検証では chi ルーターは用いない。
HTTP/1.1 と HTTP/2
先へ進む前に HTTP/2 が使われているかどうかを調べる方法と、HTTP/2 を強制する方法を確認しておきます。
Go と HTTP/2
Go の net/http
パッケージは、環境変数 GODEBUG
の設定で HTTP/2 サポートを無効化したり、HTTP/2 が使われている場合に(のみ)、http2 デバッグメッセージ をログに出力できる。
GODEBUG 環境変数サポート
GODEBUG=http2client=0 # HTTP/2 クライアントのサポートを無効にする
GODEBUG=http2server=0 # HTTP/2 サーバのサポートを無効にする
GODEBUG=http2debug=1 # 詳細な HTTP/2 デバッグ ログを有効にする
GODEBUG=http2debug=2 # ... フレームダンプでさらに詳細に
http2 デバッグメッセージを有効化し connect-go サーバを実行
$ GODEBUG=http2debug=1 go run ./cmd/server/main.go
HTTP/2 が使われていれば次のような http2 デバッグメッセージ が出力される
http2: server processing setting [MAX_CONCURRENT_STREAMS = 100]
http2: server processing setting [INITIAL_WINDOW_SIZE = 1073741824]
http2: server processing setting [ENABLE_PUSH = 0]
... <以下略> ...
connect-goクライアントから gRPC 呼び出すと http2 デバッグメッセージ は出力されなかったので HTTP/2 は使われていない模様。
connect-goサーバは h2c
パッケージを用いている。h2c
パッケージとは、非TLS(httpスキーム)での HTTP/2 サポートを可能にするパッケージ。
http.ListenAndServe(
"0.0.0.0:8080",
// Use h2c so we can serve HTTP/2 without TLS.
h2c.NewHandler(mux, &http2.Server{}),
)
h2c(HTTP/2 Cleartext)とは
h2
や h2c
は、HTTP/2 バージョンの識別子
-
h2
は暗号化された Transport Layer Security (TLS) を使用する HTTP/2 プロトコルを識別する。つまり https の HTTP/2 -
h2c
は平文の TCP 上での HTTP/2 プロトコルを識別する。つまり http の HTTP/2
だとすれば HTTP/2 が使われてもよさそうだがそうならないのは、connect-goクライアントが非TLS での HTTP/2 を無効にしているから。
環境変数 GODEBUG
は、HTTP/2 を無効にできるが有効化を強制することはできないようなので、非TLS の HTTP/2 サポートを強制するにはコードを改変する必要がある。
HTTP/2 を強制する connect-goクライアントコード例
package main
import (
"context"
+ "crypto/tls"
"log"
+ "net"
"net/http"
userv1 "example/gen/user/v1"
"example/gen/user/v1/userv1connect"
"github.com/bufbuild/connect-go"
+ "golang.org/x/net/http2"
)
func main() {
- httpClient := http.DefaultClient
+ httpClient := &http.Client{
+ Transport: &http2.Transport{
+ AllowHTTP: true,
+ DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
+ return net.Dial(network, addr)
+ },
+ },
+ }
client := userv1connect.NewUserServiceClient(
httpClient,
"http://localhost:8080",
)
...
- HTTPクライアントのトランスポートに
http2.Transport
を指定-
AllowHTTP: true
とし httpスキーム(平文)を許可 -
DialTLS
にTLS証明書を必要としないダイアル関数を指定。デフォルトは TLS証明書が必要なtls.Dial
を使用するため
-
Curl と HTTP/2
Curl は、オプション --http2
で HTTP/2 を強制できる。HTTP/1.1 を強制したければ --http1.1
を指定。
$ curl --http2 -H "Content-Type: application/json" --data '{"name": "Jane"}' http://localhost:8080/user.v1.UserService/GetUser
オプション -v
で詳細を確認できる。
$ curl -v --http2 --header "Content-Type: application/json" --data '{"name": "Jane"}' http://localhost:8080/user.v1.UserService/GetUser
...
> Connection: Upgrade, HTTP2-Settings
> Upgrade: h2c
...
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
...
< HTTP/2 200
...
上記の場合、HTTP/1.1 から HTTP/2(h2c)へ Upgrade している。
HTTP/1.1 と HTTP/2 に互換性はない。このため HTTP/2 通信を開始するには相手と HTTP/2 の使用を合意する必要があり、いくつかの方法が仕様に定義されている。
- ALPNを使用する
- HTTP/1.1からアップグレードする
- ダイレクトで開始する
ALPN は TLS(https)が前提。TLS を用いない場合は二つめのアップグレードが行われる。その場合、最初にHTTP/1.1で通信を開始した後、そのコネクションをHTTP/2 にアップグレードする。
参照:「HTTP/2とは - JPNIC」
Curl にオプション --http2-prior-knowledge
を指定するとアップグレードではなくダイレクトに HTTP/2 を開始できる
$ curl -v --http2-prior-knowledge --header "Content-Type: application/json" --data '{"name": "Jane"}' http://localhost:8080/user.v1.UserService/GetUser
...
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
...
* Using Stream ID: 1 (easy handle 0x557e00ad28e0)
> POST /user.v1.UserService/GetUser HTTP/2
...
< HTTP/2 200
...
これらの設定を駆使し確認したところ、非TLS における HTTP/1.1 と HTTP/2 のいずれも gRPC 呼出し(Unary RPC)が動作することを確認できた。
RPC | Protocol | 動作 | |
---|---|---|---|
Curl | Unary | HTTP/2(非TLS) | Success |
Curl | Unary | HTTP/1.1(非TLS) | Success |
connect-goクライアント | Unary | HTTP/2(非TLS) | Success |
connect-goクライアント | Unary | HTTP/1.1(非TLS) | Success |
Connect で Server Streaming RPC
そうなると気になるのは gRPC のストリーミング。
Server Streaming RPC を試します。
プロト定義を追加
プロト定義に Server Streaming RPC のサービスメソッド PingUser()
を追加
syntax = "proto3";
package user.v1;
option go_package = "example/gen/user/v1;userv1";
message User {
string name = 1;
int32 age = 2;
string phone_number = 3;
}
message GetUserRequest {
string name = 1;
}
message GetUserResponse {
User user = 1;
}
+message PingUserRequest {
+ string name = 1;
+ }
+message PingUserResponse {
+User user = 1;
+}
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
+ rpc PingUser(PingUserRequest) returns (stream PingUserResponse) {}
}
メソッド PingUser()
の returns
に stream
が指定されていることに注意
インタフェースコードを再生成
$ buf generate proto/
connect-goサーバに PingUser()
ハンドラを追加
package main
import ( ... )
type UserServer struct{}
func (s *UserServer) GetUser( ... ) { ... }
+func (s *UserServer) PingUser(
+ ctx context.Context,
+ req *connect.Request[userv1.PingUserRequest],
+ stream *connect.ServerStream[userv1.PingUserResponse],
+) error {
+ msg := &userv1.PingUserResponse{
+ User: &userv1.User{
+ Name: req.Msg.Name,
+ Age: 21,
+ PhoneNumber: "090-123-4567",
+ },
+ }
+ var i time.Duration
+ for i = 1; i < 3600; i = i * 2 {
+ err := stream.Send(msg)
+ if err != nil {
+ return err
+ }
+ time.Sleep(i * time.Second)
+ }
+ return nil
+}
func main() { ... }
PingUser()
の引数で渡される connect.ServerStream
を用いて繰り返しメッセージ User
を送信する。なお、送信の度にインターバルを倍増しているため徐々に送信間隔が長くなることに留意。
Server Streaming RPC を呼び出す connect-goクライアントを新たに実装
package main
import (
"context"
"crypto/tls"
"log"
"net"
"net/http"
userv1 "example/gen/user/v1"
"example/gen/user/v1/userv1connect"
"github.com/bufbuild/connect-go"
"golang.org/x/net/http2"
)
// Server Streaming RPC
func main() {
httpClient := &http.Client{
Transport: &http2.Transport{
AllowHTTP: true,
DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
return net.Dial(network, addr)
},
},
}
client := userv1connect.NewUserServiceClient(
httpClient,
"http://localhost:8080",
)
stream, err := client.PingUser(
context.Background(),
connect.NewRequest(&userv1.PingUserRequest{Name: "Mike"}),
)
for stream.Receive() {
msg := stream.Msg()
log.Println(msg.User)
}
log.Println("Stopped")
if err != nil {
panic(err)
}
}
for
ループで client.PingUser()
の戻り値 stream
からメッセージを読み込むところが Unary RPC
と大きく異なる。なお、上記は HTTP/2 を強制していることに留意。
connect-go サーバを実行
$ go run ./cmd/server/main.go
connect-go クライアントを実行。繰り返しメッセージ User
が届くことを確認。メッセージが届く間隔が徐々に長くなっている。
$ go run ./cmd/stream/main.go
15:32:59 name:"Mike" age:21 phone_number:"090-123-4567"
15:33:00 name:"Mike" age:21 phone_number:"090-123-4567"
15:33:02 name:"Mike" age:21 phone_number:"090-123-4567"
15:33:06 name:"Mike" age:21 phone_number:"090-123-4567"
15:33:14 name:"Mike" age:21 phone_number:"090-123-4567"
15:33:30 name:"Mike" age:21 phone_number:"090-123-4567"
15:34:02 name:"Mike" age:21 phone_number:"090-123-4567"
15:35:06 name:"Mike" age:21 phone_number:"090-123-4567"
15:37:14 name:"Mike" age:21 phone_number:"090-123-4567"
15:41:30 name:"Mike" age:21 phone_number:"090-123-4567"
15:50:02 name:"Mike" age:21 phone_number:"090-123-4567"
HTTP/2 で動くことを確認できたので次は HTTP/1.1
connect-go クライアントコードを改変し HTTP/2 の強制を解除
package main
import ( ... )
// Server Streaming RPC
func main() {
+ httpClient := http.DefaultClient
- httpClient := &http.Client{
- Transport: &http2.Transport{
- AllowHTTP: true,
- DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
- return net.Dial(network, addr)
- },
- },
- }
client := userv1connect.NewUserServiceClient(
httpClient,
"http://localhost:8080",
)
...
環境変数に GODEBUG=http2client=0
を指定(HTTP/1.1 を強制)し connect-go クライアントを実行
$ GODEBUG=http2client=0 go run cmd/stream/main.go
15:53:42 name:"Mike" age:21 phone_number:"090-123-4567"
15:53:43 name:"Mike" age:21 phone_number:"090-123-4567"
15:53:45 name:"Mike" age:21 phone_number:"090-123-4567"
15:53:49 name:"Mike" age:21 phone_number:"090-123-4567"
15:53:57 name:"Mike" age:21 phone_number:"090-123-4567"
15:54:13 name:"Mike" age:21 phone_number:"090-123-4567"
15:54:45 name:"Mike" age:21 phone_number:"090-123-4567"
15:55:49 name:"Mike" age:21 phone_number:"090-123-4567"
15:57:57 name:"Mike" age:21 phone_number:"090-123-4567"
16:02:13 name:"Mike" age:21 phone_number:"090-123-4567"
HTTP/1.1 を強制してもストリーミングできた。素晴らしいけどどんな実装なのか気になる。
tcpdump で確認するとチャンク形式でレスポンスを返していた。
チャンク形式とは、チャンク(小さなかたまり)に小分けし返送する方式
次は、tcpdump の出力を見易いように成形したもの
$ sudo tcpdump -A -n -i lo
...
14:27:18.034781 IP 127.0.0.1.43848 > 127.0.0.1.8080: Flags [P.], seq 1:267, ack 1, win 512, options [nop,nop,TS val 3794005145 ecr 3794005145], length 266: HTTP: POST
POST /user.v1.UserService/PingUser HTTP/1.1
Host: localhost:8080
User-Agent: connect-go/1.6.0 (go1.20.1)
Transfer-Encoding: chunked
Accept-Encoding: identity
Connect-Accept-Encoding: gzip
Connect-Protocol-Version: 1
Content-Type: application/connect+proto
<...リクエストボディ...>
14:27:18.035166 IP 127.0.0.1.8080 > 127.0.0.1.43848: Flags [P.], seq 1:248, ack 293, win 512, options [nop,nop,TS val 3794005145 ecr 3794005145], length 247: HTTP: HTTP/1.1 200 OK
HTTP/1.1 200 OK
Connect-Accept-Encoding: gzip
Connect-Content-Encoding: gzip
Content-Type: application/connect+proto
Date: Tue, 18 Apr 2023 05:27:18 GMT
Transfer-Encoding: chunked
35
<...chunk-data...>35
<...chunk-data...>35
<...chunk-data...>0
HTTPリクエストヘッダに Transfer-Encoding: chunked
が指定されている。HTTPレスポンスボディのチャンクは、16進数のチャンクサイズ 35
で始まりチャンクデータが続く。そのようなチャンクを何度か繰り返した後、サイズ 0
のチャンクで終了。
Curl は動作せず。
curl -v --http2 -H "Content-Type: application/json" --data '{"name": "Jane"}' http://localhost:8080/user.v1.UserService/PingUser
...
< HTTP/2 415
...
curl -v --http1.1 -H "Content-Type: application/json" --data '{"name": "Jane"}' http://localhost:8080/user.v1.UserService/PingUser
...
< HTTP/1.1 415 Unsupported Media Type
...
結果
RPC | Protocol | 動作 | |
---|---|---|---|
Curl | Server Streaming | HTTP/2(非TLS) | Error |
Curl | Server Streaming | HTTP/1.1(非TLS) | Error |
connect-goクライアント | Server Streaming | HTTP/2(非TLS) | Success |
connect-goクライアント | Server Streaming | HTTP/1.1(非TLS) | Success |
AWS 検証環境
connect-go サーバを AWS ALB(Application Load Balancer)のバックエンドで実行し、ローカルPCのブラウザや Curl コマンド、connect-goクライアントから呼び出せることを確認する。ALB とバックエンド間は HTTP/2(h2c)。
バックエンドは、EC2 に Docker を立ち上げ connect-go サーバをコンテナで実行する。ローカルPC の Docker CLI から接続できるよう SSH 接続のコンテキストを作成しておく。
本筋から外れるため構築手順は割愛。
connect-goサーバをデプロイ
connect-go サーバをビルド
$ go build -trimpath -ldflags "-w -s" -o bin/server cmd/server/main.go
コンテナに connect-go 実行イメージ bin/server
と静的ファイル static/
を含める
FROM debian:bullseye-slim
WORKDIR /app
COPY bin/server /app/server
COPY static /app/static/
ENTRYPOINT [ "/app/server"]
docker-compose.yml を記述
version: "3.9"
services:
grpc:
image: connect/server:latest
container_name: grpc
build:
context: ./
dockerfile: Dockerfile
ports:
- 0.0.0.0:80:8080
EC2インスタンス上の Docker で connect-goサーバ コンテナを開始
$ docker context use ec2-docker
$ docker compose build
$ docker compose up -d
$ docker compose ps
NAME IMAGE COMMAND SERVICE ... PORTS
grpc connect/server:latest "/app/server" grpc ... 0.0.0.0:80->8080/tcp
動作確認 Unary RPC
Curlコマンドを実行。GetUser()
を呼び出せることを確認
$ curl -H "Content-Type: application/json" --data '{"name": "Jane"}' https://<Public-DNS-Name>/user.v1.UserService/GetUser
{"user":{"name":"Jane", "age":21, "phoneNumber":"090-123-4567"}}
Curl の -v
オプションで HTTP/2(h2)が使われていることを確認
$ curl -v --header "Content-Type: application/json" --data '{"name": "Jane"}' https://<Public-DNS-Name>/user.v1.UserService/GetUser
...
* ALPN, offering h2
* ALPN, offering http/1.1
...
* ALPN, server accepted to use h2
...
ブラウザ Fetch API から GetUser()
を呼び出せることを確認
<!DOCTYPE html>
<html>
<body>
<h1>Welcome to connect-go demo!</h1>
<input type="button" value="実行" onclick="postData()">
<script language="javascript" type="text/javascript">
async function postData() {
let data = {name: "Foo"};
fetch("/user.v1.UserService/GetUser", {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
})
.then((response) => response.json())
.then((data) => console.log(data));
}
</script>
</body>
</html>
connect-goクライアントを実行。GetUser()
を呼び出せることを確認
$ go run cmd/client/main.go
name:"Mike" age:21 phone_number:"090-123-4567"
環境変数 GODEBUG=http2debug=1
を設定。HTTP/2 が使われている
$ GODEBUG=http2debug=1 go run cmd/client/main.go
http2: Transport failed to get client conn for xx.xx.xx:443: http2: no cached connection was available
http2: Transport creating client conn 0xc000208180 to 99.99.99.99:443
http2: Transport encoding header ":authority" = "xx.xx.xx"
http2: Transport encoding header ":method" = "POST"
...
結果
RPC | Request protocol --> ALB |
Backend protocol ALB --> |
Result | |
---|---|---|---|---|
Curl | Unary | HTTPs/2 | HTTP/2 | Success |
ブラウザ Fetch API | Unary | HTTPs/2 | HTTP/2 | Success |
connect-goクライアント | Unary | HTTPs/2 | HTTP/2 | Success |
動作確認 Server Streaming RPC
インターバルが1分を超えたあたりで通信が切断される現象が発生。
$ go run cmd/stream/main.go
15:52:33 name:"Mike" age:21 phone_number:"090-123-4567"
15:52:34 name:"Mike" age:21 phone_number:"090-123-4567"
15:52:36 name:"Mike" age:21 phone_number:"090-123-4567"
15:52:40 name:"Mike" age:21 phone_number:"090-123-4567"
15:52:48 name:"Mike" age:21 phone_number:"090-123-4567"
15:53:04 name:"Mike" age:21 phone_number:"090-123-4567"
15:53:36 name:"Mike" age:21 phone_number:"090-123-4567"
15:54:36 Stopped
原因は、AWS ALB の設定 Idle timeout
(デフォルト 60秒)がタイムアウトしたことによるセッション切断。
connect-go クライアントから HTTP/2 PING を定期的に送信してみたが、ALB は PING には応答するものの問題は解消せず。HTTP/2 PING ではタイムアウトはリセットされない模様。
ReadIdleTimeout
を設定し 10秒毎に HTTP/2 PING を送信する例
...
httpClient := &http.Client{
Transport: &http2.Transport{
ReadIdleTimeout: 10 * time.Second,
},
}
...
HTTP/2 PING の送受信ログ。PING には応答するが...
$ GODEBUG="http2debug=2" go run cmd/stream/main.go
...
16:00:58 http2: Transport sending health check
16:00:58 http2: Framer 0xc0001aa0e0: wrote PING len=8 ping="QR\xb29\x00y\xff\xbf"
16:00:58 http2: Framer 0xc0001aa0e0: read PING flags=ACK len=8 ping="QR\xb29\x00y\xff\xbf"
16:00:58 http2: Transport received PING flags=ACK len=8 ping="QR\xb29\x00y\xff\xbf"
16:00:58 http2: Transport health check success
...
結果
RPC | Request protocol --> ALB |
Backend protocol ALB --> |
Result | |
---|---|---|---|---|
connect-goクライアント | Server Streaming | HTTPs/2 | HTTP/2 | Success ただし、ALB Idle timeout の制限あり |
バックエンドのプロトコルバージョン
ALB は、プロトコル バージョンを使用して、HTTP/2 または gRPC を使用してターゲットにリクエストを送信できる。
ALB と connect-goサーバ間のプロトコルバージョンを HTTP/2 から gRPC に変更したが ALB の Idle timeout
の制限は解消せず。
gRPCスタイルのヘルスチェック
ALB と connect-goサーバ間のプロトコルバージョンを gRPC とする場合、connect-goサーバに gRPCスタイルのヘルスチェック エンドポイントを実装する必要がある。でないと ALB からヘルスチェックできない。
詳しくは ↓
Go パッケージ connect-grpchealth-go で gRPCヘルスチェック エンドポイントを簡単に実装できる
connect-goサーバに gRPCヘルスチェックを実装した例
package main
import (
...
+ grpchealth "github.com/bufbuild/connect-grpchealth-go"
...
)
...
func main() {
svr := &UserServer{}
mux := http.NewServeMux()
path, handler := userv1connect.NewUserServiceHandler(svr)
mux.Handle(path, handler)
+ // gRPC ヘルスチェックエンドポイント
+ checker := grpchealth.NewStaticChecker(
+ userv1connect.UserServiceName, // サービス名
+ )
+ mux.Handle(grpchealth.NewHandler(checker))
...
}
Curl で gRPC ヘルスチェックエンドポイントを呼び出し
# サービス名を省略
$ curl -H "Content-Type: application/json" -d '{"service": ""}' http://localhost:8080/grpc.health.v1.Health/Check
{"status":"SERVING_STATUS_SERVING"}
# サービス名を指定
$ curl -H "Content-Type: application/json" -d '{"service": "user.v1.UserService"}' http://localhost:8080/grpc.health.v1.Health/Check
{"status":"SERVING_STATUS_SERVING"}
(参考)パフォーマンス計測
AWS 環境で簡単に速度を計測しました。各種要因の影響を分析できていないため参考まで。
リクエスト -> レスポンスをシーケンシャルに 100回繰り返したときの処理時間(ミリ秒)
HTTP-JSON | Connect-Protobuf | |
---|---|---|
最大値 | 2,373 | 2,672 |
中央値 | 2,063 | 2,076 |
最小値 | 1,935 | 1,969 |
※ サンプル数は 32
※ HTTP-JSON のコードは、Go の net/http で別途作成したもの
※ Connect-Protobuf のコードは、上記で作成したものをほぼそのまま流用
計測用 connect-goサーバコード
package main
import (
"context"
"log"
"net/http"
"time"
"github.com/bufbuild/connect-go"
grpchealth "github.com/bufbuild/connect-grpchealth-go"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
userv1 "example/gen/user/v1" // generated by protoc-gen-go
"example/gen/user/v1/userv1connect" // generated by protoc-gen-connect-go
)
type UserServer struct{}
func (s *UserServer) GetUser(
ctx context.Context,
req *connect.Request[userv1.GetUserRequest],
) (*connect.Response[userv1.GetUserResponse], error) {
res := connect.NewResponse(&userv1.GetUserResponse{
User: &userv1.User{
Name: req.Msg.Name,
Age: 21,
PhoneNumber: "090-123-4567",
},
})
return res, nil
}
func (s *UserServer) PingUser(
ctx context.Context,
req *connect.Request[userv1.PingUserRequest],
stream *connect.ServerStream[userv1.PingUserResponse],
) error {
msg := &userv1.PingUserResponse{
User: &userv1.User{
Name: req.Msg.Name,
Age: 21,
PhoneNumber: "090-123-4567",
},
}
var i time.Duration
for i = 1; i < 3600; i = i * 2 {
err := stream.Send(msg)
if err != nil {
return err
}
time.Sleep(i * time.Second)
}
return nil
}
func main() {
svr := &UserServer{}
mux := http.NewServeMux()
path, handler := userv1connect.NewUserServiceHandler(svr)
mux.Handle(path, handler)
// gRPC ヘルスチェック
checker := grpchealth.NewStaticChecker(
userv1connect.UserServiceName,
)
mux.Handle(grpchealth.NewHandler(checker))
// HTTPヘルスチェックエンドポイント
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write([]byte(`{"status": "ok"}`))
})
// 静的ファイル index.html を返すエンドポイント
mux.Handle("/", http.FileServer(http.Dir("static")))
log.Fatal(http.ListenAndServe(
"0.0.0.0:8080",
h2c.NewHandler(mux, &http2.Server{}),
))
}
計測用 connect-goクライアントコード
package main
import (
"context"
"log"
"net/http"
"time"
userv1 "example/gen/user/v1"
"example/gen/user/v1/userv1connect"
"github.com/bufbuild/connect-go"
)
func main() {
httpClient := http.DefaultClient
client := userv1connect.NewUserServiceClient(
httpClient,
"https://<Public-DNS-Name>",
)
req := connect.NewRequest(&userv1.GetUserRequest{Name: "Bar"})
ctx := context.Background()
nLoop := 100
startAt := time.Now()
for i := 0; i < nLoop; i++ {
_, err := client.GetUser(ctx, req)
if err != nil {
panic(err)
}
}
duration := time.Since(startAt).Milliseconds()
log.Printf("gRPC: Complete! %v msec, loop %d\n", duration, nLoop)
}
参考文献