はじめに
今回はAWS CDKを使ってAWS上にリソースをデプロイする検証をしたので、その内容をご紹介します。この検証では以下のようなリソースを払い出すCDKアプリケーションをTypescriptの言語で書いてみました。
使用したコードエディタはVS Codeです。以下から入手することができます。
CDKとは
そもそもAWS CDKとはどんなものなのでしょうか。AWS公式ドキュメントには以下の説明が記載されています。
AWS Cloud Development Kit (AWS CDK) は、 AWS CloudFormationクラウドインフラストラクチャをコードで定義し、それをプロビジョニングするためのオープンソースのソフトウェア開発フレームワークです。
つまり、Management Consoleを使って手動で環境構築したりYAML/JSONでCloudFormation Templateを一から書くといったことをせず、シンプルなプログラミングにより少ないコーディングの量でリソースをプロビジョニングすることができるということです。
基本的にはConstructと呼ばれるあらかじめ提供されたコードの集合体を利用してStackを定義し、CDKアプリケーションを実行することでCloudFormationテンプレートが自動生成されてリソースがプロビジョニングされる、といった流れになります。
前提
まず今回の構成をCDKで実装するにあたり、SSL証明書の発行のみ手動で行いました。
詳しい対応方法はAWS公式ドキュメントを参照ください。
今回のCDKアプリではALB+EC2の構成を作っていますが、実際の環境ではURLを使ったアクセスを行う場合のドメイン取得やAmazon Route53のパブリックホストゾーン作成なども必要になる場合があります。
手順
実施する手順は以下の通りです。
- CDK実行環境の準備
- アプリケーションの作成
- AWS CloudFormationテンプレートの生成
- デプロイ
1. CDK実行環境の準備
まず下記リンク先の手順に沿ってCDK実行環境を準備していきます。
必要なモジュールのインストールやCDKのbootstrapなどが完了したら、下記リンク先の手順1「CDKプロジェクトを作成する」を実行してCDKの実行環境を用意していきます。
具体的には、CDKアプリケーションを作成して実行するディレクトリを作成したり、Terminalで該当するディレクトリに移動してアプリケーションのベースを作成したり、といった作業を行います。
> cdk init app --language=typescript
Applying project template app for typescript
# Welcome to your CDK TypeScript project
This is a blank project for CDK development with TypeScript.
The `cdk.json` file tells the CDK Toolkit how to execute your app.
## Useful commands
* `npm run build` compile typescript to js
* `npm run watch` watch for changes and compile
* `npm run test` perform the jest unit tests
* `npx cdk deploy` deploy this stack to your default AWS account/region
* `npx cdk diff` compare deployed stack with current state
* `npx cdk synth` emits the synthesized CloudFormation template
Initializing a new git repository...
warning: in the working copy of '.gitignore', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of '.npmignore', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'README.md', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'bin/qiitablog_testcdk.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'cdk.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'jest.config.js', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'lib/qiitablog_testcdk-stack.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'package.json', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'test/qiitablog_testcdk.test.ts', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'tsconfig.json', LF will be replaced by CRLF the next time Git touches it
Executing npm install...
npm WARN deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
npm WARN deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
✅ All done!
この手順が完了すると、cdk init
のコマンドを実行したディレクトリにCDKアプリケーションを構成するファイルが自動的に作成されます(↓)。
筆者はTypescriptを使ってCDKアプリケーションを作っていますが、別の言語で作る場合にはその言語に合った手順を実行してください。
2. アプリケーションの作成
CDKアプリケーションを作るための準備が整ったら、実際にファイルを編集してCDKアプリケーションを作っていきます。
"context": {
"stackName": "qiitablog_testcdk",
"vpcCidr": "10.100.0.0/16",
"env": {
"account": "xxxxxxxxxxxx",
"region": "<使用するregion>"
},
"availabilityZones": "ap-northeast-1a,ap-northeast-1c",
"sslCertificationArn": "arn:aws:acm:<region>:<accountid>:certificate/xxxxx",
※各種パラメータとして渡す値をcdk.jsonに格納し、CDKアプリケーション側は参照する形で構成します。これによりパラメータをハードコードすることなく、CDKアプリケーションを複数環境に別々のパラメータでデプロイすることができます。
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { QiitaBlogStack } from '../lib/qiitablog_testcdk-stack';
const app = new cdk.App();
// パラメータの設定 //
const stackName = app.node.getContext('stackName');
const env = app.node.getContext('env');
const vpcCidr = app.node.getContext('vpcCidr');
const availabilityZones = app.node.getContext('availabilityZones');
const sslCertificationArn = app.node.getContext('sslCertificationArn');
new QiitaBlogStack(
app,
'QiitaBlogStack',
{
stackName,
env: typeof env === 'string' ? JSON.parse(env) : env,
vpcCidr,
availabilityZones,
sslCertificationArn,
}
);
// 必要なモジュールのインストール //
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { aws_elasticloadbalancingv2 as elbv2 } from 'aws-cdk-lib';
import { aws_ssm as ssm } from 'aws-cdk-lib';
import { aws_elasticloadbalancingv2_targets as elbv2_targets } from 'aws-cdk-lib'
import { aws_certificatemanager as acm } from 'aws-cdk-lib';
// propsのインポート //
export type QiitaBlogStackProps = cdk.StackProps & {
vpcCidr: string,
stackName: string,
availabilityZones: string|string[],
sslCertificationArn: string,
};
// stackの構成 //
export class QiitaBlogStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: QiitaBlogStackProps) {
super(scope, id, props);
const {
vpcCidr,
stackName,
availabilityZones,
sslCertificationArn,
} = props;
// vpc and subnets //
const vpc = new ec2.Vpc(this, 'Vpc', {
ipAddresses: ec2.IpAddresses.cidr(vpcCidr),
vpcName: `${stackName}-vpc`,
enableDnsSupport: true,
createInternetGateway: true, //Internet Gatewayをプロビジョンする
availabilityZones: typeof availabilityZones === 'string' ? availabilityZones.split(',') : availabilityZones,
subnetConfiguration: [
{
name: "PublicSubnet", //パブリックサブネット(NAT GatewayやALB用)
subnetType: ec2.SubnetType.PUBLIC,
cidrMask: 24,
},
{
name: "PrivateSubnet", //プライベートサブネット(EC2インスタンス用)
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
cidrMask: 24,
}
],
natGateways: 1, //NAT Gatewayをプロビジョニングする
});
// security group for ec2 endpoint //
const httpsInboundSg = new ec2.SecurityGroup(this, 'httpsInboundSecurityGroup', {
vpc: vpc,
allowAllOutbound: true,
securityGroupName: `${stackName}-https-sg`,
});
httpsInboundSg.addIngressRule(
ec2.Peer.anyIpv4(),
ec2.Port.tcp(443)
);
vpc.addInterfaceEndpoint("Ec2MsgEdp", {
service: ec2.InterfaceVpcEndpointAwsService.EC2_MESSAGES,
securityGroups: [ httpsInboundSg ],
subnets: vpc.selectSubnets({subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS}),
});
vpc.addInterfaceEndpoint("SsmEdp", {
service: ec2.InterfaceVpcEndpointAwsService.SSM,
securityGroups: [ httpsInboundSg ],
subnets: vpc.selectSubnets({subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS}),
});
vpc.addInterfaceEndpoint("SsmMsgEdp", {
service: ec2.InterfaceVpcEndpointAwsService.SSM_MESSAGES,
securityGroups: [ httpsInboundSg ],
subnets: vpc.selectSubnets({subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS}),
});
// role for ssm and instance profile //
const instanceRole = new iam.Role(this, 'qiitaInstanceRole', {
assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'),
roleName: `qiitainstance-role-${cdk.Aws.ACCOUNT_ID}-${cdk.Aws.REGION}`,
inlinePolicies: { //インラインポリシーの追加
'requiredPolicies': new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'sts:AssumeRole'
],
resources: ['*'],
}),
],
}),
},
managedPolicies: [ //AWSマネージドポリシーの利用(正式名称を記載する)
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'),
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonS3ReadOnlyAccess'),
iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonEC2ContainerRegistryReadOnly'),
]
});
// resource for backstage app //
const ec2Sg = new ec2.SecurityGroup(this, 'EC2SecurityGroup', {
vpc,
allowAllOutbound: true,
});
const qiitaInstance = new ec2.Instance(this, 'qiitaEc2Instance', {
vpc,
instanceType: ec2.InstanceType.of( //インスタンスタイプの指定
ec2.InstanceClass.T2,
ec2.InstanceSize.MICRO
),
machineImage: ec2.MachineImage.latestAmazonLinux2(), //最新のAmazon Linux 2バージョンOSの指定
role: instanceRole, //自身で作成したInstance Roleの使用を宣言
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, //プライベートサブネットを使用
},
securityGroup: ec2Sg,
instanceName: `${stackName}-qiitainstance`,
requireImdsv2: true
});
const qiitaBucket = new s3.Bucket(this, 'qiitaTestBucket', { //ログ保管などに利用できるS3バケットの作成(詳細に設定を追加)
accessControl: s3.BucketAccessControl.PRIVATE,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
versioned: true,
removalPolicy: RemovalPolicy.RETAIN,
bucketName: `${stackName}-qiitabucket-${cdk.Aws.ACCOUNT_ID}-${cdk.Aws.REGION}`,
});
// ALB //
const albSg = new ec2.SecurityGroup(this, 'albSecurityGroup', {
vpc: vpc,
allowAllOutbound: true,
securityGroupName: `${stackName}-alb-sg`,
});
albSg.addIngressRule( //ポート80番のIPv4インバウンドを許可
ec2.Peer.anyIpv4(),
ec2.Port.tcp(80)
);
albSg.addIngressRule( //ポート443番のIPv4インバウンドを許可
ec2.Peer.anyIpv4(),
ec2.Port.tcp(443)
);
const applicationLoadBalancer = new elbv2.ApplicationLoadBalancer(this, 'ApplicationLoadBalancer', {
vpc,
internetFacing: true,
loadBalancerName: `${stackName}-Alb`,
securityGroup: albSg,
vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
});
const listener80 = applicationLoadBalancer.addListener('Listener80', {
port: 80,
open: true,
protocol: elbv2.ApplicationProtocol.HTTP,
defaultAction: elbv2.ListenerAction.redirect({ //HTTP通信をポート443のHTTPSへRedirect
protocol: 'HTTPS',
port: '443',
permanent: true,
})
});
const certificationArn = acm.Certificate.fromCertificateArn(this, 'sslCertification', sslCertificationArn)
const sslCert = elbv2.ListenerCertificate.fromCertificateManager(certificationArn);
const instanceTarget = new elbv2_targets.InstanceTarget(qiitaInstance, 7007); //ALBからEC2へのターゲット作成
const targetGroup = new elbv2.ApplicationTargetGroup(this, 'targetGroup', {
vpc: vpc,
port: 7007,
protocol: elbv2.ApplicationProtocol.HTTP,
targetType: elbv2.TargetType.INSTANCE,
targets: [
instanceTarget,
],
healthCheck: {
path: '/healthcheck',
interval: cdk.Duration.seconds(30),
timeout: cdk.Duration.seconds(3),
},
});
const listener443 = applicationLoadBalancer.addListener('Listener443', {
port: 443,
open: true,
protocol: elbv2.ApplicationProtocol.HTTPS,
certificates: [sslCert],
defaultAction:
elbv2.ListenerAction.forward([targetGroup]) //Default ActionでTarget GroupへのFoward設定
});
qiitaInstance.connections.allowFrom(applicationLoadBalancer, ec2.Port.tcp(443));
// SSM Parameter //
const ssmParameter = new ssm.StringParameter(this, 'ssmParameter', { //SSM Parameterへのパラメータ登録(例)
parameterName: `/qiitablog/test/${stackName}`,
simpleName: false,
stringValue: [
`QIITA_S3BUCKET=${qiitaBucket.bucketName}`,
`QIITA_ACCOUNTID=${cdk.Aws.ACCOUNT_ID}`,
`QIITA_REGION=${cdk.Aws.REGION}`,
].join('\n'),
});
};
}
3. AWS CloudFormationテンプレートの生成
ある程度CDKアプリケーションの記載ができたら、実際にCloudFormationテンプレートの生成を行ってみます。cdk synth
コマンドを実行することでCloudFormationテンプレートの生成が開始され、CDKアプリケーションに不備が無ければYaml形式で記載されたCloudFormationテンプレートが生成されます。エラーが出てCloudFormationテンプレートが生成できなければ、エラーメッセージを解読してCDKアプリケーションの記載を修正し、再度cdk synth
を実行してCloudFormationテンプレートが生成できることを確認してください。
4. デプロイ
CloudFormationテンプレートが生成できたら、実際にAWSアカウントの環境へdeployをしていきます。コマンドとしてはcdk deploy
を実行します。この際に特定のリソースを作成することを確認するメッセージ(Do you wish to deploy these changes?)が表示されたら、y
+Enter
を入力します。
うまくいくとリソースが作成されていき、CREATE_IN_PROGRESSやCREATE_COMPLETE等のステータスが表示されます。全て作成が完了すると、デプロイにかかった総時間やOutputとして設定した出力が表示されます。
(↑この状態になればCDK側でのデプロイは完了となります。)
ここでエラーが出る場合(CREATE_FAILED等のメッセージが出てロールバックされる場合など)は、CDKアプリケーションのデバッグを行った上で再度cdk synth
やcdk deploy
の手順を実行してください。
ここまできたら、あとはManagement Consoleで実際にデプロイされているリソース群が正しく設定されているか等を確認するもよし、既に成功実績のあるCDKであればそのまま環境利用を開始するでもよし、ご自身の利用用途や状況を加味して対応を検討してください。
気を付けたこと&まとめ
CDKを使い始めたばかりの時はYAMLでゴリゴリCloudFormationテンプレートを作成していましたが、CDKに慣れてくると自力でCloudFormationテンプレートを書くのが大変に思えるようになりました。それぞれメリットがあるので必ずしもCDKが正解というわけではありませんが、コードによるリソース管理(IaC)の観点から最適の方法を選択することが必要だと感じました。
今回のテストではできるだけシンプルにコードを記載することを意識しましたが、Constructsを別ファイルとして複数に分けて作成することでStackの構造を整理したり、まだまだ改善の余地はあると思っています。更に良い形でのCDKアプリケーション作成のナレッジができましたらまた共有させていただきますので、お楽しみにしていただければと思います。
最後まで読んでいただきありがとうございました。