3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-05-07

はじめに

CDKの学習を始めるにあたり、CFnでつくった同じ内容を今回はCDKで作り直しました。

全体図

スクリーンショット 2022-05-07 8.35.23.png

前提

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

実装

ディレクトリ構成

.
├── 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

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

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

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

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より上。

参考

3
1
0

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
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?