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:
- Affronta i sintomi, non la causa.
- 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.