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

    Terraform @ Scale - Partie 4b : Bonnes pratiques pour des Data Sources évolutives

    Dans la dernière partie de cette série, nous avons montré comment des Data Sources apparemment inoffensives dans les modules Terraform peuvent devenir un véritable problème de performance. Des exécutions de terraform plan durant plusieurs minutes, des pipelines instables et des effets incontrôlables de limitation d’API en étaient les conséquences.

    Mais comment éviter élégamment et durablement ce piège de mise à l’échelle ?

    Dans cette partie, nous présentons des modèles d’architecture éprouvés vous permettant de centraliser les Data Sources, de les injecter avec parcimonie en termes de ressources, et ainsi de garantir des exécutions Terraform rapides, stables et prévisibles, même avec des centaines d’instances de modules.

    Au programme : trois stratégies de solution évolutives, un guide pas-à-pas éprouvé en pratique et une checklist de bonnes pratiques pour des modules d’infrastructure prêts pour la production.

     

    Bonnes pratiques : alternatives évolutives

    Solution 1 (scénarios simples) : Variable Injection Pattern

    Au lieu d’utiliser des Data Sources dans les modules, injectez les données nécessaires sous forme de variables :


    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
    }

     

    Solution 2 (scénarios complexes) : Structured Configuration Pattern

    Pour des scénarios plus complexes, utilisez des objets de configuration structurés :


    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
      }
    }

    Solution 3 (scénarios très complexes) : Data Proxy Pattern

    Pour les scénarios très complexes, créez des modules "Data Proxy" dédiés :


    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
    }

    Comparaison des performances

    Un exemple concret issu d’un projet client avec le déploiement de 50 instances VM montre la différence spectaculaire :

     
     Avant : Data Sources dans les modules

    Après : Variable Injection

    Nombre d'appels API
    150 appels API 3 appels API
    Durée
    $ 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

     

    Résultat : 93 % de temps de planification en moins et 98 % de réduction des appels API.

    Variable Injection : guide étape par étape

    Étape 1 : Centraliser les Data Sources

    Objectif : Supprimer toutes les Data Sources des modules et les centraliser dans le module racine afin de consolider les appels API et créer une source de vérité unique.

    Comment : Déplacez toutes les Data Sources utilisées par les modules dans le module racine. Cela garantit que chaque information n’est récupérée qu’une seule fois, quel que soit le nombre de modules qui en ont besoin. Vous réduisez ainsi le nombre d'appels API de N×M (nombre de modules × nombre de Data Sources) à M (nombre de 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
    }

     

    Étape 2 : Traiter les données dans des locals

    Objectif : Transformer les résultats bruts des Data Sources en un format exploitable, tout en gardant la complexité hors des modules.

    Comment : Utilisez des locals pour filtrer, trier et transformer les résultats des Data Sources en structures de données. Cela permet de gérer la logique complexe de manière centralisée et d’alimenter les modules avec des données déjà nettoyées et structurées. Grâce aux boucles for et aux expressions conditionnelles, vous pouvez également implémenter des mécanismes de secours et des logiques de validation.


    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
      }
    }

     

    Étape 3 : Définir des variables dans les modules

    Objectif : Créer des interfaces claires pour la transmission de données aux modules tout en assurant la validation et la sécurité des types.

    Comment : Remplacez les Data Sources dans les modules par des variables typées avec des descriptions explicites et des règles de validation. Les définitions de type assurent la cohérence et la robustesse, tandis que les blocs de validation garantissent que seules des données valides sont transmises aux modules. Cela rend les modules plus testables et indépendants de l’API du fournisseur cloud.


    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."
      }
    }

     

    Étape 4 : Implémenter les modules sans Data Sources

    Objectif : Libérer complètement les modules des appels API externes et les transformer en conteneurs de définitions de ressources pures.

    Comment : Remplacez toutes les références aux Data Sources dans les modules par des références de variables. Cela rend les modules déterministes et prévisibles, car ils ne travaillent qu’avec les paramètres fournis et n’exécutent plus d’appels API imprévus. Les modules deviennent ainsi testables de manière indépendante grâce à l’injection de données simulées via les variables.


    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
      }
    }

     

    Étape 5 : Appeler les modules avec des données injectées

    Objectif : Établir la liaison entre les données collectées de manière centralisée et les modules, afin de mettre en place un modèle de flux de données clair.

    Comment : Transmettez les données traitées dans les locals comme paramètres aux modules. Cela boucle le processus de Variable Injection : les données sont récupérées une seule fois de manière centralisée, transformées, puis réparties de manière ciblée aux modules. Cette transmission explicite crée des dépendances claires, compréhensibles à la fois pour les humains et pour Terraform.


    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 de bonnes pratiques

    ✅ À faire : patterns évolutifs

    • [ ] Data Sources centralisées : définir toutes les Data Sources dans le root module
    • [ ] Variable Injection : transmettre les données aux modules via des variables
    • [ ] Objets structurés : organiser les données complexes en objets typés
    • [ ] Validation Rules : implémenter des validations pour les variables injectées
    • [ ] Documentation : ajouter des descriptions aux variables injectées
    • [ ] Traitement local : transformer les données dans les locals du root module
    • [ ] Data Proxy Pattern : utiliser des modules de données séparés pour les scénarios très complexes

    ❌ À éviter : anti-patterns

    • [ ] Data Sources dans les modules : ne jamais utiliser de Data Sources dans des modules réutilisables
    • [ ] Lookups redondants : Data Sources identiques dans plusieurs modules
    • [ ] Filtrage complexe : opérations de filtrage coûteuses dans chaque module
    • [ ] Data Sources imbriquées : Data Sources dépendantes d’autres Data Sources
    • [ ] Références dynamiques : boucles for_each sur des résultats de Data Source dans les modules
    • [ ] Validation manquante : utilisation de données injectées sans validation

    Monitoring et débogage

    Pour surveiller les performances des Data Sources, vous pouvez analyser la sortie de débogage de terraform plan à la recherche d’entrées liées aux 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

    Conclusion : des modules performants grâce à une architecture réfléchie

    Les Data Sources sont une fonctionnalité puissante de Terraform - mais utilisées dans les modules, elles peuvent nuire aux performances. Le Variable Injection Pattern constitue une solution élégante :

    Avantages :

    • Réduction drastique des appels API (plus de 95 % d’économie possible)
    • Mise à l’échelle linéaire des performances au lieu d’une dégradation exponentielle
    • Logique de données centralisée pour une meilleure maintenabilité
    • Dépendances explicites au lieu d’appels cachés à des Data Sources
    • Meilleure testabilité grâce à l’injection de données fictives

    La clé réside dans un changement de paradigme : au lieu de récupérer les données lorsqu’elles sont nécessaires, vous les collectez une fois de manière centralisée et les distribuez de manière ciblée.

    Chez ICT.technology, nous avons réduit les temps de planification Terraform de plusieurs minutes à quelques secondes grâce à l’application systématique de ces patterns - même avec des centaines d’instances de modules.