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

サービスのデプロイ先をLambdaからECS on Fargateに変更するにあたり、本番環境以外にかかるコストをできるだけ削減しようと、いろいろ試みてきました。
- vCPUとメモリを最小構成(0.25 vCPU, 0.5 GB)にする
- Fargate Spotを使用する

ECS側の設定で工夫できるのはこれくらいだったのですが、1時間あたりのvCPUとメモリ単位でコストがかかる料金体系ということで、LambdaとEventBridgeで業務時間外だけECSタスクを停止するような仕組みをつくりました。

Lambda関数のコードやEventBridgeのTerraform設定などをそのまま記載するので、コピペしてコスト削減に活用していただければ幸いです。

https://aws.amazon.com/jp/fargate/pricing/

# Lambda関数
EventBridgeとの組み合わせでスケジュール実行できるため、ECSタスクの停止と起動処理の実行環境にはLambdaを選びました。

単一責任の原則(Single responsibility principle)を考慮して、停止と起動処理を別の関数に分けようとも思ったのですが、変更が頻繁に発生するようなコードでもないですし、単純に管理する関数を増やしたくなかったので、1つの関数に処理をまとめました。

https://qiita.com/suzuki0430/items/b13c7c1f637e7cd2146d

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

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

```py: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`で行われます。

```python
    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を設定している場合はこちらの処理が必要となります。

```python
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のターゲットグループに登録する必要があります。

```python
if action == 'start':
    for cluster_name, service_name in clusters_services:
        # 新しいタスクのIDを取得してターゲットグループに登録
        tasks = client.list_tasks(cluster=cluster_name, serviceName=service_name)['taskArns']
        ...
```
タスク停止中でもECSサービスにターゲットグループは関連付けられたままなので、以下の部分でターゲットグループを取得できています。

```python
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デプロイする場合の設定は以下のようになります。

```tf
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`すればデプロイが完了します。

```Dockerfile: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"]
```

```sh: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形式のリクエストをテストイベントのボディに貼り付けて、テストボタンを押せば関数が実行されます。

```json
{
  "action": "start",
  "environment": "manual"
}
```

![スクリーンショット 2024-06-01 17.10.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569054/6450ace1-2758-a249-9bb0-91f5db76d221.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で行われるため、地域に応じた時間調整が必要です。

## 平日のスケジュール

```hcl
# 平日の停止スケジュール（毎日日本時間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
}
```

## 土日のスケジュール

```hcl
# 土曜日の停止スケジュール（日本時間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)の管理**：ターゲットの登録と削除、ターゲットグループとリスナーの詳細情報の取得
- **ログの管理**：ロググループとログストリームの作成、ログイベントの投稿

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

https://aws.amazon.com/jp/savingsplans/compute-pricing/