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

    Terraform @ Scale - Parte 5a: comprendere gli API Limits

    Sono le 14:30 di un normale martedì pomeriggio. Il team DevOps di un fornitore di servizi finanziari svizzero avvia come di consueto la propria pipeline Terraform per il test mensile di Disaster Recovery. 300 macchine virtuali, 150 backend di load balancer, 500 record DNS e innumerevoli regole di rete devono essere provisionati nella regione di backup.
    Dopo 5 minuti, la pipeline si interrompe. HTTP 429: Too Many Requests.
    Le successive 3 ore vengono spese dal team per ripulire manualmente le risorse parzialmente provisionate, mentre il management guarda nervosamente l’orologio.
    Il test di DR è fallito prima ancora di iniziare.

    Cosa era successo? Il team era incappato in una delle trappole più subdole dell’automazione in cloud: i limiti di velocità delle API (API Rate Limits). Mentre Terraform tentava diligentemente di creare centinaia di risorse in parallelo, il cloud provider, dopo le prime 100 richieste al minuto, aveva tirato il freno di emergenza. Un problema che con 10 VM non si era mai manifestato, con 300 VM è diventato un muro insormontabile.

    Il muro invisibile: comprendere gli API Limits

    Ogni grande cloud provider implementa limiti di velocità delle API per garantire la stabilità dei propri servizi. Questi limiti non sono una vessazione, ma una necessità. Senza di essi, un singolo run di Terraform malfunzionante potrebbe mettere fuori uso gli endpoint API per tutti i clienti di un provider.

    Oracle Cloud Infrastructure implementa il rate limiting delle API, con IAM che, al superamento dei limiti, restituisce un codice di errore 429. I limiti specifici variano a seconda del servizio e non sono documentati in maniera globale, ma implementati in modo specifico per ciascun servizio. OCI imposta per ogni tenancy dei Service Limits, concordati al momento dell’acquisto con il commerciale Oracle oppure applicati automaticamente come limiti standard/trial.

    AWS implementa un sistema Token Bucket per il throttling delle API EC2. Per la chiamata RunInstances, la dimensione del Resource Token Bucket è di 1000 token con un tasso di reintegro di due token al secondo. Ciò significa che è possibile avviare immediatamente 1000 istanze e successivamente fino a due istanze al secondo. Le azioni API sono raggruppate in categorie, con le Non-mutating Actions (Describe*, List*, Search*, Get*) che in genere hanno i limiti di throttling più alti.
    [Nota: le Non-mutating Actions sono operazioni API che non provocano modifiche allo stato del cloud, ma si limitano a leggere dati. AWS (e anche altri provider) le soggetta di norma a limiti di velocità più alti rispetto alle azioni di scrittura, poiché non creano, modificano o eliminano risorse e sono quindi meno critiche per la stabilità dei sistemi backend.]

    Terraform stesso aggrava questo problema tramite la propria parallelizzazione. Per impostazione predefinita, Terraform tenta di creare o modificare fino a 10 risorse contemporaneamente. Con grafi di dipendenza complessi e molte risorse indipendenti, ciò può rapidamente portare a decine di chiamate API simultanee. Moltiplicando il tutto per le Data Sources, di cui abbiamo discusso negli articoli precedenti di questa serie, si genera una sorta di “tempesta perfetta” di richieste API.

    Il paradosso della parallelizzazione

    La parallelizzazione predefinita di Terraform è un’arma a doppio taglio. Da un lato accelera notevolmente i deployment - nessuno vuole aspettare ore mentre le risorse vengono create in sequenza. Dall’altro, questa ottimizzazione porta a problemi nelle infrastrutture di grandi dimensioni.

    Consideriamo uno scenario tipico: una configurazione Terraform per un’applicazione multi-tier con livello web, applicativo e database. Ogni livello è composto da più istanze distribuite su diversi 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
    }

    Questa configurazione dall’aspetto innocuo genera, in fase di esecuzione:

    • 50 chiamate API per le istanze del livello web
    • 30 chiamate API per le istanze del livello applicativo
    • 50 chiamate API per le registrazioni backend del Load Balancer
    • 400 chiamate API per gli allarmi di monitoraggio, 80 istanze × 5 allarmi

    In totale 530 chiamate API, che Terraform tenta di eseguire in parallelo. Con un limite di 10 operazioni di scrittura al secondo, nel migliore dei casi il processo dura poco meno di un minuto - ma solo se fosse perfettamente serializzato.

    Nella pratica, il throttling porta invece a cicli di retry, backoff esponenziale e, nel peggiore dei casi, a timeout e interruzione con risorse parzialmente create che devono poi essere eliminate manualmente.

    Terraform -target: la soluzione sbagliata

    Nella disperazione, molti team ricorrono a terraform apply -target per creare selettivamente le risorse. “Distribuiamo prima le reti, poi le istanze di calcolo, poi il monitoraggio” - questo il piano. Tuttavia, si tratta di un approccio pericoloso che crea più problemi di quanti ne risolva.

    Il flag -target è stato progettato per interventi chirurgici di emergenza, non per i deployment regolari.

    Questo approccio aggira la gestione delle dipendenze di Terraform e può portare a stati inconsistenti.

    Ancora più problematico: non è scalabile. Con centinaia di risorse, o si targetta ogni tipo di risorsa singolarmente - aumentando esponenzialmente la complessità - oppure si scrivono script complessi che eseguono Terraform più volte con target differenti.

    Un antipattern che ho visto nella pratica:


    #!/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

     Questo script può anche funzionare, ma è fragile, difficile da mantenere e soggetto a errori:

    1. Affronta i sintomi, non la causa.
    2. Inoltre, si perde l’idempotenza di Terraform: se lo script si interrompe a metà, non è chiaro quali risorse siano già state create.

    La soluzione corretta: controllare la parallelizzazione

    La soluzione più elegante ai problemi di API Limit è il controllo della parallelizzazione di Terraform. Terraform offre a tale scopo il flag -parallelism:


     terraform apply -parallelism=5
    

    Questo riduce il numero di operazioni simultanee da 10 a 5. Per ambienti particolarmente sensibili, questo valore può essere ulteriormente ridotto:


    terraform apply -parallelism=1 
    

     In questo modo tutte le risorse vengono create completamente in sequenza. Sì, in questo caso il processo diventa molto lento e l’esempio è quindi puramente accademico.

    E questo, in generale, è solo la punta dell’iceberg. Per ambienti produttivi potrebbe essere necessaria una strategia più articolata.

    Ecco un esempio quasi “paranoico” di uno script wrapper che regola dinamicamente la parallelizzazione in base al numero di risorse da creare:


    #!/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 e meccanismi di retry a livello di provider

    Per strumenti OCI sviluppati internamente utilizzando l’SDK ufficiale OCI, è possibile controllare il comportamento tramite variabili d’ambiente. L’SDK supporta le seguenti impostazioni:


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

     Queste impostazioni non sostituiscono una buona progettazione architetturale, ma aiutano ad assorbire picchi temporanei di throttling.

    Opzioni di retry nel provider OCI per Terraform

    Il provider OCI per Terraform supporta due opzioni di configurazione nel blocco provider che controllano il comportamento dei tentativi automatici di ritentare in caso di determinati errori API:

    disable_auto_retries (boolean): Se impostato su true, disattiva tutti i tentativi automatici del provider, indipendentemente dal valore di retry_duration_seconds. Valore predefinito: false.

    retry_duration_seconds (integer): Definisce la finestra temporale minima in secondi in cui il provider effettua tentativi di ritentare in caso di specifici errori HTTP. Questa opzione agisce esclusivamente sugli HTTP Status 429 e 500. Il periodo indicato viene interpretato come valore minimo; a causa del backoff quadratico con jitter completo, la durata effettiva può essere superiore. Se disable_auto_retries è impostato su true, questo valore viene ignorato. Valore predefinito: 600 secondi.

    Comportamento predefinito: Nella configurazione di default (disable_auto_retries = false, retry_duration_seconds = 600), il provider ritenta automaticamente in caso di risposte HTTP 429 e HTTP 500. Altri errori HTTP come 400, 401, 403, 404 o 409 non vengono ritentati da questo meccanismo.

    Esempio di configurazione:


    provider "oci" {
      # I retry automatici sono abilitati per impostazione predefinita
      disable_auto_retries   = false
      retry_duration_seconds = 600  # Finestra minima per i retry (in secondi)
    }

     

    Configurazione dei retry nel provider AWS per Terraform

    In AWS l’argomento varia molto a seconda del servizio. La configurazione globale nel provider limita il numero massimo di retry; la modalità della strategia di backoff è determinata dall’AWS SDK. In pratica:


    provider "aws" {
      region = var.region
      # Numero massimo di tentativi di retry se una chiamata API fallisce
      max_retries = 5
      # Strategia di retry:
      # "adaptive": si adatta dinamicamente a latenza e tasso di errore (utile per throttling)
      # "standard": backoff esponenziale deterministico
      retry_mode = "adaptive"
    }

     

    Pattern architetturale: Resource Batching

    Una tecnica avanzata per evitare i limiti delle API è il Resource Batching. Consiste nel raggruppare le risorse in unità logiche da distribuire in sequenza. Nell’esempio seguente utilizziamo intenzionalmente il provider time e il relativo tipo di risorsa time_sleep per introdurre un tempo di attesa artificiale tra i batch. In questo modo si garantisce che il batch successivo inizi solo dopo il termine del tempo di attesa.


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

    Questo pattern consente di suddividere grandi deployment in blocchi gestibili, senza dover ricorrere a -target. I batch vengono creati automaticamente con ritardi per rispettare i limiti delle API. Il trucco sta nello scaglionare nel tempo le operazioni API più costose, senza frammentare artificialmente l’architettura o i moduli.

    Questo articolo dovrebbe ora fornire un primo aiuto in caso di problemi con gli API Limit e offrire spunti per possibili soluzioni. Finora siamo stati estremamente reattivi e anche un po’ paranoici. Inoltre, va aggiunto che aspetti come il batching dovrebbero essere già gestiti dai provider ben progettati, cosa che l’utente finale tende a dare per scontata.
    Nel prossimo articolo vedremo quindi in che modo tecniche avanzate come API Gateway, test con Ephemerals e Sentinel Policies possano diagnosticare proattivamente tali limiti.