サービスのデプロイ先をLambdaからECS on Fargateに変更するにあたり、本番環境以外にかかるコストをできるだけ削減しようと、いろいろ試みてきました。
- vCPUとメモリを最小構成(0.25 vCPU, 0.5 GB)にする
- Fargate Spotを使用する
ECS側の設定で工夫できるのはこれくらいだったのですが、1時間あたりのvCPUとメモリ単位でコストがかかる料金体系ということで、LambdaとEventBridgeで業務時間外だけECSタスクを停止するような仕組みをつくりました。
Lambda関数のコードやEventBridgeのTerraform設定などをそのまま記載するので、コピペしてコスト削減に活用していただければ幸いです。
Lambda関数
EventBridgeとの組み合わせでスケジュール実行できるため、ECSタスクの停止と起動処理の実行環境にはLambdaを選びました。
単一責任の原則(Single responsibility principle)を考慮して、停止と起動処理を別の関数に分けようとも思ったのですが、変更が頻繁に発生するようなコードでもないですし、単純に管理する関数を増やしたくなかったので、1つの関数に処理をまとめました。
また、環境によってはスケジュールではなく手動でタスクの停止や起動を行いたいケースもあったので、引数action
でscheduled
とmanual
を選択できるようにしました。
以下がコードの全容です。
clusters_services_scheduled
とclusters_services_manual
で指定しているECSクラスターとECSサービスの名前を変えれば、そのままコピペして使えるはずです。
import boto3
from botocore.exceptions import ClientError
def update_service(cluster_name, service_name, desired_count):
client = boto3.client('ecs')
application_autoscaling_client = boto3.client('application-autoscaling')
# AutoScalingポリシーの最小タスク数を制御
scalable_targets = application_autoscaling_client.describe_scalable_targets(
ServiceNamespace='ecs',
ResourceIds=[f'service/{cluster_name}/{service_name}'],
ScalableDimension='ecs:service:DesiredCount'
)['ScalableTargets']
for scalable_target in scalable_targets:
application_autoscaling_client.register_scalable_target(
ServiceNamespace='ecs',
ResourceId=f'service/{cluster_name}/{service_name}',
ScalableDimension='ecs:service:DesiredCount',
MinCapacity=0 if desired_count == 0 else 1,
MaxCapacity=scalable_target['MaxCapacity']
)
# サービスの更新
service_update_result = client.update_service(
cluster=cluster_name,
service=service_name,
desiredCount=desired_count
)
print(service_update_result)
def lambda_handler(event, context):
try:
client = boto3.client('ecs')
elbv2_client = boto3.client('elbv2')
action = event.get('action') # 'stop' or 'start'
environment = event.get('environment') # 'scheduled' or 'manual'
clusters_services_scheduled = [
('example-cluster', 'example-service-stg')
]
clusters_services_manual = [
('example-cluster', 'example-service-dev')
]
if environment == 'scheduled':
clusters_services = clusters_services_scheduled
elif environment == 'manual':
clusters_services = clusters_services_manual
else:
raise ValueError("Invalid environment specified")
desired_count = 0 if action == 'stop' else 1
for cluster_name, service_name in clusters_services:
update_service(cluster_name, service_name, desired_count)
if action == 'start':
for cluster_name, service_name in clusters_services:
# 新しいタスクのIDを取得してターゲットグループに登録
tasks = client.list_tasks(
cluster=cluster_name,
serviceName=service_name
)['taskArns']
task_descriptions = client.describe_tasks(
cluster=cluster_name,
tasks=tasks
)['tasks']
# サービスに紐づくターゲットグループ情報を取得
load_balancers = client.describe_services(
cluster=cluster_name,
services=[service_name]
)['services'][0]['loadBalancers']
for load_balancer in load_balancers:
target_group_arn = load_balancer['targetGroupArn']
for task in task_descriptions:
task_id = task['taskArn'].split('/')[-1]
elbv2_client.register_targets(
TargetGroupArn=target_group_arn,
Targets=[{'Id': task_id}]
)
except ClientError as e:
print(f"Exception: {e}")
except ValueError as e:
print(f"Exception: {e}")
コードの解説
サービスのタスク数の制御
ECSサービスのタスク数の更新は、update_service
関数内のupdate_service
で行われます。
service_update_result = client.update_service(
cluster=cluster_name,
service=service_name,
desiredCount=desired_count
)
この部分では、update_service
メソッドを使用して、指定されたクラスターとサービスのdesiredCount
を動的に更新します。このdesiredCount
は、サービスにおけるタスクの目標数を指定し、タスクの停止または起動を行います。
サービスのスケーリング設定の制御
update_service
関数は、ECSサービスのインスタンス数を調整し、Auto ScalingポリシーのMinCapacity
を0か1に設定します。
サービスのタスク数を0にしても、MinCapacity
が1のままだとタスクを何度も起動しようとしてしまうので、Auto Scalingを設定している場合はこちらの処理が必要となります。
def update_service(cluster_name, service_name, desired_count):
client = boto3.client('ecs')
application_autoscaling_client = boto3.client('application-autoscaling')
...
タスクの起動とターゲットグループへの登録
ELB(ALB)とECSを連携している場合、タスクが起動される際に新しいタスクIDを取得し、それをALBのターゲットグループに登録する必要があります。
if action == 'start':
for cluster_name, service_name in clusters_services:
# 新しいタスクのIDを取得してターゲットグループに登録
tasks = client.list_tasks(cluster=cluster_name, serviceName=service_name)['taskArns']
...
タスク停止中でもECSサービスにターゲットグループは関連付けられたままなので、以下の部分でターゲットグループを取得できています。
load_balancers = client.describe_services(
cluster=cluster_name,
services=[service_name]
)['services'][0]['loadBalancers']
for load_balancer in load_balancers:
target_group_arn = load_balancer['targetGroupArn']
Terraformのコード例
Lambda関数をZIPデプロイする場合の設定は以下のようになります。
resource "aws_lambda_function" "ecs_task_scheduler" {
function_name = "ecs-task-scheduler"
s3_bucket = var.s3_bucket_lambda_functions_storage_bucket
s3_key = "ecs-task-scheduler.zip"
handler = "app.lambda_handler"
runtime = "python3.12"
role = var.iam_role_ecs_task_scheduler_lambda_exec_role_arn
timeout = 300 # 5 minutes
}
app.py
と同じディレクトリ階層にDockerfile
とbuild.sh
を配置して./build.sh
を実行すれば、ecs-task-scheduler.zip
が作成されます。このZIPファイルを該当のS3バケットに配置してterraform apply
すればデプロイが完了します。
FROM public.ecr.aws/lambda/python:3.12
# Install Python dependencies
COPY requirements.txt /var/task/
RUN pip install -r /var/task/requirements.txt --target /var/task
# Copy the Lambda function code
COPY app.py /var/task/
# Set the working directory
WORKDIR /var/task
# Set the CMD to your handler
CMD ["app.lambda_handler"]
#!/bin/bash
# Build the Docker image
docker build -t ecs-task-scheduler-build .
# Create a container from the image
container_id=$(docker create ecs-task-scheduler-build)
# Copy the contents of the container to a local directory
docker cp $container_id:/var/task ./package
# Clean up
docker rm $container_id
# Zip the contents of the local directory
cd package
zip -r ../ecs-task-scheduler.zip .
cd ..
# Clean up
rm -rf package
手動実行する方法
Lambda関数を手動で実行するには、コンソールのテストタブを使用します。
JSON形式のリクエストをテストイベントのボディに貼り付けて、テストボタンを押せば関数が実行されます。
{
"action": "start",
"environment": "manual"
}
EventBridge
EventBridgeのCron式を使用して、特定の曜日と時間にLambda関数を自動的に実行するスケジュールを設定します。この機能を利用して、平日の夜間と週末全日にわたってECSタスクを停止するスケジュールを構築できます。
平日のスケジュール(22:00 - 5:00停止)
- 停止:平日22:00(UTC 13:00)
- 開始:平日5:00(UTC 20:00)
土日のスケジュール(終日停止)
- 停止:土曜日0:00(UTC 15:00前日)
- 開始:月曜日5:00(UTC 20:00)
以下は、これらのスケジュールを設定するためのTerraformコード例です。EventBridgeのルールを定義し、適切なLambda関数をターゲットに設定しています。また、時間設定はUTCで行われるため、地域に応じた時間調整が必要です。
平日のスケジュール
# 平日の停止スケジュール(毎日日本時間22:00)
resource "aws_cloudwatch_event_rule" "ecs_weekday_stop_tasks_schedule" {
name = "ECSWeekdayStopTasksSchedule"
description = "Schedule to stop ECS tasks on weekdays at 22:00 JST"
schedule_expression = "cron(0 13 ? * MON-FRI *)" # 平日22:00 JST
}
resource "aws_cloudwatch_event_target" "ecs_weekday_stop_tasks_target" {
rule = aws_cloudwatch_event_rule.ecs_weekday_stop_tasks_schedule.name
target_id = "ecsTaskSchedulerWeekdayStop"
arn = var.lambda_function_ecs_task_scheduler_arn
input = jsonencode({
action = "stop"
environment = "scheduled"
})
}
resource "aws_lambda_permission" "ecs_weekday_stop_tasks_allow_eventbridge" {
statement_id = "AllowEventBridgeInvokeLambdaWeekdayStop"
action = "lambda:InvokeFunction"
function_name = var.lambda_function_ecs_task_scheduler_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.ecs_weekday_stop_tasks_schedule.arn
}
# 平日の開始スケジュール(毎日日本時間5:00)
resource "aws_cloudwatch_event_rule" "ecs_weekday_start_tasks_schedule" {
name = "ECSWeekdayStartTasksSchedule"
description = "Schedule to start ECS tasks on weekdays at 5:00 JST"
schedule_expression = "cron(0 20 ? * MON-FRI *)" # 平日5:00 JST
}
resource "aws_cloudwatch_event_target" "ecs_weekday_start_tasks_target" {
rule = aws_cloudwatch_event_rule.ecs_weekday_start_tasks_schedule.name
target_id = "ecsTaskSchedulerWeekdayStart"
arn = var.lambda_function_ecs_task_scheduler_arn
input = jsonencode({
action = "start"
environment = "scheduled"
})
}
resource "aws_lambda_permission" "ecs_weekday_start_tasks_allow_eventbridge" {
statement_id = "AllowEventBridgeInvokeLambdaWeekdayStart"
action = "lambda:InvokeFunction"
function_name = var.lambda_function_ecs_task_scheduler_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.ecs_weekday_start_tasks_schedule.arn
}
土日のスケジュール
# 土曜日の停止スケジュール(日本時間0:00)
resource "aws_cloudwatch_event_rule" "ecs_weekend_stop_tasks_schedule" {
name = "ECSWeekendStopTasksSchedule"
description = "Schedule to stop ECS tasks on Saturday at 00:00 JST"
schedule_expression = "cron(0 15 ? * SAT *)" # 土曜日0:00 JST
}
resource "aws_cloudwatch_event_target" "ecs_weekend_stop_tasks_target" {
rule = aws_cloudwatch_event_rule.ecs_weekend_stop_tasks_schedule.name
target_id = "ecsTaskSchedulerWeekendStop"
arn = var.lambda_function_ecs_task_scheduler_arn
input = jsonencode({
action = "stop"
environment = "scheduled"
})
}
resource "aws_lambda_permission" "ecs_weekend_stop_tasks_allow_eventbridge" {
statement_id = "AllowEventBridgeInvokeLambdaWeekendStop"
action = "lambda:InvokeFunction"
function_name = var.lambda_function_ecs_task_scheduler_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.ecs_weekend_stop_tasks_schedule.arn
}
# 月曜日の開始スケジュール(日本時間5:00)
resource "aws_cloudwatch_event_rule" "ecs_weekend_start_tasks_schedule" {
name = "ECSWeekendStartTasksSchedule"
description = "Schedule to start ECS tasks on Monday at 05:00 JST"
schedule_expression = "cron(0 20 ? * MON *)" # 月曜日5:00 JST
}
resource "aws_cloudwatch_event_target" "ecs_weekend_start_tasks_target" {
rule = aws_cloudwatch_event_rule.ecs_weekend_start_tasks_schedule.name
target_id = "ecsTaskSchedulerWeekendStart"
arn = var.lambda_function_ecs_task_scheduler_arn
input = jsonencode({
action = "start"
environment = "scheduled"
})
}
resource "aws_lambda_permission" "ecs_weekend_start_tasks_allow_eventbridge" {
statement_id = "AllowEventBridgeInvokeLambdaWeekendStart"
action = "lambda:InvokeFunction"
function_name = var.lambda_function_ecs_task_scheduler_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.ecs_weekend_start_tasks_schedule.arn
}
IAMロール
Lambda関数でECSタスクをスケジュールするための実行ロールを定義するTerraformコード例です。このロールは、Lambda関数がECSおよび関連AWSサービスのAPIを呼び出せるように設定されています。
- ECSサービスの管理:サービスの更新、タスクのリストアップ、タスクとサービスの詳細情報の取得
- Auto Scalingの管理:スケーラブルターゲットの登録と削除、スケーラブルターゲットの情報取得
- Elastic Load Balancing(ELB)の管理:ターゲットの登録と削除、ターゲットグループとリスナーの詳細情報の取得
- ログの管理:ロググループとログストリームの作成、ログイベントの投稿
resource "aws_iam_policy" "ecs_task_scheduler_policy" {
name = "ecs-task-scheduler-policy"
description = "Policy for ECS task scheduler Lambda function"
policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Action = [
"ecs:UpdateService",
"ecs:ListTasks",
"ecs:DescribeTasks",
"ecs:DescribeServices"
],
Resource = "*"
},
{
Effect = "Allow",
Action = [
"application-autoscaling:RegisterScalableTarget",
"application-autoscaling:DeregisterScalableTarget",
"application-autoscaling:DescribeScalableTargets"
],
Resource = "*"
},
{
Effect = "Allow",
Action = [
"elasticloadbalancing:RegisterTargets",
"elasticloadbalancing:DeregisterTargets",
"elasticloadbalancing:DescribeTargetGroups",
"elasticloadbalancing:DescribeListeners",
"elasticloadbalancing:DescribeRules"
],
Resource = "*"
},
{
Effect = "Allow",
Action = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
Resource = "arn:aws:logs:*:*:*"
}
]
})
}
resource "aws_iam_role_policy_attachment" "ecs_task_scheduler_policy_attach" {
role = aws_iam_role.ecs_task_scheduler_lambda_exec_role.name
policy_arn = aws_iam_policy.ecs_task_scheduler_policy.arn
}
おわりに
さらなるコスト削減を目指して、次のステップとしてCompute Savings Plansを導入しようと思います。