結論

  • Terraform がリソース間の依存を張るのは、式が別のリソースや module の output を参照したときと、depends_on を書いたときだけ。同じ値を文字列や root の変数で渡しても依存はできず、親と子は並行に作られる
  • 依存が無くても、親が既にある環境では通る。落ちるのはまっさらな環境への初回 apply だけで、Firestore の Field が 404 になったり、API の有効化より先にリソースの作成が走ったりする
  • depends_on = [module.x] は x の全リソースを待つ。x の中に自分の出力を使うリソースがあると、依存が一周して cycle (循環参照) になり、Error: Cycle で落ちる。x の output を書けば、その output が参照するリソースだけを待つ
  • 設定を持つ provider ブロックを中に書いた module には、depends_on (と count / for_each) を付けられない。provider は root へ上げる
  • module の depends_on は、依存先に未適用の変更がある plan で、module 内の data source の読み込みを apply 時まで遅らせる。その値を ForceNew の属性に渡すと、何も変わっていなくても replace が出る

以下のコードと出力は Terraform v1.15.8 で確かめた。使ったのは terraform init -backend=false と terraform validate だけで、plan / apply は打っていない。 GCP の API を呼ぶ節と data source の節は、作業ログの記録をもとに書いた。

前提: 依存は式に書いた参照から作られる

Terraform は、どのリソースを先に作るかを依存グラフで決める。リソースが点で、「A は B を待つ」が A から B への矢印 (edge) になる。 矢印でつながっていないリソース同士は、順序を決めずに並行に作られる。この記事では、矢印ができることを「依存を張る」と書く。

矢印は、式に現れる参照から作られる。terraform_data.database.output のように別のリソースの属性を参照すると、参照した側が参照された側を待ち、参照された側が先に作られる。 depends_on は、参照の無いところに同じ依存を手で張る。

root の変数 (default、tfvars、-var で外から渡す値) は plan の時点で値が決まっているので、どのリソースとも依存を張らない。同じ値を文字列で書いた場合も依存はできない。

A が B を待ち、B も A を待つように依存が一周すると、どちらも先に作れない。これが cycle (循環参照) で、Terraform は Error: Cycle: の後ろに一周に含まれる点を並べて止まる。

依存があるかは plan を打たなくても確かめられる。親に逆向きの depends_on (親 => 子) を足して terraform validate を打つ。 子 => 親の依存が既にあれば一周するので Error: Cycle が出る。Success! なら依存は無い。以下の節はこの方法で見た。

属性を参照した子だけが親を待つ

子が親の属性を参照したときと、module の入力に属性を渡したときだけ依存ができ、文字列と root の変数では依存ができない

field が database の属性を参照すると依存ができる。database に逆向きの depends_on を足すと cycle になり、validate が落ちる。

# probe-ref/main.tf
variable "db_name" {
  type    = string
  default = "(default)"
}

resource "terraform_data" "database" {
  input = var.db_name
  # 逆向きの依存を足す。field => database の依存があれば cycle になる
  depends_on = [terraform_data.field]
}

resource "terraform_data" "field" {
  input = terraform_data.database.output # 属性を参照する
}
$ terraform init -backend=false -no-color > /dev/null && terraform validate -no-color

Error: Cycle: terraform_data.field, terraform_data.database

field と database が互いを待って一周している、という意味になる。field => database の依存が属性の参照から張られていたことが分かる。

field の input を var.db_name (probe-var) か "(default)" (probe-literal) に変えると、database と同じ値でも依存が無いので通る。

$ terraform init -backend=false -no-color > /dev/null && terraform validate -no-color
Success! The configuration is valid.

module の入力変数は root の変数と違う。属性を module の入力に渡すと、module の中で var.db_name として読んでも依存は残る。

# probe-module-var/main.tf
resource "terraform_data" "database" {
  input = "(default)"
  # 逆向きの依存を足す。module の中の field => database の依存があれば cycle になる
  depends_on = [module.child.field_id]
}

# 属性を module の入力変数に渡す
module "child" {
  source  = "./child"
  db_name = terraform_data.database.output
}
# probe-module-var/child/main.tf
variable "db_name" {
  type = string
}

resource "terraform_data" "field" {
  input = var.db_name # module の中では変数として読む
}

output "field_id" {
  value = terraform_data.field.id
}
$ terraform init -backend=false -no-color > /dev/null && terraform validate -no-color

Error: Cycle: module.child.terraform_data.field, module.child.output.field_id (expand), terraform_data.database, module.child.var.db_name (expand)

依存が切れるのは、値の出どころがリソースでないとき (root の変数と文字列) だけ。

対策は、親を属性で指すか depends_on を書くこと。属性で指せば (database = google_firestore_database.database.name) 暗黙の依存になり、depends_on は要らない。 どちらでもよいので、同じ module の兄弟リソースの書き方に揃える。どの属性を参照できるかは provider の schema で引ける。

terraform providers schema -json | jq '.provider_schemas[].resource_schemas["<type>"].block.attributes'
  • PITFALL: depends_on は、上流に保留中の変更があると下流の値を unknown として扱うので、闇雲に付けると plan に (known after apply) が増える。 上流が作成後に変わらないリソース (DB 本体など) なら、定常状態では保留中の変更が無いので実害は出ない。module に付けたときの害は 最後の節 に書いた。

まっさらな環境の初回 apply だけで落ちる

既存の環境では親が先にあるので通り、新しい環境では依存の無い子が親と並行に作られて 404 になる

依存の無い子は、既存の環境では親が既にあるので落ちない。まっさらなプロジェクトへの初回 apply だけで、親と並行に作られて落ちる。

Firestore の TTL 用の google_firestore_field が database = "(default)" のように文字列で Database を指していると、新しいプロジェクトでは Database と並行に作られる。 同じ module の composite index には depends_on があり、Field には無かった。

Error creating Field: googleapi: Error 404: Requested database was not found.
resource "google_firestore_field" "expire_at_ttl" {
  database   = google_firestore_database.database.name # 参照にすれば暗黙の依存になる
  collection = "webhooks"
  field      = "expireAt"
  ttl_config {}
  # もしくは depends_on = [google_firestore_database.database]
}

API の有効化も同じ形で落ちる。google_project_service で API を有効にしても、それを参照しない module や resource は有効化を待たずに作成が走る。 「この project の API が有効なこと」はリソース定義に参照として現れないので、グラフに依存ができない。API 有効化のリソースへ、全 module から depends_on を張る。

resource "google_project_service" "service" {
  for_each = toset(var.services)
  service  = each.value
}

module "artifact_registry" {
  # source などは省略
  depends_on = [google_project_service.service]
}

同じ形の競合は IAM でも起きる。サービスアカウントへの権限付与とワークロードの作成に順序が無いと、権限が付く前に起動したリビジョンが Secret を読めずに失敗する (Permission denied on secret: ... The service account must be granted ...)。文面が「権限が無い」なので、実際には付いている権限の方を疑ってしまう。

  • PITFALL: 失敗した apply でも、Database と index は state に入っている。直した後は、terraform plan -target=<field> が 1 to add だけになることを確かめてから apply し直す。

この節は GCP の API を呼ぶ apply が要るので、手元では再現していない。仕組みは前の節と同じ。

module 全体を待つと cycle (循環参照) になる

depends_on に module 全体を書くと commons の全リソースを待ち、その中の vpn が psk の出力を使うので cycle になる。output を書くと services だけを待つ

root の resource や module に、別の module の中の API 有効化だけを待たせたいとき、depends_on = [module.commons] と module 全体を書くと Cycle になることがある。 commons の中に、待たせる側 (psk) の出力を使うリソースがあるとき。

# modules/commons/main.tf
variable "psk_value" {
  type = string
}

# API の有効化の代わり
resource "terraform_data" "services" {
  input = "enabled"
}

# psk module の出力を使うリソース
resource "terraform_data" "vpn" {
  input = var.psk_value
}

output "enabled_services" {
  value = terraform_data.services.id
}
# modules/psk/main.tf
resource "terraform_data" "key" {
  input = "secret"
}

output "value" {
  value = terraform_data.key.id
}
# whole/main.tf
module "commons" {
  source    = "../modules/commons"
  psk_value = module.psk.value
}

module "psk" {
  source = "../modules/psk"
  # module 全体を待つ
  depends_on = [module.commons]
}
$ terraform init -backend=false -no-color > /dev/null && terraform validate -no-color

Error: Cycle: module.psk.terraform_data.key, module.commons.var.psk_value (expand), module.commons.terraform_data.vpn, module.commons (close), module.psk (expand), module.psk.output.value (expand)

cycle に module.commons (close) が入っている。module 全体への depends_on は、commons の全リソースが終わる点 (close) を待つ。 psk の key は module.psk (expand) を通して module.commons (close) を待ち、close は vpn を待ち、vpn は var.psk_value を通して psk の value、つまり key を待つ。

depends_on に commons の output を書くと、その output が参照する services だけを待つので cycle にならない。

# output/main.tf
module "commons" {
  source    = "../modules/commons"
  psk_value = module.psk.value
}

module "psk" {
  source = "../modules/psk"
  # API の有効化を返す output だけを待つ
  depends_on = [module.commons.enabled_services]
}
$ terraform init -backend=false -no-color > /dev/null && terraform validate -no-color
Success! The configuration is valid.

provider を持つ module には depends_on を付けられない

module の中に設定を持つ provider ブロックがあると depends_on と両立せず init の module の段で止まる。provider を root に上げると通る

module の中に設定を持つ provider ブロックがあると、その module の呼び出しに depends_on を書けない。count と for_each も同じ。 depends_on は module 全体の評価を遅らせるが、provider の設定が遅れると、その provider を使う他のリソースの計画が立てられない。 google-beta や aws.secondary のような追加の provider を module の中に書いているときに当たる。

# main.tf
resource "terraform_data" "service" {
  input = "api enabled"
}

module "child" {
  source     = "./child"
  depends_on = [terraform_data.service]
}
# child/main.tf
terraform {
  required_providers {
    google-beta = {
      source = "hashicorp/google-beta"
    }
  }
}

# module の中に、設定を持つ provider ブロックを書く
provider "google-beta" {
  project = "example-project"
}

resource "terraform_data" "app" {
  input = "app"
}

init の Initializing modules... の段で止まり、provider の取得までは進まない。

$ terraform init -backend=false -no-color
Initializing modules...

Error: Module is incompatible with count, for_each, and depends_on

  on main.tf line 7, in module "child":
   7:   depends_on = [terraform_data.service]

The module at module.child is a legacy module which contains its own local
provider configurations, and so calls to it may not use the count, for_each,
or depends_on arguments.

If you also control the module "./child", consider updating this module to
instead expect provider configurations to be passed by its caller.

provider ブロックを root へ上げ、module には required_providers だけを残す。module には root の provider が暗黙に渡る。

# fixed/main.tf
terraform {
  required_providers {
    google-beta = {
      source = "hashicorp/google-beta"
    }
  }
}

# provider の設定は root に置く
provider "google-beta" {
  project = "example-project"
}

resource "terraform_data" "service" {
  input = "api enabled"
}

module "child" {
  source     = "./child"
  depends_on = [terraform_data.service]
}
# fixed/child/main.tf
terraform {
  required_providers {
    google-beta = {
      source = "hashicorp/google-beta"
    }
  }
}

# provider ブロックは書かない。root の google-beta が暗黙に渡る

resource "terraform_data" "app" {
  input = "app"
}
$ terraform init -backend=false -no-color
Initializing modules...
- child in child

Initializing provider plugins...
- terraform.io/builtin/terraform is built in to Terraform
- Finding latest version of hashicorp/google-beta...
...

module の段を抜けて provider の取得へ進む。設定を持たない provider "terraform" {} を module に書いた場合は、Redundant empty provider block の警告が出るだけで、depends_on を付けても init と validate は通った。

  • PITFALL: エラーの文面が「provider の設定は呼び出し元から渡す形に直せ」と案内している。そこを読み飛ばすと原因の分からない失敗に見える。 provider を移した後は、module の中で使われなくなった変数 (labels など) が残るので消す。

module の depends_on は data source の読み込みを apply まで遅らせる

依存先に未適用の変更があると module 内の data source が plan で読まれず、その値を渡した ForceNew の属性が replace になる

module に depends_on を付けると、依存先に未適用の変更がある plan では、module の中の data source の読み込みが apply 時まで遅れる。data source が依存先の変更を待つ必要があるので、plan の時点では読めない。 読み込みが遅れた値は (known after apply) になり、それを ForceNew の属性に渡すと、何も変わっていなくても plan に replace が出る。

API の有効化を module の depends_on で待たせつつ、module の中で既存のリソースを data source で参照しているときに当たる。 API を 1 つ足しただけの plan で、App Check の登録に replace が出た。組み込みの provider だけの構成でも同じ plan になる。

値が既に分かっているなら、data source を通さず変数で渡す。

# NG: data "google_firebase_web_app" から読んだ app_id を渡す
# OK: 変数から直接渡す
resource "google_firebase_app_check_recaptcha_enterprise_config" "default" {
  for_each = var.app_check_web_apps
  app_id   = each.value.app_id
  # ...
}
  • PITFALL: その replace は、Delete が state から外すだけで実害が無い場合もある。それでも「destroy の件数を見る」確認が効かなくなる。

この節は plan が要るので、手元では再現していない。

まとめ

書き方起きること対策
子が親を文字列か root の変数で指す依存が無く、親と並行に作られる。初回 apply だけで落ちる親の属性を参照するか、depends_on を書く
子が親の属性を参照する / module の入力に属性を渡す依存ができ、親を待つなし
API 有効化を参照しない module有効化より先に作成が走る全 module に depends_on = [google_project_service.service]
権限付与とワークロード作成に順序が無い権限が付く前のリビジョンが Secret を読めない権限付与への依存を張る
depends_on = [module.x] で、x が自分の出力を使うCyclex の output を depends_on に書く
設定を持つ provider ブロックがある module に depends_on / count / for_eachinit で Module is incompatible with count, for_each, and depends_onprovider を root へ上げる
module に depends_on を付け、中で data source を読む依存先に変更がある plan で (known after apply)。ForceNew なら replace値が分かっているなら変数で渡す
闇雲な depends_onplan に (known after apply) が増える上流が作成後に変わらないリソースなら実害は無い

参考