Go-无服务应用实用指南(二)

141 阅读29分钟

Go 无服务应用实用指南(二)

原文:zh.annas-archive.org/md5/862FBE1FF9A9C074341990A4C2200D42

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:部署您的无服务器应用程序

在之前的章节中,我们学习了如何从头开始构建一个无服务器 API。在本章中,我们将尝试完成以下内容:

  • 通过一些高级 AWS CLI 命令构建、部署和管理我们的 Lambda 函数

  • 发布 API 的多个版本

  • 学习如何使用别名分隔多个部署环境(沙盒、暂存和生产)

  • 覆盖 API Gateway 阶段变量的使用,以更改方法端点的行为。

Lambda CLI 命令

在本节中,我们将介绍各种 AWS Lambda 命令,您可能在构建 Lambda 函数时使用。我们还将学习如何使用它们来自动化您的部署过程。

列出函数命令

如果您还记得,此命令是在第二章中引入的,开始使用 AWS Lambda。顾名思义,它会列出您提供的 AWS 区域中的所有 Lambda 函数。以下命令将返回北弗吉尼亚地区的所有 Lambda 函数:

aws lambda list-functions --region us-east-1

对于每个函数,响应中都包括函数的配置信息(FunctionName、资源使用情况、Environment变量、IAM 角色、Runtime环境等),如下截图所示:

要仅列出一些属性,例如函数名称,可以使用query筛选选项,如下所示:

aws lambda list-functions --query Functions[].FunctionName[]

创建函数命令

如果您已经阅读了前面的章节,您应该对此命令很熟悉,因为它已经多次用于从头开始创建新的 Lambda 函数。

除了函数的配置,您还可以使用该命令以两种方式提供部署包(ZIP):

  • ZIP 文件:它使用--zip-file选项提供代码的 ZIP 文件路径:
aws lambda create-function --function-name UpdateMovie \
 --description "Update an existing movie" \
 --runtime go1.x \
 --role arn:aws:iam::ACCOUNT_ID:role/UpdateMovieRole \
 --handler main \
 --environment Variables={TABLE_NAME=movies} \
 --zip-file fileb://./deployment.zip \
 --region us-east-1a
  • S3 存储桶对象:它使用--code选项提供 S3 存储桶和对象名称:
aws lambda create-function --function-name UpdateMovie \
 --description "Update an existing movie" \
 --runtime go1.x \
 --role arn:aws:iam::ACCOUNT_ID:role/UpdateMovieRole \
 --handler main \
 --environment Variables={TABLE_NAME=movies} \
 --code S3Bucket=movies-api-deployment-package,S3Key=deployment.zip \
 --region us-east-1

如上述命令将以 JSON 格式返回函数设置的摘要,如下所示:

值得一提的是,在创建 Lambda 函数时,您可以根据函数的行为覆盖计算使用率和网络设置,使用以下选项:

  • --timeout:默认执行超时时间为三秒。当达到三秒时,AWS Lambda 终止您的函数。您可以设置的最大超时时间为五分钟。

  • --memory-size:执行函数时分配给函数的内存量。默认值为 128 MB,最大值为 3,008 MB(以 64 MB 递增)。

  • --vpc-config:这将在私有 VPC 中部署 Lambda 函数。虽然如果函数需要与内部资源通信,这可能很有用,但最好避免,因为它会影响 Lambda 的性能和扩展性(这将在即将到来的章节中讨论)。

AWS 不允许您设置函数的 CPU 使用率,因为它是根据为函数分配的内存自动计算的。 CPU 使用率与内存成比例。

更新函数代码命令

除了 AWS 管理控制台外,您还可以使用 AWS CLI 更新 Lambda 函数的代码。该命令需要目标 Lambda 函数名称和新的部署包。与上一个命令类似,您可以按以下方式提供包:

  • .zip文件的路径:
aws lambda update-function-code --function-name UpdateMovie \
    --zip-file fileb://./deployment-1.0.0.zip \
    --region us-east-1
  • 存储.zip文件的 S3 存储桶:
aws lambda update-function-code --function-name UpdateMovie \
    --s3-bucket movies-api-deployment-packages \
    --s3-key deployment-1.0.0.zip \
    --region us-east-1

此操作会为 Lambda 函数代码中的每个更改打印一个新的唯一 ID(称为RevisionId):

获取函数配置命令

为了检索 Lambda 函数的配置信息,请发出以下命令:

aws lambda get-function-configuration --function-name UpdateMovie --region us-east-1

前面的命令将以输出提供与使用create-function命令时显示的相同信息。

要检索特定 Lambda 版本或别名的配置信息(下一节),您可以使用--qualifier选项。

调用命令

到目前为止,我们直接从 AWS Lambda 控制台和通过 API Gateway 的 HTTP 事件调用了我们的 Lambda 函数。除此之外,Lambda 还可以通过 AWS CLI 使用invoke命令进行调用:

aws lambda invoke --function-name UpdateMovie result.json

上述命令将调用UpdateMovie函数,并将函数的输出保存在result.json文件中:

状态码为 400,这是正常的,因为UpdateFunction需要 JSON 输入。让我们看看如何使用invoke命令向我们的函数提供 JSON。

返回到 DynamoDB 的movies表,并选择要更新的电影。在本例中,我们将更新 ID 为 13 的电影,如下所示:

创建一个包含新电影项目属性的body属性的 JSON 文件,因为 Lambda 函数期望输入以 API Gateway 代理请求格式呈现:

{
  "body": "{\"id\":\"13\", \"name\":\"Deadpool 2\"}"
}

最后,再次运行invoke函数命令,将 JSON 文件作为输入参数:

aws lambda invoke --function UpdateMovie --payload file://input.json result.json

如果打印result.json的内容,更新后的电影应该会返回,如下所示:

您可以通过调用FindAllMovies函数来验证 DynamoDB 表中电影的名称是否已更新:

aws lambda invoke --function-name FindAllMovies result.json

body属性应该包含新更新的电影,如下所示:

返回到 DynamoDB 控制台;ID 为 13 的电影应该有一个新的名称,如下截图所示:

删除函数命令

要删除 Lambda 函数,您可以使用以下命令:

aws lambda delete-function --function-name UpdateMovie

默认情况下,该命令将删除所有函数版本和别名。要删除特定版本或别名,您可能需要使用--qualifier选项。

到目前为止,您应该熟悉了在构建 AWS Lambda 中的无服务器应用程序时可能使用和需要的所有 AWS CLI 命令。在接下来的部分中,我们将看到如何创建 Lambda 函数的不同版本,并使用别名维护多个环境。

版本和别名

在构建无服务器应用程序时,您必须将部署环境分开,以便在不影响生产的情况下测试新更改。因此,拥有多个 Lambda 函数版本是有意义的。

版本控制

版本代表了您函数代码和配置在某个时间点的状态。默认情况下,每个 Lambda 函数都有一个$LATEST版本,指向您函数的最新更改,如下截图所示:

要从$LATEST版本创建新版本,请单击“操作”并选择“发布新版本”。让我们称其为1.0.0,如下截图所示:

新版本将创建一个 ID=1(递增)。请注意以下截图中窗口顶部的 ARN Lambda 函数;它具有版本 ID:

版本创建后,您无法更新函数代码,如下所示:

此外,高级设置,如 IAM 角色、网络配置和计算使用情况,无法更改,如下所示:

版本被称为不可变,这意味着一旦发布,它们就无法更改;只有$LATEST版本是可编辑的。

现在,我们知道如何从控制台发布新版本。让我们使用 AWS CLI 发布一个新版本。但首先,我们需要更新FindAllMovies函数,因为如果自从发布版本1.0.0以来对$LATEST没有进行任何更改,我们就无法发布新版本。

新版本将具有分页系统。该函数将仅返回用户请求的项目数量。以下代码将读取Count头参数,将其转换为数字,并使用带有Limit参数的Scan操作从 DynamoDB 中获取电影:

func findAll(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
  size, err := strconv.Atoi(request.Headers["Count"])
  if err != nil {
    return events.APIGatewayProxyResponse{
      StatusCode: http.StatusBadRequest,
      Body: "Count Header should be a number",
    }, nil
  }

  ...

  svc := dynamodb.New(cfg)
  req := svc.ScanRequest(&dynamodb.ScanInput{
    TableName: aws.String(os.Getenv("TABLE_NAME")),
    Limit: aws.Int64(int64(size)),
  })

  ...
}

接下来,使用update-function-code命令更新FindAllMovies Lambda 函数的代码:

aws lambda update-function-code --function-name FindAllMovies \
    --zip-file fileb://./deployment.zip

然后,基于当前配置和代码,使用以下命令发布一个新版本1.1.0

aws lambda publish-version --function-name FindAllMovies --description 1.1.0

返回到 AWS Lambda 控制台,导航到您的FindAllMovies;应该创建一个新版本,ID=2,如下截图所示:

现在我们的版本已经创建,让我们通过使用 AWS CLI invoke命令来测试它们。

FindAllMovies v1.0.0

使用以下命令在限定参数中调用FindAllMovies v1.0.0 版本:

aws lambda invoke --function-name FindAllMovies --qualifier 1 result.json

result.json应该包含 DynamoDBmovies表中的所有电影,如下所示:

输出显示 DynamoDB 电影表中的所有电影

FindAllMovies v1.1.0

创建一个名为input.json的新文件,并粘贴以下内容。此函数的版本需要一个名为Count的 Header 参数,用于返回电影的数量:

{
  "headers": {
    "Count": "4"
  }
}

执行该函数,但这次使用--payload参数和指向input.json文件的路径位置:

aws lambda invoke --function-name FindAllMovies --payload file://input.json
    --qualifier 2 result.json

result.json应该只包含四部电影,如下所示:

这就是如何创建多个版本的 Lambda 函数。但是,Lambda 函数版本控制的最佳实践是什么?

语义化版本控制

当您发布 Lambda 函数的新版本时,应该给它一个重要且有意义的版本名称,以便您可以通过其开发周期跟踪对函数所做的不同更改。

当您构建一个将被数百万客户使用的公共无服务器 API 时,您命名不同 API 版本的方式至关重要,因为它允许您的客户知道新版本是否引入了破坏性更改。它还让他们选择合适的时间升级到最新版本,而不会冒太多破坏他们流水线的风险。

这就是语义化版本控制(semver.org)的作用,它是一种使用三个数字序列的版本方案:

每个数字都根据以下规则递增:

  • 主要:如果 Lambda 函数与先前版本不兼容,则递增。

  • 次要:如果新功能或特性已添加到函数中,并且仍然向后兼容,则递增。

  • 补丁:如果修复了错误和问题,并且函数仍然向后兼容,则递增。

例如,FindAllMovies函数的版本1.1.0是第一个主要版本,带有一个次要版本带来了一个新功能(分页系统)。

别名

别名是指向特定版本的指针,它允许您将函数从一个环境提升到另一个环境(例如从暂存到生产)。别名是可变的,而版本是不可变的。

为了说明别名的概念,我们将创建两个别名,如下图所示:一个指向FindAllMovies Lambda 函数1.0.0版本的Production别名,和一个指向函数1.1.0版本的Staging别名。然后,我们将配置 API Gateway 使用这些别名,而不是$LATEST版本:

返回到FindAllMovies配置页面。如果单击Qualifiers下拉列表,您应该看到一个名为Unqualified的默认别名,指向您的$LATEST版本,如下截图所示:

要创建一个新别名,单击操作,然后创建一个名为Staging的新别名。选择5版本作为目标,如下所示:

创建后,新版本应添加到别名列表中,如下所示:

接下来,使用 AWS 命令行为Production环境创建一个指向版本1.0.0的新别名:

aws lambda create-alias --function-name FindAllMovies \
    --name Production --description "Production environment" \
    --function-version 1

同样,新别名应成功创建:

现在我们已经创建了别名,让我们配置 API Gateway 以使用这些别名和阶段变量

阶段变量

阶段变量是环境变量,可用于在每个部署阶段运行时更改 API Gateway 方法的行为。接下来的部分将说明如何在 API Gateway 中使用阶段变量。

在 API Gateway 控制台上,导航到Movies API,单击GET方法,并更新目标 Lambda 函数,以使用阶段变量而不是硬编码的 Lambda 函数名称,如下截图所示:

当您保存时,将会出现一个新的提示,要求您授予 API Gateway 调用 Lambda 函数别名的权限,如下截图所示:

执行以下命令以允许 API Gateway 调用ProductionStaging别名:

  • Production 别名:
aws lambda add-permission --function-name "arn:aws:lambda:us-east-1:ACCOUNT_ID:function:FindAllMovies:Production" \
 --source-arn "arn:aws:execute-api:us-east-1:ACCOUNT_ID:API_ID/*/GET/movies" \
 --principal apigateway.amazonaws.com \
 --statement-id STATEMENT_ID \
 --action lambda:InvokeFunction
  • Staging 别名:
aws lambda add-permission --function-name "arn:aws:lambda:us-east-1:ACCOUNT_ID:function:FindAllMovies:Staging" \
 --source-arn "arn:aws:execute-api:us-east-1:ACCOUNT_ID:API_ID/*/GET/movies" \
 --principal apigateway.amazonaws.com \
 --statement-id STATEMENT_ID \
 --action lambda:InvokeFunction

然后,创建一个名为production的新阶段,如下截图所示:

接下来,单击Stages Variables选项卡,并创建一个名为lambda的新阶段变量,并将FindAllMovies:Production设置为值,如下所示:

对于staging环境,使用指向 Lambda 函数Staging别名的lambda变量进行相同操作,如下所示:

要测试端点,请使用cURL命令或您熟悉的任何 REST 客户端。我选择了 Postman。在 API Gateway 的production阶段调用的 URL 上使用GET方法应该返回数据库中的所有电影,如下所示:

对于staging环境,执行相同操作,使用名为Count=4的新Header键;您应该只返回四个电影项目,如下所示:

这就是您可以维护 Lambda 函数的多个环境的方法。现在,您可以通过将Production指针从1.0.0更改为1.1.0而轻松将1.1.0版本推广到生产环境,并在失败时回滚到以前的工作版本,而无需更改 API Gateway 设置。

摘要

AWS CLI 对于创建自动化脚本来管理 AWS Lambda 函数非常有用。

版本是不可变的,一旦发布就无法更改。另一方面,别名是动态的,它们的绑定可以随时更改以实现代码推广或回滚。采用 Lambda 函数版本的语义化版本控制可以更容易地跟踪更改。

在下一章中,我们将学习如何从头开始设置 CI/CD 流水线,以自动化部署 Lambda 函数到生产环境的过程。我们还将介绍如何在持续集成工作流程中使用别名和版本。

第七章:实施 CI/CD 流水线

本章将讨论高级概念,如:

  • 如何建立一个高度弹性和容错的 CI/CD 流水线,自动化部署您的无服务器应用程序

  • 拥有一个用于 Lambda 函数的集中式代码存储库的重要性

  • 如何自动部署代码更改到生产环境。

技术要求

在开始本章之前,请确保您已经创建并上传了之前章节中构建的函数的源代码到一个集中的 GitHub 存储库。此外,强烈建议具有 CI/CD 概念的先前经验。本章的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Serverless-Applications-with-Go

持续集成和部署工作流

持续集成、持续部署和持续交付是加速软件上市时间并通过反馈推动创新的绝佳方式,同时确保在每次迭代中构建高质量产品。但这些实践意味着什么?在构建 AWS Lambda 中的无服务器应用程序时,如何应用这些实践?

持续集成

持续集成CI)是指拥有一个集中的代码存储库,并在将所有更改和功能整合到中央存储库之前,通过一个复杂的流水线进行处理的过程。经典的 CI 流水线在代码提交时触发构建,运行单元测试和所有预整合测试,构建构件,并将结果推送到构件管理存储库。

持续部署

持续部署CD)是持续集成的延伸。通过持续集成流水线的所有阶段的每个更改都会自动发布到您的暂存环境。

持续交付

持续交付CD)与 CD 类似,但在将发布部署到生产环境之前需要人工干预或业务决策。

现在这些实践已经定义,您可以使用这些概念来利用自动化的力量,并构建一个端到端的部署流程,如下图所示:

在接下来的章节中,我们将介绍如何使用最常用的 CI 解决方案构建这个流水线。

为了说明这些概念,只使用FindAllMovies函数的代码,但相同的步骤可以应用于其他 Lambda 函数。

自动化部署 Lambda 函数

在本节中,我们将看到如何构建一个流水线,以不同的方式自动化部署前一章中构建的 Lambda 函数的部署过程。

  • 由 AWS 管理的解决方案,如 CodePipeline 和 CodeBuild

  • 本地解决方案,如 Jenkins

  • SaaS 解决方案,如 Circle CI

使用 CodePipeline 和 CodeBuild 进行持续部署

AWS CodePipeline 是一个工作流管理工具,允许您自动化软件的发布和部署过程。用户定义一组步骤,形成一个可以在 AWS 托管服务(如 CodeBuild 和 CodeDeploy)或第三方工具(如 Jenkins)上执行的 CI 工作流。

在本例中,AWS CodeBuild 将用于测试、构建和部署您的 Lambda 函数。因此,应在代码存储库中创建一个名为buildspec.yml的构建规范文件。

buildspec.yml定义了将在 CI 服务器上执行的一组步骤,如下所示:

version: 0.2
env:
 variables:
 S3_BUCKET: "movies-api-deployment-packages"
 PACKAGE: "github.com/mlabouardy/lambda-codepipeline"

phases:
 install:
 commands:
 - mkdir -p "/go/src/$(dirname ${PACKAGE})"
 - ln -s "${CODEBUILD_SRC_DIR}" "/go/src/${PACKAGE}"
 - go get -u github.com/golang/lint/golint

 pre_build:
 commands:
 - cd "/go/src/${PACKAGE}"
 - go get -t ./...
 - golint -set_exit_status
 - go vet .
 - go test .

 build:
 commands:
 - GOOS=linux go build -o main
 - zip $CODEBUILD_RESOLVED_SOURCE_VERSION.zip main
 - aws s3 cp $CODEBUILD_RESOLVED_SOURCE_VERSION.zip s3://$S3_BUCKET/

 post_build:
 commands:
 - aws lambda update-function-code --function-name FindAllMovies --s3-bucket $S3_BUCKET --s3-key $CODEBUILD_RESOLVED_SOURCE_VERSION.zip

构建规范分为以下四个阶段:

  • 安装

  • 设置 Go 工作空间

  • 安装 Go linter

  • 预构建:

  • 安装 Go 依赖项

  • 检查我们的代码是否格式良好,并遵循 Go 的最佳实践和常见约定

  • 使用go test命令运行单元测试

  • 构建

  • 使用go build命令构建单个二进制文件

  • 从生成的二进制文件创建一个部署包.zip

  • .zip文件存储在 S3 存储桶中

  • 后构建

  • 使用新的部署包更新 Lambda 函数的代码

单元测试命令将返回一个空响应,因为我们将在即将到来的章节中编写我们的 Lambda 函数的单元测试。

源提供者

现在我们的工作流已经定义,让我们创建一个持续部署流水线。打开 AWS 管理控制台(console.aws.amazon.com/console/home),从开发人员工具部分导航到 AWS CodePipeline,并创建一个名为 MoviesAPI 的新流水线,如下图所示:

在源位置页面上,选择 GitHub 作为源提供者,如下图所示:

除了 GitHub,AWS CodePipeline 还支持 Amazon S3 和 AWS CodeCommit 作为代码源提供者。

点击“连接到 GitHub”按钮,并授权 CodePipeline 访问您的 GitHub 存储库;然后,选择存储代码的 GitHub 存储库和要构建的目标 git 分支,如下图所示:

构建提供者

在构建阶段,选择 AWS CodeBuild 作为构建服务器。Jenkins 和 Solano CI 也是支持的构建提供者。请注意以下截图:

在创建流水线的下一步是定义一个新的 CodeBuild 项目,如下所示:

将构建服务器设置为带有 Golang 的 Ubuntu 实例作为运行时环境,如下图所示:

构建环境也可以基于 DockerHub 上公开可用的 Docker 镜像或私有注册表,例如弹性容器注册表ECR)。

CodeBuild 将在 S3 存储桶中存储构件(部署包),并更新 Lambda 函数的FindAllMovies代码。因此,应该附加一个具有以下策略的 IAM 角色:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "VisualEditor0",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:GetObject",
        "lambda:UpdateFunctionCode"
      ],
      "Resource": [
        "arn:aws:s3:::movies-api-deployment-packages/*",
        "arn:aws:lambda:us-east-1:305929695733:function:FindAllMovies"
      ]
    }
  ]
}

在上述代码块中,arn:aws:lambda:us-east-1帐户 ID 应该替换为您的帐户 ID。

部署提供者

项目构建完成后,在流水线中配置的下一步是部署到一个环境。在本章中,我们将选择无部署选项,并让 CodeBuild 使用 AWS CLI 将新代码部署到 Lambda,如下图所示:

这个部署过程需要解释无服务器应用程序模型和 CloudFormation,这将在后续章节中详细解释。

审查详细信息;当您准备好时,点击保存,将创建一个新的流水线,如下所示:

流水线将启动,并且构建阶段将失败,如下图所示:

如果我们点击“详细信息”链接,它将带您到该特定构建的 CodeBuild 项目页面。可以在这里看到描述构建规范文件的阶段:

如图所示,预构建阶段失败了;在底部的日志部分,我们可以看到这是由于golint命令:

在 Golang 中,所有顶级的、公开的名称(大写)都应该有文档注释。因此,应该在 Movie 结构声明的顶部添加一个新的注释,如下所示:

// Movie entity
type Movie struct {
  ID string `json:"id"`
  Name string `json:"name"`
}

将新更改提交到 GitHub,新的构建将触发流水线的执行:

您可能想知道如何将代码更改推送到代码存储库会触发新的构建。答案是 GitHub Webhooks。当您创建 CodeBuild 项目时,GitHub 存储库中会自动创建一个新的 Webhook。因此,所有对代码存储库的更改都会通过 CI 流水线,如下截图所示:

一旦流水线完成,所有 CodeBuild 阶段都应该通过,如下截图所示:

打开 S3 控制台,然后单击流水线使用的存储桶;新的部署包应该以与提交 ID 相同的键名存储:

最后,CodeBuild 将使用update-function-code命令更新 Lambda 函数的代码。

使用 Jenkins 的连续管道

多年来,Jenkins 一直是首选工具。它是一个用 Java 编写的开源持续集成服务器,构建在 Hudson 项目之上。由于其插件驱动的架构和丰富的生态系统,它具有很高的可扩展性。

在接下来的部分中,我们将使用 Jenkins 编写我们的第一个Pipeline as Code,但首先我们需要设置我们的 Jenkins 环境。

分布式构建

要开始,请按照此指南中的官方说明安装 Jenkins:jenkins.io/doc/book/installing/。一旦 Jenkins 启动并运行,将浏览器指向http://instance_ip:8080。此链接将打开 Jenkins 仪表板,如下截图所示:

使用 Jenkins 的一个优势是其主/从架构。它允许您设置一个 Jenkins 集群,其中有多个负责构建应用程序的工作节点(代理)。这种架构有许多好处:

  • 响应时间,队列中等待构建的作业不多

  • 并发构建数量增加

  • 支持多个平台

以下步骤描述了为 Jenkins 构建服务器启动新工作节点的配置过程。工作节点是一个 EC2 实例,安装了最新稳定版本的JDK8Golang(有关说明,请参见第二章,使用 AWS Lambda 入门)。

工作节点运行后,将其 IP 地址复制到剪贴板,返回 Jenkins 主控台,单击“管理 Jenkins”,然后单击“管理节点”。单击“新建节点”,给工作节点命名,并选择永久代理,如下截图所示:

然后,将节点根目录设置为 Go 工作空间,并粘贴节点的 IP 地址并选择 SSH 密钥,如下所示:

如果一切配置正确,节点将上线,如下所示:

设置 Jenkins 作业

现在我们的集群已部署,我们可以编写我们的第一个 Jenkins 流水线。这个流水线定义在一个名为Jenkinsfile的文本文件中。这个定义文件必须提交到 Lambda 函数的代码存储库中。

Jenkins 必须安装Pipeline插件才能使用Pipeline as Code功能。这个功能提供了许多即时的好处,比如代码审查、回滚和版本控制。

考虑以下Jenkinsfile,它实现了一个基本的五阶段连续交付流水线,用于FindAllMovies Lambda 函数:

def bucket = 'movies-api-deployment-packages'

node('slave-golang'){
    stage('Checkout'){
        checkout scm
    }

    stage('Test'){
        sh 'go get -u github.com/golang/lint/golint'
        sh 'go get -t ./...'
        sh 'golint -set_exit_status'
        sh 'go vet .'
        sh 'go test .'
    }

    stage('Build'){
        sh 'GOOS=linux go build -o main main.go'
        sh "zip ${commitID()}.zip main"
    }

    stage('Push'){
        sh "aws s3 cp ${commitID()}.zip s3://${bucket}"
    }

    stage('Deploy'){
        sh "aws lambda update-function-code --function-name FindAllMovies \
                --s3-bucket ${bucket} \
                --s3-key ${commitID()}.zip \
                --region us-east-1"
    }
}

def commitID() {
    sh 'git rev-parse HEAD > .git/commitID'
    def commitID = readFile('.git/commitID').trim()
    sh 'rm .git/commitID'
    commitID
}

流水线使用基于 Groovy 语法的领域特定语言DSL)编写,并将在我们之前添加到集群的节点上执行。每次对 GitHub 存储库进行更改时,您的更改都将经过多个阶段:

  • 检查来自源代码控制的代码

  • 运行单元和质量测试

  • 构建部署包并将此构件存储到 S3 存储桶

  • 更新FindAllMovies函数的代码

请注意使用 git 提交 ID 作为部署包的名称,以便为每个发布提供有意义且重要的名称,并且如果出现问题,可以回滚到特定的提交。

现在我们的管道已经定义好,我们需要通过单击“新建”在 Jenkins 上创建一个新作业。然后,为作业输入名称,并选择多分支管道。设置存储您的 Lambda 函数代码的 GitHub 存储库以及Jenkinsfile的路径如下:

在构建之前,必须在 Jenkins 工作程序上配置具有对 S3 的写访问权限和对 Lambda 的更新操作的 IAM 实例角色。

保存后,管道将在主分支上执行,并且作业应该变为绿色,如下所示:

管道完成后,您可以单击每个阶段以查看执行日志。在以下示例中,我们可以看到部署阶段的日志:

Git 钩子

最后,为了使 Jenkins 在您推送到代码存储库时触发构建,请从您的 GitHub 存储库中单击设置,然后在集成和服务中搜索Jenkins(GitHub 插件),并填写类似以下的 URL:

现在,每当您将代码推送到 GitHub 存储库时,完整的 Jenkins 管道将被触发,如下所示:

另一种使 Jenkins 在检测到更改时创建构建的方法是定期轮询目标 git 存储库(cron 作业)。这种解决方案效率有点低,但如果您的 Jenkins 实例在私有网络中,这可能是有用的。

使用 Circle CI 进行持续集成

CircleCI 是“CI/CD 即服务”。这是一个与基于 GitHub 和 BitBucket 的项目非常好地集成,并且内置支持 Golang 应用程序的平台。

在接下来的部分中,我们将看到如何使用 CircleCI 自动化部署我们的 Lambda 函数的过程。

身份和访问管理

使用您的 GitHub 帐户登录 Circle CI(circleci.com/vcs-authorize/)。然后,选择存储您的 Lambda 函数代码的存储库,然后单击“设置项目”按钮,以便 Circle CI 可以自动推断设置,如下面的屏幕截图所示:

与 Jenkins 和 CodeBuild 类似,CircleCI 将需要访问一些 AWS 服务。因此,需要一个 IAM 用户。返回 AWS 管理控制台,并创建一个名为circleci的新 IAM 用户。生成 AWS 凭据,然后从 CircleCI 项目的设置中点击“设置”,然后粘贴 AWS 访问和秘密密钥,如下面的屏幕截图所示:

确保附加了具有对 S3 读/写权限和 Lambda 函数的权限的 IAM 策略到 IAM 用户。

配置 CI 管道

现在我们的项目已经设置好,我们需要定义 CI 工作流程;为此,我们需要在.circleci文件夹中创建一个名为config.yml的定义文件,其中包含以下内容:

version: 2
jobs:
  build:
    docker:
      - image: golang:1.8

    working_directory: /go/src/github.com/mlabouardy/lambda-circleci

    environment:
        S3_BUCKET: movies-api-deployment-packages

    steps:
      - checkout

      - run:
         name: Install AWS CLI & Zip
         command: |
          apt-get update
          apt-get install -y zip python-pip python-dev
          pip install awscli

      - run:
          name: Test
          command: |
           go get -u github.com/golang/lint/golint
           go get -t ./...
           golint -set_exit_status
           go vet .
           go test .

      - run:
         name: Build
         command: |
          GOOS=linux go build -o main main.go
          zip $CIRCLE_SHA1.zip main

      - run:
          name: Push
          command: aws s3 cp $CIRCLE_SHA1.zip s3://$S3_BUCKET

      - run:
          name: Deploy
          command: |
            aws lambda update-function-code --function-name FindAllMovies \
                --s3-bucket $S3_BUCKET \
                --s3-key $CIRCLE_SHA1.zip --region us-east-1

构建环境将是 DockerHub 中 Go 官方 Docker 镜像。从该镜像中,将创建一个新容器,并按照steps部分中列出的命令执行:

  1. 从 GitHub 存储库中检出代码。

  2. 安装 AWS CLI 和 ZIP 命令。

  3. 执行自动化测试。

  4. 从源代码构建单个二进制文件并压缩部署包。与构建对应的提交 ID 将用作 zip 文件的名称(请注意使用CIRCLE_SHA1环境变量)。

  5. 将工件保存在 S3 存储桶中。

  6. 使用 AWS CLI 更新 Lambda 函数的代码。

一旦模板被定义并提交到 GitHub 存储库,将触发新的构建,如下所示:

当流水线成功运行时,它会是这个样子:

基本上就是这样。本章只是初步介绍了 CI/CD 流水线的功能,但应该为您开始实验和构建 Lambda 函数的端到端工作流提供了足够的基础。

总结

在本章中,我们学习了如何从头开始设置 CI/CD 流水线,自动化 Lambda 函数的部署过程,以及如何使用不同的 CI 工具和服务来实现这个解决方案,从 AWS 托管服务到高度可扩展的构建服务器。

在下一章中,我们将通过为我们的无服务器 API 编写自动化单元和集成测试,以及使用无服务器函数构建带有 REST 后端的单页面应用程序,构建这个流水线的改进版本。

问题

  1. 使用 CodeBuild 和 CodePipeline 为其他 Lambda 函数实现 CI/CD 流水线。

  2. 使用 Jenkins Pipeline 实现类似的工作流。

  3. 使用 CircleCI 实现相同的流水线。

  4. 在现有流水线中添加一个新阶段,如果当前的 git 分支是主分支,则发布一个新版本。

  5. 配置流水线,在每次部署或更新新的 Lambda 函数时,在 Slack 频道上发送通知。

第八章:扩展您的应用程序

本章是上一技术章节的一个短暂休息,我们将深入探讨以下内容:

  • 无服务器自动扩展的工作原理

  • Lambda 如何在高峰服务使用期间处理流量需求,而无需容量规划或定期扩展

  • AWS Lambda 如何使用并发性来并行创建多个执行以执行函数的代码

  • 它如何影响您的成本和应用程序性能。

技术要求

本章是上一章的后续,因为它将使用上一章中构建的无服务器 API;建议在处理本节之前先阅读上一章。

负载测试和扩展

在这部分中,我们将生成随机工作负载,以查看 Lambda 在传入请求增加时的表现。为了实现这一点,我们将使用负载测试工具,比如Apache Bench。在本章中,我将使用hey,这是一个基于 Go 的工具,由于 Golang 内置的并发性,它非常高效和快速,比传统的HTTP基准测试工具更快。您可以通过在终端中安装以下Go包来下载它:

go get -u github.com/rakyll/hey

确保$GOPATH变量被设置,以便能够在任何当前目录下执行hey命令,或者您可以将$HOME/go/bin文件夹添加到$PATH变量中。

Lambda 自动扩展

现在,我们准备通过执行以下命令来运行我们的第一个测试或负载测试:

hey -n 1000 -c 50 https://51cxzthvma.execute-api.us-east-1.amazonaws.com/staging/movies

如果您更喜欢 Apache Benchmark,可以通过将hey关键字替换为ab来使用相同的命令。

该命令将打开 50 个连接,并对 API Gateway 端点 URL 执行 1,000 个请求,用于FindAllMovies函数。在测试结束时,hey将显示有关总响应时间的信息以及每个请求的详细信息,如下所示:

确保用您自己的调用 URL 替换调用 URL。另外,请注意,截图的某些部分已被裁剪,以便只关注有用的内容。

除了总响应时间外,hey还输出了一个响应时间直方图,显示第一个请求花费更多时间(大约 2 秒)来响应,这可以解释为 Lambda 需要下载部署包并初始化新容器的冷启动。然而,其余的请求很快(不到 800 毫秒),这是由于热启动和使用先前请求的现有容器。

从先前的基准测试中,我们可以说 Lambda 在流量增加时保持了自动扩展的承诺;虽然这可能是一件好事,但它也有缺点,我们将在下一节中看到。

下游资源

在我们的 Movies API 示例中,DynamoDB 表已被用于解决无状态问题。该表要求用户提前定义读取和写入吞吐量容量,以创建必要的基础设施来处理定义的流量。在第五章中,使用 DynamoDB 管理数据持久性,我们使用了默认的吞吐量,即五个读取容量单位和五个写入容量单位。五个读取容量单位对于不太重读的 API 来说非常有效。在先前的负载测试中,我们创建了 50 个并发执行,也就是说,对movies表进行了 50 次并行读取。结果,表将遭受高读取吞吐量,并且Scan操作将变慢,DynamoDB 可能会开始限制请求。

我们可以通过转到 DynamoDB 控制台,并点击movies表的Metrics选项卡来验证这一点,如下截图所示:

显然,读取容量图经历了一个高峰期,导致读取请求被限制,并且表格被所有这些传入的请求压倒。

DynamoDB 的限流请求可以通过启用自动扩展机制来增加预留的读写容量以处理突然增加的流量,或者通过重用存储在内存缓存引擎中的查询结果(可以使用 AWS ElastiCache 与 Redis 或 Memcached 引擎等解决方案)来避免过载表并减少函数执行时间。但是,您无法限制和保护数据库资源免受 Lambda 函数扩展事件的影响。

私有 Lambda 函数

如果您的 Lambda 函数在私有 VPC 中运行,可能会出现并发问题,因为它需要将弹性网络接口(ENI)附加到 Lambda 容器,并等待分配 IP 地址。AWS Lambda 使用 ENI 安全连接到 VPC 中的内部资源。

除了性能不佳(附加 ENI 平均需要 4 秒),启用 VPC 的 Lambda 函数还需要您维护和配置一个用于互联网访问的 NAT 实例和多个可支持函数 ENI 扩展需求的 VPC 子网,这可能导致 VPC 的 IP 地址用尽。

总之,Lambda 函数的自动扩展是一把双刃剑;它不需要您进行容量规划。但是,它可能导致性能不佳和令人惊讶的月度账单。这就是并发执行模型发挥作用的地方。

并发执行

AWS Lambda 会根据流量增加动态扩展容量。但是,每次执行函数的代码数量是有限的。这个数量称为并发执行,它是根据 AWS 区域定义的。默认并发限制是每个 AWS 区域 1000 个。那么,如果您的函数超过了这个定义的阈值会发生什么呢?继续阅读以了解详情。

Lambda 限流

如果并发执行计数超过限制,Lambda 会对您的函数应用限流(速率限制)。因此,剩余的传入请求将不会调用该函数。

调用客户端负责根据返回的HTTP代码(429 =请求过多)实施基于退避策略的重试失败请求。值得一提的是,Lambda 函数可以配置为在一定数量的重试后将未处理的事件存储到名为死信队列的队列中。

在某些情况下,限流可能是有用的,因为并发执行容量是所有函数共享的(在我们的示例中,findupdateinsertdelete函数)。您可能希望确保一个函数不会消耗所有容量,并避免其他 Lambda 函数的饥饿。如果您的某个函数比其他函数更常用,这种情况可能经常发生。例如,考虑FindAllMovies函数。假设现在是假期季,很多客户会使用您的应用程序查看可租用的电影列表,这可能导致多次调用FindAllMovies Lambda 函数。

幸运的是,AWS 增加了一个新功能,允许您预先保留和定义每个 Lambda 函数的并发执行值。这个属性允许您为函数指定一定数量的保留并发,以确保您的函数始终具有足够的容量来处理即将到来的事件或请求。例如,您可以为您的函数设置如下速率限制:

  • FindAllMovies函数:500

  • InsertMovie函数:100

  • UpdateMovie函数:50

  • 剩下的将分享给其他人

在接下来的部分中,我们将看到如何为FindAllMovies定义保留的并发执行,并且它如何影响 API 的性能。

您可以使用以下公式估算并发执行计数:每秒事件/请求*函数持续时间

并发执行预留

导航到 AWS Lambda 控制台(console.aws.amazon.com/lambda/home)并单击 FindAllMovies 函数。在并发 部分,我们可以看到我们的函数仅受账户中可用并发总量的限制,该总量为1000,如下截图所示:

我们将通过在保留账户的并发字段中定义 10 来更改这一点。这样可以确保在任何给定时间内只有 10 个并行执行函数。这个值将从未保留账户的并发池中扣除,如下所示:

您可以设置的最大保留并发数是 900,因为 AWS Lambda 保留了 100 个用于其他函数,以便它们仍然可以处理请求和事件。

或者,可以使用 AWS CLI 与put-function-concurrency命令来设置并发限制:

aws lambda put-function-concurrency --function FindAllMovies --reserved-concurrent-executions 10

再次使用之前给出的相同命令生成一些工作负载:

hey -n 1000 -c 50 https://51cxzthvma.execute-api.us-east-1.amazonaws.com/staging/movies

这一次,结果将会不同,因为 1000 个请求中有 171 个失败,显示为 502 错误代码,如下所示:

超过 10 个并发执行时,将应用限流,并拒绝部分请求,返回 502 响应代码。

我们可以通过返回到函数控制台来确认这一点;我们应该看到类似于以下截图中显示的警告消息:

如果您打开与movies表相关的指标并跳转到读取容量图表,您会看到我们的读取容量仍然受到控制,并且低于定义的 5 个读取单位容量:

如果您计划对 Lambda 函数进行维护并希望暂时停止其调用,可以使用限流。这可以通过将函数并发设置为 0 来实现。

限流按预期工作,现在您正在保护下游资源免受 Lambda 函数过载的影响。

摘要

在本章中,我们了解到 Lambda 由于 AWS 区域设置的执行限制,无法无限扩展。这个限制可以通过联系 AWS 支持团队来提高。我们还介绍了函数级别的并发预留如何帮助您保护下游资源,如果您正在使用启用了 VPC 的 Lambda 函数,则匹配子网大小,并在开发和测试函数期间控制成本。

在下一章中,我们将在无服务器 API 的基础上构建一个用户友好的 UI,具有 S3 静态托管网站功能。

第九章:使用 S3 构建前端

在这一章中,我们将学习以下内容:

  • 如何构建一个消耗 API Gateway 响应的静态网站,使用 AWS 简单存储服务

  • 如何通过 CloudFront 分发优化对网站资产的访问,例如 JavaScript、CSS、图像

  • 如何为无服务器应用程序设置自定义域名

  • 如何创建 SSL 证书以使用 HTTPS 显示您的内容

  • 使用 CI/CD 管道自动化 Web 应用程序的部署过程。

技术要求

在继续本章之前,您应该对 Web 开发有基本的了解,并了解 DNS 的工作原理。本章的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Serverless-Applications-with-Go

单页应用程序

在本节中,我们将学习如何构建一个 Web 应用程序,该应用程序将调用我们在之前章节中构建的 API Gateway 调用 URL,并列出电影,如下所示:

对于每部电影,我们将显示其封面图像和标题。此外,用户可以通过单击右侧的按钮来按类别筛选电影。最后,如果用户单击导航栏上的“New”按钮,将弹出一个模态框,要求用户填写以下字段:

现在应用程序的模拟已经定义,我们将使用 JavaScript 框架快速构建 Web 应用程序。例如,我将使用当前最新稳定版本的 Angular 5。

使用 Angular 开发 Web 应用程序

Angular 是由 Google 开发的完全集成的框架。它允许您构建动态 Web 应用程序,而无需考虑选择哪些库以及如何处理日常问题。请记住,目标是要吸引大量观众,因此选择了 Angular 作为最常用的框架之一。但是,您可以选择您熟悉的任何框架,例如 React、Vue 或 Ember。

除了内置的可用模块外,Angular 还利用了单页应用程序(SPA)架构的强大功能。这种架构允许您在不刷新浏览器的情况下在页面之间导航,因此使应用程序更加流畅和响应,包括更好的性能(您可以预加载和缓存额外的页面)。

Angular 自带 CLI。您可以按照cli.angular.io上的逐步指南进行安装。本书专注于 Lambda。因此,本章仅涵盖了 Angular 的基本概念,以便让那些不是 Web 开发人员的人能够轻松理解。

一旦安装了Angular CLI,我们需要使用以下命令创建一个新的 Angular 应用程序:

ng new frontend

CLI 将生成基本模板文件并安装所有必需的npm依赖项以运行 Angular 5 应用程序。文件结构如下:

接下来,在frontend目录中,使用以下命令启动本地 Web 服务器:

ng serve

该命令将编译所有的TypeScripts文件,构建项目,并在端口4200上启动 Web 服务器:

打开浏览器并导航至localhost:4200。您在浏览器中应该看到以下内容:

现在我们的示例应用程序已构建并运行,让我们创建我们的 Web 应用程序。Angular 结构基于组件和服务架构(类似于模型-视图-控制器)。

生成您的第一个 Angular 组件

对于那些没有太多 Angular 经验的人来说,组件基本上就是 UI 的乐高积木。您的 Web 应用程序可以分为多个组件。每个组件都有以下文件:

  • COMPONENT_NAME.component.ts:用 TypeScript 编写的组件逻辑定义

  • COMPONENT_NAME.component.html:组件的 HTML 代码

  • COMPONENT_NAME.component.css:组件的 CSS 结构

  • COMPONENT_NAME.component.spec.ts:组件类的单元测试

在我们的示例中,我们至少需要三个组件:

  • 导航栏组件

  • 电影列表组件

  • 电影组件

在创建第一个组件之前,让我们安装Bootstrap,这是 Twitter 开发的用于构建吸引人用户界面的前端 Web 框架。它带有一组基于 CSS 的设计模板,用于表单、按钮、导航和其他界面组件,以及可选的 JavaScript 扩展。

继续在终端中安装 Bootstrap 4:

npm install bootstrap@4.0.0-alpha.6

接下来,在.angular-cli.json文件中导入 Bootstrap CSS 类,以便在应用程序的所有组件中使 CSS 指令可用:

"styles": [
   "styles.css",
   "../node_modules/bootstrap/dist/css/bootstrap.min.css"
]

现在我们准备通过发出以下命令来创建我们的导航栏组件:

ng generate component components/navbar

覆盖默认生成的navbar.component.html中的 HTML 代码,以使用 Bootstrap 框架提供的导航栏:

<nav class="navbar navbar-toggleable-md navbar-light bg-faded">
  <button class="navbar-toggler navbar-toggler-right" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
    <span class="navbar-toggler-icon"></span>
  </button>
  <a class="navbar-brand" href="#">Movies</a>

  <div class="collapse navbar-collapse" id="navbarSupportedContent">
    <ul class="navbar-nav mr-auto">
      <li class="nav-item active">
        <a class="nav-link" href="#">New <span class="sr-only">(current)</span></a>
      </li>
    </ul>
    <form class="form-inline my-2 my-lg-0">
      <input class="form-control mr-sm-2" type="text" placeholder="Search ...">
      <button class="btn btn-outline-success my-2 my-sm-0" type="submit">GO !</button>
    </form>
  </div>
</nav>

打开navbar.component.ts并将选择器属性更新为movies-navbar。这里的选择器只是一个标签,可以用来引用其他组件上的组件:

@Component({
  selector: 'movies-navbar',
  templateUrl: './navbar.component.html',
  styleUrls: ['./navbar.component.css']
})
export class NavbarComponent implements OnInit {
   ...
}

movies-navbar选择器需要添加到app.component.html文件中,如下所示:

<movies-navbar></movies-navbar> 

Angular CLI 使用实时重新加载。因此,每当我们的代码更改时,CLI 将重新编译,重新注入(如果需要),并要求浏览器刷新页面:

当添加movies-navbar标签时,navbar.component.html文件中的所有内容都将显示在浏览器中。

同样,我们将为电影项目创建一个新组件:

ng generate component components/movie-item

我们将在界面中将电影显示为卡片;用以下内容替换movie-item.component.html代码:

<div class="card" style="width: 20rem;">
  <img class="card-img-top" src="img/185x287" alt="movie title">
  <div class="card-block">
    <h4 class="card-title">Movie</h4>
    <p class="card-text">Some quick description</p>
    <a href="#" class="btn btn-primary">Rent</a>
  </div>
</div>

在浏览器中,您应该看到类似于这样的东西:

创建另一个组件来显示电影列表:

ng generate component components/list-movies

该组件将使用 Angular 的ngFor指令来遍历movies数组中的movie并通过调用movie-item组件打印出电影(这称为组合):

<div class="row">
  <div class="col-sm-3" *ngFor="let movie of movies">
    <movie-item></movie-item>
  </div>
</div>

movies数组在list-movies.component.ts中声明,并在类构造函数中初始化:

import { Component, OnInit } from '@angular/core';
import { Movie } from '../../models/movie';

@Component({
  selector: 'list-movies',
  templateUrl: './list-movies.component.html',
  styleUrls: ['./list-movies.component.css']
})
export class ListMoviesComponent implements OnInit {

  public movies: Movie[];

  constructor() {
    this.movies = [
      new Movie("Avengers", "Some description", "https://image.tmdb.org/t/p/w370_and_h556_bestv2/cezWGskPY5x7GaglTTRN4Fugfb8.jpg"),
      new Movie("Thor", "Some description", "https://image.tmdb.org/t/p/w370_and_h556_bestv2/bIuOWTtyFPjsFDevqvF3QrD1aun.jpg"),
      new Movie("Spiderman", "Some description"),
    ]
  }

  ...

}

Movie类是一个简单的实体,有三个字段,即namecoverdescription,以及用于访问和修改类属性的 getter 和 setter:

export class Movie {
  private name: string;
  private cover: string;
  private description: string;

  constructor(name: string, description: string, cover?: string){
    this.name = name;
    this.description = description;
    this.cover = cover ? cover : "http://via.placeholder.com/185x287";
  }

  public getName(){
    return this.name;
  }

  public getCover(){
    return this.cover;
  }

  public getDescription(){
    return this.description;
  }

  public setName(name: string){
    this.name = name;
  }

  public setCover(cover: string){
    this.cover = cover;
  }

  public setDescription(description: string){
    this.description = description;
  }
}

如果我们运行上述代码,我们将在浏览器中看到三部电影:

到目前为止,电影属性在 HTML 页面中是硬编码的,为了改变这一点,我们需要将电影项目传递给movie-item元素。更新movie-item.component.ts以添加一个新的电影字段,并使用Input注释来使用 Angular 输入绑定:

export class MovieItemComponent implements OnInit {
  @Input()
  public movie: Movie;

  ...
}

在前面组件的 HTML 模板中,使用Movie类的 getter 来获取属性的值:

<div class="card">
    <img class="card-img-top" [src]="movie.getCover()" alt="{{movie.getName()}}">
    <div class="card-block">
      <h4 class="card-title">{{movie.getName()}}</h4>
      <p class="card-text">{{movie.getDescription()}}</p>
      <a href="#" class="btn btn-primary">Rent</a>
    </div>
</div>

最后,使ListMoviesComponentMovieItemComponent子嵌套在*ngFor重复器中,并在每次迭代中将movie实例绑定到子的movie属性上:

<div class="row">
  <div class="col-sm-3" *ngFor="let movie of movies">
    <movie-item [movie]="movie"></movie-item>
  </div>
</div>

在浏览器中,您应该确保电影的属性已经正确定义:

到目前为止一切都很顺利。但是,电影列表仍然是静态的和硬编码的。我们将通过调用无服务器 API 从数据库动态检索电影列表来解决这个问题。

使用 Angular 访问 Rest Web 服务

在前几章中,我们创建了两个阶段,即stagingproduction环境。因此,我们应该创建两个环境文件,以指向正确的 API Gateway 部署阶段:

  • environment.ts:包含开发 HTTP URL:
export const environment = {
  api: 'https://51cxzthvma.execute-api.us-east-1.amazonaws.com/staging/movies'
};
  • environment.prod.ts:包含生产 HTTP URL:
export const environment = {
  api: 'https://51cxzthvma.execute-api.us-east-1.amazonaws.com/production/movies'
};

如果执行ng buildng serveenvironment对象将从environment.ts中读取值,并且如果使用ng build --prod命令将应用程序构建为生产模式,则将从environment.prod.ts中读取值。

要创建服务,我们需要使用命令行。命令如下:

ng generate service services/moviesApi

movies-api.service.ts将实现findAll函数,该函数将使用Http服务调用 API Gateway 的findAll端点。map方法将帮助将响应转换为 JSON 格式:

import { Injectable } from '@angular/core';
import { Http } from '@angular/http';
import 'rxjs/add/operator/map';
import { environment } from '../../environments/environment';

@Injectable()
  export class MoviesApiService {

    constructor(private http:Http) { }

    findAll(){
      return this.http
      .get(environment.api)
      .map(res => {
        return res.json()
      })
    }

}

在调用MoviesApiService之前,需要在app.module.ts的主模块中的提供程序部分导入它。

更新MoviesListComponent以调用新服务。在浏览器控制台中,您应该会收到有关 Access-Control-Allow-Origin 头在 API Gateway 返回的响应中不存在的错误消息。这将是即将到来部分的主题:

跨域资源共享

出于安全目的,如果外部请求与您的网站的确切主机、协议和端口不匹配,浏览器将阻止流。在我们的示例中,我们有不同的域名(localhost 和 API Gateway URL)。

这种机制被称为同源策略。为了解决这个问题,您可以使用 CORS 头、代理服务器或 JSON 解决方法。在本节中,我将演示如何在 Lambda 函数返回的响应中使用 CORS 头来解决此问题:

  1. 修改findAllMovie函数的代码以添加Access-Control-Allow-Origin:*以允许来自任何地方的跨域请求(或指定域而不是*):
return events.APIGatewayProxyResponse{
    StatusCode: 200,
    Headers: map[string]string{
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": "*",
    },
    Body: string(response),
  }, nil
  1. 提交您的更改;应触发新的构建。在 CI/CD 管道的最后,FindAllMovies Lambda 函数的代码将被更新。测试一下;您应该会在headers属性的一部分中看到新的密钥:

  1. 如果刷新 Web 应用程序页面,JSON 对象也将显示在控制台中:

  1. 更新list-movies.component.ts以调用MoviesApiServicefindAll函数。返回的数据将存储在movies变量中:
constructor(private moviesApiService: MoviesApiService) {
  this.movies = []

  this.moviesApiService.findAll().subscribe(res => {
    res.forEach(movie => {
    this.movies.push(new Movie(movie.name, "Some description"))
    })
  })
}
  1. 结果,电影列表将被检索并显示:

  1. 我们没有封面图片;您可以更新 DynamoDB 的movies表以添加图像和描述属性:

NoSQL 数据库允许您随时更改表模式,而无需首先定义结构,而关系数据库则要求您使用预定义的模式来确定在处理数据之前的结构。

  1. 如果刷新 Web 应用程序页面,您应该可以看到带有相应描述和海报封面的电影:

  1. 通过实现新的电影功能来改进此 Web 应用程序。由于用户需要填写电影的图像封面和描述,因此我们需要更新insert Lambda 函数,以在后端生成随机唯一 ID 的同时添加封面和描述字段:
svc := dynamodb.New(cfg)
req := svc.PutItemRequest(&dynamodb.PutItemInput{
  TableName: aws.String(os.Getenv("TABLE_NAME")),
  Item: map[string]dynamodb.AttributeValue{
    "ID": dynamodb.AttributeValue{
      S: aws.String(uuid.Must(uuid.NewV4()).String()),
    },
    "Name": dynamodb.AttributeValue{
      S: aws.String(movie.Name),
    },
    "Cover": dynamodb.AttributeValue{
      S: aws.String(movie.Cover),
    },
    "Description": dynamodb.AttributeValue{
      S: aws.String(movie.Description),
    },
  },
})
  1. 一旦新更改被推送到代码存储库并部署,打开您的 REST 客户端并发出 POST 请求以添加新的电影,其 JSON 方案如下:

  1. 应返回200成功代码,并且在 Web 应用程序中应列出新电影:

单页应用程序部分所示,当用户点击“新建”按钮时,将弹出一个带有创建表单的模态框。为了构建这个模态框并避免使用 jQuery,我们将使用另一个库,该库提供了一组基于 Bootstrap 标记和 CSS 的本机 Angular 指令:

  • 使用以下命令安装此库:
npm install --save @ng-bootstrap/ng-bootstrap@2.0.0
  • 安装后,需要将其导入到主app.module.ts模块中,如下所示:
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';

@NgModule({
  declarations: [AppComponent, ...],
  imports: [NgbModule.forRoot(), ...],
  bootstrap: [AppComponent]
})
export class AppModule {
}
  • 为了容纳创建表单,我们需要创建一个新的组件:
ng generate component components/new-movie
  • 该组件将有两个用于电影标题和封面链接的input字段。另外,还有一个用于电影描述的textarea元素:
<div class="modal-header">
 <h4 class="modal-title">New Movie</h4>
 <button type="button" class="close" aria-label="Close" (click)="d('Cross click')">
 <span aria-hidden="true">&times;</span>
 </button>
</div>
<div class="modal-body">
 <div *ngIf="showMsg" class="alert alert-success" role="alert">
 <b>Well done !</b> You successfully added a new movie.
 </div>
 <div class="form-group">
 <label for="title">Title</label>
 <input type="text" class="form-control" #title>
 </div>
 <div class="form-group">
 <label for="description">Description</label>
 <textarea class="form-control" #description></textarea>
 </div>
 <div class="form-group">
 <label for="cover">Cover</label>
 <input type="text" class="form-control" #cover>
 </div>
</div>
<div class="modal-footer">
   <button type="button" class="btn btn-success" (click)="save(title.value, description.value, cover.value)">Save</button>
</div>
  • 用户每次点击保存按钮时,将响应点击事件调用save函数。MoviesApiService服务中定义的insert函数调用 API Gateway 的insert端点上的POST方法:
insert(movie: Movie){
  return this.http
    .post(environment.api, JSON.stringify(movie))
    .map(res => {
    return res
  })
}
  • 在导航栏中的 New 元素上添加点击事件:
<a class="nav-link" href="#" (click)="newMovie(content)">New <span class="badge badge-danger">+</span></a>
  • 点击事件将调用newMovie并通过调用ng-bootstrap库的ModalService模块打开模态框:
import { Component, OnInit, Input } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';

@Component({
 selector: 'movies-navbar',
 templateUrl: './navbar.component.html',
 styleUrls: ['./navbar.component.css']
})
export class NavbarComponent implements OnInit {

 constructor(private modalService: NgbModal) {}

 ngOnInit() {}

 newMovie(content){
 this.modalService.open(content);
 }

}
  • 一旦编译了这些更改,从导航栏中单击“新建”项目,模态框将弹出。填写必填字段,然后单击保存按钮:

  • 电影将保存在数据库表中。如果刷新页面,电影将显示在电影列表中:

S3 静态网站托管

现在我们的应用程序已创建,让我们将其部署到远程服务器。不要维护 Web 服务器,如 EC2 实例中的 Apache 或 Nginx,让我们保持无服务器状态,并使用启用了 S3 网站托管功能的 S3 存储桶。

设置 S3 存储桶

要开始,可以从 AWS 控制台或使用以下 AWS CLI 命令创建一个 S3 存储桶:

aws s3 mb s3://serverlessmovies.com

接下来,为生产模式构建 Web 应用程序:

ng build --prod

--prod标志将生成代码的优化版本,并执行额外的构建步骤,如 JavaScript 和 CSS 文件的最小化,死代码消除和捆绑:

这将为您提供dist/目录,其中包含index.html和所有捆绑的js文件,准备用于生产。配置存储桶以托管网站:

aws s3 website s3://serverlessmovies.com  -- index-document index.html

将*dist/*文件夹中的所有内容复制到之前创建的 S3 存储桶中:

aws s3 cp --recursive dist/ s3://serverlessmovies.com/

您可以通过 S3 存储桶仪表板或使用aws s3 ls命令验证文件是否已成功存储:

默认情况下,创建 S3 存储桶时是私有的。因此,您应该使用以下存储桶策略使其公开访问:

{
  "Id": "Policy1529862214606",
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Stmt1529862213126",
      "Action": [
        "s3:GetObject"
      ],
      "Effect": "Allow",
      "Resource": "arn:aws:s3:::serverlessmovies.com/*",
      "Principal": "*"
    }
  ]
}

在存储桶配置页面上,单击“权限”选项卡,然后单击“存储桶策略”,将策略内容粘贴到编辑器中,然后保存。将弹出警告消息,指示存储桶已变为公共状态:

要访问 Web 应用程序,请将浏览器指向serverlessmovies.s3-website-us-east-1.amazonaws.com(用您自己的存储桶名称替换):

现在我们的应用程序已部署到生产环境,让我们创建一个自定义域名,以便用户友好地访问网站。为了将域流量路由到 S3 存储桶,我们将使用Amazon Route 53创建一个指向存储桶的别名记录。

设置 Route 53

如果您是 Route 53 的新手,请使用您拥有的域名创建一个新的托管区域,如下图所示。您可以使用现有的域名,也可以从亚马逊注册商或 GoDaddy 等外部 DNS 注册商购买一个域名。确保选择公共托管区域:

创建后,NSSOA记录将自动为您创建。如果您从 AWS 购买了域名,您可以跳过此部分。如果没有,您必须更改您从域名注册商购买的域名的名称服务器记录。在本例中,我从 GoDaddy 购买了serverlessmovies.com/域名,因此在域名设置页面上,我已将名称服务器更改为 AWS 提供的NS记录值,如下所示:

更改可能需要几分钟才能传播。一旦由注册商验证,跳转到Route 53并创建一个新的A别名记录,该记录指向我们之前创建的 S3 网站,方法是从下拉列表中选择目标 S3 存储桶:

完成后,您将能够打开浏览器,输入您的域名,并查看您的 Web 应用程序:

拥有一个安全的网站可以产生差异,并使用户更加信任您的 Web 应用程序,这就是为什么在接下来的部分中,我们将使用 AWS 提供的免费 SSL 来显示自定义域名的内容,并使用HTTPS

证书管理器

您可以轻松地通过**AWS 证书管理器(ACM)**获得 SSL 证书。点击“请求证书”按钮创建一个新的 SSL 证书:

选择请求公共证书并添加您的域名。您可能还希望通过添加一个星号来保护您的子域:

在两个域名下,点击 Route 53 中的创建记录按钮。这将自动在 Route 53 中创建一个CNAME记录集,并由 ACM 检查以验证您拥有这些域:

一旦亚马逊验证域名属于您,证书状态将从“待验证”更改为“已签发”:

然而,我们无法配置 S3 存储桶以使用我们的 SSL 来加密流量。这就是为什么我们将在 S3 存储桶前使用一个 CloudFront 分发,也被称为 CDN。

CloudFront 分发

除了使用 CloudFront 在网站上添加 SSL 终止外,CloudFront 主要用作内容交付网络(CDN),用于在世界各地的多个边缘位置存储静态资产(如 HTML 页面、图像、字体、CSS 和 JavaScript),从而实现更快的下载和更短的响应时间。

也就是说,导航到 CloudFront,然后创建一个新的 Web 分发。在原始域名字段中设置 S3 网站 URL,并将其他字段保留为默认值。您可能希望将HTTP流量重定向到HTTPS

接下来,选择我们在证书管理器部分创建的 SSL 证书,并将您的域名添加到备用域名(CNAME)区域:

点击保存,并等待几分钟,让 CloudFront 复制所有文件到 AWS 边缘位置:

一旦 CDN 完全部署,跳转到域名托管区域页面,并更新网站记录以指向 CloudFront 分发域:

如果您再次转到 URL,您应该会被重定向到HTTPS

随意创建一个新的CNAME记录用于 API Gateway URL。该记录可能是api.serverlessmovies.com,指向51cxzthvma.execute-api.us-east-1.amazonaws.com/production/movies

CI/CD 工作流

我们的无服务器应用程序已部署到生产环境。但是,为了避免每次实现新功能时都重复相同的步骤,我们可以创建一个 CI/CD 流水线,自动化前一节中描述的工作流程。我选择 CircleCI 作为 CI 服务器。但是,您可以使用 Jenkins 或 CodePipeline——请确保阅读前几章以获取更多详细信息。

如前几章所示,流水线应该在模板文件中定义。以下是用于自动化 Web 应用程序部署流程的流水线示例:

version: 2
jobs:
  build:
    docker:
      - image: node:10.5.0

    working_directory: ~/serverless-movies

    steps:
      - checkout

      - restore_cache:
          key: node-modules-{{checksum "package.json"}}

      - run:
          name: Install dependencies
          command: npm install && npm install -g @angular/cli

      - save_cache:
          key: node-modules-{{checksum "package.json"}}
          paths:
            - node_modules

      - run:
          name: Build assets
          command: ng build --prod --aot false

      - run:
          name: Install AWS CLI
          command: |
            apt-get update
            apt-get install -y awscli

      - run:
          name: Push static files
          command: aws s3 cp --recursive dist/ s3://serverlessmovies.com/

以下步骤将按顺序执行:

  • 从代码存储库检出更改

  • 安装 AWS CLI,应用程序 npm 依赖项和 Angular CLI

  • 使用ng build命令构建工件

  • 将工件复制到 S3 存储桶

现在,您的 Web 应用程序代码的所有更改都将通过流水线进行,并将自动部署到生产环境:

API 文档

在完成本章之前,我们将介绍如何为迄今为止构建的无服务器 API 创建文档。

在 API Gateway 控制台上,选择要为其生成文档的部署阶段。在下面的示例中,我选择了production环境。然后,单击“导出”选项卡,单击“导出为 Swagger”部分:

Swagger 是OpenAPI的实现,这是 Linux Foundation 定义的关于如何描述和定义 API 的标准。这个定义被称为OpenAPI 规范文档

您可以将文档保存为 JSON 或 YAML 文件。然后,转到editor.swagger.io/并将内容粘贴到网站编辑器上,它将被编译,并生成一个 HTML 页面,如下所示:

AWS CLI 也可以用于导出 API Gateway 文档,使用aws apigateway get-export --rest-api-id API_ID --stage-name STAGE_NAME --export-type swagger swagger.json命令。

API Gateway 和 Lambda 函数与无服务器应用程序类似。可以编写 CI/CD 来自动化生成文档,每当在 API Gateway 上实现新的端点或资源时。流水线必须执行以下步骤:

  • 创建一个 S3 存储桶

  • 在存储桶上启用静态网站功能

  • github.com/swagger-api/swagger-ui下载 Swagger UI,并将源代码复制到 S3

  • 创建 DNS 记录(docs.serverlessmovies.com

  • 运行aws apigateway export命令生成 Swagger 定义文件

  • 使用aws s3 cp命令将spec文件复制到 S3

摘要

总之,我们已经看到了如何使用多个 Lambda 函数从头开始构建无服务器 API,以及如何使用 API Gateway 创建统一的 API 并将传入的请求分派到正确的 Lambda 函数。我们通过 DynamoDB 数据存储解决了 Lambda 的无状态问题,并了解了保留并发性如何帮助保护下游资源。然后,我们在 S3 存储桶中托管了一个无服务器 Web 应用程序,并在其前面使用 CloudFront 来优化 Web 资产的交付。最后,我们学习了如何使用 Route 53 将域流量路由到 Web 应用程序,并如何使用 SSL 终止来保护它。

以下图示了我们迄今为止实施的架构:

在下一章中,我们将改进 CI/CD 工作流程,添加单元测试和集成测试,以在将 Lambda 函数部署到生产环境之前捕获错误和问题。

问题

  1. 实现一个以电影类别为输入并返回与该类别对应的电影列表的 Lambda 函数。

  2. 实现一个 Lambda 函数,以电影标题作为输入,返回所有标题中包含关键字的电影。

  3. 在 Web 应用程序上实现一个删除按钮,通过调用 API Gateway 的DeleteMovie Lambda 函数来删除电影。

  4. 在 Web 应用程序上实现一个编辑按钮,允许用户更新电影属性。

  5. 使用 CircleCI、Jenkins 或 CodePipeline 实现 CI/CD 工作流程,自动化生成和部署 API Gateway 文档。

第十章:测试您的无服务器应用程序

本章将教您如何使用 AWS 无服务器应用程序模型在本地测试您的无服务器应用程序。我们还将介绍使用第三方工具进行 Go 单元测试和性能测试,以及如何使用 Lambda 本身执行测试工具。

技术要求

本章是第七章的后续内容,实施 CI/CD 流水线,因此建议先阅读该章节,以便轻松地跟随本章。此外,建议具有测试驱动开发实践经验。本章的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Serverless-Applications-with-Go

单元测试

对 Lambda 函数进行单元测试意味着尽可能完全地(尽可能)从外部资源(如以下事件:DynamoDB、S3、Kinesis)中隔离测试函数处理程序。这些测试允许您在实际部署新更改到生产环境之前捕获错误,并维护源代码的质量、可靠性和安全性。

在我们编写第一个单元测试之前,了解一些关于 Golang 中测试的背景可能会有所帮助。要在 Go 中编写新的测试套件,文件名必须以_test.go结尾,并包含以TestFUNCTIONNAME前缀的函数。Test前缀有助于识别测试例程。以_test结尾的文件将在构建部署包时被排除,并且只有在发出go test命令时才会执行。此外,Go 自带了一个内置的testing包,其中包含许多辅助函数。但是,为了简单起见,我们将使用一个名为testify的第三方包,您可以使用以下命令安装:

go get -u github.com/stretchr/testify

以下是我们在上一章中构建的 Lambda 函数的示例,用于列出 DynamoDB 表中的所有电影。以下代表我们要测试的代码:

func findAll() (events.APIGatewayProxyResponse, error) {
  ...

  svc := dynamodb.New(cfg)
  req := svc.ScanRequest(&dynamodb.ScanInput{
    TableName: aws.String(os.Getenv("TABLE_NAME")),
  })
  res, err := req.Send()
  if err != nil {
    return events.APIGatewayProxyResponse{
      StatusCode: http.StatusInternalServerError,
      Body: "Error while scanning DynamoDB",
    }, nil
  }

  ...

  return events.APIGatewayProxyResponse{
    StatusCode: 200,
    Headers: map[string]string{
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": "*",
    },
    Body: string(response),
  }, nil
}

为了充分覆盖代码,我们需要测试所有边缘情况。我们可以执行的测试示例包括:

  • 测试在未分配给函数的 IAM 角色的情况下的行为。

  • 使用分配给函数的 IAM 角色进行测试。

为了模拟 Lambda 函数在没有 IAM 角色的情况下运行,我们可以删除凭据文件或取消设置本地使用的 AWS 环境变量。然后,发出aws s3 ls命令以验证 AWS CLI 无法找到 AWS 凭据。如果看到以下消息,那么您应该可以继续:

Unable to locate credentials. You can configure credentials by running "aws configure".

在名为main_test.go的文件中编写您的单元测试:

package main

import (
  "net/http"
  "testing"

  "github.com/aws/aws-lambda-go/events"
  "github.com/stretchr/testify/assert"
)

func TestFindAll_WithoutIAMRole(t *testing.T) {
  expected := events.APIGatewayProxyResponse{
    StatusCode: http.StatusInternalServerError,
    Body: "Error while scanning DynamoDB",
  }
  response, err := findAll()
  assert.IsType(t, nil, err)
  assert.Equal(t, expected, response)
}

测试函数以Test关键字开头,后跟函数名称和我们要测试的行为。然后,它调用findAll处理程序并将实际结果与预期响应进行比较。然后,您可以按照以下步骤进行:

  1. 使用以下命令启动测试。该命令将查找当前文件夹中的任何文件中的任何测试并运行它们。确保设置TABLE_NAME环境变量:
TABLE_NAME=movies go test

太棒了!我们的测试有效,因为预期和实际响应体等于扫描 DynamoDB 时出错的值:

  1. 编写另一个测试函数,以验证如果在运行时将 IAM 角色分配给 Lambda 函数的处理程序行为:
package main

import (
  "testing"

  "github.com/stretchr/testify/assert"
)

func TestFindAll_WithIAMRole(t *testing.T) {
  response, err := findAll()
  assert.IsType(t, nil, err)
  assert.NotNil(t, response.Body)
}

再次,测试应该通过,因为预期和实际响应体不为空:

您现在已经在 Go 中运行了一个单元测试;让我们为期望输入参数的 Lambda 函数编写另一个单元测试。让我们以insert方法为例。我们要测试的代码如下(完整代码可以在 GitHub 存储库中找到):

func insert(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
  ...
  return events.APIGatewayProxyResponse{
    StatusCode: 200,
    Headers: map[string]string{
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": "*",
    },
  }, nil
}

这种情况是输入参数的无效有效负载。函数应返回带有Invalid payload消息的400错误:

func TestInsert_InvalidPayLoad(t *testing.T) {
  input := events.APIGatewayProxyRequest{
    Body: "{'name': 'avengers'}",
  }

  expected := events.APIGatewayProxyResponse{
    StatusCode: 400,
    Body: "Invalid payload",
  }
  response, _ := insert(input)
  assert.Equal(t, expected, response)
}

另一个用例是在给定有效负载的情况下,函数应将电影插入数据库并返回200成功代码:

func TestInsert_ValidPayload(t *testing.T) {
  input := events.APIGatewayProxyRequest{
    Body: "{\"id\":\"40\", \"name\":\"Thor\", \"description\":\"Marvel movie\", \"cover\":\"poster url\"}",
  }
  expected := events.APIGatewayProxyResponse{
    StatusCode: 200,
    Headers: map[string]string{
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": "*",
    },
  }
  response, _ := insert(input)
  assert.Equal(t, expected, response)
}

两个测试应该成功通过。这次,我们将以代码覆盖模式运行go test命令,使用-cover标志:

TABLE_NAME=movies go test -cover

我们有 78%的代码被单元测试覆盖:

如果您想深入了解测试覆盖了哪些语句,哪些没有,可以使用以下命令生成 HTML 覆盖报告:

TABLE_NAME=movies go test -cover -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html

如果在浏览器中打开coverage.html,您可以看到单元测试未覆盖的语句:

您可以通过利用 Go 的接口来改进单元测试,以模拟 DynamoDB 调用。这允许您模拟 DynamoDB 的实现,而不是直接使用具体的服务客户端(例如,aws.amazon.com/blogs/developer/mocking-out-then-aws-sdk-for-go-for-unit-testing/)。

自动化单元测试

拥有单元测试是很好的。然而,没有自动化的单元测试是没有用的,因此您的 CI/CD 流水线应该有一个测试阶段,以执行对代码存储库提交的每个更改的单元测试。这种机制有许多好处,例如确保您的代码库处于无错误状态,并允许开发人员持续检测和修复集成问题,从而避免在发布日期上出现最后一分钟的混乱。以下是我们在前几章中构建的自动部署 Lambda 函数的流水线的示例:

version: 2
jobs:
 build:
 docker:
 - image: golang:1.8

 working_directory: /go/src/github.com/mlabouardy/lambda-circleci

 environment:
 S3_BUCKET: movies-api-deployment-packages
 TABLE_NAME: movies
 AWS_REGION: us-east-1

 steps:
 - checkout

 - run:
 name: Install AWS CLI & Zip
 command: |
 apt-get update
 apt-get install -y zip python-pip python-dev
 pip install awscli

 - run:
 name: Test
 command: |
 go get -u github.com/golang/lint/golint
 go get -t ./...
 golint -set_exit_status
 go vet .
 go test .

 - run:
 name: Build
 command: |
 GOOS=linux go build -o main main.go
 zip $CIRCLE_SHA1.zip main

 - run:
 name: Push
 command: aws s3 cp $CIRCLE_SHA1.zip s3://$S3_BUCKET

 - run:
 name: Deploy
 command: |
 aws lambda update-function-code --function-name InsertMovie \
 --s3-bucket $S3_BUCKET \
 --s3-key $CIRCLE_SHA1.zip --region us-east-1

对 Lambda 函数源代码的所有更改都将触发新的构建,并重新执行单元测试:

如果单击“Test”阶段,您将看到详细的go test命令结果:

集成测试

与单元测试不同,单元测试测试系统的一个单元,集成测试侧重于作为一个整体测试 Lambda 函数。那么,在不将它们部署到 AWS 的本地开发环境中如何测试 Lambda 函数呢?继续阅读以了解更多信息。

RPC 通信

如果您阅读 AWS Lambda 的官方 Go 库(github.com/aws/aws-lambda-go)的底层代码,您会注意到基于 Go 的 Lambda 函数是使用net/rpc通过TCP调用的。每个 Go Lambda 函数都会在由_LAMBDA_SERVER_PORT环境变量定义的端口上启动服务器,并等待传入请求。为了与函数交互,使用了两个 RPC 方法:

  • Ping:用于检查函数是否仍然存活和运行

  • Invoke:用于执行请求

有了这些知识,我们可以模拟 Lambda 函数的执行,并执行集成测试或预部署测试,以减少将函数部署到 AWS 之前的等待时间。我们还可以在开发生命周期的早期阶段修复错误,然后再将新更改提交到代码存储库。

以下示例是一个简单的 Lambda 函数,用于计算给定数字的 Fibonacci 值。斐波那契数列是前两个数字的和。以下代码是使用递归实现的斐波那契数列:

package main

import "github.com/aws/aws-lambda-go/lambda"

func fib(n int64) int64 {
  if n > 2 {
    return fib(n-1) + fib(n-2)
  }
  return 1
}

func handler(n int64) (int64, error) {
  return fib(n), nil
}

func main() {
  lambda.Start(handler)
}

Lambda 函数通过 TCP 监听端口,因此我们需要通过设置_LAMBDA_SERVER_PORT环境变量来定义端口:

_LAMBDA_SERVER_PORT=3000 go run main.go

要调用函数,可以使用net/rpc go 包中的invoke方法,也可以安装一个将 RPC 通信抽象为单个方法的 Golang 库:

go get -u github.com/djhworld/go-lambda-invoke 

然后,通过设置运行的端口和要计算其斐波那契数的数字来调用函数:

package main

import (
  "fmt"
  "log"

  "github.com/djhworld/go-lambda-invoke/golambdainvoke"
)

func main() {
  response, err := golambdainvoke.Run(3000, 9)
  if err != nil {
    log.Fatal(err)
  }
  fmt.Println(string(response))
}

使用以下命令调用 Fibonacci Lambda 函数:

go run client.go

结果,fib(9)=34如预期返回:

另一种方法是使用net/http包构建 HTTP 服务器,模拟 Lambda 函数在 API Gateway 后面运行,并以与测试任何 HTTP 服务器相同的方式测试函数,以验证处理程序。

在下一节中,我们将看到如何使用 AWS 无服务器应用程序模型以更简单的方式在本地测试 Lambda 函数。

无服务器应用程序模型

无服务器应用程序模型SAM)是一种在 AWS 中定义无服务器应用程序的方式。它是对CloudFormation的扩展,允许在模板文件中定义运行函数所需的所有资源。

请参阅第十四章,基础设施即代码,了解如何使用 SAM 从头开始构建无服务器应用程序的说明。

此外,AWS SAM 允许您创建一个开发环境,以便在本地测试、调试和部署函数。执行以下步骤:

  1. 要开始,请使用pip Python 包管理器安装 SAM CLI:
pip install aws-sam-cli

确保安装所有先决条件,并确保 Docker 引擎正在运行。有关更多详细信息,请查看官方文档docs.aws.amazon.com/lambda/latest/dg/sam-cli-requirements.html

  1. 安装后,运行sam --version。如果一切正常,它应该输出 SAM 版本(在撰写本书时为v0.4.0)。

  2. 为 SAM CLI 创建template.yml,在其中我们将定义运行函数所需的运行时和资源:

AWSTemplateFormatVersion : '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: List all movies.
Resources:
 FindAllMovies:
 Type: AWS::Serverless::Function
 Properties:
 Handler: main
 Runtime: go1.x
 Events:
 Vote:
 Type: Api
 Properties:
 Path: /movies
 Method: get

SAM 文件描述了运行时环境和包含代码的处理程序的名称,当调用时,Lambda 函数将执行该代码。此外,模板定义了将触发函数的事件;在本例中,它是 API Gateway 端点。

  • 为 Linux 构建部署包:
GOOS=linux go build -o main
  • 在本地使用sam local命令运行函数:
sam local start-api

HTTP 服务器将在端口3000上运行并侦听:

如果您导航到http://localhost:3000/movies,在返回响应之前可能需要几分钟,因为它需要获取一个 Docker 镜像:

SAM 本地利用容器的强大功能在 Docker 容器中运行 Lambda 函数的代码。在前面的屏幕截图中,它正在从 DockerHub(一个镜像存储库)拉取lambci/lambda:go1.x Docker 镜像。您可以通过运行以下命令来列出机器上所有可用的镜像来确认:

docker image ls

以下是前面命令的输出:

一旦拉取了镜像,将基于您的deployment包创建一个新的容器:

在浏览器中,将显示错误消息,因为我们忘记设置 DynamoDB 表的名称:

我们可以通过创建一个env.json文件来解决这个问题,如下所示:

{
    "FindAllMovies" : {
        "TABLE_NAME" : "movies"
    }
}

使用--env-var参数运行sam命令:

sam local start-api --env-vars env.json

您还可以在同一 SAM 模板文件中使用Environment属性声明环境变量。

这次,您应该在 DynamoDB movies表中拥有所有电影,并且函数应该按预期工作:

负载测试

我们已经看到了如何使用基准测试工具,例如 Apache Benchmark,以及如何测试测试工具。在本节中,我们将看看如何使用 Lambda 本身作为无服务器测试测试平台。

这个想法很简单:我们将编写一个 Lambda 函数,该函数将调用我们想要测试的 Lambda 函数,并将其结果写入 DynamoDB 表进行报告。幸运的是,这里不需要编码,因为 Lambda 函数已经在蓝图部分中可用:

为函数命名并创建一个新的 IAM 角色,如下图所示:

单击“创建函数”,函数应该被创建,并授予执行以下操作的权限:

  • 将日志推送到 CloudWatch。

  • 调用其他 Lambda 函数。

  • 向 DynamoDB 表写入数据。

以下截图展示了前面任务完成后的情况:

在启动负载测试之前,我们需要创建一个 DynamoDB 表,Lambda 将在其中记录测试的输出。该表必须具有testId的哈希键字符串和iteration的范围数字:

创建后,使用以下 JSON 模式调用 Lambda 函数。它将异步调用给定函数 100 次。指定一个唯一的event.testId来区分每个单元测试运行:

{
    "operation": "load",
    "iterations": 100,
    "function": "HarnessTestFindAllMovies",
    "event": {
      "operation": "unit",
      "function": "FindAllMovies",
      "resultsTable": "load-test-results",
      "testId": "id",
      "event": {
        "options": {
          "host": "https://51cxzthvma.execute-api.us-east-1.amazonaws.com",
          "path": "/production/movies",
          "method": "GET"
        }
      }
    }
  }

结果将记录在 JSON 模式中给出的 DynamoDB 表中:

您可能需要修改函数的代码以保存其他信息,例如运行时间、资源使用情况和响应时间。

摘要

在本章中,我们学习了如何为 Lambda 函数编写单元测试,以覆盖函数的所有边缘情况。我们还学习了如何使用 AWS SAM 设置本地开发环境,以在本地测试和部署函数,以确保其行为在部署到 AWS Lambda 之前正常工作。

在下一章中,我们将介绍如何使用 AWS 托管的服务(如 CloudWatch 和 X-Ray)来排除故障和调试无服务器应用程序。

问题

  1. UpdateMovie Lambda 函数编写一个单元测试。

  2. DeleteMovie Lambda 函数编写一个单元测试。

  3. 修改前几章提供的Jenkinsfile,以包括自动化单元测试的执行。

  4. 修改buildspec.yml定义文件,以在将部署包推送到 S3 之前,包括执行单元测试的执行。

  5. 为前几章实现的每个 Lambda 函数编写一个 SAM 模板文件。