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 秒ぐらいなら・・待てなくもないでしょうか。