ECS + FargateでgRPCを動かす
gRPCでリクエストを受けるアプリをECS + Fargateで動かしつつ、ちゃんと負荷分散するために調べたことのメモ。
2021/2/22 追記
ALBがgRPCをサポートしたのでこの記事の内容は不要になった。
先に結論
リバースプロキシとしてenvoyを走らせて、ECS Service Discovery経由でつなぐのが良さそう。インターネットからの通信を受けたいならALBではなくNLBを使う。
検証用プログラム
gRPCでUUIDを返す。UUIDはサーバの起動時に1回だけ生成するので、UUIDの値を比較すれば負荷分散ができているかがわかる。
hello.proto
syntax = "proto3"; package hello; option go_package = ".;main"; message HelloRequest { } message HelloReply { string msg = 1; } service Hello { rpc SayHello (HelloRequest) returns (HelloReply) {} }
server.go
package main import ( "context" "github.com/google/uuid" "google.golang.org/grpc" "log" "net" ) type Service struct { id string } func (service *Service) SayHello(ctx context.Context, message *HelloRequest) (*HelloReply, error) { return &HelloReply{ Msg: "Hello from " + service.id, }, nil } func main() { port, err := net.Listen("tcp", ":50051") if err != nil { log.Fatalln(err) } server := grpc.NewServer() helloService := &Service{ id: uuid.New().String(), } RegisterHelloServer(server, helloService) err = server.Serve(port) if err != nil { log.Fatalln(err) } }
client.go
package main import ( "context" "flag" "fmt" "google.golang.org/grpc" "log" ) func main() { flag.Parse() conn, err := grpc.Dial(flag.Arg(0), grpc.WithInsecure()) if err != nil { log.Fatalln(err) } defer conn.Close() client := NewHelloClient(conn) msg := &HelloRequest{} res, err := client.SayHello(context.TODO(), msg) if err != nil { fmt.Printf("error::%#v \n", err) } fmt.Println(res.Msg) }
ECSで動かしたいのでDockerfileも用意した。単にgo build
をするだけでなく、マルチステージビルドにしてdockerイメージを軽くするとかAlpine Linuxにglibcを入れるとかもやっている。
FROM golang:latest as builder WORKDIR /go/src/grpc_sample COPY server.go hello.proto ./ RUN apt update \ && apt install -y protobuf-compiler \ && go get google.golang.org/grpc \ && go get github.com/golang/protobuf/protoc-gen-go RUN protoc --go_out=plugins=grpc:. hello.proto RUN go get -v ./... RUN go build -o server server.go hello.pb.go FROM alpine:latest COPY --from=builder /go/src/grpc_sample/server server RUN apk update && apk add libc6-compat EXPOSE 50051
ビルドしたdockerイメージをECRにプッシュしたらECSの検証に移る。
ECSとロードバランサーとgRPC
ECSの負荷分散だとApplication Load Balancer(ALB)が使われることが多いが、ALBのターゲット側はHTTP/1.1にしか対応していないためgRPC(HTTP/2)では使えない。
ではどうするかというと、ALBの代わりにNetwork Load Balancer(NLB)を使う。NLBはレイヤー4(TCP)で処理を行うので、gRPCの通信も問題なく通過できる。これで一見良さそうに見えるが、設定を進めてみるといくつかの問題点が判明した。
- HTTP/2は1つのTCPコネクションを長く使うため、適切な負荷分散が行われない可能性がある(らしい)
- 特定のコンテナに負荷が偏る
- 今回使用したサンプルプログラムでは1回1回通信を張るので確認できず
- セキュリティグループの設定ができない
- コンテナにはクライアントの送信元IPがそのまま届くので 0.0.0.0/0 を許可するしかない
- SSLの終端ができない
セキュリティグループは頑張ってIPを設定する、SSLはいったん諦めるとして、負荷分散はちゃんとやりたいということでたどり着いた構成が以下。
NLBとサーバの間にEnvoyというプロキシを挟む。envoyからサーバへ通信するためには各サーバコンテナのIPを知る必要があるので、ECS Service Discoveryを使って server.grpc.local
がコンテナのIPを返すようにしておく。
NLBのDNS名に対してクライアントからリクエストした様子。c9cf2091-
で始まるUUIDと 62aa1e41-
のUUIDが返ってきているのでちゃんと動いているはず。
envoy.yaml
envoy 1.16.0-dev-0b24c6で動作確認。envoy API v3。ネット上で見つかる記事だとv2の記述が多いので、調べるときは記事の日付を見た方が良い。
admin: access_log_path: '/dev/null' address: socket_address: address: 127.0.0.1 port_value: 9901 static_resources: listeners: - name: listner_0 address: socket_address: protocol: TCP address: 0.0.0.0 port_value: 5000 filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: ingress_http codec_type: AUTO route_config: name: local_route virtual_hosts: - name: backend domains: - '*' routes: - match: prefix: '/' route: cluster: grpc_sample access_log: - name: envoy.access_loggers.file typed_config: "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog path: '/dev/stdout' http_filters: - name: envoy.filters.http.router clusters: - name: grpc_sample connect_timeout: 0.25s type: LOGICAL_DNS lb_policy: ROUND_ROBIN http2_protocol_options: {} health_checks: - timeout: 5s interval: 10s unhealthy_threshold: 2 healthy_threshold: 2 tcp_health_check: {} load_assignment: cluster_name: grpc_sample endpoints: - lb_endpoints: - endpoint: address: socket_address: address: server.grpc.local port_value: 50051