技術検証: NLBでターゲットグループの自動切り替えをLambda関数で実現する手順

## はじめに
本記事では、AWSのNLB（Network Load Balancer）を利用して、ターゲットグループを自動で切り替える方法について解説します。

EC2インスタンスのヘルスチェック結果に基づき、正常時と障害時に応じてトラフィックの送信先を変更する設定を行います。

これにより、サービスの可用性を高めるとともに、自動化された障害対応が可能になります。

## 知識整理

:::note info
**まず、基本的な役割と機能を整理していきます。**
**ターゲットグループ:**
NLBがトラフィックを送信する先のリソースをまとめたグループです。通常、WebサーバーのEC2インスタンスが含まれます。

**Lambda関数:**
サーバーレスでコードを実行できるAWSのサービス。今回は、ターゲットグループを自動で切り替える処理をLambda関数で実装します。

**EventBridge(今回は、作成しない):**
AWSのイベントバスサービス。EC2インスタンスの状態変化（例: ヘルスチェック失敗）をトリガーにして、Lambda関数を起動するために利用します。
:::

## 今回の検証手順
検証内容に基づき、以下の手順で実装を進めます。

### 1. パブリックサブネットにWebサーバーを構築する
VPCを作成し、パブリックサブネットを設定します。インターネットゲートウェイを設定し、インターネットアクセスを有効にします。

作成手順の詳細は割愛しますが、私の環境では以下のようなマルチAZ構成を基本としたネットワーク設計をしています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/ef6444e6-06cf-3947-7780-77a9f3cb0791.png)

詳しい作成手順については、過去の記事で紹介していますので、気になる方は参考にしてください。

https://qiita.com/free-honda/items/e9cbaadb13c79a03b14f

パブリックサブネットにEC2インスタンスを配置し、Webサーバーをインストールします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/08d80048-7304-e17a-2795-e1bc335b2db9.png)

ここでは、WebサーバーとしてApacheを導入します。サーバーのパブリックIPアドレスでアクセスし、デフォルトのApacheのテストページが表示されれば、インストールは正常に完了しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/3f1e88b6-fb0f-b0e4-5702-8c27cb2764b3.png)

:::note warn
**デフォルトのApacheのテストページが表示されない場合は、セキュリティグループのインバウンドルールで「HTTP(80):0.0.0.0/0」を許可する必要があります。**
:::

詳しい導入手順については、過去の記事「Apache HTTP Serverのインストール手順」を参考にしてください。

https://qiita.com/free-honda/items/1ebd3d9341299713357e

:::note alert
**追記事項(2024/10/20)**
ヘルスチェックのパスが「/」の場合、ロードバランサーはEC2インスタンス上のWebサーバーのルートディレクトリ（トップページ）を参照します。
:::

デフォルトでは、/var/www/htmlディレクトリが空のため、ヘルスチェックが失敗してしまいます。そのため、NLBがルートディレクトリ（/）に対してリクエストを送信した際に、Apacheが応答するコンテンツ（通常は index.html など）を作成しておきます。

```index.html
<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>テストサイト</title>
</head>
<body>
休みの日も勉強頑張ってて偉い！
</body>
</html>
```

上記ファイルを作成後、以下のコマンドでApacheのサービスを再起動してください。

```bash
sudo systemctl restart httpd
```

私の環境では、後述するNLBのDNS名をブラウザで指定してアクセスし、確認しています。
http://lambda-test-nlb-36987626621570cb.elb.ap-northeast-1.amazonaws.com/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/485ebd77-2a75-8032-6007-ac4e7c8d0c67.png)

:::note alert
この時点では、まだNLBを作成していません。後述の手順で、セキュリティグループ設定を含めて詳しく解説します。
:::

### 2. NLBの作成とデフォルトのターゲットグループの作成
ネットワークロードバランサー（NLB）を作成し、リスナーを設定します（例: ポート80または443）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/e38fb927-923a-0f5c-9353-f8e250c5da2a.png)

次に、NLBのターゲットグループとして、先ほど構築したEC2インスタンスを登録します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/baad8a31-79dc-ab38-1126-c81d8da51fb6.png)

NLBがEC2インスタンスの正常状態を確認できるよう、適切なヘルスチェックとターゲットの登録を設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/4ea13a01-3304-ad8b-fcd8-730a0513d426.png)

ヘルスチェックのパスは「/」に設定し、その他の設定はデフォルトのままとしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/325470cf-d90b-41ca-5f62-a50106cab55b.png)

最終的に、以下の画像の通り、作成したターゲットグループがデフォルトアクションとなっていること、およびNLBが作成されたことを確認できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/1a1aaa91-1b69-96eb-1362-b7fbd5dfeb30.png)

:::note warn
**NLBからのトラフィックを受け入れるために、EC2インスタンスのセキュリティグループでポート80のHTTPトラフィックを許可します。**
:::

また、「/var/www/html」ディレクトリにコンテンツを配置したため、ヘルスチェックが「成功（Healthy）」になっていることを確認しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/b21c320b-1900-0590-aa7e-fc379bb8a7a1.png)

## 追記：セキュリティグループの整理

私の環境では、それぞれのセキュリティグループに対して、以下のインバウンドおよびアウトバウンドルールを適用しています。設定は、各自の環境に合わせて調整してください。

**public-ec2-sg**
パブリックサブネットに配置したEC2インスタンスのインバウンドルールは以下の通りです。

| タイプ | プロトコル | ポート範囲 | ソース |
|:-:|:-:|:-:|:-:|
| HTTP  | TCP  |  80 | 0.0.0.0/0  |
| HTTP  | TCP  | 80  | nlbのセキュリティグループ  |
| HTTPS  | TCP  | 443  | 0.0.0.0/0  |

アウトバウンドルールは以下の通りです。

| タイプ | プロトコル | ポート範囲 | ソース |
|:-:|:-:|:-:|:-:|
| すべてのトラフィック  | すべて  |  すべて | 0.0.0.0/0  |
| HTTP  | TCP  | 80  | 0.0.0.0/0  |
| HTTPS  | TCP  | 443  | 0.0.0.0/0  |

**nlb-lambda-sg**
NLBのロードバランサーにアタッチするセキュリティグループのインバウンドルールは以下の通りです。

| タイプ | プロトコル | ポート範囲 | ソース |
|:-:|:-:|:-:|:-:|
| HTTP  | TCP  |  80 | 0.0.0.0/0  |
| カスタム TCP  | 0  | 80  | ec2のセキュリティグループ  |
| HTTPS  | TCP  | 443  | 0.0.0.0/0  |

アウトバウンドルールは以下の通りです。

| タイプ | プロトコル | ポート範囲 | ソース |
|:-:|:-:|:-:|:-:|
| すべてのトラフィック  | すべて  |  すべて | 0.0.0.0/0  |
| HTTP  | TCP  | 80  | ec2のセキュリティグループ  |

### 3. Lambda関数をターゲットとした設定（NLBのターゲット先にLambda関数は設定不可）

:::note alert
追記ですが、NLBではLambda関数を直接ターゲットに設定できないため、ALBをターゲット先として登録することにしました。
:::

NLBでは、ALBのようにターゲットグループ内で優先度を設定したルールを変更することが難しいため、~~Lambdaを使用してターゲットを変更していきます。~~ ムリでした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/cdea11cd-6b1f-4428-4c99-93539248ad17.png)

設定しているLambda関数については、前回の記事で紹介している、Lambda関数からS3の静的ページにリダイレクトするプログラムを使用しています。

https://qiita.com/free-honda/items/d5c8502cc019b94aa061

今回は、テスト用のターゲットグループとしてLambda関数を設定しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/96f8c65d-009d-e662-d499-65efbb747272.png)

~~障害発生時には、Lambda関数を使用してNLBのリスナー設定を変更し、ターゲットを切り替えます。~~ ムリでした。

### 4. イベント検知後にトリガーされるLambda関数の作成

以下のコードをLambda関数に設定し、NLBのリスナーを更新してターゲットグループを切り替えます。

```target_group.py
import boto3
import os

LISTENER_ARN = os.environ['LISTENER_ARN']
DEFAULT_TG_ARN = os.environ['DEFAULT_TG_ARN']
FAILOVER_TG_ARN = os.environ['FAILOVER_TG_ARN']
elbv2_client = boto3.client('elbv2')

def lambda_handler(event, context):
    try:
        # 現在のリスナー設定を取得
        response = elbv2_client.describe_listeners(
            ListenerArns=[LISTENER_ARN]
        )
        
        # 現在のターゲットグループARNを取得
        current_tg_arn = response['Listeners'][0]['DefaultActions'][0]['TargetGroupArn']
        print(f'Current target group ARN: {current_tg_arn}')

        # 切り替え先のターゲットグループARNを決定
        if current_tg_arn == DEFAULT_TG_ARN:
            new_tg_arn = FAILOVER_TG_ARN
            print('Switching to failover target group.')
        else:
            new_tg_arn = DEFAULT_TG_ARN
            print('Switching back to default target group.')
        
        # リスナーのデフォルトアクションを更新
        elbv2_client.modify_listener(
            ListenerArn=LISTENER_ARN,
            DefaultActions=[
                {
                    'Type': 'forward',
                    'TargetGroupArn': new_tg_arn
                }
            ]
        )
        print(f'Successfully updated listener to use target group: {new_tg_arn}')
        return {
            'statusCode': 200,
            'body': f'Successfully updated listener to use target group: {new_tg_arn}'
        }
        
    except Exception as e:
        print(f'Error updating listener: {str(e)}')
        return {
            'statusCode': 500,
            'body': f'Error updating listener: {str(e)}'
        }
```

:::note
**IAMロールの設定:** 
elasticloadbalancing:ModifyListenerの権限を持つIAMロールをLambdaに設定し、リスナー設定を変更できるようにします。

```bash
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "elasticloadbalancing:DescribeListeners",
        "elasticloadbalancing:ModifyListener"
      ],
      "Resource": "arn:aws:elasticloadbalancing:<region>:<account-id>:listener/app/<load-balancer-name>/<listener-id>"
    }
  ]
}
```
:::

補足として、上記のコードをテスト実行する際には、事前にLambda関数の環境変数を設定する必要があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/94a33cc1-2f74-d627-c05d-1bd0217d0ab6.png)

## 検証失敗：NLBではLambda関数をターゲットとしてそもそも選択できなかった...。

今回は、ターゲット先としてLambda関数を設定しているターゲットグループに対し、トリガーが発生した際にLambda関数内でターゲットグループを変更することを予定していました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/312c7359-f8a1-1fce-0456-50874d3279f7.png)

しかし、**上記の通り、NLBのターゲット先としてLambda関数を設定することはできず、プルダウンメニューにも表示されませんでした。**

### 急遽な代替策

今回の要件として、どうしてもNLBを使う必要があったため、Lambdaをターゲットとして指定する代わりに、ALBをターゲット先として設定することにしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/91e24174-06f8-173c-adc7-4c9007178902.png)

では、事前に作成しておいたLambda関数をテスト実行していきます。

```test.log
Test Event Name
test

Response
{
  "statusCode": 200,
  "body": "Successfully updated listener to use target group: arn:aws:elasticloadbalancing:ap-northeast-1:340823193247:targetgroup/ec-web/49710aa7bffe0cd0"
}
```

NLBのターゲットグループが変更され、デフォルトのターゲットグループ（ApacheのWebサーバー）から、事前に用意しておいたALB経由で別のLambda関数を呼び出すことができました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/43426480-4705-a4af-fb58-aa232d3f38df.png)

:::note
**その結果、S3バケットにリダイレクトされた先のindex.htmlが表示されていることを確認できました！**
:::

上記Lambda関数のコードにより、想定通りNLBのリスナーのターゲットグループが変更されていることが確認できたため、検証内容としては成功と言えます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3662571/d061fc58-2585-79a5-52d7-c620a8eb57e9.png)

## まとめ
当初はNLBのターゲットグループにLambdaを直接指定して自動切り替えを行う予定でしたが、NLB自体がLambdaを直接ターゲットにできないという制約により、その方法は難しいことがわかりました。

しかし、この制約を乗り越えるために、リスナー設定のターゲットグループをALBのターゲット先にLambda関数で変更するという代替手法を検討し、検証をやりきりました。

## おまけ：個人備忘録メモ
このコードは、CloudWatchアラームの状態に応じてALBのターゲットグループを切り替える処理を行い、すでに目的のページ（通常ページまたはSorryページ）が表示されている場合には処理を中断します。

```py
import boto3
import os

LISTENER_ARN = os.environ['LISTENER_ARN']
DEFAULT_TG_ARN = os.environ['DEFAULT_TG_ARN']
FAILOVER_TG_ARN = os.environ['FAILOVER_TG_ARN']
SORRY_RULE_ARN = os.environ['SORRY_RULE_ARN']
TARGET_GROUP_RULE_ARN = os.environ['TARGET_GROUP_RULE_ARN']

elbv2_client = boto3.client('elbv2')
cloudwatch = boto3.resource('cloudwatch')
alarm = cloudwatch.Alarm("HealthlyHostCount")

def lambda_handler(event, context):
    try:
        # 現在のリスナー設定を取得
        response = elbv2_client.describe_listeners(
            ListenerArns=[LISTENER_ARN]
        )
        
        # 現在のターゲットグループARNを取得
        current_tg_arn = response['Listeners'][0]['DefaultActions'][0]['TargetGroupArn']
        print(f'Current target group ARN: {current_tg_arn}')

        # ALBのルールのARNを取得
        current_rule_arn = elbv2_client.describe_rules(ListenerArn=LISTENER_ARN)['Rules'][0]['RuleArn']

        # すでにSorryページが表示されている場合は処理を中断
        if alarm.state_value == "ALARM" and current_rule_arn == SORRY_RULE_ARN:
            return {
                'statusCode': 200,
                'body': 'Sorryページはすでに表示されています'
            }
        elif alarm.state_value == "OK" and current_rule_arn == TARGET_GROUP_RULE_ARN:
            return {
                'statusCode': 200,
                'body': '通常ページはすでに表示されています'
            }

        # 切り替え先のターゲットグループARNを決定
        if current_tg_arn == DEFAULT_TG_ARN:
            new_tg_arn = FAILOVER_TG_ARN
            print('Switching to failover target group.')
        else:
            new_tg_arn = DEFAULT_TG_ARN
            print('Switching back to default target group.')
        
        # リスナーのデフォルトアクションを更新
        elbv2_client.modify_listener(
            ListenerArn=LISTENER_ARN,
            DefaultActions=[
                {
                    'Type': 'forward',
                    'TargetGroupArn': new_tg_arn
                }
            ]
        )
        print(f'Successfully updated listener to use target group: {new_tg_arn}')
        return {
            'statusCode': 200,
            'body': f'Successfully updated listener to use target group: {new_tg_arn}'
        }
        
    except Exception as e:
        print(f'Error updating listener: {str(e)}')
        return {
            'statusCode': 500,
            'body': f'Error updating listener: {str(e)}'
        }
```

:::note warn
この条件により、不要なターゲットグループの切り替えを防ぎ、必要な場合にのみ切り替えを実行することができます。
:::


### 追加した内容の解説
cloudwatch = boto3.resource('cloudwatch') でCloudWatchのリソースを作成し、HealthlyHostCountというアラームの状態を確認します。

current_rule_arn は、現在ALBで適用されているルールのARNです。

アラームの状態がALARMで、現在のルールがSorryページのルール（SORRY_RULE_ARN）の場合、すでにSorryページが表示されているので処理を中断します。

アラームがOKで、通常ページのルール（TARGET_GROUP_RULE_ARN）の場合も同様に処理を中断します。

## 関連記事

https://qiita.com/free-honda/items/d5c8502cc019b94aa061