エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

片づけの魔法でFargateの費用を65/168にしましょう

こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。

前回の記事で書いたように、 現在、クラウド化推進で、多くのサービスのAWS移行を実施している関係で知見が貯まっているので、今回もインフラの話題です。

f:id:fukubaya:20200807213916j:plain
横浜アリーナは、1989年4月1日に神奈川県横浜市に開業した多目的イベントホール。本文には特に関係ありません。

使わないなら片付けましょう

検証環境で動いているサービスのうち、主に人が使うためのサービスは人がいない時間帯は動いている必要がありません。 例えば、スタッフ向けの管理画面は、検証環境では、検証用のデータを投入したり、設定を変更したりするために使うものなので、 夜間や休日に動いていても使うことはありません。

これらのサービスは、本番環境では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で必要数をスケジュールで変更させて、停止/再開の設定をします。

docs.aws.amazon.com

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.enablefalse の場合は count0 になるので、この設定自体が生成されません。 したがって、本番環境では 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") 権限

最終的に以下のようにできます。

f:id:fukubaya:20200807214042p:plain
CloudWatchイベントとLambda

f:id:fukubaya:20200807214140p:plain
Labmdaの権限。指定したCloudWatch Alarmのアクションを有効/無効にできる。

f:id:fukubaya:20200807214313p:plain
CloudWatch Alarmの履歴

22時(JST)にアラームのアクション(SNSによる通知)が無効化され、9時(JST)に再び有効化されます。 なお、アラーム自体は止まっていないので、22時(JST)にECSが止まった後にOK→アラーム状態になり、9時(JST)にECSが起動した後にアラーム状態→OKに変化します。

We are hiring!

基盤開発チームでは一緒に参加してくれる仲間を募集中です。 お気軽にお問い合わせください。

open.talentio.com

jobs.m3.com

*1:Alarmのアクションは停止しますが、Alarm自体は停止しません

*2:22時まで働いている人はほとんどいないですけど、広めに設定しています