Android Map | Article Map
Terraform @ Scale - Parte 4b: Best Practices per Data Sources scalabili

Color logo   no background

Terraform @ Scale - Parte 4b: Best Practices per Data Sources scalabili

Nell'ultima parte di questa serie abbiamo mostrato come fonti dati apparentemente innocue all'interno di moduli Terraform possano trasformarsi in un serio problema di performance. Esecuzioni di terraform plan della durata di diversi minuti, pipeline instabili ed effetti di throttling delle API fuori controllo sono state le conseguenze.

Ma come si può evitare elegantemente e in modo sostenibile questa trappola di scalabilità?

In questa parte presentiamo pattern architetturali collaudati con cui centralizzare le Data Sources, iniettarle in modo efficiente dal punto di vista delle risorse e ottenere così esecuzioni Terraform rapide, stabili e prevedibili, anche con centinaia di istanze di moduli.

Inclusi: tre strategie scalabili, una guida passo-passo collaudata nella pratica e una checklist di Best Practices per moduli infrastrutturali pronti per la produzione.

 

Best Practice: Alternative scalabili

Soluzione 1 (scenari semplici): Variable Injection Pattern

Invece di usare Data Sources all'interno dei moduli, iniettate i dati necessari come variabili:


data "oci_identity_availability_domains" "available" {
  compartment_id = var.tenancy_ocid
}

data "oci_core_subnets" "database" {
  compartment_id = var.compartment_id
  vcn_id         = var.vcn_id
  
  filter {
    name   = "display_name"
    values = ["*database*"]
  }
}

locals {
  availability_domains = data.oci_identity_availability_domains.available.availability_domains
  database_subnets     = data.oci_core_subnets.database.subnets
}

module "databases" {
  for_each = var.database_configs != null ? var.database_configs : {}
  
  source = "./modules/database"
  
  availability_domains = local.availability_domains
  subnet_ids          = [for subnet in local.database_subnets : subnet.id]
  
  name = each.key
  size = each.value.size
}

 


variable "availability_domains" {
  type = list(object({
    name = string
    id   = string
  }))
  description = "Available ADs for database placement"
}

variable "subnet_ids" {
  type        = list(string)
  description = "Database subnet IDs"
}

resource "oci_database_db_system" "main" {
  for_each = var.db_systems != null ? var.db_systems : {}
  
  availability_domain = var.availability_domains[0].name
  subnet_id          = var.subnet_ids[0]
  compartment_id     = var.compartment_id
}

 

Soluzione 2 (scenari complessi): Structured Configuration Pattern

Per scenari più complessi utilizzate oggetti di configurazione strutturati:


data "oci_core_images" "ol8" {
  compartment_id           = var.tenancy_ocid
  operating_system         = "Oracle Linux"
  operating_system_version = "8"
}

locals {
  compute_images = {
    "VM.Standard.E4.Flex" = {
      image_id         = [for img in data.oci_core_images.ol8.images : img.id if can(regex(".*E4.*", img.display_name))][0]
      boot_volume_size = 50
    }
    "BM.Standard3.64" = {
      image_id         = [for img in data.oci_core_images.ol8.images : img.id if can(regex(".*Standard.*", img.display_name))][0]
      boot_volume_size = 100
    }
  }
  
  network_config = {
    availability_domains = data.oci_identity_availability_domains.ads.availability_domains
    vcn_id              = data.oci_core_vcn.main.id
  }
}

module "compute_instances" {
  for_each = var.instance_configs != null ? var.instance_configs : {}
  
  source = "./modules/compute-instance"
  
  compute_config = local.compute_images[each.value.shape]
  network_config = local.network_config
}

 


variable "compute_config" {
  type = object({
    image_id         = string
    boot_volume_size = number
  })
  description = "Pre-resolved compute configuration"
}

variable "network_config" {
  type = object({
    availability_domains = list(object({
      name = string
      id   = string
    }))
    vcn_id = string
  })
  description = "Pre-resolved network configuration"
}

resource "oci_core_instance" "this" {
  for_each = var.instances != null ? var.instances : {}
  
  availability_domain = var.network_config.availability_domains[0].name
  compartment_id      = var.compartment_id
  
  source_details {
    source_id               = var.compute_config.image_id
    source_type            = "image"
    boot_volume_size_in_gbs = var.compute_config.boot_volume_size
  }
}

Soluzione 3 (scenari molto complessi): Data Proxy Pattern

Per scenari molto complessi create moduli "Data Proxy" dedicati:


data "oci_core_images" "oracle_linux" {
  compartment_id           = var.tenancy_ocid
  operating_system         = "Oracle Linux"
  operating_system_version = "8"
}

data "oci_core_vcn" "main" {
  vcn_id = var.vcn_id
}

data "oci_core_security_lists" "web" {
  compartment_id = var.compartment_id
  vcn_id         = var.vcn_id
  
  filter {
    name   = "display_name"
    values = ["*web*"]
  }
}

output "platform_data" {
  value = {
    image_id = data.oci_core_images.oracle_linux.images[0].id
    vcn_id   = data.oci_core_vcn.main.id
    
    instance_shapes = {
      small  = "VM.Standard.E3.Flex"
      medium = "VM.Standard.E4.Flex"
      large  = "VM.Standard3.Flex"
    }
  }
}

 


module "platform_data" {
  source = "./modules/data-proxy"
  
  tenancy_ocid   = var.tenancy_ocid
  compartment_id = var.compartment_id
  vcn_id         = var.vcn_id
}

module "web_servers" {
  for_each = var.web_server_configs != null ? var.web_server_configs : {}
  
  source = "./modules/oci-instance"
  
  platform_data = module.platform_data.platform_data
  
  name          = each.key
  instance_type = each.value.size
}

Confronto delle performance

Un esempio concreto da un progetto cliente con provisioning di 50 istanze VM mostra la differenza drastica:

 
 Prima: Data Sources nei moduli

Dopo: Variable Injection

Numero di API-Calls
150 API-Calls 3 API-Calls
Tempo di esecuzione
$ time terraform plan
...
Plan: 50 to add, 0 to change, 0 to destroy.
real 4m23.415s
user 0m12.484s
sys 0m2.108s
$ time terraform plan
...
Plan: 50 to add, 0 to change, 0 to destroy.
real 0m18.732s
user 0m8.234s
sys 0m1.456s

 

Risultato: 93% in meno di tempo di pianificazione e 98% in meno di API-Calls.

Variable Injection: Guida passo-passo

Passaggio 1: Centralizzare le Data Sources

Obiettivo: Rimuovere tutte le Data Sources dai moduli e raccoglierle centralmente nel modulo root, per consolidare gli API-Calls e creare una singola fonte di verità.

Come: Spostate tutte le Data Sources utilizzate dai moduli nel modulo root. Questo garantisce che ogni informazione venga interrogata una sola volta, indipendentemente da quanti moduli ne abbiano bisogno. In questo modo si riduce il numero di API-Calls da N×M (numero di moduli × numero di Data Sources) a solo M (numero di Data Sources).


data "oci_identity_availability_domains" "ads" {
  compartment_id = var.tenancy_ocid
}

data "oci_core_images" "ol8" {
  compartment_id           = var.tenancy_ocid
  operating_system         = "Oracle Linux"  
  operating_system_version = "8"
}

data "oci_core_vcn" "main" {
  vcn_id = var.vcn_id
}

 

Passaggio 2: Elaborare i dati nei Locals

Obiettivo: Trasformare i risultati grezzi delle Data Sources in una forma consumabile, mantenendo la complessità fuori dai moduli.

Come: Usate Locals per filtrare, ordinare e convertire i risultati delle Data Sources in formati dati strutturati. Questo consente di gestire logiche complesse in modo centralizzato e fornire ai moduli dati già elaborati e puliti. Tramite for-loop ed espressioni condizionali potete implementare anche meccanismi di fallback e logiche di validazione.


locals {
  availability_domains = [
    for ad in data.oci_identity_availability_domains.ads.availability_domains : ad.name
  ]
  
  compute_images = {
    standard = [
      for img in data.oci_core_images.ol8.images :
      img.id if can(regex(".*Standard.*", img.display_name))
    ][0]
    
    gpu = [
      for img in data.oci_core_images.ol8.images :
      img.id if can(regex(".*GPU.*", img.display_name))  
    ][0]
  }
  
  network_config = {
    vcn_id               = data.oci_core_vcn.main.id
    vcn_cidr            = data.oci_core_vcn.main.cidr_block
    availability_domains = local.availability_domains
  }
}

 

Passaggio 3: Definire variabili nei moduli

Obiettivo: Creare interfacce chiare per il passaggio dei dati ai moduli, garantendo al contempo sicurezza dei tipi e validazione.

Come: Sostituite le Data Sources nei moduli con variabili tipizzate, dotate di descrizioni significative e regole di validazione. Le definizioni di tipo assicurano coerenza e resistenza agli errori, mentre i blocchi di validazione garantiscono che solo dati validi vengano passati ai moduli. In questo modo i moduli diventano più testabili e indipendenti dalle API del cloud provider.


variable "availability_domains" {
  type        = list(string)
  description = "List of available availability domains"
  
  validation {
    condition     = length(var.availability_domains) > 0
    error_message = "At least one availability domain must be provided."
  }
}

variable "compute_images" {
  type        = map(string)
  description = "Map of compute images by type"
  
  validation {
    condition = alltrue([
      for image_id in values(var.compute_images) :
      can(regex("^ocid1\\.image\\.", image_id))
    ])
    error_message = "All image IDs must be valid OCI OCIDs."
  }
}

 

Passaggio 4: Implementare moduli senza Data Sources

Obiettivo: Liberare completamente i moduli da chiamate API esterne e trasformarli in semplici contenitori di definizioni di risorse.

Come: Sostituite tutti i riferimenti a Data Sources nei moduli con riferimenti a variabili. Questo rende i moduli deterministici e prevedibili, poiché operano solo con i parametri passati e non eseguono più chiamate API inattese. Al contempo i moduli diventano testabili in modo indipendente, poiché potete iniettare dati mock tramite le variabili.


resource "oci_core_instance" "this" {
  for_each = var.instances != null ? var.instances : {}
  
  availability_domain = var.availability_domains[each.value.ad_index]
  compartment_id      = var.compartment_id
  shape              = each.value.shape
  
  create_vnic_details {
    subnet_id = each.value.subnet_id
  }
  
  source_details {
    source_id   = var.compute_images[each.value.image_type]
    source_type = "image"
  }
  
  metadata = {
    ssh_authorized_keys = var.ssh_public_key
  }
}

 

Passaggio 5: Richiamare i moduli con dati iniettati

Obiettivo: Stabilire il collegamento tra i dati raccolti centralmente e i moduli, per definire un pattern di flusso dati pulito.

Come: Passate i dati elaborati nei locals come parametri ai moduli. In questo modo si chiude il cerchio della Variable Injection: i dati vengono raccolti una sola volta centralmente, elaborati e poi distribuiti in modo mirato ai moduli. Questo tipo di trasmissione esplicita dei dati crea dipendenze chiare, comprensibili sia per le persone che per Terraform stesso.


module "web_servers" {
  for_each = var.web_server_configs != null ? var.web_server_configs : {}
  
  source = "./modules/compute"
  
  availability_domains = local.availability_domains
  compute_images      = local.compute_images  
  network_config      = local.network_config
  
  instances      = each.value.instances
  compartment_id = each.value.compartment_id
  ssh_public_key = var.ssh_public_key
}

 

Checklist Best Practices

✅ Do's: Pattern scalabili

  • [ ] Data Sources centrali: Definire tutte le Data Sources nel root module
  • [ ] Variable Injection: Passare i dati ai moduli come variabili
  • [ ] Oggetti strutturati: Organizzare dati complessi in oggetti tipizzati
  • [ ] Validation Rules: Implementare validazioni per i dati iniettati
  • [ ] Documentation: Scrivere descrizioni per le variabili iniettate
  • [ ] Local Processing: Elaborare i dati nei locals nel root module
  • [ ] Data Proxy Pattern: Usare moduli dati separati per scenari molto complessi

❌ Don'ts: Evitare gli anti-pattern

  • [ ] Data Sources nei moduli: Mai usare Data Sources in moduli riutilizzabili
  • [ ] Lookups ridondanti: Data Sources identiche in più moduli
  • [ ] Filtri complessi: Operazioni di filtro costose in ogni modulo
  • [ ] Data Sources annidate: Data Sources che dipendono da altre Data Sources
  • [ ] Riferimenti dinamici: for_each su risultati di Data Sources nei moduli
  • [ ] Validazione mancante: Usare dati iniettati senza validazione

Monitoring e Debugging

Per monitorare la performance delle Data Sources, potete analizzare l'output di debug di terraform plan alla ricerca di voci relative alle Data Sources:


export TF_LOG=DEBUG
export TF_LOG_PATH=./terraform.log

terraform plan 2>&1 | grep -E "(data\.|GET|POST)" | wc -l

terraform plan 2>&1 | grep -E "data\." | awk '{print $2}' | sort | uniq -c

Conclusione: Moduli performanti grazie a un'architettura consapevole

Le Data Sources sono una potente funzionalità di Terraform - ma all'interno dei moduli possono diventare una trappola per la performance. Il Variable Injection Pattern offre una soluzione elegante:

Vantaggi:

  • Riduzione drastica degli API-Calls (risparmi superiori al 95%)
  • Scalabilità lineare delle performance invece di degrado esponenziale
  • Logica dati centralizzata per una migliore manutenibilità
  • Dipendenze esplicite invece di chiamate Data Source nascoste
  • Migliore testabilità grazie a dati mock iniettabili

La chiave sta nel cambio di paradigma: invece di ottenere i dati quando servono, li si ottiene una volta centralmente e li si distribuisce in modo mirato.

In ICT.technology, applicando coerentemente questi pattern, abbiamo ridotto i tempi di terraform plan da minuti a secondi - anche con centinaia di istanze di moduli.