【AWS】実例で学ぶCloudFormation～RDS Aurora編～

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

[【AWS】実例で学ぶCloudFormation～VPC/Route53編～
](https://qiita.com/tmiki/items/886656a7b0b03f0d05e6)

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

# 解説
## templateの概要・特徴
このtemplateは、Aurora(MySQL) Clusterを作ります。
Cross-stack referenceの機能を使い、VPCを構築したStackのExport名を指定して、デプロイ対象のVPC/Subnetの情報を取得します。

特徴は下記の通りです。

- 新規作成／Snapshotからリストアのいずれも対応可能
- Multi-AZ相当構成にするかどうかをParametersで指定可能
- Clusterは固定名で、Instanceはランダムな名前で作成される


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

- Aurora以外のRDSに対応していない。Aurora(PostgreSQL)にしたい場合は、恐らくPropertiesのEngineを変更するだけで対応可能。それ以外のRDSはかなり変更が必要。
- RDSのParameter Groupはtemplateにべた書き。変えたい場合はtemplateを変更する必要がある。

## ポイント
### 他のtemplateでExportした値をImportValueで利用する（Cross-stack reference）
VPC構築用のtemplateの、Outputsセクションで定義した値を、RDS用のtemplateから参照します。

まず、VPC側のtemplateは下記の通りです。
ResourcesセクションでSubnetを作成したのち、これをOutputsセクションでExportします。
Export名は、Export:Name:プロパティで指定したものが利用されます。

```yaml:VPC側の定義
Resources:
# ※途中省略
  DatastoreSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [ 0, !GetAZs '' ]
      CidrBlock: !FindInMap [Vpc, !Ref EnvName, DatastoreSubnet1CIDR]
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: !Sub ${EnvName}-Datastore(AZ1)
        - Key: Env
          Value: !Ref EnvName
        - Key: AppService
          Value: !Ref AppServiceName

  DatastoreSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [ 1, !GetAZs '' ]
      CidrBlock: !FindInMap [Vpc, !Ref EnvName, DatastoreSubnet2CIDR]
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: !Sub ${EnvName}-Datastore(AZ2)
        - Key: Env
          Value: !Ref EnvName
        - Key: AppService
          Value: !Ref AppServiceName

# ※途中省略

Outputs:
# ※途中省略
  DatastoreSubnet1:
    Description: A reference to the datastore subnet in the 1st Availability Zone
    Value: !Ref DatastoreSubnet1
    Export:
      Name: !Sub ${EnvName}-DatastoreSubnet1

  DatastoreSubnet2:
    Description: A reference to the datastore subnet in the 2nd Availability Zone
    Value: !Ref DatastoreSubnet2
    Export:
      Name: !Sub ${EnvName}-DatastoreSubnet2

```

RDSではCluster/Instance作成時、デプロイ先のSubnetを指定する必要がありますが、これを参照させます。
Exportされた値を、RDS側のtemplateから参照するには下記のようにFn::ImportValueを使います。

```yaml:RDS側の定義
Resources:
# ※途中省略
  DBSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: RDS subnet group.
      SubnetIds:
        - Fn::ImportValue: !Sub ${EnvName}-DatastoreSubnet1
        - Fn::ImportValue: !Sub ${EnvName}-DatastoreSubnet2
```

なお、!ImportValue関数と!Sub関数は、両方とも短縮形を用いることはできません。
!Sub関数で置換した文字列を!ImportValue関数に渡したいときは、下記のように完全関数名で指定します。

Fn::ImportValue - AWS CloudFormation
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-importvalue.html


> **Important**
> 
> You can't use the short form of !ImportValue when it contains a !Sub. The following example is valid for AWS CloudFormation, but not valid for YAML:
> 
> ```
> !ImportValue
>   !Sub "${NetworkStack}-SubnetID" 
> ```
> Instead, you must use the full function name, for example:
>
>```
>Fn::ImportValue:
>  !Sub "${NetworkStack}-SubnetID"
>```

### Snapshotから戻すかどうかによって動作を変える
ParametersでRDS SnapshotのARNを指定できるようにしておきます。当該値が空文字列であれば新規に作成し、指定されていればSnapshotからのリストアを行うようにします。

まず、ParametersセクションでDBSnapshotArnという名称でSnapshotのARNを受け取れるようにします。
その後Conditionsセクションで、当該値が空文字列であれば、isBrandNewDBというConditionをtrueにセットします。

```yaml
Parameters:
# ※途中省略
  DBSnapshotArn:
    Description: If you want to create an Aurora Cluster from your snapshot, please enter your Snapshot ARN. If you leave it empty, this template creates a brand new Aurora Cluster.
    Type: String

# ※途中省略

Conditions:
  isBrandNewDB: !Equals [ !Ref DBSnapshotArn, "" ]
  enableMultiAz: !Equals [ !Ref EnableMultiAz, "true" ]
```

次に、RDS Aurora Clusterの定義です。
isBrandNewDB Conditionがtrueであった場合とfalseの場合で、templateは下記のように解釈されます。

- isBrandNewDBがtrue
    - SnapshotIdentifier propertyに、Parametersで指定された文字列が渡されます。
    - MasterUsername及びMasterUserPassword propertiesは、そもそも指定されていないものと解釈されます。
- isBrandNewDBがfalse
    - SnapshotIdentifier propertyは、そもそも当該propertyが指定されていないものと解釈されます。
    - MasterUsername及びMasterUserPassword propertiesは、Parametersで指定された文字列が渡されます。


```yaml
Resources:
# ※途中省略
  DBCluster:
    Type: AWS::RDS::DBCluster
    DeletionPolicy: Snapshot
    Properties:
      Engine: aurora-mysql
      EngineVersion: 5.7.12
      SnapshotIdentifier:
        !If [isBrandNewDB, !Ref "AWS::NoValue", !Ref "DBSnapshotArn" ]
      DBClusterIdentifier: !FindInMap [RDS, !Ref "EnvName", "ClusterName"]
      MasterUsername:
       !If [isBrandNewDB, !Ref "DBMasterUserName", !Ref "AWS::NoValue" ]
      MasterUserPassword:
       !If [isBrandNewDB, !Ref "DBMasterUserPassword", !Ref "AWS::NoValue" ]
      BackupRetentionPeriod: !FindInMap [RDS, !Ref "EnvName", "BackupRetentionPeriod"]
      PreferredBackupWindow: !FindInMap [RDS, !Ref "EnvName", "PreferredBackupWindow"]
      PreferredMaintenanceWindow: !FindInMap [RDS, !Ref "EnvName", "PreferredMaintenanceWindow"]
      DBSubnetGroupName: !Ref "DBSubnetGroup"
      DBClusterParameterGroupName: !Ref "DBClusterParameterGroup"
      VpcSecurityGroupIds:
        - Fn::ImportValue: !Sub ${EnvName}-SecurityGroupInternal

```

### Pseudo Parameters Reference（疑似パラメータ参照）
先のAurora Clusterの定義でも用いましたが、条件によって指定するpropertyの値を変えたい場合があります。
またもっと言うと、条件によっては当該propertyを指定しない状態にしたい、といった状況があります。
このようなときに、"AWS::NoValue"というpseusdo parameterを利用します。

Pseudo Parameters Reference - AWS CloudFormation
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/pseudo-parameter-reference.html#cfn-pseudo-param-novalue

上記公式ドキュメントにもありますが、正確には、"AWS::NoValue"を参照したpropertyは、当該resourceの定義から削除される、という挙動になります。

> **AWS::NoValue**
> 
> Removes the corresponding resource property when specified as a return value in the Fn::If intrinsic function.
> 
> For example, you can use the AWS::NoValue parameter when you want to use a snapshot for an Amazon RDS DB instance only if a snapshot ID is provided. If the UseDBSnapshot condition evaluates to true, AWS CloudFormation uses the DBSnapshotName parameter value for the DBSnapshotIdentifier property. If the condition evaluates to false, AWS CloudFormation removes the DBSnapshotIdentifier property.


### Multi-AZ相当構成
RDS Aurora ClusterのDB Instanceを1つ作るか／2つ作るかを、Parametersで制御します。

まずはParametersセクションで、これを制御するためのを値を設けます。
ここではEnableMultiAzというParameter名で、String型受け取ることとします。
次にConditionsセクションで、当該値が"true"であった場合は、enableMultiAzというConditionをtrueに設定します。

```yaml
Parameters:
# ※途中省略
  EnableMultiAz:
    Description: Decide whether the Aurora Cluster has a single instance or 2 instances.
    Type: String
    Default: false
    AllowedValues:
      - true
      - false

# ※途中省略

Conditions:
  isBrandNewDB: !Equals [ !Ref DBSnapshotArn, "" ]
  enableMultiAz: !Equals [ !Ref EnableMultiAz, "true" ]
```

AuroraのDB Instance作成時に、enableMultiAzがtrueの場合はDBInstance2を作成し、falseの場合は作成しない、というように定義します。

```yaml
Resources:
# ※途中省略
  DBInstance1:
    Type: AWS::RDS::DBInstance
    Properties:
      Engine: aurora-mysql
      DBClusterIdentifier: !Ref "DBCluster"
      DBInstanceClass: !FindInMap [RDS, !Ref "EnvName", "InstanceType"]
      DBSubnetGroupName: !Ref "DBSubnetGroup"
      DBParameterGroupName: !Ref "DBParameterGroup"

# ※途中省略

  DBInstance2:
    Type: AWS::RDS::DBInstance
    Condition: enableMultiAz
    Properties:
      Engine: aurora-mysql
      DBClusterIdentifier: !Ref "DBCluster"
      DBInstanceClass: !FindInMap [RDS, !Ref "EnvName", "InstanceType"]
      DBSubnetGroupName: !Ref "DBSubnetGroup"

```

#### AuroraのMulti-AZ構成について
ちなみにAuroraの場合は、正確にはMulti-AZ構成というものは存在しません。
これは旧来のRDSのみにある構成です。

Multi-AZ 配置 - Amazon RDS | AWS
https://aws.amazon.com/jp/rds/details/multi-az/

旧来のRDSでいうところのMulti-AZ構成とは、以下の特徴を持っています。
費用が2台分かかる割に、スレーブをリードレプリカとして使うことはできず、完全に可用性を向上させるためだけのものとして提供されています。

- DBインスタンスは2つのAZにて常時起動されている
- マスターとなるインスタンスからスレーブとなるインスタンスへ常時レプリケーションされている
- スレーブとなるインスタンスは、平常時は参照できない
- 他のホストから見えるのは、マスターとなっているインスタンスのみ
- マスターの障害時には、AWSインフラ内部で自動で切り替わる

Auroraの場合は、レプリカインスタンスを別のAZに配置し、プライマリインスタンスの障害時にfailoverする、という構成になります。
そのため設定も単純で、単に2台以上Instanceを作るか作らないか、というだけの制御になります。


### Resourceの名称を指定する／指定しないケースの挙動の差異
今回のtemplateでは、Aurora Clusterの名称は指定したもので固定し、DB Instanceの名称はランダムで指定される、という動作になります。

具体的には、Aurora Cluster作成時にはDBClusterIdentifierを指定し、DB Instance作成時にはDBInstanceIdentifierを指定しない、というように定義しています。

```yaml
# ※必要個所のみ抜粋
Resources:
  DBCluster:
    Type: AWS::RDS::DBCluster
    DeletionPolicy: Snapshot
    Properties:
      Engine: aurora-mysql
      EngineVersion: 5.7.12
      SnapshotIdentifier:
        !If [isBrandNewDB, !Ref "AWS::NoValue", !Ref "DBSnapshotArn" ]
      DBClusterIdentifier: !FindInMap [RDS, !Ref "EnvName", "ClusterName"]
      MasterUsername:
       !If [isBrandNewDB, !Ref "DBMasterUserName", !Ref "AWS::NoValue" ]
      MasterUserPassword:
       !If [isBrandNewDB, !Ref "DBMasterUserPassword", !Ref "AWS::NoValue" ]
      DBSubnetGroupName: !Ref "DBSubnetGroup"
      DBClusterParameterGroupName: !Ref "DBClusterParameterGroup"
      VpcSecurityGroupIds:
        - Fn::ImportValue: !Sub ${EnvName}-SecurityGroupInternal

  DBInstance1:
    Type: AWS::RDS::DBInstance
    Properties:
      Engine: aurora-mysql
      DBClusterIdentifier: !Ref "DBCluster"
      DBInstanceClass: !FindInMap [RDS, !Ref "EnvName", "InstanceType"]
      DBSubnetGroupName: !Ref "DBSubnetGroup"
      DBParameterGroupName: !Ref "DBParameterGroup"

  DBInstance2:
    Type: AWS::RDS::DBInstance
    Condition: enableMultiAz
    Properties:
      Engine: aurora-mysql
      DBClusterIdentifier: !Ref "DBCluster"
      DBInstanceClass: !FindInMap [RDS, !Ref "EnvName", "InstanceType"]
      DBSubnetGroupName: !Ref "DBSubnetGroup"

```

これは、StackのUpdateをする際に重要になってきます。
CloudFormationのドキュメントにおいて、Propertyが「Update requires: Replacement」となっているものは、当該Propertyの変更時には当該Resourceの作り直しを意味しています。

新規にResourceを作る→既存のResource削除する、という動きになりますが、名前を指定していると、新規のResourceを指定できず、Updateが失敗します。

これは公式ドキュメントにも記載があります。
そのため、DB Instanceの名称を指定する、DBInstanceIdentifierは指定しないようにします。

AWS::RDS::DBInstance - AWS CloudFormation
https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-rds-database-instance.html#cfn-rds-dbinstance-dbinstanceidentifier

> DBInstanceIdentifier
> 
> A name for the DB instance. If you specify a name, AWS CloudFormation converts it to lowercase. If you don't specify a name, AWS CloudFormation generates a unique physical ID and uses that ID for the DB instance. For more information, see Name Type.
> 
> **Important**
> 
> If you specify a name, you cannot perform updates that require replacement of this resource. You can perform updates that require no or some interruption. If you must replace the resource, specify a new name.
> 
> Required: No
> 
> Type: String
> 
> Update requires: Replacement


では、Aurora Clusterの方のDBClusterIdentifierも指定しない方が良いのでは、という疑問もあります。
これは以下の2点の理由により、指定しないメリットが殆どありません。
名称を明示的にした方が人が見たときに分かりやすくなるので、DBClusterIdentifierは指定します。

- Aurora ClusterのPropertyのうち、Update requiresがreplacementのものがあまり無い。
- DBClusterIdentifierやEngineぐらいで、これらは作り直しレベルであるため、この場合はいずれにせよ人手で対応する必要がある（Stack再構築時にSnapshotのARNを指定するため）


### ClusterのEndPointに対し、分かりやすいドメイン名のCNAMEレコードを作る
AuroraのClusterは、読み書き可能なEndpointと読み取り専用のEndpointを持っています。

- 読み書き可能Endpoint（Cluster Endpoint）
    - dev1-main-rds-cluster.cluster-xxxxxxxxxxxx.ap-northeast-1.rds.amazonaws.com
- 読み取り専用Endpoint（Reader Endpoint）
    - dev1-main-rds-cluster.cluster-ro-xxxxxxxxxxxx.ap-northeast-1.rds.amazonaws.com

Endpointが長いことと、Clusterを作りなおした際にCluster名が変わった場合はこのEndpointも変更になることから、分かりやすい別名をつけたほうが便利です。
このため、Route53でCNAMEレコードを作ります。


```yaml
Resources:
# ※途中省略
  RdsWriterDnsRecord:
    Type: "AWS::Route53::RecordSet"
    Properties:
      HostedZoneName:
        Fn::ImportValue: 
          !Sub "${EnvName}-Route53HostedZoneName"
      Name:
        Fn::Join:
          - ""
          - - "rds-writer."
            - Fn::ImportValue: !Sub "${EnvName}-Route53HostedZoneName"
      Type: CNAME
      ResourceRecords:
        - !GetAtt DBCluster.Endpoint.Address
      TTL: "60"

  RdsReaderDnsRecord:
    Type: "AWS::Route53::RecordSet"
    Properties:
      HostedZoneName:
        Fn::ImportValue:
          !Sub "${EnvName}-Route53HostedZoneName"
      Name:
        Fn::Join:
          - ""
          - - "rds-reader."
            - Fn::ImportValue: !Sub "${EnvName}-Route53HostedZoneName"
      Type: CNAME
      ResourceRecords:
        - !GetAtt DBCluster.ReadEndpoint.Address
      TTL: "60"

```

ここでのポイントは、関数の組み合わせに関して注意が必要、という点です。

!ImportValue関数と!Sub関数は、短縮形は共存できないことは上述しました。
さらに、短縮形の関数の引数として、完全関数名の関数は含められないようです。

下記のように、!Join関数の短縮形にFn::ImportValueの完全関数名を渡そうとすると、構文エラーになります。
したがって上記の例では、Fn::Joinを完全関数名で表記し、さらにパラメータもyaml的な配列で表現しています。

```yaml
  RdsReaderDnsRecord:
    Type: "AWS::Route53::RecordSet"
    Properties:
      HostedZoneName:
        Fn::ImportValue:
          !Sub "${EnvName}-Route53HostedZoneName"
      Name:
        !Join ["", ["rds-reader.", Fn::ImportValue: !Sub "${EnvName}-Route53HostedZoneName"]]
      Type: CNAME
      ResourceRecords:
        - !GetAtt DBCluster.ReadEndpoint.Address
      TTL: "60"
```

# おわりに
RDS、というよりAuroraをCloudFormationで構築する際に必要なポイント、つまづきやすいポイントをまとめました。
調べると確かにドキュメントに記載があるのですが、作っていくときにはなかなか気づきにくい点が多いです。

本稿がCloudFormation利用の際の一助になれば幸いです。