TerraformでCloud RunとCloud DNSのドメインマッピングを自動化する

## 2023/11/29 追記

以下の方法は正しく動作しますが、Cloud Runを新たに追加したり初回の実行時には以下のエラーが発生すると思われます。
```
│ The "count" value depends on resource attributes that cannot be determined until apply, so
│ Terraform cannot predict how many instances will be created. To work around this, use the -target
│ argument to first apply only the resources that the count depends on.
```
その場合、まずはCloud Runだけデプロイを先に実施し、その後DNSの設定を実行するという感じにTerraformコマンドを `target` オプションを使って分割する必要があります。
以下はmoduleを使ってTerraformの各種サービスを管理している場合のコマンド例です。
```
terraform plan -target=module.example.google_cloud_run_service.default
terraform apply -target=module.example.google_cloud_run_service.default

terraform plan -target=module.example.google_cloud_run_domain_mapping.default
terraform apply -target=module.example.google_cloud_run_domain_mapping.default
```

## はじめに
Cloud Runはデフォルトでは`*.run.app`のドメインが割り当てられるが、
これを本番で使うには心もとない。
Google Cloudには独自ドメインをCloud Runに適用する方法として大きく2つある

* [Cloud Load Balancing](https://cloud.google.com/load-balancing/docs/https/setting-up-reg-ext-https-serverless?hl=ja)を利用
* [Cloud DNSとCloud Runのドメインマッピング](https://cloud.google.com/run/docs/mapping-custom-domains?hl=ja#run)を利用

今回の記事では「ドメインマッピング」を利用し、Terraformで自動化してしまおうというもの。

なお、2023/6現在Cloud DNSとCloud Runのドメインマッピング機能はpre-GAなので制限付きであり、今後仕様変更される可能性もあることに注意。

## 前提条件

今回はCloud DNSを使うにあたり**ドメインをGoogle Domainで管理することを前提としている。**
Cloud DomainのGoogle Domainsからの管理移譲や他ドメインサービスからの移行に関してはTerraformでは実現できないので、
[https://support.google.com/domains/answer/10050215?hl=ja](https://support.google.com/domains/answer/10050215?hl=ja)を参考に手作業する。  
他社ドメインサービスをそのまま利用するにしてもCloud DNSのNSレコードを手動で設定する必要があるので、このあたりは結局Terraformでは対応不可であることに違いはない。

## Terraformを使用したCloud RunとCloud DNSの統合

Terraformの構成をすべて説明すると長くなるので、
ここではCloud RunとCloud DNSのドメインマッピングに関連するTerraformのコードのみを説明・記述する。
variables.tfやoutputs.tfの詳細と都度出てくる変数は各自の環境で埋めてほしい。

### ディレクトリ構成

```bash
.
├── environments
│   ├── dev
│   │   ├── main.tf
│   │   ├── terraform.tfvars
│   │   └── variables.tf
│   └── prod
└── modules
    ├── network
    │   ├── main.tf
    │   ├── outputs.tf
    │   └── variables.tf
    └── cloudrun
        ├── main.tf
        ├── outputs.tf
        └── variables.tf
```

### Network(Cloud DNS)の設定

```terraform:modules/network/main.tf
# Cloud DNSのゾーン設定
resource "google_dns_managed_zone" "myapp" {
  name       = "zone-myapp"
  dns_name   = "mydomain.com."
  visibility = "public"
  dnssec_config {
    state = "on"
  }
  cloud_logging_config {
    enable_logging = true
  }
}
```

Cloud DNSの設定は特に難しいことはなく、DNSSECとCloudLoggingを有効にしているくらい。

### Cloud Runの設定
Cloud RunのTerraformの全体は以下のとおり。

```terraform:modules/cloudrun/main.tf
# Cloud Runのサービス設定
resource "google_cloud_run_service" "default" {
  name     = var.cloudrun_service_name
  location = var.region

  # 重複エラーが起きるため、リビジョン名を自動生成し既存のものと重複しないようにする。
  autogenerate_revision_name = true

  template {
    metadata {
      annotations = {
        "autoscaling.knative.dev/maxScale"        = "1" #最小構成
        "autoscaling.knative.dev/minScale"        = "1"
        "run.googleapis.com/vpc-access-connector" = var.vpc_connector_id
        "run.googleapis.com/vpc-access-egress"    = var.vpc_connector_egress
      }
    }
    spec {
      containers {
        # 正しいイメージはCloud Buildでビルドしたものを指定するため、ここでは適当なものを指定
        image = "us-docker.pkg.dev/cloudrun/container/hello:latest"
        ports {
          container_port = var.port
        }
      }
    }
  }
  lifecycle {
    ignore_changes = [
      #デプロイするたびに差分が出るため無視
      template[0].metadata[0].labels["run.googleapis.com/startupProbeType"]
    ]
  }
}

# 未認証のアクセスを許可する設定
data "google_iam_policy" "noauth" {
  binding {
    role    = "roles/run.invoker"
    members = ["allUsers"]
  }
}
resource "google_cloud_run_v2_service_iam_policy" "noauth" {
  location = google_cloud_run_service.default.location
  project  = google_cloud_run_service.default.project
  name     = google_cloud_run_service.default.name

  policy_data = data.google_iam_policy.noauth.policy_data
}

# Cloud DNSとCloud Runのドメインマッピング
resource "google_cloud_run_domain_mapping" "default" {
  location = var.region
  name     = var.domain_name

  metadata {
    namespace = var.project
  }

  spec {
    route_name = google_cloud_run_service.default.name
  }
}

# ドメインマッピングで発生する各種DNSレコード情報を動的に収集
locals {
  dns_records_A    = [for rr in google_cloud_run_domain_mapping.default.status[0].resource_records : rr.rrdata if rr.type == "A"]
  dns_records_AAAA = [for rr in google_cloud_run_domain_mapping.default.status[0].resource_records : rr.rrdata if rr.type == "AAAA"]
  dns_record_WWW   = [for rr in google_cloud_run_domain_mapping.default.status[0].resource_records : rr.rrdata if rr.type == "CNAME"]
}

# A、AAAAレコードがない場合はCNAMEレコードを生成
resource "google_dns_record_set" "www" {
  count        = length(local.dns_records_A) > 0 || length(local.dns_records_AAAA) > 0 ? 0 : 1
  name         = "${var.domain_name}."
  type         = "CNAME"
  ttl          = 3600
  managed_zone = var.google_dns_managed_zone_name
  rrdatas      = local.dns_record_WWW
}

# Aレコードがある場合はAレコードを生成
resource "google_dns_record_set" "default_A" {
  count        = length(local.dns_records_A) > 0 ? 1 : 0
  managed_zone = var.google_dns_managed_zone_name
  name         = "${var.domain_name}."
  type         = "A"
  ttl          = 3600
  rrdatas      = local.dns_records_A
}

# AAAAレコードがある場合はAAAAレコードを生成
resource "google_dns_record_set" "default_AAAA" {
  count        = length(local.dns_records_AAAA) > 0 ? 1 : 0
  managed_zone = var.google_dns_managed_zone_name
  name         = "${var.domain_name}."
  type         = "AAAA"
  ttl          = 3600
  rrdatas      = local.dns_records_AAAA
}
```

簡単に解説する。

### Cloud Runのサービス設定

まず `google_cloud_run_v2_service` ではなく `google_cloud_run_service` リソースを使っている理由は、[terraform-provider-google/issues/14569](https://github.com/hashicorp/terraform-provider-google/issues/14569#issuecomment-1548587892)にて言及されているとおり、
v2だとリビジョン名を無視すると `terraform apply` 時に毎回差分が見つかってデプロイ対象になってしまうし、
無視しないとリビジョン名が重複するためデプロイに失敗するというデッドロックに陥るためである。

`image` にてコンテナイメージをダミーではなく実際のアプリのコンテナイメージに変更することで回避できるが、
初回のデプロイ時に `image` に指定したコンテナイメージが存在しないと `terraform apply` が失敗してしまう。  
そのため、初回デプロイ時はイメージを先にArtifact Registry、DockerHub、その他指定のURLに対してpushしておくこと。

### 未認証のアクセスを許可する設定

`data "google_iam_policy" "noauth"` 、`resource "google_cloud_run_v2_service_iam_policy" "noauth"` は、
Cloud Runを一般公開するための設定である。

### Cloud DNSとCloud Runのドメインマッピング

ここが本題。  
ドメインマッピング機能を利用すると、サブドメインを指定しなかったCloud Runのサービスに対してはAレコードやAAAAレコードが生成され、CNAMEが存在しない。
対して、サブドメインを指定したCloud Runのサービスに対しては、CNAMEレコードが生成され、A/AAAAレコードが存在しない。  
この仕様を利用して `locals` でDNSレコード情報を収集し、自動的にAレコードやAAAAレコード、CNAMEレコードを対象のDNSゾーンに登録するようにしている。  

参考にしたサイト:
[How to access Cloud Run service IPs from Terraform / Pulumi to dynamically create A records? - stackoverflow](https://stackoverflow.com/questions/60479206/how-to-access-cloud-run-service-ips-from-terraform-pulumi-to-dynamically-creat)

参考サイトの仕組みでは `rrdatas` が空の場合でもレコードが生成されてしまうため、ドメインとサブドメインをもつCloud Runを同時にマッピングすることができなかった。
そこでTerraformの `count` を使って場合分けする仕組みを使っている。  
このコードはなんと **ChatGPTが生成してくれた**。さすがGPT-4。

```terraform
#ドメインマッピングで発生する各種DNSレコード情報を動的に収集
locals {
  dns_records_A    = [for rr in google_cloud_run_domain_mapping.default.status[0].resource_records : rr.rrdata if rr.type == "A"]
  dns_records_AAAA = [for rr in google_cloud_run_domain_mapping.default.status[0].resource_records : rr.rrdata if rr.type == "AAAA"]
  dns_record_WWW   = [for rr in google_cloud_run_domain_mapping.default.status[0].resource_records : rr.rrdata if rr.type == "CNAME"]
}

# A、AAAAレコードがない場合はCNAMEレコードを生成
resource "google_dns_record_set" "default_WWW" {
  count        = length(local.dns_records_A) > 0 || length(local.dns_records_AAAA) > 0 ? 0 : 1
  name         = "${var.domain_name}."
  type         = "CNAME"
  ttl          = 3600
  managed_zone = var.google_dns_managed_zone_name
  rrdatas      = local.dns_record_WWW
}

# Aレコードがある場合はAレコードを生成
resource "google_dns_record_set" "default_A" {
  count        = length(local.dns_records_A) > 0 ? 1 : 0
  managed_zone = var.google_dns_managed_zone_name
  name         = "${var.domain_name}."
  type         = "A"
  ttl          = 3600
  rrdatas      = local.dns_records_A
}

# AAAAレコードがある場合はAAAAレコードを生成
resource "google_dns_record_set" "default_AAAA" {
  count        = length(local.dns_records_AAAA) > 0 ? 1 : 0
  managed_zone = var.google_dns_managed_zone_name
  name         = "${var.domain_name}."
  type         = "AAAA"
  ttl          = 3600
  rrdatas      = local.dns_records_AAAA
}
```

## おわりに

今回はCloud RunのサービスをドメインマッピングするためのTerraformコードを紹介した。
Cloud Runのドメインマッピングはpre-GAの機能であるため、今後仕様が変更される可能性があるので恒久的にこの仕組みが使えるわけではない。  
また、それなりに規模が大きく信頼性の求められるプロダクトではドメインマッピングを使わずにCloud Load Balancingを使うことが多いと思う。
そういう意味では今回のコードはニッチなノウハウになるかもしれない。  
ニッチな誰かの参考になれば幸いである。

## 参考

DNSにCloud DNSではなくCloudflareを使う場合：
https://alpacat.com/blog/cloud-run-with-cloudflare/