Terraformで多数のDNSレコードを含むRoute53ホストゾーンを一括インポート

Terraformで多数のDNSレコードを含むRoute53ホストゾーンを一括インポート

Terraformで多数のDNSレコードを含むRoute53ホストゾーンをインポート
Clock Icon2025.07.14

Terraformで、多数のDNSレコードを含むAmazon Route 53ホストゾーンのインポートを行う機会がありましたので、その際の手順などを記載します。

経緯

あるAWSアカウント内でRoute53ホストゾーンが管理されていましたが、全てWeb Consoleで設定作業がされていて、100件以上のDNSレコードが作成されていました。
TerraformでIaC化を行うこととしましたが、手動で多数のDNSレコードのimport定義を作成するのはとても大変なため、スクリプトを準備して行いました。

手順の概要

以下のような手順としました。

  1. AWS CLIで、Route53ホストゾーンの情報をjsonファイルに出力
  2. jsonファイルから、スクリプトで、importブロックのtfファイルを作成
  3. 「terraform plan -generate-config-out=xxx.tf」で、import定義を基に、resource定義のtfファイルを作成
  4. resource定義のtfファイルを修正
  5. 「terraform plan」で確認
  6. 「terraform apply」でインポート

それぞれの手順の詳細を以下に記載します

AWS CLIで、Route53ホストゾーンの情報をjsonファイルに出力

まず、以下のようなコマンドで、対象のホストゾーンのDNSレコード情報を取得しました。
<ホストゾーンID>の部分は、Web Consoleにて、Route53の画面から対象ホストゾーンのホストゾーンIDを確認して入力します。
出力するファイル名は任意ですが、ホストゾーン名.jsonのようにすると、複数のホストゾーンを処理する場合などに分かりやすいと思います。(以下は、ホストゾーン名が hogehoge.hoge の場合の例です)

aws route53 list-resource-record-sets \
  --hosted-zone-id /hostedzone/<ホストゾーンID> \
  --query "ResourceRecordSets[*].{HostedZoneId:'<ホストゾーンID>', Name:Name, Type:Type}" \
  --output json > hogehoge.hoge.json

出力内容は以下のような形で、DNSレコードの数分だけ出力されます。

[
    {
        "HostedZoneId": "<ホストゾーンID>",
        "Name": "hogehoge.hoge.",
        "Type": "NS"
    },
    (省略)

jsonファイルから、スクリプトで、importブロックのtfファイルを作成

次に、上記のjsonファイルから、Terraformのimportブロックのtfファイルを作成します。これは、以下のようなPythonスクリプトを実行することにより、一回で作成できました。
(このスクリプトはAIで生成させました。コードの記述内容詳細の検証はしていませんが、今回の作業目的は達成できました。いくつか前提とした条件は別途下述します)

import json
import sys
import re

def sanitize_name(name):
    # ドットや特殊文字をアンダースコアに変換
    sanitized = re.sub(r'\W+', '_', name.strip('.'))
    # 先頭が数字ならアンダースコアを追加
    if re.match(r'^\d', sanitized):
        sanitized = f"_{sanitized}"
    return sanitized

def get_output_filename(zone_name):
    base = zone_name.strip('.')
    return f"{base}_import.tf"

def extract_subdomain(full_name, zone_name):
    if full_name.endswith(zone_name):
        return full_name[:-len(zone_name)].strip('.')
    return full_name.strip('.')

def generate_import_blocks(records):
    output_blocks = []
    seen_zone_ids = set()

    if not records:
        return output_blocks

    base_zone = records[0]["Name"]
    base_zone_clean = sanitize_name(base_zone)

    for record in records:
        zone_id = record["HostedZoneId"]
        name = record["Name"]
        rtype = record["Type"]

        # ゾーンの import ブロックは一度だけ
        if zone_id not in seen_zone_ids:
            block = f"""import {{
  to = aws_route53_zone.{base_zone_clean}
  id = "{zone_id}"
}}"""
            output_blocks.append(block)
            seen_zone_ids.add(zone_id)

        # ID 部分の生成
        record_id = f"{zone_id}_{name.strip('.')}_{rtype}"

        # リソース名の生成
        if name == base_zone:
            resource_name = f"{sanitize_name(name)}_{rtype.lower()}"
        else:
            subdomain = extract_subdomain(name, base_zone)
            resource_name = f"{sanitize_name(subdomain)}_{rtype.lower()}"

        block = f"""import {{
  to = aws_route53_record.{resource_name}
  id = "{record_id}"
}}"""
        output_blocks.append(block)

    return output_blocks

def main():
    if len(sys.argv) != 2:
        print("使い方: python generate_tf_imports.py input.json")
        sys.exit(1)

    input_file = sys.argv[1]

    with open(input_file, 'r') as f:
        records = json.load(f)

    if not records:
        print("JSON にレコードがありません。")
        sys.exit(1)

    zone_name = records[0]["Name"]
    output_file = get_output_filename(zone_name)

    blocks = generate_import_blocks(records)

    with open(output_file, 'w') as f:
        f.write("\n\n".join(blocks))

    print(f"{output_file} に Terraform import ブロックを書き出しました。")

if __name__ == "__main__":
    main()

上記スクリプトは、以下のようにして実行できます。スクリプトのファイル名を「generate_tf_imports.py」とした場合、上記のjsonファイルを引数で指定して実行すると、カレントディレクトリに「ホストゾーン名_import.tf」というファイル名で、該当のホストゾーン(aws_route53_zone)と全てのレコード(aws_route53_record)のimportブロックが出力されたファイルが作成されます。

python3 generate_tf_imports.py hogehoge.hoge.json

出力された結果は以下例のようになります。

import {
  to = aws_route53_zone.hogehoge_hoge
  id = "<ホストゾーンID>"
}

import {
  to = aws_route53_record.hogehoge_hoge_ns
  id = "<ホストゾーンID>_hogehoge.hoge_NS"
}

import {
  to = aws_route53_record.subdomain_a
  id = "<ホストゾーンID>_subdomain.hogehoge.hoge_A"
}

上記スクリプトはAIで生成させましたが、いくつか前提となっている条件を記載します。

  • import定義の中に、1つのaws_route53_zone(ホストゾーン)の定義を作成
    こちらに記載されている通り、idにはホストゾーンIDを指定。
  • aws_route53_zoneのtoで指定するリソース名は、ホストゾーン名とする。
  • import定義の中に、レコード数の分だけaws_route53_recordの定義を作成
    こちらに記載されている通り、idには、ホストゾーンID、レコード名、レコードタイプを_(アンダースコア)で連結して指定。
  • aws_route53_recordのtoで指定するリソース名は、以下の通りとする
    • レコード名=ホストゾーン名の場合・・・レコード名とレコードタイプを_(アンダースコア)で連結
    • レコード名≠ホストゾーン名の場合・・・サブドメイン名とレコードタイプを_(アンダースコア)で連結
  • リソース名は、ドット(.)やハイフン(-)などの文字が含まれる場合は、_ (アンダースコア)に変換する。先頭が数字だった場合は、その前に_(アンダースコア)を付与する

補足点

  • もしも、レコード名に = や * などの文字が含まれている場合には、該当レコードの import定義のみは、正常に変換されていませんでした。AWS CLIでjsonに出力した時点で、エスケープ文字になっていたためです。(例:= は \75)
    変換後に、\ のようなエスケープ文字が含まれていた場合は、変換が正常でないことを確認後、手動で修正しました。
    以下、=の文字がレコード名に含まれていた場合の修正例です。idに\075と表記されたままでは後続の処理で正常に動作しなかったため、web consoleでの表記と同じように=に修正しました。toのリソース名では=文字は使用できないため、この例では削除しています。
# 修正前
import {
  to = aws_route53_record.subdomain_075_a
  id = "<ホストゾーンID>_subdomain\075.hogehoge.hoge_A"
}

# 修正後
import {
  to = aws_route53_record.subdomain_a
  id = "<ホストゾーンID>_subdomain=.hogehoge.hoge_A"
}

「terraform plan -generate-config-out=xxx.tf」で、import定義を基に、resource定義のtfファイルを作成

次に以下のコマンドを実行します。(terraform initは完了している前提です)xxx.tfのファイル名は任意です。

terraform plan -generate-config-out=xxx.tf

xxx.tfに、importブロックで指定していたaws_route53_zoneとaws_route53_recordのリソース定義が出力されます。

(実行時にエラーメッセージが出力されましたが、xxx.tfにimportブロックで指定していたリソースのデータは全て出力されていて、以下で修正も行うため、今回の用途では問題ありませんでした。今回のようにスクリプトでimport定義のファイルを作らず、手動でimport定義のファイルを作っても、同様のエラーは表示されます)

resource定義のtfファイルを修正

上記で出力したtfファイルでは、不要なパラメータ等が存在し、上記で記載したエラーの原因になっているものもあるようでした。今回は、以下例のように修正しました。

  • aws_route53_zone
# 変更前
# __generated__ by Terraform from <ゾーンID>
resource "aws_route53_zone" "hogehoge_hoge" {
  comment           = "Sample"
  delegation_set_id = null
  force_destroy     = null
  name              = "hogehoge.hoge"
  tags              = {}
  tags_all          = {}
}

# 変更後
resource "aws_route53_zone" "hogehoge_hoge" {
  comment           = "Sample"
  name              = "hogehoge.hoge"
}
  • aws_route53_record(エイリアスレコード以外)
    レコードの定義は多数ありましたが、変更前の出力されるパラメータのパターンは全て同一で、変更後のパターンも同一だっため、エディタの一括編集機能で簡単に修正できました。
# 変更前
# __generated__ by Terraform
resource "aws_route53_record" "subdomain_a" {
  allow_overwrite                  = null
  health_check_id                  = null
  multivalue_answer_routing_policy = false
  name                             = "subdomain.hogehoge.hoge"
  records                          = ["x.x.x.x"]
  set_identifier                   = null
  ttl                              = 300
  type                             = "A"
  zone_id                          = "<ゾーンID>"
}

# 変更後
resource "aws_route53_record" "subdomain_a" {
  name                             = "subdomain.hogehoge.hoge"
  records                          = ["x.x.x.x"]
  ttl                              = 300
  type                             = "A"
  zone_id                          = aws_route53_zone.hogehoge_hoge.zone_id
}
  • aws_route53_record(エイリアスレコード)
    エイリアスレコードの場合は、上記のそれ以外のレコードと少しパラメータの内容が異なりますが、こちらも、変更前の出力されるパラメータのパターンと、変更後のパターンは同一だっため、エディタの一括編集機能で簡単に修正できました。

尚、aliasのブロック内に出力されているzone_idは、ユーザ側で作成しているRoute53ホストゾーンのIDとは異なり、aliasで設定しているAWSサービスの固有のゾーンIDとなり、AWS側で定義されているものです。以下の例だとcloudfrontですが、cloudfrontの場合には、こちらに記載されているゾーンID(Z2FDTNDATAQYW2)となります。(この値はサービスやリージョンなどにより異なり、上記リンク先のようにAWS公式サイトに記載されています)今回は、aliasのブロック内に表記されているzone_id(AWS側で定義)は、variableにて設定し、aliasのブロックからは参照する形としました。

# 変更前
# __generated__ by Terraform
resource "aws_route53_record" "sub_a" {
  allow_overwrite                  = null
  health_check_id                  = null
  multivalue_answer_routing_policy = false
  name                             = "sub.hogehoge.hoge"
  records                          = []
  set_identifier                   = null
  ttl                              = 0
  type                             = "A"
  zone_id                          = "<ゾーンID>"
  alias {
    evaluate_target_health = false
    name                   = "xxx.cloudfront.net"
    zone_id                = "Z2FDTNDATAQYW2"
  }
}

# 変更後
resource "aws_route53_record" "sub_a" {
  name    = "sub.hogehoge.hoge"
  type    = "A"
  zone_id = aws_route53_zone.hogehoge_hoge.zone_id
  alias {
    evaluate_target_health = false
    name                   = "xxx.cloudfront.net"
    zone_id                = var.cloudfront_zone_id
  }
}

variable "cloudfront_zone_id" {
  description = "Cloudfront Fixed Zone ID"
  type        = string
  default     = "Z2FDTNDATAQYW2"
}

「terraform plan」で確認

terraform planを行い、インポート対象リソースに間違いないことを確認しました。最後に出力されるPlan結果のimportの数が、importブロックで指定したリソース数(aws_route53_zoneの数+aws_route53_recordの数)と一致して、add, change , destroyの数が全て 0 であることを確認しました。

terraform plan

(実行結果例)
Plan: 123 to import, 0 to add, 0 to change, 0 to destroy

「terraform apply」でインポート

terraform applyを行い、インポートしました。実行結果は、直前のplanと同じになったことを確認しました。また、tfstateファイルが所定の保存場所(S3バケット)に作成・更新されたことを確認しました。

terraform apply

(実行結果例)
Apply complete! Resources: 123 imported, 0 added, 0 changed, 0 destroyed.

補足:AWS環境への接続設定について

今回、AWS CLIやterraformコマンドでAWS環境への接続を行っていますが、該当のAWSアカウントにはMFAで接続していたため、こちらの記事の設定内容で、接続を行いました。

おわりに

Terraformで、多数のDNSレコードを含むAmazon Route 53ホストゾーンをインポートする作業について記載しました。この記事が皆様のお役に立てば幸いです。

アノテーション株式会社について

アノテーション株式会社はクラスメソッドグループのオペレーション専門特化企業です。

サポート・運用・開発保守・情シス・バックオフィスの専門チームが、最新 IT テクノロジー、高い技術力、蓄積されたノウハウをフル活用し、お客様の課題解決を行っています。

当社は様々な職種でメンバーを募集しています。

「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、アノテーション株式会社 採用サイトをぜひご覧ください。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.