Cloudflareドメインのアカウント間移動とIaC(#19)

はじめに

私が所属している研究室では、Cloudflare Registrarで独自ドメインを取得し管理しています。このドメインは、Cloudflare TunnelとCloudflare Accessを用いたVPNレスSSH#6)や、External DNSとCloudflare DNSを利用したサービスの名前解決(#16)などに利用されています。また、現在github.ioドメインでホストしている研究室のホームページにおいて、独自ドメインを使用することも検討しています。

さて、研究室のドメインは私が半年ほど前に私用のCloudflareアカウントで取得しました。よって、引き継ぎを行うには新しいアカウントを作成して別のメンバーを招待し、管理者権限を付与する必要があります。

本記事では、Cloudflare Registrarで登録したドメインをCloudflareアカウント間で移動させる方法と、それに関連してTerraformでCloudflareリソースをIaC化し、GitHub Actionsでワークフローを自動化する方法を紹介します。

1. 旧アカウントにおけるCloudflareリソースのIaC

Cloudflareの公式ドキュメントによると、アカウント間でドメインを移動させることによって、ソースアカウント上でそのドメインに紐付く構成や設定は失われてしまいます。

The move will result in the loss of all configurations and settings for the domain in the source account.

よって、ドメインの移動が完了してから、ソースアカウントの構成をターゲットアカウントで復元するまでの時間がサービスのダウンタイムになります。サービスは研究室内でのみ提供しているため長いダウンタイムも許容されますが、一部のメンバーは研究活動がサービスに依存しているためダウンタイムは短いに越したことはありません。

Cloudflareリソースは今まで手動で管理していましたが、IaC(Infrastructure as Code)を導入することで一般的には以下のようなメリットが得られます。

Benefits of adopting Infrastructure as Code:

  • Reduces cost
  • Increases speed of deployments
  • Reduces errors
  • Improves infrastructure consistency
  • Eliminates configuration drift

What is Infrastructure as Code (IaC)?』より引用

このタイミングでIaCを導入することにより、ドメイン移動のダウンタイムを最小限に抑えられるだけでなく、研究室メンバーへの引き継ぎも容易になります。研究室サーバーでは既にAnsibleを利用して構成管理(#2#8#12)を行なっていますが、今回はIaCツールとしてTerraformを利用してCloudflareのリソースを管理します。

Terraformのバージョン管理には、tfenvの後継であるtenvを利用します。

$ tenv tf install
# 省略
Installation of Terraform 1.14.0 successful

$ terraform version
Terraform v1.14.0
on darwin_arm64

developers.cloudflare.com

既存のCloudflareリソースをTerraformで管理するためには、Terraform configuration(.tfファイル)とTerraform state(.tfstateファイル)が必要です。一般的には、resourceブロックを記述してから terraform importコマンドでstateのみ取り込む方法や、importブロックを記述してからgenerate-config-outフラグ付きのterraform planコマンドでresourceブロックを生成する方法があります。

一方でCloudflareはcf-terraformingというツールを提供しており、Cloudflare API経由で情報を取得することでTerraform configurationを生成することができます。

github.com

リソースIDを指定する必要がある方法(terraform importコマンドやimportブロック)とは異なり、cf-terraformingは指定したリソースタイプの全リソースについてTerraform configurationを生成できるため、今回のようにIaCに移行するケースではより効率的なツールだと考えました。

まずはCloudflare ProviderのTerraform公式ドキュメントに従ってprovider.tfに以下を記述し、terraform initコマンドでプロジェクトを初期化します。

terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5"
    }
  }
}

provider "cloudflare" {
}

次に、READMEのSupported Resoucesを参考にリソースタイプを指定してコマンドを実行します。

$ export CLOUDFLARE_API_TOKEN='YOUR_CLOUDFLARE_API_TOKEN'
$ export CLOUDFLARE_TERRAFORM_BINARY_PATH=$(which terraform)
$ export CLOUDFLARE_ACCOUNT_ID='YOUR_CLOUDFLARE_ACCOUNT_ID'
# export CLOUDFLARE_ZONE_ID='YOUR_CLOUDFLARE_ZONE_ID'

$ cf-terraforming generate --resource-type "cloudflare_account"
INFO[0000] Using Terraform binary from explicit path     terraform-binary-path=/opt/homebrew/bin/terraform
INFO[0000] detected provider                             registry=registry.terraform.io/cloudflare/cloudflare version=5.13.0
resource "cloudflare_account" "terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0" {
  name = "Kitsuya Azuma"
  type = "standard"
  settings = {
    abuse_contact_email    = null
    access_approval_expiry = null
    api_access_enabled     = null
    enforce_twofactor      = false
    user_groups_ui_beta    = false
  }
}

CloudflareアカウントのTerraform configurationが標準出力に出力されました。リソース名のxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxの部分は、指定したアカウントIDになっているはずです。

ちなみにAPIトークン/アカウントID/ゾーンIDは、CLOUDFLARE_API_TOKENCLOUDFLARE_ACCOUNT_IDCLOUDFLARE_ZONE_IDという環境変数の他にtokenaccountzoneフラグでも指定することができますが、環境変数が指定されている場合は環境変数でオーバーライドされます。

リソースタイプはカンマ区切りで複数タイプ指定できますが、リソースタイプ毎に指定すべきIDタイプ(アカウントまたはゾーン)が異なるため、片方のIDを指定すべきリソースタイプでもう片方のIDも指定してしまうとエラー(FATA[0000] --account and --zone are mutually exclusive, support for both is deprecated)になってしまいます。そこで今回は、IDタイプ別にリソースタイプ配列を定義する、以下のようなシェルスクリプトを記述しました。

generate.sh

#!/bin/bash

OUTPUT_FILE="generated.tf"

if [ -z "$CLOUDFLARE_API_TOKEN" ]; then
    echo "Error: CLOUDFLARE_API_TOKEN environment variable is not set."
    exit 1
fi

if [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
    echo "Error: CLOUDFLARE_ACCOUNT_ID environment variable is not set."
    exit 1
fi

if [ -z "$CLOUDFLARE_ZONE_ID" ]; then
    echo "Error: CLOUDFLARE_ZONE_ID environment variable is not set."
    exit 1
fi

ACCOUNT_RESOURCES=(
    "cloudflare_account"
    "cloudflare_account_member"
    "cloudflare_account_subscription"
    "cloudflare_address_map"
    "cloudflare_calls_sfu_app"
    "cloudflare_calls_turn_app"
    "cloudflare_d1_database"
    "cloudflare_dns_firewall"
    "cloudflare_dns_zone_transfers_acl"
    "cloudflare_dns_zone_transfers_peer"
    "cloudflare_dns_zone_transfers_tsig"
    "cloudflare_email_routing_address"
    "cloudflare_email_security_block_sender"
    "cloudflare_email_security_impersonation_registry"
    "cloudflare_email_security_trusted_domains"
    "cloudflare_list"
    "cloudflare_load_balancer_monitor"
    "cloudflare_load_balancer_pool"
    "cloudflare_magic_wan_static_route"
    "cloudflare_mtls_certificate"
    "cloudflare_notification_policy"
    "cloudflare_notification_policy_webhooks"
    "cloudflare_pages_project"
    "cloudflare_queue"
    "cloudflare_r2_bucket"
    "cloudflare_registrar_domain"
    "cloudflare_stream"
    "cloudflare_stream_key"
    "cloudflare_stream_live_input"
    "cloudflare_stream_watermark"
    "cloudflare_stream_webhook"
    "cloudflare_turnstile_widget"
    "cloudflare_user"
    "cloudflare_web_analytics_site"
    "cloudflare_workers_custom_domain"
    "cloudflare_workers_for_platforms_dispatch_namespace"
    "cloudflare_workers_kv_namespace"
    "cloudflare_zero_trust_access_custom_page"
    "cloudflare_zero_trust_access_infrastructure_target"
    "cloudflare_zero_trust_access_key_configuration"
    "cloudflare_zero_trust_access_policy"
    "cloudflare_zero_trust_access_tag"
    "cloudflare_zero_trust_device_default_profile"
    "cloudflare_zero_trust_device_default_profile_local_domain_fallback"
    "cloudflare_zero_trust_device_managed_networks"
    "cloudflare_zero_trust_device_posture_integration"
    "cloudflare_zero_trust_device_posture_rule"
    "cloudflare_zero_trust_dex_test"
    "cloudflare_zero_trust_dlp_dataset"
    "cloudflare_zero_trust_dns_location"
    "cloudflare_zero_trust_gateway_certificate"
    "cloudflare_zero_trust_gateway_policy"
    "cloudflare_zero_trust_gateway_proxy_endpoint"
    "cloudflare_zero_trust_gateway_settings"
    "cloudflare_zero_trust_list"
    "cloudflare_zero_trust_risk_behavior"
    "cloudflare_zero_trust_risk_scoring_integration"
    "cloudflare_zero_trust_tunnel_cloudflared"
    "cloudflare_zero_trust_tunnel_cloudflared_route"
    "cloudflare_zero_trust_tunnel_cloudflared_virtual_network"
)

ZONE_RESOURCES=(
    "cloudflare_api_shield_discovery_operation"
    "cloudflare_api_shield_operation"
    "cloudflare_api_shield_schema"
    "cloudflare_api_shield_schema_validation_settings"
    "cloudflare_argo_smart_routing"
    "cloudflare_argo_tiered_caching"
    "cloudflare_authenticated_origin_pulls_certificate"
    "cloudflare_bot_management"
    "cloudflare_certificate_pack"
    "cloudflare_content_scanning_expression"
    "cloudflare_custom_hostname"
    "cloudflare_custom_hostname_fallback_origin"
    "cloudflare_dns_record"
    "cloudflare_dns_zone_transfers_incoming"
    "cloudflare_dns_zone_transfers_outgoing"
    "cloudflare_email_routing_catch_all"
    "cloudflare_email_routing_dns"
    "cloudflare_email_routing_rule"
    "cloudflare_email_routing_settings"
    "cloudflare_filter"
    "cloudflare_healthcheck"
    "cloudflare_keyless_certificate"
    "cloudflare_leaked_credential_check"
    "cloudflare_leaked_credential_check_rule"
    "cloudflare_load_balancer"
    "cloudflare_logpull_retention"
    "cloudflare_managed_transforms"
    "cloudflare_origin_ca_certificate"
    "cloudflare_page_rule"
    "cloudflare_page_shield_policy"
    "cloudflare_rate_limit"
    "cloudflare_regional_hostname"
    "cloudflare_regional_tiered_cache"
    "cloudflare_snippet_rules"
    "cloudflare_snippets"
    "cloudflare_spectrum_application"
    "cloudflare_tiered_cache"
    "cloudflare_total_tls"
    "cloudflare_url_normalization_settings"
    "cloudflare_waiting_room_settings"
    "cloudflare_web3_hostname"
    "cloudflare_zone"
    "cloudflare_zone_cache_reserve"
    "cloudflare_zone_cache_variants"
    "cloudflare_zone_dnssec"
    "cloudflare_zone_lockdown"
    "cloudflare_zero_trust_device_default_profile_certificates"
)

ACCOUNT_OR_ZONE_RESOURCES=(
    "cloudflare_logpush_job"
    "cloudflare_ruleset"
    "cloudflare_waiting_room"
    "cloudflare_zero_trust_access_application"
    "cloudflare_zero_trust_access_group"
    "cloudflare_zero_trust_access_identity_provider"
    "cloudflare_zero_trust_access_mtls_certificate"
    "cloudflare_zero_trust_access_mtls_hostname_settings"
    "cloudflare_zero_trust_access_service_token"
    "cloudflare_zero_trust_access_short_lived_certificate"
    "cloudflare_zero_trust_organization"
)

echo "Generating configuration to $OUTPUT_FILE ..."

echo "--- Account Level Resources ---"
for resource in "${ACCOUNT_RESOURCES[@]}"; do
    echo "Generating $resource ..."
    CLOUDFLARE_ZONE_ID="" cf-terraforming generate \
        --resource-type "$resource" >>"$OUTPUT_FILE" 2>/dev/null
done

echo "--- Zone Level Resources ---"
for resource in "${ZONE_RESOURCES[@]}"; do
    echo "Generating $resource ..."
    CLOUDFLARE_ACCOUNT_ID="" cf-terraforming generate \
        --resource-type "$resource" >>"$OUTPUT_FILE" 2>/dev/null
done

echo "--- Account or Zone Level Resources ---"
for resource in "${ACCOUNT_OR_ZONE_RESOURCES[@]}"; do
    echo "Generating $resource ..."
    CLOUDFLARE_ACCOUNT_ID="" cf-terraforming generate \
        --resource-type "$resource" >>"$OUTPUT_FILE" 2>/dev/null
done

echo "Done! Configuration saved to $OUTPUT_FILE."

シェルスクリプトを実行したところ、合計で600行ほどのTerraform configurationが生成されました。私用のCloudflareアカウントには、研究室用のリソースだけでなく趣味用のリソースも多く存在しています。アカウントIDを指定するリソースタイプ(cloudflare_registrar_domaincloudflare_zero_trust_access_policyなど)については、生成された趣味用リソースのTerraform configurationのみ目視で削除しました。

ちなみにcf-terraforming importコマンドを実行することで、stateを取り込むためのterraform importコマンドを出力することができます。cf-terraforming importコマンドについては第3章で再登場しますが、今回は新アカウントでのリソース作成が目的であるため、旧アカウントでのstate取り込みは割愛します。

以上で、CloudflareリソースのIaCのための旧アカウント上での準備が完了しました。

2. Cloudflareアカウント間のドメイン移動

developers.cloudflare.com

Cloudflareで新アカウントを作成したら、上記の公式ドキュメントの手順に従います。

まずは、新アカウントでドメインをWebサイトとして追加し、DNSSECがoffになっていることを確認します。

次に、旧アカウントでManage Domainsページから該当ドメインManageを選択し、ConfigurationタブのMove to another Cloudflare accountStartボタンをクリックします。

Cloudflareアカウント間のドメイン移動を開始

注意書き(新アカウントのアカウントIDが必要&ゾーンの設定は移動されない)

新アカウントのアカウントIDを入力

最終確認(ドメインは旧アカウントで管理されない&ゾーン・サービス・プラン・設定が新アカウントに移動されない)

ドメイン移動が開始されたという表示

最後に、新アカウントでManage Domainsページからドメインの移動を許可します。

移動がpending状態のドメインがあるというメッセージが出現

ドメインの移動を許可

ドメイン移動が許可されたという表示

ネームサーバーの更新まで最大8時間かかると書いてありましたが、hereと書いてあるリンクをクリックしたところ数分程度でセットアップが完了しました。

ドメイン移動直後の状態(Invalid nameservers)

ドメイン移動から数分後の状態(Active)

以上で、Cloudflareアカウント間のドメイン移動が完了しました。

3. 新アカウントにおけるCloudflareリソースのIaC

移動されなかったドメイン以外のサービス・設定について、新アカウントで構成する必要があります。

新アカウント上で既に作成されているリソースのうちTerraformで管理したいリソースについては、第1章で紹介したcf-terraforming generateコマンドを利用してTerraform configurationを生成しつつ、cf-terraforming importコマンドでstateをローカルに取り込みます。cloudflare_accountcloudflare_account_membercloudflare_zoneの他にもデフォルトで多くのリソースが作成されていますが、Terraformで管理したい範囲を決めてstateを取り込むのが良いと考えました。

以下は、cloudflare_accountリソースのstateを取り込む例です。

$ export CLOUDFLARE_ACCOUNT_ID='YOUR_CLOUDFLARE_ACCOUNT_ID'
$ export CLOUDFLARE_TERRAFORM_BINARY_PATH=$(which terraform)
$ export export CLOUDFLARE_API_TOKEN='YOUR_CLOUDFLARE_API_TOKEN'

$ cf-terraforming generate --resource-type "cloudflare_account" | tee account.tf
INFO[0000] Using Terraform binary from explicit path     terraform-binary-path=/opt/homebrew/bin/terraform
INFO[0000] detected provider                             registry=registry.terraform.io/cloudflare/cloudflare version=5.13.0
resource "cloudflare_account" "terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0" {
  name = "Xxxxxx Xxxxxxxxxx"
  type = "standard"
  settings = {
    abuse_contact_email    = null
    access_approval_expiry = null
    api_access_enabled     = null
    enforce_twofactor      = false
    user_groups_ui_beta    = false
  }
}

$ sed -i.bak 's/terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0/main/g' account.tf && rm account.tf.bak

$ cf-terraforming import --resource-type "cloudflare_account"
INFO[0000] Using Terraform binary from explicit path     terraform-binary-path=/opt/homebrew/bin/terraform
terraform import cloudflare_account.terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

$ terraform import cloudflare_account.main xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
cloudflare_account.main: Importing from ID "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"...
cloudflare_account.main: Import prepared!
  Prepared cloudflare_account for import
cloudflare_account.main: Refreshing state... [id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx]

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.

$ terraform plan
cloudflare_account.main: Refreshing state... [id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.

cf-terraformingで生成されたリソース名は扱いにくいので、適切なリソース名に変更しています。

新アカウントの既存リソースのうちTerraformで管理したいリソースのstate取り込みが完了したら、第1章で生成した旧アカウントTerraform configurationを参考に、新アカウントで作成したい新規リソース(Cloudflare Tunnel、Cloudflare Access、Cloudflare DNSなど)も記述します。

terraform.tf

terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5.13.0"
    }
  }
  required_version = "~> 1.14.0"
}

provider.tf

provider "cloudflare" {
  # API token will be read from CLOUDFLARE_API_TOKEN environment variable
}

variables.tf

variable "account_name" {
  type        = string
  description = "The name of the Cloudflare account."
}

variable "super_admin_emails" {
  type        = list(string)
  description = "A list of email addresses assigned the Super Administrator role."
}

variable "domain_name" {
  type        = string
  description = "The domain name for the Cloudflare zone."
}

variable "lab_member_emails" {
  type        = list(string)
  description = "A list of email addresses for lab members."
}

variable "zero_trust_access_group_name" {
  type        = string
  description = "The name of the Cloudflare Zero Trust access group."
}

variable "zero_trust_access_policy_name" {
  type        = string
  description = "The name of the Cloudflare Zero Trust access policy."
}

variable "zero_trust_access_policy_session_duration" {
  type        = string
  description = "Session duration for the Zero Trust access policy."
}

variable "zero_trust_organization_auth_domain" {
  type        = string
  description = "The authentication domain for the Cloudflare Zero Trust organization."
}

variable "zero_trust_organization_name" {
  type        = string
  description = "The name of the Cloudflare Zero Trust organization."
}

variable "zero_trust_access_application_name" {
  type        = string
  description = "The name of the Cloudflare Zero Trust access application."
}

variable "zero_trust_access_application_domain" {
  type        = string
  description = "The domain of the Cloudflare Zero Trust access application."
}

variable "zero_trust_access_application_session_duration" {
  type        = string
  description = "Session duration for the Zero Trust access application."
}

variable "zero_trust_tunnel_cloudflared_name" {
  type        = string
  description = "The name of the Cloudflare Zero Trust tunnel (cloudflared)."
}

variable "dns_record_name" {
  type        = string
  description = "The name of the DNS record."
}

account.tf

resource "cloudflare_account" "main" {
  name = var.account_name
  type = "standard"
  settings = {
    abuse_contact_email    = null
    access_approval_expiry = null
    api_access_enabled     = null
    enforce_twofactor      = true
    user_groups_ui_beta    = false
  }
}

data "cloudflare_account_roles" "all" {
  account_id = cloudflare_account.main.id
}

locals {
  super_admin_role_id = one([
    for role in data.cloudflare_account_roles.all.result : role.id
    if role.name == "Super Administrator - All Privileges"
  ])
}

resource "cloudflare_account_member" "super_admin" {
  for_each = toset(var.super_admin_emails)

  account_id = cloudflare_account.main.id
  email      = each.value
  roles      = [local.super_admin_role_id]

  lifecycle {
    ignore_changes = [status]
  }
}

zone.tf

resource "cloudflare_zone" "main" {
  name = var.domain_name
  type = "full"
  account = {
    id = cloudflare_account.main.id
  }
}

resource "cloudflare_zone_dnssec" "main" {
  status  = "disabled"
  zone_id = cloudflare_zone.main.id
}

dns.tf

resource "cloudflare_dns_record" "polaris" {
  content = "${cloudflare_zero_trust_tunnel_cloudflared.main.id}.cfargotunnel.com"
  # name    = cloudflare_zero_trust_access_application.main.domain
  name    = var.dns_record_name
  proxied = true
  ttl     = 1
  type    = "CNAME"
  zone_id = cloudflare_zone.main.id
}

zero_trust.tf

resource "cloudflare_zero_trust_organization" "main" {
  account_id  = cloudflare_account.main.id
  auth_domain = var.zero_trust_organization_auth_domain
  name        = var.zero_trust_organization_name
}

resource "cloudflare_zero_trust_access_group" "main" {
  name    = var.zero_trust_access_group_name
  zone_id = cloudflare_zone.main.id
  include = [
    for email in var.lab_member_emails : {
      email = {
        email = email
      }
    }
  ]
}

resource "cloudflare_zero_trust_access_policy" "main" {
  account_id       = cloudflare_account.main.id
  decision         = "allow"
  name             = var.zero_trust_access_policy_name
  session_duration = var.zero_trust_access_policy_session_duration
  include = [{
    group = {
      id = cloudflare_zero_trust_access_group.main.id
    }
  }]
}

resource "cloudflare_zero_trust_access_application" "main" {
  app_launcher_visible = true
  domain               = var.zero_trust_access_application_domain
  name                 = var.zero_trust_access_application_name
  session_duration     = var.zero_trust_access_application_session_duration
  type                 = "self_hosted"
  zone_id              = cloudflare_zone.main.id
  destinations = [{
    type = "public"
    uri  = var.zero_trust_access_application_domain
  }]
  policies = [{
    id = cloudflare_zero_trust_access_policy.main.id
  }]
}

resource "cloudflare_zero_trust_tunnel_cloudflared" "main" {
  account_id = cloudflare_account.main.id
  config_src = "cloudflare"
  name       = var.zero_trust_tunnel_cloudflared_name
}

resource "cloudflare_zero_trust_tunnel_cloudflared_config" "main" {
  account_id = cloudflare_account.main.id
  tunnel_id  = cloudflare_zero_trust_tunnel_cloudflared.main.id

  config = {
    ingress = [{
      hostname = cloudflare_zero_trust_access_application.main.domain
      service  = "ssh://localhost:22"
      }, {
      service = "http_status:404"
    }]
  }
}

第1章で旧アカウントリソースのTerraform configurationを生成しましたが、ファイル分割や変数定義、命名規則などを考える過程で結局Cloudflare ProviderのTerraform公式ドキュメントを参照する必要がありました。

VPNレスSSH#6)に必要なリソースの中では、cloudflare_zero_trust_tunnel_cloudflared_configというリソースタイプのみcf-terraformingがサポートしていませんでした。よって、旧アカウント側でterraform importコマンドを実行してterraform.tfstateファイルの内容を確認しながら、新アカウントでterraform applyコマンドが成功するまで試行錯誤しました*1

以上で、新アカウントにおけるCloudflareリソースのIaCは概ね達成できました。

4. IaCワークフローの自動化

これまではlocal backendを利用してstateをローカルに保存していましたが、複数人のインフラ管理者で一貫したstate管理を実現するため、ワークフローを自動化してremote backendを利用する方針に変更します。

研究室では既にAnsible Playbookの実行にGitHub Actionsを利用しているため、Terraformワークフローの自動化についてもGitHub Actionsを採用し、CI/CD基盤の運用を統一しています。

developer.hashicorp.com

上記の公式ドキュメントではGitHub ActionsによるTerraformの自動化にHCP Terraformが利用されていますが、研究室の規模ではstateのバージョニングやRBAC、監査ログといったHCP Terraformの恩恵を受けにくく、運用コストが増加してしまいます。

今回はTerraformのremote backendとして、Cloudflare R2(S3互換のオブジェクトストレージ)を使用します。

developers.cloudflare.com

上記の公式ドキュメントに従って、R2バケットを作成してからオブジェクト読み書き用のAPIトークンを作成し、以下のようにterraformブロックに記述します。

terraform {
+  backend "s3" {
+    bucket                      = "<YOUR_BUCKET_NAME>"
+    key                         = "<YOUR_PATH_TO_STATE>"
+    region                      = "auto"
+    skip_credentials_validation = true
+    skip_metadata_api_check     = true
+    skip_region_validation      = true
+    skip_requesting_account_id  = true
+    skip_s3_checksum            = true
+    use_path_style              = true
+    access_key                  = "<YOUR_R2_ACCESS_KEY>"
+    secret_key                  = "<YOUR_R2_ACCESS_SECRET>"
+    endpoints                   = { s3 = "https://<YOUR_ACCOUNT_ID>.r2.cloudflarestorage.com" }
+  }
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5.13.0"
    }
  }
  required_version = "~> 1.14.0"
}

terraform.tfファイルを編集したら、ローカルでterraform initコマンドを実行してlocal stateからremote stateへのマイグレーションを行います。-migrate-stateオプションでは既存のローカルstateを新しいs3 backendにコピーされますが、-reconfigureオプションでは既存のstateが無視されるので注意が必要です。

$ terraform init -migrate-state
Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "s3" backend. No existing state was found in the newly
  configured "s3" backend. Do you want to copy this state to the new "s3"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes


Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Reusing previous version of cloudflare/cloudflare from the dependency lock file
- Using previously-installed cloudflare/cloudflare v5.13.0

Terraform has been successfully initialized!
# 省略

R2バケット内にterrraform.tfstateというオブジェクトが作成される

以上で、remote backendへの移行が完了しました。この変更をコミットする前に、terraform.tfaccess_keysecret_keyの2行は必ず削除してください。ローカルでterraform planコマンドを実行するメンバーのために、以下のような.env.templateをコミットするのがオススメです。

export CLOUDFLARE_API_TOKEN=
export AWS_ACCESS_KEY_ID=
export AWS_SECRET_ACCESS_KEY=

次に、GitHub Actionsを利用してワークフローを自動化します。

AWSGoogle Cloudとは異なりCloudflareではGitHub ActionsのOIDC連携が利用できないため、R2バケット読み書き用のアクセスキーとTerraform実行用のAPIトークンをGitHub Actionsのシークレットに登録する必要があります。

アクセスキーとAPIトークンをGitHub Actionsのシークレットに登録

GitHub Actions上でのTerraform CLIのセットアップには以下のアクションを利用しました。

github.com

.github/workflows/terraform.yaml

name: terraform

on:
  push:
    branches:
      - main
    paths:
      - 'terraform/**'
  pull_request:
    paths:
      - 'terraform/**'

permissions:
  contents: read
  pull-requests: write

concurrency:
  group: terraform-${{ github.ref }}
  cancel-in-progress: false

jobs:
  terraform:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./terraform

    env:
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
      AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}

    steps:
      - uses: actions/checkout@v5

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.14.0"

      - name: Terraform fmt
        id: fmt
        run: terraform fmt -check
        continue-on-error: true

      - name: Terraform Init
        id: init
        run: terraform init -input=false

      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Plan
        id: plan
        if: github.event_name == 'pull_request'
        run: terraform plan -no-color -input=false
        continue-on-error: true

      - uses: actions/github-script@v7
        if: github.event_name == 'pull_request'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            // 1. Retrieve existing bot comments for the PR
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            })
            const botComment = comments.find(comment => {
              return comment.user.type === 'Bot' && comment.body.includes('Terraform Format and Style')
            })

            // 2. Prepare format of the comment
            const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
            #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
            <details><summary>Validation Output</summary>

            \`\`\`\n
            ${{ steps.validate.outputs.stdout }}
            \`\`\`

            </details>

            #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`

            <details><summary>Show Plan</summary>

            \`\`\`\n
            ${process.env.PLAN}
            \`\`\`

            </details>

            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Working Directory: \`./terraform\`, Workflow: \`${{ github.workflow }}\`*`;

            // 3. If we have a comment, update it, otherwise create a new one
            if (botComment) {
              github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body: output
              })
            } else {
              github.rest.issues.createComment({
                issue_number: context.issue.number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: output
              })
            }

      - if: steps.plan.outcome == 'failure'
        run: exit 1

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false

paths:working-directory:を指定しているのは、リポジトリが以下のようなディレクトリ構造になっているためです。

.
├── README.md
├── .github
│   └── workflows
│       ├── ansible-on-merge.yaml
│       ├── ansible-on-pr.yaml
│       └── terraform.yaml
├── ansible
│   ├── ...
└── terraform
    ├── ...

./terraformディレクトリ以下に意図的な変更を加えると、ワークフローがトリガーされます。

PR上でのbotによるコメントの例

terraform planコマンドの出力は▶︎ Show Planというトグルを開いて確認できます。

以上で、Terraformワークフローの自動化が完了しました。

まとめ

本記事では、Cloudflare間でドメインを移動する方法から、CloudflareリソースをIaC化してワークフローを自動化する方法まで紹介しました。

取り組んだ感想として、アカウント間のドメイン移動は驚くほどスムーズに進みましたが、IaC周りでそれなりに時間がかかってしまいました。また、cf-terraformingは非常に便利なツールだと感じたので今後も積極的に活用していきたいです。

研究室の現在のインフラ構成は以下の図の通りです。

研究室のインフラ構成のアーキテクチャ図(GitOps & IaC)

メジャーなOSSを利用しつつ、GitOpsとIaCを組み合わせてインフラ管理することで、研究室メンバーへの引き継ぎはかなり容易になったと思います。

*1:Web UIでは自動的に設定されますが、Terraformではservice = "http_status:404"のように最後のIngressルールが全てのURLにマッチする必要があります(エラー:{"success":false,"errors":[{"code":1056,"message":"Bad Configuration: Validation failed: The last ingress rule must match all URLs (i.e. it should not have a hostname or path filter)\n"}],"messages":[],"result":null}