【AWS】実例で学ぶCloudFormation～VPC/Route53編～

# はじめに
具体的に動作するCloudFormation templateをもとにしながら、templateを作っていくうえでのポイントについてまとめます。

なお、AWS公式ドキュメントには、豊富なtemplateやsnippetが公開されています。大いに参考になるでしょう。

Sample Templates - AWS CloudFormation
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-sample-templates.html

Template Snippets - AWS CloudFormation
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/CHAP_TemplateQuickRef.html


# 対象となるtemplate
以下で公開しているものをベースに説明します。
https://github.com/tmiki/cloud-formation-templates/blob/master/cfn-01-basis.yml

# 解説
## templateの概要・特徴
AWSでサービスを展開する上で必須となるVPC、及びこれに付随するResourceと、Route53のHostedZoneを自動で作成します。

特徴は下記の通りです。

- Parametersに環境名を指定することで、1つのAWSアカウントで同一構成の環境を併存させることが可能。
- ParametersでNAT Gatewayの利用可否を指定可能。利用する場合、専用のSubnetが作成される。
- templateは適宜分割され、必要な値はCross-stack referenceで参照される。
- 各環境用に作成されるHostedZoneは、ベースとなるドメインのサブドメインとなる。
- ベースとなるHostedZoneは既に存在することを前提としている。

現状の制約は下記の通りです。

- ベースとなるRoute53のZoneが他のAWSアカウントにある場合はそのままでは対応できず、templateの修正が必要。
- NAT Gatewayは1つしか作らない。したがって、Subnetを2つ以上のAZに作って、AZごとにNAT Gatewayを作る必要がある場合は、templateの修正が必要。

## ポイント
### Parameters
まず、全てのtemplatesに共通の値と、当該templateに固有の値を峻別します。

```yaml
Parameters:
  EnvName:
    Description: Please select the environment you want to create. Besides, its name will be prefixed to all resources' name.
    Type: String
    AllowedValues:
      - dev1
      - dev2
      - prd
  AppServiceName:
    Description: Please enter the service name will be provided to the customer.
    Type: String
    Default: YourApp

  RevisionNumber:
    Description: The revision number of this template consists of 3 parts.
    Type: String
    Default: 0.2.0
```

環境名（EnvName）と、アプリケーション名（AppServiceName）、リビジョン番号（RevisionNumber）は全templates共通で同名のParameterを持ちます。

EnvNameは以下の用途で用いられます。

- 作成される全てのAWS Resourceのプレフィックス
- サブドメイン
- 作成されるAWS ResourceのTag

AppServiceName名は、特にAWS Resource作成のためには使われず、AWS ResourceのTagとして付与されるのみです。

RevisionNumberは、当該templateのリビジョン番号を表すために指定します。template内では全く使われません（ので、cfn-lintをかけるとWarningが出ます）が、運用中に人が目視で現状のインフラ構成を確認するのに必要です。


### Mappings
環境固有の値は、Mappingsで定義しておくと便利です。Mappingsの値は3階層構造で定義します。
階層構造は自由に定義・設計できます。
いろいろ試行錯誤した結果、「AWS Resourceタイプ」→「環境名」→「AWS Resourceに指定するProperty」の構造に落ち着きました。

当該templateでは、下記のようにVPC名やCIDRを定義しています。

```yaml
Mappings:
  Vpc:
    dev1:
      VpcName: dev1-YourApp
      VpcCIDR: 172.16.0.0/19
# ※省略
    dev2:
      VpcName: dev2-YourApp
      VpcCIDR: 172.16.32.0/19
# ※省略

```

参照するときは「!FindInMap」関数を使います。
VPC Resourceを作成する個所で、下記のように指定しています。

```yaml
Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !FindInMap [Vpc, !Ref EnvName, VpcCIDR] # ここでMappingsから値を参照している
# ※省略

```

### ResourcesにはTagsを付与する
Cost Explorerは、付与されたTagを元に集計の切り口・対象を変えることができます。
環境やアプリケーションごとに費用を確認できた方が便利ですので、費用がかかるAWS Resourceには必ずTagを付与するようにします。

```yaml
  NatGateway1:
    Type: AWS::EC2::NatGateway
    Condition: enableNatGw
    DependsOn: InternetGatewayAttachment
    Properties:
      AllocationId: !GetAtt NatGateway1EIP.AllocationId
      SubnetId: !Ref FrontSubnet1
      Tags:
        - Key: Name
          Value: !Sub ${EnvName}-NatGateway1
        - Key: Env
          Value: !Ref EnvName
        - Key: AppService
          Value: !Ref AppServiceName

```

### NAT Gatewayの設定
NAT Gatewayは、ElasticIPを取得してから構築する必要があります。
具体的には下記のように作っていきます。

```yaml
Resources:
# ※省略
  NatGateway1EIP:
    Type: AWS::EC2::EIP
    Condition: enableNatGw
    Properties:
      Domain: vpc

  NatGateway1:
    Type: AWS::EC2::NatGateway
    Condition: enableNatGw
    DependsOn: InternetGatewayAttachment
    Properties:
      AllocationId: !GetAtt NatGateway1EIP.AllocationId
      SubnetId: !Ref FrontSubnet1
      Tags:
        - Key: Name
          Value: !Sub ${EnvName}-NatGateway1
        - Key: Env
          Value: !Ref EnvName
        - Key: AppService
          Value: !Ref AppServiceName
```

なお、NAT Gatwayに関するResourceには「Condition:」が指定されており、当該値がtrueの場合にのみ、作成されるようになっています。

これは手前のConditionsセクションで定義しています。当該Conditionsセクションで、指定されたParametersを元に、true/falseを設定しています。

```yaml
Parameters:
# ※省略
  EnableNatGw:
    Description: This parameter defines whether to create Private Subnets with a NAT Gateway.
    Type: String
    Default: false
    AllowedValues:
      - true
      - false

# ※省略

Conditions:
# ※省略
  enableNatGw: !Equals [ !Ref EnableNatGw, "true"]
```


### サブドメインのRoute53 HostedZone
Parametersで指定された、ベースとなるドメイン（Route53BaseDomain）のサブドメインとして、当該環境のHostedZone作ります。
ドメイン名は、「環境名.ベースとなるドメイン名」という構成になります。

本番環境（prd）の場合は、ベースとなるドメインをそのまま利用する想定ですので、他の環境とは挙動が変わってきます。
このため、「isNotPrdEnv」というConditionを定義しています。

```yaml
Parameters:
  EnvName:
    Description: Please select the environment you want to create. Besides, its name will be prefixed to all resources' name.
    Type: String
    AllowedValues:
      - dev1
      - dev2
      - prd

# ※省略

Conditions:
  isPrdEnv: !Equals [ !Ref EnvName, "prd" ]
  isNotPrdEnv: !Not [Condition: isPrdEnv]
```

Route53でサブドメインを作るには、大きく2つの作業が必要です。

1. HostedZoneを作る
2. ベースとなるドメインのHostedZoneに、サブドメインと同じNSレコードを登録する（ゾーンの委任）

前者は「"AWS::Route53::HostedZone"」のResourceで、後者は「"AWS::Route53::RecordSet"」のResoruceで定義しています。


```yaml
Resources:
# ※省略
  Route53HostedZone:
    Type: "AWS::Route53::HostedZone"
    Condition: isNotPrdEnv
    Properties:
      Name: !Sub ${EnvName}.${Route53BaseDomain}
      HostedZoneTags:
        - Key: Name
          Value: !Sub ${EnvName}-Route53HostedZone
        - Key: Env
          Value: !Ref EnvName
        - Key: AppService
          Value: !Ref AppServiceName

  Route53DelegationInParent:
    Type: "AWS::Route53::RecordSet"
    Condition: isNotPrdEnv
    Properties:
      HostedZoneName: !Ref "Route53BaseDomain"
      Name: !Sub ${EnvName}.${Route53BaseDomain}
      Type: NS
      ResourceRecords:
        !GetAtt Route53HostedZone.NameServers
      TTL: "172800"
```


### Outputs
他のtemplateから参照させる値をExportします。Cross-stack referenceと呼ばれる機能の一環です。

全てのExport Nameに、プレフィックスとしてEnvNameを付与します。

Route53のHostedZoneIdとHostedZoneNameも他のtemplateから参照する予定です。
本番環境かそうでないかで、当該HostedZoneを作る／既存のものを利用するという差異がありますので、Exportもこれに対応できるよう、Fn::If関数を活用します。

```yaml
Outputs:
  VPC:
    Description: A reference to the created VPC
    Value: !Ref VPC
    Export:
      Name: !Sub ${EnvName}-VPC

# ※省略

  Route53HostedZoneId:
    Description: "Route53 Hosted Zone Id."
    Value:
      Fn::If:
        - "isPrdEnv"
        - !Ref PrdRoute53HostedZoneId
        - !Ref Route53HostedZone
    Export:
      Name: !Sub ${EnvName}-Route53HostedZoneId

  Route53HostedZoneName:
    Description: "Route53 Hosted Zone Name."
    Value:
      Fn::If:
        - "isPrdEnv"
        - !Sub ${Route53BaseDomain}
        - !Sub ${EnvName}.${Route53BaseDomain}
    Export:
      Name: !Sub ${EnvName}-Route53HostedZoneName
```

### 関数の短縮形と完全名
Yamlでtemplateを書く場合、関数は2種類の表記が使えます。

たとえばFn::If関数であれば、以下の表記となります。

```yaml:完全名関数
Fn::If: [condition_name, value_if_true, value_if_false]
```

```yaml:短縮形
!If [condition_name, value_if_true, value_if_false]
```

Condition Functions - AWS CloudFormation
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-conditions.html#intrinsic-function-reference-conditions-if


基本的には短縮形で書いたほうがシンプルで読みやすくなりますが、関数の中に関数を指定する場合に、見通しが悪くなる／上手く動かないパターンがあります。
また、短縮形は1行で書く必要がありますが、完全名関数の場合は複数行に展開することができます。

先ほど確認したHostedZoneNameのExportを例に取って見てみます。
これは完全名関数表記で、複数行に分けています。

```yaml
  Route53HostedZoneName:
    Description: "Route53 Hosted Zone Name."
    Value:
      Fn::If:
        - isPrdEnv
        - !Sub ${Route53BaseDomain}
        - !Sub ${EnvName}.${Route53BaseDomain}
    Export:
      Name: !Sub ${EnvName}-Route53HostedZoneName
```


これを短縮形で書くと下記のようになります。

```yaml
  Route53HostedZoneName:
    Description: "Route53 Hosted Zone Name."
    Value: !If [ isPrdEnv, !Sub "${Route53BaseDomain}", !Sub "${EnvName}.${Route53BaseDomain}" ]
    Export:
      Name: !Sub ${EnvName}-Route53HostedZoneName

```

# おわりに
全てを説明していると際限が無いので、実運用で用いる上で重要になりそうなポイントだけまとめてみました。
引き続き他のAWS Resourceについても記事を投稿していきます。