スナップショットからリストアしたRDSを引き続きCloudFormationの管理下にする方法

# 背景
普段CloudFormationのテンプレートでリソースの定義を書いており、あらゆる設定変更はテンプレート経由ですることを心掛けていますが、
RDSをスナップショットからリストアしたい場合は「ごめんやで…ごめんやで…」と思いながらマネージメントコンソールから手作業をしてしまっていました。
どこかでトラブルを起こしそうなのでCloudFormationの枠組みの中で完結させるやり方を調べてみました。

# テスト用CloudFormationテンプレート
- ポイントは以下の3つです。
    - RDSのリソース`AWS::RDS::DBInstance`の`DBSnapshotIdentifier`でスナップショットの名前を指定する。
    - RDSのリソース`AWS::RDS::DBInstance`の`UpdateReplacePolicy`を`Retain`にしてスタック更新時に古いインスタンスをすぐに消さない。
    - RDSのエンドポイントの名前解決はプライベートホストゾーンを使う。RDSのリストア前後でアプリ側の設定変更をしたくないし、なるべく短時間で切り替えたかったので。
- RDSのエンジンはPostgreSQLです。その他の設定は適当です。
- DBの動作確認用にEC2インスタンスを作っています。
- SSM用やS3のエンドポイントなどのリソースを作成していますが、本質でないのでスルーしてください。

```yaml:rds.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "Snapshot Restore Test"

Metadata: 
  AWS::CloudFormation::Interface: 
    ParameterGroups: 
      - 
        Label: 
          default: "Configuration"
        Parameters: 
          - DBInstanceName
          - DBSnapshotIdentifier
          - DBPassword
          - ClientKeyName

Parameters:
  DBInstanceName:
    Type: String

  DBSnapshotIdentifier:
    Type: String

  DBPassword:
    Type: String
    MinLength: 8
    NoEcho: true

  ClientKeyName:
    Type: "AWS::EC2::KeyPair::KeyName"

Conditions:
  RestoreFromSnapshot:
    !Not [!Equals [!Ref DBSnapshotIdentifier, ""]]

Resources:
  VPC:
    Type: "AWS::EC2::VPC"
    Properties:
      CidrBlock: "10.1.0.0/16"
      EnableDnsSupport: true
      EnableDnsHostnames: true
      InstanceTenancy: "default"
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-rds-test-vpc"

  PrivateSubnet1:
    Type: "AWS::EC2::Subnet"
    Properties:
      AvailabilityZone: !Sub "${AWS::Region}a"
      CidrBlock: "10.1.64.0/20"
      VpcId: !Ref VPC
      MapPublicIpOnLaunch: false
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-rds-test-private-subnet01"

  PrivateSubnet2:
    Type: "AWS::EC2::Subnet"
    Properties:
      AvailabilityZone: !Sub "${AWS::Region}c"
      CidrBlock: "10.1.80.0/20"
      VpcId: !Ref VPC
      MapPublicIpOnLaunch: false
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-rds-test-private-subnet02"

  RouteTable:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref VPC
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-rds-test-rtb"

  SubnetRouteTableAssociation1:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      RouteTableId: !Ref RouteTable
      SubnetId: !Ref PrivateSubnet1

  SubnetRouteTableAssociation2:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      RouteTableId: !Ref RouteTable
      SubnetId: !Ref PrivateSubnet2

  SSMEndpointSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "cfn-rds-test-ssm-ep-sg"
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: "cfn-rds-test-ssm-ep-sg"
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: !GetAtt VPC.CidrBlock

  SSMEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      PrivateDnsEnabled: true
      SecurityGroupIds:
        - !Ref SSMEndpointSecurityGroup
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ssm
      SubnetIds:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
      VpcEndpointType: Interface
      VpcId: !Ref VPC

  SSMEndpointMessages:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      PrivateDnsEnabled: true
      SecurityGroupIds:
        - !Ref SSMEndpointSecurityGroup
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ssmmessages
      SubnetIds:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
      VpcEndpointType: Interface
      VpcId: !Ref VPC

  SSMEndpointEC2:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      PrivateDnsEnabled: true
      SecurityGroupIds:
        - !Ref SSMEndpointSecurityGroup
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ec2messages
      SubnetIds:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
      VpcEndpointType: Interface
      VpcId: !Ref VPC

  S3Endpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      RouteTableIds:
        - !Ref RouteTable
      ServiceName: !Sub com.amazonaws.${AWS::Region}.s3
      VpcEndpointType: Gateway
      VpcId: !Ref VPC

  DBInstance:
    Type: "AWS::RDS::DBInstance"
    Properties:
      DBInstanceIdentifier: !Ref DBInstanceName
      DBSnapshotIdentifier:  !If
        - RestoreFromSnapshot
        - !Ref DBSnapshotIdentifier
        - !Ref "AWS::NoValue"
      AllocatedStorage: 20
      DBInstanceClass: "db.t3.micro"
      Engine: "postgres"
      MasterUsername: "postgres"
      MasterUserPassword: !Ref DBPassword
      EngineVersion: "14.6"
      PubliclyAccessible: false
      StorageType: "gp2"
      Port: 5432
      DBSubnetGroupName: !Ref DBSubnetGroup
      VPCSecurityGroups: 
        - !Ref DBSecurityGroup
      MaxAllocatedStorage: 1000
      DBParameterGroupName: "default.postgres14"
      OptionGroupName: "default:postgres-14"
      CACertificateIdentifier: "rds-ca-2019"
    UpdateReplacePolicy: "Retain"
    DeletionPolicy: "Delete"

  DBSubnetGroup:
    Type: "AWS::RDS::DBSubnetGroup"
    Properties:
      DBSubnetGroupDescription: "cfn-rds-test-rds-subnet-group"
      DBSubnetGroupName: "cfn-rds-test-subnet-group"
      SubnetIds: 
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2

  DBSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupDescription: "cfn-rds-test-rds-sg"
      GroupName: "cfn-rds-test-sg"
      VpcId: !Ref VPC
      SecurityGroupIngress: 
        - 
          CidrIp: !GetAtt VPC.CidrBlock
          FromPort: 5432
          IpProtocol: "tcp"
          ToPort: 5432
      SecurityGroupEgress: 
        - 
          CidrIp: "0.0.0.0/0"
          IpProtocol: "-1"

  Client:
    Type: "AWS::EC2::Instance"
    Properties:
      ImageId: "ami-0df2ca8a354185e1e"
      InstanceType: "t2.micro"
      KeyName: !Ref ClientKeyName
      AvailabilityZone: !Sub "${AWS::Region}a"
      Tenancy: "default"
      SubnetId: !Ref PrivateSubnet1
      EbsOptimized: false
      SecurityGroupIds: 
        - !Ref ClientSecurityGroup
      SourceDestCheck: true
      BlockDeviceMappings: 
        - 
          DeviceName: "/dev/xvda"
          Ebs: 
            Encrypted: false
            VolumeSize: 8
            VolumeType: "gp3"
            DeleteOnTermination: true
      IamInstanceProfile: !Ref ClientInstanceProfile
      Tags: 
        - 
          Key: "Name"
          Value: "cfn-test-client"
      UserData:
        !Base64 |
          #!/bin/bash
          amazon-linux-extras install -y postgresql14
              
  ClientSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      GroupDescription: "cfn-rds-test-client-sg"
      GroupName: "cfn-rds-test-client-sg"
      VpcId: !Ref VPC
      SecurityGroupIngress: 
        - 
          CidrIp: "0.0.0.0/0"
          FromPort: 22
          IpProtocol: "tcp"
          ToPort: 22
      SecurityGroupEgress: 
        - 
          CidrIp: "0.0.0.0/0"
          IpProtocol: "-1"

  ClientRole:
    Type: "AWS::IAM::Role"
    Properties:
      Path: "/"
      RoleName: "cfn-rds-test-client-role"
      AssumeRolePolicyDocument: 
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      MaxSessionDuration: 3600
      ManagedPolicyArns: 
        - "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"

  ClientInstanceProfile:
    Type: "AWS::IAM::InstanceProfile"
    Properties:
      Path: "/"
      InstanceProfileName: !Ref ClientRole
      Roles: 
        - !Ref ClientRole

  PrivateHostedZone:
    Type: AWS::Route53::HostedZone
    Properties:
      Name: "cfn-rds-test-hostedzone.com"
      HostedZoneConfig:
        Comment: "cfn-rds-test-hostedzone"
      VPCs:
        -
          VPCId: !Ref VPC
          VPCRegion: !Ref "AWS::Region"

  PrivateHostedZoneDBRecord:
    Type: AWS::Route53::RecordSet
    Properties:
      Name: "rds.cfn-rds-test-hostedzone.com"
      Comment: "cfn-rds-test-rds"
      HostedZoneId: !Ref PrivateHostedZone
      ResourceRecords:
        - !GetAtt DBInstance.Endpoint.Address
      TTL: 60
      Type: "CNAME"
```

# 使い方
ここで想定するのは以下のようなシナリオです。
1. まずは何もない状態から上記のテンプレートを使って初期状態のリソースを作成する。
1. EC2から適当にデータを書き込んでスナップショットを取得しておく。
1. 上記のテンプレートを使ってスナップショットからリストアする。
1. EC2の接続先をリストア後のRDSインスタンスに切り替える。
1. リストア後のRDSインスタンスの設定をテンプレートを使って更新し、リストア後もCloudFormationの管理下であることを確認する。

## 初期状態のリソース作成
1. CloudFormationの「スタックの作成」より上記のテンプレートを選択し、以下のように適当なパラメータを入力します。
    - まずは空っぽのRDSを作るので`DBSnapshotIdentifier`はブランクにしておきます。
    - `ClientKeyName`には検証用のEC2インスタンスのSSHキーを指定します。
    - それ以外のパラメータについてはご自由にどうぞ。
    ![stack-inputs.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/ad2be301-2655-ff93-a6cd-b5d0549ddcd1.png)
1. EC2にSession Managerで接続して以下のコマンドを実行します。
    - 1秒ごとに現在の時刻をDBに書き込みます。ちゃんとデータがリストアできたか見たいだけです。
    ```bash
    export PGPASSWORD=<設定したパスワード>
    export PGHOST=rds.cfn-rds-test-hostedzone.com
    export PGPORT=5432
    export PGDATABASE=postgres
    export PGUSER=postgres
    
    psql -c "create table test_table (data varchar(20));"
    psql -c "\dt"
    for i in $(seq 1 10); do now=$(date "+%H:%M:%S"); psql -c "insert into test_table values ('Hello $now');"; sleep 1; done
    psql -c "select * from test_table;"
    ```
    - 下のような感じに適当なデータを書きました。
    ```
          data
    ----------------
     Hello 14:20:03
     Hello 14:20:04
     Hello 14:20:05
     Hello 14:20:06
     Hello 14:20:07
     Hello 14:20:08
     Hello 14:20:10
     Hello 14:20:11
     Hello 14:20:12
     Hello 14:20:13
    ```

## スナップショットの作成
普通にスナップショットを取るだけです。
![create-snapshot.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/d8cde201-f81e-c9c6-9063-d24602e0df7d.png)

## スナップショットからリストア
1. リストア中にEC2からの通信がどうなるか見たいので下のコマンドで無限ループを作ってRDSにデータを書き込み続けます。
    ```bash
    while true; do now=$(date "+%H:%M:%S"); psql -c "insert into test_table values ('Hi $now');"; sleep 1; done
    ```
1. 最初に作成したCloudFormationのスタックを開き、変更セットを作成します。このときに以下の点に注意します。
    - `DBInstanceName`は以前のものと違うものにする。そうしないと名前がダブってエラーになります。
    - `DBSnapshotIdentifier`は前の手順で作成したスナップショットの名前を指定する。
    ![update-set.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/8048e34a-6a1d-0301-23eb-994931346e1e.png)
1. 変更セットを作成し、変更内容を確認します。
    - ちなみに「スナップショットからリストアする」と言うと、すでにあるRDSインスタンスにデータを流し込んで過去の状態に戻すようなイメージがありますが、実際はスナップショットから別のRDSのインスタンスを作るだけです。
    - CloudFormationでスナッかんｎョットからリストアすると古いRDSインスタンスを削除して、スナップショットから復元した新しいRDSインスタンスを作成するという"置換"の動作になります。
    - ところが、`UpdateReplacePolicy`が`Retain`にしていると置換の場合でも古いRDSインスタンスは削除されません。このような指定をしなくても最終的な結果は同じなのですが、古いインスタンスの削除中と新しいインスタンスの作成中は完全なダウンタイムになってしまいます。
    ![diff.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/20aabab2-b192-1dd0-e05b-a82138fd8243.png)
1. 変更セットの適用後、しばらくするとリストアしたインスタンスが作成され、同時にプライベートホストゾーンの設定も切り替わります。
    ![restored-instance.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/5a101245-60a3-1663-78a3-59633e11d7ee.png)
1. EC2インスタンス側の様子を見てみるとDBの接続にエラーは発生していない模様です。`nslookup`などで名前解決結果を確認するとリストアしたインスタンスに切り替わっているようです。

## リストアの後始末
ここで新旧のRDSインスタンスのDBの中身を比べてみます。
ちょっとわかりにくいですが、旧インスタンスへの書き込みは`14:56:01`を最後に途絶え、
それ以降は新インスタンスに`14:56:02`のデータとして書き込まれています。
また、新インスタンスにはスナップショット作成時に存在していた`14:20`頃のデータが復元されています。
- 旧インスタンス
![old-instance-data.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/0cb19da5-cd80-d350-f9be-e38e839295e7.png)
- 新インスタンス
![new-instance-data.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/070cb71a-1502-0a3f-53e8-dab8a2f3f86e.png)

スナップショット作成後に旧インスタンスに追加されたデータは必要に応じて新インスタンスに移行する必要があるかもしれません。
そのような後始末がすべて完了したら古いRDSインスタンスは削除してしまいましょう。

## テンプレートの更新
リストアしたRDSのDBの容量をテンプレートを使って更新してみます。
1. テンプレートを以下のように編集します。ディスクの容量をアップしてみました。
    ```diff_yaml:rds.yml(抜粋)
    (前略)
      DBInstance:
        Type: "AWS::RDS::DBInstance"
        Properties:
          DBInstanceIdentifier: !Ref DBInstanceName
          DBSnapshotIdentifier:  !If
            - RestoreFromSnapshot
            - !Ref DBSnapshotIdentifier
            - !Ref "AWS::NoValue"
    -     AllocatedStorage: 20
    +     AllocatedStorage: 30
          DBInstanceClass: "db.t3.micro"
          Engine: "postgres"
          MasterUsername: "postgres"
          MasterUserPassword: !Ref DBPassword
          EngineVersion: "14.6"
          PubliclyAccessible: false
          StorageType: "gp2"
          Port: 5432
          DBSubnetGroupName: !Ref DBSubnetGroup
          VPCSecurityGroups: 
            - !Ref DBSecurityGroup
          MaxAllocatedStorage: 1000
          DBParameterGroupName: "default.postgres14"
          OptionGroupName: "default:postgres-14"
          CACertificateIdentifier: "rds-ca-2019"
        UpdateReplacePolicy: "Retain"
        DeletionPolicy: "Delete"
    (後略)
    ```
1. 変更セットを作成しますが、このとき`DBSnapshotIdentifier`は**変更してはいけません**。これをブランクにしたりすると新しいインスタンスが作られてしまいます。
    ![update-storage.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57609/8bc8ca1e-0f57-7991-9c7f-d21544dea504.png)
1. しばらく待つと無事RDSの容量が変更されます。

ちなみに余談ですが、DBの名前である`DBInstanceIdentifier`をCloudFormationから変更した場合、必ず"置換"になります。マネージメントコンソールからだと既存のインスタンスの名前を変更することができるのですが、CloudFormationからはできません。なぜそのようになっているかはわかりません。

# 派生形
- リストア後にアプリ側で接続先を簡単に切り替えられる場合はプライベートホストゾーンを使用しなくても構いません。
- アプリ側で切り替えが難しいが、プライベートホストゾーンも作成したくない場合はRDSの名前のダブりの回避をしないといけないので手順がややこしい＆ダウンタイムが長いです。以下の手順のように仮のリストアインスタンスを作って古いインスタンスを削除してから再度最終的なリストアインスタンスを作ります。要は2回リストアします。
    1. 上記の手順のとおりリストアを実行する
    1. 古いインスタンスを手動で削除する(こいつはもうCloudFormation管理から外れているので手動でOK)。もしくはそもそも`UpdateReplacePolicy`のプロパティをつけないでおく。
    1. リストアしたインスタンスの`DBInstanceIdentifier`を古いインスタンスと同じものにする変更セットを作成して適用する。

# まとめ＆補足
- CloudFormationを使ってRDSをスナップショットからリストアし、リストア後も引き続きCloudFormationの管理下にする方法を調査しました。
- 他にいい方法があればぜひ教えてください。