LoginSignup
3
3

More than 1 year has passed since last update.

gRPC 初心者が AWS 環境で Connect を試した

Last updated at Posted at 2023-04-26

本記事は、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 の例
users.csv
name,age,phoneNumber
Yamada,20,090-123-4567
XML の例
users.xml
<Users>
  <User>
    <Name>Yamada</Name>
    <Age>20</Age>
    <PhoneNumber>090-123-4567</PhoneNumber>
  </User>
</Users>
JSON の例
users.json
[
  {
    "name": "Yamada",
    "age": "20",
    "phoneNumber": "090-123-4567"
  }
]

バイナリ型のシリアライズ Protocol Buffers

バイナリ型のシリアライズは、効率的な一方で、32ビットと64ビットの違いやエンディアン(バイトを並べる順序)の違いなど、互換性に課題がある。このためバイナリ型のシリアライズは固有の実装となりがちだが、プログラム言語やプラットフォームに依存しない方法の1つに Protocol Buffers (略して Protobuf)がある。

Protobuf は、最初にデータ構造(プロト)を定義する。

プロト定義の例

user/v1/user.proto
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 のコード例

main.py
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レスポンスをやりとり(多重化)できる。

qt_2_2.png

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

プロト定義を記述

proto/user/v1/user.proto
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 を扱うサービス UserServiceUser を返すメソッド GetUser() を定義している。

buf.yaml ファイル作成

プロト定義のルート階層に proto/buf.yaml を作成

$ buf mod init -o proto/
proto/buf.yaml
version: v1
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT

Linter

プロト定義を Linter でチェック。指摘がなければ何も表示されない

$ buf lint proto/

コード生成

コード生成プラグインを制御する buf.gen.yaml を記述。

次の buf.gen.yaml は3つのプライグインを実行する。

  • Python のインターフェイスコード生成
  • Go のインターフェイスコード生成
  • connect-goのクライアントスタブ、サーバースタブ生成
buf.gen.yaml
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() の引数と戻り値に使用する構造体 GetUserRequestGetUserResponse が定義されている
  • gen/user/v1/userv1connect/user.connect.go には、サーバの HTTPハンドラ インタフェース UserServiceHandler とクライアントのコンストラクタ NewUserServiceClient が定義されている

connect-go を実装

connect-go サーバ

生成されたコードを import し connect-go サーバを実装

cmd/server/main.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

gen/user/v1/userv1connect/user.connect.go
...
type UserServiceHandler interface {
	GetUser(context.Context, *connect_go.Request[v1.GetUserRequest]) (*connect_go.Response[v1.GetUserResponse], error)
}
...

connect-go クライアント

生成されたコードを import し connect-go クライアントを実装

cmd/client/main.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 ルーター に差し替え
cmd/server/main.go
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 を作成

static/index.html
Welcome to connect-go demo!

connect-go サーバ実行

$ go mod tidy

$ go run ./cmd/server/main.go

次を確認

  1. gRPCエンドポイントを connect-goクライアントで呼び出せる
  2. gRPCエンドポイントを Curl で呼び出せる
  3. HTTPヘルスチェックエンドポイントを Curl で呼び出せる
  4. 静的ファイル 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 # ... フレームダンプでさらに詳細に

参照:「Golang net/http ドキュメント

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)とは
h2h2c は、HTTP/2 バージョンの識別子

  • h2暗号化された Transport Layer Security (TLS) を使用する HTTP/2 プロトコルを識別する。つまり https の HTTP/2
  • h2c平文の TCP 上での HTTP/2 プロトコルを識別する。つまり http の HTTP/2

参照:「RFC7540 - 3.1. HTTP/2 Version Identification

だとすれば HTTP/2 が使われてもよさそうだがそうならないのは、connect-goクライアントが非TLS での HTTP/2 を無効にしているから。

環境変数 GODEBUG は、HTTP/2 を無効にできるが有効化を強制することはできないようなので、非TLS の HTTP/2 サポートを強制するにはコードを改変する必要がある。

HTTP/2 を強制する connect-goクライアントコード例

cmd/client/main.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 を使用するため

参照:「Deployment & h2c - HTTP/2 without TLS

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 の使用を合意する必要があり、いくつかの方法が仕様に定義されている。

  1. ALPNを使用する
  2. HTTP/1.1からアップグレードする
  3. ダイレクトで開始する

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() を追加

proto/user/v1/user.proto
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()returnsstream が指定されていることに注意

インタフェースコードを再生成

$ buf generate proto/

connect-goサーバに PingUser() ハンドラを追加

cmd/server/maing.go
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クライアントを新たに実装

cmd/stream/main.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 の強制を解除

cmd/stream/main.go
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/ を含める

Dockerfile
FROM debian:bullseye-slim
WORKDIR /app
COPY bin/server /app/server
COPY static /app/static/
ENTRYPOINT [ "/app/server"]

docker-compose.yml を記述

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() を呼び出せることを確認
qt_2_1.png

static/index.html
<!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 を送信する例

cmd/stream/main.go
...
	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ヘルスチェックを実装した例

cmd/server/main.go
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

qiita2_4png.png

※ サンプル数は 32
※ HTTP-JSON のコードは、Go の net/http で別途作成したもの
※ Connect-Protobuf のコードは、上記で作成したものをほぼそのまま流用

計測用 connect-goサーバコード

cmd/server/main.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クライアントコード

cmd/loop-grpc/main.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)
}

参考文献

3
3
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
3
3