背景
普段CloudFormationのテンプレートでリソースの定義を書いており、あらゆる設定変更はテンプレート経由ですることを心掛けていますが、
RDSをスナップショットからリストアしたい場合は「ごめんやで…ごめんやで…」と思いながらマネージメントコンソールから手作業をしてしまっていました。
どこかでトラブルを起こしそうなのでCloudFormationの枠組みの中で完結させるやり方を調べてみました。
テスト用CloudFormationテンプレート
- ポイントは以下の3つです。
- RDSのリソース
AWS::RDS::DBInstance
のDBSnapshotIdentifier
でスナップショットの名前を指定する。 - RDSのリソース
AWS::RDS::DBInstance
のUpdateReplacePolicy
をRetain
にしてスタック更新時に古いインスタンスをすぐに消さない。 - RDSのエンドポイントの名前解決はプライベートホストゾーンを使う。RDSのリストア前後でアプリ側の設定変更をしたくないし、なるべく短時間で切り替えたかったので。
- RDSのリソース
- RDSのエンジンはPostgreSQLです。その他の設定は適当です。
- DBの動作確認用にEC2インスタンスを作っています。
- SSM用やS3のエンドポイントなどのリソースを作成していますが、本質でないのでスルーしてください。
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"
使い方
ここで想定するのは以下のようなシナリオです。
- まずは何もない状態から上記のテンプレートを使って初期状態のリソースを作成する。
- EC2から適当にデータを書き込んでスナップショットを取得しておく。
- 上記のテンプレートを使ってスナップショットからリストアする。
- EC2の接続先をリストア後のRDSインスタンスに切り替える。
- リストア後のRDSインスタンスの設定をテンプレートを使って更新し、リストア後もCloudFormationの管理下であることを確認する。
初期状態のリソース作成
- CloudFormationの「スタックの作成」より上記のテンプレートを選択し、以下のように適当なパラメータを入力します。
- EC2にSession Managerで接続して以下のコマンドを実行します。
- 1秒ごとに現在の時刻をDBに書き込みます。ちゃんとデータがリストアできたか見たいだけです。
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
スナップショットの作成
スナップショットからリストア
- リストア中にEC2からの通信がどうなるか見たいので下のコマンドで無限ループを作ってRDSにデータを書き込み続けます。
while true; do now=$(date "+%H:%M:%S"); psql -c "insert into test_table values ('Hi $now');"; sleep 1; done
- 最初に作成したCloudFormationのスタックを開き、変更セットを作成します。このときに以下の点に注意します。
- 変更セットを作成し、変更内容を確認します。
- ちなみに「スナップショットからリストアする」と言うと、すでにあるRDSインスタンスにデータを流し込んで過去の状態に戻すようなイメージがありますが、実際はスナップショットから別のRDSのインスタンスを作るだけです。
- CloudFormationでスナッかんnョットからリストアすると古いRDSインスタンスを削除して、スナップショットから復元した新しいRDSインスタンスを作成するという"置換"の動作になります。
- ところが、
UpdateReplacePolicy
がRetain
にしていると置換の場合でも古いRDSインスタンスは削除されません。このような指定をしなくても最終的な結果は同じなのですが、古いインスタンスの削除中と新しいインスタンスの作成中は完全なダウンタイムになってしまいます。
- 変更セットの適用後、しばらくするとリストアしたインスタンスが作成され、同時にプライベートホストゾーンの設定も切り替わります。
- EC2インスタンス側の様子を見てみるとDBの接続にエラーは発生していない模様です。
nslookup
などで名前解決結果を確認するとリストアしたインスタンスに切り替わっているようです。
リストアの後始末
ここで新旧のRDSインスタンスのDBの中身を比べてみます。
ちょっとわかりにくいですが、旧インスタンスへの書き込みは14:56:01
を最後に途絶え、
それ以降は新インスタンスに14:56:02
のデータとして書き込まれています。
また、新インスタンスにはスナップショット作成時に存在していた14:20
頃のデータが復元されています。
スナップショット作成後に旧インスタンスに追加されたデータは必要に応じて新インスタンスに移行する必要があるかもしれません。
そのような後始末がすべて完了したら古いRDSインスタンスは削除してしまいましょう。
テンプレートの更新
リストアしたRDSのDBの容量をテンプレートを使って更新してみます。
- テンプレートを以下のように編集します。ディスクの容量をアップしてみました。
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" (後略)
- 変更セットを作成しますが、このとき
DBSnapshotIdentifier
は変更してはいけません。これをブランクにしたりすると新しいインスタンスが作られてしまいます。
- しばらく待つと無事RDSの容量が変更されます。
ちなみに余談ですが、DBの名前であるDBInstanceIdentifier
をCloudFormationから変更した場合、必ず"置換"になります。マネージメントコンソールからだと既存のインスタンスの名前を変更することができるのですが、CloudFormationからはできません。なぜそのようになっているかはわかりません。
派生形
- リストア後にアプリ側で接続先を簡単に切り替えられる場合はプライベートホストゾーンを使用しなくても構いません。
- アプリ側で切り替えが難しいが、プライベートホストゾーンも作成したくない場合はRDSの名前のダブりの回避をしないといけないので手順がややこしい&ダウンタイムが長いです。以下の手順のように仮のリストアインスタンスを作って古いインスタンスを削除してから再度最終的なリストアインスタンスを作ります。要は2回リストアします。
- 上記の手順のとおりリストアを実行する
- 古いインスタンスを手動で削除する(こいつはもうCloudFormation管理から外れているので手動でOK)。もしくはそもそも
UpdateReplacePolicy
のプロパティをつけないでおく。 - リストアしたインスタンスの
DBInstanceIdentifier
を古いインスタンスと同じものにする変更セットを作成して適用する。
まとめ&補足
- CloudFormationを使ってRDSをスナップショットからリストアし、リストア後も引き続きCloudFormationの管理下にする方法を調査しました。
- 他にいい方法があればぜひ教えてください。