Deploy CDK Stack To Multiple Account#

Keywords: Amazon, AWS, Cloud Development, Kit, CDK

Overview#

在使用 AWS CDK 进行基础设施即代码部署时, 我们经常会遇到这样一个场景: 我们有一个 CloudFormation Stack 的定义, 需要将它部署到多个不同的 AWS 账户中. 每个账户中的 Stack 可能会有不同的参数配置, 需要使用不同的 AWS Credentials. 那么, 如何才能优雅地实现只定义一次 Stack, 并用一个 Python 脚本就能管理多个账户的部署呢?本文将为您详细介绍这个解决方案.

核心挑战#

在实现这个需求的过程中, 我们面临着两个主要的技术挑战:

1. CDK 工具的限制#

虽然 CDK 支持使用 Python 编写基础设施代码, 但它本身是基于 TypeScript 开发的工具. 它并不提供可以在 Python 代码中直接调用的部署接口. 有人可能会想到先用 CDK 生成 CloudFormation 模板, 然后用其他工具部署, 但这种方式是有风险的. 因为 CDK CLI 在部署过程中会执行许多重要的步骤, 包括:

  • 完整性检查

  • 依赖关系验证

  • Artifacts 打包

  • 其他安全性校验

如果绕过 CDK CLI 直接部署 CloudFormation 模板, 就会失去这些重要的保障.

2. 参数传递和 Boto Session 管理的挑战#

CDK 命令行工具存在两个主要限制:

  • CDK CLI 不支持向 Python 的 app.py 文件传递参数

  • 动态修改 app.py 的内容是不安全的实践

此外, 在处理 AWS 会话时也存在挑战:

  • CDK 支持通过 profile 指定权限

  • 但当使用临时 credentials (如 assume role 获取的 token) 时, 无法预先配置到 AWS CLI 配置文件中

解决方案#

针对以上挑战, 我们设计了一个优雅的解决方案:

1. 参数传递机制#

lib.py 中, 我们实现了一个参数传递机制:

lib.py
 1# -*- coding: utf-8 -*-
 2
 3import typing as T
 4import json
 5import subprocess
 6from pathlib_mate import Path
 7from boto_session_manager import BotoSesManager
 8
 9dir_here = Path.dir_here(__file__)
10path_params = dir_here / "params.json"
11
12
13def deploy_cdk(
14    params: dict[str, T.Any],
15    bsm: BotoSesManager,
16):
17    print(f"Deploying CDK stack to {bsm.aws_account_id} {bsm.aws_region} ...")
18    path_params.write_text(json.dumps(params))
19    args = [
20        "cdk",
21        "deploy",
22        "--all",
23        "--require-approval",
24        "never",
25    ]
26    with dir_here.temp_cwd():
27        with bsm.awscli():
28            subprocess.run(args)
29
30
31def destroy_cdk(
32    params: dict[str, T.Any],
33    bsm: BotoSesManager,
34):
35    print(f"Delete CDK stack from {bsm.aws_account_id} {bsm.aws_region} ...")
36    path_params.write_text(json.dumps(params))
37    args = [
38        "cdk",
39        "destroy",
40        "--all",
41        "--force",
42    ]
43    with dir_here.temp_cwd():
44        with bsm.awscli():
45            subprocess.run(args)
46
47
48params_dev = {"acc_name": "app_dev"}
49bsm_dev = BotoSesManager(profile_name="bmt_app_dev_us_east_1")
50
51params_test = {"acc_name": "app_test"}
52bsm_test = BotoSesManager(profile_name="bmt_app_test_us_east_1")
  1. 部署前将特定账户的参数序列化为 JSON 文件

  2. app.py 中读取该 JSON 文件

  3. 使用参数创建对应的 Stack 实例

为了确保参数的时效性, 我们在 app.py 中增加了文件时间戳检查机制:

app.py
 1#!/usr/bin/env python3
 2# -*- coding: utf-8 -*-
 3
 4# ------------------------------------------------------------------------------
 5# Read parameters
 6# ------------------------------------------------------------------------------
 7import json
 8from pathlib import Path
 9from datetime import datetime
10
11path_params = Path(__file__).absolute().parent.joinpath("params.json")
12# Ensure that this path_params file is created recently
13ts_mtime = path_params.stat().st_mtime_ns / 1000000000
14ts_now = datetime.now().timestamp()
15elapsed = ts_now - ts_mtime
16if elapsed > 3:
17    raise SystemExit("The params.json file is too old.")
18params = json.loads(path_params.read_text())
19
20# ------------------------------------------------------------------------------
21# Synth the Stack
22# ------------------------------------------------------------------------------
23import aws_cdk as cdk
24import aws_cdk.aws_iam as iam
25from constructs import Construct
26
27
28class Stack(cdk.Stack):
29    def __init__(
30        self,
31        scope: Construct,
32        construct_id: str,
33        acc_name: str,
34        **kwargs,
35    ) -> None:
36        super().__init__(scope, construct_id, **kwargs)
37
38        iam.CfnGroup(
39            self,
40            "IamGroup",
41            group_name=f"deploy-cdk-stack-to-multiple-account-test-stack-{acc_name}",
42        )
43
44
45app = cdk.App()
46
47# You can comment them in and out to deploy part of the stacks
48Stack(
49    app,
50    "deploy-cdk-stack-to-multiple-account-test-stack",
51    acc_name=params["acc_name"],
52)
53
54# create CloudFormation template
55app.synth()
ts_mtime = path_params.stat().st_mtime_ns / 1000000000
ts_now = datetime.now().timestamp()
elapsed = ts_now - ts_mtime
if elapsed > 3:
    raise SystemExit("The params.json file is too old.")

这样就能确保每次部署都使用最新的参数文件.

2. AWS 会话管理#

我们使用了 boto_session_manager 库来解决会话管理的问题:

with bsm.awscli():
    subprocess.run(args)

这个方案的优点在于:

  • 支持 in-memory 的临时权限管理

  • 可以使用 assume role 或者 profile

  • Context Manager 自动管理环境变量的生命周期

  • 确保 CDK CLI 使用正确的权限

实际使用#

有了这些基础设施, 我们就可以像 run_cdk_deploy.pyrun_cdk_destroy.py 中展示的那样:

run_cdk_deploy.py
1# -*- coding: utf-8 -*-
2
3from lib import params_dev, bsm_dev, params_test, bsm_test, deploy_cdk
4
5deploy_cdk(params_dev, bsm_dev)
6deploy_cdk(params_test, bsm_test)
run_cdk_destroy.py
1# -*- coding: utf-8 -*-
2
3from lib import params_dev, bsm_dev, params_test, bsm_test, destroy_cdk
4
5destroy_cdk(params_dev, bsm_dev)
6destroy_cdk(params_test, bsm_test)
  • 可以针对单个账户进行部署

  • 也可以批量部署到多个账户

  • 支持灵活的部署策略

这样的设计既保证了部署的安全性, 又提供了足够的灵活性.

总结#

通过这个解决方案, 我们成功解决了 CDK 多账户部署的几个关键问题:

  • 参数管理的安全性和时效性

  • AWS 权限的灵活管理

  • 部署流程的可控性和可扩展性

这个方案既保持了 CDK 的优势, 又克服了它的局限性, 为多账户部署提供了一个优雅的解决方案.