ECSのマネージドインスタンスドレインを試したい

# はじめに
こんにちは。

2024/01にAmazon ECSの`Managed Instance Draining`が[リリースされました。](https://aws.amazon.com/jp/about-aws/whats-new/2024/01/amazon-ecs-managed-instance-draining/)
2019/8にAWSのコンテナのロードマップに起票されてようやく実現した機能なのですが、触る機会がなかったので背景などを整理しつつ触ってみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/a13d80ae-109b-f2de-fa52-e22da669a711.png)
[【引用】[ECS] [RFC]: Automatic management of instance draining in an ASG #256](https://github.com/aws/containers-roadmap/issues/256)

# 背景の整理
## はじめに：インスタンスのドレインとは
EC2起動タイプのECSで、あるEC2インスタンスで新規タスクの配置をやめ、実行中のタスクを他のインスタンスに退避する機能です。エージェントの更新などEC2インスタンスを削除する必要がある場合に利用されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/08eca525-cca0-ea37-4218-43eb5cc20999.png)

[【引用】Amazon Elastic Container Service - AWS Black Belt Online Seminar](https://d1.awsstatic.com/webinars/jp/pdf/services/20200422_BlackBelt_Amazon_ECS_Share.pdf)

ドレインを行うにはマネジメントコンソールやCLIなどから対象のインスタンスのステータスを`DRAINING`に設定します。

<details><summary>参考：AWS CLIでの実行例</summary>

```bash
#指定したコンテナインスタンスの"Status"を"Draining"にする

aws ecs update-container-instances-state --cluster ${CLUSTERS} \
--container-instances ${CONTAINER_INSTANCE_ID} \
--status DRAINING
```

</details>

<details><summary>参考：マネジメントコンソールでの設定箇所</summary>
「クラスター」=> 「インフラストラクチャ」タブ

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/810d4ffc-103d-8f2a-489f-375f68cd305b.png)

</details>

## マネージドインスタンスドレインとは

EC2起動タイプのECSには[Capacity Provider](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/cluster-capacity-providers.html)という機能があり、これを利用することでタスクが必要とするリソース量に応じてAutoScalingのEC2インスタンスを増減させることが可能です。

一方、インスタンスのスケールインが発生した際にドレインを自動で行いたい場合は以下のような仕組みを自作する必要がありました。

- LifeCycleHook + Lambda
    - スケールインが発生した場合にLifeCycleHookでLambdaを発火させ、対象のインスタンスをドレインする=>[AWSブログのサンプル実装](https://aws.amazon.com/jp/blogs/news/how-to-automate-container-instance-draining-in-amazon-ecs/)
- LifeCycleHook + Self-Draining
    - スケールインが発生した際にLifeCycleHookで削除を待機、インスタンス内から自身の状態を常に監視し削除待ちだったらドレインを実行。タスクがすべて退避したところでCompleteLifeCycleActionで削除を再開

Capacity Provider管理のECSにおいてマネージドにドレインができるようになったのが今回のアップデートになります！すべてのタスクが他のインスタンスに代替されるまで (最大 48 時間) インスタンスの削除を遅らせることができます。

::: note warn
AWSブログのサンプル実装について、英語の原文を見るとLambdaの再帰に関する補足が2023年に追加されています。もしご利用される場合はご一読ください。
:::


## マネージド終了保護との違い

「スケールイン時にタスクの削除を避けたいだけであれば[CapacityProviderのマネージド終了保護](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/managed-termination-protection.html)を有効化すればいいのでは」と思われた方がいるかもしれませんがその通りです。マネージド終了保護を有効化するとタスクが存在するインスタンスの削除を防止できます。

一方、AMIの世代替えをするケースでタスクを新しい世代のインスタンスに移動させるためにはドレインを別途実行する必要があります。AutoScalingのRollingUpdateでインスタンスを世代替えしつつ、必要なタスク数は維持したいようなケースにおいてはマネージドインスタンスドレインの利用が有効です。

なお、マネージド終了保護とマネージドインスタンスドレインは共存するもので併用によって最大限の保護を実現できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/01c3e645-f7ba-317d-6218-3f0b595703ec.png)
[【引用】Amazon ECSマネージドインスタンスのドレイン](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/managed-instance-draining.html)


# 検証
マネージドインスタンスドレインを有効化した状態でAutoScalingのAMI更新（AL2023=>AL2)を試してみます。
なお、マネージド終了保護は無効化し、サービスの設定は以下とします。

|項目|設定値|Desc|
|:-|:-|:-|
|desiredCount|3|希望するタスク数|
|minHealthyPercent|100|最低限起動が必要なタスクの割合|
|maxHealthyPercent|200|起動するタスクの最大の割合|

## 構成図
（LBありますが無視いただいて結構です。）

![super2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/30befad0-8176-67ec-0d8c-bc8efe299ea0.png)


## 設定の詳細

設定の詳細は検証に使ったCDKのコードをご確認いただければと思います。

<details><summary>ecs_cp.ts</summary>

```typescript
import {
  aws_ecs as ecs,
  aws_ec2 as ec2,
  aws_elasticloadbalancingv2 as elbv2,
  aws_iam as iam,
  aws_s3 as s3,
  aws_autoscaling as asg,
  aws_logs as logs,
  Stack,
  StackProps,
  CfnOutput,
  Duration,
  RemovalPolicy,
} from "aws-cdk-lib";
import { Construct } from "constructs";

export class EcsCpStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const vpc = new ec2.Vpc(this, "Vpc", {
      ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/16"),
      maxAzs: 2,
    });

    // ALBのセキュリティグループ
    const lbSG = new ec2.SecurityGroup(this, "LbSG", {
      vpc: vpc,
    });
    lbSG.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80));
    lbSG.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(8080));

    const alb = new elbv2.ApplicationLoadBalancer(this, "Alb", {
      vpc: vpc,
      internetFacing: true,
      securityGroup: lbSG,
    });
    new CfnOutput(this, "AlbDnsName", {
      value: "http://" + alb.loadBalancerDnsName,
    });

    const listener = alb.addListener("Listener", {
      port: 80,
    });
    // ECSサービスのセキュリティグループ
    const serviceSG = new ec2.SecurityGroup(this, "ServiceSG", {
      vpc: vpc,
    });
    serviceSG.connections.allowFrom(alb, ec2.Port.tcp(80));
    serviceSG.connections.allowFrom(alb, ec2.Port.tcp(8080));

    const autoscaling = new asg.AutoScalingGroup(this, "Asg", {
      vpc: vpc,
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T3,
        ec2.InstanceSize.MEDIUM
      ),
      // 検証時に片方をコメントアウトする
      machineImage: ecs.EcsOptimizedImage.amazonLinux2023(),
      // machineImage: ecs.EcsOptimizedImage.amazonLinux2(),
      minCapacity: 0,
      maxCapacity: 6,
      securityGroup: serviceSG,
      updatePolicy: asg.UpdatePolicy.rollingUpdate(), // CDKによるローリングアップデート有効化
      newInstancesProtectedFromScaleIn: false, // スケールインの保護は無効
      cooldown: Duration.seconds(30),
    });

    const cluster = new ecs.Cluster(this, "Cluster", {
      clusterName: "EcsBgCluster",
      vpc: vpc,
    });

    // Capacity Provider
    const capacityProvider = new ecs.AsgCapacityProvider(this, "AsgCP", {
      autoScalingGroup: autoscaling,
      enableManagedDraining: true, // マネージドインスタンスドレインは有効化
      enableManagedTerminationProtection: false, // マネージド終了保護は無効化
    });
    cluster.addAsgCapacityProvider(capacityProvider);

    // ECSタスク定義
    const taskDefinition = new ecs.Ec2TaskDefinition(this, "TaskDef");

    const containerName = "web";
    taskDefinition.addContainer("Container", {
      image: ecs.ContainerImage.fromRegistry("nginx:latest"),
      containerName: containerName,
      command: [
        "/bin/sh",
        "-c",
        "echo '<html> <head> <title>Amazon ECS Sample App</title> <style>body {margin-top: 40px; background-color: #00FFFF;} </style> </head><body> <div style=color:white;text-align:center> <h1>Amazon ECS Sample App</h1> <h2>Congratulations!</h2> <p>Your application is now running on a container in Amazon ECS.</p> </div></body></html>' > /usr/share/nginx/html/index.html && nginx -g 'daemon off;'",
      ],
      portMappings: [
        {
          containerPort: 80,
          hostPort: 8080,
        },
      ],
      memoryLimitMiB: 512,
      healthCheck: {
        command: ["CMD-SHELL", "curl -f http://localhost/ || exit 1"],
        interval: Duration.seconds(60),
        retries: 10,
        startPeriod: Duration.seconds(30),
        timeout: Duration.seconds(30),
      },
    });

    const service = new ecs.Ec2Service(this, "Service", {
      cluster: cluster,
      taskDefinition: taskDefinition,
      desiredCount: 3, // 希望するタスク数
      circuitBreaker: { enable: true, rollback: true },
      capacityProviderStrategies: [
        {
          capacityProvider: capacityProvider.capacityProviderName,
          weight: 1,
          base: 0,
        },
      ],
      minHealthyPercent: 100, // 最小の保持タスク数の割合
      maxHealthyPercent: 200, // 最大の保持タスク数の割合
    });

    listener.addTargets("ECSTarget", {
      port: 8080,
      targets: [
        service.loadBalancerTarget({
          containerName: containerName,
          containerPort: 80,
        }),
      ],
      healthCheck: {
        path: "/",
        protocol: elbv2.Protocol.HTTP,
        interval: Duration.seconds(30),
        timeout: Duration.seconds(5),
        unhealthyThresholdCount: 10,
      },
    });
  }
}

```
</details>

<details><summary>bin/ecs_bg.ts</summary>

```typescript
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { EcsCpStack } from "../lib/ecs_cp";

const app = new cdk.App();
new EcsCpStack(app, "MyEcsCP");
```

</details>

## 試した

`ecs_cp.ts`の58行目と59行目のコードのコメントアウトを逆転して`cdk deploy`し、ASGのAMIを更新します。
AWS CDKだと1行書き換えてデプロイするだけなので簡単ですね。

```typescript
// 検証時に片方をコメントアウトする
machineImage: ecs.EcsOptimizedImage.amazonLinux2023(),
// machineImage: ecs.EcsOptimizedImage.amazonLinux2(),
```
コンテナインスタンスの初期状態。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/a680bf3e-b1d5-06e8-a285-af131966248c.png)

いきなり終盤のほうの状態です（最初のほうキャプチャがうまくいかなかったので:bow:）

ドレイン状態になった後、新しいインスタンスでタスクが起動したら、古いインスタンスのタスクを終了してますね。ASGの更新中、実行中のタスク数は`minHealthyPercent`と`maxHealthyPercent`を満たしたまま遷移しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/f2629513-d5b3-9aba-8cad-b7a3f0257a5c.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/bb763b65-a32b-467e-8f97-f1e04e138380.png)

最終状態。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3552758/23554e67-c0ea-543c-4b3f-3c2383ce1488.png)

# まとめ
ECSのマネージドインスタンスドレインを試してみました。
なにかのお役に立てば幸いです。

# 参考

https://aws.amazon.com/jp/blogs/news/amazon-ecs-enables-easier-ec2-capacity-management-with-managed-instance-draining/

https://community.aws/content/2bMN99heQOCAAkJDC7wwB6ObVyx/manage

https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/managed-instance-draining.html