自由帳

@_nibral の技術ブログ

ECS + FargateでgRPCを動かす

gRPCでリクエストを受けるアプリをECS + Fargateで動かしつつ、ちゃんと負荷分散するために調べたことのメモ。


2021/2/22 追記

ALBがgRPCをサポートしたのでこの記事の内容は不要になった。

aws.amazon.com


先に結論

リバースプロキシとしてenvoyを走らせて、ECS Service Discovery経由でつなぐのが良さそう。インターネットからの通信を受けたいならALBではなくNLBを使う。

検証用プログラム

gRPCでUUIDを返す。UUIDはサーバの起動時に1回だけ生成するので、UUIDの値を比較すれば負荷分散ができているかがわかる。

github.com

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 Linuxglibcを入れるとかもやっている。

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)では使えない。

f:id:nibral:20200906172522p:plain
ALB構成

ではどうするかというと、ALBの代わりにNetwork Load Balancer(NLB)を使う。NLBはレイヤー4(TCP)で処理を行うので、gRPCの通信も問題なく通過できる。これで一見良さそうに見えるが、設定を進めてみるといくつかの問題点が判明した。

  • HTTP/2は1つのTCPコネクションを長く使うため、適切な負荷分散が行われない可能性がある(らしい)
    • 特定のコンテナに負荷が偏る
    • 今回使用したサンプルプログラムでは1回1回通信を張るので確認できず
  • セキュリティグループの設定ができない
    • コンテナにはクライアントの送信元IPがそのまま届くので 0.0.0.0/0 を許可するしかない
  • SSLの終端ができない
    • NLBより後ろでSSLの処理を行う必要がある
    • ACMで発行した無料のSSL証明書が使えない

f:id:nibral:20200906172533p:plain
NLB構成

セキュリティグループは頑張ってIPを設定する、SSLはいったん諦めるとして、負荷分散はちゃんとやりたいということでたどり着いた構成が以下。

NLBとサーバの間にEnvoyというプロキシを挟む。envoyからサーバへ通信するためには各サーバコンテナのIPを知る必要があるので、ECS Service Discoveryを使って server.grpc.local がコンテナのIPを返すようにしておく。

f:id:nibral:20200906173807p:plain
envoy + ECS Service Discovery構成

NLBのDNS名に対してクライアントからリクエストした様子。c9cf2091- で始まるUUIDと 62aa1e41- のUUIDが返ってきているのでちゃんと動いているはず。

f:id:nibral:20200906175206p:plain
サーバ2台で負荷分散

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