AWSによる自動メンテナンスページ表示の実現: NLBとLambdaを活用した可用性向上の構成案

## はじめに
実務において、ネットワーク負荷分散（NLB）を利用した構成で、システムが停止した際に自動でメンテナンスページ（Sorryページ）を表示させる必要がありました。

特に、EC2インスタンスが停止した際に、その状態を監視して自動的にSorryページへ切り替えるフローの構築が求められ、EventBridgeやLambdaを利用した動的な制御が必要でした。

今回の記事では、この動作を実現するために行った構成案について、考えましたので、検証前の自分への備忘録として整理していきます。

## 前提知識
基本的なAWSに関する知識は以下になります。

:::note
**NLB (Network Load Balancer):** 
通常時、クライアントのリクエストはヘルシーなEC2インスタンスへルーティングされます。

**EC2インスタンス:**
ヘルシーな状態であれば、通常のサービスを提供します。

**EventBridge:** 
EC2インスタンスの状態（Unhealthyかどうか）を監視します。すべてのインスタンスがUnhealthyになると、イベントを発生させます。

**Lambda関数:**
EventBridgeで発生したイベントを受け、NLBのターゲットグループをSorryページ用の
ALB（Application Load Balancer）に切り替えます。

**ALB (Application Load Balancer):** 
Sorryページを固定レスポンス機能を使って表示します。
:::

## フロー全体の流れ

**通常時のトラフィックフロー**
まず、クライアントからのリクエストは、NLBに送られ、そこから EC2 インスタンスにトラフィックがルーティングされます。

この構成は、基本的なNLBとEC2インスタンスの組み合わせです。

```css
[クライアント (HTTPS)] 
        |
        v
[NLB (TCP:443)] 
        |
        v
[通常時のターゲットグループ (EC2インスタンス)]
        |
        v
[正常なサービス提供]
```

**EC2インスタンス停止時のトラフィックフロー**
障害やメンテナンス時にすべてのEC2インスタンスが停止、もしくはヘルスチェックに失敗した場合、NLBはトラフィックをルーティングする先がなくなります。

```css
[クライアント (HTTPS)] 
    |
    v
[NLB (TCP:443)] 
    |
    v
[UnhealthyなEC2インスタンス]
    |
    v
[トラフィック停止] 
    |
    v
[CloudWatchアラームが発動] 
    |
    v
[EventBridge] 
    |
    v
[Lambda起動] 
    |
    v
[NLBターゲットグループをSorryページ用の
ターゲットグループ (ALB/EC2)に変更]
    |
    v
[Sorryページ用のALB]
    |
    v
[固定レスポンスでSorryページ表示]
```

ここで、EventBridgeとLambda関数が動的にターゲットグループを変更します。
## 実装手順案
NLBに関連付けられた2台のEC2インスタンスが停止した場合、EventBridgeルールがこの状態を検知します。

EventBridgeがLambdaをトリガーし、そのLambdaがNLBのターゲットグループを動的に変更する案の流れです。

### 1. EventBridgeルールの作成
EventBridgeでEC2インスタンスの状態変化を監視します。EC2インスタンスを インスタンスID で指定し、stopped 状態のみを検知するためのEventBridgeルールのJSONは以下の通りです。

```json
{
  "source": ["aws.ec2"],
  "detail-type": ["EC2 Instance State-change Notification"],
  "detail": {
    "state": ["stopped"],
    "instance-id": [
      "i-xxxxxxxxxxxxxxxxx",  // 1台目のEC2インスタンスID
      "i-yyyyyyyyyyyyyyyyy"   // 2台目のEC2インスタンスID
    ]
  }
}
```

### 2. Lambda関数の作成
Lambda関数内でNLBのターゲットグループを変更するコードを書きます。

![スクリーンショット 2024-09-28 16.35.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/610d4d25-25f7-3d93-27f0-23c4a5de0788.png)


**プログラムフロー図**
```txt
[Lambda関数呼び出し]
    |
    v
1. [NLBのARNとSorryページ用のターゲットグループARNを準備]
    |
    v
2. [get_nlb_listener(nlb_arn) 呼び出し]
    |
    v
   2.1 [NLBリスナー情報取得 (describe_listeners API)]
    |
    v
3. [update_nlb_target_group(listener_arn, new_target_group_arn) 呼び出し]
    |
    v
   3.1 [NLBのリスナーをSorryページ用のターゲットグループに変更 (modify_listener API)]
    |
    v
4. [Sorryページ表示用のターゲットグループにルーティング]
    |
    v
[Lambda関数終了]
```

**Lambdaコード例 (Python: Boto3)**

AWS SDK（Python用Boto3など）を使用して、NLBのターゲットグループの状態を確認します。

```python
import boto3

def lambda_handler(event, context):
    elbv2_client = boto3.client('elbv2')
    ec2_client = boto3.client('ec2')

    # NLBのターゲットグループARNとSorryページのターゲットグループARNを指定
    target_group_arn = 'arn:aws:elasticloadbalancing:region:account-id:targetgroup/your-target-group'
    sorry_target_group_arn = 'arn:aws:elasticloadbalancing:region:account-id:targetgroup/sorry-target-group'

    # チェックするインスタンスのIDを指定
    instance_ids = ['i-xxxxxxxxxxxxxxxxx', 'i-yyyyyyyyyyyyyyyyy']

    # EC2インスタンスの状態を取得
    instance_status = ec2_client.describe_instance_status(InstanceIds=instance_ids)

    # すべてのインスタンスが停止しているか確認
    stopped_count = sum(1 for status in instance_status['InstanceStatuses'] if status['InstanceState']['Name'] == 'stopped')

    # すべてのインスタンスが停止している場合、ターゲットグループをSorryページに切り替える
    if stopped_count == len(instance_ids):
        # ターゲットグループのヘルスチェック設定を無効化
        elbv2_client.modify_target_group_attributes(
            TargetGroupArn=target_group_arn,
            Attributes=[
                {'Key': 'healthcheck.enabled', 'Value': 'false'}
            ]
        )
        
        # NLBのルーティングをSorryページのターゲットグループに切り替え
        elbv2_client.modify_listener(
            ListenerArn='arn:aws:elasticloadbalancing:region:account-id:listener/net/your-nlb-listener',
            DefaultActions=[
                {
                    'Type': 'forward',
                    'TargetGroupArn': sorry_target_group_arn
                }
            ]
        )
        print("All instances are stopped. Switched to Sorry page target group.")
    else:
        print("Not all instances are stopped.")
```

上記のように、NLBのリスナー設定を更新してターゲットグループを切り替えます。

イベントを検知し、2台のインスタンスが停止していることを確認するロジックも実装しています。

### 3. IAMロールの設定
Lambda関数がNLBを操作するためには、必要なIAMロールを設定する必要があります。以下のような権限を付与します。

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "elasticloadbalancing:ModifyListener",
        "elasticloadbalancing:DescribeTargetGroups"
      ],
      "Resource": "*"
    }
  ]
}
```

### 4. LambdaとEventBridgeの統合
EventBridgeルールでEC2インスタンスの状態変化が検知されたとき、Lambda関数をトリガーする設定を行います。

:::note
ターゲット: Lambda関数
条件: EC2インスタンスの状態が stopped であること
:::

### 5. NLBのターゲットグループの作成
NLBに紐づくターゲットグループを作成し、そのターゲットとしてALB（Application Load Balancer）の固定レスポンスを設定します。

![スクリーンショット 2024-09-28 16.46.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/83ed5e48-21b7-8366-7766-b1dab793b30d.png)

![スクリーンショット 2024-09-28 16.47.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/d50fc9c2-f6e3-827b-527d-131f8603464a.png)


:::note
ALBの固定レスポンスが設定され、リクエストに対して指定したレスポンスが返されるようになります。
:::

### 6. 動作確認
EC2インスタンスを手動で停止し、EventBridgeがLambdaをトリガーするかを確認します。

LambdaがNLBのターゲットグループをTCP:443からTCP:80に切り替え、Sorryページが正しく表示されることを確認できれば問題ありません。

:::note warn
この構成は、NLBのターゲットグループが変更された場合に、ユーザーに対してメンテナンス情報を提供することを想定していますが、未検証になります。(2024/9/28時点)
:::


## まとめ
この構成を利用することで、EC2インスタンスが障害やメンテナンスで停止した際に、自動的にトラフィックがSorryページへ誘導され、ユーザーに対してメンテナンス情報を提供できることを想定しています。

実務でのメンテナンス時に役立つ柔軟な構成として、ぜひ参考にしてみてください。

## 追記：この案を検討した理由
障害時もHTTPS通信 (TCP:443) を維持し、ターゲットグループのみを動的に変更する構成です。

この方法では、クライアントからのHTTPSアクセスを維持したまま、ターゲットのEC2インスタンスやALBを切り替えることができます。

### 構成概要
**通常時:** NLBは通常、ヘルシーなEC2インスタンスにルーティングし、ユーザーにサービスを提供します。通信は HTTPS (TCP:443) で行われます。

**障害時:** すべてのEC2インスタンスが停止するか、Unhealthyになると、EventBridgeがこの状態を検知します。EventBridgeがLambda関数をトリガーし、NLBのリスナー（HTTPS/TCP:443）で使用するターゲットグループを、Sorryページ用のターゲットグループに変更します。

**Sorryページ表示:** 障害が発生しても、ユーザーからのアクセスは引き続き HTTPS (TCP:443) でNLBに送信され、NLBが新しいターゲットグループにルーティングすることで、Sorryページが表示されます。

### この案が現実的と考えた理由
**HTTPS通信が維持される:** クライアントからのHTTPS通信が中断されることなく、障害時も同じポート（TCP:443）でSorryページを提供できるため、エンドユーザーの視点ではスムーズな切り替えが実現します。

**自動的な切り替え:** EventBridgeとLambdaによる自動的なターゲットグループ変更により、インフラチームの手動対応を減らすことができ、ダウンタイムを最小限に抑えられます。

**可用性向上:** EC2インスタンスに障害が発生した場合、すぐにSorryページへと切り替わるため、可用性とユーザーエクスペリエンスの向上が図れます。

## おまけ
以下は、EC2インスタンスが復旧した場合にNLBのターゲットグループを元のEC2インスタンスに戻すためのLambda関数とEventBridgeの設定コードです。

### EventBridgeルールの作成
EC2インスタンスが復旧した際に、running状態に変わったことを検知するEventBridgeルールのJSONは以下の通りです。
```json
{
  "source": ["aws.ec2"],
  "detail-type": ["EC2 Instance State-change Notification"],
  "detail": {
    "state": ["running"],
    "instance-id": [
      "i-xxxxxxxxxxxxxxxxx",  // 1台目のEC2インスタンスID
      "i-yyyyyyyyyyyyyyyyy"   // 2台目のEC2インスタンスID
    ]
  }
}
```

### Lambda関数の作成
このLambda関数は、復旧したEC2インスタンスの状態に基づいてNLBのターゲットグループを元のEC2インスタンスに戻します。

**Lambdaコード例 (Python: Boto3)**

```python
import boto3

# NLBのリスナーを取得する
def get_nlb_listener(nlb_arn):
    elbv2 = boto3.client('elbv2')
    response = elbv2.describe_listeners(LoadBalancerArn=nlb_arn)
    return response['Listeners'][0]['ListenerArn']

# ターゲットグループを変更する
def update_nlb_target_group(nlb_listener_arn, original_target_group_arn):
    elbv2 = boto3.client('elbv2')
    elbv2.modify_listener(
        ListenerArn=nlb_listener_arn,
        DefaultActions=[
            {
                'Type': 'forward',
                'TargetGroupArn': original_target_group_arn
            }
        ]
    )

def lambda_handler(event, context):
    # NLBのARNと元のターゲットグループのARNを指定
    nlb_arn = 'arn:aws:elasticloadbalancing:region:account-id:loadbalancer/net/my-nlb/abc123'
    original_target_group_arn = 'arn:aws:elasticloadbalancing:region:account-id:targetgroup/original-target-group/xyz789'
    
    # リスナーのARNを取得
    listener_arn = get_nlb_listener(nlb_arn)
    
    # ターゲットグループを元のEC2インスタンスのターゲットグループに変更
    update_nlb_target_group(listener_arn, original_target_group_arn)
```