こんにちは、エムスリーエンジニアリンググループの福林 (@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!
基盤開発チームでは一緒に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。