実践!AWS CDK #28 VPC Stack

題字・息子たち
2021.12.17

はじめに

今回からはこれまで作成してきたリソースを複数のスタックに分割し、リファクタリングしていきたいと思います。
まずは VPC 関連のリソースをまとめていきましょう。

前回の記事はこちら。

方針説明

現在の構成図はこちらです。

1

これまでザックリ 16 のリソースを作成してきました。それらのリソースを次のように 5 つのスタックに分割していきます。

  • VPC スタック
    • VPC
    • サブネット
    • インターネットゲートウェイ
    • Elastic IP
    • NAT ゲートウェイ
    • ルートテーブル
    • ネットワーク ACL
  • IAM スタック
    • IAM ロール
  • EC2 スタック
    • セキュリティグループ
    • EC2
    • ALB
  • Secrets Manager スタック
    • Secrets Manager
  • RDS スタック
    • DB サブネットグループ
    • DB パラメータグループ
    • DB クラスター
    • DB インスタンス

分割の基準は マネジメントコンソールのサービス画面単位 です。

サービス画面を開いた時に左側に表示されるリソースすべてをそのサービス用のスタックで管理していきます。

2

なお Elastic IP やセキュリティグループは VPC と EC2 の両画面で存在しますが、そのようなリソースの場合は私の好みで選別することにしました。(Elastic IP は VPC スタック、セキュリティグループは EC2 スタックで管理)

3

色々考え実践した結果、この方針に落ち着きました。

また、今後の実装作業は以下の前提で進めていきます。

  • 既存の実装はすべて削除した上で作り直す
  • これまで作成してきたリソースとの互換性は担保しない

AWS の構成は大きく変更しませんが、リソースの論理 ID やリソース名は多少変わるかもしれません。
あしからず!

実装

CDK プロジェクトのディレクトリ構成は次のようにしました。

├── lib
│   ├── devio-stack.ts
│   ├── resource
│   │   ├── abstract
│   │   │   └── base-resource.ts
│   │   ├── subnet.ts
│   │   └── vpc.ts
│   └── stack
│       └── vpc-stack.ts
└── test
    └── stack
        └── vpc-stack.test.ts
  • devio-stack.ts
    • メインのスタッククラス
    • 各スタックの生成処理を記述する
  • vpc-stack.ts
    • VPC 関連のリソースを管理するスタッククラス
    • 今回実装する部分
  • base-resource.ts
    • リソースの抽象クラス
    • すべてのリソースクラスはこのクラスを継承する
  • vpc.ts, subnet.ts
    • リソースクラス
    • 各リソースに関する生成処理を記述する
  • vpc-stack.test.ts
    • テストコードを記述する
    • スタック単位で書く(以前はリソース単位)

まずはメインのスタッククラスの実装です。

lib/devio-stack.ts

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { VpcStack } from './stack/vpc-stack';

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

    // VPC Stack
    new VpcStack(scope, 'VpcStack', {
      stackName: this.createStackName(scope, 'vpc')
    });
  }

  private createStackName(scope: Construct, originalName: string): string {
    const systemName = scope.node.tryGetContext('systemName');
    const envType = scope.node.tryGetContext('envType');
    const stackNamePrefix = `${systemName}-${envType}-stack-`;

    return `${stackNamePrefix}${originalName}`;
  }
}

VPC スタックを生成するだけです。
スタック名を作成するためのメソッド createStackName() を実装しています。スタック生成時に stackName を指定することにより任意の名前を付けることができます。スタック名が変わると別のスタックと認識されるため、環境名などを含めることで同一アカウントで複数環境のスタックを管理できます。

次が VPC スタッククラスの実装です。

lib/stack/vpc-stack.ts

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { ElasticIp } from '../resource/elastic-ip';
import { InternetGateway } from '../resource/internet-gateway';
import { NatGateway } from '../resource/nat-gateway';
import { NetworkAcl } from '../resource/network-acl';
import { RouteTable } from '../resource/route-table';
import { Subnet } from '../resource/subnet';
import { Vpc } from '../resource/vpc';

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

        // VPC
        const vpc = new Vpc(this);

        // Subnet
        const subnet = new Subnet(this, vpc);

        // Internet Gateway
        const internetGateway = new InternetGateway(this, vpc);

        // Elastic IP
        const elasticIp = new ElasticIp(this);

        // NAT Gateway
        const natGateway = new NatGateway(this, subnet, elasticIp);

        // Route Table
        new RouteTable(this, vpc, subnet, internetGateway, natGateway);

        // Network ACL
        new NetworkAcl(this, vpc, subnet);
    }
}

自前のリソースクラスの生成処理を行っています。以前はリソースクラスにパラメーターを渡す場合は CfnXXXX クラスのオブジェクトを渡していましたが、自前のリソースクラスのオブジェクトを渡すように変更しました。記述量を減らす & リソースの増減に対応しやすくするためです。

次がリソースの抽象クラスの実装です。

lib/resource/abstract/base-resource.ts

import { Construct } from 'constructs';

export abstract class BaseResource {
    constructor() { }

    protected createResourceName(scope: Construct, originalName: string): string {
        const systemName = scope.node.tryGetContext('systemName');
        const envType = scope.node.tryGetContext('envType');
        const resourceNamePrefix = `${systemName}-${envType}-`;

        return `${resourceNamePrefix}${originalName}`;
    }
}

以前と同様、リソース名を生成するためのメソッドを定義しています。前は createResources() という抽象メソッドを記述していたのですが、あまりメリットを感じなかったので削除しました。CDK の標準処理(?)である コンストラクタ内でリソースを生成する 方式に切り替えます。

次が VPC リソースクラスの実装です。

lib/resource/vpc.ts

import { CfnVPC } from "aws-cdk-lib/aws-ec2";
import { Construct } from "constructs";
import { BaseResource } from "./abstract/base-resource";

export class Vpc extends BaseResource {
    public readonly vpc: CfnVPC;

    constructor(scope: Construct) {
        super();

        this.vpc = new CfnVPC(scope, 'Vpc', {
            cidrBlock: '10.0.0.0/16',
            tags: [{
                key: 'Name',
                value: this.createResourceName(scope, 'vpc')
            }]
        });
    }
}

大きな変更はありませんが createResources() メソッドを廃止し、コンストラクタ内でリソースを生成することにより他のクラスから参照するための public 変数を readonly にすることができました。(コンストラクタ内では readonly プロパティに代入可能)
これで参照する側で誤って値が代入されることがなくなるため、少し安心できます。

もう一つ、複数リソースを生成する場合の例として NAT ゲートウェイの実装を見てみましょう。

lib/resource/nat-gateway.ts

import { Construct } from 'constructs';
import { CfnNatGateway } from 'aws-cdk-lib/aws-ec2';
import { BaseResource } from './abstract/base-resource';
import { Subnet } from './subnet';
import { ElasticIp } from './elastic-ip';

interface ResourceInfo {
    readonly id: string;
    readonly resourceName: string;
    readonly allocationId: () => string;
    readonly subnetId: () => string;
    readonly assign: (natGateway: CfnNatGateway) => void;
}

export class NatGateway extends BaseResource {
    public readonly ngw1a: CfnNatGateway;
    public readonly ngw1c: CfnNatGateway;

    private readonly subnet: Subnet;
    private readonly elasticIp: ElasticIp;
    private readonly resources: ResourceInfo[] = [
        {
            id: 'NatGateway1a',
            resourceName: 'ngw-1a',
            allocationId: () => this.elasticIp.ngw1a.attrAllocationId,
            subnetId: () => this.subnet.public1a.ref,
            assign: natGateway => (this.ngw1a as CfnNatGateway) = natGateway
        },
        {
            id: 'NatGateway1c',
            resourceName: 'ngw-1c',
            allocationId: () => this.elasticIp.ngw1c.attrAllocationId,
            subnetId: () => this.subnet.public1c.ref,
            assign: natGateway => (this.ngw1c as CfnNatGateway) = natGateway
        }
    ];

    constructor(scope: Construct, subnet: Subnet, elasticIp: ElasticIp) {
        super();

        this.subnet = subnet;
        this.elasticIp = elasticIp;

        for (const resourceInfo of this.resources) {
            const natGateway = this.createNatGateway(scope, resourceInfo);
            resourceInfo.assign(natGateway);
        }
    }

    private createNatGateway(scope: Construct, resourceInfo: ResourceInfo): CfnNatGateway {
        const natGateway = new CfnNatGateway(scope, resourceInfo.id, {
            allocationId: resourceInfo.allocationId(),
            subnetId: resourceInfo.subnetId(),
            tags: [{
                key: 'Name',
                value: this.createResourceName(scope, resourceInfo.resourceName)
            }]
        });

        return natGateway;
    }
}

コンストラクタの引数に自前のリソースクラスのオブジェクト(ここではサブネットと Elastic IP)を受け取っています。必要になった時にそのオブジェクトのプロパティにアクセスし値を参照しています。

また assign() メソッドでは readonly プロパティに値を代入するため、無理やりキャストしています。
処理自体はコンストラクタで実施しているのですが、記述場所がコンストラクタでないためキャストしないとエラーになります。あまりいい書き方ではありませんが、ここらへんはテストコードなどで担保するということで。

以上がスタッククラスとそれに関連するリソースクラスの実装でした。
説明は省略しましたが VPC に関するその他のクラスの実装も行っています。

テスト

少し長いですが全部貼ります。

test/stack/vpc-stack.test.ts

import { App } from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { VpcStack } from '../../lib/stack/vpc-stack';

test('Vpc Stack', () => {
    const app = new App({
        context: {
            'systemName': 'devio',
            'envType': 'stg'
        }
    });
    const vpcStack = new VpcStack(app, 'VpcStack');
    const template = Template.fromStack(vpcStack);

    // VPC
    template.resourceCountIs('AWS::EC2::VPC', 1);
    template.hasResourceProperties('AWS::EC2::VPC', {
        CidrBlock: '10.0.0.0/16',
        Tags: [{ Key: 'Name', Value: 'devio-stg-vpc' }]
    });

    // Subnet
    template.resourceCountIs('AWS::EC2::Subnet', 6);
    template.hasResourceProperties('AWS::EC2::Subnet', {
        CidrBlock: '10.0.11.0/24',
        VpcId: Match.anyValue(),
        AvailabilityZone: 'ap-northeast-1a',
        Tags: [{ Key: 'Name', Value: 'devio-stg-subnet-public-1a' }]
    });
    template.hasResourceProperties('AWS::EC2::Subnet', {
        CidrBlock: '10.0.12.0/24',
        VpcId: Match.anyValue(),
        AvailabilityZone: 'ap-northeast-1c',
        Tags: [{ Key: 'Name', Value: 'devio-stg-subnet-public-1c' }]
    });
    template.hasResourceProperties('AWS::EC2::Subnet', {
        CidrBlock: '10.0.21.0/24',
        VpcId: Match.anyValue(),
        AvailabilityZone: 'ap-northeast-1a',
        Tags: [{ Key: 'Name', Value: 'devio-stg-subnet-app-1a' }]
    });
    template.hasResourceProperties('AWS::EC2::Subnet', {
        CidrBlock: '10.0.22.0/24',
        VpcId: Match.anyValue(),
        AvailabilityZone: 'ap-northeast-1c',
        Tags: [{ Key: 'Name', Value: 'devio-stg-subnet-app-1c' }]
    });
    template.hasResourceProperties('AWS::EC2::Subnet', {
        CidrBlock: '10.0.31.0/24',
        VpcId: Match.anyValue(),
        AvailabilityZone: 'ap-northeast-1a',
        Tags: [{ Key: 'Name', Value: 'devio-stg-subnet-db-1a' }]
    });
    template.hasResourceProperties('AWS::EC2::Subnet', {
        CidrBlock: '10.0.32.0/24',
        VpcId: Match.anyValue(),
        AvailabilityZone: 'ap-northeast-1c',
        Tags: [{ Key: 'Name', Value: 'devio-stg-subnet-db-1c' }]
    });

    // Internet Gateway
    template.resourceCountIs('AWS::EC2::InternetGateway', 1);
    template.hasResourceProperties('AWS::EC2::InternetGateway', {
        Tags: [{ Key: 'Name', Value: 'devio-stg-igw' }]
    });
    template.resourceCountIs('AWS::EC2::VPCGatewayAttachment', 1);
    template.hasResourceProperties('AWS::EC2::VPCGatewayAttachment', {
        VpcId: Match.anyValue(),
        InternetGatewayId: Match.anyValue()
    });

    // Elastic IP
    template.resourceCountIs('AWS::EC2::EIP', 2);
    template.hasResourceProperties('AWS::EC2::EIP', {
        Domain: 'vpc',
        Tags: [{ Key: 'Name', Value: 'devio-stg-eip-ngw-1a' }]
    });
    template.hasResourceProperties('AWS::EC2::EIP', {
        Domain: 'vpc',
        Tags: [{ Key: 'Name', Value: 'devio-stg-eip-ngw-1c' }]
    });

    // NAT Gateway
    template.resourceCountIs('AWS::EC2::NatGateway', 2);
    template.hasResourceProperties('AWS::EC2::NatGateway', {
        AllocationId: Match.anyValue(),
        SubnetId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-ngw-1a' }]
    });
    template.hasResourceProperties('AWS::EC2::NatGateway', {
        AllocationId: Match.anyValue(),
        SubnetId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-ngw-1c' }]
    });

    // Route Table
    template.resourceCountIs('AWS::EC2::RouteTable', 4);
    template.hasResourceProperties('AWS::EC2::RouteTable', {
        VpcId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-rtb-public' }]
    });
    template.hasResourceProperties('AWS::EC2::RouteTable', {
        VpcId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-rtb-app-1a' }]
    });
    template.hasResourceProperties('AWS::EC2::RouteTable', {
        VpcId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-rtb-app-1c' }]
    });
    template.hasResourceProperties('AWS::EC2::RouteTable', {
        VpcId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-rtb-db' }]
    });
    template.resourceCountIs('AWS::EC2::Route', 3);
    template.hasResourceProperties('AWS::EC2::Route', {
        RouteTableId: Match.anyValue(),
        DestinationCidrBlock: '0.0.0.0/0',
        GatewayId: Match.anyValue()
    });
    template.hasResourceProperties('AWS::EC2::Route', {
        RouteTableId: Match.anyValue(),
        DestinationCidrBlock: '0.0.0.0/0',
        NatGatewayId: Match.anyValue()
    });
    template.resourceCountIs('AWS::EC2::SubnetRouteTableAssociation', 6);
    template.hasResourceProperties('AWS::EC2::SubnetRouteTableAssociation', {
        RouteTableId: Match.anyValue(),
        SubnetId: Match.anyValue()
    });

    // Network ACL
    template.resourceCountIs('AWS::EC2::NetworkAcl', 3);
    template.hasResourceProperties('AWS::EC2::NetworkAcl', {
        VpcId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-nacl-public' }]
    });
    template.hasResourceProperties('AWS::EC2::NetworkAcl', {
        VpcId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-nacl-app' }]
    });
    template.hasResourceProperties('AWS::EC2::NetworkAcl', {
        VpcId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-nacl-db' }]
    });
    template.resourceCountIs('AWS::EC2::NetworkAclEntry', 6);
    template.hasResourceProperties('AWS::EC2::NetworkAclEntry', {
        NetworkAclId: Match.anyValue(),
        Protocol: -1,
        RuleAction: 'allow',
        RuleNumber: 100,
        CidrBlock: '0.0.0.0/0',
        Egress: false
    });
    template.hasResourceProperties('AWS::EC2::NetworkAclEntry', {
        NetworkAclId: Match.anyValue(),
        Protocol: -1,
        RuleAction: 'allow',
        RuleNumber: 100,
        CidrBlock: '0.0.0.0/0',
        Egress: true
    });
    template.resourceCountIs('AWS::EC2::SubnetNetworkAclAssociation', 6);
    template.hasResourceProperties('AWS::EC2::SubnetNetworkAclAssociation', {
        NetworkAclId: Match.anyValue(),
        SubnetId: Match.anyValue()
    });
});

VPC のスタッククラスで管理するすべてのリソースに関してテストコードを記述しています。長くはなってしまいますが、リソースごとにファイルを作成するという手間を省きます。

やっていることは従来通りの リソース数とプロパティのチェック です。
また App クラスのインスタンス生成時に Context の systemNameenvType を明示的に指定しています。これにより、これまでリソース名が undefined と曖昧になっていた部分をきちんと確認するようにしました。

実行してテストが通ることを確認します。

$ tsc && npm test

> devio@0.1.0 test
> jest

 PASS  test/stack/vpc-stack.test.ts (10.166 s)
  ✓ Vpc Stack (87 ms)

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

デプロイ

デプロイは以下のいずれかのコマンドで実行します。

$ cdk deploy --all

or

$ cdk deploy VpcStack

対象のスタックを確認したい時は cdk ls コマンドで確認しましょう。

$ cdk ls

DevioStack
VpcStack

GitHub

今回のソースコードは コチラ です。

おわりに

スタック分割の基準を マネジメントコンソールのサービス画面単位 とすることで複雑さをなくしました。残り 4 つのスタックについても本シリーズで実装していきます。

リンク