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

    Terraform @ Scale - Teil 4a: Data Sources sind gefährlich!

    Terraforms Data Sources sind ein beliebtes Mittel, um Variablen dynamisch mit real existierenden Werten der jeweiligen Cloudumgebung zu befüllen. Aber ihre Benutzung in dynamischen Infrastrukturen erfordert etwas Weitblick. Es reicht zum Beispiel ein harmloses data.oci_identity_availability_domains in einem Modul - und plötzlich dauert jeder terraform plan Minuten statt Sekunden. Denn 100 Modulinstanzen bedeuten 100 API-Calls, und Ihr Cloud-Provider beginnt zu throtteln. Willkommen in der Welt der ungewollten API-Amplifikation durch Data Sources.

    In diesem Artikel zeige ich Ihnen, warum Data Sources in Terraform-Modulen ein Skalierungsproblem darstellen können. 

     

    Das versteckte Skalierungsproblem

    Terraform at scale 11 5

    Szenario: Die 10-Sekunden-Falle

    Sie haben ein sauberes Terraform-Modul für VM-Instanzen geschrieben. Für jede VM brauchen Sie eine Availability Domain, also nutzen Sie eine Data Source:


    data "oci_identity_availability_domains" "ads" {
      compartment_id = var.tenancy_ocid
    }
    
    resource "oci_core_instance" "this" {
      for_each = var.instances != null ? var.instances : {}
    
      availability_domain = data.oci_identity_availability_domains.ads.availability_domains[0].name
      compartment_id      = var.compartment_id
      shape              = each.value.shape
      
      create_vnic_details {
        subnet_id = each.value.subnet_id
      }
    }

    Auf den ersten Blick ist hier alles korrekt. Das Modul funktioniert perfekt - für eine einzelne VM-Instanz, auch noch für eine Handvoll davon.

    Aber dann skaliert Ihr Kunde auf 50 VMs:


    module "app_servers" {
      for_each = var.server_configs != null ? var.server_configs : {}
      
      source = "./modules/compute-instance"
      
      instances      = each.value.instances
      compartment_id = each.value.compartment_id
    }

     Das Ergebnis: 50 identische API-Calls zu oci_identity_availability_domains bei jedem terraform plan und terraform apply.

    Was früher Sekunden dauerte, braucht jetzt Minuten. Bei 100 oder 200 Instanzen wird es dann richtig schmerzhaft.

    Bei neu bereitgestellten VM-Instanzen kann das in vielen Use Cases noch erträglich und planbar sein - aber was passiert, wenn es dabei um bestehende Ressourcen mit kurzen Lebenszyklen geht? Stellen Sie sich längere Wartezeiten bei der Provisionierung von Cluster Nodes zum Abfangen von Lastspitzen vor, bei Load Balancer Backends, bei DNS-Records oder gar Ressourcen, von denen wiederum andere Ressourcen abhängen. Dann drohen lange Ausfälle mit entsprechender Außenwirkung auf Ihre Kunden, die im schlimmsten Fall sogar noch unvorhersehbar sind.

    Warum Data Sources in Modulen problematisch sind

    Data Sources verhalten sich fundamental anders als Ressourcen. Sie werden bei jedem terraform plan und terraform apply ausgeführt und können nicht gecacht werden. In einem Modul bedeutet das:

    Problem 1: API-Amplifikation

    Jede Modulinstanz führt ihre eigenen Data Source-Abfragen aus, selbst wenn die Daten identisch sind.

    Zum Beispiel diese zwei Module, die beide das gleiche Child-Modul ./modules/server aufrufen:


    module "web_servers" {
      for_each = var.web_configs != null ? var.web_configs : {}
      source   = "./modules/server"
      
      compartment_id = each.value.compartment_id
    }
    
    module "app_servers" {
      for_each = var.app_configs != null ? var.app_configs : {}
      source   = "./modules/server"
      
      compartment_id = each.value.compartment_id
    }

    In dem Modul ./modules/server versteckt sich, für den Autor des Root-Moduls unsichtbar, die Abfrage einer Data Source:


    data "oci_identity_availability_domains" "available" {
      compartment_id = var.compartment_id
    }
    
    resource "oci_core_instance" "this" {
      for_each = var.instances != null ? var.instances : {}
      
      availability_domain = data.oci_identity_availability_domains.available.availability_domains[0].name
      compartment_id      = var.compartment_id
    }

     Werden jetzt zum Beispiel 10 app_server und 50 web_server deployed, resultiert das in 60 parallelen(!) API-Abfragen. Kein Cloud-Provider lässt dies zu. Stattdessen läuft ihr terraform apply dann in API-Limits des API-Gateways und die Bereitstellung dauert ewig.

    Im besten Fall werden die Server in kleineren Paketen, zum Beispiel in Bündeln von jeweils 10 Instanzen, bereitgestellt. Dann dauert die Bereitstellung bereits miindestens sechs mal so lange.

    Und das betrifft dann nicht nur die hier im Codebeispiel gezeigten Serverinstanzen, sondern auch alle weiteren noch im Plan enthaltenen Ressourcen. Der API-Limit gilt für alles, nicht nur für einen bestimmten Resource Type wie oci_core_instance. Auch andere Ressourcen, auf die normalerweise zeitnah oder sogar parallel zugegriffen wird, müssen sich in der Warteschlange jetzt hinten anstellen. Das kann im schlimmsten Fall dann sogar zu Race Conditions oder Timeouts führen.

    Problem 2: Performance-Degradation

    Bei wachsender Anzahl von Modulinstanzen steigt die Planungszeit linear oder sogar exponentiell. 

    Werfen Sie ein Blick auf dieses Beispiel, welches die Liste der vorhandenen OS-Images von OCI ausliest und nach einem bestimmten Release der auf GPU-Anwendungen optimierten Variante von Oracle Linux 8 filtert:


     data "oci_core_images" "compute_images" {
      compartment_id           = var.compartment_id
      operating_system         = "Oracle Linux"
      operating_system_version = "8"
      sort_by                  = "TIMECREATED"
      sort_order              = "DESC"
      
      filter {
        name   = "display_name"
        values = [".*GPU.*"]
        regex  = true
      }
    }
    
    resource "oci_core_instance" "gpu_instances" {
      for_each = var.gpu_instances != null ? var.gpu_instances : {}
      
      source_details {
        source_id   = data.oci_core_images.compute_images.images[0].id
        source_type = "image"
      }
    }
    

     Die Liste der vorhandenen OS-Images ist extrem lang und sie wird jedes Mal, bei jeder VM-Instanz, komplett ausgelesen und durch den Terraform-Provider gefiltert.  Das sorgt also nicht nur für unnötige API-Zugriffe mit sehr umfangreichen Antworten, sondern setzt auch noch Terraform selbst unter Last. 

    Problem 3: API-Limits und Throttling

    Wir haben es weiter oben in diesem Artikel bereits angesprochen: Cloud-Provider haben API-Limits.

    Zu viele Data Source-Calls führen deshalb zu:

    • HTTP 429 (Too Many Requests) Fehlern
    • Exponential Backoff und Retry-Zyklen
    • Blockierung anderer Terraform-Operationen
    • Instabilen CI/CD-Pipelines

     

    Anti-Pattern: Nicht skalierende Beispiele

    Terraform at scale 11 4

    Beispiel 1: Das Availability Domain Dilemma

    Problem: Dieses Modul führt bei jeder Instanziierung zwei separate API-Calls durch – einen für Availability Domains und einen für Subnets. In einer Umgebung mit 20 Datenbankmodulen entstehen 40 redundante API-Calls für identische Informationen.

    Warum es nicht skaliert: Jedes Modul fragt dieselben globalen Availability Domains ab, obwohl sich diese sehr selten ändern. Zusätzlich wird bei jeder Modulinstanz das gleiche Subnet-Filter ausgeführt, was bei komplexeren VPCs mit vielen Subnets besonders teuer wird.

    Auswirkung: Bei 50 Datenbankinstanzen = 100 API-Calls plus 50x Filterung für Daten, die sich höchstens einmal pro Jahr ändern.


     data "oci_identity_availability_domains" "available" {
      compartment_id = var.compartment_id
    }
    
    data "oci_core_subnets" "database" {
      compartment_id = var.compartment_id
      vcn_id         = var.vcn_id
      
      filter {
        name   = "display_name"
        values = ["*database*"]
      }
    }
    
    resource "oci_database_db_system" "main" {
      for_each = var.db_systems != null ? var.db_systems : {}
      
      availability_domain = data.oci_identity_availability_domains.available.availability_domains[0].name
      subnet_id          = data.oci_core_subnets.database.subnets[0].id
      compartment_id     = var.compartment_id
    }

     

    Beispiel 2: Das Image-Lookup Problem

    Problem: Diese Implementierung führt bei jedem Modulaufruf eine teure Image-Suche mit Regex-Filterung durch. Die Sortierung und lokale Verarbeitung von Image-Listen ist sowohl bei OCI als auch lokal in Terraform ressourcenintensiv.

    Warum es nicht skaliert: Image-Lookups sind besonders langsam, da sie große Datenmengen übertragen und komplex filtern. Jede VM führt denselben Lookup durch, obwohl das Ergebnis für alle Instanzen gleich ist. Die lokale Sortierung mit sort() verstärkt das Problem zusätzlich.

    Auswirkung: Bei 100 VM-Instanzen = 100 teure Image-API-Calls + 100 lokale Filteroperationen = mehrere Minuten Planungszeit für etwas, was auch Ratzfatz erledigt werden kann.


     data "oci_core_images" "ol8_images" {
      compartment_id           = var.tenancy_ocid
      operating_system         = "Oracle Linux"
      operating_system_version = "8"
      shape                    = var.instance_shape
      
      filter {
        name   = "display_name"
        values = ["Oracle-Linux-8.8-.*"]
        regex  = true
      }
    }
    
    locals {
      latest_image_id = sort([for img in data.oci_core_images.ol8_images.images : img.id])[0]
    }
    
    resource "oci_core_instance" "this" {
      for_each = var.instances != null ? var.instances : {}
      
      source_details {
        source_id   = local.latest_image_id
        source_type = "image"
      }
    }
    

     

    Beispiel 3: Das Nested Data Source Problem

    Problem: Dieses Modul kombiniert gleich mehrere Skalierungsprobleme: Cluster-Lookups, Image-Lookups für Worker-Nodes und komplexe Abhängigkeitsketten zwischen Data Sources. Jede Node Pool-Erstellung triggert beide Data Sources erneut.

    Warum es nicht skaliert: Nested Data Sources erzeugen Abhängigkeitsketten, die bei jeder Modulinstanz komplett durchlaufen werden müssen. Kubernetes-spezifische Image-Suchen sind besonders langsam, da sie sehr spezifische Filter mit Regex-Patterns verwenden, die server-seitig verarbeitet werden müssen.

    Auswirkung: Bei 10 Node Pools = 20 API-Calls für Cluster-Informationen + 10 teure OKE-Image-Lookups = exponentiell wachsende Planungszeiten mit steigender Node Pool-Anzahl.


     data "oci_containerengine_clusters" "existing" {
      compartment_id = var.compartment_id
      
      filter {
        name   = "name"
        values = [var.cluster_name]
      }
    }
    
    data "oci_core_images" "worker_images" {
      compartment_id   = var.compartment_id
      operating_system = "Oracle Linux"
      
      filter {
        name   = "display_name"
        values = ["Oracle-Linux-.*-OKE-.*"]
        regex  = true
      }
    }
    
    resource "oci_containerengine_node_pool" "workers" {
      for_each = var.node_pools != null ? var.node_pools : {}
      
      cluster_id = data.oci_containerengine_clusters.existing.clusters[0].id
      
      node_config_details {
        size = each.value.size
      }
    }
    

     

    Jetzt haben wir Ihnen mehrere Beispiele dafür gegeben, wie man Data Sources NICHT einsetzen sollte. Das alleine hilft Ihnen aber nicht weiter. Im nächsten Artikel zeige ich, wie Sie durch intelligente Architektur und Variable Injection diese Falle elegant umgehen.

    Denn die beste Data Source ist die, die gar nicht oder nur einmal ausgeführt wird.