简介:面向AWS架构师与DevOps工程师,针对Landing Zone自动化与测试的代码示例和文档,聚焦组织与组织单元创建、AWS Core账户构建、测试环境设置等关键环节,适合需要以基础设施即代码方式搭建多账户环境的中高级实践者。资料共69个文件、1.85MB,其中JSON文件用于策略与参数定义,Markdown提供说明文档,YAML为CloudFormation模板,Ruby脚本承担自动化校验,另含少量PDF/DOCX补充说明,覆盖CloudFormation工具、VPC多层级架构、组织OU与SCP管理、安全审计规则等场景。已有310人学习。具体可获取环境搭建指南、账户与权限管理脚本、CloudTrail/Config/VPC Flow Logs安全基线配置、示例工作负载,以及目录结构清晰的组织管理实例,能帮助读者快速构建可复用的Landing Zone测试环境,并理解从组织创建到策略落地的完整自动化流程。
1. Landing Zone 自动化与测试:先想清楚这三个问题,再动手写代码
刚接手 Landing Zone 自动化的时候,我最怕的不是写代码,而是打开控制台发现某环境里多了一个不知道谁建的账号。Landing Zone 不是一件搭完就能交付的静态产物,它更像一条长期维护的治理流水线。这个标题拆开看,核心是两件事:用自动化代码去创建和维护云上多账号环境的基线,再用测试证明基线没有被偷偷改过;文档则是把“为什么这么搭”记录下来,让后来的人能照着改,而不是靠截图和记忆考古。适合正在做多账号治理的平台工程师、DevOps 和云架构师,也适合刚接手存量 Landing Zone、想把手工维护转成代码化的人。如果你只想得到一个“能跑就行”的脚本,那这里更想讲清楚背后接得住变化的那套逻辑。
2. Landing Zone 在自动化里的角色:一套带状态的合规基座,而不是普通应用
2.1 为什么 Landing Zone 必须自动化:手工建账号的隐性成本
Landing Zone 通常包含组织层级(根、OU、账号)、服务控制策略(SCP)、标签策略、日志归档、身份权限和审计规则。这些资源彼此依赖,而且账号一旦创建就很难直接删除,改起来也常常影响一大片。手工点控制台在前几个账号时并不慢,真正慢的是半年后:你不知道某个 SCP 为什么拒绝了一项操作,也不知道账号 A 为什么从测试 OU 被挪到了生产 OU。审计要你提供变更记录,控制台给出的却只是“未知操作者”。隐性成本表现在返工、排查和不信任:每次有人改动基线,其他人都不敢确定是不是安全修改。
把这些操作交给代码后,最大的变化不是节省了几次点击,而是每一次变更都变成可评审的 diff。代码仓库里能看到谁改了、改了什么、为什么改(commit message)。配合 CI 的 plan 阶段,还能在真正改动前看到资源将如何变化。这就是 Landing Zone 自动化的核心价值:环境状态不再是一个黑匣子,而是可以被完整解释和复现的声明。
所谓“声明”,指的是你只写下目标状态,比如“生产 OU 下有三个账号,每个账号都要有日志桶”,然后由自动化工具去计算差异并执行。这个思路听起来简单,但落地时有一个坑:Landing Zone 是强状态依赖的。创建 OU 要在组织根存在之后,创建账号要等邮箱确认,SCP 要挂到 OU 上。如果用手写脚本串这些步骤,很容易因为缺了依赖而半路失败。这也是为什么大多数落地实践会选 IaC 工具,因为它天然会处理依赖顺序。
另外还要认清一件事:普通应用服务可以随手删了重建,但组织根和账号不行。账号一旦和外部身份源、日志归档绑定,删除会把历史审计数据一并带走。所以 Landing Zone 的自动化代码天生是“低销毁、高变更风险”的。这意味着你在写代码时就要留好回滚设计,而不能像对待一个普通接口那样只关心正向路径。
2.2 自动化的两条路线:IaC 编排 vs 独立管道,我为什么选前者
常见做法是分两条路线:一种是用 IaC 工具(比如 Terraform)直接管理 Landing Zone 资源;另一种是自己写一套独立管道,用云厂商的 API 或 CLI 把“建账号、建 OU、打标签、配日志”串起来。两种路线各有适用场景,我一般会优先选 IaC,而不是自己造管道。
IaC 的优势是状态文件和计划。工具会记住上次应用后的资源状态,下次执行时逐项比对,给你一份“要新增、要修改、要删除”的清单。这个能力是做 Landing Zone 自动化的关键,因为账号和策略的变更如果不可预测,测试就无从谈起。独立管道的灵活性强,能写重试、补偿和并发控制,对存量环境、一次性迁移很友好;但你需要自己维护状态记录和幂等性,否则跑一次和跑两次的结果可能不一样,很难说清楚环境当前到底处于什么状态。
如果你的 Landing Zone 还在图纸阶段,或者可以用 import 把存量资源纳入 IaC,我建议直接走 IaC 路线。如果你的环境已经手工长了三年、资源凌乱,我建议先用独立管道做一批清理任务,把组织层级收敛到你认为干净的状态,再反过来用 IaC 接管。两条路线不是互斥的,落到代码上,都是同一种模式:定义目标状态 -> 计算差异 -> 执行 -> 记录结果。
我见过不少团队自己写管道,第一版两三百行没问题,半年后要加一个 OU 批量导入,发现代码里全是初始化函数和异常重试,状态还得额外存一份。不是说独立管道不行,而是你需要为此投入更多时间维护状态机。如果你已经有 IaC 工具可用,我更推荐把状态管理交给工具,把精力花在测试和文档上。
2.3 用代码描述 Landing Zone 的最小骨架:HCL 示例与参数说明
这里给一个结构示意,它不是某个厂商官方 SDK 的完整产物,而是一个能表达依赖关系的骨架。为便于阅读,我用cloud_前缀代指云厂商 Terraform Provider 的资源;实际替换成你所用云厂商的官方资源名即可,目录结构和组织逻辑不变。
# 演示用骨架,不是可直接运行的完整配置。 # 使用前请将 cloud_ 前缀替换为所用云厂商 Terraform Provider 的资源前缀。 terraform { required_providers { cloud = { source = "<所用云厂商的官方 provider 源>" version = "~> 5.0" } } } provider "cloud" { region = "<你的主区域>" } # 1. 先建组织根,feature_set 决定是否启用全部治理功能 resource "cloud_organizations_organization" "root" { feature_set = "ALL" # 显式声明策略类型,避免后续启用 SCP 时产生不可预期的批量变更 enabled_policy_types = ["SERVICE_CONTROL_POLICY", "TAG_POLICY"] } # 2. 建 OU,parent_id 指向组织根的 roots[0].id resource "cloud_organizations_organizational_unit" "prod" { name = "Prod" parent_id = cloud_organizations_organization.root.roots[0].id } # 3. 在 Prod OU 下建账号;email 必须全局唯一,且不能是已存在的账号 resource "cloud_organizations_account" "prod_log" { name = "prod-log" email = "<你的通知邮箱>" parent_id = cloud_organizations_organizational_unit.prod.id } # 4. 中心日志桶,开启版本管理,防止合规日志被误删 resource "cloud_log_bucket" "central" { bucket_name = "org-central-log" versioning = true }这段代码的目的是让你看清依赖顺序:OU 依赖组织根,账号依赖 OU,日志桶是独立资源。实际项目中,组织根几乎是一次性资源,不建议反复重建;enabled_policy_types决定了你能不能把 SCP 挂到 OU 上,如果一开始只开了SERVICE_CONTROL_POLICY,后续再想开TAG_POLICY,Terraform 会计划把 OU 和账号重新读一遍,大部分状态还在,但 plan 会变得很长,容易被误判为有破坏性变更。
email参数要特别注意:云厂商的跨账号通知机制要求账号邮箱唯一,而且通常需要在创建后去邮箱点确认链接,账号才能正常登录和使用。自动化代码里可以放一个占位邮箱,但实际流程中需要把“验证邮箱”这一步作为发布清单的卡点,否则账号建出来了却用不了,这是最典型的翻车点之一。下一章我会讲怎么把这段代码放到一条可重复执行的管道里。
3. 把自动化变成一条可重复执行的管道:从手动点控制台到一键变更
3.1 选择执行环境:在 CI 里跑还是在本地跑,差异比你想象的大
Landing Zone 的代码写好后,接下来要决定在哪执行。很多人第一反应是本地跑,毕竟自己能盯着输出。本地跑的问题是长期凭证容易留在开发机里,而且每个人本地 Terraform 版本不一致,plan 结果五花八门。更严重的是,如果两个人同时 apply,后跑的人可能覆盖前一个人的变更,这在 Landing Zone 里是灾难级的。
我一般会把 Landing Zone 的执行放到 CI 里。CI 的好处有三个:一是凭证由系统任务角色注入,不落盘;二是每次执行前固定工具版本,避免“本地可以,CI 不行”的玄学;三是执行日志自动留存,审计时能说清楚“这条 OU 是谁在什么时间改的”。
但 CI 也有一个反直觉的地方:Landing Zone 的 apply 通常需要人工审批,不能像普通应用那样 push 后自动部署。因为账号、SCP 的变更影响范围太大,一旦自动失败可能要回滚很久。所以常见的做法是:CI 自动跑 init 和 plan,生成 diff 并贴到变更单里;人工确认后再触发 apply。如果团队只有一个人维护,本地跑也可以,但一定要用远程状态后端把锁开起来;如果有多人参与,就别犹豫,直接走 CI。
3.2 一个标准的 init-plan-apply 脚本:锁定、预览、人工确认
下面这个脚本是我在多个落地项目里反复用过的结构,它把“初始化、计划、应用”三个步骤拆开,并且用退出码把“无变化”和“有变化”区分开。
#!/usr/bin/env bash set -euo pipefail STACK_DIR="infra/landing-zone" ENV_NAME="${1:-dev}" BACKEND_BUCKET="org-tfstate-${ENV_NAME}" # 1. 初始化远程后端,关键参数用变量注入,不要提交到仓库 terraform -chdir="$STACK_DIR" init \ -backend-config="bucket=${BACKEND_BUCKET}" \ -backend-config="key=landing-zone/${ENV_NAME}/terraform.tfstate" \ -backend-config="region=<你的主区域>" PLAN_FILE="${ENV_NAME}.tfplan" # 2. 生成计划,-detailed-exitcode 让 plan 在“有变化”时返回 2 terraform -chdir="$STACK_DIR" plan -detailed-exitcode -out="$PLAN_FILE" > /tmp/plan.out 2>&1 PLAN_RC=$? if [ "$PLAN_RC" -eq 0 ]; then echo "无变更,跳过 apply" exit 0 fi # 3. 输出差异,人工确认后再决定是否继续 cat /tmp/plan.out if [ "${APPLY:-false}" = "true" ]; then terraform -chdir="$STACK_DIR" apply "$PLAN_FILE" else echo "未设置 APPLY=true,跳过 apply" fi这个脚本里有几个参数值得说明。-detailed-exitcode让 plan 的退出码从 0/1 变成 0/1/2,其中 2 表示有差异,这正好可以作为 CI 里的“是否需要人工审批”信号。-backend-config里不要写死访问密钥,本地执行时从环境变量读取,CI 里用任务的临时身份。PLAN_FILE用${ENV_NAME}区分环境,避免多个环境共享同一份 plan 文件导致 apply 错乱。
如果你用的是 Terraform 1.5 以上版本,-chdir参数可以避免在脚本里反复cd,也更容易在子目录里定位模块路径。要注意的是,plan 文件只能在同一台机器、同一份状态上使用,不能把dev.tfplan拿到另一个目录执行,否则再 apply 时可能会提示状态不匹配。
3.3 状态文件拆分:别让整个 Landing Zone 变成一个巨大的 state
很多人第一次写 Landing Zone 自动化时,把所有资源放在一个 state 里,因为这样最省事。但 Landing Zone 一旦长大,这个 state 会变成负担:每次 plan 都要读取全量账号下的资源,慢且容易误报。我一般会按“组织控制层”和“业务账号层”拆开。
| 工作区 | 内容 | 变更频率 | 典型执行者 |
|---|---|---|---|
| org-root | 组织根、OU、SCP、组织账号 | 很低 | 平台管理员 |
| central-log | 中心日志桶、桶策略、跨账号角色 | 低 | 平台管理员 |
| per-account | 单个账号内的 VPC、子网、堡垒机、合规规则 | 高 | 业务侧或平台侧 |
拆分后,Landing Zone 的 apply 只处理组织根、OU、SCP 和账号级资源,业务账号内部的东西由各自独立的工作区管理。工作区之间的数据需要联动时,通过terraform_remote_state按需读取,避免把别人的状态全拉进自己的 plan。命名规范我习惯用landing-zone/{env}/{workspace},比如landing-zone/prod/org-root和landing-zone/prod/central-log,这样日志里一眼能看出改的是哪一层。
拆分还有一个额外好处:测试可以只针对某个工作区做,不必每次跑全量。比如你改了 SCP,只需要跑org-root的测试和central-log的日志链路验证,不需要申请一堆业务账号来重跑整个 Landing Zone。
4. 测试 Landing Zone 的三层防线:plan、静态扫描和端到端验证
4.1 第一层:用 validate 和 plan 做基线健全性检查
Landing Zone 的测试不能只靠“最后跑一次 apply 看报不报错”,因为很多错误在 apply 前就该被拦住。我用的第一层防线是 Terraform 自带的validate和plan -detailed-exitcode。前者检查语法和类型错误,后者检查基础设施漂移。
#!/usr/bin/env bash set -euo pipefail # 语法与引用完整性检查 terraform validate # 如果 plan 返回 2,说明有资源变更,CI 将把它当成一次“未审核变更”阻断 terraform plan -detailed-exitcode -out=plan.tfplan这段脚本的逻辑很简单,但意义重大:plan不只是“改之前看一眼”,它是一份可执行的差异报告。配合 CI 的合并请求,每次改动都会把 diff 发表评论,由维护者逐条确认。比如改动里突然多了一个cloud_organizations_account的force_destroy,这大概率不是你想看到的,plan 阶段就能拦住。
这里有个容易被忽略的点:validate通过不代表策略正确,它只证明代码能被解析。比如 OU 的parent_id引用写错,validate不一定报错,plan时才会因为找不到依赖而失败。所以这两条命令都要跑,而且要在同一个 CI job 里跑,不要只跑一个。
4.2 第二层:用 Terraform 的内置测试能力断言模块输出
第二层测试关注模块的输入输出是否符合约定。Terraform 1.6 之后自带terraform test,你不需要额外安装 SDK,只要在测试文件里声明run块,断言模块输出的值。这非常适合 Landing Zone 里的 OU 命名、账号标签、策略内容等约定。
# tests/ou.tftest.hcl run "setup_ou" { module { source = "./modules/ou" } } run "assert_ou_exists" { assert { condition = run.setup_ou.ou_id != "" error_message = "创建 OU 后必须返回非空的 ou_id" } } run "assert_name_prefix" { assert { condition = startswith(run.setup_ou.ou_name, "Prod") error_message = "生产 OU 命名必须以 Prod 开头,便于下游策略匹配" } }这个测试文件把模块的“契约”固定下来:OU 创建后必须有 ID,而且名称必须符合前缀约定。任何人后续改模块,只要破坏了这两条,terraform test就会失败。参数上要注意,run.setup_ou.ou_id引用的是前一个run里模块输出的值,如果模块没有暴露这个输出,断言会报错。所以写模块时要把测试需要的外部接口都暴露成output,这本身也是让模块更可用的过程。
我习惯把测试文件放在模块目录旁边的tests/下,这样每个模块自带可验证的“说明书”。CI 里在terraform validate之后跑terraform test,不需要额外编排,命令是内置的,输出也够直观。
4.3 第三层:端到端验证:临时账号 + 日志投递检查
前两层测试都是“代码对代码”,Landing Zone 真正怕的是“云厂商实际行为和代码预期不一致”。比如账号创建了但无法接收邮件,SCP 没有真正阻止指定操作,日志桶权限看起来对但投递链路是断的。这些必须靠端到端测试,用真实资源验证一遍。
#!/usr/bin/env bash set -euo pipefail TEST_ACCOUNT_ID="$(terraform output -raw test_account_id)" TMP_LOG="${TMPDIR:-/tmp}/landing-zone-e2e-$(date +%s)" echo "==> 等待账号服务可用(轮询)" for i in $(seq 1 30); do if "$CLOUD_CLI" describe-account --account-id "$TEST_ACCOUNT_ID" >/dev/null 2>&1; then break fi sleep 10 done echo "==> 验证普通账号不能读取中心日志桶(预期失败才通过)" if "$CLOUD_CLI" read-bucket --account-id "$TEST_ACCOUNT_ID" --bucket "org-central-log"; then echo "FAIL: 普通账号不应能读取中心日志桶" >&2 exit 1 fi echo "==> 写入一条测试日志,验证审计通道连通" echo '{"test": "landing-zone-e2e"}' | "$CLOUD_CLI" put-log --account-id "$TEST_ACCOUNT_ID" >/dev/null sleep 20 "$CLOUD_CLI" get-log --account-id "$TEST_ACCOUNT_ID" --bucket "org-central-log" | grep -q "landing-zone-e2e" echo "PASS: 端到端日志链路正常"这里的$CLOUD_CLI需要替换为你所用云厂商的命令行工具,不同厂商命令格式不一样,重点是验证三个行为:账号能正常查询、普通账号读取日志桶被拒绝、日志能写入并回读。第一项是验证账号生命周期,第二项是验证权限边界,第三项是验证审计链路,这就是 Landing Zone 测试里最有价值的三个真实场景。
写这个脚本要注意,describe-account的轮询时间不能太长,超过 5 分钟建议直接失败,因为账号就绪时间通常受邮件确认影响,属于人为流程,不该让 CI 干等。日志写入和读取之间的延迟也需要测试确认,生产环境可能几秒,测试环境可能稍长,但不要上来就sleep 60,那只会掩盖真正的性能问题。
5. Landing Zone 自动化测试避坑指南:五个踩过就记住的现象、原因和解决
5.1 坑一:两个 job 同时 apply,state 锁形同虚设
现象:CI 里两个分支同时跑 Landing Zone 的 apply,后提交的 job 报“获取状态锁失败”,但又显示锁已经被释放,整个执行记录混乱。原因:远程后端没有开启锁,或者两个 job 用了不同的后端配置,导致他们认为自己在操作两个不同的 state。解决:使用支持锁的远程后端,并保证所有 job 的backend-config里的bucket和key完全一致;同时设置lock_timeout,让并发任务等待前一个任务释放锁而不是直接失败。我在多个项目里都遇到过这个坑,最后统一把后端配置收敛到一个共享变量文件里,才彻底解决。
5.2 坑二:控制台手改一个 OU 名字,plan 里出现账号搬迁
现象:有运维同事图快,在控制台把ProdOU 改成了Production,下次 CI 的 plan 显示所有账号都要从旧 OU 移出再移入新 OU,甚至 SCP 都要重新关联。原因:OU 的 ID 没变,但 Terraform 认为路径变了,于是把账号的parent_id视为待更新资源。解决:组织层级的任何变更都走代码,控制台权限通过 SCP 限制,不让普通管理员直接操作组织根;如果已经手改了,先把代码里的 name 改成和线上一致跑一次 plan,确认只剩预期变更后再 apply。这个坑的核心教训是:Landing Zone 的代码一旦被绕过,plan 的结果就不再可信。
5.3 坑三:测试环境和生产环境模板不一致,测试全绿但生产翻车
现象:测试环境用的是简化模板,只有两个 OU 和默认策略;生产环境有专门的日志归档账号和复杂的 SCP,结果测试通过后 apply 生产,plan 多出十几个预期外资源。原因:测试工作区和生产工作区虽然模块相同,但tfvars里的变量值差太多,测试没有覆盖“生产参数组合”下的行为。解决:测试环境使用与生产同一套tfvars文件,只改环境前缀,比如prod-account-email换成test-account-email;同时在测试里加断言,校验账号数量和策略数量符合预期。我会在 CI 里跑一个脚本,diff 两份tfvars文件的 key,凡是生产有而测试没有的 key 直接报错。
5.4 坑四:日志桶的跨账号权限没落在同一个模块,端到端测试必挂
现象:端到端测试反复失败,日志桶里始终没有新日志,手动去控制台看桶策略又没问题。原因:日志投递需要桶策略加上跨账号角色信任,这两段配置分散在两个模块里,apply 顺序导致先建的桶没有等角色就绪,权限实际没有生效。解决:把“日志投递权限”和日志桶放在同一个模块里,用显式依赖让角色先建好,再创建桶策略;端到端测试里不要只读日志,要先写一条真实日志再读,如果写不进去,问题一定出在权限链路上。这个坑属于典型的“分开看都对,合起来就坏”,没有端到端测试根本发现不了。
5.5 坑五:Landing Zone 的 state 混进业务资源,plan 慢到让人怀疑人生
现象:terraform plan从十几秒变成两分钟,输出里全是业务账号的 VPC、子网、安全组资源,根本不是 Landing Zone 的治理内容。原因:一开始为了省事,把某个账号里的 VPC 也放进了 Landing Zone 的 state,之后业务侧每次变更都会让这个 state 变得更大。解决:用terraform state rm把业务资源移出去,交给独立工作区管理;Landing Zone 的 state 只保留组织级资源,包括组织根、OU、SCP、账号、中心日志桶和身份权限。拆分之后,plan 速度恢复正常,业务侧的变更也不再影响治理基线。这个坑的教训是:state 的边界就是团队的治理边界,不归你管的资源,就不要放进你的状态里。
6. 把文档变成测试的一部分:治理规则直接驱动代码校验
进阶用法,我强烈建议你尝试:把文档里的治理要求写成一份机器可读的规则文件,测试直接引用它,而不是在测试代码里硬编码字符串。这样文档和测试不再是两套东西,而是同一份规则的两个出口。
先看一份规则文件,我用 YAML 描述几条简单的治理要求:
# docs/rules.yaml landing_zone: prod_ou: name_prefix: "Prod" # 生产 OU 必须以此前缀开头 scp_deny: - "account:CloseAccount" # 不允许关闭账号 - "iam:AttachUserPolicy" # 不允许直接给用户附加策略 log_bucket: versioning: true allow_anonymous_read: false然后在测试里读取这份文件,对实际模块输出做校验。Python 脚本示例:
# scripts/check_rules.py import yaml import sys def validate(rule, actual): errors = [] if not actual["name"].startswith(rule["name_prefix"]): errors.append(f"OU 名称前缀不符: {actual['name']}") for action in rule["scp_deny"]: if action not in actual["scp_document"]: errors.append(f"缺少 Deny 规则: {action}") if rule["log_bucket"]["allow_anonymous_read"]: errors.append("日志桶不应允许匿名读取") return errors rules = yaml.safe_load(open("docs/rules.yaml")) actual = { "name": "Production", "scp_document": "account:CloseAccount", } errs = validate(rules["landing_zone"]["prod_ou"], actual) if errs: print("\n".join(errs)) sys.exit(1) print("PASS")这个脚本的价值在于:当有人提议“给生产 OU 改名”或者“放开一个 SCP 拒绝项”时,评审的人只要看rules.yaml的 diff,就能知道这条改动会不会破坏治理约定。测试代码里不再出现魔法字符串,规则只维护一份。
我还会配套一个简单的 Makefile,把这套流程固化:
.PHONY: plan test e2e doc-check plan: ./scripts/plan.sh dev test: terraform test e2e: ./scripts/e2e.sh doc-check: python scripts/check_rules.py docs/rules.yaml以后每次改动 Landing Zone,至少跑make test和make doc-check,发布前再手动触发make e2e。我后来养成的习惯是:先写一条会失败的测试,再改代码让它通过,最后顺手更新规则文件。这样留下的 Landing Zone,半年后接手的同事不会想删库跑路。希望帮到你。
本文还有配套的精品资源,点击获取