はじめに
_s__o_ です。
便利な CloudFormatin ですが、あれもこれもと記載しているうち、コード量がついつい多くなってしまいます。そこで便利なのが スタックのネスト機能 。スタックを入れ子にすることで、スタック (コード) を分割することができます。たとえば、子スタックとして VPC 用スタック & EC2 用スタックを、親スタックとしてラッパースタックを準備します。この場合、ラッパースタックだけをキックすれば、VPC 用スタックも EC2 用スタックも実行されます。
今回は、このネスト機能を、色々考えながら使ってみようと思います。
スタック分割の考え方
まず初めにスタック分割の考え方、つまりはスタックの設計について少し検討してみたいと思います。単純にコードを分割するだけもいいと思いますが、折角なのでスタック内の要素の特性なども考慮しながら、良さげな分割方法を考えてみたいと思います。
分割対象の要素
とりあえず下記とします。① AWS 環境を構築する ② Client VPN Endpoint によるアクセスを提供する ③ AWS 環境からインターネットへの通信を許可する……だけの、シンプルなオブジェクト群です。
- AWS::EC2::VPC
- AWS::EC2::Subnet
- AWS::EC2::SecurityGroup
- AWS::EC2::NetworkInterface
- AWS::EC2::InternetGateway
- AWS::EC2::VPCGatewayAttachment
- AWS::EC2::EIP
- AWS::EC2::NatGateway
- AWS::EC2::RouteTable
- AWS::EC2::Route
- AWS::EC2::SubnetRouteTableAssociation
- AWS::EC2::Instance
- AWS::EC2::ClientVpnEndpoint
- AWS::EC2::ClientVpnTargetNetworkAssociation
- AWS::EC2::ClientVpnAuthorizationRule
分割の方針
試行錯誤の結果、下記 3 点の方針としました。
- 参照可能性のあるオブジェクトは親スタックで作成する (例 : VPC、サブネットなど)
- 参照可能性のないオブジェクトは子スタックで作成する (例 : ルートテーブルなど)
- 子スタックは、さらにオブジェクト種類で分割する (例 : EC2 用、ルートテーブル用など)
分割結果
上記の方針に基づいて仕分けた結果が下記となります。必ずしも下記が最適ではないですが、個人的には一番しっくりきました。
コード
コードを記載するにあたっては こちらの記事 を参考にさせていただきました。非常によくまとまっており、とてもわかりやすい記事です。
親スタック
親スタックです。このコードを CloudFormation にアップロードして、実行します。1 環境をまるっと作成するので、指定するパラメータは非常に多くなっています。
AWSTemplateFormatVersion: 2010-09-09
# ------------------------------------------------------------#
# Parameter
# ------------------------------------------------------------#
Parameters:
000001templateRouteURL:
Type: String
Description: "Specify the URL where the route cfTemplate is placed."
Default: "https://s3-ap-northeast-1.amazonaws.com/xxxxx/route.yml"
000002templateCvpnURL:
Type: String
Description: "Specify the URL where the Client VPN Endpoint cfTemplate is placed."
Default: "https://s3-ap-northeast-1.amazonaws.com/xxxxx/cvpn.yml"
000003templateEc2URL:
Type: String
Description: "Specify the URL where the EC2 (AWS) cfTemplate is placed."
Default: "https://s3-ap-northeast-1.amazonaws.com/xxxxx/ec2.yml"
000101vpcName:
Type: String
Description: "Specify a VPC name."
Default: "test-vpc"
000102vpcCIDR:
Type: String
Description: "Specify a VPC CIDR."
Default: "192.168.100.0/24"
000201nwPubCIDR:
Type: String
Description: "Specify a Public NW CIDR."
Default: "192.168.100.0/26"
000202nwPriv1CIDR:
Type: String
Description: "Specify a Private 1 NW CIDR."
Default: "192.168.100.128/26"
000203nwPriv2CIDR:
Type: String
Description: "Specify a Private 2 NW CIDR."
Default: "192.168.100.192/26"
000302eniApIP:
Type: String
Description: "Specify a AP server IP address."
Default: "192.168.100.136"
000303eniDbIP:
Type: String
Description: "Specify a DB server IP address."
Default: "192.168.100.137"
020201cvpnCIDR:
Type: String
Description: "Specify a Cline VPN user CIDR."
Default: "100.64.0.0/22"
020202cvpnCertARN:
Type: String
Description: "Specify a cert ARN for Cline VPN."
Default: "xxxxx"
020203cvpnDnsIP:
Type: String
Description: "Specify a DNS IP which provided for user."
Default: "192.168.100.2"
030101AmiName:
Type: String
Description: "Specify a AMI name."
Default: "Win2019Std20200610"
AllowedValues:
- "Win2019Std20200610"
- "amazonlinux2"
030102InstanceType:
Type: String
Description: "Specify a instance type."
Default: "t3.small"
AllowedValues:
- "t3.nano"
- "t3.small"
030103KeyName:
Type: String
Description: "Specify a private key name."
Default: "kp-xxxxx"
# ------------------------------------------------------------#
# Mappings
# ------------------------------------------------------------#
Mappings:
EC2:
AMI:
Win2019Std20200610: "ami-06f39b98503ed7f4e"
amazonlinux2: "ami-0a1c2ec61571737db"
# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------#
Resources:
# ------------------------------------------------------------#
# AWS::EC2::VPC
# ------------------------------------------------------------#
vpc:
Type: "AWS::EC2::VPC"
Properties:
CidrBlock: !Ref 000102vpcCIDR
EnableDnsSupport: "true"
EnableDnsHostnames: "true"
InstanceTenancy: default
Tags:
- Key: Name
Value: !Ref 000101vpcName
# ------------------------------------------------------------#
# AWS::EC2::Subnet
# ------------------------------------------------------------#
nwPublic1:
Type: "AWS::EC2::Subnet"
DependsOn:
- vpc
Properties:
VpcId: !Ref vpc
CidrBlock: !Ref 000201nwPubCIDR
MapPublicIpOnLaunch: 'true'
AvailabilityZone: "ap-northeast-1a"
Tags:
- Key: Name
Value: !Sub "nw-${000101vpcName}-public-a"
nwPrivate1:
Type: "AWS::EC2::Subnet"
DependsOn:
- vpc
Properties:
VpcId: !Ref vpc
CidrBlock: !Ref 000202nwPriv1CIDR
MapPublicIpOnLaunch: 'false'
AvailabilityZone: "ap-northeast-1a"
Tags:
- Key: Name
Value: !Sub "nw-${000101vpcName}-private-a"
nwPrivate2:
Type: "AWS::EC2::Subnet"
DependsOn:
- vpc
Properties:
VpcId: !Ref vpc
CidrBlock: !Ref 000203nwPriv2CIDR
MapPublicIpOnLaunch: 'false'
AvailabilityZone: "ap-northeast-1c"
Tags:
- Key: Name
Value: !Sub "nw-${000101vpcName}-private-c"
# ------------------------------------------------------------#
# AWS::EC2::SecurityGroup
# ------------------------------------------------------------#
# See the URL below for the reason for using "GetAtt".
# https://dev.classmethod.jp/articles/careful-when-using-security-group-with-cloud-formation/
sgCvpn01:
Type: "AWS::EC2::SecurityGroup"
DependsOn:
- vpc
Properties:
GroupName: !Sub "sgr-${000101vpcName}-cvpn01"
GroupDescription: !Sub "sgr-${000101vpcName}-cvpn01"
VpcId: !Ref vpc
Tags:
- Key: Name
Value: !Sub "sgr-${000101vpcName}-cvpn01"
sgAp01:
Type: "AWS::EC2::SecurityGroup"
DependsOn:
- vpc
- sgCvpn01
Properties:
GroupName: !Sub "sgr-${000101vpcName}-ap01"
GroupDescription: !Sub "sgr-${000101vpcName}-ap01"
VpcId: !Ref vpc
SecurityGroupIngress:
- IpProtocol: "-1"
SourceSecurityGroupId: !GetAtt sgCvpn01.GroupId
Tags:
- Key: Name
Value: !Sub "sgr-${000101vpcName}-ap01"
sgDb01:
Type: "AWS::EC2::SecurityGroup"
DependsOn:
- vpc
- sgCvpn01
Properties:
GroupName: !Sub "sgr-${000101vpcName}-db01"
GroupDescription: !Sub "sgr-${000101vpcName}-db01"
VpcId: !Ref vpc
SecurityGroupIngress:
- IpProtocol: "-1"
SourceSecurityGroupId: !GetAtt sgCvpn01.GroupId
Tags:
- Key: Name
Value: !Sub "sgr-${000101vpcName}-db01"
# ------------------------------------------------------------#
# AWS::EC2::SecurityGroupIngress
# ------------------------------------------------------------#
# "AWS::EC2::SecurityGroupIngress" is used to avoid circular dependency.
sgAp01In:
Type: "AWS::EC2::SecurityGroupIngress"
DependsOn:
- sgAp01
- sgDb01
Properties:
GroupId: !GetAtt sgAp01.GroupId
IpProtocol: "-1"
SourceSecurityGroupId: !GetAtt sgDb01.GroupId
sgDb01In:
Type: "AWS::EC2::SecurityGroupIngress"
DependsOn:
- sgAp01
- sgDb01
Properties:
GroupId: !GetAtt sgDb01.GroupId
IpProtocol: "-1"
SourceSecurityGroupId: !GetAtt sgAp01.GroupId
# ------------------------------------------------------------#
# AWS::EC2::NetworkInterface
# ------------------------------------------------------------#
eniAp01:
Type: "AWS::EC2::NetworkInterface"
DependsOn:
- sgAp01
- nwPrivate1
Properties:
SourceDestCheck: "true"
GroupSet:
- !GetAtt sgAp01.GroupId
SubnetId: !Ref nwPrivate1
PrivateIpAddress: !Ref 000302eniApIP
Tags:
- Key: Name
Value: !Sub "eni-${000101vpcName}-ap01"
eniDb01:
Type: "AWS::EC2::NetworkInterface"
DependsOn:
- sgDb01
- nwPrivate1
Properties:
SourceDestCheck: "true"
GroupSet:
- !GetAtt sgDb01.GroupId
SubnetId: !Ref nwPrivate1
PrivateIpAddress: !Ref 000303eniDbIP
Tags:
- Key: Name
Value: !Sub "eni-${000101vpcName}-db01"
# ------------------------------------------------------------#
# AWS::CloudFormation::Stack - route
# ------------------------------------------------------------#
# Create IGW, NGW, Routetable.
stackRoute:
Type: AWS::CloudFormation::Stack
DependsOn:
- nwPublic1
- nwPrivate1
- nwPrivate2
Properties:
TemplateURL: !Ref 000001templateRouteURL
Parameters:
vpcID: !Ref vpc
vpcName: !Ref 000101vpcName
pub1SubnetID: !Ref nwPublic1
priv1SubnetID: !Ref nwPrivate1
priv2SubnetID: !Ref nwPrivate2
# ------------------------------------------------------------#
# AWS::CloudFormation::Stack - cvpn
# ------------------------------------------------------------#
# Create Client VPN Endpoint.
stackCvpn:
Type: AWS::CloudFormation::Stack
DependsOn:
- nwPrivate1
- nwPrivate2
- sgCvpn01
Properties:
TemplateURL: !Ref 000002templateCvpnURL
Parameters:
vpcID: !Ref vpc
vpcName: !Ref 000101vpcName
cvpnCIDR: !Ref 020201cvpnCIDR
cvpnCertARN: !Ref 020202cvpnCertARN
cvpnDnsIP: !Ref 020203cvpnDnsIP
cvpnSgID: !GetAtt sgCvpn01.GroupId
cvpnNwId1: !Ref nwPrivate1
cvpnNwId2: !Ref nwPrivate2
cvpnAuthCIDR: !Ref 000102vpcCIDR
# ------------------------------------------------------------#
# AWS::CloudFormation::Stack - ec2
# ------------------------------------------------------------#
# Create EC2.
stackEc2:
Type: AWS::CloudFormation::Stack
DependsOn:
- eniAp01
- eniDb01
Properties:
TemplateURL: !Ref 000003templateEc2URL
Parameters:
amiID: !FindInMap [ EC2, AMI, !Ref 030101AmiName ]
instanceType: !Ref 030102InstanceType
eniID1: !Ref eniAp01
eniID2: !Ref eniDb01
keyName: !Ref 030103KeyName
vpcName: !Ref 000101vpcName
serverName1: "ap01"
serverName2: "db01"
以下、解説です。
Parameters:
000001templateRouteURL:
000002templateCvpnURL:
000003templateEc2URL:
ここで子スタックの保管先 (S3 上の URL) を指定します。
stackRoute:
Type: AWS::CloudFormation::Stack
Properties:
TemplateURL: !Ref 000001templateRouteURL
Parameters:
ここで子スタックを呼び出しています。「Parameters:」では、子スタックに渡す引数を指定しています。
子スタック (route)
ルートテーブル関連のオブジェクトを作成する子スタックです。IGW や NAT Gateway もあわせて作成しています。
ちなみに「AWS::EC2::Route」の Public デフォルトルート (0.0.0.0/0) で、DependsOn に「AWS::EC2::VPCGatewayAttachment」を指定しておくのは、地味に重要です。これを指定しておかないと、紐付け前の IGW を経路登録しようとして、エラーが出てしまいます (2 敗)。
AWSTemplateFormatVersion: 2010-09-09
# ------------------------------------------------------------#
# Parameter
# ------------------------------------------------------------#
Parameters:
vpcID:
Type: AWS::EC2::VPC::Id
vpcName:
Type: String
pub1SubnetID:
Type: AWS::EC2::Subnet::Id
priv1SubnetID:
Type: AWS::EC2::Subnet::Id
priv2SubnetID:
Type: AWS::EC2::Subnet::Id
# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------#
Resources:
# ------------------------------------------------------------#
# AWS::EC2::InternetGateway
# ------------------------------------------------------------#
igw:
Type: "AWS::EC2::InternetGateway"
Properties:
Tags:
- Key: Name
Value: !Sub "igw-${vpcName}"
# ------------------------------------------------------------#
# AWS::EC2::VPCGatewayAttachment
# ------------------------------------------------------------#
igwAttachGateway1:
Type: "AWS::EC2::VPCGatewayAttachment"
DependsOn:
- igw
Properties:
VpcId: !Ref vpcID
InternetGatewayId: !Ref igw
# ------------------------------------------------------------#
# AWS::EC2::EIP
# ------------------------------------------------------------#
eipNgw01:
Type: "AWS::EC2::EIP"
Properties:
Domain: "vpc"
Tags:
- Key: Name
Value: !Sub "eip-${vpcName}-ngw01"
# ------------------------------------------------------------#
# AWS::EC2::NatGateway
# ------------------------------------------------------------#
ngw:
Type: "AWS::EC2::NatGateway"
Properties:
AllocationId: !GetAtt eipNgw01.AllocationId
SubnetId: !Ref pub1SubnetID
Tags:
- Key: Name
Value: !Sub "ngw-${vpcName}"
# ------------------------------------------------------------#
# AWS::EC2::RouteTable
# ------------------------------------------------------------#
rtPublic:
Type: 'AWS::EC2::RouteTable'
Properties:
VpcId: !Ref vpcID
Tags:
- Key: Name
Value: !Sub "rt-${vpcName}-public"
rtPrivate:
Type: 'AWS::EC2::RouteTable'
Properties:
VpcId: !Ref vpcID
Tags:
- Key: Name
Value: !Sub "rt-${vpcName}-private"
# ------------------------------------------------------------#
# AWS::EC2::Route
# ------------------------------------------------------------#
# Default : 0.0.0.0/0
rtPublicDefault:
Type: 'AWS::EC2::Route'
DependsOn:
- rtPublic
- igwAttachGateway1
Properties:
RouteTableId: !Ref rtPublic
DestinationCidrBlock: "0.0.0.0/0"
GatewayId: !Ref igw
rtPrivateDefault:
Type: 'AWS::EC2::Route'
DependsOn:
- rtPrivate
- ngw
Properties:
RouteTableId: !Ref rtPrivate
DestinationCidrBlock: "0.0.0.0/0"
NatGatewayId: !Ref ngw
# ------------------------------------------------------------#
# AWS::EC2::SubnetRouteTableAssociation
# ------------------------------------------------------------#
rtPublicAssociation1:
Type: 'AWS::EC2::SubnetRouteTableAssociation'
DependsOn:
- rtPublic
Properties:
SubnetId: !Ref pub1SubnetID
RouteTableId: !Ref rtPublic
rtPrivateAssociation1:
Type: 'AWS::EC2::SubnetRouteTableAssociation'
DependsOn:
- rtPrivate
Properties:
SubnetId: !Ref priv1SubnetID
RouteTableId: !Ref rtPrivate
rtPrivateAssociation2:
Type: 'AWS::EC2::SubnetRouteTableAssociation'
DependsOn:
- rtPrivate
Properties:
SubnetId: !Ref priv2SubnetID
RouteTableId: !Ref rtPrivate
子スタック (cvpn)
Client VPN Endpoint 関連のオブジェクトを作成する子スタックです。とりあえず、証明書認証のみの想定です。
Client VPN Endpoint は、上記で作成した Private NW x 2 に関連付けを行っています。また、認証設定で、Amazon DNS (VPC の CIDR + 2 のアドレス)、および、VPC の CIDR への通信を許可しています。
AWSTemplateFormatVersion: 2010-09-09
# ------------------------------------------------------------#
# Parameter
# ------------------------------------------------------------#
Parameters:
vpcID:
Type: AWS::EC2::VPC::Id
vpcName:
Type: String
cvpnCIDR:
Type: String
cvpnCertARN:
Type: String
cvpnDnsIP:
Type: String
cvpnSgID:
Type: AWS::EC2::SecurityGroup::Id
cvpnNwId1:
Type: AWS::EC2::Subnet::Id
cvpnNwId2:
Type: AWS::EC2::Subnet::Id
cvpnAuthCIDR:
Type: String
# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------#
Resources:
# ------------------------------------------------------------#
# AWS::EC2::ClientVpnEndpoint
# ------------------------------------------------------------#
cvpn:
Type: AWS::EC2::ClientVpnEndpoint
Properties:
ClientCidrBlock: !Ref cvpnCIDR
ServerCertificateArn: !Ref cvpnCertARN
AuthenticationOptions:
- Type: "certificate-authentication"
MutualAuthentication:
ClientRootCertificateChainArn: !Ref cvpnCertARN
ConnectionLogOptions:
Enabled: false
DnsServers:
- !Ref cvpnDnsIP
TransportProtocol: "tcp"
SplitTunnel: "true"
VpcId: !Ref vpcID
SecurityGroupIds:
- !Ref cvpnSgID
VpnPort: 443
TagSpecifications:
- ResourceType: "client-vpn-endpoint"
Tags:
- Key: "Name"
Value: !Sub "cvpn-${vpcName}"
# ------------------------------------------------------------#
# AWS::EC2::ClientVpnTargetNetworkAssociation
# ------------------------------------------------------------#
cvpnAssociation1:
Type: "AWS::EC2::ClientVpnTargetNetworkAssociation"
DependsOn:
- cvpn
Properties:
ClientVpnEndpointId: !Ref cvpn
SubnetId: !Ref cvpnNwId1
cvpnAssociation2:
Type: "AWS::EC2::ClientVpnTargetNetworkAssociation"
DependsOn:
- cvpn
Properties:
ClientVpnEndpointId: !Ref cvpn
SubnetId: !Ref cvpnNwId2
# ------------------------------------------------------------#
# AWS::EC2::ClientVpnAuthorizationRule
# ------------------------------------------------------------#
cvpnAuthRule1:
Type: "AWS::EC2::ClientVpnAuthorizationRule"
DependsOn:
- cvpn
Properties:
ClientVpnEndpointId: !Ref cvpn
AuthorizeAllGroups: true
TargetNetworkCidr: !Join [ "/", [ !Ref cvpnDnsIP, "32" ] ]
cvpnAuthRule2:
Type: "AWS::EC2::ClientVpnAuthorizationRule"
DependsOn:
- cvpn
Properties:
ClientVpnEndpointId: !Ref cvpn
AuthorizeAllGroups: true
TargetNetworkCidr: !Ref cvpnAuthCIDR
子スタック (ec2)
EC2 関連のオブジェクトを作成する子スタックです。
AWSTemplateFormatVersion: 2010-09-09
# ------------------------------------------------------------#
# Parameter
# ------------------------------------------------------------#
Parameters:
amiID:
Type: String
instanceType:
Type: String
eniID1:
Type: String
eniID2:
Type: String
keyName:
Type: String
vpcName:
Type: String
serverName1:
Type: String
serverName2:
Type: String
# ------------------------------------------------------------#
# Resources
# ------------------------------------------------------------#
Resources:
# ------------------------------------------------------------#
# AWS::EC2::Instance
# ------------------------------------------------------------#
sv01:
Type: AWS::EC2::Instance
Properties:
ImageId: !Ref amiID
InstanceType: !Ref instanceType
NetworkInterfaces:
- DeviceIndex: '0'
NetworkInterfaceId: !Ref eniID1
KeyName: !Ref keyName
Tags:
- Key: Name
Value: !Sub "sv-${vpcName}-${serverName1}"
sv02:
Type: AWS::EC2::Instance
Properties:
ImageId: !Ref amiID
InstanceType: !Ref instanceType
NetworkInterfaces:
- DeviceIndex: '0'
NetworkInterfaceId: !Ref eniID2
KeyName: !Ref keyName
Tags:
- Key: Name
Value: !Sub "sv-${vpcName}-${serverName2}"
実行方法
- 子スタックを S3 にアップロードする。アップロード URL は控えておくこと。
- 親スタックをアップロードして実行する。その際のパラメータで、子スタックの URL を指定する。
- だいたい 10 ~ 20 分ぐらいで環境ができあがる。
まとめ
以上、CloudFormation のスタックネスト機能をいろいろ考えながら使ってみる、でした。
コード分割の一番の利点は、やはり「細かくテストできる」というところでしょうか。小さな単位にコードを分割することで、その小さな単位毎に単体テストを実行できます。また、小さな単位に分割されるので、修正や保守もやりやすくなります。
「分割の考え方」に答えはないので、あーでもないこーでもないと試行錯誤し続けながら、自分なりの最適解を見つけていきたいと思います。
おまけで、Route53 、ELB、RDS も含めた場合の構成も考えてみました。いつか検証できる機会があれば、下記で実際に動くか検証してみたいと思います。