ECS(Fargate)タスクがスケジュールで停止できない？Auto Scaling設定の落とし穴と対策

# はじめに

AWS ECS（Fargate）を運用していると、コスト削減のために夜間や休日にタスクを自動停止する仕組みを導入したいケースがよくあります。  
一般的には、EventBridgeスケジューラで ECS の`UpdateService`API を呼び出し、`desiredCount`を 0 にする方法が採用されます。（Lambdaが不要でお手軽）

しかし、ここでECSのAuto Scaling（Application Auto Scaling）が有効になっている場合、設定によってはタスクが自動的に再起動してしまうという可能性があります。

例えば、スケーラブルターゲットのタスク最小数が 1 に設定されていると、`UpdateService`で desiredCount を 0 にしても、<font color="red">**Auto Scalingによってすぐに desiredCount が 1 に戻されてしまいます。**</font>
つまり、タスクを完全に停止させるには、Auto Scalingの設定自体を変更する必要があるのです。

下記は、スケジュールで `desiredCount` を 0 に設定したものの、Auto Scaling によって再び 1 に戻ってしまった ECS サービスのデプロイログの一例です。
※ ログは、古いものが下部に、新しいものが上部に記録されています。（下から見てください）

```
↑新しいログ
2025年2月24日 21:00 (UTC+9:00)
service Hoge-Ecs-Service has started 1 tasks: task a048dc06c02b49baadf2475ad2d35483.

2025年2月24日 21:00 (UTC+9:00)
メッセージ: Successfully set desired count to 1. Change successfully fulfilled by ecs. 
原因: monitor alarm Hoge-Step-Scaling-Policy-Alarm in state ALARM triggered policy Hoge-Step-Scaling-Policy

2025年2月24日 21:00 (UTC+9:00)
service Hoge-Ecs-Service has reached a steady state.

2025年2月24日 21:00 (UTC+9:00)
(service Hoge-Ecs-Service, taskSet ecs-svc/5673045712131029903) has begun draining connections on 1 tasks.

2025年2月24日 21:00 (UTC+9:00)
service Hoge-Ecs-Service deregistered 1 targets in target-group Hoge-Target-Group

2025年2月24日 21:00 (UTC+9:00)
service Hoge-Ecs-Service has stopped 1 running tasks: task 2e6166875d2d4168bba5eb6141fb963a.
↓古いログ
```

# 対策ポイント

### タスクを完全に停止する場合

タスクを完全に停止するには、`UpdateService` ではなく、`registerScalableTarget` API を利用して Auto Scaling の設定を変更します。
具体的には、<font color="red">**スケーラブルターゲットのタ最小タスク数と最大タスク数の両方を 0 に更新する**</font>ことで、`desiredCount` も自動的に 0 となり、タスクが完全に停止します。  
（AWS サポートからもこの方法が推奨されました）

### タスク再開する場合
タスクを再び稼働させる際は、再度 `registerScalableTarget` API を使って、最小タスク数と最大タスク数を元の設定値に戻します。  
これにより、`desiredCount`も自動で元のタスク数に復元され、タスクが開始されます。

# コード例

以下は、CDK (TypeScript) を使用して Auto Scaling の設定を変更し、タスクを確実に停止／再開するためのサンプルコードです。

```typescript
    // ECSクラスター・ECSサービスを定義しておく
    declare const cluster: ecs.Cluster;
    declare const ecsService: ecs.FargateService;

    // スケーラブルターゲットの定義（通常時のタスク数設定）
    const scalableTarget = new applicationautoscaling.ScalableTarget(
      this,
      "ScalableTarget",
      {
        serviceNamespace: applicationautoscaling.ServiceNamespace.ECS,
        minCapacity: props.minTaskCount, // 通常時の最小タスク数
        maxCapacity: props.maxTaskCount, // 通常時の最大タスク数
        resourceId: `service/${ecsCluster.clusterName}/${ecsService.serviceName}`,
        scalableDimension: "ecs:service:DesiredCount",
      },
    );

// ~~ ※ 必要に応じて、ここでスケーリングポリシーの設定を追加してください ~~


    // ECSタスクの自動停止・開始用 IAM ロールの定義
    const autoScalingSchedulerExecutionRole = new iam.Role(
      this,
      "AutoScalingSchedulerExecutionRole",
      {
        assumedBy: new iam.ServicePrincipal("scheduler.amazonaws.com"),
      }
    );
    autoScalingSchedulerExecutionRole.addToPolicy(
      new iam.PolicyStatement({
        effect: iam.Effect.ALLOW,
        actions: ["application-autoscaling:RegisterScalableTarget"],
        resources: ["*"], // ARNやResourceIdを取得するのが簡単ではないため、ひとまず"*"を指定しています
      })
    );

    // タスク停止用 EventBridgeスケジューラ
    // ※ スケーラブルターゲットの最小・最大タスク数を 0 に更新してタスクを完全停止するする）
    new scheduler.CfnSchedule(this, "AutoScalingStopSchedule", {
      state: props.ecsStopSchedulerState,
      scheduleExpression: "cron(0 22 ? * MON-FRI *)", // 平日 22:00（日本時間）
      scheduleExpressionTimezone: "Asia/Tokyo",
      flexibleTimeWindow: {
        mode: "OFF",
      },
      target: {
        arn: "arn:aws:scheduler:::aws-sdk:applicationautoscaling:registerScalableTarget",
        roleArn: autoScalingSchedulerExecutionRole.roleArn,
        input: JSON.stringify({
          ServiceNamespace: "ecs",
          ScalableDimension: "ecs:service:DesiredCount",
          ResourceId: `service/${ecsCluster.clusterName}/${ecsService.serviceName}`,
          // ⭐️ タスクの最小数・最大数を0に更新 ⭐️
          MinCapacity: 0,
          MaxCapacity: 0
        }),
      },
    });

    // タスク再開用 EventBridge スケジューラ
    // ※ スケーラブルターゲットの最小・最大タスク数を元に戻してタスクを再開する
    new scheduler.CfnSchedule(this, "AutoScalingStartSchedule", {
      state: props.ecsStartSchedulerState,
      scheduleExpression: "cron(0 7 ? * MON-FRI *)", // 平日 07:00（日本時間）
      scheduleExpressionTimezone: "Asia/Tokyo",
      flexibleTimeWindow: {
        mode: "OFF",
      },
      target: {
        arn: "arn:aws:scheduler:::aws-sdk:applicationautoscaling:registerScalableTarget",
        roleArn: autoScalingSchedulerExecutionRole.roleArn,
        input: JSON.stringify({
          ServiceNamespace: "ecs",
          ScalableDimension: "ecs:service:DesiredCount",
          ResourceId: `service/${ecsCluster.clusterName}/${ecsService.serviceName}}`,
          // ⭐️ タスクの最小数と最大数を元の設定に戻す ⭐️
          MinCapacity: props.minTaskCount, // 元の最小タスク数
          MaxCapacity: props.maxTaskCount  // 元の最大タスク数
        }),
      },
    });

```

このサンプルコードでは、Auto Scaling の設定を変更することによって、`UpdateService`API の代わりに `registerScalableTarget`API を使用してタスクの停止／再開を実現しています。
これにより、Auto Scaling の設定によってタスクが自動的に再起動される問題を回避できます。
この記事が皆さんの参考になれば幸いです。