しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

Terraform と Azure DNS を使って Azure Front Door への Apex ドメイン割り当てを自動化する

Azure Front Door は Azure DNS のような Alias record set に対応したサービスと組み合わせると、Apex ドメインを割り当てて利用することが可能です。もちろん Managed Certificate として必要な証明書も自動で発行されるので HTTPS を無料で有効化できます。

手順は全て以下のドキュメントにまとまっているので、特に難しい作業なく有効化できます。

Azure DNS の Alias record set を利用することで以前問題となった Subdomain Takeover を防ぐことが出来るのでお勧めなのですが、Front Door の場合は Managed Certificate の自動更新が Alias record set を利用していると行われないという制約があります。

具体的には以下のように CNAME で Front Door のエンドポイントを指している場合のみ、Managed Certificate の自動更新が行われるとドキュメントにも記載されています。

Apex domains don't have a CNAME record pointing to an Azure Front Door endpoint, so the auto-rotation for managed certificate fails until the domain ownership is revalidated.

Apex domains in Azure Front Door | Microsoft Learn

流石にドキュメントが古いだけで、今は改善されているだろうと思って調べたのですが、以下の Issue にもあるように現時点でも挙動は改善されておらず、実際に半年前に割り当てた Apex ドメインの証明書が切れるという事象も確認されています。

自動更新が出来ない場合には、証明書の期限が切れる 45 日前から再検証が行えるようになるらしいです。もちろん Azure Portal からの手動での作業が必要となるので、証明書の期限切れの危険性が高まりますので、何らかの手を打つ必要があります。

同じように Alias record set を利用することで Apex ドメインと証明書の発行まで出来る Static Web Apps ではこのような問題は発生しないので、Front Door 側でも改善できるだろうと思っていますが、暫くはこのままだと思うので Terraform でのある程度の自動化を検証しました。

Terraform を利用して Front Door に Apex ドメインを追加

まずは最低限必要となる Terraform を利用した Front Door 関連リソースの作成を行っておきます。少し Front Door 周りの定義は癖がありますが、用語を理解しておけば何とかなるはずです。個人的にはカスタムドメインの割り当て周りで少しはまりましたが、最終的には以下のような定義に落ち着きました。

ちなみに Azure DNS 周りも Terraform で管理するようにしていますが、実際は DNS Zone を含めるのは少し難しいと思うので、DNS レコードのみ Terraform で管理するのが良さそうです。

provider "azurerm" {
  features {}
}

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

resource "azurerm_resource_group" "example" {
  name     = "rg-apex-demo"
  location = "West Europe"
}

resource "azurerm_cdn_frontdoor_profile" "example" {
  name                = "afd-apex-demo"
  resource_group_name = azurerm_resource_group.example.name
  sku_name            = "Standard_AzureFrontDoor"
}

resource "azurerm_cdn_frontdoor_endpoint" "example" {
  name                     = "fde-apex-demo"
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.example.id
}

resource "azurerm_cdn_frontdoor_origin_group" "example" {
  name                     = "apex-demo-origin-group"
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.example.id

  load_balancing {}
}

resource "azurerm_cdn_frontdoor_origin" "example" {
  name                          = "apex-demo-origin"
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.example.id
  enabled                       = true

  certificate_name_check_enabled = true

  host_name          = "shibayan.jp"
  http_port          = 80
  https_port         = 443
  origin_host_header = "shibayan.jp"
  priority           = 1
  weight             = 1
}

resource "azurerm_cdn_frontdoor_route" "example" {
  name                          = "apex-demo-route"
  cdn_frontdoor_endpoint_id     = azurerm_cdn_frontdoor_endpoint.example.id
  cdn_frontdoor_origin_group_id = azurerm_cdn_frontdoor_origin_group.example.id
  cdn_frontdoor_origin_ids      = [azurerm_cdn_frontdoor_origin.example.id]
  enabled                       = true

  forwarding_protocol    = "HttpsOnly"
  https_redirect_enabled = true
  patterns_to_match      = ["/*"]
  supported_protocols    = ["Http", "Https"]

  cdn_frontdoor_custom_domain_ids = [azurerm_cdn_frontdoor_custom_domain.example.id]
  link_to_default_domain          = false
}

resource "azurerm_cdn_frontdoor_custom_domain" "example" {
  name                     = "apex-demo-custom-domain"
  cdn_frontdoor_profile_id = azurerm_cdn_frontdoor_profile.example.id
  dns_zone_id              = azurerm_dns_zone.example.id
  host_name                = "e5c4p3.cloud"

  tls {
    certificate_type    = "ManagedCertificate"
    minimum_tls_version = "TLS12"
  }
}

resource "azurerm_dns_zone" "example" {
  name                = "e5c4p3.cloud"
  resource_group_name = azurerm_resource_group.example.name
}

resource "azurerm_dns_a_record" "example" {
  name                = "@"
  zone_name           = azurerm_dns_zone.example.name
  resource_group_name = azurerm_resource_group.example.name
  ttl                 = 3600
  target_resource_id  = azurerm_cdn_frontdoor_endpoint.example.id
}

resource "azurerm_dns_txt_record" "example" {
  name                = "_dnsauth"
  zone_name           = azurerm_dns_zone.example.name
  resource_group_name = azurerm_resource_group.example.name
  ttl                 = 3600

  record {
    value = azurerm_cdn_frontdoor_custom_domain.example.validation_token
  }
}

Front Door は出てくるリソースが多いので複雑そうに見えますが、ルーティングやオリジンの定義が多いだけで、一つずつ分解してみていくと大したことはしていません。Azure DNS 周りも Alias record set の作成と、ドメイン認証に利用する TXT レコードの作成ぐらいなのでシンプルです。

この Terraform 定義を使って Azure 上にリソースを作成して、DNS のネームサーバーを変更してしばらく待てば Front Door のプロビジョニングが完了して、以下のようにアクセスできるようになります。

証明書も 180 日有効なものが発行されているので、もちろん HTTPS での接続も問題なく行えます。

これで Azure リソースの構築はすべて完了していますが、180 日後には証明書の自動更新が行われず HTTPS で接続が出来なくなるという問題が発生してしまうので、Terraform 内で再検証に必要な処理を実行して、証明書の自動更新が行われるようにします

実現には AzureRM Provider だけでは不可能なので、ARM を直接扱える AzAPI Provider を利用します。

AzAPI Provider を利用して再検証 Action を実行

先に Front Door のカスタムドメイン再検証について簡単に説明しておくと、ARM 上はドメイン検証用トークンを再発行して、再度検証フローを実行する API が用意されています。

この API を実行するとカスタムドメインの紐づいている検証トークンが再発行されるので、後は TXT レコードの値を新しく発行されたトークンで更新してあげるだけですが、AzureRM Provider には ARM でいうところの Action に相当する機能が提供されていません。

Action を実行するために AzAPI Provider に用意された azapi_resource_action を使います。

詳細はドキュメントがあるので深くは説明しませんが、AzAPI Provider は非常に薄い ARM REST API のラッパーという感じなので、この Provider だけで全てのリソースを構築するというのは厳しいです。AzureRM Provider に足りない機能を補うために、ピンポイントで利用するのが適しています。

実際に azapi_resource_action を利用して検証用トークンの再発行を行う定義が以下になります。ぱっと見は Bicep っぽさがありますが、ARM REST API を直接叩いているので仕方ない部分です。

resource "time_rotating" "validation_token" {
  rotation_days = 150
}

resource "time_static" "validation_token" {
  rfc3339 = time_rotating.validation_token.rfc3339
}

resource "azapi_resource_action" "refresh_validation_token" {
  type        = "Microsoft.Cdn/profiles/customDomains@2023-05-01"
  resource_id = azurerm_cdn_frontdoor_custom_domain.example.id
  action      = "refreshValidationToken"
  method      = "POST"

  lifecycle {
    replace_triggered_by = [ time_static.validation_token ]
  }
}

time_rotatingtime_static を組み合わせることで、一定時間の経過後に azapi_resource_action が再実行されるようにしています。すなわち、150 日経過した後に Terraform を実行すると検証用トークンが再発行されるということです。

上で作成した Front Door をプロビジョニングする Terraform 定義にこのリソースを追加すると、最初は azapi_resource_action が存在しないので以下のように検証用トークンを再発行しようとします。

Action のリソースを作成すると検証用トークンを再発行し、検証フローを再度開始するので Azure Portal 上は Validation State が Pending に変わります。

そしてもう一度 Terraform を実行すると azurerm_cdn_frontdoor_custom_domain に紐づいている検証トークンの値が変化していることが Terraform によって報告されます。本来なら Action の実行時の 1 回だけで完結させたいですが、検証トークンの再発行が非同期で行われるため困難です。

そのタイミングで報告された Terraform のプランでは Azure DNS の TXT レコードが更新対象だと報告されます。先の Action 実行によって検証トークンの値が変化しているので、Terraform がそれに自動的に追従しようとしてくれていることが確認できますね。

そのまま Terraform から TXT レコードの値を更新し、暫く待つと Azure Portal 上の Validation State が Pending から Approved に変化して、証明書も発行されていることが確認できます。

Azure Portal で手動実行するよりは明らかに Terraform 上で全てを定義した方が、単純にコマンドを実行するだけで自動的に更新されるので安全ですが、期限切れ前に Terraform を実行しないと意味がないです。

この問題を回避するためには Drift detection と同じような考え方で、定期的に terraform plan を実行する仕組みが必要になりそうです。*1

*1:Terraform Cloud で Drift detection を有効化しておくと検出されるかもしれません(未確認