简介:围绕AWS Landing Zone自动化与测试的代码示例和文档合集,面向需要搭建多账户治理体系的云架构师、运维工程师及安全合规人员。内容以Python为辅助工具,系统演示如何通过CloudFormation模板创建AWS组织与组织单元,配置Core账户,启用CloudTrail、Config、VPC流日志等安全检测,并设定IAM权限边界与SCP策略。压缩包共69个文件,核心类型为JSON策略文件、YAML的CloudFormation模板和Markdown说明文档,同时包含少量Ruby脚本、配置文件及PDF/Word文档,整体仅1.85MB,并按cloudformation-tools、test-environment-setup、organizations、security、accounts等模块清晰划分,便于按需查阅;其中README与build-out-tasks.md对测试环境准备和任务构建做了梳理。当前已有310人学习。通过该集合,读者可复现Landing Zone的完整创建流程,掌握create-cfn-stack与manage-orgs-ous-scps等工具脚本用法,获得ec2-policy、跨账户策略、ADFS联合访问配置等典型实现,并借助test-environment-setup模块快速搭建测试环境,减少多账户自动化实施中的踩坑风险,可作为企业云治理与安全基线改造的实用起步参考。
1. AWS Landing Zone 自动化测试的代码示例和文档,到底在解决谁的麻烦
假设你接手一个多账号 AWS 环境,十几个账号没有统一组织,权限靠人肉发,安全基线靠口头约定。这时要上一套 Landing Zone,传统做法是打开控制台点几十个页面,花两三天创建 OU 和 SCP,之后每加一个账号都像做手术。而“landing-zone 自动化测试的代码示例和文档”这类仓库,就是把这个过程沉淀成能提交、能评审、能回滚的代码,用自动化测试证明账号、策略、网络基线确实长成想的样子。我会沿着这个标题,把组件拆分、自动化选型、pytest 集成测试怎么写、CI 怎么接、以及那些测不出来的坑都讲清楚。适合正在用 Control Tower 或准备往 Terraform 迁的人,也适合想给现有 AWS 组织补一套自动化回归的人。
2. 先拆开 Landing Zone 的组件:不拆开就写不了自动化
2.1 Landing Zone 的组件地图:组织树、网络、日志、安全基线
Landing Zone 不是一个独立的 AWS 服务,而是一组跨账号治理规则的组合。很多团队上来就照着控制台点,等到想自动化的时候才发现,不知道从哪里下手。我一般先把环境拆成五个块:组织与账号、身份权限、网络、日志审计、安全防护。每一块在 AWS 上对应不同的资源,也决定后面测试要断言什么。
组织与账号这一层由 AWS Organizations 承载,核心资源是根、OU 和成员账号,以及挂在 OU 上的 SCP。身份权限通常走 IAM Identity Center,也就是传统意义上的 SSO,另外还要考虑账号内的 IAM role 和权限集。网络基线一般是 VPC、子网、Transit Gateway、路由表和安全组规则,Landing Zone 里最常见的是中心化出口和隔离环境。日志审计包括 CloudTrail、Config、S3 日志桶和 KMS 加密。安全防护则是 GuardDuty、Security Hub、Control Tower 的防护规则。这些块不是独立存在的,SCP 限制了账号行为,网络规则又依赖日志审计可观测。
如果要把 Landing Zone 自动化,你首先要把上面这些组件在代码里一一对应成可声明的资源。Terraform 里有对应的 resource,CloudFormation 里有资源类型,CDK 里有构造类。如果某一块没有对应资源,那它只能靠脚本或人工配置,这一块的测试就没法只用 IaC 断言,需要另外补集成测试。所以组件拆得越细,自动化和测试的边界就越清楚。
为什么不先从 VPC 和日志桶这类资源开始?因为 OU 和 SCP 是 Landing Zone 的骨架,网络和日志都可以后补,但账号一旦在错误的 OU 里跑一段时间,再迁移会很麻烦。SCP 是账号权限的唯一上限,它决定了子账号能不能碰某些服务。先管住骨架,再往上面长肉,这是我做了几次 Landing Zone 自动化之后最深的感受。
2.2 自动化选型:Control Tower、Terraform、CDK 各自适合哪一层
自动化 Landing Zone 的常见路线有三条:Control Tower 原生托管,Terraform 完全自定义,以及 AWS CDK 与 CloudFormation 结合。选型时最重要的问题不是“哪个工具更好”,而是“谁来做权威源”。Control Tower 是 AWS 官方 Landing Zone 产品,它把组织管理、账号工厂、防护规则做成托管服务,很省事,但很多细节不能直接改。Terraform 的 aws_organizations 和 aws_controltower 给出来的控制力强,能精确管理 OU、SCP、标签策略,但和 Control Tower 混用时容易产生漂移,这是后面要重点防的。
我一般在团队里这样推荐:如果是第一次上云、没有历史包袱,直接用 Control Tower,再用 CloudFormation 或 Terraform 只管理 Control Tower 不覆盖的自定义资源。如果已经有一套多账号环境,而且管理得很乱,考虑 Terraform 完全接管组织,但做好把历史账号纳管的方式。CDK 更适合那些想把 Landing Zone 和业务应用放在同一个 TypeScript 或 Python 代码库里维护的团队,但它本质上还是 CloudFormation,测试上可以用 pytest 写 Lambda 测试和云资源断言。
下面用一个表格把三条路线的边界说清楚:
| 维度 | Control Tower 托管 | Terraform 自定义 | AWS CDK + CloudFormation |
|---|---|---|---|
| 账号开通 | Account Factory,界面和 API | Organizations + 自定义基线 | ServiceCatalog / Custom Resources |
| SCP/OU 管理 | 托管的 OU 不能轻易移动 | 完全声明式,可纳入代码评审 | CloudFormation 资源,能力有限 |
| 漂移风险 | 低,但自定义资源要小心 | 高,尤其和 Control Tower 混合时 | 中,依赖对 CloudFormation 的控制 |
| 测试友好度 | 需要等 Control Tower 同步 | tflint/checkov/pytest 链路成熟 | cfn-lint/ Jest/pytest 都可以 |
| 谁适合做 | 想快速落地、接受 AWS 托管限制的人 | 需要精细治理和有平台工程团队的团队 | 习惯用代码定义基础设施的团队 |
选型之后还要注意:不管你选哪条线,都要在 README 里明确写清“权威源是哪个”。否则大家看到控制台上能改,就直接控制台改,代码仓库里 plan 出来的差异会一天比一天大。这个我在后面避坑章会再展开。
2.3 最小自动化示例:用 Terraform 把 OU 和 SCP 先管起来
先别急着写一整层 Landing Zone,从一个最小的组织结构开始。下面这份 HCL 示例作用是把组织启用到 ALL 模式,创建一个 workloads OU,再挂一条禁止删除 CloudTrail 的 SCP。
# ous.tf resource "aws_organizations_organization" "this" { feature_set = "ALL" enabled_policy_types = ["SERVICE_CONTROL_POLICY"] } resource "aws_organizations_organizational_unit" "workloads" { name = "workloads" parent_id = aws_organizations_organization.this.roots[0].id }# scp.tf resource "aws_organizations_policy" "deny_cloudtrail_deletion" { name = "deny-cloudtrail-deletion" content = <<JSON { "Version": "2012-10-17", "Statement": [ { "Effect": "Deny", "Action": "cloudtrail:DeleteTrail", "Resource": "*" } ] } JSON } resource "aws_organizations_policy_attachment" "workloads_scp" { policy_id = aws_organizations_policy.deny_cloudtrail_deletion.id target_id = aws_organizations_organizational_unit.workloads.id }这段代码的逻辑很简单:aws_organizations_organization 第一次会在你的 AWS 账号里创建一个组织,feature_set 必须是 ALL,否则 SCP 的开关不会打开。组织创建后,可以在根下建 workloads OU。SCP 的 content 是一个 JSON 字符串,里面是一条 Deny 规则,删除 CloudTrail 的行为会被拒绝。aws_organizations_policy_attachment 把这条策略挂到 workloads OU 上,之后该 OU 下的所有账号都会继承这条限制。
参数说明:enabled_policy_types 这里只声明了 SERVICE_CONTROL_POLICY,如果你以后要开标签策略或备份策略,可以并行加别的类型。parent_id 用 roots[0].id 是常见写法,因为一个组织只有一个根。SCP 的 content 要严格符合 IAM policy 格式,少一个逗号会导致 terraform plan 通过但 apply 时被 AWS 拒绝。另外,如果这个组织已经由 Control Tower 创建,aws_organizations_organization 只能作为 data source 来读,不能再用 resource 去接管,否则会得到“organization already exists”之类的冲突错误。
在跑 apply 之前,我一般会把当前身份先确认一遍,避免在错误的环境里改组织:
aws sts get-caller-identity terraform plan -out=plan.tfplan第一行命令输出当前身份。如果是个人本机上的临时凭证,要再确认一下对应的是管理账号还是某个子账号;很多事故都来自用子账号凭证去跑 org 资源,结果权限不足,或者更糟的是用管理员凭证把生产组织整个重写了。terraform plan 出 plan 文件后,建议在 CI 里做一次人工 review,再 apply。不是所有的 Landing Zone 都适合无人值守 apply。
2.4 文档先行:把架构决策记录放进同一个仓库
自动化代码只是 Landing Zone 的一部分,还有一个容易被忽略的东西是文档。代码示例和文档放在同一个仓库里,才让这个标题真正落地。我见过太多团队只推代码,结果没人敢改,因为不知道为什么要用 Terraform 拿下整个组织,或者为什么某些 OU 不能动。
常见的落地做法是在仓库里放三份文件:README 说明部署流程和测试命令,ADR(架构决策记录)记录每次技术选型的原因,runbook 描述日常变更的检查清单。ADR 不一定要很复杂,每条几段话就够了,比如“为什么选择 Control Tower 而不是 Terraform 管理组织”,把时间和背景写清楚。runbook 可以写“新增账号后,必须先等状态 ACTIVE,再挂 SCP,最后在 pytest 里执行 test_ou_active”。这样,代码示例是文档的补充,文档是代码的说明书,而不是又一份没人看的 Markdown。
测试的目的也因此变得清晰:不是为了让 CI 变绿,而是验证当前 AWS 环境是否符合文档里描述的架构。如果文档说 SCP 挂在 workloads 上,测试就会去查;如果文档说日志桶必须加密,测试也会去查。文档写不全的自动化是裸奔的自动化,回归跑几次可能就把问题掩盖了。所以第二章先把文档和代码的绑定关系立住,后面第四章写的测试用例才有依据。
3. 把账号和基线代码化:从最小示例到可复制的 Landing Zone
3.1 用 Control Tower Account Factory,还是直接调 Organizations API
管住 OU 和 SCP 之后,下一个问题就是账号怎么创建。常见做法有两种:走 Control Tower 的 Account Factory,或者直接调 AWS Organizations 的 CreateAccount API。两者的差异不只是代码写法,而是账号是否进入 Control Tower 的托管范围。
Account Factory 本质上是 Service Catalog 产品,Terraform 里用 aws_servicecatalog_provisioned_product 来调用。它创建的账号会带着 Control Tower 的标签、防护规则和基线配置,后续要关停账号也用同一个产品,生命周期由 Control Tower 管理。缺点是预置慢,通常要 10 到 15 分钟,而且如果 Control Tower 版本升级,账号工厂的中间状态会变长。直接调 Organizations API 则快得多,Terraform 的 aws_organizations_account 几分钟就能把一个账号建出来,但它不会自动被 Control Tower 纳管。如果你之后想在 Control Tower 页面统一看这些账号,还得手动注册,这个过程容易出边界问题。
我的建议是:环境里已经有 Control Tower,就优先走 Account Factory。你得到的不是“快”,而是“统一”。如果整套 Landing Zone 全是 Terraform 自己组织、自己管 SCP、自己投递基线,那就用 aws_organizations_account,然后在账号创建后立刻用一套配置脚本把 CloudTrail、Config、GuardDuty 的委派管理建好。两种方式都能自动化,但自动化不等于纳管,这一点要先想清楚。
3.2 最小账号创建示例:Account Factory 和 Organizations 两种写法
先看 Account Factory 的写法。下面的 HCL 会在 workloads 这个 OU 下预置一个名为 production 的账号:
# account_factory.tf resource "aws_servicecatalog_provisioned_product" "production" { name = "prod-account" product_name = "AWS Control Tower Account Factory" provisioning_artifact_name = "latest" provisioning_parameters { key = "AccountName" value = "production" } provisioning_parameters { key = "AccountEmail" value = "aws-production@example.com" } provisioning_parameters { key = "SSOUserEmail" value = "admin@example.com" } provisioning_parameters { key = "SSOUserFirstName" value = "Admin" } provisioning_parameters { key = "SSOUserLastName" value = "User" } create_timeout = "15m" update_timeout = "15m" }关键参数是 AccountName 和 AccountEmail,前者显示在 Organizations 控制台里,后者是 AWS 官方发通知的邮箱。SSOUser 这三个参数会在 IAM Identity Center 里创建一个初始用户,方便管理员第一时间登录。create_timeout 建议设成 15 分钟,因为账号工厂经常跑到接近十分钟,默认超时会让你误以为创建失败。
如果不用 Control Tower,直接调用 Organizations 的写法会更轻:
# account.tf resource "aws_organizations_account" "production" { name = "production" email = "production@example.com" parent_id = aws_organizations_organizational_unit.workloads.id }这段代码会创建一个独立的成员账号,并把它放进 workloads OU。注意 aws_organizations_account 创建后,你只能通过注册邮箱拿密码,而不是像 IAM 用户一样在控制台里直接能看到。如果事后想改邮件地址,很麻烦,几乎等于重新建号。所以 email 参数一定要核对三遍再 apply。
无论用哪种方式,创建完账号后都不要立刻做下一个步骤。账号在 Organizations 里的状态会经历 CREATING、ACTIVE 和 SUSPENDED 等阶段。只有 ACTIVE 状态才能正常挂 SCP、放资源。CI 里一般要轮询:
aws organizations list-accounts --query "Accounts[?Name=='production'].{Id:Id,Status:Status}" --output table这个命令展示账号当前状态。建议在 CI 里做一个最长等待 15 分钟的重试循环,等 Status 变为 ACTIVE 再继续跑测试。
3.3 参数设计:账号命名、邮箱、权限集和关闭保护
账号创建里最容易出问题的不是 IaC 代码,而是参数体系。我从几个项目里沉淀出如下推荐值:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 账号命名 | 环境-项目,如 prod-billing | 避免空格和大小写混用,后续标签和 SCP 条件都要靠它 |
| 账号邮箱 | aws- @ | 不要用个人邮箱;邮箱创建后不可变,用团队共享邮箱更稳妥 |
| SSOUserEmail | 负责人企业邮箱或团队邮箱 | 如果为空可能无法完成首次登录 |
| 权限集 | PowerUserAccess / ViewOnlyAccess | 不要给每个账号配 Admin,除非是隔离治理的沙箱 |
| 关闭保护 | 根账号开启 MFA 和关闭保护 | 测试账号建议进入生命周期关闭,不要依赖控制台手动删除 |
其中一个经验是:账号邮箱务必做成可配置项,而不是在 HCL 里写死。每个环境的地域或公司域名可能不同,写死在仓库里会让测试环境和管理环境互相污染。常见的做法是在 terraform.tfvars 里统一声明账号邮箱后缀,再通过变量拼接。
3.4 第一次创建账号后必须核对的状态
代码跑完不代表账号可用,我一般会按下面的顺序核对账号是否真正“入队”:
- 账号状态是不是 ACTIVE。
- 账号是不是挂在预期的 OU 下,而不是落在根下。
- workloads OU 上的 SCP 是否对账号生效。
- IAM Identity Center 里是否出现了对应权限集。
- 账号是否能通过 cross-account role 访问,而不是只能从控制台临时登录。
前两项可以通过 Organizations API 查,第三项建议真实验证一次。怎么验证?在刚创建好的账号里跑一条会被 SCP 拒绝的操作,比如尝试删除 CloudTrail。如果返回 AccessDenied,说明 SCP 真的生效了。很多团队只检查 SCP 是否“附加”在 OU 上,却忽略了继承和显式例外,所以行为验证比状态查询更可靠。这个思路在第四章做成自动化测试。
4. 用 pytest + boto3 给 Landing Zone 写集成测试:从检查 SCP 到验证网络基线
4.1 测试目标分层:静态检查、状态断言、行为验证
Landing Zone 的代码示例跑通之后,如果没有测试,你只能靠控制台截图证明环境没问题。自动化测试应该分三层:静态检查、状态断言和行为验证。
静态检查在 apply 之前做,比如 terraform validate、tflint、checkov、cfn-lint,它们主要查语法、权限配置风险和资源之间的引用是否正确。状态断言是集成测试的主菜,用 boto3 读取 AWS 当前的资源状态,断言 OU 存在、SCP 已挂、日志桶有加密、权限集名称符合规范。行为验证最真实,但也最危险,常见做法是在沙箱账号里故意触发一个 SCP 限制操作,期望 AWS 返回 AccessDenied。
我给团队定的规矩是:静态检查在本地和 CI 的 push 阶段跑,状态断言在 pull request 和定时任务里跑,行为验证只允许在专门的沙箱 OU 里跑。分层的好处是,如果行为验证导致某个资源受损,影响范围被限制在沙箱账号,不会波及生产。
4.2 最小 pytest 集成测试:检查 OU、SCP 和加密日志桶
下面是一份可以直接放到 tests/ 目录下的 pytest 文件,它用 boto3 读取 Landing Zone 的真实状态。
# tests/test_landing_zone.py import boto3 import pytest PRODUCTION_OU_NAME = "workloads" SCP_NAME = "deny-cloudtrail-deletion" LOG_BUCKET = "aws-landing-zone-logs" @pytest.fixture(scope="module") def org_client(): return boto3.client("organizations") @pytest.fixture(scope="module") def s3_client(): return boto3.client("s3") def test_production_ou_exists(org_client): roots = org_client.list_roots()["Roots"] all_ous = [] for root in roots: ous = org_client.list_organizational_units_for_parent( ParentId=root["Id"] )["OrganizationalUnits"] all_ous.extend(ous) assert any(ou["Name"] == PRODUCTION_OU_NAME for ou in all_ous) def test_scp_attached_to_workloads(org_client): roots = org_client.list_roots()["Roots"] ous = org_client.list_organizational_units_for_parent( ParentId=roots[0]["Id"] )["OrganizationalUnits"] workloads_id = next(ou["Id"] for ou in ous if ou["Name"] == PRODUCTION_OU_NAME) policies = org_client.list_policies_for_target( TargetId=workloads_id, Filter="SERVICE_CONTROL_POLICY", )["Policies"] assert any(p["Name"] == SCP_NAME for p in policies) def test_log_bucket_encrypted(s3_client): response = s3_client.get_bucket_encryption(Bucket=LOG_BUCKET) rules = response["ServerSideEncryptionConfiguration"]["Rules"] assert len(rules) > 0三个测试分别验证了 OU 存在、SCP 已附加、日志桶加密。fixture 的 scope 是 module,所以整个测试模块只初始化一次 boto3 client,跑起来会快很多。断言用名称而不是 ID,因为 OU ID 和账号 ID 在跨环境时完全不同,名称一般是稳定的。
参数说明:test_scp_attached_to_workloads 里用 list_policies_for_target 只看直接附加在 OU 上的策略。如果 SCP 是从父 OU 继承的,这个接口看不到,需要再查父链路。test_log_bucket_encrypted 假设你有权限读取加密配置。如果 CI 角色只给了组织读权限,这个测试会在 S3 的 GetBucketEncryption 上报 AccessDenied。此时不要直接删断言,而是给 IAM 角色补上对应权限,或者把测试标记为需要特定权限集。
4.3 把 pytest 接进 CI:CodeBuild 和 GitHub Actions 的最小配置
有了 pytest 脚本,下一步是让它每次 PR 或定时自动跑。下面是一份 CodeBuild 的最小 buildspec:
# buildspec.yml version: 0.2 phases: install: runtime-versions: python: 3.11 commands: - pip install -r tests/requirements.txt build: commands: - pytest tests/ -m integration --junitxml=report.xml post_build: commands: - ls -la report.xmlrequirements.txt 里只需要 boto3、pytest 和 python-dotenv。CodeBuild 的 service role 要允许 Organizations 和 S3 的只读权限。注意我是用-m integration来区分集成测试,这就要求测试文件里给用例打上@pytest.mark.integration的标记。否则 CI 会把你本地开发中的单元测试也一起跑掉。
如果是 GitHub Actions,常见做法是用 OIDC 身份联邦,不在仓库里存长期密钥:
# .github/workflows/landing-zone-test.yml name: landing-zone-test on: pull_request: paths: ["*.tf", "*.py"] schedule: - cron: "0 2 * * 0" jobs: integration: runs-on: ubuntu-latest permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" - run: pip install -r tests/requirements.txt - run: pytest tests/ -m integration env: AWS_REGION: us-east-1这段配置的要点:只有 pulll request 改动.tf或.py文件时才触发,另外每天凌晨 2 点定时跑一次。schedule 的目的是漂移检测,因为 Landing Zone 可能被控制台手动改掉,定时任务会把这种漂移暴露出来。OIDC 的身份配置需要提前在 AWS 里建立一个 IdP,并让 GitHub Actions 的 role 只包含 testing 所需的权限。别忘了在运行 pytest 时通过环境变量注入AWS_REGION。
4.4 测试夹具与清理:固定沙箱账号,不做每次新建
集成测试最容易踩的坑是每次 CI 都创建新账号。上一章讲了账号配额,这里说清理逻辑。测试夹具里不要用“创建账号”作为第一步,而应尽量用现有账号。如果你的 Landing Zone 里没有沙箱账号,就在一个单独的 OU 下创建并留作长期测试专用,测试结束后不删除,而是恢复到基线状态。
行为验证示例:
# tests/test_sandbox_scp.py import boto3 import pytest from botocore.exceptions import ClientError SANDBOX_ACCOUNT_ID = "123456789012" SANDBOX_ROLE = "arn:aws:iam::123456789012:role/LandingZoneTestRole" @pytest.fixture(scope="module") def sandbox_session(): sts = boto3.client("sts") assumed = sts.assume_role( RoleArn=SANDBOX_ROLE, RoleSessionName="landing-zone-test", )["Credentials"] return boto3.Session( aws_access_key_id=assumed["AccessKeyId"], aws_secret_access_key=assumed["SecretAccessKey"], aws_session_token=assumed["SessionToken"], ) def test_deny_cloudtrail_deletion_in_sandbox(sandbox_session): client = sandbox_session.client("cloudtrail") with pytest.raises(ClientError) as exc_info: client.delete_trail(Name="landing-zone-test-trail") assert exc_info.value.response["Error"]["Code"] == "AccessDenied"这个测试故意触发一次删除 CloudTrail 的请求,预期是 AccessDenied。sandbox_session fixture 通过 sts.assume_role 拿到沙箱账号的临时凭证。RoleArn 是预先在沙箱账号里创建好的,只在测试账号里存在,不影响其它账号。如果 SCP 没有生效,delete_trail 会成功,测试会失败,这正好暴露问题。要注意,这里真的会调用 API,虽然被拒绝,但会产生 CloudTrail 事件,所以沙箱账号越干净,测试结果越可信。
4.5 文档里怎么写测试命令和范围
测试代码和 CI 配好之后,仓库里的 docs/tests.md 要能直接回答三个问题:怎么跑、哪些能跑、失败看哪里。我习惯用一个表格列清楚:
| 测试类型 | 命令 | 需要凭证 | 适合环境 |
|---|---|---|---|
| 静态检查 | terraform validate && checkov -d . | 不需要 | 本地/CI push |
| 集成只读断言 | pytest tests/ -m integration | Organizations/S3 只读 | PR / 定时 |
| 行为验证 | pytest tests/ -m sandbox | 沙箱账号 cross-account role | 单独 job |
这个表格同时也决定了 CI 流水线的 stage。比如 push 阶段只跑静态检查,pull request 阶段跑只读断言,定时任务跑行为验证。文档是代码示例的一部分,没有文档的测试将来只会变成一堆没人敢动的脚本。
5. Landing Zone 自动化和测试避坑指南:现象、原因、解决
5.1 Control Tower 和 Terraform 互相接管组织资源,漂移检测天天报警
现象:Terraform 代码把 OU 和 SCP 都声明成 resource 之后,apply 成功,但 Control Tower 控制台开始显示组织或者 OU “drifted”,一些账号上的 GuardRail 状态变成“非强制”。Terraform plan 每次都提示要重建同一个 OU。
原因:Control Tower 本身在后台用 CloudFormation 管理 OU 和 SCP,它会维护自己的资源生命周期。你在 Terraform 里用 resource 强行接管同一个 OU,其实有两个权威源在抢同一块地盘。Terraform apply 后 Control Tower 认为这超出了它的预期配置,于是把状态标成漂移。
解决:区分“托管资源”和“自定义资源”。凡是 Control Tower 默认管理的 OU,比如根下的 Core、Security 和 Sandbox,Terraform 里用 data 读取而不是 resource。自定义的 OU,比如 workloads,如果不在 Control Tower 的默认 OU 清单里,可以用 resource。但挂 SCP 时要注意,不要动 Control Tower 自动创建的 FullAccess 或 GuardRail 策略。代码注释里一定要写明“该资源由 Control Tower 托管,不要改成 resource”,不然下一个接手的人会照着错误的习惯改回去。
5.2 测试 SCP 时用了管理账号,永远测不出策略生效
现象:SCP 明明写着 Deny cloudtrail:DeleteTrail,但在某个账号里手动删 CloudTrail 还是成功,自动化测试也一直通过。
原因:SCP 不影响组织的管理账号,也不影响某些处于异常状态的账号。如果 pytest 里的 boto3 凭证属于组织管理账号的 role,那这条行为验证就是自欺欺人。管理账号相当于组织里的“超级管理员”,SCP 对它是放行的。
解决:行为验证必须用成员账号的临时凭证。方法是在测试 fixture 里通过 sts.assume_role 切到某个专门负责测试的成员账号,role 的信任策略只允许组织里的测试执行角色来 assume。另外,有的团队会用根用户证书去测试,这更要避免。正确的流程是:测试 execute role 先切到一个低权限 Ops 账户,再从这个账户的 role 跳入沙箱账号,全程无长期密钥。
5.3 每次测试都新建账号,最终撞上账号配额
现象:CI 跑了一两个月后,某天 CreateAccount 开始报AccountLimitExceededException,控制台里一堆SUSPENDED账号躺着,但测试还在继续创建。
原因:AWS 组织的账号配额不是按当前活跃账号数算的,而是包含历史创建但还未完全删除的账号。删除账号后,很多状态只是进入关闭流程,要等几周到几个月才能彻底释放。每次测试都新建账号,等于把配额当成能无限刷的沙盒。
解决:测试环境固定用 1 到 2 个沙箱账号,不要新建。如果一定要验证新账号的初始化流程,也只在 nightly 任务里做,并设置一个“当日失败自动清理”的步骤。配额监控可以用 CloudWatch Metric Filter 读取 AccountCreate 事件,低于 15 时给运维发通知。教训是:测试代码里的账号创建逻辑要尽量少触发,触发了就当需要人工介入的信号。
5.4 pytest 里写死了 OU ID / 账号 ID,换个环境全红
现象:同一份测试代码,在开发环境的 Landing Zone 里全部通过,在预生产或生产环境的 AWS 组织里跑,test_prod_ou 直接失败,日志显示找不到关联的 OU。
原因:代码里把ou-xyz、123456789012这类 ID 写死在断言里。不同账号的 OU ID 完全随机,生产环境的账号 ID 也和开发不同。开发环境绿,生产环境红,这种测试没人敢信。
解决:统一用名字和标签作为稳定标识。举个例子,测试里先按名称列出当前环境的 OU,再用.get取 ID,最后断言该 ID 不为空。账号 ID 的硬编码可以放到环境变量或一个 mapping 文件里,不同环境用同一个 pytest 代码,只是变量不同。另外,conftest.py 里可以做一个缓存函数,把 OU 名称到 ID 的映射统一获取一次,避免每个测试都去调一次 Organizations API。
5.5 文档、代码、实际环境三者不同步,示例代码半个月就废
现象:README 里写的部署命令和代码示例跑不通,有人按照示例去执行,得到一堆资源不存在的报错,最后对仓库失去信任。
原因:Landing Zone 是持续演进的。OU 可能从 workloads 改成了 workloads-stage,SCP 可能从 deny-cloudtrail-deletion 改成了 deny-security-log-disruption,但文档里的示例还停留在最初版本。代码本身可能改了,注释和 README 没人同步。
解决:把文档纳入 CI,而不是当作可有可无的附赠品。一个轻量做法是写一个 make doc-check,用脚本抓取 README 里的示例命令,逐条跑一遍--dry-run。更严格的做法是在 PR 模板里加一个勾选项“本条改动是否影响 docs/ 下的内容”。我们团队现在把 ADR 和测试命令放在同一个目录,凡是代码变更没同步文档,CI 里的链接检查就会提示。这样文档不一定是完美的,但至少不会比代码晚太多。
6. 进阶:把 Landing Zone 测试做成一套能反复跑的基线
6.1 让测试结果从 CI 走到云上,变成持续合规信号
pytest 在本地跑通只是第一步。我见过不少团队把测试配置好之后,就只在出事故时跑一次,这样的自动化等于没有。要让它成为基线,把测试结果沉淀到云上更有意义。常见做法是在 EventBridge 里配一个每天凌晨触发的规则,启动 CodeBuild 跑上一章说的集成测试,测试报告输出到 S3 和 CloudWatch Logs。如果断言失败,通过 SNS 把消息发给平台团队。这一套不需要额外容器,控制塔本就支持 CloudFormation StackSet,或者用 Terraform 部署也很方便。
6.2 固定沙箱账号的复用小脚本
为了避免账号配额问题,我更喜欢用一个脚本从现有账号里挑出沙箱,再复用。下面这段 Python 可以放在 scripts/ 下:
# scripts/pick_sandbox.py import boto3 client = boto3.client("organizations") sandbox_ou_name = "sandbox" account_id = None root = client.list_roots()["Roots"][0] ous = client.list_organizational_units_for_parent(ParentId=root["Id"])["OrganizationalUnits"] sandbox_ou = next(ou for ou in ous if ou["Name"] == sandbox_ou_name) accounts = client.list_accounts_for_parent(ParentId=sandbox_ou["Id"])["Accounts"] for acc in accounts: if acc["Status"] == "ACTIVE": account_id = acc["Id"] break print(account_id)脚本先找到 sandbox OU,再挑第一个 Active 账号。打印出来的 ID 可以直接作为 5.4 里提到的时间参数,或者塞进 pytest 的--account-id参数。这样做的好处是测试不再依赖硬编码,每次自动找到当前环境的沙箱账号。
6.3 我每次改完 Landing Zone 都会做的三件事
改了 SCP 之后,我一定会在沙箱账号里先跑行为验证,确认拒绝生效,再合入代码。改了 OU 名称或层级后,我会把 docs/ADR 里的记录翻出来,看有没有过时。这两个动作做多了,会变成条件反射。还有一件事是查看 CloudTrail 里有没有其它人直接用控制台动过组织资源。我常跑一条命令:
aws cloudtrail lookup-events \ --lookup-attributes AttributeKey=EventName,AttributeValue=UpdateOrganizationalUnit \ --query "Events[].CloudTrailEvent" \ --output json这条命令列出组织更新的历史,如果看到非计划内的改动,就知道文档和现实之间已经出现了偏差。Landing Zone 这类系统,最大的风险不是自动化代码写错,而是文档、代码、现实各说各话。希望这套落地思路能帮你把这三者重新拉回一条线上。希望帮到你。
本文还有配套的精品资源,点击获取