CloudFormation のスタックネスト機能をいろいろ考えながら使ってみる

# はじめに
\_s\_\_o\_ です。

便利な CloudFormatin ですが、あれもこれもと記載しているうち、コード量がついつい多くなってしまいます。そこで便利なのが [スタックのネスト機能](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/using-cfn-nested-stacks.html) 。スタックを入れ子にすることで、スタック (コード) を分割することができます。たとえば、子スタックとして 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 用、ルートテーブル用など)

## 分割結果

上記の方針に基づいて仕分けた結果が下記となります。必ずしも下記が最適ではないですが、個人的には一番しっくりきました。
![01_スタック分解案.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46216/44a068ef-a17e-7430-8a26-961748128b5a.png)


# コード

コードを記載するにあたっては [こちらの記事](https://qiita.com/tyoshitake/items/ff08855d629ba4097125) を参考にさせていただきました。非常によくまとまっており、とてもわかりやすい記事です。

## 親スタック

親スタックです。このコードを 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}"
```

# 実行方法
1. 子スタックを S3 にアップロードする。アップロード URL は控えておくこと。
1. 親スタックをアップロードして実行する。その際のパラメータで、子スタックの URL を指定する。
1. だいたい 10 ～ 20 分ぐらいで環境ができあがる。

# まとめ

以上、CloudFormation のスタックネスト機能をいろいろ考えながら使ってみる、でした。

コード分割の一番の利点は、やはり「細かくテストできる」というところでしょうか。小さな単位にコードを分割することで、その小さな単位毎に単体テストを実行できます。また、小さな単位に分割されるので、修正や保守もやりやすくなります。

「分割の考え方」に答えはないので、あーでもないこーでもないと試行錯誤し続けながら、自分なりの最適解を見つけていきたいと思います。

おまけで、Route53 、ELB、RDS も含めた場合の構成も考えてみました。いつか検証できる機会があれば、下記で実際に動くか検証してみたいと思います。
![02_ELBなどを含めた版.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/46216/c3b7ac27-5bef-2c5a-2f0c-1e4a21360978.png)