1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

この記事誰得? 私しか得しないニッチな技術で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

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

Last updated at Posted at 2024-06-14

概要

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

前提条件

  • ECSRuby on Railsの環境が動作している
  • Amazon aurora(postgres)を使用している (RDSでも今回の実施は可能)
  • Terraformを使用している (webコンソールから設定する事も可能)

Lambdaの処理を動かす為の技術選定

フェイルオーバーが行われた事をLambdaに通知する機能として、RDSのイベントサブスクリプションEventBridgeの選択肢がありました。どちらの機能を使用しても表題の仕組みを構築するのは可能である為、技術選定に悩みました。悩んだ結果、早く実装が出来る利点を取り、RDSから提供されているイベントサブスクリプションを使用して今回は構築しました。

Terraformの構築

注意事項
リソース名(リソースラベル)は、任意で変更して使用して下さい

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がサブモジュールに該当する)
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に格納されている
ディレクトリ構造
.
└── project/
    └── terraform/
        ├── main.tf # path.moduleを記述
        ├── variables.tf
        ├── lambda_function/
        │   └── index.js
        └── modules/
            └── example_module/
                ├── module.tf  # path.moduleを記述
                └── nested_lambda_function/
                    └── handler.js
  

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"
}

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

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の場合は、db-instanceにする
  • Auroraの場合は、db-clusterにする
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

resource "aws_cloudwatch_log_group" "db_failover" {
  name = "/aws/lambda/db-failover"
}

Python

  • ECSの[サービス(Service起動の事)]で起動しているタスクに対して、ExecuteCommandを使用してpumaを再起動する処理を実装した
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_idsreturnの部分の省略形の書き方を記載

return [task_arn.split('/')[-1] for task_arn in response['taskArns']]

フェイルオーバーの検証方法

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

Terraform参考資料

Python参考資料

表題の機能を構築した際の参考資料

まとめ

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?