スタイル・エッジ技術ブログ

士業集客支援/コンサルティングのスタイル・エッジのエンジニアによるブログです。

AWS CDK入門|準備からサブネットを分けるまで

はじめに

こんにちは!子育てエンジニアのganpeです。毎日暑いですね。
夏の太陽のせいでしょうか。インフラとは一定の距離を保ってきた私の中でもIaC熱がふつふつと沸き上がってきたので、今回はAWS CDK (Cloud Development Kit) について書いてみたいと思います。

AWS CDKとは

AWS クラウド開発キット (AWS CDK) は、最新のプログラミング言語を使用してクラウドインフラストラクチャをコードとして定義し、それを AWS CloudFormation を通じてデプロイするためのオープンソースのソフトウェア開発フレームワークです。

引用元|https://aws.amazon.com/cdk/faqs/

つまりCloudFormation (以下、CFn) のようにJSONYAMLで構成を記述するのではなく、プログラミング言語*1によってAWSリソースを定義・デプロイできるということですね。
プログラミング言語そのものの力によって、クラスの継承ができたりテストができたりするのはもちろんのこと、さらに後述のConstruct LibraryによってCFnより少ない記述量でベストプラクティスが利用できることも魅力です。

AWS CDKは2019年7月にv1が一般公開され、そして昨年12月にはv2が利用可能になりました。
2022年4月には日本でもAWS CDKのカンファレンスが開催されるなど、盛り上がりを見せています。
ワクワクしてきましたね。

まずはやってみよう

早速ですが、次のようなよくある構成を作成してみたいと思います。
1度では書ききれない予感がしますので、今回はサブネットまでをご紹介します。

構成図(未来予想図)
構成図(未来予想図)
今回のゴール
今回のゴール

環境構築

事前準備

AWS CDKの利用にはNode.js*2とnpmが必要です。

AWS CDKは内部でAWS CLIをしているので、AWS CLIのデフォルトプロファイルにデフォルトリージョン、アクセスキー、シークレットキーをそれぞれ設定しておきます。

[default]
region=MYREGION
[default]
aws_access_key=MYACCESSKEY
aws_secret_access_key=MYSECRETKEY

参考|https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html#getting_started_prerequisites

AWS CDKインストール

$ npm install -g aws-cdk
$ cdk --version
2.28.1 (build d035432)

参考|https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html#getting_started_install

プロジェクトの作成

ディレクトリを作成し、プログラミング言語を指定してCDKプロジェクトを作成します。(csharpとしたい気持ちをぐっと堪えてtypescriptと叩きました)
このとき、ディレクトリ名がそのままCDKのプロジェクト名 (CFnテンプレート名) になるので注意が必要です。

$ mkdir my-first-cdk-app && cd my-first-cdk-app
$ 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
* `cdk deploy`      deploy this stack to your default AWS account/region
* `cdk diff`        compare deployed stack with current state
* `cdk synth`       emits the synthesized CloudFormation template

Initializing a new git repository...
Executing npm install...
✅ All done!

参考|https://docs.aws.amazon.com/cdk/v2/guide/work-with-cdk-typescript.html#typescript-newproject


作りたてのプロジェクトはこのような構成でした。

my-first-cdk-app
├── bin
│   ├── my-first-cdk-app.ts
├───lib
│   ├── my-first-cdk-app-stack.ts
├── node_modules
│   ├── ~省略~
│   ├── aws-cdk-lib
│   ├── ~省略~
├── test
│   ├── my-first-cdk-app.test.ts
├── .gitignore
├── .npmignore
├── cdk.json
├── jest.config.js
├── package-lock.json
├── package.json
├── README.md
└── tsconfig.json    

後述のConstruct Library (aws-cdk-lib) もこのときインストールされています。
ユニットテスト用にjestも導入済みの状態です。便利ですね。

binの下にmy-first-cdk-app.tsというファイルがあり、こちらがエントリポイントになっているようです。


my-first-cdk-app.ts

#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import { MyFirstCdkAppStack } from '../lib/my-first-cdk-app-stack';

const app = new cdk.App();
new MyFirstCdkAppStack(app, 'MyFirstCdkAppStack', {
~省略~
});

appではstackを読み込んでいて、stackはlib/my-first-cdk-app-stack.tsに定義されているようです。

CDKプロジェクトの構造

appやstackという単語が登場したので少し補足します。
CDKプロジェクトはapp, stack, constructの3階層で構成されます。
stackというのはCFnのstackに相当し、デプロイの単位になります。
stackの中ではconstructを使ってリソースを定義します。
appはCDKのプロジェクトファイルのようなもので、stackどうしの依存関係を定義します。

CDKプロジェクト構成
CDKプロジェクトの構造

VPCの作成

今はstack1つだけなのでappは無視してstackのソースに手を加えていきます。
VPCを追加してみましょう。

my-first-cdk-app-stack.ts

import { Stack, StackProps } from 'aws-cdk-lib';
import { Vpc } from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';

export class MyFirstCdkAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const vpc = new Vpc(this, 'MyVpc1'); // <- 第3引数のpropsは指定なし=デフォルト設定
  }
}

参考|https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.Vpc.html

え?これだけ?と思われるかもしれませんが、まずはこのnew Vpc()によってどのようなリソースが作成されるのかを見てみることにします。

CFnテンプレートへのコンパイルとデプロイ

コンパイル(synthesize)

$ cdk synth

参考|https://docs.aws.amazon.com/cdk/v2/guide/work-with-cdk-typescript.html#typescript-running


cdk synthコマンドによってcdk.outディレクトリの中にCFnテンプレート (MyFirstCdkAppStack.template.json) が生成されます。
AWS CDKはこのCFnテンプレートをCFnにアップロードすることでデプロイを実現しているわけですね。なるほど。

ちなみにTerraformユーザーであれば、cdktf (CDK for Terraform)*3を使うことでCFnテンプレートの代わりにTerraformのHCLステートファイルを出力することもできます。
CFnやTerraformなどこれまでの資産を活かしつつ利用できるのがCDKの嬉しいところですね。


さて、先程のコードから生成されたテンプレートを見てみましょう。

$ grep "Type" cdk.out/MyFirstCdkAppStack.template.json | sort
   "Type": "AWS::CDK::Metadata",
   "Type": "AWS::EC2::EIP",
   "Type": "AWS::EC2::EIP",
   "Type": "AWS::EC2::InternetGateway",
   "Type": "AWS::EC2::NatGateway",
   "Type": "AWS::EC2::NatGateway",
   "Type": "AWS::EC2::Route",
   "Type": "AWS::EC2::Route",
   "Type": "AWS::EC2::Route",
   "Type": "AWS::EC2::Route",
   "Type": "AWS::EC2::RouteTable",
   "Type": "AWS::EC2::RouteTable",
   "Type": "AWS::EC2::RouteTable",
   "Type": "AWS::EC2::RouteTable",
   "Type": "AWS::EC2::Subnet",
   "Type": "AWS::EC2::Subnet",
   "Type": "AWS::EC2::Subnet",
   "Type": "AWS::EC2::Subnet",
   "Type": "AWS::EC2::SubnetRouteTableAssociation",
   "Type": "AWS::EC2::SubnetRouteTableAssociation",
   "Type": "AWS::EC2::SubnetRouteTableAssociation",
   "Type": "AWS::EC2::SubnetRouteTableAssociation",
   "Type": "AWS::EC2::VPC",
   "Type": "AWS::EC2::VPCGatewayAttachment",
   "Type": "AWS::SSM::Parameter::Value<String>",

VPCだけではなく、インターネットゲートウェイ、Elastic IPx2、NATゲートウェイx2、サブネットx4(上記からは分かりづらいですが、パブリックx2、プライベートx2)、ルートテーブルなどがもりもり生成されていることが分かります。すごい。。
感覚的には、AWSマネジメントコンソールからVPCを作成するときの「VPCのみ/VPCなど」の選択肢で「VPCなど」を選んだときと似ていますね。

一度このままデプロイまで進めてみたいと思います。

Bootstrap

デプロイに移る前にBootstrapを実行しておきます。
Bootstrapでは、S3バケットやIAMロールなど、デプロイの際に必要になる各種リソースをまとめて準備します。
アカウント、リージョンごとに1度だけ実行すればOKです。

$ cdk bootstrap aws://MYACCOUNTNUMBER/MYREGION
~省略~
  ✅  Environment aws://MYACCOUNTNUMBER/MYREGION bootstrapped.

参考|https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html#getting_started_bootstrap

Bootstrapによって作成されたAWSリソースはこちらです。

  • CFn > Stack > CDKToolKit
    • SSMパラメータ: CdkBootstrapVersion
    • IAMポリシー: FilePublishingRoleDefaultPolicy, ImagePublishingRoleDefaultPolicy
    • IAMロール: CloudFormationExecutionRole, DeploymentActionRole, FilePublishingRole, ImagePublishingRole, LookupRole
    • S3バケット: StagingBucket
    • S3バケットポリシー: StagingBucketPolicy
    • ECRリポジトリ: ContainerAssetsRepository

デプロイ

$ cdk deploy 

✨  Synthesis time: 7.61s

MyFirstCdkAppStack: deploying...
~省略~
  ✅  MyFirstCdkAppStack

✨  Deployment time: 169.28s

Stack ARN:
arn:aws:cloudformation:以下略

✨  Total time: 176.89s

参考|https://docs.aws.amazon.com/cdk/v2/guide/work-with-cdk-typescript.html#typescript-running

VPCとその他諸々が無事にデプロイされました!

なお、デプロイ対象のstackがapp内に複数ある場合は「cdk deploy stack1 stack2」のようにstack名を指定します。

テストを書く

先ほどのデフォルトのVPCは、私が欲しい形になっていませんでした。
サブネットを分けてみたいと思いますが、せっかくユニットテストができるのでテストを先に書いておきます。
テスト名を日本語で書いている理由は後述します。

import * as cdk from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import * as MyFirstCdkApp from '../lib/my-first-cdk-app-stack';

const app = new cdk.App();
const stack = new MyFirstCdkApp.MyFirstCdkAppStack(app, 'MyTestStack');
const template = Template.fromStack(stack);

const getResourceIds = (type: string, props?: any): string[] =>
  Object.keys(template.findResources(type, props ? { Properties: props } : {}));

const getResourceId = (type: string, props?: any): string => {
  const resourceIds = getResourceIds(type, props);
  if (resourceIds.length !== 1)
    throw new Error('リソースがないか1つに特定できません');
  return resourceIds[0];
};

test('VPCがcidr10.0.0.0/16で1つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::VPC', 1);
  template.hasResourceProperties('AWS::EC2::VPC', {
    CidrBlock: '10.0.0.0/16',
  });
});

// 後で使うためにVPCのリソースIDを取得しておく
const vpcId = getResourceId('AWS::EC2::VPC');

test('VPCにインターネットゲートウェイが1つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::InternetGateway', 1);
  template.hasResourceProperties('AWS::EC2::VPCGatewayAttachment', {
    VpcId: { Ref: vpcId },
    InternetGatewayId: { Ref: getResourceId('AWS::EC2::InternetGateway') },
  });
});

test('サブネットが6つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::Subnet', 6);
});

test('ALB用のパブリックサブネットが/24で作成されること', () => {
  template.hasResourceProperties('AWS::EC2::Subnet', {
    VpcId: { Ref: vpcId },
    CidrBlock: Match.stringLikeRegexp('.*/24'),
    MapPublicIpOnLaunch: true,
  });
});

test('アプリケーション用のパブリックサブネットが/24で作成されること', () => {
  template.hasResourceProperties('AWS::EC2::Subnet', {
    VpcId: { Ref: vpcId },
    CidrBlock: Match.stringLikeRegexp('.*/24'),
    MapPublicIpOnLaunch: false,
  });
});

test('RDS用のパブリックサブネットが/28で作成されること', () => {
  template.hasResourceProperties('AWS::EC2::Subnet', {
    VpcId: { Ref: vpcId },
    CidrBlock: Match.stringLikeRegexp('.*/28'),
    MapPublicIpOnLaunch: false,
  });
});

test('NATゲートウェイが2つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::NatGateway', 2);
});

test('ALB用のサブネット2つにそれぞれNATゲートウェイが設置されること', () => {
  const ingressSubnetIds = getResourceIds('AWS::EC2::Subnet', {
    VpcId: { Ref: vpcId },
    MapPublicIpOnLaunch: true,
  });
  template.hasResourceProperties('AWS::EC2::NatGateway', {
    SubnetId: { Ref: ingressSubnetIds[0] },
  });
  template.hasResourceProperties('AWS::EC2::NatGateway', {
    SubnetId: { Ref: ingressSubnetIds[1] },
  });
});

test('EIPが2つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::EIP', 2);
});

test('NATゲートウェイ2つにそれぞれEIPが作成されること', () => {
  const eipIds = getResourceIds('AWS::EC2::EIP');
  template.hasResourceProperties('AWS::EC2::NatGateway', {
    AllocationId: { 'Fn::GetAtt': [ eipIds[0], 'AllocationId' ] },
  });
  template.hasResourceProperties('AWS::EC2::NatGateway', {
    AllocationId: { 'Fn::GetAtt': [ eipIds[1], 'AllocationId' ] },
  });
});

test('ルートテーブルが6つ作成されること', () => {
  template.resourceCountIs('AWS::EC2::RouteTable', 6);
  template.resourceCountIs('AWS::EC2::SubnetRouteTableAssociation', 6);
});

test('アプリケーション用のサブネット2つがそれぞれNATゲートウェイにルーティングされること', () => {
  const applicationSubnetIds = getResourceIds('AWS::EC2::Subnet', {
    VpcId: { Ref: vpcId },
    CidrBlock: Match.stringLikeRegexp('.*/24'),
    MapPublicIpOnLaunch: false,
  });

  const applicationRouteTableId1 = Object.values(template.findResources('AWS::EC2::SubnetRouteTableAssociation', {
    Properties: { SubnetId: { Ref: applicationSubnetIds[0] } },
  }))[0]?.Properties?.RouteTableId?.Ref;
  if (!applicationRouteTableId1) throw new Error('SubnetRouteTableAssociationが正しく定義されていません');

  const applicationRouteTableId2 = Object.values(template.findResources('AWS::EC2::SubnetRouteTableAssociation', {
    Properties: { SubnetId: { Ref: applicationSubnetIds[1] } },
  }))[0]?.Properties?.RouteTableId?.Ref;
  if (!applicationRouteTableId2) throw new Error('SubnetRouteTableAssociationが正しく定義されていません');

  const natGatewayIds = getResourceIds('AWS::EC2::NatGateway');

  template.hasResourceProperties('AWS::EC2::Route', {
    RouteTableId: { Ref: applicationRouteTableId1 },
    NatGatewayId: { Ref: natGatewayIds[0] },
  });
  template.hasResourceProperties('AWS::EC2::Route', {
    RouteTableId: { Ref: applicationRouteTableId2 },
    NatGatewayId: { Ref: natGatewayIds[1] },
  });
});

サブネットを分ける

サブネットを分けてみます。

  • パブリックサブネット (ALB用を想定)
  • NAT付のプライベートサブネット (アプリケーション用を想定)
  • NATなしのプライベートサブネット (RDS用を想定)

それぞれデフォルトでMulti-AZに配置されます。

import { Stack, StackProps } from 'aws-cdk-lib';
import { Vpc } from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';

export class MyFirstCdkAppStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const vpc = new Vpc(this, 'MyVpc1', {
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'Ingress',
          subnetType: SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: 'Application',
          subnetType: SubnetType.PRIVATE_WITH_NAT,
        },
        {
          cidrMask: 28,
          name: 'Rds',
          subnetType: SubnetType.PRIVATE_ISOLATED,
        },
      ]
    });
  }
}

VPCを作るときにサブネットマスクとサブネットのタイプを指定するだけで、あとはよしなに作ってくれるようです。
cdk synthコマンドの結果は割愛しますが、気になる各サブネットのCIDRブロックは次のようになっていました。
パブリックサブネット→10.0.0.0/24、10.0.1.0/24
プライベートサブネット(App)→10.0.2.0/24、10.0.3.0/24
プライベートサブネット(RDS)→10.0.4.0/28、10.0.4.16/28

デプロイ前の差分確認

cdk diffコマンドを使うと、現在デプロイされているテンプレートと、ローカルでsynthされたテンプレートの差分を見ることができます。
デプロイ前に修正内容が確認できるので非常に便利です。

また、cdk deployの際にも、センシティブな変更を伴う場合には以下のように警告を出してくれるので助かります。

This deployment will potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

~各種センシティブな変更内容~

Do you wish to deploy these changes (y/n)?

テストの実施

jestは設定された状態になっていますので npm run test だけで実行可能です。

$ npm run test

> my-first-cdk-app@0.1.0 test
> jest

 PASS  test/my-first-cdk-app.test.ts (11.626 s)
  ✓ VPCがcidr10.0.0.0/16で1つ作成されること (2 ms)
  ✓ VPCにインターネットゲートウェイが1つ作成されること (1 ms)
  ✓ サブネットが6つ作成されること
  ✓ ALB用のパブリックサブネットが/24で作成されること (2 ms)
  ✓ アプリケーション用のパブリックサブネットが/24で作成されること (1 ms)
  ✓ RDS用のパブリックサブネットが/28で作成されること (1 ms)
  ✓ NATゲートウェイが2つ作成されること
  ✓ ALB用のサブネット2つにそれぞれNATゲートウェイが設置されること (1 ms)
  ✓ EIPが2つ作成されること (1 ms)
  ✓ NATゲートウェイ2つにそれぞれEIPが作成されること (1 ms)
  ✓ ルートテーブルが6つ作成されること (1 ms)
  ✓ アプリケーション用のサブネット2つがそれぞれNATゲートウェイにルーティングされること (1 ms)

Test Suites: 1 passed, 1 total
Tests:       12 passed, 12 total
Snapshots:   0 total
Time:        11.891 s, estimated 12 s
Ran all test suites.

テスト名を日本語かつテストケースのような記述にしていた理由、おわかりいただけたでしょうか?
テスト結果がわかりやすく、どんな要件が満たされている/いないのか一目瞭然です。
命名に悩まれている方は是非真似してみてください。

リソースの破棄

$ cdk destroy
Are you sure you want to delete: MyFirstCdkAppStack (y/n)? y
MyFirstCdkAppStack: destroying...

  ✅  MyFirstCdkAppStack: destroyed

参考|https://docs.aws.amazon.com/cdk/v2/guide/hello_world.html#hello_world_tutorial_destroy

デプロイされたリソースはコマンド一つで破棄できます。
EIPのコストももったいないので、しばらく使うことがないようなら破棄しておきましょう。

AWS Construct Library

最後にAWS Construct Libraryについて少し触れておきます。

AWS Construct Libraryは、CDKが標準で提供しているconstructのライブラリです。
ライブラリには低レイヤのものから、L1 〜 L3の3種類があります。

L1 (CFn Resources)

AWSリソースと1対1にマッピングされたライブラリです。
CFnで定義するのと同じようなイメージで、リソースに対して1つ1つ設定を加えていきます。

今回はL2のConstructを使ってVPCを定義してみましたが、細かい設定が必要な場合はもしかするとこちらを使ったほうが良いのかも知れません。このあたりは未調査です。

disassemblerやFormer2のように、既存リソースからCDKのコードを生成してくれるツールもこのL1のConstructに変換してくれるようです。
www.npmjs.com
former2.com

L2 (Curated)

AWSのベストプラクティスをデフォルトとして定義されたライブラリです。
今回作成したVPCもこちらを使っています。
細かいパラメータの設定が省略可能となっており、コードを簡潔に記述することが可能です。もちろん指定することもできます。

さらにこのレイヤでは、リソースごとの便利なメソッド (例えばS3バケットでの grantRead(user)) が提供されています。
これによって、IAMポリシーを定義して、s3:GetObject*とs3:GetBucket*とs3:List*をアタッチして・・・のような、煩雑なIAMポリシーとAWSリソースのマッピングからも解放され、よりサクサクとコードを書くことができます。

L3 (Patterns)

特定のユースケースのために、複数のAWSリソースを組み合わせたパターンが提供されています。
以下のリンクを見ていただいた方が早いと思いますので説明は割愛します。
cdkpatterns.com

おわりに

熱い気持ちばかりが先行してとても長くなってしまいました。
ここまで読んでくださった方、ありがとうございます。

CDKを使ってみた所感としては、

  • Construct Libraryを使って書くことで、必須パラメータが何なのか?どういう仕様なのか?をWebサイトで確認する必要がなく、VS Code上ですべて完結するのはストレスフリー。
  • L2のライブラリを使うことで、細かいパラメータを設定しなくて済むのが楽。(もしかするとL1でないとできないことがあるのかも?)
  • cdk diffの安心感
  • cdk deploy/destroyの簡単さ
  • CFnを知らなくても書ける(ただしユニットテストを書くのにはCFnの知識が必要)

といった感じでしょうか。

AWSリソースに関する知識の再整理もできたように思います。
もしかするとマネジメントコンソールでGUIを恐る恐る触るよりも、こちらのほうが結果を確認しながら構成していける分学習には向いているかもしれません。

テストに関してはまだ正解がわからずにいます。
実際に使っていくとなると既存リソースの移行が多く発生しそうなので、ユニットテストというよりスナップショットテストの方が活躍しそうです。
そちらはまた別の機会に。



☆スタイル・エッジ・グループでは、一緒に働く仲間を募集しています☆
もし興味を持っていただけましたら、採用サイト↓も覗いてみてください!
recruit.styleedge-labo.co.jp

*1:2022年6月21日現在では、TypeScript、JavaScriptPythonJavaC#、Go (開発者プレビュー) が利用可能です。

*2:>=10.13.0。ただし13.0.0から13.6.0を除く

*3:2022年6月21日現在ではα版