Module Yazmak ve Kullanmak: Input/Output Sözleşmesi, Registry Module'leri, Versiyonlama ve Dynamic Source
Module, variable'ları ve output'larıyla bir sözleşme sunan, tekrar eden bir kaynak grubudur; kaynakları module'e taşımak moved block'ları olmadan silip yeniden oluşturmak demektir. API'yi bookstore-service, Web App'i bookstore-webapp module'üne taşıyor, prevent_destroy'un neden korumadığını görüyor, Azure Verified Modules'tan birini indirip inceliyor, sürüm sabitlemeyi ve Terraform 1.15'teki const variable'lı module source ile deprecated input/output'ları deniyoruz.
Sürüm notu: Bu yazıdaki örnekler Terraform 1.17.2, kreuzwerker/docker provider 4.7.0, hashicorp/http 3.6.3, Azure Verified Module avm-res-web-site 0.24.1 (AzAPI provider 2.13.0) 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
5. yazıda API'nin veri volume'lerine prevent_destroy = true koymuştuk. Şimdi container'ı, volume'leri ve image takibini bir module'e taşıyoruz: kaynak tanımlarını root'tan silip modules/bookstore-service klasörüne koyuyor, root'tan module'ü çağırıyoruz. Kod aynı, yalnız yeri değişti. İlk plan ne der? prevent_destroy volume'leri korur mu? Cevap ilk plan çıktısında.
Module Ne Zaman Gerekir?
Terraform'da her klasör bir module. terraform komutunu çalıştırdığın klasör root module; root'un module block'uyla çağırdığı her klasör de bir child module. Child module bir fonksiyon gibi düşünülebilir: variable'ları parametreleri, output'ları dönüş değerleri, içindeki kaynaklar da gövdesi.
Module'ün gerçek bir faydası olduğu iki durum var. Birincisi, aynı kaynak grubu birden fazla yerde tekrar ediyorsa: Bookstore'un API'si, ilerideki bir admin API'si ve bir background worker aynı "container + veri volume'leri + label'lar" kalıbını kullanacaksa, kalıbı bir kez yazıp üç kez çağırmak tutarlılık sağlar. İkincisi, bir ekip başka ekiplere hazır ve denetlenmiş bir yapı sunmak istiyorsa: platform ekibinin "şirketteki her web app HTTPS-only, en az TLS (trafiği şifreleyen protokol) 1.2 ve managed identity'li olur" kuralını bir module'e gömmesi gibi.
Gereksiz olduğu durumlar da var. Tek bir kaynağı aynı argümanlarla saran ince bir module (her argümanı bir variable'a çeviren bir azurerm_storage_account sarmalayıcısı) hiçbir şey soyutlamaz; okuyan kişi artık iki dosyaya bakar. Bir kalıp bir kez kullanılıyorsa önce root'ta yaz, ikinci ihtiyaç gelince module'e çıkar. Bu yazıda module'e geçiş sebebimiz birincisi: API'nin yapısını ileride başka service'lerde de kullanacağız.
bookstore-service Module'ü: Input/Output Sözleşmesi
Module'ün dışarıya bakan yüzü variable'ları ve output'ları. İçerideki kaynakların adları, sayısı ya da sırası değişebilir; sözleşme değişmediği sürece çağıran taraf etkilenmez. modules/bookstore-service/variables.tf'ten ilgili kısım:
variable "name" {
type = string
description = "Container name."
}
variable "volume_prefix" {
type = string
description = "Data volumes are named <volume_prefix>-<data set>; defaults to name."
default = null
}
variable "image" {
type = string
description = "Image reference with a pinned tag, e.g. bookstore-api:1.1.0."
validation {
condition = !endswith(var.image, ":latest")
error_message = "Use a pinned image tag, not latest."
}
}
variable "env" {
type = map(string)
description = "Non-secret environment variables."
default = {}
nullable = false
}
variable "environment_variables" {
type = map(string)
description = "Old name of env."
default = {}
nullable = false
deprecated = "Use env instead; environment_variables will be removed in the next major version of this module."
}
variable "secret_env" {
type = map(string)
description = "Secret environment variables (still stored in the state)."
default = {}
sensitive = true
}
# networks, data_volumes, internal_port, external_port, restart, labels ...
Birkaç tasarım kararı var. Module ortamı bilmiyor: environment diye bir input'u yok. 5. yazıdaki "production'da latest yasak" precondition'ı, module'ün içinde her ortam için geçerli bir validation'a dönüştü: bu module ile çalışan hiçbir container latest tag'i kullanamaz. Ortama özgü kurallar root'ta kalır. Secret'lar ayrı bir secret_env input'unda ve sensitive; böylece module içinde hangi değerin secret olduğu kaybolmaz. Kaynak blokları 5. yazıdakiyle aynı, yalnız adresleri değişti: docker_container.this, docker_volume.data, terraform_data.image. Module içinde tek olan kaynağa this demek yaygın bir alışkanlık. Output'lar:
output "container_name" {
description = "Name of the running container."
value = docker_container.this.name
}
output "base_url" {
description = "Base URL on the host."
value = "http://localhost:${var.external_port}"
}
output "volume_names" {
description = "Volume names, keyed by data set."
value = { for key, volume in docker_volume.data : key => volume.name }
}
output "image_id" {
description = "ID of the image the container runs."
value = data.docker_image.this.id
}
output "url" {
description = "Old name of base_url."
value = "http://localhost:${var.external_port}"
deprecated = "Use base_url instead; url will be removed in the next major version of this module."
}
Son output'taki deprecated'a birazdan döneceğiz. Module'ün kendi versions.tf'i de var:
terraform {
required_version = ">= 1.15"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = ">= 4.0, < 5.0"
}
}
}
Root'ta provider'ı ~> 4.7 ile dar bir aralığa sabitlemiştik. Module'de ise geniş bir aralık var: "4.x ile çalışırım". Module'ü çağıran her root kendi kısıtını koyar ve Terraform hepsinin kesişimini kullanır. Module dar bir kısıt koyarsa, onu kullanan root'ların provider yükseltmesini gereksiz yere engeller.
Module'ü Çağırmak ve Kaynakları Taşımak
Root'taki main.tf'ten container, volume ve image kaynaklarını silip yerine module çağrısını koyuyoruz:
module "api" {
source = "../modules/bookstore-service"
name = "${local.name_prefix}-api"
volume_prefix = local.name_prefix
image = "bookstore-api:${var.api.image_tag}"
restart = var.environment == "prod" ? "always" : "unless-stopped"
env = local.app_settings
secret_env = { Bookstore__Payments__ApiKey = var.payments_api_key }
networks = [docker_network.bookstore.name, data.docker_network.shared.name]
data_volumes = var.volume_names
external_port = var.api.external_port
labels = local.labels
}
Output'taki volume_names artık module.api.volume_names'i döndürüyor, check block'u da URL'yi module.api.base_url'den alıyor. Module eklenince terraform init yeniden çalıştırılır:
Initializing modules...
- api in ../modules/bookstore-service
Tahmin sorusunun cevabı ilk plan'da:
# docker_container.api will be destroyed
# (because docker_container.api is not in configuration)
# docker_volume.api_data["covers"] will be destroyed
# (because docker_volume.api_data is not in configuration)
# docker_volume.api_data["exports"] will be destroyed
# (because docker_volume.api_data is not in configuration)
# terraform_data.api_image will be destroyed
# (because terraform_data.api_image is not in configuration)
# module.api.docker_container.this will be created
# module.api.docker_volume.data["covers"] will be created
# module.api.docker_volume.data["exports"] will be created
# module.api.terraform_data.image will be created
Plan: 4 to add, 0 to change, 4 to destroy.
Terraform için docker_volume.api_data ile module.api.docker_volume.data iki farklı adres; birinin koddan kalktığını, diğerinin yeni geldiğini görüyor. Volume'ler korunmuyor, çünkü 5. yazıda gördüğümüz sınır burada geçerli: prevent_destroy, kaynak block'u kodda durduğu sürece çalışır. Block koddan kalkınca koruma da kalktı. Bu plan apply edilseydi kapak resimleri ve export'lar silinirdi.
Taşımayı Terraform'a anlatmanın yolu moved block'ları. 4. yazıdaki state mv'nin kodda duran, pull request'te review edilen karşılığı:
# API kaynakları bookstore-service module'üne taşındı (moved block'larının ayrıntısı 8. yazıda)
moved {
from = docker_container.api
to = module.api.docker_container.this
}
moved {
from = docker_volume.api_data
to = module.api.docker_volume.data
}
moved {
from = terraform_data.api_image
to = module.api.terraform_data.image
}
İlk denememde plan hâlâ bir şey silmek istiyordu, bu kez prevent_destroy devreye girdi:
# module.api.docker_volume.data["covers"] must be replaced
# (moved from docker_volume.api_data["covers"])
...
~ name = "tfseries-dev-covers" -> "tfseries-dev-api-covers" # forces replacement
...
Error: Instance cannot be destroyed
on ../modules/bookstore-service/main.tf line 17:
17: resource "docker_volume" "data" {
Resource module.api.docker_volume.data["covers"] 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.
Module'ün ilk sürümü volume adlarını container adından türetiyordu (tfseries-dev-api-covers), eski kod ise ortam önekinden (tfseries-dev-covers). Adresi taşımak yetmedi; ad değiştiği için replace gerekti. İsimlendirme bir module'ün sözleşmesinin parçası. Module'e varsayılanı container adı olan bir volume_prefix input'u ekleyip root'tan local.name_prefix'i verince plan temizlendi:
# docker_container.api has moved to module.api.docker_container.this
resource "docker_container" "this" {
id = "1284850060aeaccace28350326fd4c6844eca43127f96f05cacadc66452cf462"
name = "tfseries-dev-api"
# (51 unchanged attributes hidden)
# (8 unchanged blocks hidden)
}
...
# docker_volume.api_data["covers"] has moved to module.api.docker_volume.data["covers"]
# docker_volume.api_data["exports"] has moved to module.api.docker_volume.data["exports"]
# terraform_data.api_image has moved to module.api.terraform_data.image
...
Plan: 0 to add, 0 to change, 0 to destroy.
Apply'dan sonra state'teki adresler module'ün içinde:
$ terraform state list
data.docker_network.shared
data.http.health
docker_network.bookstore
module.api.data.docker_image.this
module.api.docker_container.this
module.api.docker_volume.data["covers"]
module.api.docker_volume.data["exports"]
module.api.terraform_data.image
Container hiç yeniden oluşturulmadı. moved block'larını hemen silme; ekipte herkes ve her ortam bu değişikliği apply edene kadar kodda kalmaları gerekir. Ne zaman silineceklerini 8. yazıda konuşacağız.
Azure Tarafı: bookstore-webapp Module'ü
Azure kodunda aynı ayrımı yapıyoruz. App Service Plan, managed identity, Key Vault role assignment'ı ve Web App modules/bookstore-webapp'e taşındı; içerideki kaynaklar this adını aldı, depends_on ve ignore_changes onlarla birlikte gitti. Root'taki çağrı:
module "api" {
source = "../modules/bookstore-webapp"
name_suffix = local.suffix
resource_group_name = azurerm_resource_group.bookstore.name
location = azurerm_resource_group.bookstore.location
sku_name = var.api.sku_name
image = { name = var.api.image_name, tag = var.api.image_tag, registry_url = var.api.registry_url }
allowed_ip_ranges = var.allowed_ip_ranges
key_vault = { id = data.azurerm_key_vault.shared.id, name = data.azurerm_key_vault.shared.name }
tags = local.tags
app_settings = local.app_settings
}
Module'ün sözleşmesi şu: bir isim soneki, resource group ve region, SKU (App Service Plan'ın boyut ve fiyat katmanı), ilk image, app setting'ler, erişime açık IP aralıkları ve okunacak Key Vault'un ID'si ile adı. Karşılığında URL'yi, Web App ID'sini ve identity'nin principal ID'sini veriyor; sonuncusu, root'un bu identity'ye başka roller vermesine izin veriyor. Root'ta moved.tf dört kaynağı yeni adreslerine taşıyor ve check block'u URL'yi module.api.url'den alıyor. Bu kodu Azure'da çalıştırmadım; init module'ü buldu, validate geçti.
Bir şey yer değiştirdi: 5. yazıdaki "production'da B1 (en küçük Basic katman) plan olmaz" precondition'ı ortamı bilmesi gerektiği için module'e giremezdi. Module block'unun lifecycle'ı yok, bu yüzden kural root'taki api variable'ının validation'ına taşındı; Terraform 1.9'dan beri validation başka variable'lara da bakabiliyor.
Module Kaynakları ve Sürüm Sabitleme
source argument'ı module'ün nereden geleceğini söyler. Üç yaygın kaynak:
# Aynı repo'daki bir klasör: sürüm, repo'nun commit'i
source = "../modules/bookstore-service"
# Başka bir git repo'su: ref ile bir tag'e sabitlenir
source = "git::https://github.com/example/bookstore-terraform-modules.git//bookstore-webapp?ref=v1.4.0"
# Terraform Registry: version ile semver kısıtı
source = "Azure/avm-res-web-site/azurerm"
version = "0.24.1"
Lokal path'te ayrı bir sürüm yok; module, onu çağıran kodla aynı commit'te değişir. Bu, tek repo'da yaşayan bir ekip için en basit seçenek. Module'ler ayrı bir repo'da yaşıyor ve birden fazla ekip tarafından kullanılıyorsa git kaynağı ve bir tag'e sabitleme gerekir: ?ref= olmayan bir git kaynağı varsayılan branch'i çeker ve module'ün sahibi bir değişiklik push ettiği an, senin bir sonraki init'in farklı kod indirir. Git örneğindeki repo yalnız biçimi göstermek için; çalıştırmadım. // işareti repo içindeki alt klasörü gösteriyor.
Terraform Registry'deki module'ler version ile semver kısıtı alır. Microsoft'un Azure Verified Modules (AVM) projesi, Azure kaynakları için Microsoft'un desteklediği module'leri burada yayınlıyor. Web App module'ünü ayrı bir örnek klasöründe çağırdım:
# Yalnız örnek: aynı web app'i kendi module'ümüz yerine bir Azure Verified Module ile kurmak
terraform {
required_version = "~> 1.17"
required_providers {
azapi = {
source = "Azure/azapi"
version = "~> 2.12"
}
}
}
provider "azapi" {}
module "web" {
source = "Azure/avm-res-web-site/azurerm"
version = "0.24.1"
name = "app-bookstore-dev-weu"
location = "westeurope"
parent_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-bookstore-dev-weu"
service_plan_resource_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-bookstore-dev-weu/providers/Microsoft.Web/serverFarms/asp-bookstore-dev-weu"
enable_telemetry = false
}
Initializing modules...
Downloading registry.terraform.io/Azure/avm-res-web-site/azurerm 0.24.1 for web...
- web in .terraform/modules/web
- web.config_slotconfignames in .terraform/modules/web/modules/config_slotconfignames
...
Downloading registry.terraform.io/Azure/avm-utl-interfaces/azure 0.7.0 for web.avm_interfaces...
...
$ terraform providers
Providers required by configuration:
.
├── provider[registry.terraform.io/azure/azapi] ~> 2.12
└── module.web
├── provider[registry.terraform.io/hashicorp/time] >= 0.9.0, < 1.0.0
├── provider[registry.terraform.io/azure/azapi] ~> 2.12
├── provider[registry.terraform.io/azure/modtm] ~> 0.3
├── provider[registry.terraform.io/hashicorp/random] >= 3.5.0, < 4.0.0
...
Bu çıktıdan üç şey öğreniyoruz. Birincisi, adında azurerm geçmesine rağmen module'ün güncel sürümü AzureRM provider'ını değil, Azure'un REST (HTTP üzerinden kaynak odaklı API tarzı) API'sini doğrudan çağıran AzAPI provider'ını kullanıyor (lock file'a 2.13.0 yazıldı); kendi kodumuzla aynı provider'da değiliz. İkincisi, küçük bir web app için 329 .tf dosyası ve 7,5 MB'lık bir module ağacı indi: bütün App Service özelliklerini kapsayan bir module'ün bedeli bu. Üçüncüsü, modtm AVM'nin telemetri provider'ı; module'ün enable_telemetry input'u varsayılan olarak true, ben kapattım. validate geçti.
Sürüm sabitlemeye gelince: 1.0'ın altındaki bir module'de semver'e göre minor sürüm bile breaking change getirebilir, bu yüzden AVM gibi 0.x module'leri tam sürümle ("0.24.1") sabitle ve yükseltmeyi changelog'u okuyarak yap. 1.0 ve üstündeki module'lerde ~> 1.4 gibi bir kısıt yeterli. Hangisi olursa olsun yükseltme sonrası ilk plan'ı dikkatle oku: module içindeki bir varsayılanın değişmesi kodunda hiçbir satır değişmeden replace üretebilir.
Bookstore için önerim: kendi küçük module'lerimiz aynı repo'da, lokal path ile. AVM gibi registry module'lerini ise yalnız kendi yazacağımız kodun bakımı onlarınkinden pahalıya gelecekse kullanmak.
Terraform 1.15: Module Source'ta Variable ve deprecated
Terraform 1.15'e kadar source ve version yalnız literal değer alıyordu, çünkü module'ler init sırasında, variable'lar daha değerlendirilmeden indirilir. 1.15 ile bunlar local'leri ve const = true işaretli variable'ları kabul ediyor. Bir variable'ı const olmadan source'ta kullanmayı denediğimde:
Error: Unknown module source
on main.tf line 27, in module "api":
27: source = "${var.modules_path}/bookstore-service"
Only literal values and const variables can be evaluated during init.
Variable'ı const yapınca:
variable "modules_path" {
type = string
description = "Where shared modules live; a git URL with ?ref= in CI."
default = "../modules"
const = true
}
$ terraform init -var 'modules_path=../../lab/modules'
...
- api in ../../lab/modules/bookstore-service
Terraform has been successfully initialized!
Bu denemeyi kodun bir kopyasında yaptım; Bookstore'un asıl kodunda module yolu literal kalıyor. Gerçek kullanım alanı, bütün ortam klasörlerinde aynı module sürümünü tek bir yerden (ör. bir local'deki ref ya da version değeri) yönetmek; 7. yazıda ortamları ayırırken bunu kullanacağız. const variable plan sırasında hesaplanan bir değere bağlanamaz, yalnız sabit değer alır.
1.15'in getirdiği ikinci özellik, variable ve output'lar için deprecated. Module'lerin en zor yanı sözleşmeyi değiştirmek: bir input'un adını değiştirirsen onu kullanan bütün root'lar bir sonraki init'te kırılır. bookstore-service'in ilk sürümünde input'un adı environment_variables'dı, sonra env oldu. Eski adı silmek yerine deprecated ile işaretledik (yukarıdaki variables.tf). Root eski adı kullanınca plan çalışmaya devam ediyor ama uyarı veriyor:
Warning: Deprecated variable got a value
on main.tf line 33, in module "api":
33: environment_variables = local.app_settings
Use env instead; environment_variables will be removed in the next major
version of this module.
Output'ta da aynısı: url output'u base_url oldu, eskisi deprecated. Check block'unda module.api.url'i kullanınca:
Warning: Deprecated value used
with data.http.health,
on main.tf line 43, in check "api_health":
43: url = "${module.api.url}/health"
The deprecation originates from module.api.url
Use base_url instead; url will be removed in the next major version of this
module.
Dokümana göre deprecated output yalnız child module'lerde kullanılabiliyor, root module'de kullanılamıyor. Akış şöyle: yeni adı ekle, eskisini deprecated ile işaretle, module'ü kullananlara bir sürüm boyunca geçiş süresi ver, sonraki major sürümde eskisini sil.
Üretimde Dikkat
Bir module'ü, kullanıcıları olan bir API gibi yönet. Variable ve output'ların açıklamaları onun dokümanı; adlarını değiştirmek breaking change. Ayrı repo'da yaşayan module'lerde semver kullan, her sürüm için bir changelog satırı yaz ve kaldırılacak input'ları en az bir sürüm önce deprecated ile işaretle.
Kaynakları module'e taşımadan önce plan'ı mutlaka moved block'larıyla birlikte al ve son satırın 0 to destroy olduğunu gör. Taşıma bir refactor'dür; altyapıda hiçbir şeyin değişmemesi gerekir. Değişiyorsa sebebi, bizdeki volume adı gibi, sözleşmede kaçırdığın bir fark.
Registry module'lerinin indirdiği kodu ve provider'ları gözden geçir. 329 dosyalık bir module, senin altyapında çalışacak ve her yükseltmede değişecek bir bağımlılık. Telemetri gibi varsayılanları bilinçli olarak ayarla.
Sık Düşülen Tuzaklar
Her resource'u bir module'e sarmak. "Storage account module'ü", "resource group module'ü" gibi tek kaynaklı module'ler bir şey soyutlamaz, yalnız bir dolaylılık katmanı ekler. Module, bir arada anlam taşıyan bir kaynak grubu için yazılır.
Module içinde provider configuration tanımlamak. Module'ü çağıran root'un provider'ı devralması gerekir; module'ün kendi provider block'u olursa onu for_each ile çağıramazsın. bookstore-service'in bir kopyasına provider "docker" ekleyip iki ortam için for_each ile çağırdığımda:
Error: Module is incompatible with count, for_each, and depends_on
on main.tf line 3, in module "api":
3: for_each = toset(["dev", "staging"])
The module at module.api 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.
Module'de yalnız required_providers durur; provider'ın kendisi, kimlik bilgileri ve subscription ID root'ta tanımlanır.
Git kaynağını ref olmadan ya da bir branch adına sabitlemek. ?ref=main bir sabitleme değildir; module'ün sahibi main'e push ettiği an altyapın farklı bir kodla plan alır. Tag ya da commit SHA'sı (commit'in benzersiz hash'i) kullan.
Küçük Bir Deneme
★☆☆ terraform state list çıktısını module'e taşımadan önce ve sonra karşılaştır. Hangi adresler değişti, hangileri aynı kaldı?
★★☆ bookstore-service'i ikinci kez çağırarak ilerideki admin API'si için bir container ekle (farklı bir host port'uyla). Root'ta kaç satır yazman gerekti? Bu iki container arasında module'ün karşılamadığı bir fark çıktı mı?
★★★ Module'ün url output'unu tamamen sil ve root'ta hâlâ module.api.url kullanan bir satır bırak. Hata hangi komutta, hangi mesajla çıkıyor? Bu deneyim, deprecated ile verdiğin geçiş süresinin neden önemli olduğunu gösteriyor.
Bir sonraki yazıda environment'ları ayırıyoruz: CLI workspace'leri mi, environment başına dizin mi, tek kod ve environment başına tfvars mı? State'i katmanlara bölmek, blast radius ve Bookstore için önerilen klasör düzeni.