AWS Landing Zone多账号治理自动化与测试实践
简介:围绕 AWS Landing Zone 自动化与测试的代码示例及文档合集,面向具备一定 AWS 基础、需要搭建多账户治理环境的开发者与云架构学习者。内容涉及组织与 OU 创建、Core 账户配置、SCP/IAM 权限策略、CloudFormation 模板与 VPC 网络,并给出 CloudTrail、Config、S3 等安全基线示例及测试环境搭建说明与校验脚本,可帮助理解着陆区部署流程和验证思路。压缩包共 69 个文件,以 JSON 配置、Markdown 文档、YAML 模板为主,另含少量 Ruby 脚本及跨账户策略文件,约 1.85MB,目录按组织、网络、安全、账户与示例工作负载划分。已有 311 人学习下载,适合按模块查阅并复现多账户 Landing Zone 的关键配置与测试环节。
1. 从一份 landing-zone 仓库说起:多账号治理的自动化基础
接手一套二十多个账号的环境时,最头疼的不是写代码,而是没人说得清 OU 树怎么分的、哪条 SCP 挂在哪一层、上次改动是谁在控制台点的。这份 landing-zone 资源就是冲着这个痛点来的:围绕 AWS Landing Zone 的自动化与测试,把账号创建、OU 划分、服务控制策略绑定、基线资源下发这些手工活写成 Python 脚本和模板,再配一层单元测试与集成测试,让改动落地前先在本地跑一遍。它适合两类人——正在搭多账号基础、又不想全靠手点的后端与运维开发;以及 Organization 结构已经成型、想补自动化和回归测试短板的团队。下面按骨架、目录、跑通、代码、踩坑、改造的顺序拆开讲。
2. 架构与目录:账号骨架和仓库分层怎么对应
2.1 三层结构:管理账号、OU 树、护栏策略
Landing Zone 的核心不是某个脚本,而是一套「谁管谁」的关系。管理账号持有 Organizations,是整个组织的唯一入口,创建账号、建 OU、挂策略这些 API 只有它能调;OU 是一棵树,越往上管的账号越多,策略从上往下继承,子 OU 只能加严不能放宽;账号是叶子节点,业务资源都落在里面。护栏(Guardrail)分两类,一类是预防性的服务控制策略,直接拒绝越界 API 调用,另一类是探测性的 Config 规则和 CloudTrail,事后能查。这套代码把三者拆成独立的执行阶段,先动组织、再动账号、最后下发基线资源,顺序错了就会撞权限。
常见的 OU 划分我一般按职责切,不按部门切,因为部门会重组,职责不会:
| OU | 承载账号 | 典型护栏 |
|---|---|---|
| Security | 日志归档、审计 | 禁止停用 CloudTrail、禁止删除日志桶 |
| Infrastructure | 网络中枢、共享服务 | 限定网络资源的修改范围 |
| Workloads/Prod | 生产业务 | 锁定区域、强制加密 |
| Workloads/NonProd | 预发、测试 | 放宽实例规格,保留加密 |
| Sandbox | 实验 | 限额,禁止接触生产数据 |
这张表要落到配置文件里,而不是靠人记。config 目录下的 ous.yaml 定义树形结构和父节点,accounts.yaml 定义每个账号的邮箱、所属 OU、要打的标签和要额外挂的策略。脚本读这两份文件推导出「应该是什么样」,再和云端实际状态比对,差异部分才去调 API,这样重复执行不会重复创建,也不会因为中途失败留下半截状态。
2.2 仓库目录:config、src、templates、tests 四层分工
拿到仓库先别急着装依赖,花十分钟看目录能省下后面两小时。这份资源的目录大致是这样组织的:
landing-zone/ ├── config/ # 声明式输入,改这里而不是改代码 │ ├── ous.yaml # OU 树:id / 父节点 / 描述 │ ├── accounts.yaml # 账号清单:邮箱、目标 OU、标签 │ └── environments/ # 按环境覆盖的参数,如 prod.yaml ├── src/landingzone/ │ ├── org/ # 组织层:建账号、建 OU、挂 SCP │ ├── baseline/ # 基线层:日志桶、Config、CloudTrail │ ├── guardrails/ # SCP 策略文档与校验 │ └── cli.py # 统一入口,分 stage 执行 ├── templates/ # CloudFormation / StackSet 模板 ├── tests/ │ ├── unit/ # Stubber 打桩,不碰云端 │ └── integration/ # 打真实账号,跑完即回收 ├── scripts/ # 一键部署脚本 └── pyproject.toml # 依赖与工具链锁定四层各管一件事:config 是人和代码的唯一接口,只放声明不放逻辑;src 是可执行逻辑,按组织、基线、护栏拆包,互不 import 对方的内部函数;templates 放下发到成员账号的模板,尽量保持「一份模板多环境复用」;tests 分两层,单元测试必须秒级跑完,集成测试允许慢但必须能清理干净。这个划分最大的好处是改动影响面可预测——改 config 只影响参数,改 src 才需要跑全量测试。
2.3 一条改动从提交到落地的执行链路
代码有了,链路得说清楚。一次典型的改动是这样走的:先在 config/accounts.yaml 里加一个账号,本地跑校验(YAML schema + 邮箱唯一性 + OU 是否存在),校验过了跑单元测试,再跑一次 plan 阶段把「将要发生的 API 调用」打成 diff 打在终端上,人眼确认没问题才执行 apply。apply 阶段内部又分三段:组织段建账号、挂 OU;护栏段绑 SCP;基线段用 StackSet 把日志桶、Config 记录器、CloudTrail 推到所有成员账号。
这个顺序不能反。账号还没建完就去绑 SCP,接口会直接报找不到目标;SCP 还没绑就下发基线资源,中间那段窗口期成员账号是裸奔的。集成测试也放在最后,断言每个账号的 CloudTrail 处于 logging 状态、日志桶的公开访问被禁、Config 聚合器里能看到该账号。整条链路的失败点都落在具体某个阶段,日志里带阶段名,重跑时只重跑失败的那段。
提示:plan 阶段不要图省事直接跳过。多账号环境下一次误操作的影响面是几十个账号,一份 diff 的成本远低于事后回滚。
3. 首次部署:用最小路径把示例跑通
3.1 工具链与凭据:先把版本和身份钉死
Python 版本和依赖版本必须先钉住,这套代码里用了 boto3 的新版参数,老版本会静默丢掉字段而不报错,排查起来极其难受。我习惯用 venv 隔离,pyproject.toml 里把 boto3、pyyaml、pytest、moto 都卡到小版本:
# 建隔离环境,避免污染系统 Python python3.11 -m venv .venv source .venv/bin/activate # 以可编辑模式安装,src 布局必须这样才能 import landingzone pip install -e ".[dev]" # 确认身份:这一步必须返回管理账号的账号 ID,否则后面全白干 aws sts get-caller-identity --profile org-admin # 组织级 API 只在管理账号可用,区域建议固定,避免策略和资源漂移 export AWS_PROFILE=org-admin export AWS_REGION=us-east-1这里的三个参数值得说清楚。--profile指向的是本地配置里的命名凭据,不建议用长期 AK 写死在环境变量里;AWS_REGION影响 Organizations 的调用端点,虽然组织是全局资源,但 StackSet 和 S3 是区域的,固定一个值能让状态一致;pip install -e里的[dev]会额外装测试依赖,不装的话 pytest 一跑就报 import error。
凭据方面,我一般用身份中心拿临时凭据再切角色,而不是直接长期密钥。切换用的信任关系要提前建好,脚本里不写死账号,从环境变量读:
# src/landingzone/auth.py import os import boto3 from botocore.config import Config def management_session() -> boto3.Session: """返回管理账号的会话;角色 ARN 从环境变量读,代码里不写死账号。""" role_arn = os.environ["LZ_MGMT_ROLE_ARN"] # 例如 arn:aws:iam::<mgmt>:role/LZDeployer sts = boto3.client("sts", config=Config(retries={"max_attempts": 10})) creds = sts.assume_role( RoleArn=role_arn, RoleSessionName="landingzone-deploy", DurationSeconds=3600, # 一次部署够用即可,别开太长 )["Credentials"] return boto3.Session( aws_access_key_id=creds["AccessKeyId"], aws_secret_access_key=creds["SecretAccessKey"], aws_session_token=creds["SessionToken"], )DurationSeconds给一小时是有意的,长于一次部署、短于一次误操作可能持续的窗口。RoleSessionName建议带上流水线 ID,CloudTrail 里能一眼看出是谁跑的。
3.2 引导阶段:状态存储和跨账号角色先落地
在动组织之前,有两样东西要先建好,否则后面每一步都要手工补:一是 CloudFormation 下发到成员账号时用的执行角色,二是集中存放日志和状态的基础设施账号里的桶。这段只在全新环境跑一次,写进 bootstrap 阶段。
# src/landingzone/bootstrap.py import json import boto3 TRUST = { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": {"AWS": "arn:aws:iam::<mgmt-account-id>:root"}, "Action": "sts:AssumeRole", # 外部 ID 防止混淆代理,值从配置读,不要用默认值 "Condition": {"StringEquals": {"sts:ExternalId": "<external-id>"}}, }], } def ensure_stackset_role(session: boto3.Session) -> str: iam = session.client("iam") policy = { "Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Action": ["cloudformation:*"], "Resource": "*"}], } try: iam.create_role(RoleName="LZStackSetExecution", AssumeRolePolicyDocument=json.dumps(TRUST)) except iam.exceptions.EntityAlreadyExistsException: pass # 幂等:已存在就跳过,不要报错中断 iam.put_role_policy( RoleName="LZStackSetExecution", PolicyName="LZStackSetExecutionPolicy", PolicyDocument=json.dumps(policy), ) return "LZStackSetExecution"AssumeRolePolicyDocument里的 Principal 限定为管理账号根,配合ExternalId,防止别的账号猜中角色名直接 assume。put_role_policy是可覆盖的,所以权限调整直接重跑这一句就生效,不需要删了重建。EntityAlreadyExistsException一定要捕获,否则第二次执行整个引导阶段就断了。
日志桶在同一阶段建,三个属性必须开:版本控制、默认加密、公开访问全禁。这三项如果漏了,后面 Config 规则一开,聚合器里全是红叉。
3.3 按阶段执行:org → guardrails → baseline
入口脚本把三个阶段串起来,用参数控制执行到哪一步,这样出问题能单阶段重跑:
#!/usr/bin/env bash # scripts/deploy.sh set -euo pipefail STAGE="${1:-plan}" # plan 只打印 diff,apply 才真调 API CONFIG="config/environments/${ENV:-prod}.yaml" python -m landingzone.cli validate --config "$CONFIG" # 三个阶段严格串行:账号没建完绑不了 SCP,SCP 没绑完不能下发基线 python -m landingzone.cli org --config "$CONFIG" --stage "$STAGE" python -m landingzone.cli guardrail --config "$CONFIG" --stage "$STAGE" python -m landingzone.cli baseline --config "$CONFIG" --stage "$STAGE"set -euo pipefail三个开关缺一不可:-e让任何一步失败立即退出,-u让未定义变量报错而不是当空串,-o pipefail保证管道中间失败也算失败。没有-o pipefail的话,xxx | tee log这类写法会把失败吞掉,血泪经验。--stage plan在各阶段内部只做 describe 和 diff,不调任何 create/attach 接口,这是最安全的一档。
跑完 apply 之后,建议立刻跑一次--stage plan复验。如果还有 diff,说明有资源没做到幂等,通常是某个 create 返回后没记录状态,或者策略绑定用了追加而不是覆盖。这个自检习惯能提前发现大部分重复执行的坑。
3.4 验证:用测试断言代替肉眼检查
部署完不进控制台点,直接跑集成测试,断言的是「应该是什么」而不是「看起来像什么」:
# tests/integration/test_baseline.py import pytest from landingzone.auth import management_session @pytest.mark.integration def test_every_account_has_trail_logging(): session = management_session() org = session.client("organizations") accounts = [ a for page in org.get_paginator("list_accounts").paginate() for a in page["Accounts"] if a["Status"] == "ACTIVE" ] assert accounts, "组织里没有任何 ACTIVE 账号,检查凭据是不是管理账号" for acct in accounts: member = session.client("cloudtrail") trails = member.describe_trails(includeShadowTrails=True)["trailList"] logging = [t for t in trails if t.get("IsMultiRegionTrail")] assert logging, f"{acct['Id']} 缺少多区域跟踪"用 paginator 而不是裸调list_accounts,因为账号超过一页时裸调只返回前 20 个,测试会「假通过」。断言里先检查列表非空,是为了区分「真的都没配」和「根本没查到」这两种失败,否则凭据不对也会显示成测试通过。
4. 核心代码拆解:Python 侧的部署编排与策略校验
4.1 账号创建:把异步接口包成同步调用
Organizations 的create_account是异步的,返回的是请求 ID 而不是账号 ID,很多人在这里翻车——拿请求 ID 去当账号 ID 调move_account,报参数格式错误。正确做法是轮询创建状态:
# src/landingzone/org/accounts.py import time import boto3 from botocore.config import Config _client = boto3.client( "organizations", # 组织级接口限流较严,重试必须开,否则批量建号必挂 config=Config(retries={"max_attempts": 10, "mode": "standard"}), ) def create_account(email: str, name: str, role_name: str = "OrganizationAccountAccessRole") -> str: """创建账号并等待完成,返回真实的 account_id。""" resp = _client.create_account(Email=email, AccountName=name, RoleName=role_name) request_id = resp["CreateAccountStatus"]["Id"] deadline = time.time() + 600 # 建号通常几分钟,给 10 分钟上限 while time.time() < deadline: status = _client.describe_create_account_status( CreateAccountRequestId=request_id )["CreateAccountStatus"] if status["State"] == "SUCCEEDED": return status["AccountId"] # 只有 SUCCEEDED 才有 AccountId if status["State"] == "FAILED": raise RuntimeError(f"建号失败: {status.get('FailureReason')}") time.sleep(10) # 轮询间隔别太密,会触发限流 raise TimeoutError(f"建号超时: {request_id}")三个参数要注意:Email在组织内必须全局唯一,同一个邮箱重试会直接报ConstraintViolationException,所以上层调用前要先按邮箱查一遍;RoleName是成员账号里自动创建的管理角色名,改成自定义名字的话,后面 StackSet 下发时的执行角色要同步改,否则信任关系对不上;time.sleep(10)的间隔不是随便定的,5 秒以内容易在批量建号时撞上限流,30 秒又太慢拖长流水线。
上层再包一层幂等,先查后建:
def ensure_account(email: str, name: str, ou_id: str) -> str: """存在则复用,不存在才创建,最后确保挂在目标 OU。""" for page in _client.get_paginator("list_accounts").paginate(): for acct in page["Accounts"]: # list_accounts 不支持按邮箱过滤,只能客户端筛,必须翻页 if acct["Email"].lower() == email.lower(): _move_to_ou(acct["Id"], ou_id) return acct["Id"] account_id = create_account(email, name) _move_to_ou(account_id, ou_id) return account_id def _move_to_ou(account_id: str, ou_id: str) -> None: parents = _client.list_parents(ChildId=account_id)["Parents"] if any(p["Id"] == ou_id for p in parents): return # 已在目标 OU,跳过避免多余调用 _client.move_account(AccountId=account_id, SourceParentId=parents[0]["Id"], DestinationParentId=ou_id)list_parents这一步别省。move_account必须同时给源父节点和目标父节点,源节点猜错就报SourceParentNotFoundException。先查出真实父节点再搬,是唯一稳的写法。
4.2 护栏绑定:SCP 的幂等与继承规则
服务控制策略最容易被误用成「给账号打补丁」,其实它是自上而下继承的拒绝列表,挂在越高的层影响面越大。这份代码里策略文档单独放在 guardrails 目录,Python 只负责绑定和校验,不拼 JSON 字符串:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "DenyLeaveOrganization", "Effect": "Deny", "Action": ["organizations:LeaveOrganization"], "Resource": ["*"] }, { "Sid": "DenyRegionsOutsideAllowed", "Effect": "Deny", "NotAction": ["iam:*", "sts:*", "organizations:*", "cloudfront:*"], "Resource": ["*"], "Condition": { "StringNotEquals": {"aws:RequestedRegion": ["us-east-1", "ap-northeast-1"]} } } ] }第一条防止成员账号自己退出组织,这是所有 Landing Zone 的默认动作。第二条用NotAction而不是Action,因为要拦的是「除这些之外的所有服务在非允许区域的操作」,写成Action就得枚举上千个接口,永远漏。iam、sts、organizations这几个全局服务必须放进白名单,否则连角色都切不了,整个账号直接失联。
绑定逻辑本身很短,但幂等要做对:
# src/landingzone/org/policies.py def attach_policy(policy_id: str, target_id: str) -> None: """幂等绑定:已绑过就跳过,避免重复调用触发限流。""" targets = _client.list_targets_for_policy(PolicyId=policy_id)["Targets"] if any(t["TargetId"] == target_id for t in targets): return _client.attach_policy(PolicyId=policy_id, TargetId=target_id) def detach_policy(policy_id: str, target_id: str) -> None: """解除绑定前先确认存在,不存在就静默返回。""" targets = _client.list_targets_for_policy(PolicyId=policy_id)["Targets"] if not any(t["TargetId"] == target_id for t in targets): return _client.detach_policy(PolicyId=policy_id, TargetId=target_id)list_targets_for_policy默认只返回一页,策略绑了很多目标时要接 paginator,这点在测试里很容易漏。target_id可以是根、OU 或账号 ID,三种类型混传不会报错但影响面完全不同,建议在 CLI 层加一层校验,要求显式声明--target-type root|ou|account。
4.3 基线下发:StackSet 的参数注入与故障隔离
成员账号的基线资源走 StackSet,一次下发到整个 OU。关键在参数注入和失败策略:
# src/landingzone/organizations/stackset.py def deploy_baseline(session, stackset_name: str, ou_ids: list[str], template_url: str) -> str: cf = session.client("cloudformation") params = [ {"ParameterKey": "LogArchiveBucket", "ParameterValue": "lz-log-archive-<acct>"}, {"ParameterKey": "TrailRetentionDays", "ParameterValue": "365"}, ] kwargs = dict( StackSetName=stackset_name, TemplateURL=template_url, Parameters=params, Capabilities=["CAPABILITY_NAMED_IAM"], # 模板里建角色必须声明 PermissionModel="SERVICE_MANAGED", AutoDeployment={"Enabled": True, "RetainStacksOnAccountRemoval": False}, ) try: cf.create_stack_set(**kwargs) except cf.exceptions.NameAlreadyExistsException: # 已存在时只更新模板和参数,不动部署实例 cf.update_stack_set( StackSetName=stackset_name, TemplateURL=template_url, Parameters=params, Capabilities=["CAPABILITY_NAMED_IAM"], ) op = cf.create_stack_instances( StackSetName=stackset_name, DeploymentTargets={"OrganizationalUnitIds": ou_ids}, Regions=["us-east-1"], # 失败时保留已成功的实例,别整体回滚,否则排查现场就没了 OperationPreferences={"FailureTolerancePercentage": 10, "MaxConcurrentPercentage": 25}, ) return op["OperationId"]Capabilities=["CAPABILITY_NAMED_IAM"]漏了会直接报权限不足,而且报错信息指向模板行号,很容易误判成模板写错。AutoDeployment开启后,新账号进 OU 会自动拿到基线,这比定时轮询优雅得多。FailureTolerancePercentage设 10 是折中:一个 OU 里挂一两个账号不至于让整批停摆,但挂得多了会主动刹车,避免把错误配置推到全部账号。MaxConcurrentPercentage压到 25 是为了避开 CloudFormation 的并发限流,要快速跑完就调到 50,但批量建号场景下不建议。
4.4 测试:Stubber 打桩和真实环境的边界
单元测试不该碰云端,但也不能把 boto3 整体 mock 掉,那样连参数拼错都发现不了。这份代码用的是 botocore 的 Stubber,它校验请求参数、返回预设响应,参数名写错会直接抛异常:
# tests/unit/test_accounts.py import boto3 from botocore.stub import Stubber, ANY from landingzone.org import accounts def test_ensure_account_reuses_existing(monkeypatch): client = boto3.client("organizations", region_name="us-east-1") stubber = Stubber(client) # 第一页返回一个已存在的账号,邮箱大小写不同也要能匹配上 stubber.add_response( "list_accounts", {"Accounts": [{"Id": "111122223333", "Email": "Team-A@example.com", "Status": "ACTIVE"}]}, {"NextToken": ANY} if False else {}, ) stubber.add_response( "list_parents", {"Parents": [{"Id": "ou-root", "Type": "ROOT"}]}, {"ChildId": "111122223333"}, ) stubber.add_response( "move_account", {}, {"AccountId": "111122223333", "SourceParentId": "ou-root", "DestinationParentId": "ou-workloads"}, ) stubber.activate() monkeypatch.setattr(accounts, "_client", client) result = accounts.ensure_account("team-a@example.com", "team-a", "ou-workloads") assert result == "111122223333" # 复用了已有账号,没有走 create stubber.assert_no_pending_responses()assert_no_pending_responses()是这套测试的价值所在:只要代码少调一次或者多调一次接口,测试立刻失败。ANY用在不确定的参数上,比如分页 token,但核心的ChildId、AccountId一定要写死,否则等于没校验。monkeypatch.setattr替换模块级 client 而不是环境变量,这样并发跑测试也不会互相干扰。
集成测试是另一套逻辑,打真实账号但必须可回收:跑之前给测试账号打lz:ephemeral=true标签,跑完按标签扫描并清理。测试和生产的账号绝对不要共用一个,我见过在 prod OU 上跑集成测试直接把策略全解绑的。
5. 避坑与排查:五个最容易翻车的点
5.1 用长期密钥跑组织级接口,权限大且无法追溯
现象是脚本能跑通,但 CloudTrail 里所有操作都记在同一个 IAM 用户上,出了事分不清是谁触发的。原因很直接:组织级 API 只认管理账号,图省事就把管理账号的长期 AK 写进了 CI 变量。解决方式是全链路改成角色扮演,CI 侧只存一个可被信任的角色 ARN,运行时装临时凭据,RoleSessionName带上流水线 ID。同时把长期 AK 停用,用 CloudTrail 事件名 + 角色会话名做审计。这一步不做,后面所有自动化的合规性都站不住。
5.2 SCP 绑定层级错了,把自己锁在门外
现象是绑完策略后管理账号的运维人员也登不进去了,或者目标账号里连 CloudFormation 都调不动。原因是把限制性策略绑到了根节点,而策略里又没有排除organizations:*和sts:*这类全局服务。解决方案是先绑 Sandbox OU 验证,验证期策略里保留FullAWSAccess作为例外,确认无副作用再往上提。已经锁死的情况,必须用管理账号的根用户登录,先把策略解绑,这个兜底路径平时就要演练一遍,别等出事才找。
5.3 忽略接口分页,测试假通过、线上漏账号
现象是单元测试全绿,但线上总有那么几个账号没拿到基线。原因是list_accounts、list_targets_for_policy这些接口默认返回一页,账号超过 20 个后面的就丢了。解决办法统一换成get_paginator(...).paginate(),测试里构造带NextToken的两页响应做覆盖。判断标准很简单:凡是返回NextToken的接口,代码里就不该出现裸调。
5.4 建号后立刻操作,撞上异步未完成
现象是偶发AccountNotFoundException或者ConstraintViolationException,重跑一次又好了。这是典型的时序问题,create_account返回时账号只是个请求,OU 移动和策略绑定都得等状态变SUCCEEDED。解决是把轮询封成独立函数,超时给足,并且在move_account外面加一层退避重试。另外注意建号失败后同邮箱不能直接重试,得先清理失败记录或者换邮箱,这个坑排查起来极耗时。
5.5 区域假设不一致,资源和策略两套逻辑
现象是 SCP 限制了区域,但因为全局服务的处理不当,日志桶建在了被禁区域,或者 StackSet 实例建到了另一个 region 导致成员账号里找不到资源。原因是 SCP 的aws:RequestedRegion条件对全局服务的行为和普通服务不同,而代码里区域是散落在各处的字面量。解决办法是把允许区域收敛到配置文件里的一处,代码里只读不写;部署脚本显式传--region,禁止依赖默认值;集成测试里断言每个账号的日志桶区域与配置一致。
6. 二次开发:把它改成自己团队的基线
真正落地时,几乎没人会原样用示例里的 OU 结构,改造点集中在三处:config、guardrails、CLI 的 stage 划分。我的建议是先加一个新 OU 走通全链路,再动别的。具体做法是在 ous.yaml 里加一个节点,父节点指向 Workloads,然后写一个最小的集成测试,断言「新 OU 下的账号有 CloudTrail 且在允许区域内」。这个测试写完再跑 apply,如果失败,失败点一定在新增的那段逻辑里,排查面很窄。
护栏策略的改造要格外保守。加限制容易,收限制难。我的习惯是新增一条 Deny 之前,先在 Sandbox OU 挂 24 小时,用 Config 规则统计有多少调用会被拦,数据出来了再决定是否上提到 Workloads。策略文档里每条都要写Sid,Sid 是唯一的排查锚点,CloudTrail 的errorCode加上 Sid 基本能定位到是哪条规则拦的。
CLI 层的 stage 拆分是可以按团队习惯改的部分。如果团队已经有成熟的流水线,可以把org、guardrail、baseline三段映射成三个流水线阶段,每段单独审批。要注意的是段与段之间必须传递状态,比如org段产出的新建账号 ID 要落盘给baseline段用,别让后面一段重新查一遍——重查在生产环境里可能拿到不同的快照,导致基线漏下发。我一般落一个 JSON 到工作目录,内容包含账号 ID、目标 OU、执行时间戳。
验证方法我固定用三步:单元测试跑全绿(秒级)、plan 阶段 diff 为空、集成测试断言全过。三步都过才算改完,缺一步都不合入。有次图快跳过了 plan,结果一个detach_policy的 target 写成了根节点 ID,直接把整个组织的护栏解绑了十分钟——从那以后我每次改策略绑定相关代码,都强制走一遍 plan 加一条「解绑前后 target 列表必须一致」的断言,宁可多花五分钟。希望帮到你。
本文还有配套的精品资源,点击获取