Sägetstrasse 18, 3123 Belp, Switzerland +41 79 173 36 84 info@ict.technology

    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.