ECS でサービスにアクセスがないときにタスクを自動的に停止して、アクセスがあったときに自動的に開始する

ECS で Review Apps のような、マージリクエスト(普段会社で Gitlab を使っているのでそう呼んでますが PR=プルリクエスト でも概ね同じです)の都度、コンテナイメージのビルド、ALB ターゲットグループ作成、ECS サービス作成、ALB リスナーにルール追加、して環境を自動で作成し、マージリクエストをマージする前に実際に動く環境で簡単に動作確認できるようにしようと思ったのですが、よくよく考えると ECS を Fargate で利用してる場合、マージリクエストを開いてから閉じるまでの期間ずっと Fargate の費用が発生するのもどうかな・・と思ったので、アクセスがないときは自動的にタスクを停止し、アクセスがあったときに自動的にタスクを開始する仕組みを作ってみました。

残骸はこちら→ ngyuki-sandbox/aws-ecs-autostart-autostop: aws-ecs-autostart-autostop

ちなみに EC2 にべたにアプリをデプロイするだけなら↓のような方法で簡単にひとつのインスタンスに複数の環境が作れるんですけどね。

概要

ECS サービスから実行されるタスクはサービスの DesiredCount を 0 にすればすべて停止できます。サービスへ一定時間アクセスが無かったときにこれを 0 に、サービスへアクセスがあったときにこれを 1 にできればよいのですが・・

前者は CloudWatch アラームで ECS サービスがアタッチされるターゲットグループの RequestCountPerTarget メトリクスが一定時間 0 になったときのアクションで DesiredCount を 0 にするための Lambda を呼べば出来そうです。

次のようなイメージです。

// ECS サービスのターゲットグループの RequestCountPerTarget が一定時間 0 ならアラーム状態
resource "aws_cloudwatch_metric_alarm" "autostop" {
  alarm_name          = "autostop"
  alarm_actions       = [aws_sns_topic.autostop.arn]
  namespace           = "AWS/ApplicationELB"
  metric_name         = "RequestCountPerTarget"
  statistic           = "Sum"
  treat_missing_data  = "missing"
  period              = 300
  evaluation_periods  = 1
  comparison_operator = "LessThanThreshold"
  threshold           = 1
  dimensions = {
    TargetGroup = aws_lb_target_group.service.arn_suffix
  }
}

// アラーム状態になったときに Lambda を実行
resource "aws_sns_topic" "autostop" {
  name = "autostop"
}
resource "aws_sns_topic_subscription" "autostop" {
  topic_arn = aws_sns_topic.autostop.arn
  protocol  = "lambda"
  endpoint  = aws_lambda_function.autostop.arn
}
resource "aws_lambda_permission" "autostop" {
  statement_id  = "autostop"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.autostop.function_name
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.autostop.arn
}

// Lambda には環境変数で ECS のクラスタ&サービスを渡す
resource "aws_lambda_function" "autostop" {
  // ...snip...

  environment {
    variables = {
      cluster_arn  = var.cluster_arn,
      service_name = var.service_name,
    }
  }
}

Lambda のコードは次のようになります。

const AWS = require('aws-sdk');
const ecs = new AWS.ECS();
exports.handler = async (event, context) => {
    await ecs.updateService({
        cluster: process.env.cluster_arn,
        service: process.env.service_name,
        desiredCount: 0,
    }).promise();
    return {};
};

問題は後者の「サービスへアクセスがあったとき」です。同じように CloudWatch アラームを用いたのでは、アクセスがあってからサービスのタスクが立ち上がるまでに時間が掛かりすぎて使いものになりません。

ので ALB リスナーに、ECS サービスのターゲットグループのルールより優先度が高い Lambda 関数を呼び出すルールを追加し、その Lambda でサービスのタスクを開始&リスナールールの優先度を変更し、ECS サービスの方のターゲットグループにトラフィックが流れるようにします。

次のようなイメージです。

// ECS サービスがアタッチされるターゲットグループのリスナールール
resource "aws_lb_listener_rule" "service" {
  listener_arn = aws_lb_listener.main.arn
  priority     = 100

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.service.arn
  }

  condition {
    host_header {
      values = [var.domain]
    }
  }
}

// Lambda 関数がアタッチされるターゲットグループのリスナールール
resource "aws_lb_listener_rule" "autostart" {
  listener_arn = aws_lb_listener.main.arn

  // サービスを停止(DesiredCount=0)したときは優先度を低く
  // サービスを開始(DesiredCount=1)したときは優先度を高く
  priority     = 1

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.autostart.arn
  }

  // ECS サービスのリスナールールと同じ条件
  condition {
    host_header {
      values = [var.domain]
    }
  }
}

// Lambda 関数の方のリスナールールに設定されるターゲットグループ
resource "aws_lb_target_group" "autostart" {
  name        = "autostart"
  target_type = "lambda"
}
resource "aws_lb_target_group_attachment" "autostart" {
  target_group_arn = aws_lb_target_group.autostart.arn
  target_id        = aws_lambda_function.autostart.arn
}
resource "aws_lambda_permission" "autostart" {
  statement_id  = "autostart"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.autostart.function_name
  principal     = "elasticloadbalancing.amazonaws.com"
  source_arn    = aws_lb_target_group.autostart.arn
}

// Lambda には環境変数でリスナールールも渡す
resource "aws_lambda_function" "autostart" {
  // ...snip...

  environment {
    variables = {
      cluster_arn       = var.cluster_arn,
      service_name      = var.service_name,
      listener_rule_arn = aws_lb_listener_rule.autostart.arn
    }
  }
}

Lambda 関数は次のようになります。

const AWS = require('aws-sdk');
const elbv2 = new AWS.ELBv2();
const ecs = new AWS.ECS();
exports.handler = async (event, context) => {
    await ecs.updateService({
        cluster: process.env.cluster_arn,
        service: process.env.service_name,
        desiredCount: 1,
    }).promise();
    await elbv2.setRulePriorities({
        RulePriorities: [{
            Priority: 999,
            RuleArn: process.env.listener_rule_arn,
        }],
    }).promise();
    return {
        "statusCode": 200,
        "statusDescription": "200 OK",
        "isBase64Encoded": false,
        "headers": { "Content-Type": "text/html" },
        "body": `<h1>Please just a moment. Now starting environment.</h1>`,
    };
};

自動停止の Lambda 関数でも ELB リスナールールの優先度を変更する必要があります。

const AWS = require('aws-sdk');
const elbv2 = new AWS.ELBv2();
const ecs = new AWS.ECS();
exports.handler = async (event, context) => {
    await elbv2.setRulePriorities({
        RulePriorities: [{
            Priority: 1,
            RuleArn: process.env.listener_rule_arn,
        }],
    }).promise();
    await ecs.updateService({
        cluster: process.env.cluster_arn,
        service: process.env.service_name,
        desiredCount: 0,
    }).promise();
    return {};
};

さいごに

最初のアクセスから、Fargate 内でコンテナが開始し、さらにヘルスチェックが通るまで待つ必要があるため、ヘルスチェックの設定を最小限にしても 60 秒程度の時間が必要でした。 60 秒ぐらいなら・・待てなくもないでしょうか。