【AWS-CDK】お名前.comで購入したドメインを使ってALBでECSにホストベースルーティングする

# はじめに
CDKの学習を始めるにあたり、[CFnでつくった同じ内容](https://qiita.com/theFirstPenguin/items/f6165120465238814a8b)を今回はCDKで作り直しました。

# 全体図
![スクリーンショット 2022-05-07 8.35.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/c9479a70-0382-a1df-c585-79b3216547cf.png)

# 前提
-　お名前.comでドメイン(example.com)を購入済み
-　Route53でexample.comのホストゾーンを作成し、そのNSレコードをお名前.comで設定
-　ECRのプライベートリポジトリを作成後、アプリのイメージをpush済

https://blog.i-tale.jp/2020/04/13_02/

# 実装
## ディレクトリ構成

```
.
├── bin
│   └── app.ts
├── cdk.json
├── jest.config.js
├── lib
│   ├── app-stack.ts
│   ├── getENV.ts
│   └── resources
│       ├── fargateService.ts
│       ├── fargateServiceWithAlb.ts
│       └── vpc.ts
├── package-lock.json
├── package.json
├── test
│   └── app.test.ts
├── tsconfig.json
└── yarn.lock

```

## 環境変数の設定
```lib/getENV.ts
const ENV_NAMES = ['stg', 'prd'] as const;
type EnvName = typeof ENV_NAMES[number];
type EnvValues = {
  envName: string;
  domainName: string;
  subDomainName: string;
  HostedZoneId: string;
};

export function getEnv(): EnvValues {
  const envName = (process.env.ENV_NAME) as EnvName;
  switch (envName) {
    case 'stg':
      return {
        envName,
        domainName: 'example.com', // お名前.comでRoute53のNSレコードを事前設定済
        subDomainName: 'www.example.com',
        HostedZoneId: 'Zxxxxxxxxxxxx' // Route53でexample.comのホストゾーンを事前設定済
      };
    case 'prd':
      return {
        envName,
        domainName: 'xxxxx.com',
        subDomainName: 'xxxx.xxxxx.com',
        HostedZoneId: 'Zxxxxxxxxxxxx'
      };
    default:
      const _: never = envName;
      throw new Error(
        `Invalid environment variable ENV_NAME has given. ${envName}`
      );
  }
}
```
```package.json
{
  ...
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk",
    "synth:stg": "ENV_NAME=stg npx cdk synth",
    "synth:prd": "ENV_NAME=prd npx cdk synth",
    "diff:stg": "ENV_NAME=stg npx cdk diff",
    "diff:prd": "ENV_NAME=prd npx cdk diff",
    "deploy:stg": "ENV_NAME=stg npx cdk deploy",
    "deploy:prd": "ENV_NAME=prd npx cdk deploy"
  },
  ...
}
```

## App
```bin/app.ts
#!/usr/bin/env node
import 'source-map-support/register';
import { App } from 'aws-cdk-lib';
import { AppStack } from '../lib/app-stack';

const app = new App();
new AppStack(app, 'AppStack', {});

```
## Stack
![スクリーンショット 2022-05-07 9.05.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/b69e016a-8963-41b4-eaea-fecf100d4139.png)

```lib/app-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Cluster } from 'aws-cdk-lib/aws-ecs';
import { Construct } from 'constructs';
import {
  Certificate,
  CertificateValidation
} from 'aws-cdk-lib/aws-certificatemanager';
import { HostedZone } from 'aws-cdk-lib/aws-route53';
import { CustomVpc } from './resources/vpc';
import { FargateServiceWithSslAlb } from './resources/fargateServiceWithAlb';
import { HostBasedRoutingFargateService } from './resources/fargateService';
import { getEnv } from './getENV';

export class AppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
    
    // VPC
    const VpcCdk = new CustomVpc(this, `${getEnv().envName}-cdk-VPC`);

    // ECS Cluster
    const EcsClusterCdk = new Cluster(
      this,
      `${getEnv().envName}-cdk-EcsCluster`,
      {
        clusterName: `${getEnv().envName}-cdk-EcsCluster`,
        vpc: VpcCdk.vpc
      }
    );

    // 作成済のホストゾーン（example.com）の読み込み
    const HostedZoneCdk = HostedZone.fromHostedZoneAttributes(
      this,
      `${getEnv().envName}-cdk-HostedZone`,
      { hostedZoneId: getEnv().HostedZoneId, zoneName: getEnv().domainName }
    );

    // SSL証明書
    const CertificateCdk = new Certificate(
      this,
      `${getEnv().envName}-cdk-Cert`,
      {
        domainName: getEnv().domainName,
        subjectAlternativeNames: [getEnv().subDomainName],
        validation: CertificateValidation.fromDns(HostedZoneCdk)
      }
    );
 
    // ALB + Service1
    const serviceName1 = 'sample-service-1';
    const ServiceWithAlb = new FargateServiceWithSslAlb(
      this,
      `${getEnv().envName}-cdk-Service1`,
      {
        loadBalancerName: `${getEnv().envName}-cdk-ALB`,
        domainName: getEnv().domainName,
        domainZone: HostedZoneCdk,
        certificate: CertificateCdk,
        serviceName: serviceName1,
        cluster: EcsClusterCdk,
        taskSubnets: { subnets: VpcCdk.vpc.privateSubnets },
        containerPort: 3000,　// Fargateにバインドするコンテナのポート番号（アプリが動いているポート番号）
        ecrRepoName: serviceName1,　// ECRリポジトリ名
        imageTag: 'xxxxx' // ECRイメージのタグ
        awsAccountNumber: this.account
      }
    );

    // ECR repo of Serviec1
    const ecrRepo1 = ServiceWithAlb.ecrRepo;

    // Service2
    const serviceName2 = 'sample-service-1';
    const AnotherService = new HostBasedRoutingFargateService(
      this,
      `${getEnv().envName}-cdk-Service2`,
      {
        domainName: getEnv().subDomainName,
        domainZone: HostedZoneCdk,
        serviceName: serviceName2,
        cluster: EcsClusterCdk,
        vpc: VpcCdk.vpc,
        vpcSubnets: { subnets: VpcCdk.vpc.privateSubnets },
        alb: ServiceWithAlb.loadBalancer,
        listener: ServiceWithAlb.listener,
        containerPort: 3000,
        ecrRepoName: serviceName2,
        imageTag: 'yyyyy',
        listenerPriority: 2,
        awsAccountNumber: this.account
      }
    );

    // ECR repo of Serviec2
    const ecrRepo2 = AnotherService.ecrRepo;
  }
}

```
## Construct
### VPC
![スクリーンショット 2022-05-08 14.02.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/6fa3714c-e39b-2432-6242-ac3de05834e7.png)

```lib/resources/vpc.ts
import { Construct } from 'constructs';
import { Vpc, SubnetType } from 'aws-cdk-lib/aws-ec2';
import { getEnv } from '../getENV';

export interface CustomVpcProps {}

export class CustomVpc extends Construct {
  public readonly vpc: Vpc;

  constructor(scope: Construct, id: string, _props: CustomVpcProps = {}) {
    super(scope, id);

    const vpc = new Vpc(this, `${getEnv().envName}-vpc`, {
      cidr: '10.0.0.0/16',
      natGateways: getEnv().envName === 'prd' ? 2 : 1,
      maxAzs: 2,
      subnetConfiguration: [
        {
          name: 'private-subnet',
          subnetType: SubnetType.PRIVATE_WITH_NAT,
          cidrMask: 24
        },
        {
          name: 'public-subnet',
          subnetType: SubnetType.PUBLIC,
          cidrMask: 24
        },
        // DB配置用のisolated-subnet(今回は使わない)
        // { 
        //   name: 'isolated-subnet',
        //   subnetType: SubnetType.PRIVATE_ISOLATED,
        //   cidrMask: 28
        // }
      ]
    });
 
    this.vpc = vpc;
  }
}
```
### ALB(HTTPS) + Service1
![スクリーンショット 2022-05-07 9.14.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/3bc6f42c-b2ad-9da9-9978-66b338843a7e.png)


```lib/resources/fargateServiceWithAlb.ts
import { Construct } from 'constructs';
import { ApplicationLoadBalancedFargateService } from 'aws-cdk-lib/aws-ecs-patterns';
import { ContainerImage, ICluster, FargateService } from 'aws-cdk-lib/aws-ecs';
import { Repository, IRepository } from 'aws-cdk-lib/aws-ecr';
import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager';
import { IHostedZone } from 'aws-cdk-lib/aws-route53';
import {
  ApplicationLoadBalancer,
  ApplicationListener
} from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import { SubnetSelection } from 'aws-cdk-lib/aws-ec2';

import { getEnv } from '../getENV';

export interface FargateServiceWithSslAlbProps {
  loadBalancerName: string;
  domainName: string;
  domainZone: IHostedZone;
  serviceName: string;
  cluster: ICluster;
  certificate: ICertificate;
  taskSubnets: SubnetSelection;
  containerPort: number;
  ecrRepoName: string;
  imageTag: string;
  autoScaleMaxCount?: number;
  awsAccountNumber: string;
}

export class FargateServiceWithSslAlb extends Construct {
  public readonly loadBalancer: ApplicationLoadBalancer;
  public readonly listener: ApplicationListener;
  public readonly ecrRepo: IRepository;
  public readonly service: FargateService;

  constructor(
    scope: Construct,
    id: string,
    props: FargateServiceWithSslAlbProps
  ) {
    super(scope, id);

    const ImageArn = Repository.arnForLocalRepository(
      props.ecrRepoName,
      this,
      props.awsAccountNumber
    );
    const Image = Repository.fromRepositoryAttributes(
      this,
      `${getEnv().envName}-image-${props.ecrRepoName}-${props.imageTag}`,
      { repositoryArn: ImageArn, repositoryName: props.ecrRepoName }
    );

    const loadBalancedFargateService =
      new ApplicationLoadBalancedFargateService(
        this,
        `${getEnv().envName}-fargate-alb-${props.serviceName}`,
        {
          loadBalancerName: props.loadBalancerName,
          domainName: props.domainName,
          domainZone: props.domainZone,
          serviceName: props.serviceName,
          certificate: props.certificate,
          redirectHTTP: true,
          cluster: props.cluster,
          taskSubnets: props.taskSubnets,
          taskImageOptions: {
            containerName: props.serviceName,
            image: ContainerImage.fromEcrRepository(ecrRepo, props.imageTag),
            containerPort: props.containerPort
          }
        }
      );

    // オートスケール設定
    const scalableTarget =
      loadBalancedFargateService.service.autoScaleTaskCount({
        minCapacity: 1,
        maxCapacity: props.autoScaleMaxCount ? props.autoScaleMaxCount : 10
      });

    scalableTarget.scaleOnCpuUtilization('CpuScaling', {
      targetUtilizationPercent: 50
    });

    scalableTarget.scaleOnMemoryUtilization('MemoryScaling', {
      targetUtilizationPercent: 50
    });

    this.loadBalancer = loadBalancedFargateService.loadBalancer;
    this.listener = loadBalancedFargateService.listener;
    this.service = loadBalancedFargateService.service;
    this.ecrRepo = ecrRepo;
  }
}
```
### Service2
![スクリーンショット 2022-05-07 9.08.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/572098/c39ae77d-f020-8847-358c-ff861c96fed0.png)

```lib/resources/fargateService.ts
import { Construct } from 'constructs';
import { FargateService, FargateTaskDefinition } from 'aws-cdk-lib/aws-ecs';
import {
  ApplicationLoadBalancer,
  ApplicationTargetGroup,
  ApplicationProtocol,
  ApplicationListener,
  ListenerCondition
} from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import { ContainerImage, ICluster } from 'aws-cdk-lib/aws-ecs';
import { Repository, IRepository } from 'aws-cdk-lib/aws-ecr';
import { Port, Vpc } from 'aws-cdk-lib/aws-ec2';
import { SubnetSelection } from 'aws-cdk-lib/aws-ec2';
import {
  IHostedZone,
  RecordSet,
  RecordType,
  RecordTarget
} from 'aws-cdk-lib/aws-route53';

import { getEnv } from '../getENV';

export interface FargateServiceProps {
  domainName: string;
  domainZone: IHostedZone;
  serviceName: string;
  cluster: ICluster;
  vpc: Vpc;
  vpcSubnets: SubnetSelection;
  alb: ApplicationLoadBalancer;
  listener: ApplicationListener;
  containerPort: number;
  ecrRepoName: string;
  imageTag: string;
  autoScaleMaxCount?: number;
  listenerPriority: number;
  awsAccountNumber: string;
}

export class HostBasedRoutingFargateService extends Construct {
  public readonly service: FargateService;
  public readonly ecrRepo: IRepository;

  constructor(scope: Construct, id: string, props: FargateServiceProps) {
    super(scope, id);

    const ecrRepoArn = Repository.arnForLocalRepository(
      props.ecrRepoName,
      this,
      props.awsAccountNumber
    );
    const ecrRepo = Repository.fromRepositoryAttributes(
      this,
      `${getEnv().envName}-image-${props.ecrRepoName}-${props.imageTag}`,
      { repositoryArn: ecrRepoArn, repositoryName: props.ecrRepoName }
    );

    const taskDefinition = new FargateTaskDefinition(
      this,
      `${getEnv().envName}-taskDef-${props.serviceName}`
    );
    const container = taskDefinition.addContainer(
      `${props.serviceName}-contaier`,
      {
        image: ContainerImage.fromEcrRepository(ecrRepo, props.imageTag),
        containerName: props.serviceName,
        environment: { MY_ENVIRONMENT_VAR: 'FOO' },
        memoryLimitMiB: 512,
        cpu: 256
      }
    );
    container.addPortMappings({ containerPort: props.containerPort });

    const service = new FargateService(
      this,
      `${getEnv().envName}-${props.serviceName}`,
      {
        cluster: props.cluster,
        taskDefinition,
        vpcSubnets: props.vpcSubnets,
        serviceName: props.serviceName,
        desiredCount: 1
      }
    );
    service.connections.allowFrom(props.alb, Port.tcp(props.containerPort));

    const targetGroup = new ApplicationTargetGroup(
      this,
      `${props.serviceName}-Targetgroup`,
      {
        targets: [service],
        protocol: ApplicationProtocol.HTTP,
        port: props.containerPort,
        vpc: props.vpc
      }
    );

    props.listener.addTargetGroups('targetgroup', {
      targetGroups: [targetGroup],
      conditions: [ListenerCondition.hostHeaders([props.domainName])],
      priority: props.listenerPriority
    });

    const recordSet = new RecordSet(this, `${props.serviceName}-RecordSet`, {
      recordType: RecordType.CNAME,
      target: RecordTarget.fromValues(props.alb.loadBalancerDnsName),
      zone: props.domainZone,
      recordName: props.domainName
    });

    const scalableTarget = service.autoScaleTaskCount({
      minCapacity: 1,
      maxCapacity: props.autoScaleMaxCount ? props.autoScaleMaxCount : 10
    });

    scalableTarget.scaleOnCpuUtilization('CpuScaling', {
      targetUtilizationPercent: 50
    });

    scalableTarget.scaleOnMemoryUtilization('MemoryScaling', {
      targetUtilizationPercent: 50
    });

    this.service = service;
    this.ecrRepo = ecrRepo;
  }
}

```

# いまさらCDKを使ってみて感想
- 可読性については好みによりそう。自分はCFnの方が好き。
- CFnより管理しやすそう。特にチームでインフラ管理する場合はCDKの方よさげ。
- L3などインスタントに構築できるpatternが充実しているとはいえ、実際の運用にはCFn同様にAWSの理解が必須。AWS初心者に優しい、というわけではなさそう。
- でもTypeScriptの（というかVSCodeの）型補完が最高。開発体験はCFnより上。

# 参考
https://zenn.dev/yamatatsu/books/aws-cdk-documentation-jp/viewer/02-concepts-constructs

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecs_patterns.ApplicationLoadBalancedFargateService.html

https://github.com/aws/aws-cdk/issues/7327

https://bobbyhadz.com/blog/aws-cdk-vpc-example

https://zenn.dev/5hintaro/articles/e8a20c171bf8fd