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

Terraform Kurulumu ve İlk Resource: Provider, init, plan, apply ve destroy ile Azure'da Bookstore Resource Group'u

Mert Özen 14 Kas 2026 12 dk 13 görüntülenme
Terraform Kurulumu ve İlk Resource: Provider, init, plan, apply ve destroy ile Azure'da Bookstore Resource Group'u

Terraform kurulumu tek bir binary'yi PATH'e koymaktır; asıl iş required_version, provider sürüm kısıtları ve commit'lenen .terraform.lock.hcl ile herkesin aynı sürümü kullanmasını sağlamaktır. AzureRM 5.x ile Bookstore'un resource group ve storage account'unu yazıyor, 5.0'daki resource provider registration değişikliğini görüyor, plan çıktısındaki +, ~, -/+ ve - işaretlerini gerçek çıktılarla okuyor ve saved plan, stale plan, destroy, .gitignore ve fmt'yi deniyoruz.

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

Önce Tahmin Et

Bookstore API container'ının tanımında tek bir satırı değiştiriyorsun: image tag'ini 1.0.0'dan 1.1.0'a çekiyorsun. Plan'ın son satırı ne der: 1 to change mı, yoksa 1 to add, 1 to destroy mı? İkinci soru: terraform plan -out=tfplan ile planı kaydettin, sen kahve alırken ekip arkadaşın aynı ortama bir apply yaptı. Döndüğünde terraform apply tfplan dersen ne olur? Cevaplar yazının ortasında.

Terraform'u Windows'a Kurmak

Terraform tek bir çalıştırılabilir dosyadan ibaret; kurulum aslında bu dosyayı PATH'e (komutların arandığı klasör listesi) koymaktan ibaret. HashiCorp'un kurulum sayfası Windows için bir paket yöneticisi listelemiyor, yalnız 386, AMD64 (masaüstü ve sunucuların çoğundaki 64 bit x86 mimarisi) ve ARM64 (telefonlarda ve yeni dizüstülerde kullanılan 64 bit işlemci mimarisi) için zip dosyası veriyor. Windows'ta üç pratik yol var:

# 1. winget (topluluk tarafından güncellenen paket)
winget install -e --id Hashicorp.Terraform

# 2. Resmi zip: indir, SHA256SUMS dosyasındaki değerle karşılaştır, PATH'teki bir klasöre aç
Get-FileHash .\terraform_1.17.2_windows_amd64.zip -Algorithm SHA256

# 3. Sürüm yöneticisi: projeye göre farklı sürüm
tenv tf install 1.17.2

Tek bir projeyle çalışıyorsan winget yeterli. Birden fazla repo farklı Terraform sürümlerine sabitlenmişse tenv gibi bir sürüm yöneticisi işini kolaylaştırır: proje köküne koyduğun .terraform-version dosyasını ya da koddaki required_version'ı okuyup doğru sürümü seçer, OpenTofu'yu da yönetir. Bu yazıyı Windows olmayan bir makinede hazırladım; Terraform'u resmi zip'ten kurup SHA256 (dosyanın değişmediğini doğrulayan hash) değerini SHA256SUMS dosyasıyla karşılaştırdım. Yukarıdaki üç Windows komutunu kendim çalıştırmadım, HashiCorp'un ve tenv'in dokümanından. Kurulumdan sonra terraform version çıktısının ilk satırı Terraform v1.17.2 olmalı.

required_version ve required_providers: Sürüm Kısıtları

1. yazıdaki yerel örneği iki dosyaya bölüyoruz. Sürümle ilgili her şey versions.tf'te durur:

terraform {
  required_version = "~> 1.17"

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

required_version bu kodu hangi Terraform sürümlerinin çalıştırabileceğini söyler. ~> operatörü "pessimistic constraint" olarak geçer ve yalnız en sağdaki parçanın artmasına izin verir: ~> 1.17, 1.17 ve üstü ama 2.0'dan küçük demek; ~> 1.17.2 ise 1.17.2 ile 1.18.0 arasındaki patch sürümleri kabul eder. Ekipte biri daha eski ya da kısıtın dışında bir Terraform'la çalışırsa, daha init aşamasında durur. Kısıtı bilerek >= 1.18 yapınca 1.17.2 şunu söylüyor:

Error: Unsupported Terraform Core version

  on versions.tf line 2, in terraform:
   2:   required_version = ">= 1.18"

This configuration does not support Terraform version 1.17.2. To proceed,
either choose another supported Terraform version or update this version
constraint. Version constraints are normally set for good reason, so updating
the constraint may lead to other errors or unexpected behavior.

required_providers ise hangi provider'ın hangi kaynaktan ve hangi sürüm aralığında geleceğini söyler. source adresi registry'deki yeridir: kreuzwerker/docker bir topluluk provider'ı, hashicorp/azurerm HashiCorp'un resmi provider'ı. Provider'lar kendi takvimleriyle major sürüm çıkarır ve major sürümler breaking change getirir; AzureRM'in 4.0 ve 5.0 sürümleri tam olarak bunu yaptı. Bu yüzden provider kısıtı her zaman bir major sürümün içinde kalmalı: ~> 5.14 5.14 ve sonraki 5.x'leri alır, 6.0'ı almaz.

.terraform.lock.hcl: Neden Commit'lenir

terraform init provider'ı indirdiğinde seçtiği tam sürümü ve paketin hash'lerini .terraform.lock.hcl dosyasına yazar:

# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.

provider "registry.terraform.io/kreuzwerker/docker" {
  version     = "4.7.0"
  constraints = "~> 4.7"
  hashes = [
    "h1:5Ja6Ls3U05S22eICsWOoJSbkOfNIbvCwtgylDlywZ/w=",
    "h1:6hDUAMnx0fxA8sjN05+cuUJKAxuW4N68BZiwZBWmUhg=",
    "h1:HzzytbpOhZJuIsTJsxq0iN/mtrTkktS+goYm0+fBV9o=",
    "zh:218c89bc881f743d3badd045020015d4ea3fde20837638c6fb8e4425a1330baa",
    ...
  ]
}

Bu dosya, package-lock.json'ın ya da NuGet lock file'ının Terraform'daki karşılığı. Commit'lendiğinde ekipteki herkes ve CI aynı provider sürümünü kullanır, hash'ler de indirilen paketin değiştirilmediğini doğrular. Kısıt ~> 4.7 olsa bile, lock file 4.7.0 diyorsa 4.8 çıktığında kendiliğinden yükselmez; yükseltmek bilinçli bir adımdır: terraform init -upgrade, sonra lock file'daki diff'in review'u.

Kısıtı lock file'la çelişecek şekilde değiştirirsen Terraform tahmin yürütmez, durur. version'ı ~> 3.6 yapınca:

Error: Failed to query available provider packages

Could not retrieve the list of available versions for provider
kreuzwerker/docker: locked provider registry.terraform.io/kreuzwerker/docker
4.7.0 does not match configured version constraint ~> 3.6; must use terraform
init -upgrade to allow selection of new versions

Bir tuzak daha var. h1: hash'i platforma özgü paketten hesaplanır ve init varsayılan olarak yalnız çalıştığı platformun hash'ini yazar. Lock file'ı Windows'ta oluşturup Linux runner'ında çalışan CI'a gönderirsen, CI paketi doğrulayacak hash'i bulamaz. Çözüm, ekibin kullandığı bütün platformları baştan kilitlemek:

terraform providers lock -platform=windows_amd64 -platform=linux_amd64 -platform=darwin_arm64
- Obtained kreuzwerker/docker checksums for windows_amd64; Additional checksums for this platform are now tracked in the lock file
- Obtained kreuzwerker/docker checksums for linux_amd64; Additional checksums for this platform are now tracked in the lock file
- Obtained kreuzwerker/docker checksums for darwin_arm64; All checksums for this platform were already tracked in the lock file
Success! Terraform has updated the lock file.

Yukarıdaki lock file'daki üç h1: satırı bu üç platformun hash'leri.

Bookstore'un İlk Azure Kaynakları: Resource Group ve Storage Account

Şimdi asıl hedefe, infra/azure klasörüne geçiyoruz. Sürüm dosyası aynı kalıpta:

terraform {
  required_version = "~> 1.17"

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

Provider configuration'ı providers.tf'te:

provider "azurerm" {
  # Az CLI ile giriş yapılmış hesabı kullanır; subscription ID ARM_SUBSCRIPTION_ID'den gelir
  resource_providers_to_register = ["Microsoft.Storage"]

  features {}
}

Burada AzureRM 5.x'e özgü iki ayrıntı var. Birincisi, 4.0'dan beri subscription ID zorunlu: ya provider block'unda subscription_id ile ya da ARM_SUBSCRIPTION_ID environment variable'ıyla verilir. Upgrade guide'ın açıklamasına göre bu, Azure CLI'da o an hangi subscription seçiliyse plan'ın oraya gitmesi riskini kapatmak için yapıldı. İkincisi, resource provider registration. Azure'da bir service'i bir subscription'da ilk kez kullanmadan önce o service'in resource provider'ının (ör. Microsoft.Storage) kayıtlı olması gerekir. AzureRM 4.x açılışta altmış kadar resource provider'ı kendiliğinden kaydetmeye çalışıyordu; bu hem açılışı yavaşlatıyor hem de kısıtlı yetkili kullanıcılarda permission hatası veriyordu. 5.0'da resource_provider_registrations'ın varsayılanı none oldu ve skip_provider_registration kaldırıldı. Ya ihtiyacın olanları resource_providers_to_register ile listelersin, ya eski davranış için resource_provider_registrations = "legacy" dersin, ya da kaydı Terraform'un dışında, platform ekibi yapar.

Kaynaklar main.tf'te:

resource "azurerm_resource_group" "bookstore" {
  name     = "rg-bookstore-dev-weu"
  location = "westeurope"

  tags = {
    app         = "bookstore"
    environment = "dev"
    managed_by  = "terraform"
  }
}

resource "azurerm_storage_account" "covers" {
  name                     = "stbookstoredevweu"
  resource_group_name      = azurerm_resource_group.bookstore.name
  location                 = azurerm_resource_group.bookstore.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
  min_tls_version          = "TLS1_2"

  tags = azurerm_resource_group.bookstore.tags
}

Resource group, Azure'da kaynakları bir arada tutan ve birlikte silinebilen mantıksal kutu. Storage Account Bookstore'un kitap kapaklarını tutacak. Adı bütün Azure'da tekil olmalı, 3 ile 24 karakter arasında küçük harf ve rakamdan oluşmalı; bu yüzden tire yok. resource_group_name = azurerm_resource_group.bookstore.name satırı bir referans: Terraform bundan storage account'un resource group'tan sonra oluşturulması gerektiğini çıkarır. Bu dependency graph'ı 5. yazıda ayrıntılı göreceğiz.

Azure hesabın varsa akış şu: Azure CLI ile az login, az account show --query id -o tsv ile subscription ID'yi al, PowerShell'de $env:ARM_SUBSCRIPTION_ID'ye ata, sonra terraform init, plan ve apply. Plan iki kaynak ekleyeceğini söyler. Ben bu kodla Azure'a gitmedim; çalıştırdıklarım ve gerçek çıktıları:

$ terraform init
Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/azurerm versions matching "~> 5.14"...
- Installing hashicorp/azurerm v5.14.0...
- Installed hashicorp/azurerm v5.14.0 (signed by HashiCorp)
...
$ terraform validate
Success! The configuration is valid.

validate yalnız kodun kendi içinde tutarlı olduğunu söyler: attribute adları doğru, tipler uyumlu, referanslar var. Storage account adının Azure'da boşta olup olmadığını, subscription'ının kotasını ya da yetkini kontrol etmez. Giriş yapmadan plan almaya çalışınca provider daha Azure'a bağlanmadan duruyor:

Error: unable to build authorizer for Resource Manager API: could not configure AzureCli Authorizer: could not parse Azure CLI version: launching Azure CLI: exec: "az": executable file not found in $PATH

  with provider["registry.terraform.io/hashicorp/azurerm"],
  on providers.tf line 1, in provider "azurerm":
   1: provider "azurerm" {

Plan, provider'ın gerçek dünyaya sorduğu adımdır; credential yoksa plan da yoktur. Bu yüzden plan çıktısını okumayı yerel eşte öğreniyoruz.

Plan Çıktısını Okumak: +, ~, -/+ ve -

Yerel örnekte container'a bir restart policy ekliyoruz: restart = "unless-stopped". Plan:

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # docker_container.api will be updated in-place
  ~ resource "docker_container" "api" {
        id                                          = "8ece986c7ad708170eb1337ed0d7a075ba09c7c46fee59b005c17a468ed0de61"
        name                                        = "tfseries-dev-api"
      ~ restart                                     = "no" -> "unless-stopped"
        # (50 unchanged attributes hidden)

        # (2 unchanged blocks hidden)
    }

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

~ in-place update demek: kaynak yerinde kalır, yalnız bir attribute'u değişir. Docker API'si restart policy'yi çalışan container'da değiştirebildiği için provider bunu update olarak planladı. Şimdi ilk sorudaki değişikliği yapıyoruz. Önce API'nin yeni sürümünü build edip main.tf'te image tag'ini 1.1.0'a çekiyoruz:

docker build -t bookstore-api:1.1.0 --build-arg VERSION=1.1.0 app/Bookstore.Api

Plan:

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # docker_container.api must be replaced
-/+ resource "docker_container" "api" {
      ~ hostname                                    = "8ece986c7ad7" -> (known after apply)
      ~ id                                          = "8ece986c7ad708170eb1337ed0d7a075ba09c7c46fee59b005c17a468ed0de61" -> (known after apply)
      ~ image                                       = "bookstore-api:1.0.0" -> "bookstore-api:1.1.0" # forces replacement
      - memory                                      = 0 -> null
      - user                                        = "1654" -> null
      - working_dir                                 = "/app" -> null
      ...
    }

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

İlk sorunun cevabı: 1 to add, 1 to destroy. Çalışan bir container'ın image'ı değiştirilemez, bu yüzden provider image attribute'unu "forces replacement" olarak işaretler ve Terraform eski container'ı silip yenisini oluşturur. Gerçek plan seksen satırdan uzun; yukarıdaki kısaltılmış hâli. Uzun bir replace plan'ında ~ ve - ile başlayan onlarca satır görürsün: id'nin (known after apply) olması, varsayılan değerlerin null'a dönmesi gibi. Bunların çoğu replace'in doğal sonucu. Okurken iki şeye bak: resource başlığındaki must be replaced ve satır sonundaki # forces replacement. Replace'i hangi attribute'un tetiklediğini bu ikisi söyler.

Docker container için replace birkaç saniyelik kesinti demek. Azure'da aynı işaret çok daha pahalı olabilir: azurerm_storage_account'ın name ya da location değeri değişirse, provider dokümanına göre kaynak yeniden oluşturulur. Yani eski storage account içindeki bütün kapak resimleriyle silinir, yerine boş bir tane açılır. Veritabanında da aynı işaret, verinin gitmesi demektir.

Dördüncü işaret -, yani destroy. terraform plan -destroy her şeyi silmeyi planlar:

  # docker_container.api will be destroyed
  - resource "docker_container" "api" {
  # docker_network.bookstore will be destroyed
  - resource "docker_network" "bookstore" {
Plan: 0 to add, 0 to change, 2 to destroy.

Bu çıktıyı grep ile kısalttım; tam hâlinde her kaynağın bütün attribute'ları - ile listelenir.

Saved Plan, Stale Plan ve destroy

terraform apply parametresiz çalıştırıldığında yeniden plan alır, onay ister ve uygular. Arada geçen sürede kod ya da gerçek dünya değiştiyse, onay verdiğin şey review ettiğin plan olmayabilir. -out bu boşluğu kapatır:

$ terraform plan -out=tfplan
...
Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"
$ terraform apply tfplan
docker_container.api: Modifying... [id=8ece986c7ad708170eb1337ed0d7a075ba09c7c46fee59b005c17a468ed0de61]
docker_container.api: Modifications complete after 0s [id=8ece986c7ad708170eb1337ed0d7a075ba09c7c46fee59b005c17a468ed0de61]

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

Saved plan ile apply onay sormaz, çünkü onayı plan'ı review ederken verdin. Kaydedilmiş plan'ın içeriğini sonradan terraform show tfplan ile okursun. İkinci soruya gelince: image değişikliğinin planını tfplan'a kaydettim, sonra başka bir terminalden terraform apply -auto-approve ile aynı değişikliği uyguladım. Kaydedilmiş planı uygulamaya çalışınca:

Error: Saved plan is stale

The given plan file can no longer be applied because the state was changed by
another operation after the plan was created.

Terraform plan dosyasıyla birlikte state'in o anki sürümünü de kaydeder. State değiştiyse plan bayatlamıştır (stale) ve uygulanmaz. Bu davranış 11. yazıda CI'da "plan job'u, onay, apply job'u" akışını güvenli kılan şeyin ta kendisi.

İşin bitince terraform destroy state'teki her şeyi siler:

docker_container.api: Destroying... [id=5897de26d92d077f394a8dfc57eb59169d4233e151772533b416d893f928edad]
docker_container.api: Destruction complete after 0s
docker_network.bookstore: Destroying... [id=2c7682d8293a661c453ea92809589c7d60564fec3cde46232b1b0ce8c99d84d5]
docker_network.bookstore: Destruction complete after 2s

Destroy complete! Resources: 2 destroyed.

Sıralamaya dikkat: oluştururken önce network sonra container, silerken önce container sonra network. Terraform dependency graph'ı silerken ters sırada yürür.

.gitignore ve terraform fmt

Terraform klasöründe commit'lenmemesi gereken dosyalar var:

# Provider binary'leri ve module cache'i
.terraform/

# State ve yedekleri: remote backend'de durur, içinde secret olabilir
*.tfstate
*.tfstate.*

# Kaydedilmiş plan'lar ve crash log'ları
tfplan
*.tfplan
crash.log
crash.*.log

# CLI configuration dosyaları credential içerebilir
.terraformrc
terraform.rc

.terraform.lock.hcl bu listede yok, çünkü commit'lenmeli. State'in neden repo'ya girmemesi gerektiğini 4. yazıda ayrıntılı göreceğiz.

Biçim tartışmasını da baştan kapatmak için terraform fmt var: girintiyi ve = hizalamasını HCL'in (Terraform'un configuration dili) standart stiline getirir. CI'da -check ile çalıştırılır; düzeltilmesi gereken dosya varsa adını yazar ve sıfırdan farklı bir exit code döner. Bilerek bozulmuş bir dosyada:

$ terraform fmt -check
main.tf
$ echo $?
3
$ terraform fmt -diff
main.tf
--- old/main.tf
+++ new/main.tf
@@ -1,7 +1,7 @@
 resource "docker_network" "bookstore" {
-    name = "tfseries-dev"
+  name = "tfseries-dev"
   labels {
-  label = "app"
-  value = "bookstore"
+    label = "app"
+    value = "bookstore"
   }
 }

Üretimde Dikkat

Production'da apply'ı her zaman saved plan'dan yap. Review edilen plan ile uygulanan plan aynı dosya olmalı; arada state değişirse Terraform zaten durur.

Terraform'u, provider'ları ve lock file'ı bir arada yükselt. Sürüm yükseltmesi ayrı bir pull request olsun, açıklamasında provider'ın changelog'undan ilgili satırlar ve yükseltmeden sonraki plan'ın "No changes." çıktısı dursun. Kod değişikliğiyle sürüm yükseltmesini aynı pull request'e koyarsan, plan'daki bir farkın hangisinden geldiğini ayırt edemezsin.

AzureRM 5.x'te resource provider registration'ı bilinçli yönet. Production subscription'larında Terraform'u çalıştıran identity'ye "her resource provider'ı kaydet" yetkisi vermek yerine, gerekli olanları platform ekibinin bir kez kaydetmesi daha güvenli.

Sık Düşülen Tuzaklar

Plan okumadan apply -auto-approve çalıştırmak. Özellikle "küçük bir değişiklik" diye düşündüğün anlarda: bir attribute'un replace tetiklediğini bilmiyorsan plan'ın son satırındaki 1 to destroy tek uyarındır.

-/+ işaretini gözden kaçırıp veritabanını ya da storage account'u yeniden yaratmak. Adını "daha düzgün" bir naming convention'a çekmek için değiştirdiğin bir kaynak, Azure'da silinip yeniden oluşturulan bir kaynaktır. Adı değiştirmek gerekiyorsa veri taşıma planıyla birlikte yapılır; Terraform tarafındaki adresi değiştirmek için ise 8. yazıdaki moved block'u var.

Lock file'ı .gitignore'a eklemek ya da yalnız kendi platformunun hash'iyle commit'lemek. İlki ekipte herkesin farklı provider sürümüyle çalışmasına, ikincisi CI'ın lock file'ı reddetmesine yol açar.

Küçük Bir Deneme

★☆☆ Yerel örneği terraform init ile kur, restart değerini değiştir ve -out=tfplan ile plan al. terraform show tfplan çıktısını oku, sonra planı uygula.

★★☆ ports block'undaki external değerini değiştirip plan al. Bu değişiklik in-place mi, replace mi? # forces replacement hangi satırda çıkıyor?

★★★ infra/azure kodundaki storage account'a allow_nested_items_to_be_public ekleyip validate çalıştır. Sonra AzureRM 5.0 upgrade guide'ında bu attribute'un varsayılan değerinin ne olduğunu bul ve 4.x'ten 5.x'e geçen bir projede plan'ın bu satırda neden fark gösterebileceğini açıkla.

Bir sonraki yazıda HCL'in kendisine geçiyoruz: variable, output ve local'ler, count ile for_each arasındaki fark, for expression'ları ve dynamic block'lar. Bookstore'un Azure altyapısı da App Service Plan, Web App ve Azure SQL ile büyüyecek.