こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。
前回の記事で書いたように、 現在、クラウド化推進で、多くのサービスのAWS移行を実施している関係で知見が貯まっているので、今回もインフラの話題です。
使わないなら片付けましょう
検証環境で動いているサービスのうち、主に人が使うためのサービスは人がいない時間帯は動いている必要がありません。 例えば、スタッフ向けの管理画面は、検証環境では、検証用のデータを投入したり、設定を変更したりするために使うものなので、 夜間や休日に動いていても使うことはありません。
これらのサービスは、本番環境では24時間365日動いていなければいけませんが、 検証環境では、夜間や休日は起動していても誰も使わないのでもったいないです。 夜間と休日はサービスを停止してしまいましょう。
検証環境の監視通知は無視してよい?
しかし、夜間と休日にサービスを停止すると、監視設定が問題になります。 基本的には、本番環境も検証環境もパラメータは違っても同じ監視設定をするので、 検証環境で夜間や休日にサービスを停止した時に毎回サービス停止の監視アラームが通知されてしまいます。
「検証環境は夜間と休日に止めているので、通知は無視すればいい」という暗黙の運用もアリだとは思いますが、 この運用は大量の通知が定期的に来ることで、夜間と休日 以外 の通知も無視する方向に向かうのであまりよくありません。 検証環境であっても、異常が発生したその時だけに通知するように設計したいです。
ECSとCloudWatch Alarmの通知を片づける魔法
今回は検証環境のECS(Fargate)とそれを監視するCloudWatch Alarmのアクション (=Simple Notification Serviceによる通知)*1を 過不足なく停止して、ECSの料金を節約します。
- 検証環境では夜間と休日は、ECSもCloudWatch Alarmによる通知も停止させたい
- でもその他の時間帯はCloudWatch Alarmの監視通知を有効にしたい
- 本番環境と検証環境では同じTerraformを使いたい
月〜金の9:00〜22:00だけ稼動させると1週間に65時間稼動します*2。 無停止の場合は1週間で168時間稼動するので、夜間と休日に停止させることでECSの料金が65/168=38%になります。 Fargateで1vCPU, 1GBのコンテナを1年間無停止で稼動させると約500USD(東京リージョン、2020年8月現在)かかるので、年間で310USDくらい節約できます。
ECSの停止/再開
ECSはApplication Auto Scalingで必要数をスケジュールで変更させて、停止/再開の設定をします。
Terraformで設定します。まずは入力の定義。
# variables.tf variable "application_env_name" { type = string description = "アプリ名と環境名を連結した名前" # service-qa1 とか } variable "scheduled_suspending" { description = "定期停止の設定と、止める場合はスケジュール設定" type = object({ # 定期停止するか enable = bool # (止める場合だけ) 止めるスケジュール stop_schedule = string # (止める場合だけ) 復帰させるスケジュール start_schedule = string }) # デフォルトは無効 default = { enable = false stop_schedule = "invalid" start_schedule = "invalid" } } variable "resource_id" { type = string description = "停止対象リソース" } variable "ecs_task_desired_count" { type = number description = "ECSタスクの必要数" }
scheduled_suspending
で定期停止するかどうかと、停止する場合のスケジュールを設定しますresource_id
は停止する対象です。ecs_task_desired_count
は稼動時の必要数です。停止時にこれを0にします。
具体的には以下のように設定します。
# terraform.tfvars scheduled_suspending = { # 検証環境は夜間と土日は停止する enable = true # 毎日22時(JST)に停止 (cronはUTC) stop_schedule = "cron(0 13 * * ? *)" # 月〜金の9時(JST)に起動 (cronはUTC) start_schedule = "cron(0 0 ? * MON-FRI *)" } resource_id = "service/${ECSクラスター名}/${ECSサービス名}" ecs_task_desired_count = 1
土日関係なく22時(JST)に停止させ、月〜金の9時(JST)に起動させます。これで夜間、休日は稼動しません。
これらを aws_appautoscaling_scheduled_action
で設定します。
# autoscaling対象 resource "aws_appautoscaling_target" "ecs_main" { count = var.scheduled_suspending.enable ? 1 : 0 max_capacity = var.ecs_task_desired_count min_capacity = var.ecs_task_desired_count resource_id = var.resource_id scalable_dimension = "ecs:service:DesiredCount" service_namespace = "ecs" } # 自動停止(QA向け) resource "aws_appautoscaling_scheduled_action" "ecs_main_stop" { count = var.scheduled_suspending.enable ? 1 : 0 name = "${var.application_env_name}-stop" service_namespace = aws_appautoscaling_target.ecs_main[count.index].service_namespace resource_id = aws_appautoscaling_target.ecs_main[count.index].resource_id scalable_dimension = aws_appautoscaling_target.ecs_main[count.index].scalable_dimension scalable_target_action { min_capacity = 0 max_capacity = 0 } schedule = var.scheduled_suspending.stop_schedule } # 自動起動(QA向け) resource "aws_appautoscaling_scheduled_action" "ecs_main_start" { count = var.scheduled_suspending.enable ? 1 : 0 name = "${var.application_env_name}-start" service_namespace = aws_appautoscaling_target.ecs_main[count.index].service_namespace resource_id = aws_appautoscaling_target.ecs_main[count.index].resource_id scalable_dimension = aws_appautoscaling_target.ecs_main[count.index].scalable_dimension scalable_target_action { min_capacity = var.ecs_task_desired_count max_capacity = var.ecs_task_desired_count } schedule = var.scheduled_suspending.start_schedule }
var.scheduled_suspending.stop_schedule
で指定したスケジュールに従って、
ECSの必要数が 0
になるのでコンテナが止まります。
同時に、var.scheduled_suspending.stop_schedule
で指定したスケジュールで
ECSのコンテナが必要数で指定した数に戻ります。
なお、 var.scheduled_suspending.enable
が false
の場合は
count
が 0
になるので、この設定自体が生成されません。
したがって、本番環境では false
を設定して間違って設定されないようにします。
これでECSはスケジュールに沿って停止/起動させることができます。
CloudWatch Alarmによる通知の停止/再開
CloudWatch AlarmはAuto Scalingでコントロールできないので、 CloudWatch イベントルールでアクションを定義し、そのアクションでLambdaを起動して CloudWatch Alarmのアクション(今回はAmazon SNSによる通知)を有効にしたり、無効にしたりします。
まずはLabmdaの定義。このLambdaで無効と有効の両方を実行できます。
# main.py """指定のアラームのアクション(SNSによる通知)を無効/有効にする""" # ロギングやエラー処理は省略 import boto3 def disable_alarm_actions(name): """アラームのアクションを無効にする""" boto3.client("cloudwatch").disable_alarm_actions(AlarmNames=[name]) def enable_alarm_actions(name): """アラームのアクションを有効にする""" boto3.client("cloudwatch").enable_alarm_actions(AlarmNames=[name]) def handler(event: dict, context): # 対象 name = event.get("alarm", None) # 有効/無効 state = event.get("state", None) if state == "enable": enable_alarm_actions(name) elif state == "disable": disable_alarm_actions(name) else: raise ValueError("'state' should be 'enable' or 'disable'")
操作するアラームの名前("alarm"
)と、無効/有効("state"
)を受けとり操作するだけです。
次にCloudWacthイベントルールで、スケジュールに沿って発生するイベントを定義します。
resource "aws_cloudwatch_event_rule" "stop_alarm_actions" { count = var.scheduled_suspending.enable ? 1 : 0 name = "${var.application_env_name}-stop-alarm-actions" description = "${var.application_env_name} のアラームのアクションを停止する" schedule_expression = var.scheduled_suspending.stop_schedule is_enabled = true } resource "aws_cloudwatch_event_rule" "start_alarm_actions" { count = var.scheduled_suspending.enable ? 1 : 0 name = "${var.application_env_name}-start-alarm-actions" description = "${var.application_env_name} のアラームのアクションを再開する" schedule_expression = var.scheduled_suspending.start_schedule is_enabled = true }
このCloudWatchイベントの対象を定義します。
以下ではALBのunhealthyの数を監視するCloudWacthアラーム ("aws_cloudwatch_metric_alarm.unhealthy_host_count"
) に対する
アクション(SNSによる通知)の停止と再開を定義しています。
input
には先ほど定義したLambda本体に渡す変数を指定します。
# unhealthyなコンテナ数 通知停止 resource "aws_cloudwatch_event_target" "stop_unhealthy_host_count_alarm" { count = var.scheduled_suspending.enable ? 1 : 0 rule = aws_cloudwatch_event_rule.stop_alarm_actions[count.index].name arn = aws_lambda_function.manage_alarm_actions[count.index].arn input = jsonencode({ "alarm" = "${aws_cloudwatch_metric_alarm.unhealthy_host_count.alarm_name}", "state" = "disable" }) } # unhealthyなコンテナ数 通知再開 resource "aws_cloudwatch_event_target" "start_unhealthy_host_count_alarm" { count = var.scheduled_suspending.enable ? 1 : 0 rule = aws_cloudwatch_event_rule.start_alarm_actions[count.index].name arn = aws_lambda_function.manage_alarm_actions[count.index].arn input = jsonencode({ "alarm" = "${aws_cloudwatch_metric_alarm.unhealthy_host_count.alarm_name}", "state" = "enable" }) } # 他の監視項目も同様
次にLambdaの定義です。
# aws_lambda_function.tf # source_dir 内に main.py がある data "archive_file" "lambda" { type = "zip" source_dir = "${abspath(path.module)}/lambda" output_path = "${abspath(path.module)}/.upload/lambda.zip" } resource "aws_lambda_function" "manage_alarm_actions" { count = var.scheduled_suspending.enable ? 1 : 0 filename = data.archive_file.lambda.output_path role = aws_iam_role.manage_alarm_actions[count.index].arn source_code_hash = data.archive_file.lambda.output_base64sha256 function_name = "${var.application_env_name}-manage-alarm-actions" timeout = 60 runtime = "python3.8" handler = "main.handler" }
Lambdaを定義しただけだと、権限が足りなくて動きません。 別途、権限を設定します。
# aws_lambda_function.tf resource "aws_iam_role" "manage_alarm_actions" { count = var.scheduled_suspending.enable ? 1 : 0 name = "${var.application_env_name}-manage-alarm-actions" assume_role_policy = data.aws_iam_policy_document.manage_alarm_actions_assume_role_policy.json } data "aws_iam_policy_document" "manage_alarm_actions_assume_role_policy" { statement { actions = ["sts:AssumeRole",] principals { type = "Service" identifiers = ["lambda.amazonaws.com",] } } } resource "aws_iam_role_policy_attachment" "manage_alarm_actions_basic_execution" { count = var.scheduled_suspending.enable ? 1 : 0 role = aws_iam_role.manage_alarm_actions[count.index].name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" } # CloudWatch Alarm Actionsの有効化/無効化 権限 data "aws_iam_policy_document" "manage_alarm_actions_policy" { statement { actions = ["cloudwatch:EnableAlarmActions", "cloudwatch:DisableAlarmActions"] resources = [ aws_cloudwatch_metric_alarm.unhealthy_host_count.arn, aws_cloudwatch_metric_alarm.memory_utilization.arn, aws_cloudwatch_metric_alarm.cpu_utilization.arn, aws_cloudwatch_metric_alarm.elb_5xx_count.arn, ] } } resource "aws_iam_policy" "manage_alarm_actions_policy" { count = var.scheduled_suspending.enable ? 1 : 0 name = "${var.application_env_name}-manage-alarm-actions" policy = data.aws_iam_policy_document.manage_alarm_actions_policy.json } resource "aws_iam_role_policy_attachment" "manage_alarm_actions" { count = var.scheduled_suspending.enable ? 1 : 0 role = aws_iam_role.manage_alarm_actions[count.index].name policy_arn = aws_iam_policy.manage_alarm_actions_policy[count.index].arn } resource "aws_lambda_permission" "stop_alarm_actions" { count = var.scheduled_suspending.enable ? 1 : 0 action = "lambda:InvokeFunction" function_name = aws_lambda_function.manage_alarm_actions[count.index].function_name principal = "events.amazonaws.com" source_arn = aws_cloudwatch_event_rule.stop_alarm_actions[count.index].arn } resource "aws_lambda_permission" "start_alarm_actions" { count = var.scheduled_suspending.enable ? 1 : 0 action = "lambda:InvokeFunction" function_name = aws_lambda_function.manage_alarm_actions[count.index].function_name principal = "events.amazonaws.com" source_arn = aws_cloudwatch_event_rule.start_alarm_actions[count.index].arn }
ちょっと長いですが、やっていることは2つです。
"aws_iam_role.manage_alarm_actions"
- Lambdaに付与するIAM Role
- CloudWatch メトリクスアラームのアクション(SNSの通知)を操作する権限 (
"cloudwatch:EnableAlarmActions"
,"cloudwatch:DisableAlarmActions"
)
"aws_lambda_permission.start_alarm_actions
,"aws_lambda_permission.stop_alarm_actions
- さきほど定義したloudWatchイベント (
"aws_cloudwatch_event_rule.start_alarm_actions"
,"aws_cloudwatch_event_rule.stop_alarm_actions"
) がLambdaを起動する ("lambda:InvokeFunction"
) 権限
- さきほど定義したloudWatchイベント (
最終的に以下のようにできます。
22時(JST)にアラームのアクション(SNSによる通知)が無効化され、9時(JST)に再び有効化されます。 なお、アラーム自体は止まっていないので、22時(JST)にECSが止まった後にOK→アラーム状態になり、9時(JST)にECSが起動した後にアラーム状態→OKに変化します。
We are hiring!
基盤開発チームでは一緒に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。