3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CloudWatch Alarm + LambdaでECSタスクをアクセス時だけ自動起動し、コストカットした話

Last updated at Posted at 2025-12-12

この記事は、GENDA Advent Calendar 2025 シリーズ3 Day13 の記事です。

はじめまして、株式会社GENDAに来年度入社予定のsatowowです!

本記事では、AWSのECS(Elastic Container Service)のコストカットについて紹介しています。

背景

私は複数のプロダクトを開発しているのですが、その中に完成が近く、開発が落ち着いているサービスがあります。その開発環境のECSタスクを常時立ち上げておくのはコスト的にもったいないため、使いたい時以外はタスクを停止させておくことにしました。

実装環境と構成

既存環境として、ECSクラスター・ECSサービスと、そこにトラフィックを流すALBリスナー・ルールが既に存在しています。

今回実現した内容は以下の通りです:

  • タスク数 1 以上の場合: CloudWatch Alarmで一定期間アクセスがない場合に検知し、それをトリガーにLambdaが実行され、タスク数を0にする。
  • タスク数 0 の場合: リクエストが来るとLambdaが実行され、自動でタスクを起動する。

実装手順

1. Lambda用ECRの作成&関数の実装

まず、ECSタスク数を制御するLambda関数用のECRを作成します。リポジトリを作成したら、関数をDockerイメージをpushします。
今回実装した関数はこちらです。

ecs_task_control.py
import json
import logging
import os

import boto3

logger = logging.getLogger()
logger.setLevel(logging.INFO)

ecs = boto3.client('ecs')
elbv2 = boto3.client('elbv2')


def _calculate_high_priority(listener_arn: str, target_rule_arn: str) -> tuple[int, int]:
    """
    対象ルールの現在の優先度と、次に利用可能な最高優先度を取得する。
    """
    response = elbv2.describe_rules(ListenerArn=listener_arn)
    rules = response.get("Rules", [])

    occupied_priorities: set[int] = set()
    current_priority: int | None = None

    for rule in rules:
        if rule.get("IsDefault"):
            continue

        priority_str = rule.get("Priority")
        if not priority_str or priority_str == "default":
            continue

        try:
            priority = int(priority_str)
        except ValueError:
            continue

        if rule.get("RuleArn") == target_rule_arn:
            current_priority = priority
        else:
            occupied_priorities.add(priority)

    if current_priority is None:
        raise ValueError(f"Target rule not found in listener: {target_rule_arn}")

    new_priority = 1
    while new_priority in occupied_priorities:
        new_priority += 1

    return current_priority, new_priority


def _calculate_low_priority(listener_arn: str, target_rule_arn: str) -> tuple[int, int]:
    """
    リスナールールの現在の優先度と、1000以下で次に利用可能な優先度を取得する。
    1000が使用中の場合は、1001、1002など、利用可能なものが見つかるまで試行する。(サービスへ転送するリスナールールより低い優先度に設定してください。)
    """
    response = elbv2.describe_rules(ListenerArn=listener_arn)
    rules = response.get("Rules", [])

    occupied_priorities: set[int] = set()
    current_priority: int | None = None

    for rule in rules:
        if rule.get("IsDefault"):
            continue

        priority_str = rule.get("Priority")
        if not priority_str or priority_str == "default":
            continue

        try:
            priority = int(priority_str)
        except ValueError:
            continue

        if rule.get("RuleArn") == target_rule_arn:
            current_priority = priority
        else:
            occupied_priorities.add(priority)

    if current_priority is None:
        raise ValueError(f"Target rule not found in listener: {target_rule_arn}")

    # 1000から開始して、最初に利用可能な優先度を探す
    new_priority = 1000
    while new_priority in occupied_priorities:
        new_priority += 1

    return current_priority, new_priority


def lambda_handler(event, context):
    """
    ECSサービスのタスク数に応じて自動的にスケールイン/アウトし、
    このLambda関数をターゲットとするリスナールールの優先度を調整する。
    - タスクが1つ以上起動している場合: タスク数を0にスケールインし、リスナールールの優先度を上げる
    - タスクが0の場合: タスクを1つ起動し、リスナールールの優先度を1000以下に下げる
    
    環境変数:
      - ECS_SERVICE_ARN: 対象ECSサービスのARN
      - LISTENER_ARN:    対象ALBリスナーARN
      - TARGET_RULE_ARN: このLambda関数をターゲットとするリスナールールARN
    """
    try:
        ecs_service_arn = os.environ.get("ECS_SERVICE_ARN")
        listener_arn = os.environ.get("LISTENER_ARN")
        target_rule_arn = os.environ.get("TARGET_RULE_ARN")

        if not ecs_service_arn:
            raise ValueError("ECS_SERVICE_ARN is required")
        if not listener_arn:
            raise ValueError("LISTENER_ARN is required")
        if not target_rule_arn:
            raise ValueError("TARGET_RULE_ARN is required")

        logger.info(f"Processing ECS service: {ecs_service_arn}")
        logger.info(f"Target listener: {listener_arn}")
        logger.info(f"Target rule: {target_rule_arn}")

        # ARNからクラスター名とサービス名を抽出
        # ARN形式: arn:aws:ecs:{region}:{account}:service/{cluster-name}/{service-name}
        arn_parts = ecs_service_arn.split('/')
        if len(arn_parts) < 3:
            raise ValueError(f"Invalid ECS service ARN format: {ecs_service_arn}")
        
        cluster_name = arn_parts[1]
        service_name = arn_parts[2]

        logger.info(f"Cluster: {cluster_name}, Service: {service_name}")

        # 現在のサービス状態を確認
        response = ecs.describe_services(
            cluster=cluster_name,
            services=[service_name]
        )

        if not response['services']:
            raise Exception(f"ECS service not found: {service_name} in cluster {cluster_name}")

        service = response['services'][0]
        current_desired_count = service.get('desiredCount', 0)
        current_running_count = service.get('runningCount', 0)

        logger.info(f"Current desired count: {current_desired_count}")
        logger.info(f"Current running count: {current_running_count}")

        # タスク数に応じてスケールイン/アウトを決定
        update_response = None
        if current_desired_count >= 1:
            # タスクが1つ以上起動している場合: スケールイン(0に)
            logger.info("Tasks are running. Scaling in to 0.")
            update_response = ecs.update_service(
                cluster=cluster_name,
                service=service_name,
                desiredCount=0,
            )
            new_desired_count = 0
            action_type = "scale-in"
            logger.info("Service update initiated successfully")
            logger.info(f"Service status: {update_response['service']['status']}")

            # 優先度を上げる
            current_priority, new_priority = _calculate_high_priority(listener_arn, target_rule_arn)
            priority_result = {
                "current_priority": current_priority,
                "new_priority": current_priority,
                "updated": False,
            }

            if new_priority < current_priority:
                elbv2.set_rule_priorities(
                    RulePriorities=[
                        {
                            'RuleArn': target_rule_arn,
                            'Priority': new_priority
                        },
                    ]
                )
                priority_result["new_priority"] = new_priority
                priority_result["updated"] = True
                logger.info(f"Rule priority updated from {current_priority} to {new_priority}")
            else:
                logger.info(
                    f"Rule already at highest possible priority ({current_priority}). No change made."
                )

        else:
            # タスクが0の場合: スケールアウト(1に)
            logger.info("No tasks running. Scaling out to 1.")
            update_response = ecs.update_service(
                cluster=cluster_name,
                service=service_name,
                desiredCount=1,
            )
            new_desired_count = 1
            action_type = "scale-out"
            logger.info("Service update initiated successfully")
            logger.info(f"Service status: {update_response['service']['status']}")

            # 優先度を1000以下に下げる
            current_priority, new_priority = _calculate_low_priority(listener_arn, target_rule_arn)
            priority_result = {
                "current_priority": current_priority,
                "new_priority": current_priority,
                "updated": False,
            }

            if new_priority != current_priority:
                elbv2.set_rule_priorities(
                    RulePriorities=[
                        {
                            'RuleArn': target_rule_arn,
                            'Priority': new_priority
                        },
                    ]
                )
                priority_result["new_priority"] = new_priority
                priority_result["updated"] = True
                logger.info(f"Rule priority updated from {current_priority} to {new_priority}")
            else:
                logger.info(
                    f"Rule already at target priority ({current_priority}). No change made."
                )

        result_body = {
            "message": f"Completed {action_type} and priority adjustment",
            "action": action_type,
            "service_arn": ecs_service_arn,
            "cluster": cluster_name,
            "service": service_name,
            "previous_desired_count": current_desired_count,
            "previous_running_count": current_running_count,
            "new_desired_count": new_desired_count,
            "service_status": update_response['service']['status'] if update_response else service.get("status"),
            "listener_arn": listener_arn,
            "target_rule_arn": target_rule_arn,
            "priority": priority_result,
        }

        logger.info(f"{action_type.capitalize()} & priority adjustment completed: {json.dumps(result_body, default=str)}")
        return {"statusCode": 200, "body": json.dumps(result_body)}

    except Exception as e:
        logger.exception("Failed to process ECS service")
        return {
            "statusCode": 500,
            "body": json.dumps({
                "error": "Failed to process ECS service",
                "message": str(e)
            })
        }

2. Lambda関数用実行ロールの作成

次に、Lambda用の実行ロールを作成します。
必要なポリシーは以下の通りです。ログ、ELB、ECS、EC2(ENI)への操作権限を付与します。

lambda_exec_policy.json
{
    "Statement": [
        {
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Effect": "Allow",
            "Resource": "*"
        },
        {
            "Action": [
                "elasticloadbalancing:DescribeListeners",
                "elasticloadbalancing:DescribeRules",
                "elasticloadbalancing:ModifyListener",
                "elasticloadbalancing:ModifyRule",
                "elasticloadbalancing:SetRulePriorities"
            ],
            "Effect": "Allow",
            "Resource": "*"
        },
        {
            "Action": [
                "ecs:DescribeServices",
                "ecs:UpdateService"
            ],
            "Effect": "Allow",
            "Resource": "*"
        },
        {
            "Action": [
                "ec2:CreateNetworkInterface",
                "ec2:DescribeNetworkInterfaces",
                "ec2:DeleteNetworkInterface"
            ],
            "Effect": "Allow",
            "Resource": "*"
        }
    ],
    "Version": "2012-10-17"
}

3. Lambda用のCloudWatchロググループの作成

/aws/lambda/{Lambda関数の名前}でロググループを作成します。

4. Lambda作成

ECSのタスク数を制御するLambdaを作成します。

  • 作成方法は コンテナイメージ を選択し、先ほどECRにpushしたイメージを選択
  • 実行ロール では 既存のロールを使用する を選択し、先ほど作成したロールを選択

5. ターゲットグループの作成

次に、ECSタスクを停止している間に来たリクエストをトリガーとしてタスクを起動できるよう、先ほど作成したLambdaをターゲットとしたターゲットグループを作成します。

6. リスナールールの作成

既存のALBのリスナーに、先ほど作成したターゲットグループにトラフィックを流すリスナールールを作成します。

  • 条件
    • 既存のECSサービスにトラフィックを流しているリスナールールと揃える
  • アクション
    • 先ほど作成したターゲットグループへ転送するよう設定
  • 優先度
    • 既存ECSサービスへトラフィックを転送しているリスナールールの優先度より低い値
      • 例: 既存リスナールールが100なら、200に設定

7. Cloudwatch alarmの作成

一定時間アクセスが来ていないかどうかを検知するアラームを作成します。

  • メトリクスの選択
    • 参照
      • ApplicationELB > AppELB別・TargetGroup別メトリクス の中から対象となるサービスに紐づくターゲットグループの RequestCount メトリクスを選択。今回はblue/greenデプロイを採用していたので、blue用とgreen用の2つのメトリクスを選択しました。
    • グラフ化したメトリクス
      • 先ほど選択したメトリクスの 期間 の値を 1分 に設定
      • 数式を追加すべての関数FILL を選択
      • 追加されたメトリクスの 詳細 から数式を編集し、FILL(m1,0)+FILL(m2,0) に変更
      • m1とm2のチェックマークを外し、e1のみにチェックマークをつけ、メトリクスを選択 を押す
  • 条件の指定
    • 条件
      • 静的
      • 以下
      • 閾値に0を設定
        ⇨ これにより、一定時間アクセスがこない場合にアラーム状態となります。
    • その他の設定
      • アラームを実行するデータポイント に自動でタスクをスケールインさせたい時間分設定(例: 15分間アクセスが来ない場合にスケールインさせたい場合は、15/15 と設定します。)
  • アクションの設定
    • Lambdaアクション
      • トリガーはアラーム状態を選択し、先ほど作成したLambda関数を選択する

8. Lambdaリソースベースポリシーの追加

CloudWatch AlarmからLambdaの実行を許可するポリシーを作成します。

  • Cloudwatch Alarmからの実行を許可するポリシー
cloudwatch_alarm_policy.json
{
      "Sid": "AllowCloudWatchAlarm",
      "Effect": "Allow",
      "Principal": {
        "Service": "lambda.alarms.cloudwatch.amazonaws.com"
      },
      "Action": "lambda:InvokeFunction",
      "Resource": "${作成したLambda関数のARN}",
      "Condition": {
        "ArnLike": {
          "AWS:SourceArn": "${作成したCloudwatch AlarmのARN}"
        }
      }
    }
  • ターゲットグループからの実行を許可するポリシー(リスナールールと紐づけた際に自動で作成されています)
target_group_policy.json
{
      "Sid": "AllowTargetGroup",
      "Effect": "Allow",
      "Principal": {
        "Service": "elasticloadbalancing.amazonaws.com"
      },
      "Action": "lambda:InvokeFunction",
      "Resource": "${作成したLambda関数のARN}",
      "Condition": {
        "ArnLike": {
          "AWS:SourceArn": "${Lambda関数に紐づくターゲットグループのARN}"
        }
      }
    }

動作確認

1から8まで実装できたら、実際にサービスへのアクセスを設定した時間止めてみます。すると、CloudWatch AlarmをトリガーにLambdaが実行され、対象のECSタスクが停止し、それと同時にその後サービスへリクエストが来た場合はタスクを起動するLambdaが実行されるよう、リスナールールの優先度が変更されているはずです。

終わりに

本記事では、ALBのリスナールール優先度とLambdaを組み合わせた、ECSタスクの自動起動・停止によるコストカットを紹介しました。

4月の入社後も、技術で事業に貢献できるよう精進していきます!💪

参考記事

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?