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.