この記事は、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)への操作権限を付与します。
{
"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に設定
- 既存ECSサービスへトラフィックを転送しているリスナールールの優先度より低い値
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関数を選択する
- Lambdaアクション
8. Lambdaリソースベースポリシーの追加
CloudWatch AlarmからLambdaの実行を許可するポリシーを作成します。
- Cloudwatch Alarmからの実行を許可するポリシー
{
"Sid": "AllowCloudWatchAlarm",
"Effect": "Allow",
"Principal": {
"Service": "lambda.alarms.cloudwatch.amazonaws.com"
},
"Action": "lambda:InvokeFunction",
"Resource": "${作成したLambda関数のARN}",
"Condition": {
"ArnLike": {
"AWS:SourceArn": "${作成したCloudwatch AlarmのARN}"
}
}
}
- ターゲットグループからの実行を許可するポリシー(リスナールールと紐づけた際に自動で作成されています)
{
"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月の入社後も、技術で事業に貢献できるよう精進していきます!💪
参考記事