概要
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のイベントサブスクリプションとEventBridgeの選択肢がありました。どちらの機能を使用しても表題の仕組みを構築するのは可能である為、技術選定に悩みました。悩んだ結果、早く実装が出来る利点を取り、RDSから提供されているイベントサブスクリプションを使用して今回は構築しました。
Terraformの構築
注意事項
リソース名(リソースラベル)は、任意で変更して使用して下さい
resource "リソースタイプ" "リソース名" {
# 属性の設定
}
Lambda
-
Lambad
で使用するarchive_file
は、コードが記述されたファイル(関数を記述したコード)をZIPにし、そのZIPファイルをLambda
へアップロードする為に使用する
# アーカイブ化
# 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
を操作する権限を付与する
# ロール
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
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_ids
のreturn
の部分の省略形の書き方を記載
return [task_arn.split('/')[-1] for task_arn in response['taskArns']]
フェイルオーバーの検証方法
RDSのコンソール画面にて、フェイルオーバーを検証する為のボタンが用意されている。その機能を利用して、フェイルオーバーの検証を行った。
Terraform参考資料
- archive_file (Data Source)
- aws_lambda_function
- Resource: aws_lambda_permission With SNS
- Resource: aws_lambda_permission With CloudWatch Log Group
- aws_sns_topic
- aws_sns_topic_subscription
- aws_db_event_subscription
Python参考資料
- Python の Lambda 関数ハンドラー
- [モジュール] os
- os.getenv
- AWS SDK for Python (Boto3) のドキュメント
- Boto3 reference
- ECS Client
- describe_services
- list_tasks
- execute_command
表題の機能を構築した際の参考資料
- Amazon RDS イベントサブスクリプションを通じた SNS イベントにメッセージ属性が含まれるようになりました
- Amazon SNSのサブスクリプションフィルターとLambdaでRDSイベントを監視しよう
- ECS Fargateを自動停止・自動起動する方法
- Terraformで構築するAmazon SNSからAWS Lambdaを呼び出すためのトリガ
- Terraformを使ってEventBridgeからLambdaを起動するようにAWSリソース作成してみた
- [アップデート] Amazon RDS イベントサブスクリプションを通じた SNS イベントにメッセージ属性が含まれるようになりました
まとめ
今回、初めてLambda
を使用しました。Lambda
は本当にいろんな事に使えるんだと言う事を改めて認識したので、機会があれば、Lambda
を使用して何か作れればと思います。