ECS Service Connectの負荷分散をgrpc-goで確かめる

Amazon ECS Service Connectを使用して、gRPCのリクエストが各ECSタスクに振り分けられるかを確かめる
通常のHTTP APIでも同じ挙動になる

使用する言語、ライブラリ
- Go 1.22
- google.golang.org/grpc v1.63.0

## Service Connectなし
まずはService Connectを使用せず、gRPCのリクエストが1つのサーバー側ECSタスクにリクエストし続けることを確かめる

検証するaws構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/481044/4ef813ca-049d-dcac-e14f-88c71fdc5109.png)
クライアント側はAWS Cloud Mapを使用してサーバー側ECSタスクを名前解決する

### 1. gRPCサーバーをECSサービスとして起動する
サーバーは自身のECSタスクのprivate IPアドレスを返す
特にclient side load balancingの設定等はしていない
ECSタスクは2つ起動する

```go:Go
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net"
    "net/http"
    "os"

    "google.golang.org/grpc"

    pb "google.golang.org/grpc/examples/features/proto/echo"
)

type ecServer struct {
    pb.UnimplementedEchoServer
}

func (s *ecServer) UnaryEcho(_ context.Context, req *pb.EchoRequest) (*pb.EchoResponse, error) {
    metadataURL := os.Getenv("ECS_CONTAINER_METADATA_URI_V4")
    if metadataURL == "" {
        log.Println("ECS_CONTAINER_METADATA_URI_V4 environment variable is not set")
        return &pb.EchoResponse{}, nil
    }

    resp, err := http.Get(metadataURL + "/task")
    if err != nil {
        log.Printf("failed to get metadata: %v\n", err)
        return &pb.EchoResponse{}, nil
    }
    defer resp.Body.Close()

    body, err := io.ReadAll(resp.Body)
    if err != nil {
        log.Printf("failed to read response body: %v\n", err)
        return &pb.EchoResponse{}, nil
    }

    var metadata struct {
        Containers []struct {
            Name     string `json:"name"`
            Networks []struct {
                IPv4Addresses []string `json:"IPv4Addresses"`
            } `json:"networks"`
        } `json:"containers"`
    }

    if err := json.Unmarshal(body, &metadata); err != nil {
        log.Printf("failed to parse JSON: %v\n", err)
        return &pb.EchoResponse{}, nil
    }

    myIP := ""
    for _, container := range metadata.Containers {
        if container.Name == "server" && len(container.Networks) > 0 && len(container.Networks[0].IPv4Addresses) > 0 {
            myIP = container.Networks[0].IPv4Addresses[0]
            break
        }
    }

    return &pb.EchoResponse{Message: fmt.Sprintf("%s (at %s)", req.Message, myIP)}, nil
}

func main() {
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    s := grpc.NewServer()
    pb.RegisterEchoServer(s, &ecServer{})
    log.Printf("serving on :50051\n")
    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

```

起動した2つのECSタスクのprivate IPアドレスは以下になった
- 10.0.1.174
- 10.0.17.182

なおECSタスクのコンテナヘルスチェックは設定していない

### 2. gRPCクライアントをECSサービスとして起動する
クライアントはgRPCサーバーに一定間隔でリクエストを送り、レスポンスを標準出力する

```go:Go
package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "time"

    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"
    ecpb "google.golang.org/grpc/examples/features/proto/echo"
)

func main() {
    conn, err := grpc.NewClient(
        os.Getenv("GRPC_TARGET"),
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    )
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }
    defer conn.Close()

    hwc := ecpb.NewEchoClient(conn)
    for i := 0; i < 100; i++ {
        r, err := hwc.UnaryEcho(context.Background(), &ecpb.EchoRequest{Message: "hello"})
        if err != nil {
            log.Fatalf("could not invoke: %v", err)
        }
        fmt.Println(r.Message)
        time.Sleep(5 * time.Second)
    }
}
```

ECSタスク定義のリクエスト先環境変数 GRPC_TARGETにはCloud MapのDNSとサーバー側ポート(ここでは50051)を設定する
e.g.) server.grpc.local:50051

### 3. クライアント側ECSタスクのログを確認する
1つのサーバー側ECSタスクにリクエストし続けることが確認できる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/481044/61bd8d21-2375-0ec5-b4d8-4886da04ccaa.png)

## Service Connectあり
次にService Connectを使用し、gRPCのリクエストが2つのサーバー側ECSタスクに振り分けられてリクエストされるかを確かめる

検証するaws構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/481044/6033b85a-22be-987e-7f5b-de0bf580c481.png)
クライアント側はService Connect経由でサーバーにリクエストする

### 1. サーバー側ECSサービスにService Connectを設定しサービス更新する
1. 以下設定する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/481044/f4b9472c-bb61-27b3-f076-2b19ac865ccd.png)
1. サービス検出をオフにする

起動した2つのECSタスクのprivate IPアドレスは以下になった
- 10.0.2.102
- 10.0.26.24

### 2. クライアント側ECSタスク定義のリクエスト先を更新する
環境変数 GRPC_TARGETのDNSにService ConnectのDNS(ここではserver-50051-tcp.grpc.local)を設定し新しくリビジョン作成する

### 3. クライアント側ECSサービスにService Connectと設定しサービス更新する
またタスク定義の新しいリビジョンを設定する
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/481044/5961b730-ba57-2f62-21ac-2d8c8b1cf11b.png)

### 4. クライアント側ECSタスクのログを確認する
2つのサーバー側ECSタスクに振り分けられてリクエストしていることが確認できる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/481044/f6ed816f-1310-be4d-4178-39a44bd73bb0.png)