让 CloudFormation 自定义资源经得起多区域部署

2026-07-22 34 预计阅读时间: 1 分钟
来源: aws.amazon.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

CloudFormation 自定义资源可以补上原生资源类型的能力缺口,但它也会把 Lambda、外部 API、状态管理和失败重试带进部署链路。到了多区域场景,如果所有堆栈依赖同一个区域中的处理函数,一次区域故障就可能同时阻塞多个区域的发布。更稳妥的做法是让每个区域拥有独立的自定义资源提供者,并把幂等性、失败隔离和回滚语义设计进处理逻辑。

多区域部署真正增加了哪些故障点

普通 CloudFormation 资源的生命周期由 AWS 管理。自定义资源则不同:CloudFormation 向 ServiceToken 指向的提供者发送 CreateUpdateDelete 请求,再等待提供者把结果写回预签名的 ResponseURL

这条链路在多区域环境中有几个关键约束:

  • 提供者应与调用它的堆栈部署在同一区域。不要让多个区域的堆栈共同依赖某个“主区域”中的 Lambda。
  • 区域之间应使用独立堆栈。一个区域部署失败时,其他区域仍能创建、更新或回滚。
  • 处理函数必须幂等。Lambda 超时、网络中断或 CloudFormation 重试都可能导致同一操作被执行多次。
  • Delete 必须容忍目标对象已经不存在,否则清理失败会让堆栈长期停在 DELETE_FAILED
  • PhysicalResourceId 必须稳定。随意生成新值可能让一次普通更新被 CloudFormation 解释为资源替换。

跨区域共享一个提供者看似减少了函数数量,实际上扩大了故障域,还会引入跨区域权限、延迟和部署顺序问题。区域本地化通常是更容易验证的边界。

一个可部署的区域本地提供者

下面是一个可以这样实践的最小示例。它在每个区域创建一套 Lambda 提供者,并通过自定义资源管理该区域中的 SSM Parameter Store 参数。示例假设参数由这个堆栈独占管理;生产环境还应进一步收紧 IAM 资源范围。

将以下内容保存为 template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: Region-local CloudFormation custom resource example

Parameters:
  ParameterName:
    Type: String
    Default: /demo/multi-region/custom-resource
  ParameterValue:
    Type: String
    Default: deployed

Resources:
  ProviderRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: ManageRegionalParameter
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - ssm:PutParameter
                  - ssm:DeleteParameter
                Resource: !Sub arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter/*

  ProviderFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt ProviderRole.Arn
      Timeout: 60
      Code:
        ZipFile: |
          import json
          import urllib.request
          import boto3

          ssm = boto3.client('ssm')

          def respond(event, context, status, data=None, reason=None):
              body = {
                  'Status': status,
                  'Reason': reason or 'See CloudWatch Logs: ' + context.log_stream_name,
                  'PhysicalResourceId': (
                      event.get('PhysicalResourceId')
                      or event['StackId'] + '/' + event['LogicalResourceId']
                  ),
                  'StackId': event['StackId'],
                  'RequestId': event['RequestId'],
                  'LogicalResourceId': event['LogicalResourceId'],
                  'NoEcho': False,
                  'Data': data or {},
              }
              payload = json.dumps(body).encode('utf-8')
              request = urllib.request.Request(
                  event['ResponseURL'],
                  data=payload,
                  method='PUT',
                  headers={
                      'content-type': '',
                      'content-length': str(len(payload)),
                  },
              )
              with urllib.request.urlopen(request) as response:
                  response.read()

          def delete_parameter(name):
              try:
                  ssm.delete_parameter(Name=name)
              except ssm.exceptions.ParameterNotFound:
                  pass

          def handler(event, context):
              try:
                  request_type = event['RequestType']
                  props = event['ResourceProperties']
                  name = props['Name']

                  if request_type in ('Create', 'Update'):
                      ssm.put_parameter(
                          Name=name,
                          Value=props['Value'],
                          Type='String',
                          Overwrite=True,
                      )

                      old_name = event.get('OldResourceProperties', {}).get('Name')
                      if request_type == 'Update' and old_name and old_name != name:
                          delete_parameter(old_name)

                  elif request_type == 'Delete':
                      delete_parameter(name)
                  else:
                      raise ValueError('Unsupported request type: ' + request_type)

                  respond(
                      event,
                      context,
                      'SUCCESS',
                      {'Name': name, 'Region': context.invoked_function_arn.split(':')[3]},
                  )
              except Exception as exc:
                  respond(event, context, 'FAILED', reason=str(exc)[:1000])

  RegionalParameter:
    Type: Custom::RegionalParameter
    Properties:
      ServiceToken: !GetAtt ProviderFunction.Arn
      Name: !Ref ParameterName
      Value: !Ref ParameterValue

Outputs:
  ManagedParameterName:
    Value: !GetAtt RegionalParameter.Name
  DeploymentRegion:
    Value: !GetAtt RegionalParameter.Region

这里的核心不是 SSM 参数本身,而是三个部署属性:

  1. ServiceToken 引用当前堆栈创建的 Lambda,因此每个区域都有自己的执行路径。
  2. PhysicalResourceId 基于堆栈和逻辑资源保持稳定,普通更新不会意外触发替换。
  3. 删除不存在的参数仍然返回成功,使 Delete 操作可以重复执行。

处理函数无论成功还是失败,都必须向 ResponseURL 发送响应。仅仅抛出异常并不等于完成 CloudFormation 请求,堆栈可能一直等待到超时。响应体还有大小限制,因此 Data 应只返回必要字段,不要塞入日志或大型 API 响应。

将区域故障限制在单次发布中

模板可以按区域独立部署。下面的 Bash 脚本会尝试所有目标区域,即使某个区域失败也不会立即中断;执行结束后,只要存在失败就返回非零状态码。

运行前需要配置 AWS CLI 凭证,并根据实际拓扑修改 regions

#!/usr/bin/env bash
set -u

regions=(us-east-1 us-west-2 eu-west-1)
failed=0

for region in "${regions[@]}"; do
  echo "Deploying regional-custom-resource to ${region}"

  if aws cloudformation deploy --template-file template.yaml --stack-name regional-custom-resource --region "${region}" --capabilities CAPABILITY_IAM --parameter-overrides ParameterValue="deployed-${region}"; then
    echo "Succeeded: ${region}"
  else
    echo "Failed: ${region}" >&2
    failed=1
  fi
done

exit "${failed}"

部署后可以逐个区域验证:

for region in us-east-1 us-west-2 eu-west-1; do
  aws ssm get-parameter --region "${region}" --name /demo/multi-region/custom-resource --query 'Parameter.Value' --output text
done

这个脚本强调失败隔离,但它还不是完整的生产发布系统。对于关键工作负载,建议先部署备用区域并运行验证,再推进主区域;也可以给每个区域设置独立的流水线阶段和人工审批。并行部署速度更快,却会同时暴露所有区域,因此需要根据回滚成本决定并发度。

幂等性比重试次数更重要

多区域并不会改变自定义资源协议,却会放大协议实现中的缺陷。提供者至少要处理以下情况:

  • Create 重复到达时,使用 upsert、条件写入或客户端请求令牌,避免创建重复对象。
  • Update 同时读取 ResourcePropertiesOldResourceProperties,明确哪些属性可以原地修改,哪些需要迁移或替换。
  • Delete 对“不存在”返回成功,并避免删除不属于当前堆栈的共享对象。
  • 调用外部服务时设置连接和读取超时,不让 Lambda 一直等待到自身超时。
  • 对限流和短暂错误采用带抖动的指数退避,同时确保总重试时间小于 Lambda 超时。
  • StackIdRequestIdLogicalResourceId 和目标区域写入结构化日志,便于跨区域定位失败请求。

如果操作可能超过 Lambda 的单次执行时间,可以这样实践:用 Step Functions、队列或 CloudFormation 自定义资源提供者框架承载异步工作,并用请求令牌记录进度。不要让一个 Lambda 同步等待几十分钟的外部任务。

上线前的检查边界

采用多区域自定义资源时,可以用下面的清单做设计审查:

  • 每个区域是否拥有独立的提供者、日志组和权限?
  • 主区域不可用时,备用区域的部署是否仍能独立完成?
  • Create、Update、Delete 是否都经过重复调用测试?
  • 更新属性和删除堆栈时,是否可能误删共享资源?
  • Provider 失败后,CloudFormation 是否能收到明确的 FAILED 响应?
  • IAM 是否限制到实际资源路径,而不是长期保留示例中的通配符?
  • 发布流水线是否记录每个区域的结果,并支持暂停后续区域?

自定义资源的多区域韧性并不来自“多部署几份 Lambda”,而来自清晰的区域边界和可重复执行的生命周期协议。先让每个区域能够独立创建、更新、回滚和删除,再考虑并发发布或跨区域协调,故障范围会更容易控制。


相关推荐