2026年6月13日

2026年6月13日

Azure DNS で複数ドメインを管理する方法|Terraform の for_each でゾーンとレコードをまとめて扱う

Azure DNS で複数のドメインを扱うなら、ゾーンとレコードを Terraform のコードにまとめ、ドメインごとの違いを変数(マップ)に寄せて for_each で展開する形にすると、ドメインが増えても同じ手順で追加・変更・レビューができます。この記事では、azurerm プロバイダーを使ったファイル構成、複数ゾーンのレコードを一つの変数から作る書き方、既存ゾーンの取り込み、レジストラ側のネームサーバー設定、誤って消さないための守り方までを順に説明します。

コードは手元の環境に合わせて書き換える前提の例です。azurerm プロバイダーはメジャーバージョンで書き方が変わることがあるため、使うバージョンのドキュメントで引数名を確かめてください。例は 4.x 系を想定しています(2026年10月現在)。本番のゾーンに適用する前に、テスト用のドメインか検証用のサブスクリプションで一度通しておくと安心です。

全体の構成を決める

複数ドメインの管理で最初に決めるのは「何を一つの state にまとめるか」です。ドメインが数個から十数個程度で、管理する担当者も同じなら、一つの Terraform 構成(一つの state)に全ゾーンを入れて構いません。部署ごとに担当が分かれている、あるいはドメイン数が多く plan に時間がかかる場合は、ドメインのグループごとに構成を分けます。

おすすめの最小構成は次の4ファイルです。

  • versions.tf: Terraform とプロバイダーのバージョン、state の保存先
  • variables.tf: ゾーンとレコードの定義を受け取る変数
  • main.tf: リソースグループ、ゾーン、レコード
  • terraform.tfvars: ドメインごとの具体的な値

ドメインごとに .tf ファイルを増やしていく書き方もできますが、レコードの書式がばらつきやすく、レビューもしにくくなります。値は tfvars、構造は main.tf と分けておくと、新しいドメインの追加は tfvars に数行足すだけになります。

プロバイダーと state の設定

state にはゾーンの構成がすべて記録されます。手元のファイルに置くと複数人で扱えず、消えたときに復旧も難しいため、Azure Storage のバックエンドに置きます。ストレージアカウントは DNS とは別のリソースグループに作り、削除ロックをかけておくと事故を防げます。

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }

  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstateexample"
    container_name       = "tfstate"
    key                  = "dns.tfstate"
  }
}

provider "azurerm" {
  features {}
  # 4.x 系ではサブスクリプション ID の指定が求められる(環境変数 ARM_SUBSCRIPTION_ID でも可)
  subscription_id = var.subscription_id
}

認証は、手元では az login の資格情報、CI ではサービスプリンシパルやワークロード ID フェデレーション(OIDC)を使うのが一般的です。CI 用の ID には、DNS ゾーンを置くリソースグループに対して「DNS Zone Contributor」など必要な範囲のロールだけを割り当て、サブスクリプション全体の共同作成者のような広い権限は避けます。シークレットをコードや tfvars に書かないことも大切です。

変数でドメインごとの違いを表す

ドメインごとに違うのは、A レコードの IP、CNAME の向き先、MX、TXT(SPF や所有権確認)などです。これを一つのマップ型の変数で受け取ります。optional() を使うと、レコードの種類を使わないドメインでは記述を省略できます。

variable "subscription_id" {
  type = string
}

variable "zones" {
  description = "ドメイン名をキーにしたゾーン定義"
  type = map(object({
    a     = optional(map(list(string)), {})
    cname = optional(map(string), {})
    txt   = optional(map(list(string)), {})
    mx = optional(list(object({
      preference = number
      exchange   = string
    })), [])
  }))
}

値の例です。頂点(ドメインそのもの)は @ と書きます。

zones = {
  "example.com" = {
    a     = { "@" = ["203.0.113.10"] }
    cname = { "www" = "example.com" }
    txt   = { "@" = ["v=spf1 include:_spf.example.net ~all"] }
    mx    = [{ preference = 10, exchange = "mx1.example.net" }]
  }
  "example.jp" = {
    a = { "@" = ["203.0.113.20"], "www" = ["203.0.113.20"] }
  }
}

ゾーンとレコードを for_each で作る

ゾーンは変数のキーをそのまま for_each に渡します。レコードは「ゾーン × 名前」の組み合わせになるため、locals で平らなマップに直してから展開します。キーを "ゾーン/名前" の形にしておくと、plan の出力でどのドメインのどのレコードかが読み取りやすくなります。

resource "azurerm_resource_group" "dns" {
  name     = "rg-dns"
  location = "japaneast"
}

resource "azurerm_dns_zone" "this" {
  for_each            = var.zones
  name                = each.key
  resource_group_name = azurerm_resource_group.dns.name

  lifecycle {
    prevent_destroy = true
  }
}

locals {
  a_records = merge({}, [
    for zone, cfg in var.zones : {
      for name, ips in cfg.a : "${zone}/${name}" => { zone = zone, name = name, records = ips }
    }
  ]...)

  cname_records = merge({}, [
    for zone, cfg in var.zones : {
      for name, target in cfg.cname : "${zone}/${name}" => { zone = zone, name = name, target = target }
    }
  ]...)

  txt_records = merge({}, [
    for zone, cfg in var.zones : {
      for name, values in cfg.txt : "${zone}/${name}" => { zone = zone, name = name, values = values }
    }
  ]...)

  mx_zones = { for zone, cfg in var.zones : zone => cfg.mx if length(cfg.mx) > 0 }
}

resource "azurerm_dns_a_record" "this" {
  for_each            = local.a_records
  name                = each.value.name
  zone_name           = azurerm_dns_zone.this[each.value.zone].name
  resource_group_name = azurerm_resource_group.dns.name
  ttl                 = 300
  records             = each.value.records
}

resource "azurerm_dns_cname_record" "this" {
  for_each            = local.cname_records
  name                = each.value.name
  zone_name           = azurerm_dns_zone.this[each.value.zone].name
  resource_group_name = azurerm_resource_group.dns.name
  ttl                 = 300
  record              = each.value.target
}

resource "azurerm_dns_txt_record" "this" {
  for_each            = local.txt_records
  name                = each.value.name
  zone_name           = azurerm_dns_zone.this[each.value.zone].name
  resource_group_name = azurerm_resource_group.dns.name
  ttl                 = 3600

  dynamic "record" {
    for_each = each.value.values
    content {
      value = record.value
    }
  }
}

resource "azurerm_dns_mx_record" "this" {
  for_each            = local.mx_zones
  name                = "@"
  zone_name           = azurerm_dns_zone.this[each.key].name
  resource_group_name = azurerm_resource_group.dns.name
  ttl                 = 3600

  dynamic "record" {
    for_each = each.value
    content {
      preference = record.value.preference
      exchange   = record.value.exchange
    }
  }
}

output "name_servers" {
  value = { for zone, z in azurerm_dns_zone.this : zone => z.name_servers }
}

Azure DNS は「名前とタイプの組み合わせ」を一つのレコードセットとして扱います。同じ名前・同じタイプを二つのリソースで定義すると、後から適用した側が上書きしたり、作成時に衝突したりします。上の書き方では、同じ名前の A レコードは一つのリストにまとめて書く決まりにしておくと、この問題を避けられます。また CNAME は、同じ名前にほかのタイプのレコードを置けません。頂点に CNAME は置けないため、頂点を Azure のリソース(Front Door やパブリック IP など)に向けたいときは、A レコードの target_resource_id(エイリアスレコード)を使う方法がありますが、対応するリソースの種類は公式ドキュメントで確認してください。

適用とネームサーバーの設定

terraform plan で、作成されるゾーンとレコードの件数が想定どおりかを確かめてから terraform apply します。ゾーンを作っただけではインターネットからは使われません。レジストラ(ドメインを契約している事業者)で、ネームサーバーを Azure DNS のものに変更して初めて有効になります。

Azure DNS のネームサーバーは、ゾーンごとに割り当てられ、ドメインによって組み合わせが異なります。output に出した name_servers を確認し、ドメインごとにその4つをレジストラに登録します。別のドメインの値を使い回すと、委任が正しく行われません。

terraform output name_servers

# 切り替え前に、割り当てられたネームサーバーへ直接問い合わせて答えを確かめる
dig @ns1-01.azure-dns.com example.com A +norec +short
dig @ns1-01.azure-dns.com example.com MX +norec +short

ns1-01.azure-dns.com の部分は、output に出た自分のゾーンの値に置き換えてください。運用中のドメインを別の DNS から移す場合は、切り替えの前日までに旧 DNS 側で TTL を下げておき、切り替え後もしばらくは旧 DNS のレコードを残しておきます。

既存のゾーンを Terraform に取り込む

すでにポータルや Azure CLI で作ったゾーンがある場合は、Terraform 1.5 以降の import ブロックで state に取り込めます。取り込む前に、どんなレコードがあるかを一覧にしておきます。

az network dns zone list -o table
az network dns record-set list -g rg-dns -z example.com -o table
import {
  to = azurerm_dns_zone.this["example.com"]
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-dns/providers/Microsoft.Network/dnsZones/example.com"
}

import {
  to = azurerm_dns_a_record.this["example.com/@"]
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-dns/providers/Microsoft.Network/dnsZones/example.com/A/@"
}

取り込んだ後の terraform plan で「変更なし」になるまで tfvars の値を合わせます。差分が残ったまま apply すると、既存のレコードが tfvars の内容で書き換わります。ゾーンの頂点にある NS と SOA は Azure が管理しているため、通常は Terraform で定義しません。取り込みの作業前には、レコード一覧を CLI で JSON に書き出して保存しておくと、万一のときに元へ戻せます。

誤って消さないための守り方

DNS のゾーンを消すと、そのドメインのサイトとメールが止まります。作り直しても割り当てられるネームサーバーが変わることがあり、レジストラ側の設定までやり直しになります。次の守りを重ねておきます。

  • prevent_destroy: 上の例のようにゾーンに付けておくと、tfvars からドメインを消したときに apply がエラーで止まります。本当に廃止するときだけ外します。
  • 管理ロック: azurerm_management_lock で DNS のリソースグループに削除ロック(CanNotDelete)をかけると、Terraform 以外の操作による削除も防げます。
  • plan のレビュー: CI で plan の結果をプルリクエストに表示し、destroy を含む変更は人が確認してから適用します。

まとめ

  • ドメインごとの違いはマップ型の変数に寄せ、ゾーンとレコードは for_each で展開すると、ドメインの追加が tfvars の数行で済みます。
  • state は Azure Storage のバックエンドに置き、CI の ID には DNS のリソースグループに限ったロールを与えます。
  • 同じ名前・同じタイプのレコードは一つにまとめて定義し、CNAME の同居や頂点の CNAME に注意します。
  • ネームサーバーはゾーンごとに割り当てられるため、output の値をドメインごとにレジストラへ登録します。
  • 既存ゾーンは import ブロックで取り込み、plan が変更なしになってから運用に入ります。prevent_destroy と管理ロックで削除事故を防ぎます。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲