ECS Service Connect で gRPC のロードバランシングを試してみた
先日、amazon-ecs-service-connect-agent のリポジトリに、 ECS Service Connect で gRPC を利用した際にプロキシ側で上手くバランシングしてくれないといった issue を見つけました。
gRPC を利用した場合、クライアントとサーバー間で Channel と呼ばれる接続を確立し、その中で複数ストリーム(リクエスト)が流れます。
複数のホストが存在した場合に、ストリーム単位でバランシングできるかどうかが今回議論に上がっている部分になります。
gRPC を利用した場合のバランシングの方法として、Envoy のような Proxy に任せたり、ALB に任せたり、クライアント側でバランシングするなど、複数のやり方が存在します。
例えば ALB を利用した場合、クライアント側でロードバランシングをせずとも ALB が良い感じにバランシングしてくれます。
今回は ECS Service Connect を利用した際に、クライアント側でロードバランシングをせずとも Envoy が良い感じにバランシングしてくれるかを確かめてみます。
試してみる
同一の ECS クラスター内に gRPC Client と gRPC Server を配置します。
Service Connect を有効化するので、各タスクには Service Connect Agent(図では SC Agent) が挿入されます。
Service Connect Agent は実態としては Envoy となります。
実際の手順として、サーバー側のタスク定義で 50051 番ポートに対して GRPC プロトコルを指定してポートマッピング設定を行います。
ECS サービス側でポート名を指定して、Service Connect の設定を行います。
サーバー側のコードは grpc 公式リポジトリのサンプルを活用した簡単なものを用意します。
package main
import (
"context"
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
pb "google.golang.org/grpc/examples/helloworld/helloworld"
)
type server struct{
pb.UnimplementedGreeterServer
}
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
log.Printf("Received: %v", in.Name)
return &pb.HelloReply{Message: "Hello " + in.Name}, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterGreeterServer(s, &server{})
reflection.Register(s)
log.Printf("serving on :50051\n")
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
クライアント側も同様に用意します。
package main
import (
"context"
"log"
"os"
"time"
"strconv"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
pb "google.golang.org/grpc/examples/helloworld/helloworld"
)
func main() {
// gRPCサーバーに接続
grpcTarget := os.Getenv("GRPC_TARGET")
if grpcTarget == "" {
log.Fatal("GRPC_TARGET environment variable is required")
}
conn, err := grpc.Dial(
grpcTarget,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer conn.Close()
// クライアントを作成
c := pb.NewGreeterClient(conn)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// リクエストを送信
name := os.Getenv("NAME")
if name == "" {
log.Fatal("NAME environment variable is required")
}
iterationStr := os.Getenv("ITERATION")
iteration, err := strconv.Atoi(iterationStr)
if err != nil {
log.Fatalf("Failed to parse Iteration environment variable: %v", err)
}
for i := 0; i < iteration; i++ {
reply, err := c.SayHello(ctx, &pb.HelloRequest{Name: name + "(" +strconv.Itoa(i) + ")"})
if err != nil {
log.Fatalf("リクエスト失敗: %v", err)
}
log.Printf("サーバーからの応答: %s", reply.GetMessage())
}
}
クライアント側は下記 2 点を意識して作成しました。
gRPC Channel は共有して利用
gRPC Channel はリクエスト間で共有しています。元々の issue でリクエストの度に毎回 Channel を繋ぎ直せばバランシングできる旨が書いてあったので今回は最初に作ったものを共有します。
※ この部分を for 文の外で定義しています。
conn, err := grpc.Dial(
grpcTarget,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
クライアント側でバランシングしない
grpc-go
の resolver は passthrough
スキームに設定しています。
dns
スキームを選択した場合、grpc クライアントはホスト名を元にすべての A レコードを取得して、クライアント側でバランシングする挙動になります。
今回は Envoy が良い感じにバランシングしてくれるかを確かめたかったため、passthrough
スキームを選択しています。
grpc-go
を利用した場合、Channel の作成時に利用する関数として、古くから利用されてきた grpc.Dial
と新しく推奨されている grpc.NewClient
が存在します。
grpc.Dial
だとデフォルトが passthrough
スキーム、grpc.NewClient
だとデフォルトが dns
スキームなので、今回は grpc.Dial
を利用している前提で検証してみます。
One subtle difference between NewClient and Dial and DialContext is that the former uses "dns" as the default name resolver, while the latter use "passthrough" for backward compatibility.
https://pkg.go.dev/google.golang.org/grpc#DialContext
リクエストを送ってみる
それでは実際にリクエストを送信してみます。
クライアント側で何回目のリクエストかを合わせて送信するようにして、サーバー側ではそれをそのままログに出力します。
タスク 1 のログを見ると奇数番目のリクエストが送られてきています。
タスク 2 の方は偶数番目のリクエストが送られてきています。
バランシングされてますね!
Envoy が良い感じにバランシングしてくれてそうです。
passthrough:///app.masukawa-test-ecs-namespace:50051
のようにスキーマを URL で明確に指定しても同じ結果だったので、クライアント側でバランシングしているってことはなさそうですが、最初に見つけた issue との関連は気になります。
まとめ
不完全燃焼な気持ちもありますが、良かったです!
ALB との比較もそうですが、App Mesh EOL 絡みで ECS Service Connect に移行する際、この辺りがネックにならないか気になっていたので、検証してみて安心しました。
もし、なんらかの条件でバランシングされない場合は、クライアント側でのバランシングを検討すると良いかと思います。