0
2

ECSタスクをスケジュール管理してコスト削減する方法

Last updated at Posted at 2024-06-01

サービスのデプロイ先を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つの関数に処理をまとめました。

また、環境によってはスケジュールではなく手動でタスクの停止や起動を行いたいケースもあったので、引数actionscheduledmanualを選択できるようにしました。

以下がコードの全容です。
clusters_services_scheduledclusters_services_manualで指定しているECSクラスターとECSサービスの名前を変えれば、そのままコピペして使えるはずです。

app.py
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と同じディレクトリ階層にDockerfilebuild.shを配置して./build.shを実行すれば、ecs-task-scheduler.zipが作成されます。このZIPファイルを該当のS3バケットに配置してterraform applyすればデプロイが完了します。

Dockerfile
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"]
build.sh
#!/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"
}

スクリーンショット 2024-06-01 17.10.46.png

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を導入しようと思います。

0
2
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
0
2