☰
AWS Landing Zone 自动化测试:用 pytest 和 boto3 构建可验证的多账号治理体系
2026/10/14 6:49:46 网站建设 项目流程

简介:面向AWS云架构师与DevOps工程师的Landing Zone自动化示例,围绕测试环境搭建、组织与组织单位创建、核心账户配置等关键环节,提供可直接参考的代码与文档。压缩包共69个文件,约1.85MB,主要包含24个JSON配置、13个Markdown说明、11个YAML模板及少量Ruby脚本,另有docx/pdf文档若干;其中JSON承载IAM策略、组织数据与跨账户配置,YAML用于CloudFormation模板及Config/CloudTrail规则定义,Markdown提供环境设置、操作步骤与排错说明,Ruby脚本负责辅助校验。资源以模块化目录组织,涵盖测试环境准备、CloudFormation工具、组织与OU管理、安全策略、账户管理等模块,涉及SCP策略、VPC多子网模板、CloudTrail审计、Config合规规则、跨账户角色及ADFS联合认证等典型需求。通过按序阅读各模块内的Markdown指南,并执行对应的Ruby辅助脚本,可逐步复现Landing Zone的创建与验证流程,减少手动配置的重复劳动。已有310人学习使用,适合具备一定AWS基础、希望借助示例快速搭建并测试Landing Zone的中高级云开发者。

1. Landing Zone 自动化测试的入口:一份代码示例和文档够不够用

AWS Landing Zone 不是一个能一键安装的软件,而是一套由多账号、OU、SCP、CloudTrail、Config 拼出来的基础设施骨架。手工点控制台,第一次能搭出来,第二次想照着原样复现,经常发现少配了日志或者某个 OU 名字写错。这份资源把“Landing Zone 自动化和测试”拆成了可读的代码示例和配套文档,语言主线是 Python,测试层走 pytest,等于给了你一条从“部署完成”到“持续可验证”的路径。它能解决的是:让配置漂移在测试阶段被发现,让一份代码在不同账号上复现同样的结果。适合正在做多账号治理、又不想把运维时间耗在重复点控制台上的平台工程师和 DevOps。

2. 拆解自动化链路:模块边界、账号布局与 Python 切入点

Landing Zone 不是单个服务,而是一组服务的组合。做自动化的第一步不是急着写 boto3 脚本,而是把这张图拆出来:组织与账号层包含 AWS Organizations 的 root、OU、账号;治理策略层包含 SCP、IAM 边界、权限集;安全审计层包含 CloudTrail、Config、GuardDuty;网络基础层包含 VPC、流日志、Transit Gateway;最后还有一个很容易被忽略的合规基线层,用来统一检查账号里的配置是否满足要求。自动化切入点就落在这些层之间的“状态同步”上:你期望的配置写在代码里,实际的配置在 AWS 上,测试要做的事情就是把两者拉在一起做比较。

所以这里有个很关键的问题:为什么用 Python 而不是只靠 CloudFormation?CloudFormation 适合声明资源,但很难写“跨服务”的断言。比如你想验证“每个 OU 下都有日志账号”“SCP 没有被手动改过”“某个不该存在的账号已经删除”,这些逻辑如果写在模板里,基本没法读;而用 Python 加 boto3 直接读 Organizations、Control Tower、Config 的状态,写出来就是普通的 if 和 assert,谁都能维护。这正好是这份资源的主线:它没有重复教你怎么建 Landing Zone,而是把自动化和测试封装成可以复用的代码示例,再配文档解释每一步在干什么。

2.1 Landing Zone 的常见构成与自动化切入点

在跑仓库里的示例之前,我建议你先建立一张自己的“配置清单”。以最常见的多账号 Landing Zone 为例,至少要覆盖这么几层:

  • Organizations 层:根组织、OU 列表,通常会有 Security、Infrastructure、SharedServices、Workloads 这些 OU,以及每个 OU 下的账号列表。
  • 账号层:管理账号、日志归档账号、审计账号、生产/开发账号,每个账号承担什么角色,由谁持有管理员角色。
  • 策略层:SCP 绑定在哪些 OU 上,IAM 权限边界怎么约束,权限集是否按环境区分。
  • 日志层:CloudTrail 是否开启组织级日志,日志进到哪个 S3 桶,是否同步到 CloudWatch。
  • 合规层:Config Rules 有没有覆盖所有账号,必选的托管规则是否生效。

自动化切入点的选择会影响后面测试怎么写。常见做法是分成三类:初始化类,调用 Organizations、Control Tower 或 CloudFormation StackSets 创建更新资源;校验类,实时拉取 AWS 当前状态,和预期配置做 diff;恢复类,当 diff 不为空时,按模板把配置拉回期望值。这份资源的价值重点在第二类和第三类,因为初始化已经有 Control Tower 这类托管服务做了大部分工作,真正容易翻车的是“初始化完就没人管了”。

2.2 为什么用 Python 而不是只靠 YAML 和 CloudFormation

CloudFormation 的 YAML 描述资源能力很强,但它的逻辑表达很弱。你可以在模板里写Conditions,却很难表达“遍历所有 OU,检查每个 OU 下账号数量是否大于等于 2”这种动态逻辑。Landing Zone 的测试本质上是一连串动态查询和比较,YAML 描述不了,shell 脚本又难维护。Python 生态里 boto3 覆盖 AWS 几乎所有 API,pytest 又是最成熟的自动化测试框架,所以这份资源选 Python 是合理的。

一个典型的坑是:有人把 SCP、OU 全部写死在 CloudFormation 模板里,然后用模板部署。结果某一天安全团队在控制台手动删掉了一条 SCP,下一次云上巡检完全发现不了。如果有一层 Python 测试,直接从 Organizations 拉取所有 SCP 绑定关系,和模板里的期望值比对,这个问题在几分钟内就会暴露。这也是我每次看到这类 Landing Zone 资源时,首先会去看它有没有tests/目录的原因。

2.3 从示例代码读数据流:配置 → 资源 → 测试断言

仓库里的示例代码,多半会遵循一条数据流:先读configs/landing_zone.json或 YAML,拿到期望状态;再用 boto3 客户端读 AWS 当前状态;最后转成 pytest 可以断言的 Python 对象。下面这段递归列出所有 OU 的代码,是很多用例的底座,几乎所有相关示例里都能看到类似版本:

import boto3 from typing import List, Tuple def list_all_ous(parent_id: str) -> List[Tuple[str, str]]: """列出指定父节点下的所有子 OU,并递归向下。 返回 [(OU 名称, OU ID), ...],上层测试直接用它做断言。 """ org = boto3.client("organizations") ous: List[Tuple[str, str]] = [] paginator = org.get_paginator("list_organizational_units_for_parent") for page in paginator.paginate(ParentId=parent_id): for item in page["OrganizationalUnits"]: ous.append((item["Name"], item["Id"])) ous.extend(list_all_ous(item["Id"])) return ous

这段代码里有两个值得注意的地方。第一,paginator用来处理分页,OU 数量多的时候避免只取第一页就返回;第二,递归调用list_all_ous把子 OU 也拉出来。Landing Zone 的 OU 深度一般不会超过四层,递归不会爆栈,但如果账号规模很大,建议把这个结果缓存成全局对象,避免每个测试函数都重复打 API。参数parent_id传入根组织 ID 或某个 OU ID,业务上通常是从org.list_roots()["Roots"][0]["Id"]开始。

接下来是一段读取预期配置的示例,它的作用是把 YAML/JSON 和 boto3 查询解耦开:

import json def load_expected_config(config_path: str) -> dict: """读取期望状态配置,返回字典结构。""" with open(config_path, "r", encoding="utf-8") as fp: return json.load(fp) def list_accounts_by_ou(ou_id: str) -> List[str]: """返回某个 OU 下的所有账号 ID。""" org = boto3.client("organizations") accounts = [] paginator = org.get_paginator("list_accounts_for_parent") for page in paginator.paginate(ParentId=ou_id): for acct in page["Accounts"]: accounts.append(acct["Id"]) return accounts

这里的load_expected_config是纯本地逻辑,不需要网络,适合放进单元测试;list_accounts_by_ou是真实 API 调用,需要凭据,适合放进集成测试。把这两类代码分开,是后续写测试用例的大前提。否则你在 pytest 里跑一个纯函数,它却在初始化时偷偷去连 AWS,速度慢不说,CI 里没有凭据就直接红一大片。

3. 用 pytest 把 Landing Zone 测起来:断言、固件与参数化

很多人以为 Landing Zone 的测试就是“CloudFormation 部署不报错”,其实那是最低标准。真正有用的测试,是一旦有人手动改了 SCP、删了 OU、关了 CloudTrail,测试立刻能报出来。pytest 在这类场景里很顺手,因为它有 fixture 可以复用 AWS 客户端,有参数化可以批量验证多个 OU,还有 marker 可以把单元测试和集成测试分开跑。

3.1 先定测试策略:单元测 / 集成测 / 合规测

我拿到这类仓库时,第一步不是看代码,而是看测试目录怎么分层。合理的结构是:

landing-zone/ ├── configs/ │ └── landing_zone.json ├── src/ │ ├── org_utils.py │ └── config_loader.py ├── tests/ │ ├── unit/ │ │ └── test_config_loader.py │ ├── integration/ │ │ └── test_organizations.py │ └── conftest.py

单元测试只测“不碰 AWS”的代码,比如配置解析、模板渲染、SCP 内容校验。集成测试才真正去读 AWS,用来验证当前环境和期望一致。合规测则是集成测试的一个子集,专门检查 CloudTrail、Config、GuardDuty 这类安全基线。测试策略里最核心的一条原则是:永远不要让集成测试去“写”资源,只让它“读”。一旦测试里出现create_organizational_unit或delete_bucket,说明你已经把测试变成了部署脚本,风险很大。

3.2 pytest 固件:复用客户端和配置

pytest fixture 最大的价值是让 boto3 client 只创建一次,然后在多个测试函数之间复用。常见的写法是放在conftest.py里:

import os import boto3 import pytest @pytest.fixture(scope="session") def org_client(): """创建一个 Organizations client,整个测试会话复用。""" session = boto3.Session(profile_name=os.getenv("AWS_PROFILE", "default")) client = session.client("organizations", region_name=os.getenv("AWS_REGION", "us-east-1")) yield client client.close()

这里的scope="session"表示所有测试共用同一个 client,避免每个用例都走一遍签名计算和连接建立。profile_name从环境变量读取,是为了本地和 CI 两边都能跑:本地你用AWS_PROFILE=landing-zone-test,CI 里用临时凭据注入环境变量。client.close()放在yield之后,保证会话结束连接被回收,不然长时间跑测试可能出现文件描述符泄漏。

配合 fixture,写一个最简单的组织级断言:

def test_root_ou_exists(org_client): roots = org_client.list_roots()["Roots"] assert len(roots) >= 1, "Landing Zone 必须至少有一个根组织"

这个用例虽然小,但它验证的是一整条链路:本地凭据、组织 API、pytest 收集、断言输出。如果这条链路能跑通,后面加几百个用例都只是堆料问题。

3.3 参数化用例:批量验证多个 OU 和账号

Landing Zone 里最常见的一件事,就是检查这一组 OU 是否都还在、是否都叫原来的名字。手写十遍 assert 太蠢,用 pytest 参数化最合适:

import pytest from src.org_utils import list_all_ous EXPECTED_OUS = [ ("Security", "ou-xxxx-security"), ("Infrastructure", "ou-xxxx-infra"), ("SharedServices", "ou-xxxx-shared"), ("Workloads", "ou-xxxx-workloads"), ] @pytest.mark.parametrize("expected_name,expected_id", EXPECTED_OUS) def test_required_ou_exists(org_client, expected_name, expected_id): ous = list_all_ous(org_client.list_roots()["Roots"][0]["Id"]) ou_map = {name: ou_id for name, ou_id in ous} assert expected_name in ou_map, f"缺少 OU: {expected_name}" assert ou_map[expected_name] == expected_id, f"OU {expected_name} 的 ID 发生变化"

参数化之后,每个元组都会生成一条独立的测试用例,失败时 pytest 会明确告诉你“Security”和“Workloads”哪个出了问题,而不是糊成一片。注意这里EXPECTED_OUS里的 ID 只是占位符,实际使用时要替换成你自己组织里的真实 OU ID。更稳的写法是只断言 OU 名称存在,不把 ID 写死,因为 Control Tower 重建 OU 时 ID 可能会变。

对于集成测试,我一般会加一个 marker,避免和单元测试混在一起跑:

@pytest.mark.integration def test_cloudtrail_latest_delivery(cloudtrail_client): trails = cloudtrail_client.describe_trails()["trailList"] assert any(t["IsOrganizationTrail"] for t in trails), "组织级 CloudTrail 未开启"

然后在 pytest.ini 里配置:

[pytest] testpaths = tests markers = integration: 需要真实 AWS 凭据的测试

跑的时候,单元测试用pytest -m "not integration",集成测试用pytest -m integration。这份资源里的示例代码也是按这个思路组织的,先本地跑单元,再异步跑集成,能省不少时间。

4. 把文档和示例接到你手里:环境变量、权限与配置驱动

仓库的价值不只靠代码,还要靠文档告诉你“怎么跑起来”。很多 Landing Zone 示例代码下载下来跑不通,问题不在代码,在环境变量没对齐。这一章专门讲怎么把文档、示例和真实环境接起来。

4.1 仓库文档怎么读:先看目录、再对配置、最后跑脚本

拿到这份资源后,我建议按这个顺序看文档。第一步,看 README 里“目录结构”这一段,搞清楚哪些是示例脚本、哪些是测试、哪些是配置。第二步,看configs/目录下的配置模板,把里面的账号 ID、OU 名称替换成你自己的,特别注意management_account_id、log_archive_account_id、audit_account_id这三个字段。第三步,看requirements.txt或pyproject.toml,确认 Python 版本和依赖。最后再去跑脚本。

有一类文档坑值得提醒:文档里写的命令是python scripts/deploy.py --env dev,但代码里实际用的是--environment dev,这种不一致在真实仓库里经常出现。如果你照着文档跑报错,先不要怀疑 AWS,去看argparse或click的定义,多半是参数名对不上。示例代码本身不是银弹,文档和代码不一致时,以代码实际行为为准。

4.2 环境变量与权限:让代码在本地和 CI 里都跑起来

这份资源里的代码示例,默认会读几个环境变量。我整理了一张常用表,你跑之前先核对:

环境变量作用本地示例说明
AWS_PROFILE指定本地凭据 profilelanding-zone-test对应~/.aws/credentials中的配置
AWS_REGION指定区域us-east-1Landing Zone 一般以us-east-1为主区域
LANDING_ZONE_CONFIG配置路径configs/landing_zone.json不设置时用默认路径
LOG_LEVEL日志级别INFO排查时切到DEBUG看完整请求

在代码里读取环境变量的标准示范:

import os from pathlib import Path DEFAULT_CONFIG = str(Path(__file__).parent.parent / "configs" / "landing_zone.json") CONFIG_PATH = os.getenv("LANDING_ZONE_CONFIG", DEFAULT_CONFIG) def load_org_config(): if not os.path.exists(CONFIG_PATH): raise FileNotFoundError(f"找不到配置: {CONFIG_PATH}") with open(CONFIG_PATH, "r", encoding="utf-8") as fp: return json.load(fp)

这里用Path(__file__).parent.parent拼默认路径,比用相对路径./configs更规范,因为不管从仓库根目录还是tests/目录启动,都不会找错地方。raise FileNotFoundError是故意的,配置缺失时越早报错越好,不要让代码默默用空配置跑一轮。

权限方面,本地测试建议用一个只读角色,权限集包含organizations:List*、cloudtrail:Describe*、config:Describe*就够了。千万不要用管理员账号跑集成测试,一旦某个用例误写资源,后果不可控。

4.3 配置驱动 vs 硬编码:为什么示例代码要写成可改参数

很多初看 Landing Zone 代码的人不理解:明明只有三四个 OU,为什么不用 if 直接写死?因为 Landing Zone 一旦上了生产,OU 会越来越多,账号数量会持续增长,硬编码会让测试变成一堆无法维护的魔法数字。这份资源里推荐的模式是“配置驱动”,把期望状态放在配置文件中。

配置文件的典型形态:

{ "root_ou_name": "Root", "ous": { "Security": { "accounts": ["111111111111", "222222222222"] }, "Workloads": { "accounts": ["333333333333"] } }, "scp_boundaries": { "Security": ["DenyPublicS3", "DenyLeavingOrg"] } }

测试代码只需要做一件事:读取配置,拉取真实状态,比对。比如:

expected_ous = load_org_config()["ous"] actual_ous = list_all_ous(root_id) assert set(expected_ous.keys()) <= set(actual_ous.keys())

这样新增一个 OU,只需要改配置文件,测试代码一行不动。这是自动化测试能够在团队里长期存活的关键:维护成本越低,越有人愿意继续用。如果你发现一份示例代码里所有账号 ID、OU 名称直接写在 Python 文件顶部,那它只能算教学演示,算不上工程资产,复制到生产前一定要先改成配置驱动。

5. 避坑:AWS Landing Zone 自动化测试中我踩过的坑

这一章写的是我在复现这类自动化测试过程中实际翻过的车,每一条都按“现象 → 原因 → 解决”来记录。希望能帮你少走几趟弯路。

5.1 现象:同一套配置跑第二次,CloudFormation 报资源已存在

第一次部署 CloudFormation StackSet 成功,第二次原样重跑,直接报错:BucketAlreadyOwnedByYou或者The stack set operation cannot be performed because it already exists。我一开始以为是代码写错了,后来查发现是 S3 桶名写死了。S3 桶名是全局唯一的,同一个账号重新创建同名桶,CloudFormation 会尝试再次创建,于是失败。

原因就是创建类的资源天然不具备幂等性。解决方法是:凡是 S3 桶、IAM 角色、LogGroup 这类全局资源,命名时拼上环境名和账号 ID,比如landing-zone-logs-${AWS_ACCOUNT_ID}-${ENV}。同时在部署前加一步检查:如果资源已存在,就跳过创建环节。后面你跑测试时发现同类报错,不用怀疑 AWS,先检查命名里有没有固定值。

5.2 现象:测试通过,Control Tower 控制台里却乱套了

有一段时间我用 boto3 直接调create_organizational_unit创建 OU,自动化测试跑得全绿,但打开 Control Tower 控制台一看,页面显示 OU 结构混乱,治理状态变成“未注册”。原因是我在 Control Tower 托管的组织根下手动建了一个 OU,Control Tower 并不知道这个 OU,也就不会自动绑定它该有的治理策略。

解决方法是划清边界:Control Tower 管理的资源,测试只准读,不准写。如果确实需要新建 OU,应该走 Control Tower 的账号工厂或等待手工变更,再让测试去验证结果。断言时还要过滤掉 Control Tower 自动生成的aws-controltower-*OU,否则它们会被当成“额外 OU”误报。

5.3 现象:pytest 提示 no tests ran,但目录里明明有测试文件

我第一次跑这份资源的测试时,执行pytest直接显示no tests ran。排查发现,仓库里测试文件名是check_organizations.py,不是test_organizations.py,pytest 默认收集规则不认这个文件名。还有一次是pytest.ini里的testpaths指向了空目录./tests/unit,实际测试在tests/integration下。

解决方法是先跑pytest --collect-only -v,看 pytest 到底看到了哪些文件。然后把测试文件统一命名成test_*.py,并在pytest.ini里显式声明:

[pytest] testpaths = tests python_files = test_*.py *_test.py

如果文件命名和路径都没问题,再看__init__.py。测试目录里有没有__init__.py会影响 pytest 的 rootdir 和模块导入方式,遇到“收集到了但导入报错”时,试着删掉__init__.py或者加回去,总能解决。

5.4 现象:本地能跑,CI 里全是 NoCredentialsError

本地测试过了,推到 CI 里跑,所有 AWS 相关用例都报NoCredentialsError。原因是代码里写死了boto3.Session(profile_name="landing-zone-test"),本地有~/.aws/credentials文件,CI 里没有这个 profile,也没有注入对应的临时凭据。

解决方式是把 profile 名改成从环境变量读取:

session = boto3.Session(profile_name=os.getenv("AWS_PROFILE"))

CI 里用 GitHub Actions 的话,通过aws-actions/configure-aws-credentials注入AWS_ACCESS_KEY_ID和AWS_SECRET_ACCESS_KEY;更安全的做法是配置 OIDC,不落盘,直接生成临时凭据。从那以后我所有示例代码都不再写死 profile,统一走环境变量。

5.5 现象:用 pytest-xdist 并行跑集成测试,一半用例失败

为了提速,我用pytest -n 10并行跑集成测试,结果好几个用例报了“资源不存在”或“越权”。原因是多个 worker 同时在同一个 OU 下创建临时资源,或者一个 worker 的清理逻辑把另一个 worker 正在用的测试资源删了。Landing Zone 是全局共享资源,不适合普通单机测试那种随意并行。

解决方法是把测试分成两层:单元测试永远可以并行;集成测试默认串行。在 CI 里分两个 job,一个跑pytest -m "not integration",另一个跑pytest -m integration。如果确实想并行集成测试,用--dist loadgroup,并给所有碰 Organizations 的用例加@pytest.mark.xdist_group(name="org"),保证它们落进同一个 worker。

6. 把验证固化到流水线:最小 CI 骨架与本地好习惯

6.1 最小 GitHub Actions 骨架

自动化测试真正发挥价值,是从它进入 CI 那天开始的。下面这个是最小可用的 GitHub Actions 配置,适用于这份资源里的代码结构:

name: landing-zone-test on: push: paths: - 'configs/**' - 'src/**' - 'tests/**' workflow_dispatch: jobs: unit-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.11' - run: pip install -r requirements.txt - run: pytest -m "not integration" -v integration-test: runs-on: ubuntu-latest needs: unit-test 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 requirements.txt - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/landing-zone-ci aws-region: us-east-1 - run: pytest -m integration -v --tb=short

这段配置里有一个细节值得说明:integration-testjob 会等unit-test通过后再执行。原因是集成测试需要真实 AWS 凭据,执行慢,把快速失败留给单元测试更合理。role-to-assume指定的是专门给 CI 用的只读角色,权限只包含 landing zone 校验需要的只用操作,不给管理员权限。

6.2 本地验证一条龙

每次提交前,我习惯按下面这个顺序在本地跑一遍:

python -m compileall src tests pytest --collect-only -q export AWS_PROFILE=landing-zone-test pytest -m "not integration" -v pytest -m integration -v --tb=short

第一行检查语法错误,不用 AWS 凭据;第二行确认 pytest 真的收集到了用例。如果连这一步都是空跑,后面全是浪费时间。第三行把 profile 指向测试专用账号,第四行先跑单元测试,第五行跑集成测试。这个顺序我自己跑了几十次,几乎能在五分钟内判断代码能不能上 CI。

上一次我在没有跑pytest --collect-only的情况下直接推代码,CI 里显示绿,可我后来才发现是 pytest 没找到任何测试用例,那段“绿”是假的。从那以后,我每次做 Landing Zone 相关的改动,都会强制本地先走一遍--collect-only,再执行完整测试,绝不跳过。希望这个习惯也能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询