見出し画像

ECSタスクのスケジュール停止でコストを削減したよ

こんにちは、すずきです。
最近はバチェロレッテシーズン3というお笑い恋愛リアリティショーとDoctor Climaxというタイの謎ドラマを観て真実の愛について勉強しています。

真実の愛を学んでいる傍ら、サービスのデプロイ先を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を導入しようと思います。

採用情報


この記事が気に入ったらサポートをしてみませんか?