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

      Terraform @ Scale - 第 4a 部分:Data Sources 有风险!

      Terraform 的 Data Sources 是一种常用方式,用于动态地将各个云环境中真实存在的值填充到变量中。但在动态基础架构中使用它们则需要更长远的视角。比如,在某个模块中使用一个看似无害的 data.oci_identity_availability_domains 就足以让每次执行 terraform plan 的时间从几秒变为几分钟。因为 100 个模块实例就意味着 100 次 API 调用,而您的云服务提供商将开始限制请求频率。欢迎来到通过 Data Sources 意外触发 API 调用放大的世界。

      本文将向您展示,为何在 Terraform 模块中使用 Data Sources 可能成为一个扩展性问题。

       

      隐藏的扩展性问题

      Terraform at scale 11 5

      场景:10 秒陷阱

      您编写了一个结构清晰的 Terraform 模块,用于创建 VM 实例。每个 VM 实例需要一个 Availability Domain,因此您使用了一个 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
        }
      }

      乍一看,一切都很正确。该模块运行良好 - 无论是针对单个 VM 实例,还是少量实例。

      但随后客户将规模扩大到 50 个 VM:


      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
      }

       结果: 每次执行 terraform planterraform apply 时,都会产生 50 次对 oci_identity_availability_domains 的相同 API 调用。

      原本只需几秒的操作,现在变成了几分钟。对于 100 或 200 个实例,情况会变得更加严重。

      在新部署的 VM 实例场景中,这种延迟在许多使用场景下也许还算可控和可接受——但如果涉及的是生命周期极短的现有资源,那后果将非常不同。请想象在为集群节点应对负载高峰而进行的资源部署中,在 Load Balancer 的后端配置中,在 DNS 记录中,或者在那些又被其他资源所依赖的资源中发生延迟。这将导致长时间中断,对客户造成外部影响,在最坏情况下甚至是不可预测的。

      为何在模块中使用 Data Sources 会引发问题

      Data Sources 的行为方式与资源有本质上的不同。它们会在每次执行 terraform plan 和 terraform apply 时运行,且无法被缓存。在模块中使用时,这意味着:

      问题 1:API 调用放大

      每个模块实例都会执行自己的 Data Source 查询,即便数据完全相同。

      例如以下两个模块,它们都调用相同的子模块 ./modules/server


      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
      }

      在模块 ./modules/server 中,隐藏着一个 Data Source 查询,对于 Root 模块的作者是不可见的:


      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
      }

       如果现在例如部署 10 个 app_server 和 50 个 web_server,就会产生 60 个并行的 API 查询。没有任何云服务提供商会允许这种行为。相反,terraform apply 将会触及 API 网关的调用上限,导致部署时间变得异常漫长。

      在最理想的情况下,服务器会被分批部署,例如每批 10 个实例。这种方式下,部署时间也会至少是原来的六倍。

      而这不仅影响代码示例中所示的服务器实例,还会波及到 Plan 中所有其余资源。API 限制是全局性的,不只针对特定资源类型如 oci_core_instance。其他通常需要快速甚至并行访问的资源,现在也必须排队等待。这种情况在最坏的情况下甚至会导致 Race Condition 或 Timeout。

      问题 2:性能退化

      随着模块实例数量的增加,计划阶段的耗时将呈线性甚至指数级增长。

      请看这个示例,它从 OCI 中读取现有的 OS 镜像列表,并筛选出适用于 GPU 应用的 Oracle Linux 8 特定版本:


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

       OS 镜像的列表非常庞大,每次针对单个 VM 实例都会被完整读取并由 Terraform Provider 进行过滤。这不仅导致大量不必要的 API 调用和冗长的响应,还会给 Terraform 自身带来负担。

      问题 3:API 限制与限流

      我们在本文前面已提到:云服务提供商都会设定 API 调用限制。

      过多的 Data Source 调用会导致:

      • HTTP 429(请求过多)错误
      • 指数退避和重试循环
      • 其他 Terraform 操作被阻塞
      • CI/CD 流水线变得不稳定

       

      反面教材:无法扩展的示例

      Terraform at scale 11 4

      示例 1:Availability Domain 困境

      问题:该模块在每次实例化时都会发起两个独立的 API 调用 —— 一个用于获取 Availability Domains,一个用于获取 Subnets。在有 20 个数据库模块的环境中,会为相同的信息产生 40 次冗余 API 调用。

      为何无法扩展:每个模块都会查询相同的全局 Availability Domains,尽管这些信息几乎不会发生变化。此外,每个模块实例还会执行相同的 Subnet 过滤操作,而在拥有大量子网的复杂 VPC 中,这种操作的开销尤为显著。

      影响:若有 50 个数据库实例 = 100 次 API 调用加上 50 次对子网的过滤操作,而这些信息一年最多变化一次


      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
      }

       

      示例 2:Image 查询问题

      问题:该实现会在每次模块调用时执行一次开销巨大的镜像搜索操作,且带有正则过滤。无论是在 OCI 端还是 Terraform 本地,对镜像列表进行排序和处理都会消耗大量资源。

      为何无法扩展:Image 查询操作特别缓慢,因为涉及到大量数据传输和复杂的过滤逻辑。每个 VM 实例都执行相同的查询,尽管查询结果对所有实例都是一样的。加之本地使用 sort() 进行排序,使问题进一步加剧。

      影响:100 个 VM 实例 = 100 次高开销的 Image API 调用 + 100 次本地过滤操作 = 几分钟的计划时间,而其实完全可以一瞬完成。


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

       

      示例 3:嵌套 Data Source 问题

      问题:该模块同时集成了多个扩展性问题:Cluster 查询、Worker Node 镜像查询、Data Sources 之间的复杂依赖链。每次创建 Node Pool 都会重新触发这两个 Data Source。

      为何无法扩展:嵌套的 Data Sources 会形成依赖链,每个模块实例都必须完整执行这些链。与 Kubernetes 相关的镜像查询尤其缓慢,因为它们使用了复杂的 Regex 过滤模式,必须由服务器端处理。

      影响:10 个 Node Pool = 20 次集群信息 API 调用 + 10 次高开销的 OKE 镜像查询 = 随着 Node Pool 数量增长而呈指数级上升的计划时间


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

       

      我们现在已经为您列举了多个 Data Source 的错误使用方式。但仅仅知道这些反面例子还不够。在下一篇文章中,我将展示如何通过智能架构设计与变量注入,优雅地避开这些陷阱。

      因为最好的 Data Source,就是根本不运行,或只运行一次的那一个。