CloudFormationで新規VPCの作成・未作成を1つのテンプレートで場合分けする方法

CloudFormationのResourcesセクションには「特定の条件を満たすときだけリソースを作成させる」Conditionキーを設定することが出来ます。
これを使うとConditionキーに設定された条件がTrueになるときだけそのリソースが作成されるようになります。
参考: [AWS CloudFormation ユーザーガイド](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/conditions-section-structure.html)

私がインフラをCloudFormationテンプレート化するのは同じ構成の環境を長期に渡って何回も作り、かつ構成がブレないときなので、一部リソースの作成有無を場合分けすることは通常無いのですが、唯一あるのが「指定するVPCに作ってください」「それ用に新しいVPCを作ってください」というリクエストに応じるパターンです。
以前は新規VPCに作成する場合はVPC作成→CloudFormationとしていたのですが、割合として半々くらいなので1つのテンプレートで両方に対応出来るようにしました。
今回はその実現方法について解説します。


## 新規VPCと一緒に作るもの

新規VPCを作るときはリソースとしてのVPCだけ作ってもそのままでは使えません。
VPCの中にサブネットが1つ以上必要ですし、VPC外と通信するにはInternetGatewayやNAT Gatewayが必要です。
またInternetGateway等を作成したら外への通信がそれらを通るようにするルーティングをルートテーブルに追加する必要がありますし、サブネットが該当のルーティングを使用するようサブネットとルートテーブルの紐付け設定も必要になります。

これらをCloudFormationのリソースに当てはめると、必要になるリソースは下記になります。
* `AWS::EC2::VPC`・・・VPC
* `AWS::EC2::InternetGateway`・・・VPC外へのルータ
* `AWS::EC2::VPCGatewayAttachment`・・・InternetGatewayをVPCに紐付ける設定
* `AWS::EC2::RouteTable`・・・ルートテーブル
* `AWS::EC2::Route`・・・VPC外への通信をInternetGatewayに渡すルーティング設定
* `AWS::EC2::Subnet`・・・サブネット。最低1つ必要。今回は2つ作成します
* `AWS::EC2::SubnetRouteTableAssociation`・・・ルートテーブルとサブネットの紐付け設定


## 既存VPCを使う場合の各リソースの作成条件

新規VPCを作るときは上記のリソースを全て作ってしまえば良いのですが、案件によっては「VPCは既存だけどサブネットは分けてください」というケースもあります。
そのため、各リソースごとに作る/作らないの判断条件を下記の通り設けました。

* `AWS::EC2::InternetGateway`・・・既存のInternetGatewayを与えられていない。かつSubet1とSubnet2のどちらか一方、または両方を新規作成した場合は作成する
* `AWS::EC2::VPCGatewayAttachment`・・・InternetGatewayを作成したら設定する
* `AWS::EC2::RouteTable`・・・Subnet1とSubnet2のどちらか一方、または両方を新規作成した場合は作成する
* `AWS::EC2::Route`・・・ルートテーブルを作成したらこれも設定する
* `AWS::EC2::Subnet`・・・既存のサブネットIDが渡されなかった作成する
* `AWS::EC2::SubnetRouteTableAssociation`・・・作成したサブネットには作成したルートテーブルを紐付ける


## CloudFormationで場合分けする方法

ここでは、先程定義した各リソースの作成条件をCloudFormationテンプレートで実現する記述について解説します。

### Parametersセクションで条件判断に利用する変数を宣言する

まずはParametersセクションで条件判断に利用する変数を宣言します。
変数の値はデフォルトを設定してそのまま利用したり、実行時に代入したり出来ます。

書き方は通常のParametersと同じです。

```yml
Parameters:
  NetworkPrefix:
    Type: String
    Default: "CondTest"
  ExtVPC:
    Type: String
    Default: "vpc-xxxxxxxxxxxxxxxxx"
  ExtSubnet1:
    Type: String
    Default: "subnet-xxxxxxxxxxxxxxxxx"
  ExtSubnet2:
    Type: String
    Default: "subnet-xxxxxxxxxxxxxxxxx"
  ExtInternetGateway:
    Type: String
    Default: "igw-xxxxxxxxxxxxxxxxx"
  VPCCIDR:
    Type: String
    Default: "10.10.0.0/16"
  PublicSubnetACIDR:
    Type: String
    Default: "10.10.10.0/24"
  PublicSubnetDCIDR:
    Type: String
    Default: "10.10.12.0/24"
```

* NetworkPrefix・・・リソース名の重複を避けるために付与するプレフィックス。実行時に任意の文字列を設定して下さい
* ExtVP・・・既存VPCのIDを受け取るための変数
* ExtSubnet1・・・1つ目の既存サブネットのID
* ExtSubnet2・・・2つ目の既存サブネットのID
* ExtInternetGateway・・・ExtVPで指定した既存VPCにアタッチ済みの既存InternetGatewayのID
* VPCCIDR・・・VPCを新規作成する際に利用するCIDR
* PublicSubnetACIDR・・・Az-aにサブネットを新規作成する際に利用するCIDR
* PublicSubnetDCIDR・・・Az-dにサブネットを新規作成する際に利用するCIDR

※今回はサブネットを新規作成する場合はAz-a/d固定です。

### Conditionsセクションで条件を宣言する

次にConditionsセクションに「既存VPCを使う場合の各リソースの作成条件」で考えた条件を具体的に宣言します。

```yaml
Conditions:
# 既存VPCが与えられなかったらtrue
  NotExtVPC:                  !Equals [!Ref ExtVPC, ""]
# 既存サブネットが与えられなかったらtrue
  NotExtSubnet1:         !Or [!Equals [!Ref ExtVPC, ""], !Equals [!Ref ExtSubnet1, ""]]
  NotExtSubnet2:         !Or [!Equals [!Ref ExtVPC, ""], !Equals [!Ref ExtSubnet2, ""]]
# 既存InternetGatewayが与えられなかったらtrue
  NotExtInternetGateway: !Or [!Equals [!Ref ExtVPC, ""], !Equals [!Ref ExtInternetGateway, ""]]
# 既存のInternetGatewayを与えられていない。かつSubet1とSubnet2のどちらか一方、または両方を新規作成した場合はtrue -> InternetGatewayを作成する
  CreateInternetGateway: !Or
    - !And [!Condition NotExtSubnet1, !Condition NotExtSubnet2, !Condition NotExtInternetGateway]
    - !And [!Condition NotExtSubnet1, !Not [!Condition NotExtSubnet2], !Condition NotExtInternetGateway]
    - !And [!Condition NotExtSubnet2, !Not [!Condition NotExtSubnet1], !Condition NotExtInternetGateway]
# Subnet1とSubnet2のどちらか一方、または両方を新規作成した場合はtrue -> ルートテーブルを作成する
  CreateRouteTable:      !Or [!Condition NotExtSubnet1, !Condition NotExtSubnet2]
```

### 条件の書き方

Conditionsセクションは`論理ID: 条件式`という書式で記述され、条件式の評価結果(true/false)が論理IDに代入されます。
後述しますが、この論理IDはResourcesセクションから参照されます。

条件式ではand, or等の条件関数を利用できます。利用可能な条件関数は公式ドキュメントを参照してください。
参考: [条件関数](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html)

### Resourcesセクションでの条件の使い方

Resourcesセクションでは`Condition: Conditionsセクションで宣言した論理ID`と記述することで、論理IDの評価値がtrueのときだけリソースを作成させることが出来ます。

```yml
Resources:
  CondTestVPC:
    Type: "AWS::EC2::VPC"
    # NotExtVPCがtrueだったら作成 = 変数ExtVPC(既存VPCのID)が空だったら作成
    Condition: "NotExtVPC"
    Properties: (以下略)
```


## サンプルテンプレート

ここまでの解説を踏まえて作成した動作確認用のサンプルテンプレートが下記になります。
サンプルではVPC等を作る/作らないの場合分けに終始していて、VPCの中に作られるリソースはSG1つだけです。EC2とかだと動作確認に時間が掛かるのでSGにしました。

仕様や制限事項は下記の通りです。

* 既存SubnetのRouteはいじらない。PublicRouteが無いかも知れないがそれは諦める
  * 既存SubnetのRouteいじって通信障害起こすのが怖い
* 新しいInternetGatewayを作るパターンでも既存VPCに他のInternetGatewayがアタッチ済みの場合はエラーになる
* 既存のSubnetを2つとも与えられたときは既存InternetGatewayが与えられなくても新規作成しない
    * 既存SubnetのRouteはいじらないのでInternetGateway作っても使わない
* Subnet1にAZ-aを与えられてかつSubnet2が空の場合、サンプルはSubnet2はAZ-a固定なのでエラーになる
* 既存VPCに新しくSubnetを作るとき、与えられたCIDRが使用済みだとエラーになる
* 与えれた既存VPC、既存Subnet、既存InternetGatewayが実在しないときはエラーになる
* 既存IGWInternetGateway代わりにNAT GatewayのIDを渡しても動くと思うけど未確認

(Subnet1がAz-dでSubnet2がAz-aなの気持ち悪いと思いますが、内部事情の歴史的経緯によるものです)

```yml
AWSTemplateFormatVersion: "2010-09-09"
Description:
  VPC and Subnet Create

Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "Network Prefix"
        Parameters:
          - NetworkPrefix
      - Label:
          default: "Network Configuration"
        Parameters:
          - VPCCIDR
          - PublicSubnetACIDR
          - PublicSubnetDCIDR
      - Label:
          default: "Existing Resource"
        Parameters:
          - ExtVPC
          - ExtSubnet1
          - ExtSubnet2
          - ExtInternetGateway
    ParameterLabels:
      VPCCIDR:
        default: "VPC CIDR"
      PublicSubnetACIDR:
        default: "PublicSubnetA CIDR"
      PublicSubnetDCIDR:
        default: "PublicSubnetD CIDR"
      ExtVPC:
        default: "VPC ID for deploying EC2 for CondTest in an existing VPC; blank if you want to create a new VPC."
      ExtSubnet1:
        default: "Subnet ID for deploying EC2 for CondTest in an existing Subnet; blank if you want to create a new Subnet."
      ExtSubnet2:
        default: "Subnet ID for assigning an existing Subnet as the second Subnet of the ALB; blank if you want to create a new Subnet."
      ExtInternetGateway:
        default: "InternetGateway ID already attached to the existing VPC, or blank if there is no InternetGateway already attached (a new one will be created)."

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  NetworkPrefix:
    Type: String
    Default: "CondTest"

  ExtVPC:
    Type: String
    Default: "vpc-xxxxxxxxxxxxxxxxx"

  ExtSubnet1:
    Type: String
    Default: "subnet-xxxxxxxxxxxxxxxxx"

  ExtSubnet2:
    Type: String
    Default: "subnet-xxxxxxxxxxxxxxxxx"

  ExtInternetGateway:
    Type: String
    Default: "igw-xxxxxxxxxxxxxxxxx"

  VPCCIDR:
    Type: String
    Default: "10.10.0.0/16"

  PublicSubnetACIDR:
    Type: String
    Default: "10.10.10.0/24"

  PublicSubnetDCIDR:
    Type: String
    Default: "10.10.12.0/24"

Conditions:
# 既存VPCが与えられなかったらtrue
  NotExtVPC:                  !Equals [!Ref ExtVPC, ""]
# 既存サブネットが与えられなかったらtrue
  NotExtSubnet1:         !Or [!Equals [!Ref ExtVPC, ""], !Equals [!Ref ExtSubnet1, ""]]
  NotExtSubnet2:         !Or [!Equals [!Ref ExtVPC, ""], !Equals [!Ref ExtSubnet2, ""]]
# 既存InternetGatewayが与えられなかったらtrue
  NotExtInternetGateway: !Or [!Equals [!Ref ExtVPC, ""], !Equals [!Ref ExtInternetGateway, ""]]
# 既存のInternetGatewayを与えられていない。かつSubet1とSubnet2のどちらか一方、または両方を新規作成した場合はtrue -> InternetGatewayを作成する
  CreateInternetGateway: !Or
    - !And [!Condition NotExtSubnet1, !Condition NotExtSubnet2, !Condition NotExtInternetGateway]
    - !And [!Condition NotExtSubnet1, !Not [!Condition NotExtSubnet2], !Condition NotExtInternetGateway]
    - !And [!Condition NotExtSubnet2, !Not [!Condition NotExtSubnet1], !Condition NotExtInternetGateway]
# Subnet1とSubnet2のどちらか一方、または両方を新規作成した場合はtrue -> ルートテーブルを作成する
  CreateRouteTable:      !Or [!Condition NotExtSubnet1, !Condition NotExtSubnet2]

Resources:
# ------------------------------------------------------------#
# VPC
# ------------------------------------------------------------#
# 既存VPCが与えられなかったら作成する
  CondTestVPC:
    Type: "AWS::EC2::VPC"
    Condition: "NotExtVPC"
    Properties:
      CidrBlock: !Ref VPCCIDR
      EnableDnsSupport: true
      EnableDnsHostnames: false
      InstanceTenancy: default
      Tags:
        - Key: Name
          Value: !Sub "${NetworkPrefix}-vpc"

# InternetGatewayを作成する条件を満たしたら作成する
  InternetGateway:
    Type: "AWS::EC2::InternetGateway"
    Condition: "CreateInternetGateway"
    Properties:
      Tags:
        - Key: Name
          Value: !Sub "${NetworkPrefix}-igw"

# InternetGatewayを作成したら新規VPCか既存PVCにアタッチする。既存VPCにIGWがアタッチ済みならこれはエラーになる
  InternetGatewayAttachment:
    Type: "AWS::EC2::VPCGatewayAttachment"
    Condition: "CreateInternetGateway"
    Properties:
      InternetGatewayId: !Ref InternetGateway
      VpcId: !If [ NotExtVPC, !Ref CondTestVPC, !Ref ExtVPC ]

# ------------------------------------------------------------#
# RouteTable
# ------------------------------------------------------------#
# ルートテーブルを作成する条件を満たしたら作成する
  PublicRouteTable:
    Type: "AWS::EC2::RouteTable"
    Condition: "CreateRouteTable"
    Properties:
      VpcId: !If [ NotExtVPC, !Ref CondTestVPC, !Ref ExtVPC ]
      Tags:
        - Key: Name
          Value: !Sub "${NetworkPrefix}-public-route"

# ------------------------------------------------------------#
# Routing
# ------------------------------------------------------------#
# ルートテーブルを作成したらIGWへのルートを、新規作成したルートテーブルに作成する
  PublicRoute:
    Type: "AWS::EC2::Route"
    Condition: "CreateRouteTable"
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !If [ NotExtInternetGateway, !Ref InternetGateway, !Ref ExtInternetGateway ]

# ------------------------------------------------------------#
# Subnet
# ------------------------------------------------------------#
# Subnet2が与えられなかったら作成する
  PublicSubnetA:
    Type: "AWS::EC2::Subnet"
    Condition: "NotExtSubnet2"
    Properties:
      AvailabilityZone: "ap-northeast-1a"
      CidrBlock: !Ref PublicSubnetACIDR
      VpcId: !If [ NotExtVPC, !Ref CondTestVPC, !Ref ExtVPC ]
      Tags:
        - Key: Name
          Value: !Sub "${NetworkPrefix}-public-subnet-a"

# Subnet1が与えられなかったら作成する
  PublicSubnetD:
    Type: "AWS::EC2::Subnet"
    Condition: "NotExtSubnet1"
    Properties:
      AvailabilityZone: "ap-northeast-1d"
      CidrBlock: !Ref PublicSubnetDCIDR
      VpcId: !If [ NotExtVPC, !Ref CondTestVPC, !Ref ExtVPC ]
      Tags:
        - Key: Name
          Value: !Sub "${NetworkPrefix}-public-subnet-d"

# ------------------------------------------------------------#
# RouteTable Associate
# ------------------------------------------------------------#
# PublicRouteTable Associate SubnetA
  PublicSubnetARouteTableAssociation:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Condition: "NotExtSubnet2"
    Properties:
      SubnetId: !Ref PublicSubnetA
      RouteTableId: !Ref PublicRouteTable

# PublicRouteTable Associate SubnetD
  PublicSubnetDRouteTableAssociation:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Condition: "NotExtSubnet1"
    Properties:
      SubnetId: !Ref PublicSubnetD
      RouteTableId: !Ref PublicRouteTable

# ------------------------------------------------------------#
# Security Groups
# ------------------------------------------------------------#
  CondTestSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub "${NetworkPrefix}-SG"
      GroupDescription: "SG for CondTest"
      VpcId: !If [ NotExtVPC, !Ref CondTestVPC, !Ref ExtVPC ]
      SecurityGroupIngress:
      - IpProtocol: tcp
        FromPort: 443
        ToPort: 443
        CidrIp: 0.0.0.0/0
      SecurityGroupEgress:
      - IpProtocol: "-1"
        CidrIp: 0.0.0.0/0
      Tags:
        - Key: "Name"
          Value: !Sub "${NetworkPrefix}-SG"
```

## 実行手順

aws cliでの実行例を示します。
前述のサンプルテンプレートをCondTest.ymlというファイル名でカレントディレクトリに設置してください。
リソース名が偶然被ってしまうことがなければサンプルテンプレートはそのまま使えます。
もちろんWebコンソールで実行・確認することも出来ます。

### 新規VPCを作成してそこにSGを作成するパターン

よく使うVPC/Subnetをdefaultで設定しているので、新規VPCを作らせる場合はExtVPCを空で上書きしてください。

```sh
aws cloudformation create-stack --stack-name CondTest1 --template-body file://CondTest.yml \
  --parameters ParameterKey=NetworkPrefix,ParameterValue="CondTest1" \
               ParameterKey=ExtVPC,ParameterValue=""
```

このスタックで作成されたリソースを確認すると全て作成されたのが分かります。

```sh
aws cloudformation describe-stack-resources --stack-name CondTest1 | jq -r '.StackResources[] | {ResourceType, LogicalResourceId, ResourceStatus}'
{
  "ResourceType": "AWS::EC2::SecurityGroup",
  "LogicalResourceId": "CondTestSG",
  "ResourceStatus": "CREATE_COMPLETE"
}
{
  "ResourceType": "AWS::EC2::VPC",
  "LogicalResourceId": "CondTestVPC",
  "ResourceStatus": "CREATE_COMPLETE"
}
{
  "ResourceType": "AWS::EC2::InternetGateway",
  "LogicalResourceId": "InternetGateway",
  "ResourceStatus": "CREATE_COMPLETE"
}
{
  "ResourceType": "AWS::EC2::VPCGatewayAttachment",
  "LogicalResourceId": "InternetGatewayAttachment",
  "ResourceStatus": "CREATE_COMPLETE"
}
{
  "ResourceType": "AWS::EC2::Route",
  "LogicalResourceId": "PublicRoute",
  "ResourceStatus": "CREATE_COMPLETE"
}
{
  "ResourceType": "AWS::EC2::RouteTable",
  "LogicalResourceId": "PublicRouteTable",
  "ResourceStatus": "CREATE_COMPLETE"
}
{
  "ResourceType": "AWS::EC2::Subnet",
  "LogicalResourceId": "PublicSubnetA",
  "ResourceStatus": "CREATE_COMPLETE"
}
{
  "ResourceType": "AWS::EC2::SubnetRouteTableAssociation",
  "LogicalResourceId": "PublicSubnetARouteTableAssociation",
  "ResourceStatus": "CREATE_COMPLETE"
}
{
  "ResourceType": "AWS::EC2::Subnet",
  "LogicalResourceId": "PublicSubnetD",
  "ResourceStatus": "CREATE_COMPLETE"
}
{
  "ResourceType": "AWS::EC2::SubnetRouteTableAssociation",
  "LogicalResourceId": "PublicSubnetDRouteTableAssociation",
  "ResourceStatus": "CREATE_COMPLETE"
}
```

### 既存VPC/SubnetにSGだけ作成するパターン

ExtVPC/ExtSubnet1/ExtSubnet2/ExtInternetGatewayにそれぞれ既存のリソースIDを指定してください。

```sh
aws cloudformation create-stack --stack-name CondTest2 --template-body file://CondTest.yml \
  --parameters ParameterKey=NetworkPrefix,ParameterValue="CondTest2" \
               ParameterKey=ExtVPC,ParameterValue="vpc-xxxxxxxxxxxxxxxxx" \
               ParameterKey=ExtSubnet1,ParameterValue="subnet-xxxxxxxxxxxxxxxxx" \
               ParameterKey=ExtSubnet2,ParameterValue="subnet-xxxxxxxxxxxxxxxxx" \
               ParameterKey=ExtInternetGateway,ParameterValue="igw-xxxxxxxxxxxxxxxxx"
```

こちらのスタックでは、パラメータで与えられた既存VPCにSGだけが作成されます。

```sh
aws cloudformation describe-stack-resources --stack-name CondTest2 | jq -r '.StackResources[] | {ResourceType, LogicalResourceId, ResourceStatus}'
{
  "ResourceType": "AWS::EC2::SecurityGroup",
  "LogicalResourceId": "CondTestSG",
  "ResourceStatus": "CREATE_COMPLETE"
}
```