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

Terraform 1.5'ten 1.17'ye ve AzureRM 3'ten 5'e: Ne Değişti, Eski Kodu Nasıl Okursun

Mert Özen 14 Kas 2026 13 dk 16 görüntülenme
Terraform 1.5'ten 1.17'ye ve AzureRM 3'ten 5'e: Ne Değişti, Eski Kodu Nasıl Okursun

Eski bir Terraform kodunu bugünkü sürüme taşımak, söz dizimi düzeltmelerini, adres taşımalarını ve provider'dan kaldırılmış tiplerin göçünü ayrı adımlarda, her adımda boş bir plan görerek yapmaktır. 0.11 dönemi kodu 1.17 ile çalıştırıyor, null_resource'u terraform_data'ya taşıyor, 1.5'ten 1.17'ye sürüm haritasını ve -minimal-refresh'in drift'i nasıl gizlediğini görüyor, AzureRM 3'ten 5'e yükseltme sırasını ve OpenTofu ile gerçek uyum farklarını deniyoruz.

Sürüm notu: Bu yazıdaki örnekler Terraform 1.17.2, OpenTofu 1.13.9, hashicorp/null provider 3.3.2, kreuzwerker/docker provider 4.7.0 ve Docker Engine 29 ile denenmiştir. AzureRM örnekleri 5.14.0 ile yalnız validate edildi; Azure'a plan/apply yapılmadı. Sürüm haritası Terraform'un resmi changelog'larından derlendi.

Önce Tahmin Et

Bookstore'un (sitedeki serilerin ortak örnek uygulaması, bir online kitapçı) eski bir repo'sunda 2019'dan kalma bir Terraform klasörü buldun: her değer "${...}" içinde, listeler list(...) ile kurulmuş, bir null_resource provisioner'la migration çalıştırıyor, Azure tarafında azurerm_app_service_plan ve azurerm_sql_server var. Bu kodu bugünkü Terraform 1.17 ve AzureRM 5.14 ile init edip validate çalıştırsan kaç hata alırsın? Hangileri yalnız biçim, hangileri gerçek bir taşıma işi?

Eski Kodu Okumak: 0.11'den 1.x'e

Terraform'un dili 2019'daki 0.12 sürümünde büyük bir değişiklik geçirdi. 0.11'e kadar her expression bir string interpolation'ın ("${...}") içinde yazılırdı; 0.12 ile expression'lar dilin doğrudan parçası oldu. 0.13 provider'lara source adresi (hashicorp/azurerm) ve module'lere count/for_each getirdi, 0.14 .terraform.lock.hcl'i, 0.15 de eski fonksiyonların kaldırılmasını. 1.0 (2021) ile dil ve state biçimi için uyumluluk sözü geldi. Eski bir klasör şöyle görünür:

variable "environment" {
  default = "dev"
}

variable "image" {
  default = "bookstore-api:1.1.0"
}

locals {
  data_sets = "${list("covers", "exports")}"
}

resource "null_resource" "migrate" {
  triggers = {
    image = "${var.image}"
  }

  provisioner "local-exec" {
    command = "echo migrating ${var.environment} to ${var.image}"
  }
}
$ terraform validate

Error: Error in function call

  on main.tf line 10, in locals:
  10:   data_sets = "${list("covers", "exports")}"
    ├────────────────
    │ while calling list(vals...)

Call to function "list" failed: the "list" function was deprecated in
Terraform v0.12 and is no longer available; use tolist([ ... ]) syntax to
write a literal list.

Hata mesajı ne yapacağını söylüyor. list(...)'ı tolist([...]) yapınca validate geçiyor: "${var.image}" gibi yalnız interpolation'dan oluşan string'ler 1.17'de hâlâ geçerli. terraform fmt bir kısmını kendisi düzeltiyor:

$ terraform fmt -diff
main.tf
--- old/main.tf
+++ new/main.tf
@@ -7,7 +7,7 @@
 }
 
 locals {
-  data_sets = "${tolist(["covers", "exports"])}"
+  data_sets = tolist(["covers", "exports"])
 }
 
 resource "null_resource" "migrate" {

Dikkat çekici olan, fmt'nin yalnız local'deki ifadeyi açması; triggers map'inin içindeki "${var.image}"'a dokunmadı. Eski kodu modernleştirirken fmt'ye güvenip kalanları atlama, kalan "${'leri grep ile bul.

Eski kodda sık görülen iki alışkanlık daha var. Birincisi, her katmanın diğerini terraform_remote_state ile okuması; 7. yazıda bunun neden bütün state'e erişim gerektirdiğini ve yerine data source ya da yayınlanmış değerlerin tercih edildiğini görmüştük. İkincisi, null_resource ve provisioner'larla "apply sırasında bir script çalıştır" kalıbı. Provisioner'lar hâlâ var, ama HashiCorp dokümanı onları son çare olarak anlatıyor; bugün aynı işin bir kısmı 1.14 ile gelen action'larla, bir kısmı da uygulamanın kendi deploy adımında yapılıyor.

null_resource'tan terraform_data'ya

null_resource ayrı bir provider'dan (hashicorp/null) geliyordu. Terraform 1.4'ten beri aynı işi gören terraform_data yerleşik geliyor. 1.9 ile ikisi arasında moved block'u çalışıyor; kaynak tipi değiştiği hâlde yeniden oluşturulmuyor. Eski hâli apply ettim (provisioner bir kez çalıştı), sonra şu hâle getirdim:

variable "environment" {
  type    = string
  default = "dev"
}

variable "image" {
  type    = string
  default = "bookstore-api:1.1.0"
}

locals {
  data_sets = ["covers", "exports"]
}

resource "terraform_data" "migrate" {
  triggers_replace = { image = var.image }

  provisioner "local-exec" {
    command = "echo migrating ${var.environment} to ${var.image}"
  }
}

moved {
  from = null_resource.migrate
  to   = terraform_data.migrate
}
$ terraform plan
...
  # null_resource.migrate has moved to terraform_data.migrate
    resource "terraform_data" "migrate" {
        id               = "7941439726031055028"
        # (1 unchanged attribute hidden)
    }

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

Plan "has moved" diyor ve provisioner yeniden çalışmıyor; triggers, triggers_replace'e taşındı. 8. yazıda aynı tipteki adres değişikliklerini görmüştük; farklı tipler arasındaki taşıma ise provider'ın desteğine bağlı. Terraform 1.8 provider'lara bu imkânı verdi, ama her provider her tip için desteklemiyor.

Sürüm Haritası: 1.5'ten 1.17'ye

Bu serideki örneklerin çoğu son iki yılda gelen özelliklere dayanıyor. Eski bir kodda bir özelliği görmemenin sebebi çoğu zaman o kodun bu özellikten önce yazılmış olması. Changelog'lardan derlediğim harita:

SürümTarihÖne çıkanlarBu seride
1.5Haziran 2023import ve check block'ları, -generate-config-out, plantimestamp, strcontains5, 8
1.6Ekim 2023terraform test GA (genel kullanıma açık, kararlı sürüm), import id'sinde expression; BSL (Business Source License; kaynağı açık ama ticari kullanımı kısıtlayan lisans) ile çıkan ilk sürüm1, 10
1.7Ocak 2024mock_provider, removed block'u, import'ta for_each8, 10
1.8Nisan 2024Provider-defined function'lar, farklı tipler arasında moved12
1.9Haziran 2024Başka değerlere bakan variable validation, templatestring, null_resource → terraform_data6, 12
1.10Kasım 2024Ephemeral value ve resource'lar, S3 (AWS'nin object storage service'i) backend'inde native lock9
1.11Şubat 2025Write-only argument'lar, S3 native lock GA9
1.12Mayıs 2025import'ta identity, test'lerde paralel çalışma8
1.13Ağustos 2025terraform stacks komutu, test iyileştirmeleri-
1.14Kasım 2025list block'ları ve terraform query, action'lar; test temizliğinde prevent_destroy'un yok sayılması8, 10
1.15Nisan 2026const variable ile module source, deprecated, convert, validate'in backend'i kontrol etmesi3, 6, 7
1.16Ağustos 2026lifecycle { destroy = false }, module içinde import, graph -format=mermaid, action'lara destroy event'leri5
1.17Ekim 2026Provider kısıtlarında variable, -minimal-refresh, Terraform Policy GA, ephemeral kaynakları mock'lamak10, 12

Bir kodun en az hangi sürümü istediğini required_version'dan okuyabilirsin, ama bu satır çoğu zaman güncellenmez. Daha güvenilir ipucu kullanılan block'lar: moved varsa en az 1.1, import block'u 1.5, removed 1.7, ephemeral 1.10, const 1.15.

1.17 ile Gelenler

Serinin yazıldığı sırada güncel sürüm 1.17.2; 1.17.0 Ekim 2026'da çıktı. Öne çıkan yenilikler şunlar. Provider sürüm kısıtları artık variable ve local alabiliyor; 7. yazıdaki module ref'ine benzer biçimde provider sürümünü de ortamlar arasında tek yerden yönetmek mümkün. Terraform Policy GA oldu (10. yazı). mock_provider ephemeral kaynakları da mock'layabiliyor. terraform version -json çıktısına bir format_version alanı eklendi:

$ terraform version -json
{
  "format_version": "1.0",
  "terraform_version": "1.17.2",
  "provider_selections": {
    "registry.terraform.io/hashicorp/http": "3.6.3",
    "registry.terraform.io/kreuzwerker/docker": "4.7.0"
  },
  "terraform_outdated": false
}

Bu çıktıyı okuyan script'ler artık biçimin sürümüne bakabilir. Bana en çok düşündüren yenilik ise -minimal-refresh oldu: plan yalnız değişiklik önerilen kaynakları refresh ediyor. Büyük state'lerde plan'ı belirgin biçimde hızlandırabilir, ama yerel dev ortamında container'ı elle durdurup denediğimde sonucu şu oldu:

$ docker stop tfseries-dev-api
$ terraform plan -minimal-refresh
...
Plan: 0 to add, 0 to change, 0 to destroy.
$ terraform plan
...
  # module.bookstore.module.api.docker_container.this must be replaced
...
Plan: 1 to add, 0 to change, 1 to destroy.

Refresh edilmeyen kaynakta drift görünmüyor. 11. yazıdaki drift job'unda bu flag'i asla kullanma; yalnız kod değişikliğinin etkisini hızlıca görmek istediğin geliştirme döngüsünde işe yarar.

AzureRM 3'ten 4'e, 4'ten 5'e

AzureRM'in major sürümleri, kaldırılan resource'lar ve değişen varsayılanlar demek. 4.0 (Ağustos 2024) iki şeyi zorunlu kıldı: subscription ID'nin açıkça verilmesi (subscription_id ya da ARM_SUBSCRIPTION_ID) ve resource provider kaydının resource_provider_registrations ile seçilmesi; skip_provider_registration deprecated oldu. azurerm_sql_server ve azurerm_sql_database gibi eski SQL resource'ları kaldırıldı. 5.0 (Temmuz 2026) resource_provider_registrations'ın varsayılanını none yaptı, skip_provider_registration'ı tamamen kaldırdı, enhanced_validation'ı features block'unun içine taşıyıp varsayılan olarak kapattı ve azurerm_app_service, azurerm_app_service_plan, azurerm_function_app gibi uzun süredir deprecated olan resource'ları sildi. 2. yazıdaki resource_providers_to_register listesi bu değişikliğin sonucu. Tahmin sorusundaki 3.x döneminden kalma kodu 5.14 ile denedim:

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

provider "azurerm" {
  features {}
  skip_provider_registration = true
}

resource "azurerm_app_service_plan" "bookstore" {
  name                = "asp-bookstore-dev-weu"
  location            = "westeurope"
  resource_group_name = "rg-bookstore-dev-weu"
  kind                = "Linux"
  reserved            = true

  sku {
    tier = "Basic"
    size = "B1"
  }
}

resource "azurerm_sql_server" "bookstore" {
  name                         = "sql-bookstore-dev-weu"
  resource_group_name          = "rg-bookstore-dev-weu"
  location                     = "westeurope"
  version                      = "12.0"
  administrator_login          = "bookstoreadmin"
  administrator_login_password = var.sql_admin_password
}

variable "sql_admin_password" {
  type      = string
  sensitive = true
}
$ terraform validate

Error: Unsupported argument

  on main.tf line 12, in provider "azurerm":
  12:   skip_provider_registration = true

An argument named "skip_provider_registration" is not expected here.

$ terraform validate

Error: Invalid resource type

  on main.tf line 15, in resource "azurerm_app_service_plan" "bookstore":
  15: resource "azurerm_app_service_plan" "bookstore" {

The provider hashicorp/azurerm does not support resource type
"azurerm_app_service_plan".

Error: Invalid resource type

  on main.tf line 28, in resource "azurerm_sql_server" "bookstore":
  28: resource "azurerm_sql_server" "bookstore" {

The provider hashicorp/azurerm does not support resource type
"azurerm_sql_server". Did you mean "azurerm_mssql_server"?

İlk hata yalnız bir provider ayarı, tek satırda düzeliyor. Sonraki ikisi gerçek bir taşıma: 5.x bu tipleri hiç tanımıyor. Burada önemli bir sıra var. State'inde azurerm_app_service_plan olan bir root'u doğrudan 5.x'e yükseltirsen, provider state'teki kaynağı okuyamaz. Taşımayı yükseltmeden önce, eski tipi hâlâ tanıyan son 4.x sürümünde yapmak gerekir: yeni tip için kodu yaz (azurerm_service_plan, azurerm_linux_web_app, azurerm_mssql_server), eski kaynağı 8. yazıdaki gibi removed ile destroy = false olarak bırak, yenisini import block'u ile aynı Azure kaynağına bağla. Provider ikisi arasında moved'u desteklemediği sürece yol bu.

Major yükseltmenin genel sırası da buna göre: önce bulunduğun major'ün son sürümüne çık ve deprecation uyarılarının hepsini kapat; upgrade guide'ı bir kontrol listesi olarak oku; lock file'ı yeni sürüme terraform init -upgrade ile güncelle; her ortamın plan'ını al ve "No changes" görene kadar kodu düzelt. Planda replace varsa yükseltme altyapıyı değiştiriyor demektir; o satırın sebebini guide'da bulmadan apply etme. AzureRM'in 6.0 upgrade guide'ı da şimdiden yayımlanmaya başladı; deprecation uyarıları bir sonraki major'ün yapılacaklar listesi.

OpenTofu Farkları ve Geçiş Kararı

1. yazıda aynı kodu OpenTofu ile de çalıştırmıştık. Seri boyunca Terraform'un yeni özelliklerini kullandık; uyum hâlâ ne kadar? Bookstore'un 11. yazı sonundaki kodunu OpenTofu 1.13.9 ile denedim:

$ cd infra/local/envs/dev
$ cp .terraform.lock.hcl .terraform.lock.hcl.orig
$ tofu init -backend=false && tofu validate
...
Warning: Dependency lock file entries automatically updated
...
Success! The configuration is valid.
$ diff .terraform.lock.hcl.orig .terraform.lock.hcl
1c1
< # This file is maintained automatically by "terraform init".
---
> # This file is maintained automatically by "tofu init".
4c4
< provider "registry.terraform.io/hashicorp/http" {
---
> provider "registry.opentofu.org/hashicorp/http" {
...

$ cd ../../../modules/bookstore-webapp && tofu test
tests/webapp.tftest.hcl... fail
  run "https_and_tls"... fail

Error: expected "principal_id" to be a valid UUID, got ZYlr
...
Failure! 0 passed, 1 failed, 2 skipped.

Üç şey çıktı. Kod OpenTofu'da geçerli: write-only argument'lar, ephemeral kaynaklar ve deprecated attribute'u OpenTofu'da da var. Lock file değişiyor, çünkü OpenTofu provider'ları kendi registry'sinden (registry.opentofu.org) çekiyor; aynı repo'da iki araç birden kullanılırsa lock file iki tarafın arasında gidip gelir. 10. yazıdaki mock'lu test ise OpenTofu'da kırıldı: OpenTofu, plan modundaki mock değerlerini provider'ın doğrulamasına sokuyor ve rastgele üretilen principal ID bir UUID (benzersiz kimlik biçimi) olmadığı için test duruyor. Terraform'da yalnız apply modunda karşılaştığımız sorun burada plan modunda da var. İki araç aynı test dosyasını aynı biçimde çalıştırmıyor.

Bir de required_version. Denememde OpenTofu, .tf dosyasındaki kısıta hiç bakmadı, ama aynı satır .tofu uzantılı bir dosyadayken uyguladı:

$ cat versions.tf
terraform {
  required_version = "= 99.0.0"
}
$ tofu init
...
OpenTofu has been successfully initialized!

$ mv versions.tf versions.tofu && tofu init
...
Error: Incompatible module

  on versions.tofu line 2, in terraform:
   2:   required_version = "= 99.0.0"

This module is not compatible with OpenTofu v1.13.9.

OpenTofu, aynı klasörde .tofu uzantılı dosyaları .tf'lerle birlikte okuyor ve aynı adlı dosyada .tofu'yu tercih ediyor; Terraform ise .tofu dosyalarını hiç okumuyor. Bu, iki aracın kendine özgü ayarlarını aynı repo'da tutmanın yolu. OpenTofu'ya özgü iki özelliği bu yöntemle, infra/examples/opentofu/main.tofu içinde denedim:

# Yalnız OpenTofu: Terraform .tofu dosyalarını okumaz
terraform {
  required_version = ">= 1.11"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.7"
    }
  }

  # İstemci tarafında state ve plan şifreleme; parola hiçbir dosyaya yazılmaz
  encryption {
    key_provider "pbkdf2" "passphrase" {
      passphrase = var.state_passphrase
    }

    method "aes_gcm" "default" {
      keys = key_provider.pbkdf2.passphrase
    }

    state {
      method = method.aes_gcm.default
    }

    plan {
      method = method.aes_gcm.default
    }
  }
}

variable "state_passphrase" {
  type      = string
  sensitive = true
}

variable "with_reports" {
  type    = bool
  default = false
}

provider "docker" {}

resource "docker_volume" "covers" {
  name = "tfseries-tofu-covers"
}

# count olmadan sıfır ya da bir instance: adres docker_volume.reports olarak kalır
resource "docker_volume" "reports" {
  name = "tfseries-tofu-reports"

  lifecycle {
    enabled = var.with_reports
  }
}
$ export TF_VAR_state_passphrase=<passphrase>
$ tofu apply
docker_volume.covers: Creating...
docker_volume.covers: Creation complete after 0s [id=tfseries-tofu-covers]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ head -c 160 terraform.tfstate
{"serial":1,"lineage":"646b64e9-7d89-c5df-c092-58d50b6a647a","meta":{"key_provider.pbkdf2.passphrase":"eyJzYWx0IjoiSk1MMmhJZW10blROWkdNSWRJdUVzS2hGSStqbUJG
$ tofu apply -var with_reports=true
docker_volume.reports: Creating...
docker_volume.reports: Creation complete after 0s [id=tfseries-tofu-reports]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ tofu state list
docker_volume.covers
docker_volume.reports

$ terraform show
Error: Unsupported state file format

The state file does not have a "version" attribute, which is required to
identify the format version.

$ TF_VAR_state_passphrase=wrong-passphrase-123 tofu plan
Error: Error acquiring the state lock

Error message: failed to write backup file: decryption failed for all
provided methods

State dosyasında kaynakların yerine yalnız şifreli veri var; parola yanlışsa OpenTofu state'i açamıyor, Terraform ise dosyayı tanımıyor bile. 9. yazıda state'in secret'ları düz metin tuttuğunu görmüştük; OpenTofu bunu istemci tarafında şifreleyerek çözüyor. Anahtar sağlayıcı olarak burada bir parola kullandım; gerçek kullanımda Azure Key Vault ya da başka bir KMS'ten (Key Management Service; anahtarların yönetildiği servis) anahtar alınır. lifecycle { enabled = ... } ise count = var.x ? 1 : 0 kalıbının yerini alıyor ve kaynağın adresi [0] almadan aynı kalıyor.

Geçiş kararı için 1. yazıdaki ölçütler hâlâ geçerli: lisans (OpenTofu MPL (Mozilla Public License; açık kaynak lisansı) 2.0, Terraform BSL 1.1), ihtiyaç duyulan özellikler ve ekosistem. Bu seride kullandığımız özelliklerin bir kısmı (const variable, list/query, Terraform Policy, 1.16 ve 1.17 eklemeleri) yalnız Terraform'da, state şifreleme ve enabled ise yalnız OpenTofu'da. Uyum, iki projenin yolları ayrıldıkça azalıyor; bir ekip her ikisini değil, birini seçip CI'da onun sürümünü sabitlemeli. Seçimi ve gerekçesini bir ADR (Architecture Decision Record; mimari kararın gerekçesiyle yazıldığı kısa doküman) olarak yazmak, iki yıl sonra bu kodu okuyacak kişinin "neden bu araç?" sorusunu cevaplar.

Üretimde Dikkat

Terraform sürümünü bir altyapı bağımlılığı gibi yönet. required_version'ı dar tut (~> 1.17), CI'da tam sürümü sabitle (11. yazıdaki terraform_version: 1.17.2) ve yükseltmeyi ayrı bir pull request'te, bütün ortamların plan'ıyla yap. Yeni bir Terraform sürümüyle bir kez yazılan state, eski sürümle açılamayabilir; yükseltme geri dönüşü zor bir adım.

Provider major yükseltmelerini erteleme ama aceleye de getirme. Deprecation uyarıları bir sonraki major'de hataya dönüşüyor; uyarıları birikmiş bir kodu yükseltmek, onlarca kaynağın aynı anda taşınması demek. Her minor yükseltmede uyarıları temizlemek, major'ü sıradan bir değişikliğe çevirir.

Eski kodu modernleştirirken davranışı değiştirme. Söz dizimi düzeltmeleri, moved ile adres taşımaları ve tip değişiklikleri ayrı commit'lerde, her biri "No changes" ya da yalnız "has moved" diyen bir plan'la ilerlemeli. Söz dizimi düzeltmesiyle aynı commit'te bir SKU (kaynağın boyut ve fiyat katmanı) değişirse, plan'daki replace'in sebebini ayırt edemezsin.

Sık Düşülen Tuzaklar

Eski kodu doğrudan en yeni sürümle apply etmek. 0.11 döneminden kalma bir kod 1.17 ile validate'ten geçebilir, ama aradaki sürümlerde değişen varsayılanlar ve provider davranışları plan'da beklenmedik değişiklikler getirebilir. Önce plan, sonra adım adım düzeltme.

Provider'ı major sürüm atlayarak yükseltmek. AzureRM 3'ten 5'e tek adımda geçmek, iki upgrade guide'ındaki değişiklikleri aynı anda karşılamak ve kaldırılmış tiplerle dolu bir state'le kalmak demek. Her major'de bir durak ver.

Aynı repo'da Terraform ve OpenTofu'yu rastgele karıştırmak. Lock file sürekli değişir, testler farklı davranır, bir aracın özelliği diğerinde sessizce yok sayılır. İkisini birden destekleyeceksen farkları .tofu dosyalarında tut ve CI'da ikisini de çalıştır.

Küçük Bir Deneme

★☆☆ Kendi eski bir Terraform klasöründe grep -rn '"\${' --include=*.tf . ile yalnız interpolation'dan oluşan string'leri bul. terraform fmt bunların kaçını düzeltti?

★★☆ infra/examples/opentofu/main.tofu'da enabled yerine count kullanan bir sürüm yaz ve aynı kaynağın iki sürümdeki adresini karşılaştır. count'tan enabled'a geçerken moved block'u gerekir mi?

★★★ 10. yazıdaki bookstore-webapp testini, hem Terraform 1.17 hem OpenTofu 1.13 ile geçecek şekilde düzenle (ipucu: mock_resource varsayılanları). Bu iş, ekipte iki aracı birden desteklemenin maliyeti hakkında ne söylüyor?

Serinin sonuna geldik. Bookstore'un altyapısı artık kodda: ortam başına state, module'ler, secret'ları state'e yazmayan kaynaklar, testler ve onaylı apply yapan bir pipeline. Bu altyapının üzerinde uygulamayı yayına almak, deployment stratejileri ve Kubernetes tarafı, sitedeki CI/CD ve Kubernetes serilerinin konusu.