実践!AWS CDK #31 EC2 Stack
はじめに
スタック分割リファクタリングの第 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
とします。マネジメントコンソール上でリスナーを設定する画面はロードバランサーのページであるためです。ここもシンプルに画面の構成に従いましょう。
まずは EC2 スタッククラスの実装から。
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 クラス。
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
というクラス名でした。
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 クラス。
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
というクラス名でした。
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' }); } }
メインのスタッククラスの実装はこちら。
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 スタックの中身だけ)
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
今回のソースコードは コチラ です。
おわりに
今回の対応でスタック間でのパラメーター受け渡し方法やファイル分割の方針が明確になってきました。
できるだけシンプルかつメンテナンスしやすいよう今後も続けていきたいと思います。