CloudFormation 自定义资源可以补上原生资源类型的能力缺口,但它也会把 Lambda、外部 API、状态管理和失败重试带进部署链路。到了多区域场景,如果所有堆栈依赖同一个区域中的处理函数,一次区域故障就可能同时阻塞多个区域的发布。更稳妥的做法是让每个区域拥有独立的自定义资源提供者,并把幂等性、失败隔离和回滚语义设计进处理逻辑。
多区域部署真正增加了哪些故障点
普通 CloudFormation 资源的生命周期由 AWS 管理。自定义资源则不同:CloudFormation 向 ServiceToken 指向的提供者发送 Create、Update 或 Delete 请求,再等待提供者把结果写回预签名的 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 参数本身,而是三个部署属性:
ServiceToken引用当前堆栈创建的 Lambda,因此每个区域都有自己的执行路径。PhysicalResourceId基于堆栈和逻辑资源保持稳定,普通更新不会意外触发替换。- 删除不存在的参数仍然返回成功,使
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同时读取ResourceProperties和OldResourceProperties,明确哪些属性可以原地修改,哪些需要迁移或替换。Delete对“不存在”返回成功,并避免删除不属于当前堆栈的共享对象。- 调用外部服务时设置连接和读取超时,不让 Lambda 一直等待到自身超时。
- 对限流和短暂错误采用带抖动的指数退避,同时确保总重试时间小于 Lambda 超时。
- 将
StackId、RequestId、LogicalResourceId和目标区域写入结构化日志,便于跨区域定位失败请求。
如果操作可能超过 Lambda 的单次执行时间,可以这样实践:用 Step Functions、队列或 CloudFormation 自定义资源提供者框架承载异步工作,并用请求令牌记录进度。不要让一个 Lambda 同步等待几十分钟的外部任务。
上线前的检查边界
采用多区域自定义资源时,可以用下面的清单做设计审查:
- 每个区域是否拥有独立的提供者、日志组和权限?
- 主区域不可用时,备用区域的部署是否仍能独立完成?
- Create、Update、Delete 是否都经过重复调用测试?
- 更新属性和删除堆栈时,是否可能误删共享资源?
- Provider 失败后,CloudFormation 是否能收到明确的
FAILED响应? - IAM 是否限制到实际资源路径,而不是长期保留示例中的通配符?
- 发布流水线是否记录每个区域的结果,并支持暂停后续区域?
自定义资源的多区域韧性并不来自“多部署几份 Lambda”,而来自清晰的区域边界和可重复执行的生命周期协议。先让每个区域能够独立创建、更新、回滚和删除,再考虑并发发布或跨区域协调,故障范围会更容易控制。