CDKでFargate&ECS Exec

# CDKでFargate&ECS Exec
## はじめに
### 内容
CDKでECS Execが有効なコンテナーを作成しようとしたら、躓いてしまったのでその紹介しながら、CDKでECS Execできるまでの最小構成のテンプレートを記述しています。

### 環境
| 日付          | 2021年8月22日 |
| ------------- | ------------- |
| aws cli       | 2.2.27        |
| @aws-cdk/core | 1.117.0       |

### ECS Execとは
AWSのDockerコンテナの実行サービスであるECSで実行中のコンテナに対してコマンドを実行できるという機能です。簡単にいうとAWS上のコンテナに対してローカルのコンテナと同じように`dokcer exec`や`docker-compose exec`が実行できます。もちろんbashを起動することで対話的なコマンドラインで作業することもできます。

[参考 Amazon ECS Exec](https://aws.amazon.com/jp/blogs/news/new-using-amazon-ecs-exec-access-your-containers-fargate-ec2/)

### CDKとは
TypeScriptなどのプログラミング言語 を用いてAWS上のリソースを管理することができます。分類としてはTerraformやCloudFormationなどと同じIaC(Infrastructure as Code)に該当します。特徴としてはTerraformやCloudFormationが宣言的なリソースの宣言なのに対してCDKは手続き的にインフラを記述することができます。


[参考 AWS CDK](https://aws.amazon.com/jp/cdk/)


## 実装していく！
### Ecs Execを設定せずに作成
まずはEcs Execを設定しない状態でFaragteを起動します。
上から順に、VPCの作成、ECS Clusterの作成、実行ロールの作成、タスクロールの作成、タスク定義の作成、ECS Serviceの作成という順で記述しています。
ロールなどの権限は以下を公式のサンプルを参考にしています。
[参考 デバッグに Amazon ECS Exec を使用する](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/ecs-exec.html)
後の躓きポイントにもなるので予め紹介しておくと、`@aws-cdk/aws-ecs-patterns`というECSを利用する際のテンプレートをまとめてくれているパッケージがあります。その中の`ApplicationLoadBalancedFargateService`FargateをWebアプリケーションとして利用するのに必要なターゲットグループやロードバランサーをまとめてくれる便利なスタックになっています。


```ts
import * as cdk from "@aws-cdk/core";
import * as ec2 from "@aws-cdk/aws-ec2";
import * as ecs from "@aws-cdk/aws-ecs";
import * as iam from "@aws-cdk/aws-iam";
import * as ecsp from "@aws-cdk/aws-ecs-patterns";

export class SampleStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const vpc = createVpc(this);
    const cluster = createCluster(this, vpc);
    const executionRole = createTaskExecutionRole(this);
    const taskRole = createTaskRole(this);
    const taskDefinition = createTaskDefinition(this, executionRole, taskRole);
    const service = createService(this, cluster, taskDefinition);
  }
}

function createVpc(stack: cdk.Stack) {
  return new ec2.Vpc(stack, `SampleVpc`, {
    natGateways: 0,
    cidr: "10.0.0.0/16",
    maxAzs: 2,
    subnetConfiguration: [
      {
        cidrMask: 24,
        name: "application",
        subnetType: ec2.SubnetType.PUBLIC,
      },
    ],
  });
}

function createCluster(stack: cdk.Stack, vpc: ec2.Vpc) {
  const cluster = new ecs.Cluster(stack, "SampleCluster", {
    vpc,
    clusterName: `${stack.stackName}-cluster`,
  });
  return cluster;
}

function createTaskExecutionRole(stack: cdk.Stack): iam.Role {
  const role = new iam.Role(stack, "EcsTaskExecutionRole", {
    roleName: "EcsTaskExecutionRole",
    assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
    managedPolicies: [
      iam.ManagedPolicy.fromAwsManagedPolicyName(
        "service-role/AmazonECSTaskExecutionRolePolicy"
      ),
      iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMReadOnlyAccess"),
    ],
  });
  return role;
}

function createTaskRole(stack: cdk.Stack): iam.Role {
  const role = new iam.Role(stack, "TaskRole", {
    assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
  });
  role.addToPrincipalPolicy(
    new iam.PolicyStatement({
      actions: [
        "ssmmessages:CreateControlChannel",
        "ssmmessages:CreateDataChannel",
        "ssmmessages:OpenControlChannel",
        "ssmmessages:OpenDataChannel",
      ],
      resources: ["*"],
    })
  );

  return role;
}

function createTaskDefinition(
  stack: cdk.Stack,
  executionRole: iam.Role,
  taskRole: iam.Role
) {
  const taskDefinition = new ecs.FargateTaskDefinition(
    stack,
    "SampleTaskDefinition",
    {
      cpu: 512,
      memoryLimitMiB: 2048,
      executionRole,
      taskRole,
    }
  );
  new ecs.ContainerDefinition(stack, "SampleContainer", {
    containerName: "SampleContainer",
    taskDefinition,
    image: ecs.ContainerImage.fromRegistry("httpd:2.4"),
    cpu: 10,
    entryPoint: ["sh", "-c"],
    command: [
      "/bin/sh -c \"echo '<html> <head> <title>Amazon ECS Sample App</title> <style>body {margin-top: 40px; background-color: #333;} </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/local/apache2/htdocs/index.html && httpd-foreground\"",
    ],
    portMappings: [
      {
        hostPort: 80,
        containerPort: 80,
        protocol: ecs.Protocol.TCP,
      },
    ],
  });

  return taskDefinition;
}

function createService(
  stack: cdk.Stack,
  cluster: ecs.Cluster,
  taskDefinition: ecs.TaskDefinition
) {
  const serviceName = `${stack.stackName}-service`;
  const service = new ecsp.ApplicationLoadBalancedFargateService(
    stack,
    "SampleFargateService",
    {
      cluster,
      serviceName,
      cpu: 256,
      desiredCount: 1,
      taskDefinition,
      memoryLimitMiB: 512,
      publicLoadBalancer: true,
      taskSubnets: cluster.vpc.selectSubnets({
        subnetType: ec2.SubnetType.PUBLIC,
      }),
      assignPublicIp: true,
      openListener: true,
    }
  );

  return service;
}

```

この状態で`cdk deploy`コマンドを実行します。

```shell
% cdk deploy                                                                                                                                                                                  (git)-[master]
This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
┌───┬─────────────────────────────┬────────┬─────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────────────────────┬───────────┐
│   │ Resource                    │ Effect │ Action                                                                  │ Principal                                                               │ Condition │
├───┼─────────────────────────────┼────────┼─────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┼───────────┤
│ + │ ${EcsTaskExecutionRole.Arn} │ Allow  │ sts:AssumeRole                                                          │ Service:ecs-tasks.amazonaws.com                                         │           │
├───┼─────────────────────────────┼────────┼─────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┼───────────┤
│ + │ ${TaskRole.Arn}             │ Allow  │ sts:AssumeRole                                                          │ Service:ecs-tasks.amazonaws.com                                         │           │
├───┼─────────────────────────────┼────────┼─────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────────────────────┼───────────┤
│ + │ *                           │ Allow  │ ssmmessages:CreateControlChannel                                        │ AWS:${TaskRole}                                                         │           │
│   │                             │        │ ssmmessages:CreateDataChannel                                           │                                                                         │           │
│   │                             │        │ ssmmessages:OpenControlChannel                                          │                                                                         │           │
│   │                             │        │ ssmmessages:OpenDataChannel                                             │                                                                         │           │
└───┴─────────────────────────────┴────────┴─────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬─────────────────────────┬─────────────────────────────────────────────────────────────────────────────────────┐
│   │ Resource                │ Managed Policy ARN                                                                  │
├───┼─────────────────────────┼─────────────────────────────────────────────────────────────────────────────────────┤
│ + │ ${EcsTaskExecutionRole} │ arn:${AWS::Partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy │
│ + │ ${EcsTaskExecutionRole} │ arn:${AWS::Partition}:iam::aws:policy/AmazonSSMReadOnlyAccess                       │
└───┴─────────────────────────┴─────────────────────────────────────────────────────────────────────────────────────┘
Security Group Changes
┌───┬───────────────────────────────────────────────────────┬─────┬────────────┬───────────────────────────────────────────────────────┐
│   │ Group                                                 │ Dir │ Protocol   │ Peer                                                  │
├───┼───────────────────────────────────────────────────────┼─────┼────────────┼───────────────────────────────────────────────────────┤
│ + │ ${SampleFargateService/LB/SecurityGroup.GroupId}      │ In  │ TCP 80     │ Everyone (IPv4)                                       │
│ + │ ${SampleFargateService/LB/SecurityGroup.GroupId}      │ Out │ TCP 80     │ ${SampleFargateService/Service/SecurityGroup.GroupId} │
├───┼───────────────────────────────────────────────────────┼─────┼────────────┼───────────────────────────────────────────────────────┤
│ + │ ${SampleFargateService/Service/SecurityGroup.GroupId} │ In  │ TCP 80     │ ${SampleFargateService/LB/SecurityGroup.GroupId}      │
│ + │ ${SampleFargateService/Service/SecurityGroup.GroupId} │ Out │ Everything │ Everyone (IPv4)                                       │
└───┴───────────────────────────────────────────────────────┴─────┴────────────┴───────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y

SampleStack: deploying...
SampleStack: creating CloudFormation changeset...


 ✅  SampleStack

Outputs:
SampleStack.SampleFargateServiceLoadBalancerDNS31896940 = Sampl-Sampl-1VDQOTYYPHVU4-1458992770.ap-northeast-1.elb.amazonaws.com
SampleStack.SampleFargateServiceServiceURLFB9D8017 = http://Sampl-Sampl-1VDQOTYYPHVU4-1458992770.ap-northeast-1.elb.amazonaws.com

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:303391106411:stack/SampleStack/3c3395a0-03aa-11ec-8105-064a048f128d

```

SampleStack.SampleFargateServiceServiceURLFB9D8017がロードバランサーのULRです。
アクセスしてうまく行っているかを確認します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235240/769e3747-bd38-63f8-e2c5-be79f0209c7d.png)

かなり端折ってますが全体的には以下のような感じになります。
![AWS.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235240/96aebc82-5ac2-c6b5-3381-a04bf3c3c1b3.png)


### ECS Execを有効にする
#### ECS Exec未対応で躓いた話
まずどうやってECS Execを有効にするかですが、以下の公式ユーザーガイドではサービス起動時のみでしか有効にできないようです。
[参考 デバッグに Amazon ECS Exec を使用する](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/ecs-exec.html)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235240/a90ea5b9-9589-4724-aef9-8a2953ddd646.png)

CDKであれば、ECSサービスのコンストラクタにそのフラグがあるはず...
`FargateService`のpropsには`enableExecuteCommand`というには引数がありますが、今回利用している`@aws-cdk_aws/ecs-patterns`の`ApplicationLoadBalancedFargateService`にはそのインターフェースがありません。
ついこないだまで(2021年6月時点)は`FargateService`にもこのインターフェースがありませんでした。
では`ApplicationLoadBalancedFargateService`でもECS Execする方法をご紹介します。
 
#### CDKはCloudFormationのラッパーにであることを利用する
解決策は２つあります。1つは`FargateService`を利用してロードバランサーなどのリソースも自分で定義するという方法。前述にもにもあるとおり、私がCDKを触り始めたときは`FargateService`もまだ`enableExecuteCommand`属性がなかったので利用できませんでした。今実装するならこの方法を利用すると思います。

もう一つは、CDKからCloudFormationをゴリゴリと書き換える方法です。今回はこちらを紹介します。というのもCDKはCloudFormationに依存している以上、**CloudFormationリリース→CDKで対応**という流れになるはずで、`enableExecuteCommand`以外でもCloudFormationでは対応しているがCDKでは未対応という場合に役に立つと思います。
それでは実装の仕方を紹介します。

CDKのリソースのコンストラクタは中には`node.chilren`に一緒に作る子リソースが格納されています。
それらのリソースは最終的に`CfnResource`を継承したクラスになり、そのクラスでCloudFormationを記述しています。
今回は`ApplicationLoadBalancedFargateService`クラスの中にある`FargateService`クラスの中にある`CfnService`クラスが`CfnResource`を継承したクラスでです。なので`CfnService`の属性を強引に書き換えることで今回のようにCloudFormationでは対応しているがCDKでは未対応という場合に上書きすることができます。

では実装を見ていきます。

```ts
function enableExecuteCommand(
  service: ecsp.ApplicationLoadBalancedFargateService
) {
  service.node.children.filter(isFargateService).forEach((fargateService) => {
    fargateService.node.children.filter(isCfnService).forEach((cfnService) => {
      cfnService.addOverride("Properties.EnableExecuteCommand", true);
    });
  });
}

function isFargateService(cdkChild: any): cdkChild is ecs.FargateService {
  return cdkChild instanceof ecs.FargateService;
}

function isCfnService(cdkChild: any): cdkChild is ecs.CfnService {
  return cdkChild instanceof ecs.CfnService;
}
```

解説すると`isFargateService`と`isCfnService`はTypeScriptの恩恵をギリギリまで受けるためのType Guardです。
`ApplicationLoadBalancedFargateService`から`CfnService`までたどって、`addOverride`で属性を直で書き換えに行っています。

これでECS Execが有効なコンテナを作成することができます。

## 最終アウトプット

```ts
import * as cdk from "@aws-cdk/core";
import * as ec2 from "@aws-cdk/aws-ec2";
import * as ecs from "@aws-cdk/aws-ecs";
import * as iam from "@aws-cdk/aws-iam";
import * as ecsp from "@aws-cdk/aws-ecs-patterns";

export class SampleStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    const vpc = createVpc(this);
    const cluster = createCluster(this, vpc);
    const executionRole = createTaskExecutionRole(this);
    const taskRole = createTaskRole(this);
    const taskDefinition = createTaskDefinition(this, executionRole, taskRole);
    const service = createService(this, cluster, taskDefinition);
  }
}

function createVpc(stack: cdk.Stack) {
  return new ec2.Vpc(stack, `SampleVpc`, {
    natGateways: 0,
    cidr: "10.0.0.0/16",
    maxAzs: 2,
    subnetConfiguration: [
      {
        cidrMask: 24,
        name: "application",
        subnetType: ec2.SubnetType.PUBLIC,
      },
    ],
  });
}

function createCluster(stack: cdk.Stack, vpc: ec2.Vpc) {
  const cluster = new ecs.Cluster(stack, "SampleCluster", {
    vpc,
    clusterName: `${stack.stackName}-cluster`,
  });
  return cluster;
}

function createTaskExecutionRole(stack: cdk.Stack): iam.Role {
  const role = new iam.Role(stack, "EcsTaskExecutionRole", {
    roleName: "EcsTaskExecutionRole",
    assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
    managedPolicies: [
      iam.ManagedPolicy.fromAwsManagedPolicyName(
        "service-role/AmazonECSTaskExecutionRolePolicy"
      ),
      iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMReadOnlyAccess"),
    ],
  });
  return role;
}

function createTaskRole(stack: cdk.Stack): iam.Role {
  const role = new iam.Role(stack, "TaskRole", {
    assumedBy: new iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
  });
  role.addToPrincipalPolicy(
    new iam.PolicyStatement({
      actions: [
        "ssmmessages:CreateControlChannel",
        "ssmmessages:CreateDataChannel",
        "ssmmessages:OpenControlChannel",
        "ssmmessages:OpenDataChannel",
      ],
      resources: ["*"],
    })
  );

  return role;
}

function createTaskDefinition(
  stack: cdk.Stack,
  executionRole: iam.Role,
  taskRole: iam.Role
) {
  const taskDefinition = new ecs.FargateTaskDefinition(
    stack,
    "SampleTaskDefinition",
    {
      cpu: 512,
      memoryLimitMiB: 2048,
      executionRole,
      taskRole,
    }
  );
  new ecs.ContainerDefinition(stack, "SampleContainer", {
    containerName: "SampleContainer",
    taskDefinition,
    image: ecs.ContainerImage.fromRegistry("httpd:2.4"),
    cpu: 10,
    entryPoint: ["sh", "-c"],
    command: [
      "/bin/sh -c \"echo '<html> <head> <title>Amazon ECS Sample App</title> <style>body {margin-top: 40px; background-color: #333;} </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/local/apache2/htdocs/index.html && httpd-foreground\"",
    ],
    portMappings: [
      {
        hostPort: 80,
        containerPort: 80,
        protocol: ecs.Protocol.TCP,
      },
    ],
  });

  return taskDefinition;
}

function createService(
  stack: cdk.Stack,
  cluster: ecs.Cluster,
  taskDefinition: ecs.TaskDefinition
) {
  const serviceName = `${stack.stackName}-service`;
  const service = new ecsp.ApplicationLoadBalancedFargateService(
    stack,
    "SampleFargateService",
    {
      cluster,
      serviceName,
      cpu: 256,
      desiredCount: 1,
      taskDefinition,
      memoryLimitMiB: 512,
      publicLoadBalancer: true,
      taskSubnets: cluster.vpc.selectSubnets({
        subnetType: ec2.SubnetType.PUBLIC,
      }),
      assignPublicIp: true,
      openListener: true,
    }
  );
  enableExecuteCommand(service);
  return service;
}

function enableExecuteCommand(
  service: ecsp.ApplicationLoadBalancedFargateService
) {
  /**
   * 現状ApplicationLoadBalancedFargateServiceにEnableExecuteCommandをTrueにするプロパティは存在しないため
   * 以下の方法で強引に書き換える必要があります。
   */
  service.node.children.filter(isFargateService).forEach((fargateService) => {
    fargateService.node.children.filter(isCfnService).forEach((cfnService) => {
      cfnService.addOverride("Properties.EnableExecuteCommand", true);
    });
  });
}

function isFargateService(cdkChild: any): cdkChild is ecs.FargateService {
  return cdkChild instanceof ecs.FargateService;
}

function isCfnService(cdkChild: any): cdkChild is ecs.CfnService {
  return cdkChild instanceof ecs.CfnService;
}

```

ECS Execのコマンドを実行してみます。

```shell
$ aws ecs execute-command \
  --cluster "SampleStack-cluster" \
  --task "f747f10162534a9b818d26717e604bba" \
  --container SampleContainer \
  --interactive \
  --command "/bin/sh"


The Session Manager plugin was installed successfully. Use the AWS CLI to start a session.


Starting session with SessionId: ecs-execute-command-0395f23f7329ef80a
```

うまく接続できました。