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

      Terraform @ Scale - 第 5a 部分:理解API限制

      现在是一个普通星期二下午的14:30。一家瑞士金融服务商的DevOps团队例行启动其Terraform流水线,执行每月的灾难恢复测试(Disaster-Recovery-Testing)。计划在备用区域(Backup-Region)中部署300台虚拟机、150个负载均衡器后端、500条DNS记录以及无数网络规则。
      5分钟后,流水线中断。HTTP 429: Too Many Requests
      接下来的3个小时,团队花费大量时间手动清理部分已创建的资源,而管理层则紧张地盯着时钟。
      灾备测试还未真正开始就已经宣告失败。

      发生了什么?团队踩中了云端自动化中最隐蔽的陷阱之一:API速率限制(API Rate Limits)。当Terraform尝试并行创建数百个资源时,云服务提供商在收到前100个每分钟的请求后,就触发了紧急限流。一个在部署10台虚拟机时从未出现的问题,在部署300台虚拟机时却变成了一堵无法逾越的高墙。

      看不见的墙:理解API限制

      所有大型云服务提供商都会实施API速率限制,以保证其服务的稳定性。这些限制并非人为刁难,而是必要的保护机制。没有这些限制,一个有缺陷的Terraform运行就可能导致该提供商所有客户的API端点瘫痪。

      Oracle Cloud Infrastructure 实施了API速率限制,当IAM检测到超出限制时会返回429错误码。具体的限制因服务而异,并非统一公布,而是按服务类型分别实施。OCI会为每个租户设定服务限制,这些限制要么是在采购时与Oracle销售代表商定,要么在标准/试用情况下自动设定。

      AWS在EC2 API限流中采用令牌桶(Token-Bucket)机制。对于RunInstances操作,其资源令牌桶容量为1000个令牌,补充速率为每秒两个令牌。这意味着您可以一次性启动1000台实例,之后每秒最多再启动两台。API操作会按类别分组,其中Non-mutating Actions(Describe*、List*、Search*、Get*)通常拥有最高的API限流阈值。
      [注:Non-mutating Actions是指不会改变云端状态、仅进行数据读取的API操作。AWS(以及其他提供商)通常会对这类操作设置更高的速率限制,因为它们不会创建、修改或删除资源,因此对后端系统稳定性的影响较小。]

      Terraform本身会因为并行化而加剧这一问题。默认情况下,Terraform会同时尝试创建或修改最多10个资源。在复杂的依赖关系图以及大量独立资源的情况下,这很快会导致数十个同时进行的API调用。如果再叠加我们在本系列前文中讨论过的Data Sources,就会形成一个所谓的“完美风暴”(perfect storm)API请求模式。

      并行化悖论

      Terraform的默认并行化是一把双刃剑。一方面,它能显著加快部署速度,没有人愿意在资源按顺序逐个创建时等待数小时;但另一方面,正是这种优化在大规模基础设施中带来了问题。

      让我们来看一个典型场景:一个用于多层应用(Multi-Tier Application)的Terraform配置,包括Web层、应用层和数据库层。每一层都有多个实例,分布在不同的可用域(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
      }

      这个看似无害的配置在执行时会触发:

      • 50次API调用用于创建Web层实例
      • 30次API调用用于创建应用层实例
      • 50次API调用用于负载均衡器后端注册
      • 400次API调用用于创建监控告警,80个实例 × 每实例5个告警

      总计530次API调用,Terraform会尽量并行执行。在每秒仅允许10个写操作的限制下,即使完美串行化,也要接近一分钟才能完成。

      而在实际情况中,限流会导致重试循环(Retry Loop)、指数退避(Exponential Backoff),并在最糟糕的情况下触发超时(Timeout)和执行中断,留下部分已创建的资源需要手动清理。

      Terraform -target:错误的解决方案

      在走投无路时,许多团队会选择使用terraform apply -target,以选择性地创建资源。计划是这样的:“我们先部署网络,再部署计算实例,最后部署监控”。但这是一个比解决问题带来更多麻烦的危险做法。

      -target参数是为紧急情况下的外科手术式变更而设计的,不是用于常规部署。

      它会绕过Terraform的依赖管理,可能导致状态不一致(State Inconsistency)。

      更糟糕的是,它无法扩展。当资源数量达到数百时,您要么必须单独针对每种资源类型进行target操作(使复杂性急剧上升),要么编写复杂的脚本,让Terraform用不同的Target多次运行。

      以下是我在实践中见过的一种反模式:


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

      这个脚本也许能运行,但它脆弱、难以维护且容易出错:

      1. 它治标不治本。
      2. 此外,您会失去Terraform的幂等性(Idempotenz) - 如果脚本中途停止,将无法确定哪些资源已创建。

      正确的解决方案:控制并行度

      解决API限制问题最优雅的方法是控制Terraform的并行化。Terraform为此提供了-parallelism参数:


       terraform apply -parallelism=5
      

      这会将同时运行的操作数从10减少到5。在极度敏感的环境中,这个值甚至可以进一步降低:


      terraform apply -parallelism=1 
      

      此时,所有资源都会严格按顺序依次创建。是的,这会非常耗时,因此这里只是学术示例。

      而且,这通常只是冰山一角。在生产环境中,您可能需要更为精细的策略。

      以下是一个几近“偏执”的Wrapper脚本示例,它会根据需要创建的资源数量动态调整并行度:


      #!/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级别的限流与重试机制

      对于使用官方OCI SDK自行开发的OCI工具,您可以通过环境变量控制其行为。SDK支持如下配置:


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

      这些设置并不能替代良好的架构设计,但它们能帮助缓冲临时的限流高峰。

      OCI Terraform Provider中的重试选项

      OCI Terraform Provider在Provider块中支持两个配置选项,用于控制在特定API错误发生时自动重试的行为:

      disable_auto_retries(boolean):如果设置为true,将禁用Provider的所有自动重试,无论retry_duration_seconds的值是多少。默认值:false。

      retry_duration_seconds(integer):设置在特定HTTP错误下Provider执行重试的最小时间窗口(单位:秒)。此选项仅在HTTP状态码429和500时生效。所配置的时间被解释为最小值,由于采用了带全随机抖动(full jitter)的平方退避(quadratic backoff)机制,实际持续时间可能更长。如果disable_auto_retries为true,则此值会被忽略。默认值:600秒。

      默认行为:在默认设置下(disable_auto_retries = falseretry_duration_seconds = 600),Provider会在收到HTTP 429和HTTP 500响应时自动进行重试。其他HTTP错误(如400、401、403、404或409)不会触发该机制的重试。

      配置示例:


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

       

      AWS Terraform Provider中的重试配置

      在AWS中,该机制因服务不同而差异较大。Provider中的全局配置用于限制最大重试次数;回退(Backoff)策略的模式由AWS SDK决定。实际用例如下:


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

       

      架构模式:Resource Batching

      避免触发API限制的一种高级技巧是Resource Batching(资源分批)。其核心是将资源分组为逻辑单元并按顺序部署。在下面的示例中,我们有意使用time Provider及其time_sleep资源类型,在批次之间插入人工延迟。这样可以确保下一个批次仅在延迟时间结束后才开始执行。


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

      这种模式允许将大型部署拆分成可管理的小批次,而无需使用-target。批次会自动带有延迟,以确保遵守API限制。关键在于将高成本的API操作分时执行,而不必人为拆分架构或模块。

      至此,本文内容足以在遭遇API限制问题时提供初步的应对方法和解决思路。不过,到目前为止我们依然处于高度被动、甚至略显“偏执”的状态。另外,像批处理(Batching)这种机制,其实优秀的Provider本应在内部自动处理——至少作为终端用户通常会下意识地这么认为。
      在下一篇文章中,我们将探讨高级方法,如API Gateway、使用Ephemeral进行测试以及Sentinel策略,如何主动诊断这些限制。