はじめに
CDKの学習を始めるにあたり、CFnでつくった同じ内容を今回はCDKで作り直しました。
全体図
前提
- お名前.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
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
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
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
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より上。
参考