ECS+Fargateの環境でgRPCサーバのヘルスチェックをするために色々頑張った話

# はじめに
ECS on Fargateの環境でgRPCサーバのヘルスチェックをする際に少し詰まったのですが、参考になる記事が少なかったため、ノウハウを残しておきます。
EKSやGKEとかだとどうなのかなどはコメント頂けるとありがたいです。

gRPCの基本部分で自信が無い方は、よろしければ、[いまさらだけどgRPCに入門したので分かりやすくまとめてみた](https://qiita.com/gold-kou/items/a1cc2be6045723e242eb)をご覧ください。

# 前提
- ECS on Fargate
	- LBはALBかNLBの二択
- 対象システムはRESTとgRPCの両方のAPIを持っており、Nginxをリバースプロキシとして利用している。
- REST-APIサーバのヘルスチェックはALBがヘルスチェック用のREST-APIを実行することで実現している。
- gRPCクライアントとgRPCサーバの中継はNLB(L4)、REST-APIクライアントとREST-APIサーバの中継はALB(L7)を利用している。

# 問題背景
- gRPCはHTTP2である。
- ALBは外側はHTTP2対応しているが、内側はHTTP1.1しか対応していない。
- つまり、ALBがgRPCサーバのヘルスチェック用メソッドを直接実行することができない。（REST-APIと同じようにはいかない）

# 解決案
いくつかあると思います。

## 解決案① NLBのポートヘルスチェック
NLBがサーバのポート監視する方法です。
やることはAWSの設定だけなので、比較的簡単に実現できます。
しかし、あくまでポート監視までしかできないので、実際にアプリが正常に動作しているかを確認するには少し弱いです。

## 解決案② ECSのサービスディスカバリ
参考記事：
[ECS + Fargate + gRPCを使ったマイクロサービス構成](https://qiita.com/jnst/items/2be93a6724dd68681b1a#http2-%E3%81%AE-grpc-%E3%81%AF-alb-%E3%81%A8%E7%9B%B8%E6%80%A7%E3%81%8C%E6%82%AA%E3%81%84)

こちらも、やることはAWSの設定だけなので、比較的簡単に実現できます。
しかし、上記のポートヘルスチェックと似たような問題を抱えています。

## 解決案③ grpc-gatewayを使う
![構成図](https://gyazo.com/632f11d37ffa1d0edd3e966952d9a3ef.png)

grpc-gatewayはHTTP1.1で受けてHTTP2に変換することができるため、ALBとgRPCサーバの間に配置することで、間接的ではありますが、ALBがgRPCサーバのヘルスチェック用メソッドを実行することができそうです。

### ヘルスチェック用メソッドの実装
gRPC公式でヘルスチェック用のprotoファイルが公開されていました。
https://github.com/grpc/grpc/blob/master/src/proto/grpc/health/v1/health.proto

下記のprotoファイルは、grpc-gateway用にCheckメソッドがREST-APIで受けられるように設定を追記しています。

```proto
syntax = "proto3";

package grpc.health.v1;

import "google/api/annotations.proto";

option csharp_namespace = "Grpc.Health.V1";
option go_package = "google.golang.org/grpc/health/grpc_health_v1";
option java_multiple_files = true;
option java_outer_classname = "HealthProto";
option java_package = "io.grpc.health.v1";

message HealthCheckRequest {
    string service = 1;
}

message HealthCheckResponse {
    enum ServingStatus {
        UNKNOWN = 0;
        SERVING = 1;
        NOT_SERVING = 2;
        SERVICE_UNKNOWN = 3;  // Used only by the Watch method.
    }
    ServingStatus status = 1;
}

service Health {
    // If the requested service is unknown, the call will fail with status
    // NOT_FOUND.
    rpc Check(HealthCheckRequest) returns (HealthCheckResponse) {
        option (google.api.http) = {
            get: "/grpc/health"
        };
    }
    rpc Watch(HealthCheckRequest) returns (stream HealthCheckResponse);
}
```

protocコマンドで、`health.pb.go`を自動生成します。
開発言語はGoです。

```console
$ protoc -I grpc/health/ grpc/health/health.proto --go_out=plugins=grpc:grpc/health
```

ヘルスチェック用のメソッドの実装はこんな感じです。
interceptorの認証チェックをスキップするための実装が組み込まれていますが、それに関しては[こちらの記事](https://qiita.com/gold-kou/items/d72a5c6534ee62581b47)で解説しています。

```go
type SkipAuthHealthServer struct {
	health.HealthServer
}

var _ grpc_auth.ServiceAuthFuncOverride = (*SkipAuthHealthServer)(nil)

func (*SkipAuthHealthServer) AuthFuncOverride(ctx context.Context, fullMethodName string) (context.Context, error) {
	return ctx, nil
}

func (h *SkipAuthHealthServer) Check(context.Context, *health.HealthCheckRequest) (*health.HealthCheckResponse, error) {
	return &health.HealthCheckResponse{
		Status: health.HealthCheckResponse_SERVING,
	}, nil
}

func (h *SkipAuthHealthServer) Watch(*health.HealthCheckRequest, health.Health_WatchServer) error {
	return status.Error(codes.Unimplemented, "service watch is not implemented current version.")
}
```

ひとまず、HTTP2でこのメソッドが正常に動作するかを確認してみましょう。

gRPC確認クライアントツールはいくつか存在します。
`grpcurl`を使った例です。

```console
$ grpcurl -plaintext localhost:50051 grpc.health.v1.Health.Check
{
  "status": "SERVING"
}
```

上記protoファイル専用のヘルスチェッククライアントツールを使った例です。
grpc-ecosystemのものなので信頼性も高そうです。
https://github.com/grpc-ecosystem/grpc-health-probe

```console
$ grpc-health-probe -addr=localhost:50051
status: SERVING
```

以上より、ヘルスチェック用メソッド単体は正常に動作することがわかりました。

### grpc-gatewayの実装
grpc-gatewayもprotocコマンドでpb.goを自動生成します。

grpc-gatewayサーバの実装はこんな感じです。
grpc-gatewayのポート番号は50052にしてみました。
ローカルの場合にホスト名が`grpc`で通信できるのは、docker-compose.yamlでコンテナ名をそのように指定しているからです。
ステージングあるいはプロダクション環境の場合にホスト名が`localhost`で通信できるのは、ECSのawsvpcネットワークモードを使用しているためです。

> awsvpc ネットワークモードを使用してタスクが開始されると、タスク定義内のコンテナが開始される前に、各タスクに Amazon ECS コンテナエージェントによって追加の pause コンテナが作成されます。次に、amazon-ecs-cni-plugins CNI プラグインを実行して pause コンテナのネットワーク名前空間が設定されます。その後、エージェントによってタスク内の残りのコンテナが開始されます。こうすることで pause コンテナのネットワークスタックが共有されます。つまり、タスク内のすべてのコンテナは ENI の IP アドレスによってアドレス可能であり、localhost インターフェイス経由で相互に通信できます。
> 出典: https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-networking.html

```go
package main

import (
	"context"
	"flag"
	"net/http"
	"os"

	"github.com/st-tech/go-zozoerp-po/app/common"

	"github.com/golang/glog"
	"github.com/grpc-ecosystem/grpc-gateway/runtime"
	"google.golang.org/grpc"

	log "github.com/sirupsen/logrus"

	gw "github.com/st-tech/go-zozoerp-po/grpc/health/google.golang.org/grpc/health/grpc_health_v1"
)

var (
	runserver          = "RUNSERVER"
	staging            = "STAGING"
	production         = "PRODUCTION"
	grpcServerEndpoint = flag.String("grpc-server-endpoint", "grpc:50051", "gRPC server endpoint") // LOCALの設定
)

func run() error {
	if os.Getenv(runserver) == staging || os.Getenv(runserver) == production {
		// ステージングあるいはプロダクション環境の場合はlocalhostとする
		grpcServerEndpoint = flag.String("grpc-server-endpoint", "localhost:50051", "gRPC server endpoint")
	}

	ctx := context.Background()
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	mux := runtime.NewServeMux()
	opts := []grpc.DialOption{grpc.WithInsecure()}
	err := gw.RegisterHealthHandlerFromEndpoint(ctx, mux, *grpcServerEndpoint, opts)
	if err != nil {
		return err
	}

	return http.ListenAndServe(":50052", mux)
}

func main() {
	flag.Parse()
	defer glog.Flush()

	common.LogInit()

	log.Info("grpc-gateway Server Started!!!")
	if err := run(); err != nil {
		log.Fatal(err)
	}
}
```

あとは、ローカルとECSでgrpc-gateway用のコンテナを追加します。
ここは特別なことはないので割愛します。

ローカルでgRPCサーバとgrpc-gatewayのコンテナとアプリケーションを起動し、 `http://localhost:50052/grpc/health` すると、正常に動作しました。

これをALBが定期実行することでヘルスチェックをうまく実現できそうです。

### しかし、これ使っていません。
結構頑張ったのですが、結果的にはこの方法でのヘルスチェックは現在導入していません。

理由は以下です。

- grpc-gatewayコンテナ追加に伴い、AWS利用料金が増加しそうだったから。
- ヘルスチェックをgrpc-gateway経由で行うと、たかだかヘルスチェックしか仕事していないgrpc-gatewayコンテナがなんらかの不具合で停止した場合、それに引きづられてタスクの入れ替えが発生するから。また、それを心配しなければいけないこと自体が嫌だったから。
- Nginxを経由しないためgRPCサーバのヘルスチェックだけがNginxのアクセスログ収集からは確認できない。これだとdatadogで見づらい。(他gRPCのAPIアクセルはNginx経由のためログとれる)

gRPCサーバと直接HTTP1.1で通信したくなったら、その時は移行するかもしれません。

また、上記の理由から、ローカルまでは動作確認したのですが、AWS環境上では未確認です。もし試されたり、実際に同じような運用をされているかたがいらっしゃればコメントお願いします！

## 解決案④ NginxコンテナにHTTP2でヘルスチェックさせる
![構成図](https://gyazo.com/9f113a2a50167886dda7d6d3d5fb35f7.png)

現状、この方法を採用しています。

ECSのHEALTCHECKオプションを活用し、Nginxコンテナが自分自身にgrpc-health-probeを実行する方法です。
流れは以下です。これを10秒間隔で実行しています。

1. Nginxコンテナ上で自分自身に対してヘルスチェックリクエスト
2. NginxがリバースプロキシとしてgRPCサーバへ転送
3. gRPCサーバのヘルスチェックメソッドが実行される

注意点として、あらかじめ、Nginxコンテナにgrpc-health-probeをインストールしておく必要があります。
Nginxコンテナが300MBほど増えてしまったのですが、新規でgrpc-gatewayコンテナ立てるよりはトータルで少ないです。

詳細は弊社SREチームの別日の[記事](https://qiita.com/Ichi0124/items/b93e2ae4309e10b348c6)を確認ください。

## 解決案⑤ AppMesh
この方法があると聞きましたが、未調査です。

# 最後に
gRPCを使った開発が初めてだったので色々悩むことが多かったです。
また、少し踏み込むと途端に日本語ドキュメントが少なかったので書いてみました。
わからないけど、Envoyとか使えばもっと楽なのかもしれない。そっちはまた今度勉強します。