Mert Özen Her Satırda Daha İleri
DevOps Infrastructure as Code: Terraform — #05

Dependency Graph, Data Source ve Lifecycle: depends_on, create_before_destroy, prevent_destroy ve ignore_changes

Mert Özen 14 Kas 2026 12 dk 11 görüntülenme
Dependency Graph, Data Source ve Lifecycle: depends_on, create_before_destroy, prevent_destroy ve ignore_changes

Terraform kaynakları referanslardan kurduğu bir dependency graph'a göre ve mümkün olduğunca paralel oluşturur; depends_on yalnız kodun göremediği bağımlılıklar, lifecycle ise replace ve silme davranışını değiştirmek içindir. Graph'ı Mermaid ile çiziyor, data source'larla ortak kaynakları okuyor, create_before_destroy'un sabit adda nasıl takıldığını, prevent_destroy, ignore_changes, replace_triggered_by, precondition, 1.16'daki destroy = false ve check block'larını gerçek çıktılarla görüyoruz.

Sürüm notu: Bu yazıdaki örnekler Terraform 1.17.2, kreuzwerker/docker provider 4.7.0, hashicorp/http 3.6.3 ve Docker Engine 29 ile denenmiştir. AzureRM 5.14.0 kodu için init ve validate çalıştırıldı; Azure'a plan/apply yapılmadı.

Önce Tahmin Et

Bookstore API container'ının kesintisiz yenilenmesini istiyorsun ve lifecycle block'una create_before_destroy = true ekliyorsun: Terraform önce yeni container'ı açsın, sonra eskisini silsin. Container'ın adı sabit: tfseries-dev-api. Bir sonraki replace'te ne olur? Yeni container açılır mı, Terraform adı kendisi mi değiştirir, yoksa apply hata mı verir? Cevap yazının ortasında, gerçek hata mesajıyla.

Dependency Graph: Implicit Dependency ve terraform graph

Terraform dosyalardaki block'ları yazıldıkları sırayla çalıştırmaz. Önce bütün kaynaklar arasındaki bağımlılıklardan bir graph kurar, sonra bu graph'ı birbirine bağlı olmayan kaynakları paralel çalıştırarak yürür. Bağımlılığın en yaygın kaynağı referanslar: container'ın networks_advanced block'unda docker_network.bookstore.name yazdığın an, Terraform container'ın network'ten sonra oluşturulması gerektiğini bilir. Buna implicit dependency deniyor. Bu yazıdaki yerel örneğin graph'ı, Terraform 1.16 ile gelen Mermaid formatında:

$ terraform graph -format=mermaid
flowchart LR
  data.docker_image.api["data.docker_image.api"]
  data.docker_network.shared["data.docker_network.shared"]
  data.http.health["data.http.health"]
  docker_container.api["docker_container.api"]
  docker_network.bookstore["docker_network.bookstore"]
  docker_volume.api_data["docker_volume.api_data"]
  terraform_data.api_image["terraform_data.api_image"]
  docker_container.api --> data.docker_network.shared
  docker_container.api --> docker_network.bookstore
  docker_container.api --> docker_volume.api_data
  docker_container.api --> terraform_data.api_image
  terraform_data.api_image --> data.docker_image.api

Bu çıktıyı bir Markdown dosyasına ya da pull request açıklamasına yapıştırdığında GitHub onu diyagram olarak çizer. Varsayılan format DOT (Graphviz'in diyagram dili) ve aynı bilgiyi verir. Graph'ta okları takip edersen sırayı görürsün: network, volume'ler ve image okunduktan sonra container. Network ile volume'ler birbirine bağlı olmadığı için aynı anda oluşturulur; 2. yazıdaki apply çıktısında volume'lerin aynı saniyede "Creating..." dediğini hatırla. Terraform varsayılan olarak aynı anda on işlem çalıştırır, -parallelism ile değiştirilebilir. Silerken graph tersten yürür.

depends_on: Kodun Göremediği Bağımlılık

Bazen iki kaynak arasında gerçek bir sıra ilişkisi vardır ama kodda referans yoktur. Bookstore'un Azure altyapısında tipik örnek Key Vault erişimi. Ödeme sağlayıcısının API key'i platform ekibinin yönettiği ortak bir Key Vault'ta duruyor. Web App, bir user-assigned managed identity (Azure'un uygulamaya verdiği, şifresiz kimlik) ile bu secret'ı okuyacak:

data "azurerm_key_vault" "shared" {
  name                = var.shared_key_vault.name
  resource_group_name = var.shared_key_vault.resource_group_name
}

resource "azurerm_user_assigned_identity" "api" {
  name                = "id-${local.suffix}"
  resource_group_name = azurerm_resource_group.bookstore.name
  location            = azurerm_resource_group.bookstore.location
  tags                = local.tags
}

resource "azurerm_role_assignment" "api_key_vault" {
  scope                = data.azurerm_key_vault.shared.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = azurerm_user_assigned_identity.api.principal_id
}
resource "azurerm_linux_web_app" "api" {
  name                = "app-${local.suffix}"
  resource_group_name = azurerm_resource_group.bookstore.name
  location            = azurerm_resource_group.bookstore.location
  service_plan_id     = azurerm_service_plan.bookstore.id
  https_only          = true
  tags                = local.tags

  app_settings = merge(local.app_settings, {
    Bookstore__Payments__ApiKey = "@Microsoft.KeyVault(VaultName=${data.azurerm_key_vault.shared.name};SecretName=payments-api-key)"
  })

  identity {
    type         = "UserAssigned"
    identity_ids = [azurerm_user_assigned_identity.api.id]
  }

  key_vault_reference_identity_id = azurerm_user_assigned_identity.api.id

  site_config {
    ...
  }

  # Uygulama secret'ları açılışta okur; bu block'ta role assignment'a referans yok
  depends_on = [azurerm_role_assignment.api_key_vault]

  lifecycle {
    # Image tag'ini deployment pipeline'ı belirler (CI/CD serisi); gerisi Terraform'da
    ignore_changes = [site_config[0].application_stack[0].docker_image_name]

    precondition {
      condition     = var.environment != "prod" || var.api.sku_name != "B1"
      error_message = "Production needs at least a P0v3 plan, not B1."
    }
  }
}

Web App identity'yi referans aldığı için identity'den sonra oluşur. Ama Web App role assignment'a hiçbir yerde referans vermiyor; Terraform'un gözünde ikisi bağımsız ve paralel oluşturulabilir. Bu durumda Web App role assignment'tan önce ayağa kalkabilir, açılışta @Microsoft.KeyVault(...) referansını çözmeye çalışır ve yetkisi olmadığı için secret'ı alamaz. depends_on bu görünmeyen bağımlılığı Terraform'a söyler. Bu kodu Azure'da çalıştırmadım; validate geçti.

depends_on'ı yalnız böyle gerçek bir sıra ihtiyacı varsa kullan. Her ihtimale karşı eklenen depends_on graph'taki paralelliği azaltır. Data source'larda maliyeti daha yüksek: Terraform dokümanına göre bir data source'un depends_on'ındaki kaynakta bekleyen bir değişiklik varsa, data source plan sırasında değil apply sırasında okunur. O data source'tan beslenen her değer de plan'da (known after apply) olur ve plan'ın sana söyleyebildiği şey azalır.

Data Source: Var Olanı Okumak

Key Vault örneğindeki data block'u bir data source: Terraform'un yönetmediği, başkasının kurduğu bir kaynağı okur. Okur ama asla değiştirmez ya da silmez. Ortak network, platform ekibinin Key Vault'u, başka bir state'te yönetilen DNS zone'u bu yolla okunur. Yerel örnekte iki data source var: platform ekibinin docker network create tfseries-shared ile açtığı ortak network ve build ettiğimiz API image'ı:

data "docker_network" "shared" {
  name = var.shared_network_name

  lifecycle {
    postcondition {
      condition     = self.driver == "bridge"
      error_message = "The shared network must use the bridge driver."
    }
  }
}

data "docker_image" "api" {
  name = "bookstore-api:${var.api.image_tag}"
}

# Aynı tag'i yeniden build etmek image ID'sini değiştirir; bu kaynak o değişikliği taşır
resource "terraform_data" "api_image" {
  input = data.docker_image.api.id
}

Plan'ın başında data source'ların okunduğunu görürsün:

data.docker_image.api: Reading...
data.docker_network.shared: Reading...
data.docker_network.shared: Read complete after 0s [id=f2116a3d62ee0e1469796f3d9cac6146c0b7934bdabc935c35e2f10538348b88]
data.docker_image.api: Read complete after 0s [id=sha256:7e79244a7ecd0557fa579a28aadbca8e35e271336f5d1f1ff188f66930eb4f9e]

Okunan kaynak yoksa plan durur. Ortak network'ün adını yanlış verince:

Error: Could not find docker network: Error response from daemon: network tfseries-missing not found

  with data.docker_network.shared,
  on main.tf line 3, in data "docker_network" "shared":
   3: data "docker_network" "shared" {

Bu iyi bir davranış: başkasına ait bir kaynağın varlığını varsayan kod, o kaynak yokken yarım bir apply yapacağına baştan durur.

lifecycle: create_before_destroy ve prevent_destroy

Replace'in varsayılan sırası önce silmek, sonra oluşturmak. create_before_destroy = true bu sırayı tersine çevirir. Tahmin sorusundaki ayarla container'ı -replace ile yenilemeye çalışınca plan, sıranın değiştiğini yeni bir işaretle gösteriyor:

  # docker_container.api will be replaced, as requested
+/- resource "docker_container" "api" {
...
Plan: 1 to add, 0 to change, 1 to destroy.
docker_container.api: Creating...

Error: Unable to create container: Error response from daemon: Conflict. The container name "/tfseries-dev-api" is already in use by container "1284850060aeaccace28350326fd4c6844eca43127f96f05cacadc66452cf462". You have to remove (or rename) that container to be able to reuse that name.

-/+ yerine +/-: önce oluştur, sonra sil. Ama yeni container eskisinin adını alamadı ve apply durdu. Tahmin sorusunun cevabı bu: Terraform adı kendisi değiştirmez. Eski container ayakta kaldığı için kesinti olmadı, ama yenileme de olmadı. Terraform dokümanı bunu açıkça söylüyor: create_before_destroy isteğe bağlı bir davranış, çünkü birçok kaynak türünde ad tekil olmak zorunda ve eski ile yeni nesnenin bir süre birlikte yaşayabilmesi senin sorumluluğun. Docker'da çözüm adı ve host port'unu her sürümde farklı yapmak; Azure'da da durum aynı, çünkü Web App ve Storage Account adları bütün Azure'da tekil. Bu yüzden create_before_destroy en çok adı Azure tarafından üretilen ya da sonuna rastgele bir ek alabilen kaynaklarda işe yarar.

prevent_destroy = true ise kaynağı silen ya da replace eden her plan'ı reddeder. Yerel örnekte API'nin veri volume'lerine ekledik:

resource "docker_volume" "api_data" {
  for_each = var.volume_names
  name     = "${local.name_prefix}-${each.key}"

  dynamic "labels" {
    for_each = local.labels
    content {
      label = labels.key
      value = labels.value
    }
  }

  lifecycle {
    prevent_destroy = true
  }
}
$ terraform plan -destroy -var-file=dev.tfvars
...
Error: Instance cannot be destroyed

  on main.tf line 35:
  35: resource "docker_volume" "api_data" {

Resource docker_volume.api_data["exports"] has lifecycle.prevent_destroy set,
but the plan calls for this resource to be destroyed. To avoid this error and
continue with the plan, either disable lifecycle.prevent_destroy or reduce
the scope of the plan using the -target option.

Aynı hata, ortamı yanlışlıkla prod verip volume adlarını değiştirmeye çalıştığımda da geldi: ad değişikliği replace demek ve prevent_destroy replace'i de durdurur. Azure tarafında aynı korumayı BookstoreDb'ye koyuyoruz:

resource "azurerm_mssql_database" "bookstore" {
  name      = "BookstoreDb"
  server_id = azurerm_mssql_server.bookstore.id
  sku_name  = var.environment == "prod" ? "S1" : "Basic"
  tags      = local.tags

  lifecycle {
    prevent_destroy = true
  }
}

check "api_health" {
  data "http" "health" {
    url = "https://${azurerm_linux_web_app.api.default_hostname}/health"
  }

  assert {
    condition     = data.http.health.status_code == 200
    error_message = "The Bookstore API health endpoint returned ${data.http.health.status_code}."
  }
}

Önemli bir sınır var: prevent_destroy block'un kendisi kodda durduğu sürece çalışır. Kaynağı block'uyla birlikte koddan silersen, koruma da onunla gider ve Terraform kaynağı siler.

ignore_changes ve replace_triggered_by

ignore_changes, Terraform'un bir attribute'taki farkı görmezden gelmesini sağlar. Operasyon ekibi bir incident sırasında container'ın memory limitini docker update --memory 512m --memory-swap 512m tfseries-dev-api ile ayarladı ve bu ayarı kendi aracıyla yönetiyor. Bir sonraki plan:

  # docker_container.api will be updated in-place
      - memory                                      = 512 -> null
      - memory_swap                                 = 512 -> null
Plan: 0 to add, 1 to change, 0 to destroy.

Terraform ayarı geri almak istiyor. İki attribute'u ignore_changes'e ekleyince plan artık container'a dokunmuyor:

resource "docker_container" "api" {
  ...
  networks_advanced {
    name = data.docker_network.shared.name
  }

  ports {
    internal = 8080
    external = var.api.external_port
  }

  lifecycle {
    replace_triggered_by = [terraform_data.api_image]

    # Memory limitlerini operasyon ekibi docker update ile ayarlıyor
    ignore_changes = [memory, memory_swap]

    precondition {
      condition     = var.environment != "prod" || var.api.image_tag != "latest"
      error_message = "Production must run a pinned image tag, not latest."
    }
  }
}

Azure'daki karşılığı yukarıdaki Web App'te: image tag'ini CI/CD serisinde kurduğumuz deployment pipeline'ı değiştiriyor, Terraform'un her apply'da onu koddaki eski tag'e geri çekmesini istemiyoruz. site_config[0].application_stack[0].docker_image_name yolu, nested block'ların içindeki tek bir attribute'u gösteriyor. Azure Policy'nin kaynaklara kendiliğinden eklediği tag'ler de sık görülen bir başka örnek; onlar için tags["cost-center"] gibi tek bir key verilebilir.

replace_triggered_by tersini yapar: başka bir kaynak değişince bu kaynağı replace ettirir. Yukarıdaki container bookstore-api:1.1.0 tag'iyle çalışıyor. Aynı tag'i bir hotfix için yeniden build edersen image ID'si değişir ama container'ın image attribute'u aynı string kalır; Terraform container'ı yenilemek için bir sebep görmez. terraform_data kaynağı image ID'sini taşıyor, replace_triggered_by da onun değişmesini container'ın replace'ine bağlıyor. Tag'i yeniden build ettikten sonra plan:

  # docker_container.api will be replaced due to changes in replace_triggered_by
-/+ resource "docker_container" "api" {
...
  # terraform_data.api_image will be updated in-place
  ~ resource "terraform_data" "api_image" {
        id     = "4e4febef-0bcf-27b1-9112-c7fff723d808"
      ~ input  = "sha256:7e79244a7ecd0557fa579a28aadbca8e35e271336f5d1f1ff188f66930eb4f9e" -> "sha256:9339abaff659bb735d277222fdf87e0a0dd8bee1254f9373fdee0d852e049e49"
      ~ output = "sha256:7e79244a7ecd0557fa579a28aadbca8e35e271336f5d1f1ff188f66930eb4f9e" -> (known after apply)
    }

Plan: 1 to add, 1 to change, 1 to destroy.

Yine de asıl çözüm aynı tag'i yeniden kullanmamak; Docker serisinde konuştuğumuz immutable tag kuralı bu sorunu kökünden çözer. replace_triggered_by'ı başka yolu olmayan durumlar için sakla.

precondition, postcondition ve destroy = false

3. yazıdaki variable validation'ları tek bir değeri kontrol ediyordu. precondition ve postcondition ise bir kaynağın etrafındaki varsayımları kontrol eder ve birden fazla değere, data source'lara bakabilir. Container'daki precondition production'da latest tag'ini yasaklıyor. Denediğimde:

Error: Resource precondition failed

  on main.tf line 98, in resource "docker_container" "api":
  98:       condition     = var.environment != "prod" || var.api.image_tag != "latest"
    ├────────────────
    │ var.api.image_tag is "latest"
    │ var.environment is "prod"

Production must run a pinned image tag, not latest.

Bu çıktıyı state'i olmayan bir kopyada aldım; gerçek dev state'inde environment = "prod" volume adlarını değiştirdiği için önce prevent_destroy devreye giriyordu. Postcondition ise kaynak ya da data source okunduktan sonra çalışır ve self ile sonuca bakar. Ortak network data source'undaki postcondition, platform ekibinin network'ü bir gün bridge dışında bir driver'la açması durumunda bizi plan'da durdurur.

Terraform 1.16 ile lifecycle'a bir ayar daha geldi: destroy = false. Dokümana göre kaynak silinmesi gerektiğinde gerçek nesneyi silmeden yalnız state'ten çıkarır. Küçük bir terraform_data kaynağıyla denedim; terraform plan -destroy:

 # terraform_data.keep will no longer be managed by Terraform, but will not be destroyed
 # (destroy = false is set in the configuration)
 . resource "terraform_data" "keep" {
        id     = "c4eef20a-ec27-71e6-271b-c05c822686fe"
        # (2 unchanged attributes hidden)
    }

Plan: 0 to add, 0 to change, 0 to destroy.

Warning: Some objects will no longer be managed by Terraform

Ama block'u koddan tamamen silince plan "will be destroyed (because terraform_data.keep is not in configuration)" dedi. prevent_destroy'daki sınır burada da geçerli: ayar kodda durduğu sürece çalışır. Bir kaynağı koddan çıkarıp gerçek dünyada bırakmanın doğru yolu removed block'u, 8. yazıda.

check Block'larıyla Sağlık Kontrolü

Precondition ve postcondition plan'ı durdurur. check block'u ise durdurmaz, uyarı verir: altyapının dışarıdan bakıldığında beklediğin gibi çalışıp çalışmadığını her plan ve apply'da kontrol eder. Bookstore API'sinin health endpoint'ini hashicorp/http provider'ıyla çağırıyoruz:

check "api_health" {
  data "http" "health" {
    url = "http://localhost:${var.api.external_port}/health"

    # Replace'ten sonra container'ın açılması birkaç saniye sürer
    retry {
      attempts     = 10
      min_delay_ms = 500
      max_delay_ms = 2000
    }
  }

  assert {
    condition     = data.http.health.status_code == 200
    error_message = "The Bookstore API health endpoint returned ${data.http.health.status_code}."
  }
}

Check'in içindeki data source plan sırasında değil, apply'ın sonunda okunur. Plan bunu böyle gösteriyor:

  # data.http.health will be read during apply
  # (config will be reloaded to verify a check block)

İlk denememde retry block'u yoktu. Container replace edildikten hemen sonra check çalıştı ve uygulama henüz açılmamıştı:

docker_container.api: Creation complete after 1s [id=0d6f30dfb3ca814d2c2f9f95f2f0adfa7657ee52a8c216d8b75a82371f2cee95]
data.http.health: Reading...

Warning: Error making request
...
Error making request: GET http://localhost:18480/health giving up after 1
attempt(s): Get "http://localhost:18480/health": read tcp
127.0.0.1:59541->127.0.0.1:18480: read: connection reset by peer

Apply complete! Resources: 2 added, 0 changed, 1 destroyed.

Apply başarılı bitti; check yalnız uyardı. retry eklendikten sonraki apply'da health çağrısı bir saniye içinde 200 döndü ve uyarı çıkmadı. max_delay_ms'i de mutlaka ver. API hiç ayakta değilken check plan sırasında da çalışır ve her denemede bekleme süresi katlanarak uzar. Container durdurulmuşken aldığım plan, max_delay_ms olmadan 152 saniye, max_delay_ms = 2000 ile 18 saniye sürdü; ikisinin sonucu da aynı uyarıydı. Assertion'ın kendisinin başarısız olduğunu görmek için URL'yi geçici olarak olmayan bir path'e (/healthz) çevirdim:

Warning: Check block assertion failed

  on main.tf line 107, in check "api_health":
 107:     condition     = data.http.health.status_code == 200
    ├────────────────
    │ data.http.health.status_code is 404

The Bookstore API health endpoint returned 404.

Azure'daki aynı check Web App'in default_hostname'ine HTTPS ile gider. 11. yazıda zamanlanmış bir drift job'u kuracağız; check'lerin uyarıları orada, kimse apply etmese bile altyapının sağlığı hakkında düzenli bir sinyal olacak.

Üretimde Dikkat

Veri tutan her kaynağa prevent_destroy koy: veritabanları, storage account'lar, Key Vault'lar. Bilerek silmen gerektiğinde ayarı kaldıran ayrı bir pull request açarsın; bu adım, silme kararının review'dan geçmesini sağlar.

ignore_changes listesini kısa tut ve her girdinin yanına neden orada olduğunu yazan bir yorum ekle. Listedeki her attribute, Terraform'un artık drift'ini göstermeyeceği bir alan demek.

Check'leri, Azure'a ulaşabilen bir yerden çalışan zamanlanmış bir job'la birleştir. Check'ler apply'ı durdurmadığı için sinyalleri ancak birisi okuduğunda değer üretir.

Sık Düşülen Tuzaklar

ignore_changes'i drift'i saklamak için kullanmak. Portal'dan değiştirilen bir ayarı Terraform'a kabul ettirmenin yolu, kodu güncellemek ya da değişikliği geri almak; Terraform'un gözünü kapatmak değil. Özellikle ignore_changes = all, kaynağı oluşturduktan sonra Terraform'un yönetiminden fiilen çıkarır.

Her ihtimale karşı depends_on eklemek. Paralelliği azaltır, data source'larda okumayı apply'a erteler ve plan'ı belirsizleştirir. Bir referans zaten varsa depends_on gereksizdir; yoksa ve gerçek bir sıra ihtiyacı varsa, yanına bir yorumla nedenini yaz.

create_before_destroy'u adı sabit kaynaklarda açmak. Yukarıda gördüğün gibi yeni kaynak eskisinin adını alamaz ve apply yarıda kalır. Önce kaynağın adının ve tekil olması gereken diğer değerlerinin (port, DNS adı) iki kopyanın aynı anda yaşamasına izin verdiğinden emin ol.

Küçük Bir Deneme

★☆☆ terraform graph -format=mermaid çıktısını bir Markdown dosyasına yapıştır ve GitHub'da önizle. Hangi kaynaklar paralel oluşturulabilir?

★★☆ docker_volume.api_data'nın for_each'ine yeni bir volume ekle. Plan'da yeni volume'ün oluşturulduğunu, container'ın da replace edildiğini göreceksin. Sonra prevent_destroy'u bir an kaldırıp exports'u listeden silmeyi dene; ayar yerindeyken ve değilken plan nasıl değişiyor?

★★★ Container'ın adını ve host port'unu her replace'te değişecek şekilde (ör. image ID'sinin ilk birkaç karakterini ada ekleyerek) yeniden yaz ve create_before_destroy'u bu kez çalışır hâle getir. Check block'u yeni port'u nereden bilecek?

Bir sonraki yazıda tekrar eden yapıları module'e çeviriyoruz: bookstore-webapp module'ünün input/output sözleşmesi, lokal path, git ve Terraform Registry kaynakları, sürüm sabitleme, Terraform 1.15 ile gelen module source'ta variable kullanımı ve deprecated ile bir input'u emekliye ayırmak.