AWSのNLBだけでgRPCの負荷分散ができなかった話





2020/5 に Network Loadbalancer(NLB) が TLS ALPN ポリシーに[対応](https://aws.amazon.com/about-aws/whats-new/2020/05/network-load-balancer-now-supports-tls-alpn-policies/)しました。
これに伴い「 envoy とか使わなくても NLB だけで gRPC の負荷分散できんじゃね？」という記事はあるものの、実際に試しているものはなかったので、書きました。

# 作った構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107953/09f10fe2-d1c4-c48c-d828-fa83432a3b27.png)



別件で、たまたま gRPC-gateway の環境を作っていたため、それを少しカスタマイズして利用しました。

ユーザリクエストが、ALBを経由して grpc-gatewayに着弾します。 
gRCP Gatewayでは、HTTPリクエストをgRPCリクエストに変換して、gRPC Serverに渡します。gRPC Serverに直接接続するのではなく、前段に内部用 NLB を作成して、 gRPC gateway - NLB - gRCP server という経路にしています。

今回は、gRPC Gateway も gRPC Serverも Fargate で作成しました。
NLBは、バックエンドに均等に分散されるように スティッキーセッションを無効にし、クロスゾーン負荷分散を有効にしています。

# 検証した点

ALBに対してひたすらHTTPを送って、gRPC Gateway から gRPC Serverへのアクセス状況を見ました。
gRPC Serverは、gRPC リクエストがあると、標準出力にシンプルなログを吐くようになっています。
なので、接続状況は、ログを見て判断する形になっています。


# 検証
## 負荷テストの実施
こちらは アクセス元のグローバルIPアドレスを複数にしたかったため、こちらのAWS [負荷テストソリューション](https://aws.amazon.com/jp/about-aws/whats-new/2020/10/introducing-distributed-load-testing-v1-1/)を使いました。
CloudFormatiotテンプレートをデプロイするだけで、分散HTTPテスト環境が作れます。
こちらに、負荷テスト先のURLを指定して、1時間 約1000アクセス/秒な負荷を与えました。


## Fargate、NLB、Target Group の状況
前述の通り、gRPC Server は内部用NLBの背後に、4つのFargateが動いています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107953/e117d7ed-2d5f-5673-1a7c-b105a3318a02.png)


Target Group は、ALPNを利用する場合は、TLSプロトコルを指定する必要があります。
（この環境では、 port 9998 を gRPC用のポートにしています）
TLSリスナーなので、NLBでTLSを終端し、バックエンドには gRPC 平文(cleartext, h2c)で流すといったことはできません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107953/3d3d3301-b36e-6e87-fc14-246dfe81489c.png)


## ログの確認
負荷テスト中もしくは後に、CloudWatch Logs Insightsでログが出力されていることを確認しました。

gRPC sever は、gRPC リクエストがあると、以下のようなログを吐きます(ログの中身の解説は本質ではないので説明は行わないのですが、1ログ = 1 gRPC リクエスト になっています）

```
{"Level":"INFO","Time":"2020-10-23T18:47:02.459Z","Caller":"zap/options.go:203","Msg":"finished unary call with code OK","grpc.start_time":"2020-10-23T18:47:02Z","system":"grpc","span.kind":"server","grpc.service":"grpc.health.v1.Health","grpc.method":"Check","access.ClientIp":"10.0.11.199:38104","access.Useragent":"grpc-go/1.27.0","access.AuthToken":"","grpc.code":"OK","grpc.time_ms":0.05900000035762787}
```

こんな感じで、gRPC リクエストは、バックエンドの Fargate に飛んでいることが分かります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107953/16b7b177-a2e9-b1d1-bd11-c71b446fed9e.png)

ただ、今回の検証は、バックエンドへのgRPC リクエストが負荷されるかの確認なので、
CloudWatch logs insighs でクエリを発行して、タスク(コンテナ)ごとリクエスト数の集計を行いました。

### 負荷分散状況の確認
実行した CloudWatch logs insighs のクエリ

```
stats count(*) as access_count by @logStream
```

以下のように、Fargate は4つ(4つのタスク)が存在しているにも関わらず、1つのタスクにしか、ログがありません。
つまり、gRPCリクエストは1つのFargateに集中し、他の3つFargate には gRPCリクエスト が一切来ていないことが分かりました。
もし、負荷分散されていれば、4つのタスクに均等にリクエスト数が分散されているはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/107953/52ab5045-a83b-9d45-797a-3abe6f0afe5c.png)

ここで、検証の目的である
「NLBだけでgRPCの負荷分散ができるかどうか」という疑問に対して、「できない」という検証結果になった事がわかりました。


# 考察
## なぜ負荷分散できないのか
NLBは ALPNに対応したからといって、あくまでも責務はL4だからに尽きると思います。
つまり、NLBはTLSの処理はできるものの、gRPC(HTTP2)については何もしない（というより理解しないので）、バックエンドに張ったコネクションをずっと利用するようになっているような気がします。これは、NLBは悪いのではなく、NLBの機能から見て、当たり前な動きだと思います。

## なぜ envoy だと負荷分散できるのか
envoyは L7 まで見てくれます。つまり、リクエストがgRPCかどうか判断でき、中身を見て、リクエストごとに接続先を負荷分散することが可能です。
これについては、[ここ](https://github.com/envoyproxy/envoy/issues/1855)にも記載されています。envoy は Connectionベースではなく。リクエストベースで負荷分散すると。（この言葉を使うと、NLB は Connection ベースでの負荷を行っているという感じでしょうか。なので、1 Connectを使い回す gRPCでは負荷分散ができないと）

## その他
現時点での仕様では、target group は、TLSプロトコルが必須になり、サーバ証明書をアプリ側に埋め込む必要があります。ただ、実際 gRPC Clientから見ると、接続先は NLB なので、ACMにimport した証明書が必要になります。つまり、NLB用にも証明書が必要で、gRPC サーバにも証明書が必要になります。