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

    Terraform @ Scale - Teil 5a: API Limits verstehen

    Es ist 14:30 Uhr an einem gewöhnlichen Dienstagnachmittag. Das DevOps-Team eines Schweizer Finanzdienstleisters startet routinemäßig seine Terraform-Pipeline für das monatliche Disaster-Recovery-Testing. 300 virtuelle Maschinen, 150 Load Balancer Backends, 500 DNS-Einträge und unzählige Netzwerkregeln sollen in der Backup-Region provisioniert werden.
    Nach 5 Minuten bricht die Pipeline ab. HTTP 429: Too Many Requests.
    Die nächsten 3 Stunden verbringt das Team damit, halb provisionierte Ressourcen manuell zu bereinigen, während das Management nervös auf die Uhr schaut.
    Der DR-Test ist gescheitert, bevor er überhaupt begonnen hat.

    Was war passiert? Das Team war in eine der heimtückischsten Fallen der Cloud-Automatisierung gelaufen: API Rate Limits. Während Terraform fleißig versuchte, hunderte Ressourcen parallel zu erstellen, hatte der Cloud-Provider nach den ersten 100 Requests pro Minute die Notbremse gezogen. Ein Problem, das bei 10 VMs nie aufgetreten war, wurde bei 300 VMs zur unüberwindbaren Mauer.

    Die unsichtbare Wand: API Limits verstehen

    Jeder große Cloud-Provider implementiert API Rate Limits, um die Stabilität seiner Services zu gewährleisten. Diese Limits sind keine Schikane, sondern eine Notwendigkeit. Ohne sie könnte ein einzelner, fehlerhafter Terraform-Run die API-Endpoints für alle Kunden eines Providers lahmlegen.

    Oracle Cloud Infrastructure implementiert API Rate Limiting, wobei IAM bei Überschreitung der Limits einen 429-Fehlercode zurückgibt. Die spezifischen Limits variieren je nach Service und sind nicht global dokumentiert, sondern service-spezifisch implementiert. OCI setzt für jede Tenancy Service Limits fest, die beim Kauf mit dem Oracle-Vertriebsmitarbeiter vereinbart oder als Standard-/Trial-Limits automatisch gesetzt werden.

    AWS implementiert ein Token-Bucket-System für EC2 API-Throttling. Für RunInstances beträgt die Resource Token Bucket-Größe 1000 Token mit einer Refill-Rate von zwei Token pro Sekunde. Das bedeutet, Sie können sofort 1000 Instanzen starten und danach bis zu zwei Instanzen pro Sekunde. API-Aktionen werden in Kategorien gruppiert, wobei Non-mutating Actions (Describe*, List*, Search*, Get*) typischerweise die höchsten API-Throttling-Limits haben.
    [Anm.: Non-mutating Actions sind API-Operationen, die keine Änderungen am Cloud-Zustand verursachen, sondern nur Daten lesen. Sie werden von AWS (und auch anderen Providern) typischerweise höher rate-limitiert als schreibende Aktionen, weil sie keine Ressourcen anlegen, ändern oder löschen und damit weniger kritisch für die Stabilität der Backend-Systeme sind.]

    Terraform selbst verschärft dieses Problem durch seine Parallelisierung. Standardmäßig versucht Terraform, bis zu 10 Ressourcen gleichzeitig zu erstellen oder zu modifizieren. Bei komplexen Abhängigkeitsgraphen und vielen unabhängigen Ressourcen kann dies schnell zu Dutzenden gleichzeitigen API-Calls führen. Multipliziert mit den Data Sources, die wir in den vorherigen Artikeln dieser Serie diskutiert haben, entsteht ein sogenannter „perfekter Sturm“ von API-Requests.

    Das Parallelisierungs-Paradoxon

    Die Standardparallelisierung von Terraform ist ein zweischneidiges Schwert. Einerseits beschleunigt sie Deployments erheblich, niemand möchte stundenlang warten, während Ressourcen sequenziell erstellt werden. Andererseits führt genau diese Optimierung bei großen Infrastrukturen zu Problemen.

    Betrachten wir ein typisches Szenario: Eine Terraform-Konfiguration für eine Multi-Tier-Anwendung mit Web-, App- und Datenbankschicht. Jede Schicht besteht aus mehreren Instanzen, die über verschiedene Availability Domains verteilt sind:


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

    Diese harmlos aussehende Konfiguration generiert bei der Ausführung:

    • 50 API-Calls für Web-Tier-Instanzen
    • 30 API-Calls für App-Tier-Instanzen
    • 50 API-Calls für Load Balancer Backend-Registrierungen
    • 400 API-Calls für Monitoring-Alarme, 80 Instanzen × 5 Alarme

    Das sind 530 API-Calls, die Terraform versucht, möglichst parallel auszuführen. Bei einem Limit von 10 Write-Operations pro Sekunde dauert das bestenfalls knapp unter einer Minute - aber nur, wenn alles perfekt serialisiert wäre.

    In der Praxis führt das Throttling jedoch zu Retry-Schleifen, exponentiellen Backoffs und im schlimmsten Fall zu Timeouts und einem Abbruch mit teilweise erstellten Ressourcen, die hinterher manuell bereinigt werden müssen.

    Terraform -target: Die falsche Lösung

    In der Verzweiflung greifen viele Teams zu terraform apply -target, um Ressourcen selektiv zu erstellen. „Wir deployen einfach erst die Netzwerke, dann die Compute-Instanzen, dann das Monitoring“ - so der Plan. Dies ist jedoch ein gefährlicher Ansatz, der mehr Probleme schafft als er löst.

    Das -target Flag wurde für chirurgische Eingriffe in Notfällen entwickelt, nicht für reguläre Deployments.

    Es umgeht Terraforms Abhängigkeitsmanagement und kann zu inkonsistenten States führen.

    Noch problematischer: Es skaliert nicht. Bei hunderten von Ressourcen müssten Sie entweder jeden Ressourcentyp einzeln targeten, was die Komplexität explodieren lässt, oder komplexe Skripte schreiben, die Terraform mehrfach mit verschiedenen Targets aufrufen.

    Ein Antipattern, das ich in der Praxis gesehen habe:


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

     Dieses Skript mag funktionieren, aber es ist fragil, schwer zu warten und fehleranfällig:

    1. Es behandelt die Symptome, nicht die Ursache.
    2. Außerdem verlieren Sie die Idempotenz von Terraform - wenn das Skript mittendrin abbricht, ist unklar, welche Ressourcen bereits erstellt wurden.

    Die richtige Lösung: Parallelität kontrollieren

    Die eleganteste Lösung für API-Limit-Probleme ist die Kontrolle von Terraforms Parallelisierung. Terraform bietet hierfür das -parallelism Flag:


     terraform apply -parallelism=5
    

    Dies reduziert die Anzahl gleichzeitiger Operationen von 10 auf 5. Für besonders empfindliche Umgebungen könnte dieser Wert weiter reduziert werden:


    terraform apply -parallelism=1 
    

     Jetzt werden alle Ressourcen vollständig in sequenzieller Reihenfolge angelegt. Ja, das dauert jetzt lange und ist daher nur ein akademisches Beispiel.

    Und das ist generell auch nur die Spitze des Eisbergs. Für produktive Umgebungen benötigen Sie vielleicht eine durchdachtere Strategie.

    Hier ein schon regelrecht paranoides Beispiel für ein Wrapper-Skript, das die Parallelität basierend auf der Anzahl der zu erstellenden Ressourcen dynamisch anpasst:


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

    Provider-Level Throttling und Retry-Mechanismen

    Für selbst entwickelte OCI-Tools unter Verwendung der offiziellen OCI SDK können Sie das Verhalten über Umgebungsvariablen steuern. Die SDK unterstützt hierfür:


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

     Diese Einstellungen sind wahrlich kein Ersatz für gutes Architekturdesign. Aber sie helfen, temporäre Throttling-Spitzen abzufedern.

    Retry-Optionen im OCI Terraform Provider

    Der OCI Terraform Provider unterstützt zwei Konfigurationsoptionen im Provider-Block, die das Verhalten automatischer Wiederholungsversuche bei bestimmten API-Fehlern steuern:

    disable_auto_retries (boolean): Wenn auf true gesetzt, werden alle automatischen Wiederholungsversuche des Providers deaktiviert, unabhängig vom Wert von retry_duration_seconds. Standardwert: false.

    retry_duration_seconds (integer): Legt das minimale Zeitfenster in Sekunden fest, in dem der Provider bei bestimmten HTTP-Fehlern Wiederholungsversuche unternimmt. Diese Option wirkt ausschließlich bei HTTP-Status 429 und 500. Der angegebene Zeitraum wird als Mindestwert interpretiert, aufgrund des eingesetzten quadratischen Backoffs mit vollem Jitter kann die tatsächliche Dauer darüber liegen. Wird disable_auto_retries auf true gesetzt, wird dieser Wert ignoriert. Standardwert: 600 Sekunden.

    Standardverhalten: Im Auslieferungszustand (disable_auto_retries = false, retry_duration_seconds = 600) führt der Provider bei HTTP 429- und HTTP 500-Antworten automatisch Wiederholungsversuche durch. Andere HTTP-Fehler wie 400, 401, 403, 404 oder 409 werden durch diesen Mechanismus nicht erneut versucht.

    Beispielkonfiguration:


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

     

    Retry-Konfiguration im AWS Terraform Provider

    Bei AWS variiert das Thema stark je Service. Die globale Konfiguration im Provider deckelt die Anzahl der Wiederholungen; der Modus der Backoff-Strategie wird über das AWS SDK bestimmt. In der Praxis:


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

     

    Architektur-Pattern: Resource Batching

    Eine fortgeschrittene Technik zur Vermeidung von API-Limits ist das Resource Batching. Dabei handelt es sich um die Gruppierung von Ressourcen in logische Einheiten, die sequenziell bereitgestellt werden. Im folgenden Beispiel setzen wir bewusst auf den time-Provider und dessen time_sleep-Ressourcentyp, um eine künstliche Wartezeit zwischen den Batches zu erzwingen. Dadurch wird garantiert, dass der nächste Batch erst nach Ablauf der Delay-Zeit beginnt.


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

    Dieses Pattern ermöglicht es, große Deployments in verdauliche Häppchen zu zerlegen, ohne auf -target zurückgreifen zu müssen. Die Batches werden automatisch mit Verzögerungen erstellt, um API-Limits zu respektieren. Der Trick besteht darin, teure API-Operationen zeitlich zu staffeln, ohne die Architektur oder die Module künstlich zu zerreißen.

    Dieser Artikel dürfte jetzt ausreichen, um im Falle eines Problems mit API-Limits erste Hilfe zu leisten und Ideen für Lösungsansätze entwickeln zu helfen. Wir sind hier bisher aber extrem reaktiv und auch etwas paranoid unterwegs. Auch kommt noch hinzu, dass Dinge wie Batching eigentlich durch gute Provider bereits abgefangen werden sollten, zumindest setzt man das als Endbenutzer unbewusst voraus.
    Im nächsten Artikel schauen wir uns deshalb an, inwiefern fortgeschrittene Methoden wie API Gateways, Tests mit Ephemerals und Sentinel-Policies solche Limits proaktiv diagnostizieren können.