search
LoginSignup
5

More than 1 year has passed since last update.

posted at

updated at

Organization

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 execdocker-compose execが実行できます。もちろんbashを起動することで対話的なコマンドラインで作業することもできます。

参考 Amazon ECS Exec

CDKとは

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

参考 AWS CDK

実装していく!

Ecs Execを設定せずに作成

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

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コマンドを実行します。

% 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

かなり端折ってますが全体的には以下のような感じになります。
AWS.png

ECS Execを有効にする

ECS Exec未対応で躓いた話

まずどうやってECS Execを有効にするかですが、以下の公式ユーザーガイドではサービス起動時のみでしか有効にできないようです。
参考 デバッグに Amazon ECS Exec を使用する
image.png

CDKであれば、ECSサービスのコンストラクタにそのフラグがあるはず...
FargateServiceのpropsにはenableExecuteCommandというには引数がありますが、今回利用している@aws-cdk_aws/ecs-patternsApplicationLoadBalancedFargateServiceにはそのインターフェースがありません。
ついこないだまで(2021年6月時点)はFargateServiceにもこのインターフェースがありませんでした。
ではApplicationLoadBalancedFargateServiceでもECS Execする方法をご紹介します。

CDKはCloudFormationのラッパーにであることを利用する

解決策は2つあります。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では未対応という場合に上書きすることができます。

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

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;
}

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

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

最終アウトプット

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のコマンドを実行してみます。

$ 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

うまく接続できました。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
5