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

      Terraform @ Scale - Partie 5a : comprendre les limites API

      Il est 14h30 un mardi après-midi ordinaire. L’équipe DevOps d’un prestataire de services financiers suisse lance comme à l’accoutumée sa pipeline Terraform pour le test mensuel de reprise après sinistre. 300 machines virtuelles, 150 backends de Load Balancer, 500 entrées DNS et d’innombrables règles réseau doivent être provisionnés dans la région de secours.
      Après 5 minutes, la pipeline s’interrompt. HTTP 429 : Too Many Requests.
      L’équipe passe ensuite 3 heures à nettoyer manuellement les ressources partiellement provisionnées, tandis que la direction regarde nerveusement l’horloge.
      Le test de DR a échoué avant même d’avoir commencé.

      Que s’est-il passé ? L’équipe est tombée dans l’un des pièges les plus insidieux de l’automatisation cloud : les limites de taux API. Alors que Terraform s’efforçait de créer des centaines de ressources en parallèle, le fournisseur cloud avait tiré le frein d’urgence après les 100 premières requêtes par minute. Un problème qui ne s’était jamais produit avec 10 VMs est devenu, avec 300 VMs, un mur infranchissable.

      Le mur invisible : comprendre les limites API

      Chaque grand fournisseur cloud met en place des limites de taux API afin de garantir la stabilité de ses services. Ces limites ne sont pas une entrave, mais une nécessité. Sans elles, un seul exécution Terraform défectueuse pourrait paralyser les points de terminaison API pour tous les clients d’un fournisseur.

      Oracle Cloud Infrastructure met en œuvre un API Rate Limiting, dans lequel IAM renvoie un code d’erreur 429 en cas de dépassement des limites. Les limites spécifiques varient selon le service et ne sont pas documentées globalement, mais mises en place de manière spécifique à chaque service. OCI définit pour chaque tenancy des limites de service, convenues au moment de l’achat avec le commercial Oracle ou définies automatiquement comme limites standard ou d’essai.

      AWS met en œuvre un système Token Bucket pour le throttling API EC2. Pour RunInstances, la taille du Resource Token Bucket est de 1000 jetons, avec un taux de réapprovisionnement de deux jetons par seconde. Cela signifie que vous pouvez démarrer immédiatement 1000 instances, puis jusqu’à deux instances par seconde. Les actions API sont regroupées par catégories, les Non-mutating Actions (Describe*, List*, Search*, Get*) ayant généralement les limites de throttling les plus élevées.
      [Rem. : Les Non-mutating Actions sont des opérations API qui ne modifient pas l’état du cloud, mais se contentent de lire des données. Elles sont généralement limitées à un taux plus élevé que les actions d’écriture par AWS (et aussi par d’autres fournisseurs), car elles ne créent, ne modifient ni ne suppriment de ressources et sont donc moins critiques pour la stabilité des systèmes backend.]

      Terraform aggrave lui-même ce problème par sa parallélisation. Par défaut, Terraform tente de créer ou de modifier jusqu’à 10 ressources simultanément. Dans le cas de graphes de dépendances complexes et de nombreuses ressources indépendantes, cela peut rapidement conduire à des dizaines d’appels API simultanés. Multiplié par les Data Sources que nous avons évoquées dans les articles précédents de cette série, cela génère ce que l’on appelle une « tempête parfaite » de requêtes API.

      Le paradoxe de la parallélisation

      La parallélisation par défaut de Terraform est une arme à double tranchant. D’un côté, elle accélère considérablement les déploiements - personne ne souhaite attendre des heures que les ressources soient créées de manière séquentielle. De l’autre, cette optimisation provoque précisément des problèmes dans les grandes infrastructures.

      Prenons un scénario typique : une configuration Terraform pour une application multi-tier avec couche web, couche applicative et couche base de données. Chaque couche est composée de plusieurs instances réparties sur différentes Availability Domains :


      # A seemingly harmless root module, which will turn into an API bomb
      module "web_tier" {
        source = "./modules/compute-cluster"
      
        instance_count = 50
        instance_config = {
          shape  = "VM.Standard.E4.Flex"
          ocpus  = 2
          memory_in_gbs = 16
        }
      
        availability_domains = data.oci_identity_availability_domains.ads.availability_domains
        subnet_ids           = module.network.web_subnet_ids
      
        load_balancer_backend_sets = {
          for idx, val in range(50) :
          "web-${idx}" => module.load_balancer.backend_set_name
        }
      }
      
      module "app_tier" {
        source = "./modules/compute-cluster"
      
        instance_count = 30
        # ... similar configuration
      }
      
      module "monitoring" {
        source = "./modules/monitoring"
      
        # Create monitoring alarms for each instance
        monitored_instances = concat(
          module.web_tier.instance_ids,
          module.app_tier.instance_ids
        )
      
        alarms_per_instance = 5  # CPU, Memory, Disk, Network, Application
      }

      Cette configuration, en apparence inoffensive, génère lors de l’exécution :

      • 50 appels API pour les instances de la couche Web
      • 30 appels API pour les instances de la couche applicative
      • 50 appels API pour les enregistrements backend de Load Balancer
      • 400 appels API pour les alarmes de monitoring, 80 instances × 5 alarmes

      Cela fait 530 appels API que Terraform tente d’exécuter le plus possible en parallèle. Avec une limite de 10 opérations d’écriture par seconde, cela durerait au mieux un peu moins d’une minute - mais seulement si tout était parfaitement sérialisé.

      En pratique, le throttling entraîne cependant des boucles de retry, des backoffs exponentiels et, dans le pire des cas, des timeouts et un arrêt avec des ressources partiellement créées qu’il faut ensuite nettoyer manuellement.

      Terraform -target : la mauvaise solution

      Dans la précipitation, de nombreuses équipes recourent à terraform apply -target pour créer sélectivement des ressources. « Nous déployons simplement d’abord les réseaux, puis les instances Compute, puis le monitoring » - tel est le plan. C’est toutefois une approche dangereuse qui crée plus de problèmes qu’elle n’en résout.

      Le flag -target a été conçu pour des interventions chirurgicales en cas d’urgence, pas pour des déploiements réguliers.

      Il contourne la gestion des dépendances de Terraform et peut entraîner des états incohérents.

      Plus problématique encore : cela ne passe pas à l’échelle. Avec des centaines de ressources, il faudrait soit cibler chaque type de ressource individuellement, ce qui ferait exploser la complexité, soit écrire des scripts complexes qui appellent Terraform plusieurs fois avec différentes cibles.

      Un antipattern que j’ai rencontré dans la pratique :


      #!/bin/bash
      # DON'T TRY THIS AT HOME - THIS IS AN ANTIPATTERN!
      # Note: This script requires GNU grep.
      
      # Phase 1: Network
      terraform apply -target=module.network -auto-approve
      
      # Phase 2: Compute (in Batches)
      for i in {0..4}; do
        start=$((i*10))
        end=$((start+9))
        for j in $(seq "$start" "$end"); do
          terraform apply -target="module.web_tier.oci_core_instance.this[${j}]" -auto-approve
        done
        sleep 30  # "Cooldown" period
      done
      
      # Phase 3: Monitoring
      terraform apply -target=module.monitoring -auto-approve

       Ce script peut fonctionner, mais il est fragile, difficile à maintenir et sujet aux erreurs :

      1. Il traite les symptômes, pas la cause.
      2. En outre, vous perdez l’idempotence de Terraform - si le script s’interrompt en cours d’exécution, il est impossible de savoir quelles ressources ont déjà été créées.

      La bonne solution : contrôler la parallélisation

      La solution la plus élégante aux problèmes de limites API consiste à contrôler la parallélisation de Terraform. Terraform propose pour cela le flag -parallelism :


       terraform apply -parallelism=5
      

      Cela réduit le nombre d’opérations simultanées de 10 à 5. Pour des environnements particulièrement sensibles, cette valeur peut être encore réduite :


      terraform apply -parallelism=1 
      

       Les ressources sont alors créées entièrement dans un ordre strictement séquentiel. Oui, cela prend beaucoup de temps et reste donc ici un simple exemple académique.

      Et cela n’est en réalité que la partie émergée de l’iceberg. Pour des environnements de production, vous aurez probablement besoin d’une stratégie plus élaborée.

      Voici un exemple presque paranoïaque de script wrapper, qui ajuste dynamiquement la parallélisation en fonction du nombre de ressources à créer :


      #!/bin/bash
      # Intelligent control of parallelism based on the number of planned operations
      # Requires: jq, GNU bash
      
      set -euo pipefail
      
      # Create a fresh plan and capture exit code (0=no changes, 2=changes, 1=error)
      create_plan() {
        if terraform plan -out=tfplan -detailed-exitcode; then
          return 0
        fi
        local ec=$?
        if [ "$ec" -eq 2 ]; then
          return 0
        fi
        echo "Plan failed." >&2
        exit "$ec"
      }
      
      # Retrieve the number of operations from the JSON plan
      get_resource_count() {
        # JSON output, no colors, parse via jq
        local count
        count=$(terraform show -no-color -json tfplan 2>/dev/null \
          | jq '[.resource_changes[] 
                  | select(.change.actions[] != "no-op")] 
                 | length')
        echo "${count:-0}"
      }
      
      # Compute provider-aware parallelism (heuristic)
      calculate_parallelism() {
        local resource_count=$1
        local cloud_provider=${CLOUD_PROVIDER:-oci} # Set default value "oci" if unset
        cloud_provider="${cloud_provider,,}"  # Allow Case-insensitive value
      
        case "$cloud_provider" in
          oci)
            if   [ "$resource_count" -lt 20  ]; then echo 10
            elif [ "$resource_count" -lt 50  ]; then echo 5
            elif [ "$resource_count" -lt 100 ]; then echo 3
            else                                   echo 1
            fi
            ;;
          aws)
            if   [ "$resource_count" -lt 50  ]; then echo 10
            elif [ "$resource_count" -lt 100 ]; then echo 5
            else                                   echo 2
            fi
            ;;
          *)
            echo 5
            ;;
        esac
      }
      
      echo "Analyzing Terraform plan..."
      create_plan
      resource_count=$(get_resource_count)
      echo "Planned resource operations: ${resource_count}"
      
      if [ "$resource_count" -eq 0 ]; then
        echo "No changes necessary."
        rm -f tfplan
        exit 0
      fi
      
      parallelism=$(calculate_parallelism "$resource_count")
      echo "Using parallelism: ${parallelism}"
      
      # Execute apply against the saved plan with computed parallelism
      terraform apply -parallelism="${parallelism}" tfplan
      
      # Clean up plan file
      rm -f tfplan

      Throttling et mécanismes de retry au niveau provider

      Pour des outils OCI développés en interne utilisant le SDK officiel OCI, vous pouvez contrôler le comportement via des variables d’environnement. Le SDK prend en charge :


      export OCI_SDK_DEFAULT_RETRY_ENABLED=true
      export OCI_SDK_DEFAULT_RETRY_MAX_ATTEMPTS=5
      export OCI_SDK_DEFAULT_RETRY_MAX_WAIT_TIME=30
      

       Ces paramètres ne remplacent en aucun cas une bonne conception d’architecture. Mais ils permettent d’atténuer des pics temporaires de throttling.

      Options de retry dans le provider Terraform OCI

      Le provider Terraform OCI prend en charge deux options de configuration dans le bloc provider, qui contrôlent le comportement des tentatives automatiques de réexécution pour certains types d’erreurs API :

      disable_auto_retries (boolean) : Si défini à true, toutes les tentatives automatiques de réexécution du provider sont désactivées, quel que soit le paramètre retry_duration_seconds. Valeur par défaut : false.

      retry_duration_seconds (integer) : Définit la fenêtre de temps minimale en secondes pendant laquelle le provider tente de réexécuter certaines opérations en cas d’erreurs HTTP spécifiques. Cette option ne s’applique qu’aux statuts HTTP 429 et 500. La valeur indiquée est interprétée comme un minimum - en raison du backoff quadratique avec full jitter utilisé, la durée réelle peut être supérieure. Si disable_auto_retries est défini à true, cette valeur est ignorée. Valeur par défaut : 600 secondes.

      Comportement par défaut : Par défaut (disable_auto_retries = false, retry_duration_seconds = 600), le provider effectue automatiquement des réessais en cas de réponses HTTP 429 et 500. D’autres erreurs HTTP comme 400, 401, 403, 404 ou 409 ne sont pas concernées par ce mécanisme.

      Exemple de configuration :


      provider "oci" {
        # Automatic retries are enabled by default
        disable_auto_retries   = false
        retry_duration_seconds = 600  # Minimum window for retries (in seconds)
      }

       

      Configuration des retries dans le provider Terraform AWS

      Chez AWS, le sujet varie fortement selon le service. La configuration globale dans le provider limite le nombre maximal de réessais ; le mode de stratégie de backoff est déterminé par le SDK AWS. En pratique :


      provider "aws" {
        region = var.region
        # Maximum number of retry attempts if an API call fails
        max_retries = 5
        # Retry strategy:
        # "adaptive": adjusts dynamically to latency and error rates (good for throttling)
        # "standard": deterministic exponential backoff
        retry_mode = "adaptive"
      }

       

      Architecture pattern : Resource Batching

      Une technique avancée pour éviter les limites API est le Resource Batching. Il s’agit de regrouper les ressources en unités logiques, déployées séquentiellement. Dans l’exemple suivant, nous utilisons volontairement le provider time et son type de ressource time_sleep pour imposer un délai artificiel entre les lots. Cela garantit que le lot suivant ne commence qu’après expiration du temps d’attente défini.


      terraform {
        required_version = ">= 1.5.0"
      
        required_providers {
          oci  = { source = "oracle/oci" }
          time = { source = "hashicorp/time" }
        }
      }
      
      variable "batch_size" {
        type        = number
        default     = 10
        description = "Number of instances per batch"
      }
      
      variable "batch_delay_seconds" {
        type        = number
        default     = 30
        description = "Wait time between batches (seconds)"
      }
      
      variable "total_instances" {
        type        = number
        description = "Total number of instances to create"
      }
      
      variable "name_prefix" {
        type        = string
        description = "Prefix for instance display_name"
      }
      
      variable "availability_domains" {
        type        = list(string)
        description = "ADs to spread instances across"
      }
      
      variable "subnet_ids" {
        type        = list(string)
        description = "Subnets to distribute NICs across"
      }
      
      variable "compartment_id" {
        type        = string
      }
      
      variable "instance_shape" {
        type        = string
      }
      
      variable "instance_ocpus" {
        type        = number
      }
      
      variable "instance_memory" {
        type        = number
      }
      
      variable "image_id" {
        type        = string
      }
      
      locals {
        batch_count = ceil(var.total_instances / var.batch_size)
      
        # Build batches with instance index metadata
        batches = {
          for batch_idx in range(local.batch_count) :
          "batch-${batch_idx}" => {
            instances = {
              for inst_idx in range(
                batch_idx * var.batch_size,
                min((batch_idx + 1) * var.batch_size, var.total_instances)
              ) :
              "instance-${inst_idx}" => {
                index = inst_idx
                batch = batch_idx
              }
            }
          }
        }
      }
      
      # Artificial delay resources for batch sequencing
      resource "time_sleep" "batch_delay" {
        for_each = { for b, _ in local.batches : b => b }
      
        create_duration = each.key == "batch-0" ? "0s" : "${var.batch_delay_seconds}s"
      
        # Ensure delay starts only after the previous batch instances are created
        depends_on = [
          oci_core_instance.batch
        ]
      }
      
      resource "oci_core_instance" "batch" {
        for_each = merge([
          for _, batch_data in local.batches : batch_data.instances
        ]...)
      
        display_name        = "${var.name_prefix}-${each.key}"
        availability_domain = var.availability_domains[each.value.index % length(var.availability_domains)]
        compartment_id      = var.compartment_id
        shape               = var.instance_shape
      
        shape_config {
          ocpus         = var.instance_ocpus
          memory_in_gbs = var.instance_memory
        }
      
        source_details {
          source_type = "image"
          source_id   = var.image_id
        }
      
        create_vnic_details {
          subnet_id = var.subnet_ids[each.value.index % length(var.subnet_ids)]
        }
      
        # Ensure this instance respects its batch delay
        depends_on = [
          time_sleep.batch_delay["batch-${each.value.batch}"]
        ]
      
        lifecycle {
          precondition {
            condition     = each.value.index < var.total_instances
            error_message = "Instance index ${each.value.index} exceeds total_instances ${var.total_instances}"
          }
        }
      }
      
      data "oci_core_instance" "health_check" {
        for_each    = oci_core_instance.batch
        instance_id = each.value.id
      }
      
      check "instance_health" {
        assert {
          condition = alltrue([
            for _, d in data.oci_core_instance.health_check :
            d.state == "RUNNING"
          ])
          error_message = "Not all instances reached RUNNING state"
        }
      }

      Ce pattern permet de découper de grands déploiements en portions digestes, sans recourir à -target. Les lots sont automatiquement créés avec des délais afin de respecter les limites API. L’astuce consiste à échelonner dans le temps les opérations API coûteuses, sans éclater artificiellement l’architecture ou les modules.

      Cet article devrait désormais suffire pour fournir une première aide en cas de problème de limites API et aider à développer des pistes de solutions. Nous restons toutefois ici extrêmement réactifs et même un peu paranoïaques. Il faut aussi ajouter que des mécanismes comme le batching devraient idéalement déjà être gérés par de bons providers - c’est en tout cas ce que l’on suppose inconsciemment en tant qu’utilisateur final.
      Dans le prochain article, nous verrons donc dans quelle mesure des méthodes avancées comme les API Gateways, les tests avec des environnements éphémères et les Sentinel Policies peuvent diagnostiquer de manière proactive ces limites.