RDSがフェイルオーバーした際、ECSで動作しているRuby on Railsを再起動する仕組みを構築した

# 概要
`RDS`がフェイルオーバを行うと`Ruby on Rails`(以下、`Rails`)では、ダウンしたDBを参照する現象が発生します。この問題により、`DB`を参照出来ない為、`Rails`が動作しなくなります。今回は、`RDS`のフェイルオーバーが行われたら、`RDS`のイベントサブスクリプション、`SNS`、`Lambda`を使用して、`ECS`で起動している`Rails`の`puma`を再起動する処理を構築しました。今回は、その実装方法を記載します。

# 前提条件
- `ECS`で`Ruby on Rails`の環境が動作している
- `Amazon aurora`(postgres)を使用している (RDSでも今回の実施は可能)
- `Terraform`を使用している (webコンソールから設定する事も可能)

# Lambdaの処理を動かす為の技術選定
フェイルオーバーが行われた事を`Lambda`に通知する機能として、RDSの[イベントサブスクリプション](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/USER_Events.Subscribing.html)と[EventBridge](https://docs.aws.amazon.com/ja_jp/eventbridge/latest/userguide/eb-what-is.html)の選択肢がありました。どちらの機能を使用しても表題の仕組みを構築するのは可能である為、技術選定に悩みました。悩んだ結果、早く実装が出来る利点を取り、RDSから提供されているイベントサブスクリプションを使用して今回は構築しました。


# Terraformの構築
:::note warn
注意事項
リソース名(リソースラベル)は、任意で変更して使用して下さい

```tf
resource "リソースタイプ" "リソース名" {
  # 属性の設定
}
```
:::


## Lambda
- `Lambad`で使用する`archive_file`は、コードが記述されたファイル(関数を記述したコード)をZIPにし、そのZIPファイルを`Lambda`へアップロードする為に使用する
```lambda.tf
# アーカイブ化
# Lamdbanにファイルを取り込む場合、ZIPファイルで取り込む必要がある為、このリソースでZIPファイルを作成する
data "archive_file" "db_failover" {
  # zipで固定 
  type = "zip"
  
  # 下記に指定したディレクトリ内のファイルは全てアーカイブ(zip化)される
  # source_dir = "${path.module}/任意のディレクトリー名"
  source_dir = "${path.module}/lambda_function"

  # アーカイブしたファイル(zipファイル)がこのパスに出力される
  # output_path = "${path.module}/任意のディレクトリー名/任意の名前が付与されたzipファイル"
  output_path = "${path.module}/archive/db_failover.zip"
}

# Lambda関数を作成
resource "aws_lambda_function" "db_failover" {
  # 任意の名前を設定する
  function_name = "任意の名前"

  # archive_fileにあるoutput_pathを指定する(ファイルパスでも指定可能)
  filename = data.archive_file.db_failover.output_path
  
  # lambdaのロールを指定する
  role = "IAMロールのarnを記述"
  
  # Lambda関数が呼び出された時に、最初に実行する関数を指定する
  # zipファイルを展開した際、最初に呼び出したい関数が書かれたファイルと関数名を記載する
  # (必要に応じてパスも書く必要あり。また、ファイルパスを書く場合は、[/]ではなく[.]で書く)
  # handler = "コードが書かれたファイル名(拡張子不要).最初に呼び出したい関数名を記述"
  handler = "db_failover.lambda_handler"
  
  # Lambda関数のソースコードが変更された際、Lambda関数を再デプロイする為のトリガー
  source_code_hash = data.archive_file.db_failover.output_base64sha256
  
  # Lambda関数の実行環境を指定する。著者は、pythonを選択
  runtime = "python3.9"

  # 環境変数を設定
  environment {
    variables = {
      CLUSTER_NAME   = "cluster名"
      SERVICE_NAME   = "service名"
      CONTAINER_NAME = "container名"
      MY_AWS_REGION  = "region名"  # AWS_REGIONは予約言語の為、使用不可
    }
  }

  # Lambda関数が作成される前に、下記のIAMロールが先に作成される事を保証する
  # CloudwWatchのロググループに、指定したファイルパスの場所にログが出力される様にする
  depends_on = [
    "IAMで作成したロールのarnを記述",
    "aws_cloudwatch_log_groupの[name]を記述"
  ]
}


# SNSトピックからLambda関数に対して、メッセージ送信を許可す為の設定
resource "aws_lambda_permission" "with_sns" {
  statement_id  = "AllowExecutionFromSNS"
  action        = "lambda:InvokeFunction"
  principal     = "sns.amazonaws.com"
  
  # Lambda関数をfunction_nameを記述する
  function_name = aws_lambda_function.lambda_function.function_name
  
  # SNSトピックのARN（Amazon Resource Name）を記述する
  source_arn    = "SNSトピックのarn"
}

# Lambda関数からCloudWatchに対して、ログを送信する為の設定
resource "aws_lambda_permission" "logging" {
  action        = "lambda:InvokeFunction"
  principal     = "events.amazonaws.com"
  
  # Lambda関数をfunction_nameを記述する
  function_name = aws_lambda_function.db_failover.function_name
  
  # CloudWatchLogsのARN（Amazon Resource Name）を記述する
  source_arn = "CloudWatchLogのarn"
}

```
## 補足
### アーカイブ
- 複数のファイルやディレクトリを1つのファイルにまとめる事を示す
(アーカイブ ≒ ZIP化と考えても良い)

### サブモジュール
- moduleの中の`source`に定義したモジュールをサブモジュールと呼ぶ
(下記のコードの場合、`my_module`がサブモジュールに該当する)
```tf
module "my_module" {
  source = "./modules/my_module"

  ...
}
```

### path.module
- `Terraform`の組み込み関数
- メインモジュール (`main.tf`等) から参照すると、その値はルートモジュールが定義されているディレクトリになる
(`main.rf`内で、`path.module`を使用すると`project/terraform`の値が`path.module`に格納されている)
- サブモジュール (`module.tf`等) から参照すると、その値はそのサブモジュールが定義されているディレクトリになる
(`module.tf`内で、`path.module`を使用すると`project/terraform/modules/example_module`の値が`path.module`に格納されている

<details>
  <summary>ディレクトリ構造</summary>

```sh
.
└── project/
    └── terraform/
        ├── main.tf # path.moduleを記述
        ├── variables.tf
        ├── lambda_function/
        │   └── index.js
        └── modules/
            └── example_module/
                ├── module.tf　 # path.moduleを記述
                └── nested_lambda_function/
                    └── handler.js
```
</details>　　

## IAM
`Lambda`関数がSNSトピックからメッセージを受信する為、`Lambda`関数のIAMロールに、`SNS`を操作する権限を付与する
```role.tf
# ロール
resource "aws_iam_role" "db_failover_lambda_role" {
  name = "任意の名前"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      { 
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        },
      }
    ]
  })
}


# SNSのポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "sns_full_access_policy" {
  role       = aws_iam_role.db_failover_lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSNSFullAccess"
}

# CloudWatchのポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "cloud_watch_full_access_policy" {
  role       = aws_iam_role.db_failover_lambda_role_role.name
  policy_arn = "arn:aws:iam::aws:policy/CloudWatchFullAccessV2"
}

#　ECSのポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "ecs_full_access_policy" {
  role       = aws_iam_role.db_failover_lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonECS_FullAccess"
}

```
:::note alert
ポリシーの注意事項
今回は、SNS、CloudWatch、ECSをフルアクセスにしておりますが、必要に応じて最小限のポリシーを付与して下さい
:::

- [[AWS 公式] IAM API によるロールの管理](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/lambda-intro-execution-role.html)
- [[AWS 公式] CloudWatchFullAccessV2](https://docs.aws.amazon.com/ja_jp/aws-managed-policy/latest/reference/CloudWatchFullAccessV2.html)
- [[AWS 公式] AmazonSNSFullAccess](https://docs.aws.amazon.com/ja_jp/aws-managed-policy/latest/reference/AmazonSNSFullAccess.html)


## SNS
```sns.tf
resource "aws_sns_topic" "db_failover_notify" {
  name = "任意の名前"
}

resource "aws_sns_topic_subscription" "db_failover_notify" {
  topic_arn = aws_sns_topic.db_failover_notify.arn

  # lambdaを動かす為、[lambda]を設定
  protocol  = "lambda"
  
  # 動作させたいlambda関数を指定する
  endpoint  = "aws_lambda_functionのarn"

  # 送信するイベントをフィルタリング(フェイルオーバーが完了した時のみ通知する)
  filter_policy = jsonencode({
    EventID = ["RDS-EVENT-0071"]
  })
}
```
- [DB クラスターイベント(RDS-EVENT-0071)](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/UserGuide/USER_Events.Messages.html#RDS-EVENT-0071)

## DBのイベントサブスクリプション
- RDSの場合は、`db-instance`にする
- Auroraの場合は、`db-cluster`にする
```tf
resource "aws_db_event_subscription" "db-failove" {
  name      = "任意の名前"
  sns_topic = "SNSトピックのarn"

  source_type = "db-cluster" # RDSの場合は、db_instanst
  source_ids  = [
    # RDSの場合、aws_rds_clusterのidを記述する(複数記述する事も可能)
    aws_rds_cluster.リソース名(リソースラベル).id

    # Auroraの場合は、aws_ecs_clusterのnameを記述する(複数記述する事も可能)
    aws_ecs_cluster.リソース名(リソースラベル).name
  ]
  # フェイルオーバーを実行した通知を送る為、[failover]を選択する
  event_categories = [
    "failover"   
  ]
}
```

## CloudWatch
```tf
resource "aws_cloudwatch_log_group" "db_failover" {
  name = "/aws/lambda/db-failover"
}
```

# Python
- `ECS`の[サービス(Service起動の事)]で起動しているタスクに対して、`ExecuteCommand`を使用して`puma`を再起動する処理を実装した
```py
import boto3
import os

ecs_client = boto3.client('ecs')

def lambda_handler(event, context):
    # 環境変数から設定値を読み込む
    cluster_name      = os.getenv('CLUSTER_NAME')
    service_name      = os.getenv('SERVICE_NAME')
    container_name    = os.getenv('CONTAINER_NAME')
    region_name       = os.getenv('MY_AWS_REGION')
    
    # Pumaの再起動コマンド
    puma_restart_command = 'pkill -USR2 puma'

    # 環境変数が正しく設定されているかチェック
    if not all([cluster_name, service_name, container_name, region_name]):
        raise ValueError(
            "Missing one or more required environment variables: CLUSTER_NAME, SERVICE_NAME, CONTAINER_NAME, AWS_REGION"
        )

    # ECSクライアントを作成
    ecs_client = boto3.client('ecs', region_name=region_name)

    # Step 1: ECSサービスでコマンド実行を有効にする
    enable_execute_command(ecs_client, cluster_name, service_name)
    print('Execute command enabled on the ECS service')

    # Step 2: 実行中のタスクIDを取得
    task_ids = get_running_task_ids(ecs_client, cluster_name, service_name)
    print(f'Running task IDs: {task_ids}')

    # Step 3: 各タスク内でコマンドを実行
    execute_command_in_container(ecs_client, cluster_name, task_ids, container_name, puma_restart_command)
    print('Puma restart command executed on all tasks')


# ECSサービスのExecuteCommandを有効にする
def enable_execute_command(ecs_client, cluster_name, service_name):
    # サービスの詳細を取得
    response = ecs_client.describe_services(
        cluster=cluster_name,
        services=[service_name]
    )
    service = response['services'][0]

    # ExecuteCommandが有効になっていない場合は有効にする
    if not service.get('enableExecuteCommand', False):
        # サービスのExecuteCommandを有効にする
        ecs_client.update_service(
            cluster=cluster_name,
            service=service_name,
            enableExecuteCommand=True
        )

# 実行中のタスクIDを全て取得
def get_running_task_ids(ecs_client, cluster_name, service_name):

    # サービスの中にある実行中のタスクを取得
    response = ecs_client.list_tasks(
        cluster=cluster_name,
        serviceName=service_name,
        desiredStatus='RUNNING'
    )

    # 実行中のタスクが見つからない場合は例外を発生させる
    if not response['taskArns']:
        raise Exception('No running tasks found')
    
    # タスクARNからタスクIDを取得
    task_ids = []
    for task_arn in response['taskArns']:
        task_id = task_arn.split('/')[-1]
        task_ids.append(task_id)
    
    return task_ids

# 複数のコンテナ内でコマンドを実行
def execute_command_in_container(ecs_client, cluster_name, task_ids, container_name, command):
    # 各タスクに対してコマンドを実行する
    for task_id in task_ids:
        response = ecs_client.execute_command(
            cluster=cluster_name,
            task=task_id,
            container=container_name,
            command=command,
            interactive=True
        )
        print(f"Command executed on task {task_id}: ", response)


# Lambdaハンドラのエントリポイント
if __name__ == "__main__":
    lambda_handler(None, None)
````

`get_running_task_ids`の`return`の部分の省略形の書き方を記載
```py
return [task_arn.split('/')[-1] for task_arn in response['taskArns']]
```

# フェイルオーバーの検証方法
RDSのコンソール画面にて、フェイルオーバーを検証する為のボタンが用意されている。その機能を利用して、フェイルオーバーの検証を行った。

- [インスタンスのクラッシュのテスト](https://docs.aws.amazon.com/ja_jp/AmazonRDS/latest/AuroraUserGuide/AuroraPostgreSQL.Managing.FaultInjectionQueries.html#AuroraPostgreSQL.Managing.FaultInjectionQueries.Crash)


# Terraform参考資料
- [archive_file (Data Source)](https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/file)
- [aws_lambda_function](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_function)
- [Resource: aws_lambda_permission With SNS](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission#with-sns)
- [Resource: aws_lambda_permission With CloudWatch Log Group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lambda_permission#with-cloudwatch-log-group)
- [aws_sns_topic](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic)
- [aws_sns_topic_subscription](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic_subscription)
- [aws_db_event_subscription](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/db_event_subscription)


# Python参考資料
- [Python の Lambda 関数ハンドラー](https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/python-handler.html)
- [[モジュール] os](https://docs.python.org/ja/3/tutorial/stdlib.html)
- [os.getenv](https://docs.python.org/ja/3/library/os.html#os.getenv)
- [AWS SDK for Python (Boto3) のドキュメント](https://docs.aws.amazon.com/ja_jp/pythonsdk/)
- [Boto3 reference](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/core/boto3.html)
- [ECS Client](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ecs.html#client)
- [describe_services](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ecs/client/describe_services.html)
- [list_tasks](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ecs/client/list_tasks.html)
- [execute_command](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ecs/client/execute_command.html)


# 表題の機能を構築した際の参考資料
- [Amazon RDS イベントサブスクリプションを通じた SNS イベントにメッセージ属性が含まれるようになりました](https://dev.classmethod.jp/articles/amazon-rds-events-include-attributes-filtering-amazon-sns/)
- [Amazon SNSのサブスクリプションフィルターとLambdaでRDSイベントを監視しよう](https://dev.classmethod.jp/articles/receive-rds-event-alert-with-aws-sns-subscription-filter-policy-jp/)
- [ECS Fargateを自動停止・自動起動する方法](https://qiita.com/keitah/items/aafdbb98dd79705fb604)
- [Terraformで構築するAmazon SNSからAWS Lambdaを呼び出すためのトリガ](https://qiita.com/hayaosato/items/fc3e4e8be285e8dbbd5c)
- [Terraformを使ってEventBridgeからLambdaを起動するようにAWSリソース作成してみた](https://dev.classmethod.jp/articles/terraform-creating-s3-and-eventbridge-and-lambda/)
- [[アップデート] Amazon RDS イベントサブスクリプションを通じた SNS イベントにメッセージ属性が含まれるようになりました](https://dev.classmethod.jp/articles/amazon-rds-events-include-attributes-filtering-amazon-sns/)



# まとめ
今回、初めて`Lambda`を使用しました。`Lambda`は本当にいろんな事に使えるんだと言う事を改めて認識したので、機会があれば、`Lambda`を使用して何か作れればと思います。