AWS Load Balancer Controllerを使うときのDeregisterTargetsの遅延を測定した

## この記事は何？

Amazon EKSを運用していて、Ingress Controllerとして[AWS Load Balancer Controller][albc-official](以下、ALBC)を使うことはよくあると思います。

[albc-official]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/

Kubernetesの文脈ではDeploymentなどでRolling Updateをするとサービスを無停止で更新できると考えられていますが、実際にはKubernetesやコンテナランタイム、Ingress Controllerなどがどのような仕組みで無停止更新を実現しようとしているのかを理解し、適切な設定やアプリケーション構築を行わないと完全な無停止更新は実現できません[^1]。

実際にALBCを使ってサービスを稼働した際にRolling Update中にエラーが発生することを確認したので、その原因となったALBCのDeregisterTargetsの遅延を測定しました。その実験結果をまとめます。

## 結論

下記前提条件を満たす場合、Podには15秒以上の `preStop` hookを設定するとRolling Update中のエラーが回避できる。

## 前提環境

- Control plane: Amazon EKS
- Computing resource: EKS on Fargate
- Ingress Controller: AWS Load Balancer Controller

## Rolling Update中にエラーが起きる原因

Rolling Update中に起きるエラーは以下の2つが考えられます。

- コンテナの終了シグナル(通常は `SIGTERM`)を受けてアプリが直ちに終了してしまい処理中のリクエストがエラーになる。
- 終了中(新規リクエスト停止済み)・終了済みなどのアプリにトラフィックがルーティングされたことでエラーになる。

この記事では前者については取り扱いません。アプリケーションによって適切な方法でgraceful shutdownを実装してください。実験に使用したアプリケーションではフレームワークの[Graceful Shutdown機能][boot-graceful-shutdown]を利用して30秒[^2]のtear down期間を設けています。

[boot-graceful-shutdown]: https://docs.spring.io/spring-boot/docs/current/reference/html/web.html#web.graceful-shutdown

EKSではPodを稼働するリソースとしてEC2インスタンスを使う方式[^3] (以下、Node方式)とFargateを使う方式(以下、Fargate方式)があります。Fargate方式を使う場合、いろいろな違いがありますが、ALBCでトラフィックをルーティングする際も違いがあり、宛先PodがFargateで稼働している場合は `ip` モードしか選べません(EC2インスタンスが存在しないので当然といえば当然です)。 `ip` モードではALBのターゲットとしてPodのIPアドレスが[直接登録][albc-ip-mode]されます。Node方式で `instance` モードを使うと、ALBによるルーティングの後にNodePort ServiceによるPodへのルーティングが行われますが、 `ip` モードではFargateとPodが共有しているENIのIPアドレスにALBが直接ルーティングを行います。

[albc-ip-mode]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.4/how-it-works/#ingress-traffic

Podの終了時、Podの終了処理の最初にコンテナごとの終了手順の開始と同時にPodのステータスが更新されてPodへのルーティングを停止します。これはEndpoint/EndpointSliceリソースからのPodのIPアドレス/port番号ペアのエントリを削除することで行われますが、ALBCでALBからPodにルーティングしている場合はEndpoint/EndpointSliceを経由せずにルーティングされるのでTargetGroupを更新してTargetとして登録されているPodのIPアドレス/port番号ペアのエントリを削除する必要があります。事前実験により、RollingUpdate中のエラーの原因はこのエントリの削除か、そのALB動作への反映に遅れがあるためであるとあたりを付けました[^4]。

## 実験計画

実験環境は以下の図の通り。

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147887/c2d90e3b-1e5d-1d53-866f-db5084ec2715.png)

要点を示す

- AWSアカウント内のVPCにEKSクラスタを構築(`eksctl` を利用)
    - アプリケーションにFargateを使うのですべてのPodが同じFargateProfileを利用できるよう構成
- ALBCをインストールしてIngress Controllerとして利用
- 宛先アプリケーションはJavaアプリ(SpringBootを利用)をDeployment/Serviceを利用して管理、Ingressリソースを通じてALBを設定
- ALBは特に外部(VPCのそと)に公開する必要がなかったこと、公開ALBと内部ALBで差がないと考えられることから内部ALBを利用した。
- アクセスは同一EKSクラスタ内にLocust[^5]クラスタを構築して行った。
    - 負荷をかけることは目的ではないが、アクセス間隔をあけずに一定頻度のアクセスをかけることができて、筆者が使い慣れているためLocustを利用した。
- エラー発生時の原因を調べるためにALBはアクセスログをS3に出力するようにした。
- CloudTrailを利用してALBCからELB(AWSサービス)の呼び出し履歴を取得している
- ローカルコンソール上でPod、Endpoint、Eventをwatch(`kubectl get -w`)して状態遷移ログを取得した。
- ローカルコンソール上でALBのTargetGroupのヘルスチェック状態をaws cliを利用して1秒間隔で取得した。
- 実験は条件を変えてそれぞれ3回ずつ行う。
    - 変える条件はpreStop hookの設定のみ。
    - preStop hookの設定は以下の4通り
        1. preStop hookを設定しない
        1. preStop hook で5秒sleepする(コンテナへのTERMシグナルの送信が5秒遅くなる)
        1. preStop hook で10秒sleepする
        1. preStop hook で15秒sleepする

実験手順:
- 実験ごとにアプリケーションを管理するDeploymentを変更してPodのpreStop hookの設定を実験条件に合わせる
- 各種監視プロセスが動作していることを確認
- LocustのUI上から100ユーザーでALBにアクセスをかける。
- アクセス頻度が安定したらアプリケーションのDeploymentに対してrollout restartを2回かける。
    - 1度目のrollout restartが終了(最後に終了するPodが完全に削除)したあとにLocust上でエラーレスポンスが確認されなくなったら2回目のrollout restartを実施する

## 実験結果

発生したエラー件数を以下の表にまとめる

| preStop hook | 番号 | 504 | 502 |
|:-:|:-:|--:|--:|
| 設定しない | 1 | 37 | 146 |
| 設定しない | 2 | 29 | 156 |
| 設定しない | 3 | 41 | 150 |
| sleep 5s | 1 | 0 | 125 |
| sleep 5s | 2 | 0 | 107 |
| sleep 5s | 3 | 0 | 118 |
| sleep 10s | 1 | 0 | 34 |
| sleep 10s | 2 | 0 | 42 |
| sleep 10s | 3 | 0 | 60 |
| sleep 15s | 1 | 0 | 0 |
| sleep 15s | 2 | 0 | 0 |
| sleep 15s | 3 | 0 | 0 |

ほとんどが502(BadGateway)であるが、アクセスログを確認してもアプリケーションから502エラーが返っているわけではない。今回実装したアプリはGracefulShutdownを行うさい、新規のリクエストを拒否するのにTCPコネクションを拒否する。TCPレベルでの拒否がALBに到達しているため、502エラーとなっているとみられる。

504(GatewayTimeout)エラーは、アクセスログによると、同一のPodにリクエストを続け、502エラーが発生するした後に発生しており、ALBへのリクエスト到着からクライアントのレスポンスまでに約10秒を要している。これはALBのコネクションタイムアウト時間[^6]と所要時間が一致している。したがって、504エラーはALBから、アプリケーションが終了してTCPポートをListenしなくなったあとのPod、あるいはPod自体が終了した後も当該IPアドレスにルーティングしようとした結果、ALBから上流への接続でコネクションタイムアウトが発生していると考えられる。また、この504エラーはpreStop hookで待ち時間を5秒挿入してアプリケーションの終了を5秒遅らせることで発生しなくなるが、preStop hookがない時の30~40件程度のリクエストは約10秒に1回のペースでHTTPリクエストを行っているので5秒未満の間に発生していると考えられ、アプリケーションの終了を5秒遅らせることで発生しなくなることも前述の理解と矛盾しない。

なお、ローカルコンソールで監視していたTargetGroupのTargetごとのヘルスステータスのログや、CloudTrailのログを確認すると、Podの終了とDeregisterTargetsの呼び出しはほぼ1秒以内で行われているものと推測される[^7]。したがって、上記の遅れはAPI呼び出しまでに生じる遅れではなく、DeregisterTargets API呼び出しからそのALBへの反映までの遅れが大半を占めると思われる。このことは特に、TargetHealthステータスを監視するようなpreStop hookに効果がないことを意味する。そのようなpreStop hookを作成するならばTargetHealthステータスが `draining` になった時点でアプリケーションプロセスのGraceful shutdownを開始するような作りになるが、その時点ではまだALBは当該Podに対してルーティングを続けている可能性がある。


[^1]: これは「『Rolling Updateを使えば無停止更新できる』という説明は誤り」というよりも、「前提としている仕様を満たさなければ無停止更新に失敗することもある」、ということです。
[^2]: 後述するようにどのトラフィックも10秒で完了するように実装しているので、30秒というtear down期間は十分です
[^3]: さらにManagedとSelf Managedなどの区別もありますがここでは関係ないので区別しません
[^4]: 後述の実験と似たようなことを行い、発生するエラーが502, 504であること、それらがアプリケーション側で起きたエラーではないことをALBのログからざっくり確認しました。
[^5]: [公式サイト](https://locust.io)
[^6]: 504エラーのトラブルシューティングドキュメントに記載あり: https://repost.aws/ja/knowledge-center/504-error-alb
[^7]: ローカルマシンでのログが秒単位であること、Fargate上とローカルマシンで1～2秒程度の時刻ずれがあることから確証は得られなかったが、大きく見積もっても3秒未満でDeregisterTargets APIが呼び出されている。