🚂

AWS CDK の歩き方 〜 ECS 環境構築編

2021/12/08に公開

はじめに

この記事は AWS CDK Advent Calendar 2021AWS Containers Advent Calendar 2021 の 8 日目の記事です。

AWS re: Invent 2021 Werner Vogeles Keynote で AWS Cloud Development Kit (AWS CDK) v2 の GA が発表されましたね。whats new はこちらで CDK v2 のドキュメントはこちらです。GitHub をみると Keynote の前に stable になってた ので予想はしてたもののリアルタイムで見ててようやく GA かと感動してました。好きなサービスが GA するのは嬉しいものです。

この記事では私が CDK を使う時にみるリンクやどういうふうに考えながらコードを書いていくかを、ECS でアプリを動かす場合を例に紹介していきます。言語は TypeScript です。

つくるもの

今回こちらの Copilot Primer Workshop と同じ環境を作っていきます。書き終わったあとで思ったのは簡単な構成だと AWS Copilot CLI の方が作るのは楽だなってことです。アーキテクチャ図はこちら。

やってみる

プロジェクトの初期化

適当にディレクトリを切ってプロジェクトを初期化します。

$ npm -g install aws-cdk@2.0.0
$ mkdir my-app
$ cd my-app
$ cdk init --language typescript
...
✅ All done!

これでもろもろのインストールをした後にプロジェクトの雛形が作成されます。

スタック分割

一つのスタックにごりごりリソースを定義していってもいいですがそうすると若干わかりにくくなるのでリソースのライフサイクルごとにスタックを分けましょう。スタックを分けて値を渡すにはこの S3 の例のようにします。今回の場合だとこんな感じでスタックを分けてみました。

  • infrastructure-stack.ts: VPC や ECS Cluster、DynamoDB 、ALB などを作成
  • frontend-stack.ts: ECS Service(frontend) のリソース、ECR リポジトリやタスク定義などを作成
  • backend-stack.ts: ECS Service(backend) のリソース、ECR リポジトリやタスク定義などを作成

ディレクトリ構成は以下のような形で、 backend , frontend ディレクトリのなかにそれぞれ Backend Service と Frontend Service の Dockerfile などが置かれています。ここbackend , frontend がそれぞれ対応してます。

- backend
- frontend
- bin
  |- my-app.ts
- lib
  |- infrastructure-stack.ts
  |- backend-stack.ts
  |- frontend-stack.ts

スタック間で値を渡すには

例えば infrastructure-stack.ts で定義した ECS Cluster を frontend-stack.ts で参照するには以下のように書きます。

まず infrastructure-stack.ts はこんな感じで渡したい値をプロパティとして宣言します。

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { aws_ec2 as ec2, aws_ecs as ecs } from 'aws-cdk-lib';
export class InfrastructureStack extends Stack {
  public readonly cluster: ecs.Cluster
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    const vpc = new ec2.Vpc(this, 'VPC', {
      cidr: '10.0.0.0/16'
    });

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

frontend-stack.ts はこんな感じで StackProps を拡張してしたの例だと ECS Cluster を渡せるようにします。

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { aws_ec2 as ec2, aws_ecs as ecs } from 'aws-cdk-lib';

interface frontendServiceStackProps extends StackProps {
    cluster: ecs.Cluster,
}
export class FrontendServiceStack extends Stack {
    constructor(scope: Construct, id: string, props: frontendServiceStackProps) {
        super(scope, id, props);
        const frontendService = new ecs.FargateService(this, 'FrontendService', {
            cluster: props.cluster,
            ...
        });

最後に bin/my-app.ts はこんな感じで InfrastructureStack から FrontendServiceStack へ値を渡します。

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { InfrastructureStack } from '../lib/infrastructure-stack';
import { FrontendServiceStack } from '../lib/frontend-stack';

const app = new cdk.App();
const infra = new InfrastructureStack(app, 'InfrastructureStack', {
  env: { region: 'ap-northeast-1' },
});

const frontendService = new FrontendServiceStack(app, 'ECSFrontendServiceStack', {
  env: { region: 'ap-northeast-1' },
  cluster: infra.cluster,
});

関連するページを読む

スタックを分けて方針が決まったのであとは必要なリソースを定義していきます。私の場合 AWS CDK Examples に各言語ごとの例がたくさん載っているのでこれを見たり、ドキュメントの Overview をみたりして雰囲気を掴むことが多いです。今回だと ECS のあれこれを定義しますがこちらの Overview をみながらタスク定義や ECS Service の作成方法を勉強していきます。

例えば ECS Service のドキュメントのここをみると別スタックの Load Balancer を使う場合は Listener か Target Group でスタックを分ける必要があるとわかります。今回は AWS CDK Examples のこの例のように infrastructure-stack.ts に空の Target Group を作成することにします。

完成形

最終的な完成形はこんな感じです。まず infrastructure-stack.ts から。

import { Stack, StackProps, Tags, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
  aws_ec2 as ec2,
  aws_ecs as ecs,
  aws_dynamodb as dynamodb,
  aws_iam as iam,
  aws_logs as logs,
  aws_servicediscovery as servicediscovery,
  aws_elasticloadbalancingv2 as elb,
} from 'aws-cdk-lib';

export class InfrastructureStack extends Stack {
  public readonly DDBTableName: string;
  public readonly cluster: ecs.Cluster;
  public readonly backendServiceSG: ec2.SecurityGroup;
  public readonly frontendServiceSG: ec2.SecurityGroup;
  public readonly targetGroup: elb.ApplicationTargetGroup;
  public readonly cloudmapNamespace: servicediscovery.PrivateDnsNamespace;
  public readonly backendTaskRole: iam.Role;
  public readonly TaskExecutionRole: iam.Role;
  public readonly frontendTaskRole: iam.Role;
  public readonly backendLogGroup: logs.LogGroup;
  public readonly frontendLogGroup: logs.LogGroup;
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    const vpc = new ec2.Vpc(this, 'VPC', {
      cidr: '10.0.0.0/16',
      enableDnsHostnames: true,
      enableDnsSupport: true,
    });
    Tags.of(vpc).add('Name', 'CDKECSVPC');

    this.cluster = new ecs.Cluster(this, 'Cluster', {
      vpc: vpc,
    });

    const albSG = new ec2.SecurityGroup(this, 'ALBSG', {
      securityGroupName: 'ALBSG',
      vpc: vpc,
    });
    albSG.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80));

    this.backendServiceSG = new ec2.SecurityGroup(
      this,
      'BackendServiceSG',
      {
        securityGroupName: 'backendServiceSecurityGroup',
        vpc: vpc,
      }
    );
    this.backendServiceSG.addIngressRule(
      ec2.Peer.ipv4(vpc.vpcCidrBlock),
      ec2.Port.allTcp()
    );

    this.frontendServiceSG = new ec2.SecurityGroup(
      this,
      'FrontendServiceSG',
      {
        securityGroupName: 'frontendServiceSecurityGroup',
        vpc: vpc,
      }
    );
    this.frontendServiceSG.addIngressRule(albSG, ec2.Port.allTcp());

    this.cloudmapNamespace = new servicediscovery.PrivateDnsNamespace(
      this,
      'Namespace',
      {
        name: 'cdk.ecs.local',
        vpc: vpc,
      }
    );
    this.DDBTableName = 'my-dynamodb';
    const table = new dynamodb.Table(this, 'Table', {
      partitionKey: {
        name: 'TodoId',
        type: dynamodb.AttributeType.NUMBER,
      },
      tableName: this.DDBTableName,
      removalPolicy: RemovalPolicy.DESTROY,
    });
    const ECSExecPolicyStatement = new iam.PolicyStatement({
      sid: 'allowECSExec',
      resources: ['*'],
      actions: [
        'ssmmessages:CreateControlChannel',
        'ssmmessages:CreateDataChannel',
        'ssmmessages:OpenControlChannel',
        'ssmmessages:OpenDataChannel',
        'logs:CreateLogStream',
        'logs:DescribeLogGroups',
        'logs:DescribeLogStreams',
        'logs:PutLogEvents',
      ],
    });

    this.backendTaskRole = new iam.Role(this, 'BackendTaskRole', {
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
    });
    table.grantFullAccess(this.backendTaskRole);
    this.backendTaskRole.addToPolicy(ECSExecPolicyStatement);

    this.frontendTaskRole = new iam.Role(this, 'FrontendTaskRole', {
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
    });
    this.frontendTaskRole.addToPolicy(ECSExecPolicyStatement);

    this.TaskExecutionRole = new iam.Role(this, 'TaskExecutionRole', {
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
      managedPolicies: [
        {
          managedPolicyArn:
            'arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy',
        },
      ],
    });

    this.backendLogGroup = new logs.LogGroup(this, 'backendLogGroup', {
      logGroupName: 'myapp-backend',
      removalPolicy: RemovalPolicy.DESTROY,
    });

    this.frontendLogGroup = new logs.LogGroup(this, 'frontendLogGroup', {
      logGroupName: 'myapp-frontend',
      removalPolicy: RemovalPolicy.DESTROY,
    });

    const alb = new elb.ApplicationLoadBalancer(this, 'ALB', {
      vpc: vpc,
      internetFacing: true,
      securityGroup: albSG,
      vpcSubnets: { subnets: vpc.publicSubnets },
    });

    const listener = alb.addListener('Listener', { port: 80 });

    this.targetGroup = listener.addTargets('targetGroup', {
      port: 80,
      protocol: elb.ApplicationProtocol.HTTP,
      healthCheck: {
        enabled: true,
        path: '/ishealthy',
        healthyHttpCodes: '200,301',
      },
    });
  }
}

次に frontend-stack.ts はこんな感じ。

import { Stack, StackProps, Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
    aws_ec2 as ec2,
    aws_ecs as ecs,
    aws_servicediscovery as servicediscovery,
    aws_iam as iam,
    aws_logs as logs,
    aws_elasticloadbalancingv2 as elb,
} from 'aws-cdk-lib';

interface frontendServiceStackProps extends StackProps {
    cluster: ecs.Cluster;
    targetGroup: elb.ApplicationTargetGroup;
    backendServiceName: string;
    frontendSG: ec2.SecurityGroup;
    frontendTaskRole: iam.Role;
    frontendTaskExecutionRole: iam.Role;
    frontendLogGroup: logs.LogGroup;
    cloudmapNamespace: servicediscovery.PrivateDnsNamespace;
}
export class FrontendServiceStack extends Stack {
    constructor(
        scope: Construct,
        id: string,
        props: frontendServiceStackProps
    ) {
        super(scope, id, props);

        const frontendTaskDefinition = new ecs.FargateTaskDefinition(
            this,
            'FrontendTaskDef',
            {
                memoryLimitMiB: 512,
                cpu: 256,
                executionRole: props.frontendTaskExecutionRole,
                taskRole: props.frontendTaskRole,
            }
        );

        const frontendImage = new ecs.AssetImage('frontend');

        frontendTaskDefinition.addContainer('frontendContainer', {
            image: frontendImage,
            environment: {
                BACKEND_SERVICE_NAME: props.backendServiceName,
                SERVICE_DISCOVERY_ENDPOINT:
                    props.cloudmapNamespace.namespaceName,
            },
            logging: ecs.LogDriver.awsLogs({
                streamPrefix: 'my-stream',
                logGroup: props.frontendLogGroup,
            }),
            portMappings: [
                {
                    containerPort: 80,
                    hostPort: 80,
                    protocol: ecs.Protocol.TCP,
                },
            ],
        });

        const frontendService = new ecs.FargateService(
            this,
            'FrontendService',
            {
                cluster: props.cluster,
                desiredCount: 1,
                assignPublicIp: false,
                taskDefinition: frontendTaskDefinition,
                enableExecuteCommand: true,
                cloudMapOptions: {
                    cloudMapNamespace: props.cloudmapNamespace,
                    containerPort: 80,
                    dnsRecordType: servicediscovery.DnsRecordType.A,
                    dnsTtl: Duration.seconds(10),
                },
                securityGroups: [props.frontendSG],
            }
        );

        props.targetGroup.addTarget(frontendService);
    }
}

最後に backend-stack.ts はこんな感じ。

import { Stack, StackProps, Duration } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
    aws_ec2 as ec2,
    aws_ecs as ecs,
    aws_servicediscovery as servicediscovery,
    aws_iam as iam,
    aws_logs as logs,
} from 'aws-cdk-lib';

interface backendServiceStackProps extends StackProps {
    cluster: ecs.Cluster;
    backendSG: ec2.SecurityGroup;
    backendTaskRole: iam.Role;
    backendTaskExecutionRole: iam.Role;
    backendLogGroup: logs.LogGroup;
    DDBTableName: string;
    cloudmapNamespace: servicediscovery.PrivateDnsNamespace;
}
export class BackendServiceStack extends Stack {
    public readonly backendServiceName: string;
    constructor(scope: Construct, id: string, props: backendServiceStackProps) {
        super(scope, id, props);

        const backendTaskDefinition = new ecs.FargateTaskDefinition(
            this,
            'BackendTaskDef',
            {
                memoryLimitMiB: 512,
                cpu: 256,
                executionRole: props.backendTaskExecutionRole,
                taskRole: props.backendTaskRole,
            }
        );

        const backendImage = new ecs.AssetImage('backend');

        backendTaskDefinition.addContainer('backendContainer', {
            image: backendImage,
            environment: {
                TODOTABLE_NAME: props.DDBTableName,
            },
            logging: ecs.LogDriver.awsLogs({
                streamPrefix: 'my-stream',
                logGroup: props.backendLogGroup,
            }),
            healthCheck: {
                command: [
                    'CMD-SHELL',
                    'curl -f http://localhost:10000/ishealthy || exit 1',
                ],
                interval: Duration.seconds(10),
                retries: 2,
                startPeriod: Duration.seconds(10),
                timeout: Duration.seconds(5),
            },
            portMappings: [
                {
                    containerPort: 10000,
                    hostPort: 10000,
                    protocol: ecs.Protocol.TCP,
                },
            ],
        });

        this.backendServiceName = 'backend';

        const backendService = new ecs.FargateService(this, 'BackendService', {
            cluster: props.cluster,
            desiredCount: 1,
            assignPublicIp: false,
            taskDefinition: backendTaskDefinition,
            enableExecuteCommand: true,
            cloudMapOptions: {
                cloudMapNamespace: props.cloudmapNamespace,
                containerPort: 10000,
                dnsRecordType: servicediscovery.DnsRecordType.A,
                dnsTtl: Duration.seconds(10),
                name: this.backendServiceName,
            },
            securityGroups: [props.backendSG],
        });
    }
}

bin/my-app.ts はこんな感じです。

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { InfrastructureStack } from '../lib/infrastructure-stack';
import { BackendServiceStack } from '../lib/backend-stack';
import { FrontendServiceStack } from '../lib/frontend-stack';

const app = new cdk.App();
const infra = new InfrastructureStack(app, 'InfrastructureStack', {
  env: { region: 'ap-northeast-1' },
});

const backendService = new BackendServiceStack(app, 'ECSBackendServiceStack', {
  env: { region: 'ap-northeast-1' },
  cluster: infra.cluster,
  backendSG: infra.backendServiceSG,
  backendTaskRole: infra.backendTaskRole,
  backendTaskExecutionRole: infra.TaskExecutionRole,
  backendLogGroup: infra.backendLogGroup,
  DDBTableName: infra.DDBTableName,
  cloudmapNamespace: infra.cloudmapNamespace
});

backendService.addDependency(infra);

const frontendService = new FrontendServiceStack(app, 'ECSFrontendServiceStack', {
  env: { region: 'ap-northeast-1' },
  vpc: infra.vpc,
  cluster: infra.cluster,
  backendServiceName: backendService.backendServiceName,
  frontendSG: infra.frontendServiceSG,
  targetGroup: infra.targetGroup,
  frontendTaskRole: infra.frontendTaskRole,
  frontendTaskExecutionRole: infra.TaskExecutionRole,
  frontendLogGroup: infra.frontendLogGroup,
  cloudmapNamespace: infra.cloudmapNamespace
});
frontendService.addDependency(backendService);

おわりに

ECS Exec ができるようにするための IAM Policy を書いてたら文量が膨らんだり、スタックを分けたもののこのリソースはこのスタックでいいんだっけみたいなのが出てきたり( TaskRole や TaskExecutionRole, CloudWatch Logs の log group はサービスのスタックで定義してもよかった気がする)して最後はグダリましたが、大体こんな感じでこれでいいんだっけって思いながらコードを書いてます。

最後にもう一度関連リンクをまとめておきます。

  • AWS CDK Examples: 各言語ごとの CDK テンプレートのサンプル集。
  • AWS ECS Construct Library の Overview: ECS に限らず各 AWS サービスのパッケージ先頭の Overview ページを読めば大体の使い方は掴めます。
  • AWS CDK Intro Workshop: CDK そのものに関してはこちらのワークショップがおすすめです。Construct の書き方だけでなくテストの書き方や CDK Pipelines といった発展的な内容も扱っています。

Discussion