はじめに

平素は大変お世話になっております。
クイックガードのパー子です。

AWS CloudFormation で管理している Amazon Route 53 のホストゾーンを別のスタックに移動してみました。

基本的に 公式ドキュメントどおりの手順 にしたがえばリソースを破壊することなくスタック間移動できるはずなのですが、DNS は Webサービスの提供において最も重要なリソースです。
リソースの移動中にゾーンやレコードの再作成が発生してサービスが中断することは万が一にも避けたいところです。

本記事では、公式ドキュメントに記載された手順を実際に実行してみて、無停止でスタック間の移動ができることを確かめました。

手順の概要

手順はざっくり以下の流れです。

移動元のスタックでやること:

  1. 対象のリソースに DeletionPolicy: Retain をセットしてデプロイする。
  2. テンプレートから対象リソースの記述を削除してデプロイする。

移動先のスタックでやること:

  1. テンプレートに対象リソースを追記する。
    このとき、DeletionPolicy: Retain とする。
    続いてインポート操作を実行し、対象リソースをテンプレートに紐付ける。
  2. 必要に応じて DeletionPolicy を変更する。

無停止であることの確認

Route 53 のレコードには、CloudFormation においてリソースを一意に識別する ID が存在しないようです。

どういうことかというと、例えば CloudFormation の実行前後で abc. というレコードが存在した場合、それが実行の前後で同一のリソースをそのまま維持したのか? それとも旧abc. を廃棄して新abc. を作り直したのか?
そのどちらなのか、何らかの ID を見て判断できないのです。

目視に頼らずこの変化を観測するには、CloudTrail で ChangeResourceRecordSets というイベントを探す必要があります。
レコードを作成/削除/変更する際にこのイベントが発行されるため、CloudFormation の実行前後で CloudTrail を観察すればレコードが変更されたかどうかを知ることができるのです。

なお、このイベントは us-east-1リージョンの CloudTrail に記録されます。

今回の検証では、gondawara.yumeko という夢のマイドメインを建てて say.gondawara.yumeko (Type: TXT) が YUMEKO! と返すようにレコードを登録します。

$ aws route53 list-resource-record-sets --hosted-zone-id 'Z005575336OGNL4QJGGU6'
ResourceRecordSets:
- Name: gondawara.yumeko.
  ResourceRecords:
  - Value: ns-1690.awsdns-19.co.uk.
  - Value: ns-1325.awsdns-37.org.
  - Value: ns-654.awsdns-17.net.
  - Value: ns-266.awsdns-33.com.
  TTL: 172800
  Type: NS
- Name: gondawara.yumeko.
  ResourceRecords:
  - Value: ns-1690.awsdns-19.co.uk. awsdns-hostmaster.amazon.com. 1 7200 900 1209600
      86400
  TTL: 900
  Type: SOA
- Name: say.gondawara.yumeko.
  ResourceRecords:
  - Value: '"YUMEKO!"'
  TTL: 300
  Type: TXT

この TXTレコードに対するイベントが、一連の移動手順の最中に発生しなければヨシとします。

ツール

生の CloudFormationテンプレートを書くのはちょっと面倒くさいので、AWS CDK (Cloud Development Kit) を使用しました。

検証

それでは検証を始めます。

初期状態

まず初期状態としてスタックを 2つ用意します。

移動元スタック FromStack と移動先スタック ToStack です。

bin/app.ts
import { App } from "aws-cdk-lib";
import "source-map-support/register";
import { FromStack } from "../lib/from-stack";
import { ToStack } from "../lib/to-stack";

const app = new App();

new FromStack(app, "FromStack");
new ToStack(app, "ToStack");

まず FromStack にゾーン&レコードを配置します。

lib/from-stack.ts
import { Duration, Stack } from "aws-cdk-lib";
import { PublicHostedZone, TxtRecord } from "aws-cdk-lib/aws-route53";
import { Construct } from "constructs";

import type { StackProps } from "aws-cdk-lib";

export class FromStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const zone = new PublicHostedZone(this, "HostedZone", {
      zoneName: "gondawara.yumeko",
    });

    const record = new TxtRecord(this, "TxtRecord", {
      zone: zone,
      recordName: "say",
      values: ["YUMEKO!"],
      ttl: Duration.minutes(5),
    });
  }
}

ToStack は空のスタックです。

lib/to-stack.ts
import { Stack } from "aws-cdk-lib";
import { Construct } from "constructs";

import type { StackProps } from "aws-cdk-lib";

export class ToStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);
  }
}

これらのスタックを $ npx cdk deploy --all でデプロイしておきます。

FromStack のリソースと gondawara.yumekoドメインはそれぞれ以下のようになります。

移動準備

ゾーンとレコードに DeletionPolicy: Retain をセットします。

lib/from-stack.ts
--- a/lib/from-stack.ts
+++ b/lib/from-stack.ts
@@ -1,4 +1,4 @@
-import { Duration, Stack } from "aws-cdk-lib";
+import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
 import { PublicHostedZone, TxtRecord } from "aws-cdk-lib/aws-route53";
 import { Construct } from "constructs";

@@ -18,5 +18,8 @@ export class FromStack extends Stack {
       values: ["YUMEKO!"],
       ttl: Duration.minutes(5),
     });
+
+    zone.applyRemovalPolicy(RemovalPolicy.RETAIN);
+    record.applyRemovalPolicy(RemovalPolicy.RETAIN);
   }
 }

CDK には DeletionPolicy という名称は存在せず、代わりに RemovalPolicy というプロパティを使用します。
これは DeletionPolicy だけでなく UpdateReplacePolicy も同時に設定するものだからなのです。

$ npx cdk deploy FromStack で FromStack だけデプロイすると、ゾーンとレコードに DeletionPolicy: Retain (と、ついでに UpdateReplacePolicy: Retain) がセットされます。

FromStack から切り離し

準備が整ったので対象リソースを FromStack から切り離します。

lib/from-stack.ts
--- a/lib/from-stack.ts
+++ b/lib/from-stack.ts
@@ -1,5 +1,4 @@
-import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
-import { PublicHostedZone, TxtRecord } from "aws-cdk-lib/aws-route53";
+import { Stack } from "aws-cdk-lib";
 import { Construct } from "constructs";

 import type { StackProps } from "aws-cdk-lib";
@@ -7,19 +6,5 @@ import type { StackProps } from "aws-cdk-lib";
 export class FromStack extends Stack {
   constructor(scope: Construct, id: string, props?: StackProps) {
     super(scope, id, props);
-
-    const zone = new PublicHostedZone(this, "HostedZone", {
-      zoneName: "gondawara.yumeko",
-    });
-
-    const record = new TxtRecord(this, "TxtRecord", {
-      zone: zone,
-      recordName: "say",
-      values: ["YUMEKO!"],
-      ttl: Duration.minutes(5),
-    });
-
-    zone.applyRemovalPolicy(RemovalPolicy.RETAIN);
-    record.applyRemovalPolicy(RemovalPolicy.RETAIN);
   }
 }

これをデプロイすると、リソースは削除されることなくスタックの管理下から外れます。

CloudFormation のコンソール上でイベントを確認すると、DELETE_SKIPPED となっていることがわかります。

実際、CloudTrail を見ても、切り離し中 (= 15:49:01〜15:49:02 前後) にゾーンの変更イベントは発生していません。

ToStack へゾーンをインポート

続いて、切り離したゾーンとレコードを ToStack へインポートしますが、まずはゾーンだけインポートします。

ToStack に以下のようにゾーンを定義します。
この段階では DeletionPolicy: Retain をセットしておく必要があります。

lib/to-stack.ts
--- a/lib/to-stack.ts
+++ b/lib/to-stack.ts
@@ -1,4 +1,5 @@
-import { Stack } from "aws-cdk-lib";
+import { RemovalPolicy, Stack } from "aws-cdk-lib";
+import { PublicHostedZone } from "aws-cdk-lib/aws-route53";
 import { Construct } from "constructs";

 import type { StackProps } from "aws-cdk-lib";
@@ -6,5 +7,11 @@ import type { StackProps } from "aws-cdk-lib";
 export class ToStack extends Stack {
   constructor(scope: Construct, id: string, props?: StackProps) {
     super(scope, id, props);
+
+    const zone = new PublicHostedZone(this, "HostedZone", {
+      zoneName: "gondawara.yumeko",
+    });
+
+    zone.applyRemovalPolicy(RemovalPolicy.RETAIN);
   }
 }

ゾーンを定義したら $ npx cdk diff ToStack を実行し、差分がインポート対象のリソースだけであることを確認します。

$ npx cdk diff ToStack
start: Building 645d176a4ee7d49434b189a1a7850124f8ef0a7ad7dc9544941f7b377cef8f67:current_account-current_region
success: Built 645d176a4ee7d49434b189a1a7850124f8ef0a7ad7dc9544941f7b377cef8f67:current_account-current_region
start: Publishing 645d176a4ee7d49434b189a1a7850124f8ef0a7ad7dc9544941f7b377cef8f67:current_account-current_region
success: Published 645d176a4ee7d49434b189a1a7850124f8ef0a7ad7dc9544941f7b377cef8f67:current_account-current_region
Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)
Stack ToStack
Resources
[+] AWS::Route53::HostedZone HostedZone HostedZoneDB99F866


✨  Number of stacks with differences: 1

問題なければ $ npx cdk import ToStack でインポートを実行します。
このとき、ゾーンID の入力を求められるので入力します。

$ npx cdk import ToStack
ToStack
ToStack/HostedZone/Resource (AWS::Route53::HostedZone): enter Id (empty to skip): Z005575336OGNL4QJGGU6
ToStack: importing resources into stack...
ToStack: creating CloudFormation changeset...

 ✅  ToStack
Import operation complete. We recommend you run a drift detection operation to confirm your CDK app resource definitions are up-to-date. Read more here: <https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/detect-drift-stack.html>

無事にゾーンがインポートされました。

この段階ではまだレコードはインポートされていませんが、Route 53上のレコードとしては特に削除されることなどもなく、そのまま維持されています。

なお、インポートする前に $ npx cdk diff ... を実行しておかないと S3 error: Access Denied というエラーが発生してインポートに失敗するのでご注意ください。
($ npx cdk diff ... を実行することで、インポートに必要なリソースが S3 にアップロードされます)

ToStack へレコードをインポート

この調子でレコードも ToStack へインポート… したいところですが、残念ながらレコード (AWS::Route53::RecordSetリソース) は インポートに対応していません

そのため、ゾーンと同じノリでインポートしようとしても空振りしてしまいます。

lib/to-stack.ts
--- a/lib/to-stack.ts
+++ b/lib/to-stack.ts
@@ -1,5 +1,5 @@
-import { RemovalPolicy, Stack } from "aws-cdk-lib";
-import { PublicHostedZone } from "aws-cdk-lib/aws-route53";
+import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
+import { PublicHostedZone, TxtRecord } from "aws-cdk-lib/aws-route53";
 import { Construct } from "constructs";

 import type { StackProps } from "aws-cdk-lib";
@@ -12,6 +12,14 @@ export class ToStack extends Stack {
       zoneName: "gondawara.yumeko",
     });

+    const record = new TxtRecord(this, "TxtRecord", {
+      zone: zone,
+      recordName: "say",
+      values: ["YUMEKO!"],
+      ttl: Duration.minutes(5),
+    });
+
     zone.applyRemovalPolicy(RemovalPolicy.RETAIN);
+    record.applyRemovalPolicy(RemovalPolicy.RETAIN);
   }
 }
$ npx cdk import ToStack
ToStack
ToStack/TxtRecord/Resource: unsupported resource type AWS::Route53::RecordSet, skipping import.
No resources selected for import.

ならばどうするかというと、インポートではなく、このまま普通にデプロイするだけで OK です。

$ npx cdk deploy ToStack

✨  Synthesis time: 2.99s

Stack undefined
ToStack: deploying... [1/1]
ToStack: creating CloudFormation changeset...

 ✅  ToStack

✨  Deployment time: 82.87s

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:123456789012:stack/ToStack/758e0c70-8531-11ef-8d22-0e57741be0eb

✨  Total time: 85.86s

これでレコードが CloudFormation の管理下に入ります。

イベントを見ると、既存のレコードがあるにも関わらず、競合することなくレコードの作成に成功しています。

CloudTrail を見てみると、連続して 2つの変更イベントが発生していました。

最初のイベントは、

{
  "eventVersion": "1.10",
  "userIdentity": {
    ...
  },
  "eventTime": "2024-10-08T08:11:31Z",
  "eventSource": "route53.amazonaws.com",
  "eventName": "ChangeResourceRecordSets",
  "awsRegion": "us-east-1",
  "sourceIPAddress": "cloudformation.amazonaws.com",
  "userAgent": "cloudformation.amazonaws.com",
  "errorCode": "InvalidChangeBatch",
  "errorMessage": "[Tried to create resource record set [name='say.gondawara.yumeko.', type='TXT'] but it already exists]",
  "requestParameters": {
    "hostedZoneId": "Z005575336OGNL4QJGGU6",
    "changeBatch": {
      "changes": [
        {
          "action": "CREATE",
          "resourceRecordSet": {
            "name": "say.gondawara.yumeko.",
            "type": "TXT",
            "tTL": 300,
            "resourceRecords": [
              {
                "value": "\"YUMEKO!\""
              }
            ]
          }
        }
      ]
    }
  },
  "responseElements": null,
  "additionalEventData": {
    "Note": "Do not use to reconstruct hosted zone"
  },
  "requestID": "625e8474-2045-42d0-bcfe-c395c2995ab5",
  "eventID": "0a7bec9b-c47a-42a5-b528-daa0e4dc98e1",
  "readOnly": false,
  "eventType": "AwsApiCall",
  "apiVersion": "2013-04-01",
  "managementEvent": true,
  "recipientAccountId": ...
  "eventCategory": "Management"
}

TXTレコードの作成を試みていますが、既に同名のレコードが存在するという理由で失敗しています。

“errorMessage”: “[Tried to create resource record set [name=‘say.gondawara.yumeko.’, type=‘TXT’] but it already exists]”,

続く 2つ目のイベントは、

{
  "eventVersion": "1.10",
  "userIdentity": {
    ...
  },
  "eventTime": "2024-10-08T08:11:32Z",
  "eventSource": "route53.amazonaws.com",
  "eventName": "ChangeResourceRecordSets",
  "awsRegion": "us-east-1",
  "sourceIPAddress": "cloudformation.amazonaws.com",
  "userAgent": "cloudformation.amazonaws.com",
  "requestParameters": {
    "hostedZoneId": "Z005575336OGNL4QJGGU6",
    "changeBatch": {
      "comment": "Making no-op change to get a ChangeId",
      "changes": [
        {
          "action": "DELETE",
          "resourceRecordSet": {
            "name": "say.gondawara.yumeko.",
            "type": "TXT",
            "tTL": 300,
            "resourceRecords": [
              {
                "value": "\"YUMEKO!\""
              }
            ]
          }
        },
        {
          "action": "CREATE",
          "resourceRecordSet": {
            "name": "say.gondawara.yumeko.",
            "type": "TXT",
            "tTL": 300,
            "resourceRecords": [
              {
                "value": "\"YUMEKO!\""
              }
            ]
          }
        }
      ]
    }
  },
  "responseElements": {
    "changeInfo": {
      "id": "/change/C00749273VM8093SWUIEB",
      "status": "PENDING",
      "submittedAt": "Oct 8, 2024 8:11:32 AM",
      "comment": "Making no-op change to get a ChangeId"
    }
  },
  "additionalEventData": {
    "Note": "Do not use to reconstruct hosted zone"
  },
  "requestID": "c2325280-5b80-4b65-861b-a8737809f39c",
  "eventID": "395589af-18e3-44fd-8bc3-63d0b3d5324f",
  "readOnly": false,
  "eventType": "AwsApiCall",
  "apiVersion": "2013-04-01",
  "managementEvent": true,
  "recipientAccountId": ...
  "eventCategory": "Management"
}

処理の中身としては TXTレコードの削除 + 作成なので、結果としてゾーンの変更はありません。

このイベントの出自はよくわかりませんが、

“comment”: “Making no-op change to get a ChangeId”,

とのことなので、CloudFormation で状態を追跡するために必要な処理なのだと思います。
(このあたりの話は GitHub Issue で言及がありました)

後始末

インポートが完了したら DeletionPolicy は削除して構いません。

lib/to-stack.ts
--- a/lib/to-stack.ts
+++ b/lib/to-stack.ts
@@ -1,4 +1,4 @@
-import { Duration, RemovalPolicy, Stack } from "aws-cdk-lib";
+import { Duration, Stack } from "aws-cdk-lib";
 import { PublicHostedZone, TxtRecord } from "aws-cdk-lib/aws-route53";
 import { Construct } from "constructs";

@@ -18,8 +18,5 @@ export class ToStack extends Stack {
       values: ["YUMEKO!"],
       ttl: Duration.minutes(5),
     });
- 
-    zone.applyRemovalPolicy(RemovalPolicy.RETAIN);
-    record.applyRemovalPolicy(RemovalPolicy.RETAIN);
   }
 }

まとめ

以上、Route 53 のホストゾーンについて、CloudFormationスタック間で無停止のリソース移動ができることを確かめました。

  • 基本的に公式ドキュメントの手順にしたがうことで、リソースを安全に移動できる。
  • レコードについてはインポート非対応。
    その代わり、新しいスタックで普通にレコードを作成することで移動できる。
  • リソースへの操作は CloudTrail で確認できる。

この記事が、これからホストゾーンを移動しようとしている方のお役に立てば幸いです。

今後ともよろしくお願い申し上げます。


当記事の図表には Material Symbols を使用しています。