Deploy Lambda with AWS CDK#

Keywords: Amazon, AWS, Lambda, CDK, Alias, Version

Overview#

Deploy Lambda 的工具有很多, 有官方的 SAM (AWS Serverless Application Model), Chalice (Python Serverless Microframework for AWS), AWS CDK 等等. 原生的 CDK 可能是受众最广的方法之一. 本文将介绍如何使用 CDK 部署 Lambda.

在使用 CDK 部署 Lambda 进行开发测试很容易, 但一涉及到生产环境的 blue / green, canary deployment, rollback 等操的时候就不那么容易作对了. 本文分享了我在使用 CDK 部署 Lambda 到生产环境中踩过的坑和一些经验.

How Lambda Version and Alias Works in AWS CDK#

这里有个非常 Tricky 的坑. 在对 production 进行部署的时候, 遵循 blue/green 或是 canary 的最佳实践, 当我们希望每次更新 Lambda 的时候, 如果代码和 Configuration 有变化, 则 publish 一个 new version, 然后将 Alias 指向这个 version. 如果 Configuration 没变化, 则既不 publish version, 也不更新 Alias. 那在 CDK 中要怎么实现呢?

根据直觉, 我们可能会考虑使用 aws_cdk.aws_lambda.Version 这一 Construct. 但你会在官方文档看到这样一段话 “Avoid using this resource directly. … If you use the Version resource directly, you are responsible for making sure it is invalidated (by changing its logical ID) whenever necessary.” 根据字面意思, 你不应该手动使用这个, 除非你能确保自己 changing logic id. 这是什么意思呢? 我们来看一个例子:

import aws_cdk as cdk
import aws_cdk.aws_lambda as lambda_

class Stack(cdk.Stack):
    def __init__(self, ...)
        self.lbd_func = lambda_.Function(
            self,
            "MyLambdaFunction",
            ...
        )
        self.lbd_version = lambda_.Version(
            self,
            "MyLambdaFunctionVersion",
            ...
            lambda_=self.lbd_func,
            removal_policy=cdk.RemovalPolicy.RETAIN,
        )

凭直觉, 很多人会写出这样的代码. 创建一个指向 lbd_func 的 version, 并且指定 removal_policy = RETAIN, 使得在更新的时候依然保留这个 version (毕竟发布新版本时不保留旧版本就无法回滚了, 失去了版本管理的意义了). 但是你实际操作就会发现, 每次你更新代码的时候, 你的旧 Version 还是被删除了, removal_policy 没有起作用. 这是为什么呢?

这是因为你定义 self.lbd_version 的时候给这个 resource 的 logic id 是 MyLambdaFunctionVersion. 当 Version 的内容发生变化时, CDK 在对一个 resource 进行更新时会采用先删除再创建, 或是先创建再删除. 并不存在创建但不删除这一情况, 因为这个操作的本质是 update 而不是 remove, 所以 remove policy 自然不会生效了. 而之所以这个操作被视为 update 是因为 logic id 没有变化. 而要手动实现这一点的正确做法如下 (注意, 该方法只是用来说明原理, 官方有更推荐, 更优雅的实现):

import aws_cdk as cdk
import aws_cdk.aws_lambda as lambda_

class Stack(cdk.Stack):
    def __init__(self, ...)
        self.lbd_func = lambda_.Function(
            self,
            "MyLambdaFunction",
            ...
        )

        # call API to figure out what is the last published version
        # this example won't work, it is just for demonstration
        last_published_version = boto3.client("lambda").list_versions_by_function(...)
        next_version = last_published_version + 1

        self.lbd_version = lambda_.Version(
            self,
            f"MyLambdaFunctionVersion{next_version}",
            ...
            lambda_=self.lbd_func,
            removal_policy=cdk.RemovalPolicy.RETAIN,
        )

这种实现方式的原理正对应了官方文档中的 “you are responsible for making sure it is invalidated (by changing its logical ID) whenever necessary.”. 因为这样做每次其实是创建了一个新的 Resource, 因为 logic id 变了. 这时 CDK 才会删除原来的 Resource 同时 retain 旧的 Version, 并创建一个新的 Version. 这里的关键是我们手动计算出了新的 version 数字, 并且用它构建了 logic id.

好了, 我们来看看前面提到的 “更加优雅的方法是什么”. 通常我们不会单独使用 Version, 而是将其和 Alias 一起使用. 你当然可以创建一个 aws_cdk.aws_lambda.Alias <https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.aws_lambda/Alias.html>`_, 并将其指向 ``self.lbd_version. 但是官方提供了更优雅的方法:

import aws_cdk as cdk
import aws_cdk.aws_lambda as lambda_

class Stack(cdk.Stack):
    def __init__(self, ...)
        self.lbd_func = lambda_.Function(
            self,
            "MyLambdaFunction",
            current_version_options=lambda_.VersionOptions(
                removal_policy=cdk.RemovalPolicy.RETAIN,
                retry_attempts=1,
            ),
            ...
        )

        self.lbd_func_alias = lambda_.Alias(
            self,
            "AliasLive",
            alias_name="LIVE",
            version=self.lbd_func.current_version,
        )

在这个方法里的关键是指定了 current_version_options, 它定义了每当你引用 self.lbd_func.current_version 这个 property 属性时, 如何自动创建 Version. 我们规定了每次创建新的 Version 的时候 retain 旧 Version. 其实这一个属性就等效于上面的例子中的一堆代码. 然后我们定义了一个 Alias, 引用了这个被自动创建的新 Version.

Deploy Lambda Version and Alias with AWS CDK#

Version 和 Alias 是实现 Blue / Green deployment, Canary deployment, Version Rollback 等功能的核心. 这里我们不对其进行介绍, 我们假设你已经充分了解了它的原理. 我们重点介绍如何使用 CDK 来实现用 Version 和 Alias 来进行版本管理.

首先我们要明确需求. 通常我们会将 app 按顺序发布到多个 environment (环境) 中进行充分测试后最后再到 production. 而在不同的环境下我们的部署策略可能是不同的. 我们假设有四个环境, sbx (sandbox), tst (test), stg (staging), prd (production), 其中 sbx 用于开发, tst 用于端到端测试, stg 用于使用和 prd 一样的数据进行测试, prd 用于生产. 下面是我们的部署策略的简化版本, 用于描述我们的目标. 这里面还有很多具体细节, 之后再详细解释:

  1. 💻 Dev 设置: 在 sbx, tst, 我们的主要目的是确保最新的代码能够正常运行, 所以我们会部署最新的代码到 $LATEST, 并且不发布新版本. 因为 sbx, tst 中的代码变更频率极高, 没有必要每次占用存储空间发布新版本, 就用 $LATEST 就好. 而 LIVE alias 也指向 $LATEST.

  2. 🚀 Production 设置: 在 stg, prd, 我们的主要目的是在 stg 中复现 prd 的情况, 而 prd 的 LIVE alias 一般不会指向 $LATEST, 因为 $LATEST 是 mutable 的, 所以我们一般会指向一个 immutable 的 version. 所以 stg 中的情况也要跟 prd 进行同步. 不过我们会保留手动修改 ALIAS 指向历史版本.

💻 Dev 设置

在 Dev 模式下, 我们的默认都是使用最新代码部署, 也就是每次都 publish 都不创建 Version, 而 Alias 则是将指向 $LATEST. 而如果我们真的要 debug 一个历史版本, sbx, tst 环境是不保存历史版本的, 我们要么直接在 stg 中修改 Alias 指向旧版本然后进行 debug. 要么切换回历史版本所属的 Git Tag (这个 git tag 一般等于软件的 semantic version, 在部署的时候会一并保存在 environment variable 中), 然后拉一个 release branch 将其部署到 sbx, tst 中进行 debug.

🚀 Production 设置

在实际操作中, 我们的 API 通常会这么设计:

  1. 自动化部署: 该 API 无需手动指定版本, 而是根据一定的规则自动计算出需不需要 publish version, alias 该怎么变化. 而这个规则取决于用户想要用 blue / green 还是 canary. 该 API 适用于日常发布.

  2. 手动指定: 该 API 可以允许用户指定 version1, version2 (optional), version2_weight (optional). 该 API 适用于版本回滚.

我们这里重点说一下 自动化部署 的规则. 这里我们假设是发布了新版本的情况 (lambda code 或 configuration 有变化的情况), 如果没有变化则既不会 publish version 也不需要更新 alias.

如果是 🔵🟢 blue / green 部署, 这种情况比较简单. 创建新版本, 并让 Alias 指向新版本即可.

如果是 🐤 canary 部署, 这种情况比较复杂. 首先我们要了解一个概念. 一个 Alias 如果只指向一个版本, 则视为 stable. 而如果同时指向两个版本, 则视为 transition, 处于过渡期. 这里我们定义一个 canary_increments 的概念, 它是一个整数数组, 例如 [25, 50, 75]. 它的意思是当发布新版本时, 先只给新版本 25% 的流量, 然后增加到 50%, 75% 最后才给全部流量. 下面我们分情况讨论:

  1. 目前 Alias 都不存在. 那么直接创建新 Version 并将 Alias 指向这个 Version.

  2. 目前 Alias 存在, 且 stable. 那么这是一个新版本的发布, 则创建新 Version 并将 Alias 指向这个 Version.

  3. 目前 Alias 存在, 处于 transition 状态. 那么这是一个流量增加的过程. 我们根据 canary_increments 的定义, 如果目前流量小于 25% 则提升到 25%, 小于 25% ~ 50% 则提升到 50%, 超过 75% 则提升到 100%.

对于 手动指定 的情况就没什么好说的, 通常这不涉及到创建新版本, 只是修改 Alias 的指向即可.

下面我们给出了一个简化后的我在生产环境在用的例子.

  1# -*- coding: utf-8 -*-
  2
  3import typing as T
  4import uuid
  5from pathlib import Path
  6
  7from boto_session_manager import BotoSesManager
  8from aws_lambda_version_and_alias import get_alias_routing_config
  9
 10import aws_cdk as cdk
 11import aws_cdk.aws_lambda as lambda_
 12from constructs import Construct
 13
 14dir_workspace = Path(__file__).absolute().parent
 15dir_lambda_app = dir_workspace.joinpath("lambda_app")
 16bsm = BotoSesManager(profile_name="awshsh_app_dev_us_east_1")
 17canary_increments = [30, 70]
 18
 19
 20class Stack(cdk.Stack):
 21    def __init__(
 22        self,
 23        scope: Construct,
 24        id: str,
 25        stack_name: str,
 26        func_name: str,
 27        md5: str,
 28        is_canary: bool,
 29        version1: T.Optional[T.Union[str, int]] = None,
 30        version2: T.Optional[T.Union[str, int]] = None,
 31        version2_weight: T.Optional[float] = None,
 32        **kwargs,
 33    ) -> None:
 34        super().__init__(scope, id=id, stack_name=stack_name, **kwargs)
 35
 36        self.lbd_func_name = func_name
 37        self.alias_name = "LIVE"
 38        self.lbd_func = lambda_.Function(
 39            self,
 40            "LambdaFunction",
 41            function_name=self.lbd_func_name,
 42            code=lambda_.Code.from_asset(f"{dir_lambda_app}"),
 43            handler=f"lambda_function.lambda_handler",
 44            runtime=lambda_.Runtime.PYTHON_3_10,
 45            memory_size=128,
 46            timeout=cdk.Duration.seconds(3),
 47            environment={
 48                "MD5": md5,
 49            },
 50            current_version_options=lambda_.VersionOptions(
 51                removal_policy=cdk.RemovalPolicy.RETAIN,
 52                retry_attempts=1,
 53            ),
 54        )
 55
 56        if version1 is None:
 57            if is_canary:
 58                self.create_alias_for_canary()
 59            else:
 60                self.create_alias_for_blue_green()
 61        else:
 62            self.create_alias_manually(
 63                version1=version1,
 64                version2=version2,
 65                version2_weight=version2_weight,
 66            )
 67
 68    def create_alias_for_blue_green(self):
 69        print("using blue green deployment")
 70        self.lbd_func_alias = lambda_.Alias(
 71            self,
 72            "AliasLive",
 73            alias_name=self.alias_name,
 74            version=self.lbd_func.current_version,
 75        )
 76
 77    def _ref_version(self, vx: str, version: str) -> lambda_.Version:
 78        return lambda_.Version.from_version_arn(
 79            self,
 80            f"LambdaVersion{vx}ForLive-{self.lbd_func_name}",
 81            version_arn=f"arn:aws:lambda:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:function:{self.lbd_func_name}:{version}",
 82        )
 83
 84    def create_alias_for_canary(self):
 85        """ """
 86        print("using canary deployment")
 87        rc = get_alias_routing_config(
 88            bsm.lambda_client,
 89            self.lbd_func_name,
 90            self.alias_name,
 91        )
 92        print(f"current routing config: {rc}")
 93        if rc is None:
 94            print("alias not found, create a new alias")
 95            self.lbd_func_alias = lambda_.Alias(
 96                self,
 97                "AliasLive",
 98                alias_name=self.alias_name,
 99                version=self.lbd_func.current_version,
100            )
101        elif bool(rc.version2_weight) is False:  # version2 could be None or 0
102            print(
103                "current route config only has one version! "
104                f"new version 1 = {rc.version1} + 1, "
105                f"new version 2 = {rc.version1}"
106            )
107            self.lbd_func_alias = lambda_.Alias(
108                self,
109                "AliasLive",
110                alias_name=self.alias_name,
111                version=self.lbd_func.current_version,
112                additional_versions=[
113                    lambda_.VersionWeight(
114                        version=self._ref_version("2", rc.version1),
115                        weight=round((100 - canary_increments[0]) / 100, 2),
116                    )
117                ],
118            )
119        else:
120            print(
121                "current route config has two version, "
122                "gradually increase the weight of version1 to 100%"
123            )
124            for threshold in canary_increments:
125                if rc.version1_weight < threshold:
126                    self.lbd_func_alias = lambda_.Alias(
127                        self,
128                        "AliasLive",
129                        alias_name=self.alias_name,
130                        version=self._ref_version("1", rc.version1),
131                        additional_versions=[
132                            lambda_.VersionWeight(
133                                version=self._ref_version("2", rc.version2),
134                                weight=round((100 - threshold) / 100, 2),
135                            )
136                        ],
137                    )
138                    return
139            self.lbd_func_alias = lambda_.Alias(
140                self,
141                "AliasLive",
142                alias_name=self.alias_name,
143                version=lambda_.Version.from_version_arn(
144                    self,
145                    f"LambdaVersion1ForLive-{self.lbd_func_name}",
146                    version_arn=f"arn:aws:lambda:{cdk.Aws.REGION}:{cdk.Aws.ACCOUNT_ID}:function:{self.lbd_func_name}:{rc.version1}",
147                ),
148            )
149
150    def create_alias_manually(
151        self,
152        version1: T.Union[str, int],
153        version2: T.Optional[T.Union[str, int]] = None,
154        version2_weight: T.Optional[float] = None,
155    ):
156        print("configure routing manually")
157        kwargs = {"version": self._ref_version("1", str(version1))}
158        if version2 is not None:
159            kwargs["additional_versions"] = [
160                lambda_.VersionWeight(
161                    version=self._ref_version("2", str(version2)),
162                    weight=version2_weight,
163                )
164            ]
165        self.lbd_func_alias = lambda_.Alias(
166            self,
167            "AliasLive",
168            alias_name=self.alias_name,
169            **kwargs,
170        )
171
172
173app = cdk.App()
174
175stack = Stack(
176    app,
177    "MyApp",
178    stack_name="deploy-lambda-with-aws-cdk-test",
179    func_name="deploy_lambda_with_aws_cdk_test",
180    md5=uuid.uuid4().hex,  # use random value to force new version
181    # md5="a1b2", # manually specify md5 env var
182    # is_canary=False, # use blue / green
183    is_canary=True,  # use canary
184    # version1=None, # rollback to specific version
185    # version2=None, # rollback to specific version
186    # version2_weight=None, # rollback to specific version
187)
188
189app.synth()

下面的 bash command 是我用来创建虚拟环境, 安装依赖, 执行部署所用的命令.

# create virtualenv
virtualenv -p python3.10 .venv

# activate virtualenv
source .venv/bin/activate

# install dependencies
pip install -r requirements.txt

# deploy
python cdk_deploy.py

# delete
python cdk_delete.py