実践!AWS CDK #31 EC2 Stack

題字・息子たち
2022.02.18

はじめに

スタック分割リファクタリングの第 3 弾。
EC2 スタックの実装です。

前回の記事はこちら。

実装

EC2 スタックでは以下 5 つのリソースを管理します。

  • セキュリティグループ
  • EC2 インスタンス
  • ターゲットグループ
  • ロードバランサー(ALB)
  • リスナー

追加するファイルは以下の通り。

├── lib
│   ├── resource
│   │   ├── instance.ts
│   │   ├── load-balancer.ts
│   │   ├── security-group.ts
│   │   └── target-group.ts
│   ├── script
│   │   └── ec2
│   │       └── userData.sh
│   └── stack
│       └── ec2-stack.ts
├── test
│   └── stack
│       └── ec2-stack.test.ts

リスナーのリソース生成処理を記述するファイルは load-balancer.ts とします。マネジメントコンソール上でリスナーを設定する画面はロードバランサーのページであるためです。ここもシンプルに画面の構成に従いましょう。

1

まずは EC2 スタッククラスの実装から。

lib/stack/ec2-stack.ts

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Instance } from '../resource/instance';
import { LoadBalancer } from '../resource/load-balancer';
import { SecurityGroup } from '../resource/security-group';
import { TargetGroup } from '../resource/target-group';
import { IamStack } from './iam-stack';
import { VpcStack } from './vpc-stack';

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

        // Security Group
        const securityGroup = new SecurityGroup(this, vpcStack.vpc);

        // Instance
        const instance = new Instance(this, vpcStack.subnet, iamStack.role, securityGroup);

        // Target Group
        const targetGroup = new TargetGroup(this, vpcStack.vpc, instance);

        // Load Balancer
        new LoadBalancer(this, securityGroup, vpcStack.subnet, targetGroup);
    }
}

各リソースの生成処理を記述します。
ここで new しているのは全て自前のリソースクラスとなります。

VPC や IAM など、上記のリソース生成に必要な情報は EC2 スタック生成時のパラメーターとして受け取るよう設計しました。各リソースクラスや L1 の CfnXXXX クラスのオブジェクトではなく、スタッククラスのオブジェクトを丸々渡してもらうことでパラメーターの増減に対応しやすくします。

以下が各リソースクラスの実装です。以前に比べて大きな変更はありません。

SecurityGroup クラス。

lib/resource/security-group.ts

import { CfnSecurityGroup, CfnSecurityGroupIngress, CfnSecurityGroupIngressProps } from "aws-cdk-lib/aws-ec2";
import { Construct } from "constructs";
import { BaseResource } from "./abstract/base-resouce";
import { Vpc } from "./vpc";

interface IngressInfo {
    readonly id: string;
    readonly securityGroupIngressProps: CfnSecurityGroupIngressProps;
    readonly groupId: () => string;
    readonly sourceSecurityGroupId?: () => string;
}

interface ResourceInfo {
    readonly id: string;
    readonly groupDescription: string;
    readonly ingresses: IngressInfo[];
    readonly resourceName: string;
    readonly assign: (securityGroup: CfnSecurityGroup) => void;
}

export class SecurityGroup extends BaseResource {
    public readonly alb: CfnSecurityGroup;
    public readonly ec2: CfnSecurityGroup;
    public readonly rds: CfnSecurityGroup;

    private readonly vpc: Vpc;
    private readonly resources: ResourceInfo[] = [
        {
            id: 'SecurityGroupAlb',
            groupDescription: 'for ALB',
            ingresses: [
                {
                    id: 'SecurityGroupIngressAlb1',
                    securityGroupIngressProps: {
                        ipProtocol: 'tcp',
                        cidrIp: '0.0.0.0/0',
                        fromPort: 80,
                        toPort: 80
                    },
                    groupId: () => this.alb.attrGroupId
                },
                {
                    id: 'SecurityGroupIngressAlb2',
                    securityGroupIngressProps: {
                        ipProtocol: 'tcp',
                        cidrIp: '0.0.0.0/0',
                        fromPort: 443,
                        toPort: 443
                    },
                    groupId: () => this.alb.attrGroupId
                }
            ],
            resourceName: 'sg-alb',
            assign: securityGroup => (this.alb as CfnSecurityGroup) = securityGroup
        },
        {
            id: 'SecurityGroupEc2',
            groupDescription: 'for EC2',
            ingresses: [
                {
                    id: 'SecurityGroupIngressEc21',
                    securityGroupIngressProps: {
                        ipProtocol: 'tcp',
                        fromPort: 80,
                        toPort: 80
                    },
                    groupId: () => this.ec2.attrGroupId,
                    sourceSecurityGroupId: () => this.alb.attrGroupId,
                }
            ],
            resourceName: 'sg-ec2',
            assign: securityGroup => (this.ec2 as CfnSecurityGroup) = securityGroup
        },
        {
            id: 'SecurityGroupRds',
            groupDescription: 'for RDS',
            ingresses: [
                {
                    id: 'SecurityGroupIngressRds1',
                    securityGroupIngressProps: {
                        ipProtocol: 'tcp',
                        fromPort: 3306,
                        toPort: 3306
                    },
                    groupId: () => this.rds.attrGroupId,
                    sourceSecurityGroupId: () => this.ec2.attrGroupId,
                }
            ],
            resourceName: 'sg-rds',
            assign: securityGroup => (this.rds as CfnSecurityGroup) = securityGroup
        }
    ];

    constructor(scope: Construct, vpc: Vpc) {
        super();

        this.vpc = vpc;

        for (const resourceInfo of this.resources) {
            const securityGroup = this.createSecurityGroup(scope, resourceInfo);
            resourceInfo.assign(securityGroup);

            this.createSecurityGroupIngress(scope, resourceInfo);
        }
    }

    private createSecurityGroup(scope: Construct, resourceInfo: ResourceInfo): CfnSecurityGroup {
        const resourceName = this.createResourceName(scope, resourceInfo.resourceName);
        const securityGroup = new CfnSecurityGroup(scope, resourceInfo.id, {
            groupDescription: resourceInfo.groupDescription,
            groupName: resourceName,
            vpcId: this.vpc.vpc.ref,
            tags: [{
                key: 'Name',
                value: resourceName
            }]
        });

        return securityGroup;
    }

    private createSecurityGroupIngress(scope: Construct, resourceInfo: ResourceInfo) {
        for (const ingress of resourceInfo.ingresses) {
            const securityGroupIngress = new CfnSecurityGroupIngress(scope, ingress.id, ingress.securityGroupIngressProps);
            securityGroupIngress.groupId = ingress.groupId();

            if (ingress.sourceSecurityGroupId) {
                securityGroupIngress.sourceSecurityGroupId = ingress.sourceSecurityGroupId();
            }
        }
    }
}

Instance クラス。
前は Ec2 というクラス名でした。

lib/resource/instance.ts

import * as fs from 'fs';
import { CfnInstance } from "aws-cdk-lib/aws-ec2";
import { Construct } from "constructs";
import { BaseResource } from "./abstract/base-resouce";
import { Role } from "./role";
import { SecurityGroup } from "./security-group";
import { Subnet } from "./subnet";

interface ResourceInfo {
    readonly id: string;
    readonly availabilityZone: string;
    readonly resourceName: string;
    readonly subnetId: () => string;
    readonly assign: (instance: CfnInstance) => void;
}

export class Instance extends BaseResource {
    public readonly instance1a: CfnInstance;
    public readonly instance1c: CfnInstance;

    private readonly subnet: Subnet;
    private readonly role: Role;
    private readonly securityGroup: SecurityGroup;
    private readonly resources: ResourceInfo[] = [
        {
            id: 'Ec2Instance1a',
            availabilityZone: 'ap-northeast-1a',
            resourceName: 'ec2-1a',
            subnetId: () => this.subnet.app1a.ref,
            assign: instance => (this.instance1a as CfnInstance) = instance
        },
        {
            id: 'Ec2Instance1c',
            availabilityZone: 'ap-northeast-1c',
            resourceName: 'ec2-1c',
            subnetId: () => this.subnet.app1c.ref,
            assign: instance => (this.instance1c as CfnInstance) = instance
        }
    ];

    constructor(
        scope: Construct,
        subnet: Subnet,
        role: Role,
        securityGroup: SecurityGroup
    ) {
        super();

        this.subnet = subnet;
        this.role = role;
        this.securityGroup = securityGroup;

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

    private createInstance(scope: Construct, resourceInfo: ResourceInfo): CfnInstance {
        const instance = new CfnInstance(scope, resourceInfo.id, {
            availabilityZone: resourceInfo.availabilityZone,
            iamInstanceProfile: this.role.instanceProfileEc2.ref,
            imageId: 'ami-08a8688fb7eacb171',
            instanceType: 't2.micro',
            securityGroupIds: [this.securityGroup.ec2.attrGroupId],
            subnetId: resourceInfo.subnetId(),
            tags: [{
                key: 'Name',
                value: this.createResourceName(scope, resourceInfo.resourceName)
            }],
            userData: fs.readFileSync(`${__dirname}/../script/ec2/userData.sh`, 'base64')
        });

        const keyName = scope.node.tryGetContext('keyName');
        if (keyName) {
            instance.keyName = keyName;
        }

        return instance;
    }
}

TargetGroup クラス。

lib/resource/target-group.ts

import { CfnTargetGroup } from "aws-cdk-lib/aws-elasticloadbalancingv2";
import { Construct } from "constructs";
import { BaseResource } from "./abstract/base-resouce";
import { Instance } from "./instance";
import { Vpc } from "./vpc";

export class TargetGroup extends BaseResource {
    public readonly tg: CfnTargetGroup;

    constructor(scope: Construct, vpc: Vpc, instance: Instance) {
        super();

        this.tg = new CfnTargetGroup(scope, 'AlbTargetGroup', {
            name: this.createResourceName(scope, 'tg'),
            port: 80,
            protocol: 'HTTP',
            targetType: 'instance',
            targets: [
                {
                    id: instance.instance1a.ref
                },
                {
                    id: instance.instance1c.ref
                }
            ],
            vpcId: vpc.vpc.ref
        });
    }
}

LoadBalancer クラス。
前は Alb というクラス名でした。

lib/resource/load-balancer.ts

import { CfnListener, CfnLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
import { Construct } from "constructs";
import { BaseResource } from "./abstract/base-resouce";
import { SecurityGroup } from "./security-group";
import { Subnet } from "./subnet";
import { TargetGroup } from "./target-group";

export class LoadBalancer extends BaseResource {
    public readonly alb: CfnLoadBalancer;

    constructor(
        scope: Construct,
        securityGroup: SecurityGroup,
        subnet: Subnet,
        targetGroup: TargetGroup
    ) {
        super();

        // Load Balancer
        this.alb = new CfnLoadBalancer(scope, 'Alb', {
            ipAddressType: 'ipv4',
            name: this.createResourceName(scope, 'alb'),
            scheme: 'internet-facing',
            securityGroups: [securityGroup.alb.attrGroupId],
            subnets: [subnet.public1a.ref, subnet.public1c.ref],
            type: 'application'
        });

        // Listener
        new CfnListener(scope, 'AlbListener', {
            defaultActions: [{
                type: 'forward',
                forwardConfig: {
                    targetGroups: [{
                        targetGroupArn: targetGroup.tg.ref,
                        weight: 1
                    }]
                }
            }],
            loadBalancerArn: this.alb.ref,
            port: 80,
            protocol: 'HTTP'
        });
    }
}

メインのスタッククラスの実装はこちら。

lib/devio-stack.ts

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

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

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

    // IAM Stack
    const iamStack = new IamStack(scope, 'IamStack', {
      stackName: this.createStackName(scope, 'iam')
    });

    // EC2 Stack
    new Ec2Stack(scope, 'Ec2Stack', vpcStack, iamStack, {
      stackName: this.createStackName(scope, 'ec2')
    });
  }

  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}`;
  }
}

ハイライト部分を追記しています。

テスト

EC2 スタックの作成には VPC スタックと IAM スタックも必要なため、それぞれ生成します。(ここでのチェックは EC2 スタックの中身だけ)

test/stack/ec2-stack.test.ts

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

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

    // Security Group
    template.resourceCountIs('AWS::EC2::SecurityGroup', 3);
    template.hasResourceProperties('AWS::EC2::SecurityGroup', {
        GroupDescription: 'for ALB',
        GroupName: 'devio-stg-sg-alb',
        VpcId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-sg-alb' }]
    });
    template.hasResourceProperties('AWS::EC2::SecurityGroup', {
        GroupDescription: 'for EC2',
        GroupName: 'devio-stg-sg-ec2',
        VpcId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-sg-ec2' }]
    });
    template.hasResourceProperties('AWS::EC2::SecurityGroup', {
        GroupDescription: 'for RDS',
        GroupName: 'devio-stg-sg-rds',
        VpcId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-sg-rds' }]
    });
    template.resourceCountIs('AWS::EC2::SecurityGroupIngress', 4);
    template.hasResourceProperties('AWS::EC2::SecurityGroupIngress', {
        IpProtocol: 'tcp',
        CidrIp: '0.0.0.0/0',
        FromPort: 80,
        ToPort: 80,
        GroupId: Match.anyValue()
    });
    template.hasResourceProperties('AWS::EC2::SecurityGroupIngress', {
        IpProtocol: 'tcp',
        CidrIp: '0.0.0.0/0',
        FromPort: 443,
        ToPort: 443,
        GroupId: Match.anyValue()
    });
    template.hasResourceProperties('AWS::EC2::SecurityGroupIngress', {
        IpProtocol: 'tcp',
        FromPort: 80,
        ToPort: 80,
        GroupId: Match.anyValue(),
        SourceSecurityGroupId: Match.anyValue()
    });
    template.hasResourceProperties('AWS::EC2::SecurityGroupIngress', {
        IpProtocol: 'tcp',
        FromPort: 3306,
        ToPort: 3306,
        GroupId: Match.anyValue(),
        SourceSecurityGroupId: Match.anyValue()
    });

    // Instance
    template.resourceCountIs('AWS::EC2::Instance', 2);
    template.hasResourceProperties('AWS::EC2::Instance', {
        AvailabilityZone: 'ap-northeast-1a',
        IamInstanceProfile: Match.anyValue(),
        ImageId: 'ami-08a8688fb7eacb171',
        InstanceType: 't2.micro',
        SecurityGroupIds: Match.anyValue(),
        SubnetId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-ec2-1a' }],
        UserData: Match.anyValue()
    });
    template.hasResourceProperties('AWS::EC2::Instance', {
        AvailabilityZone: 'ap-northeast-1c',
        IamInstanceProfile: Match.anyValue(),
        ImageId: 'ami-08a8688fb7eacb171',
        InstanceType: 't2.micro',
        SecurityGroupIds: Match.anyValue(),
        SubnetId: Match.anyValue(),
        Tags: [{ Key: 'Name', Value: 'devio-stg-ec2-1c' }],
        UserData: Match.anyValue()
    });

    // Target Group
    template.resourceCountIs('AWS::ElasticLoadBalancingV2::TargetGroup', 1);
    template.hasResourceProperties('AWS::ElasticLoadBalancingV2::TargetGroup', {
        Name: 'devio-stg-tg',
        Port: 80,
        Protocol: 'HTTP',
        TargetType: 'instance',
        Targets: Match.anyValue(),
        VpcId: Match.anyValue()
    });

    // Load Balancer
    template.resourceCountIs('AWS::ElasticLoadBalancingV2::LoadBalancer', 1);
    template.hasResourceProperties('AWS::ElasticLoadBalancingV2::LoadBalancer', {
        IpAddressType: 'ipv4',
        Name: 'devio-stg-alb',
        Scheme: 'internet-facing',
        SecurityGroups: Match.anyValue(),
        Subnets: Match.anyValue(),
        Type: 'application'
    });

    // Listener
    template.resourceCountIs('AWS::ElasticLoadBalancingV2::Listener', 1);
    template.hasResourceProperties('AWS::ElasticLoadBalancingV2::Listener', {
        DefaultActions: [{
            Type: 'forward',
            ForwardConfig: {
                TargetGroups: [{
                    TargetGroupArn: Match.anyValue(),
                    Weight: 1
                }]
            }
        }],
        LoadBalancerArn: Match.anyValue(),
        Port: 80,
        Protocol: 'HTTP'
    });
});

GitHub

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

おわりに

今回の対応でスタック間でのパラメーター受け渡し方法やファイル分割の方針が明確になってきました。
できるだけシンプルかつメンテナンスしやすいよう今後も続けていきたいと思います。