AWS-Lambda-编程指南-全-
AWS Lambda 编程指南(全)
原文:
zh.annas-archive.org/md5/a00e6d2e46d6e58fa60dc99d69f92ec1译者:飞龙
前言
关于本书
欢迎来到编程 AWS Lambda。我们很高兴您在这里!
无服务器计算是一种革命性的系统构建方式。在其核心,无服务器是关于进行最少的技术工作,以可持续地为用户提供价值。无服务器方法通过充分利用云供应商提供的服务来实现这一点,比如亚马逊网络服务(AWS)。
在本书中,您将学习如何设计、构建和操作使用AWS Lambda的无服务器应用程序——这是最初也是广泛采用的无服务器计算平台。然而,AWS Lambda 很少单独使用,因此在阅读本书时,您还将学习如何成功地将 Lambda 与其他无服务器 AWS 服务(如 S3、DynamoDB 等)集成。
为什么我们写了这本书
我们自 2015 年以来一直在使用 Lambda,自 Lambda 首次宣布支持 Java 以来便如此。仅仅在几周内,我们就看到 Lambda 具有让团队比以往任何时候都更快构建新功能的惊人能力。通过消除开发和运行系统的许多低级方面,而是专注于清晰的事件驱动方法,我们意识到在使用 Lambda 时许多妨碍团队的复杂性不再适用。Lambda 还让我们可以放大我们对 AWS 平台其余部分的使用——它对我们的效率产生了乘数效应。
我们最初对 Lambda 有两个担忧——一是它不能支持多年来我们在 Java 中构建的编程知识和软件库存,二是它在大规模运行时成本过高。
而我们发现的却出乎我们意料。
Lambda 对 Java 的支持并不仅仅是一个“附加功能”。事实上,Java 是 Lambda 平台内的一流运行时。在 Java 中构建 Lambda 应用程序使我们可以回归编程的本质,让我们可以利用我们的技能和现有的代码。
此外,Lambda 的运行成本竟然比等效的传统构建系统更低,而不是更高。Lambda 的“按使用付费”的高效模型,精确到亚秒级,使我们能够创建每天处理数亿事件的系统,但成本低于其前身。
这种开发速度、对现有语言的采纳以及成本效益的结合使我们相信,无服务器计算平台,以 Lambda 为前沿,是我们行业特别之处的开端。2016 年,我们创立了 Symphonia 公司,旨在帮助公司迈向这种新的系统构建方式。
本书适合对象
本书主要面向软件开发人员和软件架构师,但对于任何涉及在云中构建软件应用技术方面的人员也很有用。
我们假设您已经知道或可以学习 Java 编程语言的基础知识。您不需要了解或具有任何 Java 应用框架(如 Spring)或库(如 Guava)的知识或经验。我们不假设您具有任何关于 Amazon Web Services 的先验知识。
为什么需要本书
从许多方面来看,无服务器计算和 Lambda 也是数十年来构建服务器端软件的最重大变革之一。虽然我们的代码可能在每一行、甚至每一个类上看起来都与以前写的方式类似,但 Lambda 的架构约束和功能驱使设计具有与过去非常不同的形状。
在过去的几年里,我们已经了解到如何成功地使用 Lambda 构建系统。本书将帮助您快速学习这些相同的经验教训。
从入门技术到高级架构,从编程和测试到部署和监控,我们覆盖了您需要了解的构建 Lambda 生产质量系统的生命周期。
本书的独特之处在于我们在 Java 编程语言的背景下完成所有这些工作。我们两人都是 Java 程序员,每个人都有超过二十年的经验,所以在这本书中,我们帮助您以全新的方式使用您现有的 Java 技能。
所以系好安全带,欢迎来到无服务器时代!
使用章末练习
本书的每一章都以一些练习结束。其中一些练习鼓励您将本章的教训应用到 AWS 云中,看到它们在“真实”环境中的运作。虽然 Lambda 的某些元素可以在本地模拟,但只有通过在 AWS 平台的上下文中使用它,您才能真正感受到 Lambda 开发的感觉。好消息是,AWS 提供了一个健康的“免费层”,这样您就可以在不产生任何费用的情况下进行实验。
其他练习旨在让您考虑如何与 Lambda 与其他技术不同地工作。无服务器架构通常是一种非常不同的思维方式,通过这些练习将开始调整您的大脑思维方式。
本书使用的约定
本书使用以下排版约定:
斜体
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
等宽
用于程序清单,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
等宽粗体
显示用户应该按字面输入的命令或其他文本。
等宽斜体
显示应该用用户提供的值或上下文确定的值替换的文本。
提示
这个元素表示一个提示或建议。
注意
这个元素表示一般说明。
警告
这个元素表示一个警告或注意事项。
使用代码示例
补充材料(代码示例、练习等)可在https://github.com/symphoniacloud/programming-aws-lambda-book下载。
如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com。
本书旨在帮助您完成工作。通常情况下,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您复制了代码的大部分,否则无需联系我们以获得许可。例如,编写一个使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 图书示例需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书的大量示例代码合并到您产品的文档中需要许可。
我们感谢但不要求署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Programming AWS Lambda by John Chapin and Mike Roberts (O’Reilly). Copyright 2020 Symphonia LLC, 978-1-492-04105-4。”
如果您觉得您使用的代码示例超出了公平使用或上述许可范围,请随时通过permissions@oreilly.com与我们联系。
致谢
感谢我们的技术审阅者们为您花费的时间和对这本书的改进:布莱恩·格鲁伯、丹尼尔·布莱恩特、萨拉·韦尔斯和斯图尔特·西埃拉。感谢我们在 Intent Media 的前同事们,四年前加入并展示了这项革命性的新技术,展示了它如何改变团队。感谢所有 Symphonia 的客户、合作伙伴和朋友们——我们对您持续的信任和支持心存感激。感谢所有 O’Reilly 的工作人员,尤其是我们的编辑团队;我们在开始阅读动物书籍 20 多年后写下了自己的“动物”书籍,这真是令人惊奇。最后,感谢所有参与无服务器社区的朋友们!
还要特别感谢 AWS 无服务器团队的成员,特别是阿贾伊·奈尔、克里斯·蒙斯、诺尔·道林和萨尔曼·帕拉查,因为他们开发了一款革命性产品,并在过去几年里与我们交流。最后感谢蒂姆·瓦格纳,因为他在 Lambda 刚刚起步时为本书撰写了序言!
John 的致谢: 首先和最重要的是,感谢我的父母马克和布里奇特,他们给了我选择人生道路的特权和自由,并给予了我不脱轨的爱和支持。当然还要感谢我的合著者和商业伙伴迈克,没有他,这本书和我们的公司都不会存在——总有一天我会教他如何写美式英语(但今天不是)。无尽的感谢送给我的妻子杰西卡,她让我的精神振作,从不问文章的字数。
Mike 的致谢: 这里有太多的人要感谢了,但我还是试一试。感谢我的高中计算机课老师雷·洛维尔和我的大学导师卡罗尔·摩根。感谢这些年来的同事们,特别是在 ThoughtWorks 的时光里。丹尼尔·特霍斯特-诺斯一直是我职业生涯中的导师和智者;丹尼尔,请继续让我感到“啊!”。感谢布莱恩·古斯里、丽莎·范·格尔德和纽约极限星期二俱乐部社区的其他成员。还要感谢迈克·梅森,他是我的同事(两次)、室友(多次),并且是我一生中最亲近的朋友之一。 (是的,迈克,那个短语在书中——现在轮到你了!)
然而,我最大的感激之情要送给三个人“如果没有他们……”首先,感谢马丁·福勒的启发,感谢他的友谊,也感谢他发表了关于无服务器架构的文章,为您正在阅读的内容铺平了道路。其次,感谢我的合著者约翰,他和我一起经历了我们公司 Symphonia 的过山车式成长。最后,当然还要感谢我的美好配偶萨拉,她支持我做自由职业者的怪异时间,现在显然也支持我成为一名已出版作者。
第一章:服务器无服务器、Amazon Web Services 和 AWS Lambda 介绍
要开始您的无服务器之旅,我们将带您简要了解云,并定义无服务器。之后,我们将深入探讨 Amazon Web Services(AWS)——这对一些人来说是新的,对另一些人来说是一个复习。
有了这些基础,我们介绍 Lambda——它是什么,为什么要使用它,你可以用 Lambda 构建什么,以及 Java 和 Lambda 如何协同工作。
一个快速的历史课
让我们穿越时空回到 2006 年。现在还没有人拥有 iPhone,Ruby on Rails 是一个炙手可热的新编程环境,Twitter 正在推出。然而,对我们来说更重要的是,在这个时候,许多人都在自己拥有并在数据中心中架设的物理服务器上托管他们的服务器端应用程序。
2006 年 8 月发生了一件事,将从根本上改变这种模式。亚马逊的新 IT 部门 AWS 宣布推出了Elastic Compute Cloud(EC2)。
EC2 是最早的基础设施即服务(IaaS)产品之一。IaaS 允许公司租用计算能力——即用于运行其互联网服务器应用程序的主机——而不是购买自己的机器。它还允许他们及时提供主机,从请求机器到可用性的延迟大约为几分钟。在 2006 年,这一切都成为可能,因为虚拟化技术的进步——那时的所有 EC2 主机都是虚拟机器。
EC2 的五个关键优势是:
降低人工成本
在 IaaS 之前,公司需要雇佣特定的技术运维人员,在数据中心工作并管理他们的物理服务器。这意味着从电力和网络到机架和安装再到修复诸如坏的 RAM 之类的机器的物理问题,再到设置操作系统(OS)的一切都消失了。使用 IaaS,所有这些都消失了,而是成为 IaaS 服务提供商(在 EC2 的情况下是 AWS)的责任。
降低风险
当公司管理自己的物理服务器时,他们会暴露于由于未计划的事件(如硬件故障)引起的问题。这会导致高度不稳定长度的停机时间,因为硬件问题通常很少见,并且可能需要很长时间才能修复。使用 IaaS,客户在硬件故障发生时仍然需要做一些工作,但不再需要知道如何修复硬件。相反,客户可以简单地请求一个新的机器实例,几分钟内就可用,并重新安装应用程序,从而限制了面临此类问题的风险。
降低基础设施成本
在许多情况下,连接的 EC2 实例的成本比自己运行的硬件要便宜,尤其是当你只想连续运行几天或几周而不是数月或数年时,考虑到电力、网络等因素。同样,按小时租赁主机而不是直接购买它们允许不同的会计处理:EC2 机器是运营费用(Opex),而不是物理机器的资本支出(Capex),通常允许更灵活的会计处理方式。
扩展
考虑到 IaaS 带来的扩展性好处,基础设施成本显著下降。使用 IaaS,公司在扩展其运行的服务器数量和类型方面具有更大的灵活性。不再需要提前购买 10 台高端服务器,因为你认为未来几个月可能会需要它们。相反,你可以从一两台低功率、低成本的虚拟机(VM)开始,然后随着时间的推移扩展你的 VM 数量和类型,而无需任何负面成本影响。
交付时间
在自助托管服务器的旧时代,为新应用程序采购和配置服务器可能需要几个月的时间。如果你在几周内想尝试一个想法,那就太糟糕了。使用 IaaS,交付时间从几个月缩短到几分钟。这引领了快速产品实验的时代,正如《精益创业》中鼓励的那样。
云计算的发展
IaaS 是云计算的第一批关键元素之一,与存储(例如 AWS 的简单存储服务(S3))一起。AWS 是云服务的早期开拓者,仍然是主要提供商,但也有许多其他云供应商,如微软和谷歌。
云计算的下一个演变是平台即服务(PaaS)。最受欢迎的 PaaS 提供商之一是 Heroku。PaaS 在 IaaS 之上层,抽象了主机操作系统的管理。使用 PaaS,你只部署应用程序,平台负责操作系统安装、补丁升级、系统级监控、服务发现等。
使用 PaaS 的替代方案是使用容器。在过去几年中,Docker作为一种更清晰地区分应用程序系统需求与操作系统细节的方法变得非常流行。有基于云的服务来代表团队托管和管理/编排容器,这些服务通常被称为容器即服务(CaaS)产品。亚马逊、谷歌和微软都提供 CaaS 平台。通过使用像Kubernetes这样的工具(例如谷歌的 GKE、亚马逊的 EKS 或微软的 AKS),管理 Docker 容器的数量变得更加容易,无论是自我管理的形式还是作为 CaaS 的一部分。
这三个概念——IaaS、PaaS 和 CaaS——都可以归为作为服务的计算;换句话说,它们是我们可以在其中运行我们自己专业软件的不同类型的通用环境。PaaS 和 CaaS 通过进一步提高抽象级别来与 IaaS 有所不同,允许我们将更多的“繁重工作”交给其他人处理。
进入 Serverless
Serverless 是云计算的下一个演进阶段,可以分为两个概念:Backend as a Service(BaaS)和 Functions as a Service(FaaS)。
Backend as a Service
Backend as a service(BaaS)允许我们用现成的服务替换我们自己编写和/或管理的服务器端组件。它在概念上更接近软件即服务(SaaS)而不是像虚拟实例和容器之类的东西。SaaS 通常是外包业务流程,比如人力资源或销售工具,或者在技术方面像 GitHub 这样的产品;而使用 BaaS,我们将应用程序分解成更小的部分,并且完全使用外部托管的产品实现其中一些部分。
BaaS 服务是领域通用的远程组件(即不是进程内库),我们可以将其整合到我们的产品中,其中应用程序编程接口(API)是典型的集成范式。
BaaS 已经在开发移动应用程序或单页 Web 应用程序的团队中变得特别流行。许多这样的团队可以大量依赖第三方服务来执行本来需要自己完成的任务。让我们看几个例子。
首先我们有像 Google 的Firebase这样的服务。Firebase 是一个数据库产品,由供应商(在这种情况下是 Google)完全管理,可以直接从移动或 Web 应用程序访问,无需我们自己的中介应用服务器。这代表了 BaaS 的一个方面:管理数据组件的服务。
BaaS 服务还允许我们依赖他人已经实现的应用逻辑。一个很好的例子是认证——许多应用程序实现自己的代码来执行注册、登录、密码管理等操作,但这些代码通常在许多应用程序中是相似的。跨团队和企业的这种重复工作正好可以提取为外部服务,这正是像Auth0和亚马逊的Cognito这样的产品的目标。这些产品允许移动应用程序和 Web 应用程序拥有完整的身份验证和用户管理功能,但不需要开发团队编写或管理实现这些功能的任何代码。
BaaS一词随着移动应用程序开发的兴起而显现出来;事实上,有时这个术语被称为移动后端即服务(MBaaS)。然而,使用完全外部管理的产品作为我们应用程序开发的一部分的关键思想并不局限于移动开发,甚至不限于前端开发。
作为服务的功能
无服务器的另一半是函数即服务(FaaS)。FaaS,像 IaaS、PaaS 和 CaaS 一样,是计算作为服务的另一种形式—一个通用的环境,在其中我们可以运行我们自己的软件。有些人喜欢使用术语无服务器计算来代替 FaaS。
使用 FaaS,我们将我们的代码部署为独立的函数或操作,并配置这些函数在 FaaS 平台内发生特定事件或请求时被调用或触发。平台本身通过为每个事件实例化专用环境来调用我们的函数—这个环境由一个临时的、完全托管的轻量级虚拟机或容器;FaaS 运行时;和我们的代码组成。
这种环境的结果是,我们不必关心我们代码的运行时管理,这与任何其他计算平台的风格都不同。
此外,由于我们稍后将描述的无服务器的几个因素,使用 FaaS 时我们不必担心主机或进程,并且缩放和资源管理由平台代为处理。
区分无服务器
使用外部托管的应用程序组件的想法,就像我们使用 BaaS 一样,不是新鲜事—人们已经使用托管的 SQL 数据库十年甚至更长时间了—那么是什么使得这些服务有资格作为后端服务呢?BaaS 和 FaaS 有哪些共同点,使我们将它们归为无服务器计算的概念?
有五个关键标准可以区分无服务器服务—包括 BaaS 和 FaaS—使我们能够以新的方式设计应用程序。这些标准如下:
不需要管理长期运行的主机或应用程序实例
这是无服务器的核心。操作服务器端软件的大多数其他方式都要求我们部署、运行和监视一个应用程序实例(无论是我们自己编写的还是其他人编写的),并且该应用程序的生命周期跨越一个以上的请求。无服务器则意味着相反:我们不需要管理长期运行的服务器进程或服务器主机。这并不意味着这些服务器不存在—它们绝对存在—但它们不是我们的关注或责任。
自动按负载自动调整和自动分配资源
自动缩放是系统根据负载动态调整容量需求的能力。大多数现有的自动缩放解决方案需要利用团队做一定的工作。无服务器服务从第一次使用起就能自动自动调整,无需任何努力。
无服务器服务在执行自动扩展时也会自动进行配置。它们消除了分配容量的所有工作,包括底层资源的数量和大小。这是一个巨大的操作负担减轻。
具有基于精确使用量的成本,从零到零的使用量
这与前一点密切相关——无服务器成本与使用量精确相关。例如,使用 BaaS 数据库的成本应与使用量紧密相关,而不是预定义的容量。此成本应主要来自实际使用的存储量和/或发出的请求。
请注意,我们并不是说成本应仅基于使用量——通常可能会有一些使用服务的基本成本——但是大部分成本应与细粒度的使用成正比。
具有以除主机大小/数量外的其他术语定义的性能能力
对于无服务器平台来说,暴露一些性能配置是合理且有用的。但是,这种配置应完全抽象出所使用的任何基础实例或主机类型。
具有隐式高可用性
在运行应用程序时,我们通常使用高可用性(HA)一词来表示即使底层组件失败,服务也将继续处理请求。对于无服务器服务,我们期望供应商为我们提供透明的 HA。
例如,如果我们正在使用 BaaS 数据库,我们假设提供商正在执行处理单个主机或内部组件失败所需的所有操作。
什么是 AWS?
在本章中,我们已经几次谈到了 AWS,现在是时候稍微详细地看一下这个云服务提供商的巨头了。
自 2006 年推出以来,AWS 以令人难以置信的速度增长,涉及的服务数量和类型,AWS 云提供的容量以及使用它的公司数量。让我们来看看所有这些方面。
服务类型
AWS 拥有一百多种不同的服务。其中一些是相当低级的——网络、虚拟机、基本块存储。在这些服务之上,在抽象层面上,是组件服务——数据库、平台即服务、消息总线。然后在所有这些服务之上,真正的应用程序组件——用户管理、机器学习、数据分析。
这个堆栈的侧面是必要的管理服务,用于以规模使用 AWS——安全性、成本报告、部署、监视等。
这些服务的组合如图 1-1 所示。

图 1-1. AWS 服务层
AWS 喜欢将自己宣传为终极 IT “乐高积木”提供商——它提供了大量可插拔类型的资源,可以组合在一起创建庞大、高度可扩展的企业级应用程序。
容量
AWS 将其计算机设施分布在全球 60 多个数据中心,如图 1-2 所示。在 AWS 术语中,每个数据中心对应一个可用区(AZ),而紧邻的数据中心群组成区域。AWS 在 5 个大陆上拥有 20 多个不同的区域。
那是很多计算机。
尽管区域总数继续增长,但每个区域内的容量也在增加。大量美国互联网公司在北弗吉尼亚州的 us-east-1 区域(华盛顿特区外)运行其系统——公司在那里运行系统越多,AWS 在增加可用服务器数量方面就越有信心。这是亚马逊与其客户之间的良性循环。

图 1-2. AWS 区域(来源:AWS)
当你使用亚马逊的一些低级服务,比如 EC2 时,通常会指定要使用的可用区。不过,对于高级服务,你通常只需指定一个区域,亚马逊将为你在单个数据中心级别处理任何问题。
亚马逊的区域模型的一个引人注目的方面是,从物流和软件管理的角度来看,每个区域基本上是独立的。这意味着,如果一个区域出现了像停电这样的物理问题,或者像部署错误这样的软件问题,其他区域几乎肯定不会受到影响。从我们用户的角度来看,区域模型确实会增加一些额外的工作量,但总体上它运行良好。
谁在使用 AWS?
AWS 拥有遍布全球的大量客户。大型企业、政府、初创公司、个人以及中间的所有人都在使用 AWS。你使用的许多互联网服务可能都托管在 AWS 上。
AWS 不仅适用于网站。许多公司已经将大部分“后端”IT 基础设施迁移到 AWS 上,发现这比运行自己的物理基础设施更具吸引力。
当然,AWS 并不垄断。在英语世界至少,谷歌和微软是它们最大的竞争对手,而阿里云则在不断增长的中国市场上与它们竞争。还有许多其他云服务提供商,提供适合特定类型客户的服务。
你如何使用 AWS?
你与 AWS 的第一次互动很可能是通过AWS Web 控制台。为此,你将需要某种访问凭证,这将为你在一个账户内授予权限。账户是一个映射到结算(即向 AWS 支付你使用的服务费用)的概念,但它也是 AWS 内部定义的服务配置的分组。公司倾向于在一个账户中运行多个生产应用程序。(账户也可以有子账户,但在本书中我们不会过多讨论它们——只需知道如果使用公司提供的凭证,它们可能是为特定的子账户。)
如果公司没有向你提供凭证,你需要创建一个账户。你可以通过提供 AWS 你的信用卡详细信息来完成此操作,但要知道 AWS 提供了一个慷慨的免费套餐,如果你只是按照本书中的基本练习进行,应该不会需要支付 AWS 任何费用。
你的凭证可能采用典型的用户名和密码形式,也可能通过单点登录(SSO)工作流程(例如通过 Google Apps 或 Microsoft Active Directory)进行。无论哪种方式,最终你都将成功登录到 Web 控制台。第一次使用 Web 控制台可能会让人畏缩,因为有 100 多个 AWS 服务争相吸引你的注意——Amazon Polly 在与一个叫做 Macie 的奇怪东西平分秋色时大喊“选我!”。然后当然还有那些仅以首字母缩略词命名的服务——它们到底是什么呢?
AWS 控制台主页如此令人震惊的原因之一是因为它实际上并非作为一个产品开发的——它是作为一百个不同产品开发的,所有产品都在主页上得到了链接。此外,深入了解一个产品可能看起来与了解另一个产品大不相同,因为在 AWS 宇宙中,每个产品都被赋予了相当多的自治权。有时使用 AWS 可能感觉像是在探险中浏览 AWS 的公司组织——不用担心,我们都有这种感觉。
除了 Web 控制台,与 AWS 交互的另一种方式是通过其广泛的 API。亚马逊在其历史的早期甚至在 AWS 时代之前就拥有的一个伟大方面是,每个服务必须通过公共 API 完全可用,这意味着在 AWS 中可以配置的任何事情实际上都可以通过 API 完成。
API 的顶层是 CLI——命令行界面——这是我们在本书中使用的工具。CLI 最简单的描述是一个与 AWS API 通信的轻量级客户端应用程序。我们将在下一章讨论如何配置 CLI(“AWS 命令行界面”)。
什么是 AWS Lambda?
Lambda 是亚马逊的 FaaS 平台。我们在前面简要提到了 FaaS,但现在是时候更详细地挖掘它了。
作为服务的函数
正如我们之前介绍的,FaaS 是一种围绕部署单个函数或操作的构建和部署服务器端软件的新方式。FaaS 是关于无服务器的许多噪音的来源;事实上,许多人认为无服务器 就是 FaaS,但他们忽略了完整的图景。虽然本书专注于 FaaS,我们鼓励您在构建更大型应用程序时也考虑 BaaS。
当我们部署传统的服务器端软件时,我们首先使用主机实例,通常是 VM 实例或容器(参见 图 1-3)。然后我们部署我们的应用程序,在主机内作为操作系统进程运行。通常,我们的应用程序包含多个不同但相关的操作的代码;例如,Web 服务可能允许检索和更新资源。

图 1-3. 传统服务器端软件部署
从所有权的角度来看,我们作为用户对此配置的三个方面负责——主机实例、应用程序进程,当然还有程序操作。
FaaS 改变了部署和所有权的模型(参见 图 1-4)。我们从模型中剥离了主机实例和应用程序进程。相反,我们专注于表达应用程序逻辑的单个操作或函数。我们将这些函数单独上传到 FaaS 平台,这个平台由云供应商负责,而不是我们。

图 1-4. FaaS 软件部署
函数在应用程序进程中不是持续活动的,而是处于空闲状态,直到需要运行它们,就像传统系统中的方式。相反,FaaS 平台被配置为为每个操作监听特定的事件。当事件发生时,平台实例化 FaaS 函数,然后调用它,传递触发事件。
当函数执行完成后,FaaS 平台可以自由地将其销毁。或者作为优化,它可以将函数保留一段时间,直到有另一个事件需要处理。
Lambda 实现的 FaaS
AWS Lambda 在 2014 年推出,并且在范围、成熟度和使用率方面不断增长。某些 Lambda 函数可能吞吐量非常低——可能每天只执行一次,甚至更少。但是其他函数可能每天执行数十亿次。
Lambda 通过实例化短暂的托管 Linux 环境来实现 FaaS 模式,以托管每个函数实例。Lambda 保证每次只处理一个环境中的事件。在撰写本文时,Lambda 还要求函数在 15 分钟内完成对事件的处理;否则,执行将被中止。
Lambda 提供了一个异常轻量级的编程和部署模型——我们只需提供一个函数及其依赖项,打包成 ZIP 或 JAR 文件,Lambda 完全管理运行时环境。
Lambda 与许多其他 AWS 服务紧密集成。这对应于可以触发 Lambda 函数的许多不同类型的事件源,从而可以使用 Lambda 构建许多不同类型的应用程序。
Lambda 是一个完全无服务器的服务,根据我们与之前的区分标准定义的:
不需要管理长时间运行的主机或应用程序实例
使用 Lambda,我们完全抽象出运行我们代码的底层主机。此外,我们不管理长时间运行的应用程序——一旦我们的代码完成处理特定事件,AWS 就可以自由终止运行时环境。
自动按负载自动扩展和自动配置
这是 Lambda 的一个关键优势之一——资源管理和扩展是完全透明的。一旦我们上传函数代码,Lambda 平台将创建足够的环境来处理任何特定时间的负载。如果一个环境足够,Lambda 将在需要时创建环境。另一方面,如果需要数百个单独的实例,Lambda 将快速扩展,而我们无需任何努力。
具有基于精确使用量的费用结构,从零使用到上升
AWS 仅按照我们的代码在每个环境中执行的时间收费,精确到 100 毫秒。如果我们的函数每 5 分钟活动 200 毫秒,那么我们每小时只需支付 2.4 秒的使用费用。这种精确的使用费用结构,无论我们的函数需要一个实例还是一千个实例,都是相同的。
性能能力是根据除主机大小/数量以外的条件定义的。
由于 Lambda 完全抽象出底层主机,我们无法指定要使用的 EC2 实例的数量或类型。相反,我们指定我们的函数需要多少 RAM(最多 3GB),性能的其他方面也与此相关。我们将在本书后面更详细地探讨这个问题——参见“内存和 CPU”。
具有隐式高可用性
如果特定的基础主机失败,那么 Lambda 将自动在不同的主机上启动环境。同样,如果特定的数据中心/可用区失败,Lambda 将在同一地区的不同 AZ 中自动启动环境。请注意,作为 AWS 客户,我们需要处理区域范围的故障,我们将在本书末尾讨论这个问题——参见“全球分布式应用”。
为什么选择 Lambda?
正如我们之前所描述的,云的基本好处也适用于 Lambda——与其他类型的主机平台相比,它通常更便宜;运行 Lambda 应用程序需要的操作和时间更少;Lambda 的伸缩性灵活性超过了 AWS 内的任何其他计算选项。
然而,从我们的角度来看,最大的好处在于与其他 AWS 服务结合使用时,Lambda 可以多快地构建应用程序。我们经常听说公司可以在一两天内构建全新的应用程序,并将其部署到生产环境中。能够摆脱我们通常在常规应用程序中编写的大量基础设施相关代码,这是一个巨大的时间节省者。
Lambda 还比任何其他 FaaS 平台拥有更多的容量、成熟度和集成点。它并不完美,我们认为一些其他产品在开发者体验上比 Lambda 更好。但是在没有与现有云供应商的强大联系的情况下,我们会推荐 AWS Lambda,原因正如前面列出的那些。
Lambda 应用程序是什么样子?
传统的长时间运行的服务器应用通常有两种启动工作的方式之一:它们可以打开 TCP/IP 套接字并等待传入连接,或者有一个内部调度机制,使它们可以访问远程资源以检查新的工作。由于 Lambda 基本上是一个事件驱动的平台,并且 Lambda 强制执行超时,所以这两种模式都不适用于 Lambda 应用程序。那么我们如何构建 Lambda 应用程序呢?
要考虑的第一点是,Lambda 函数在最低级别上可以通过两种方式调用:
-
Lambda 函数可以被称为同步调用,由 AWS 称为
RequestResponse。在这种情况下,上游组件调用 Lambda 函数,并等待 Lambda 函数生成的任何响应。 -
或者,Lambda 函数可以异步调用,由 AWS 称为
Event。这时来自上游调用者的请求会立即由 Lambda 平台响应,而 Lambda 函数继续处理请求。在这种情况下,不会向调用者返回进一步的响应。
这两种调用模型有各种其他行为,我们稍后会深入探讨,从“调用类型”开始。现在让我们看看它们在一些示例应用中的使用方式。
Web API
一个显而易见的问题是 Lambda 是否可以用于实现 HTTP API,幸运的是答案是肯定的!虽然 Lambda 函数本身不是 HTTP 服务器,但我们可以使用另一个 AWS 组件API Gateway来提供 HTTP 协议和路由逻辑,这些通常在 Web 服务中使用(见图 1-5)。

图 1-5. 使用 AWS Lambda 的 Web API
上图显示了单页 Web 应用程序或移动应用程序使用的典型 API。用户客户端通过 HTTP 进行各种调用,以从后端检索数据和/或发起请求。在我们的情况下,处理请求的组件是亚马逊 API 网关——它是一个 HTTP 服务器。
我们通过将请求映射到处理程序来配置 API 网关(例如,如果客户端发出GET /restaurants/123请求,则可以设置 API 网关调用名为RestaurantsFunction的 Lambda 函数,并传递请求的详细信息)。API 网关将同步调用 Lambda 函数,并等待函数评估请求并返回响应。
由于 Lambda 函数实例本身不是可远程调用的 API,API 网关实际上会调用 Lambda 平台,指定要调用的 Lambda 函数、调用类型(RequestResponse)和请求参数。Lambda 平台随后会实例化一个RestaurantsFunction实例,并使用请求参数调用它。
Lambda 平台确实有一些限制,例如我们已经提到的最大超时时间,但除此之外,它基本上是一个标准的 Linux 环境。在RestaurantsFunction中,例如,我们可以调用数据库——亚马逊的 DynamoDB 是与 Lambda 一起使用的流行数据库之一,部分原因是两个服务的类似扩展能力。
一旦函数完成其工作,它会返回一个响应,因为它以同步方式调用。Lambda 平台将此响应传回 API 网关,后者将响应转换为 HTTP 响应消息,并将其传递回客户端。
通常,Web API 将满足多种类型的请求,映射到不同的 HTTP 路径 和 动词(如 GET、PUT、POST 等)。在开发由 Lambda 支持的 Web API 时,通常会将不同类型的请求实现为不同的 Lambda 函数,尽管您不必使用这样的设计——如果愿意,可以将所有请求作为一个函数处理,并根据原始 HTTP 请求路径和动词在函数内部切换逻辑。
文件处理
Lambda 的常见用例是文件处理。让我们想象一个移动应用程序可以将照片上传到远程服务器,然后我们希望以不同的图像尺寸在我们的产品套件的其他部分中使用,如图 1-6 所示。

图 1-6. 使用 AWS Lambda 进行文件处理
S3 是亚马逊的简单存储服务,即 2006 年推出的同一服务。移动应用可以通过 AWS API 安全地将文件上传到 S3。
当文件上传时,可以配置 S3 来调用 Lambda 平台,指定要调用的函数,并传递文件路径。与前面的示例类似,Lambda 平台会实例化 Lambda 函数,并使用 S3 传递的请求详细信息调用它。但是,此时的调用是异步调用(S3 指定了Event调用类型)——不会向 S3 返回任何值,S3 也不会等待返回值。
这次我们的 Lambda 函数仅存在于副作用的目的——它加载由请求参数指定的文件,然后在不同的 S3 存储桶中创建新的调整大小版本的文件。副作用完成后,Lambda 函数的工作就完成了。由于它在 S3 存储桶中创建了文件,我们可以选择向该存储桶添加 Lambda 触发器,还可以调用进一步处理这些生成文件的 Lambda 函数,从而创建处理管道。
Lambda 应用程序的其他示例
前两个示例展示了两种不同的 Lambda 事件源的场景。还有许多其他事件源可以使我们构建许多其他类型的应用程序。其中一些如下:
-
我们可以构建消息处理应用程序,使用消息总线,如简单通知服务(SNS)、简单队列服务(SQS)、事件桥或 Kinesis 作为事件源。
-
我们可以构建邮件处理应用程序,使用简单电子邮件服务(SES)作为事件源。
-
我们可以构建类似于 cron 程序的定时任务应用程序,使用 CloudWatch 计划事件作为触发器。
请注意,除 Lambda 外的许多服务都是BaaS服务,因此也是无服务器的。结合 FaaS 和 BaaS 来生成无服务器架构是一种非常强大的技术,因为它们具有类似的扩展性、安全性和成本特性。事实上,正是这些服务的组合推动了无服务器计算的流行。
我们在第五章中深入讨论了以这种方式构建应用程序的内容。
AWS Lambda 在 Java 世界中
AWS Lambda 原生支持大量编程语言。JavaScript 和 Python 是 Lambda 的非常流行的“入门”语言(以及重要的生产应用程序语言),部分原因是它们的动态类型和非编译性质使得开发周期非常快速。
但是,我们起步时都使用 Java 与 Lambda。Java 在 Lambda 的世界中偶尔会有不良声誉——其中有些是合理的,有些则不是。然而,如果 Lambda 函数所需的内容可以用大约 10 行或更少的代码表达,通常在 JavaScript 或 Python 中快速组合会更快。但是,对于较大的应用程序,有许多很好的理由在 Java 中实现 Lambda 函数,其中一些如下:
-
如果您或您的团队比其他 Lambda 支持的语言更熟悉 Java,那么您将能够在一个新的运行时平台中重用这些技能和库。在 Lambda 生态系统中,Java 与 JavaScript、Python、Go 等一样是“一流语言”—Lambda 不会因为您使用 Java 而对您造成限制。此外,如果您已经在 Java 中实现了大量代码,则与其重新实现为其他语言相比,将其移植到 Lambda 可能会带来显著的时间市场优势。
-
在高吞吐量消息系统中,相比于 JavaScript 或 Python,Java 通常能带来显著的运行时性能优势。在任何系统中,“更快”通常意味着“更好”,而在 Lambda 中,“更快”还可能导致实际的成本优势,这是由于 Lambda 的定价模型。
对于 JVM 工作负载,在撰写本文时,Lambda 原生支持 Java 8 和 Java 11 运行时。Lambda 平台将在其 Linux 环境中实例化一个 Java 运行环境版本,然后在该 Java 虚拟机中运行我们的代码。因此,我们的代码必须与该运行时环境兼容,但我们不仅仅局限于使用 Java 语言。Scala、Clojure、Kotlin 等都可以在 Lambda 上运行(详见“其他 JVM 语言和 Lambda”)。
Lambda 还有一个高级选项,即定义自己的运行时环境,如果这两个 Java 版本都不够用,我们在“自定义运行时”中进一步讨论这一点。
Lambda 平台提供了一些基本的库与运行时(例如 AWS Java 库的一个小子集),但您的代码需要的任何其他库必须随代码本身提供。您将在“构建和打包”中学习如何做到这一点。
最后,虽然 Java 具有Lambda 表达式的编程构造,但这与 AWS Lambda 函数无关。如果您愿意,您可以在 AWS Lambda 函数中使用 Java Lambda 表达式(因为 AWS Lambda 支持 Java 8 及更高版本),也可以选择不使用。
概要
在本章中,您了解到无服务器计算是云计算的下一个演进阶段—一种通过依赖处理资源管理、扩展等服务来构建应用程序的方式,而无需配置。
此外,您现在了解到函数即服务(FaaS)和后端即服务(BaaS)是无服务器的两个组成部分,其中 FaaS 是无服务器中的通用计算范式。有关无服务器的更多信息,请参阅我们的免费 O’Reilly 电子书什么是无服务器?
您还至少对亚马逊 Web 服务有基本的了解—这是全球最流行的云平台之一。您了解到 AWS 具有托管应用程序的巨大容量,并且了解到如何通过 Web 控制台以及 API/CLI 访问 AWS。
您已经了解了 AWS Lambda—亚马逊的函数即服务产品。我们将“思考 Lambda”与传统构建的应用程序进行了比较,讨论了为什么您可能希望使用 Lambda 而不是其他函数即服务的实现,然后给出了一些使用 Lambda 构建的应用程序示例。
最后,您看到了 Java 作为 Lambda 语言选项的快速概述。
在第二章中,我们实现了我们的第一个 Lambda 函数—为一个全新的世界做好准备!
练习
-
获取一个AWS 账户的凭据。最简单的方法是创建一个新账户。正如我们之前提到的,如果您这样做,您将需要提供信用卡号码,但我们在本书中所做的一切应该都在免费层范围内,除非您在测试中非常热情!
或者您可以使用现有的 AWS 账户,但如果这样做,我们建议使用一个“开发”账户,以免干扰任何“生产”系统。
我们还强烈建议,无论您使用何种访问方式,都应该为您授予账户内的完全管理员权限;否则,您将因分散注意力的安全问题而陷入困境。
-
登录到AWS 控制台。找到 Lambda 部分—那里已经有任何函数了吗?
-
扩展任务: 查看Amazon 的无服务器营销页面,特别是它描述“无服务器平台”中各种服务的部分。哪些服务完全满足我们之前描述的无服务器服务的区分标准?哪些不满足,并且以什么方式它们“大部分”是无服务器的?
第二章:开始使用 AWS Lambda
第一章为你提供了这本书其余部分的背景:云端、无服务器、AWS,以及对 Lambda 的介绍,它的工作原理及其用途。但这是一本实用的书籍,面向实际的人,所以在本章中,我们将卷起袖子,在云端部署一些工作函数。
我们将从使你更熟悉 AWS 控制台开始,然后部署和运行我们的第一个 Lambda 函数。之后,我们将准备一个本地开发环境,最后我们将构建并部署一个本地开发的函数到 Lambda。
注意
如果你已经对 AWS 很有经验,请随时跳到“Lambda Hello World (尽快)”。
AWS 控制台快速指南
第一章的前两个练习涉及获取 AWS 凭证,然后登录到AWS Web 控制台。如果你还没有这样做,现在应该去做。
有点令人困惑的是,你可能使用了三种不同类型的凭据来登录:
-
你可能使用过账户“root”用户,使用电子邮件地址和密码登录。这相当于在 Linux 系统中使用 root 用户。
-
你可能使用了一个“IAM 用户”和密码。在这种情况下,你还需要提供数值 AWS 账户 ID(或 AWS 账户别名)。
-
最后,你可能使用了单一登录方法(例如通过 Google Apps 账户)。
现在你成功登录了吗?太棒了!让我们来一场关于 AWS 世界的小旅行。
注意
首先,我要简要警告和解释一下。AWS Web 控制台经常进行用户体验(UX)改进,所以当你阅读这本书时,UI 可能与书中所示有所不同。我们会尽量解释示例的意图,而不仅仅是操作方式,这样当亚马逊改变其 UI 时,你仍然能够跟上。
区域
让我们开始吧。首先让我们谈谈区域。在右上角,你会看到当前选择的区域(见图 2-1](#currently-selected-region))。

图 2-1. 当前选择的区域
正如你在第一章学到的,AWS 将其基础设施组织成称为可用区(AZs)的数据中心,然后将 AZs 集成到一个称为区域的紧密位于一起的组中。每个区域都是半自治的。现在你正在查看特定区域的 Web 控制台首页——在我们之前的例子中,那是俄勒冈州,也被称为 us-west-2 区域。
当你登录时,不一定要使用默认选择的区域——你可以自由地环游世界,寻找适合你的正确区域。点击区域名称,查看可用区域的列表(见图 2-2](#pick-a-region))。

图 2-2. 选择一个区域
对于本书中要涵盖的内容,任何区域都应该足够。我们将在所有操作中都默认使用美国西部(俄勒冈州),如果你愿意,也可以选择更接近你家的区域作为备用选择。
身份和访问管理
现在让我们选择我们的第一个服务。在 Web 控制台首页上,展开所有服务,找到名为IAM的服务,或者在搜索框中搜索IAM,然后选择它。
IAM 是身份和访问管理的缩写——它是 AWS 中最基础的安全服务之一。它也是少数几个不绑定到任何特定区域的 AWS 服务之一(注意引用全球,以前用于定义你的区域)。
IAM 允许你创建“IAM 用户”、组、角色、策略等等。如果你使用为本书创建的 AWS 帐户(因此使用“根”电子邮件地址用户登录),我们建议为将来的工作创建一个 IAM 用户。我们将在“获取 AWS CLI 的凭证”中描述如何做到这一点。
角色类似于用户,可以用来允许人或过程获取特定的权限以完成任务。与用户不同的是,它们没有用户名或密码,而是必须承担角色才能使用。
AWS 是安全性的坚定支持者,这一点你很快就会发现。当你创建 Lambda 函数时,必须指定它执行时要承担的角色。如果没有指定角色,AWS 不会为其提供默认角色。我们稍后将在创建第一个函数时看到这一点。
你必须对 IAM 有基本的理解,因为在 Lambda 开发中,像角色和策略这样的方面是无处不在的。我们会在“身份和访问管理”章节中为你提供 IAM 的全面基础知识。
Lambda Hello World(尽快上手)
在本节中,我们将部署和运行我们的第一个 Lambda 函数。我们会告诉你一个小秘密——我们将使用 JavaScript 完成这个任务。嘘——别告诉我们的编辑——我们曾承诺这将是一本 Java 书籍!
之所以选择 JavaScript 作为第一个示例,是因为我们可以在 Web 浏览器中完全进行整个练习,让我们在几分钟内尝试 Lambda 的潜力。
首先,返回 AWS Web 控制台主屏幕,选择 Lambda。如果你以前没有在此帐户中使用 Lambda,你将看到类似于图 2-3 的屏幕。

图 2-3 Lambda 欢迎屏幕
如果在此帐户中之前使用过 Lambda,Web 控制台的外观会更像图 2-4。

图 2-4 Lambda 函数列表
由于亚马逊不断变化的 UI 设计,你阅读时可能会看到不同的外观。
无论哪种方式,点击创建函数,然后选择从头开始编写—这里有一些其他选项可供您开始使用更复杂的函数,但我们现在将做一些非常简单的事情。
在名称框中(参见图 2-5),输入HelloWorld,在运行时下点击Node.js 10.x。别担心,我们很快就会开始使用 Java!现在点击创建函数。

图 2-5. 创建 HelloWorld 函数
如果此后控制台扩展了权限部分,请在执行角色下拉菜单中选择使用基本 Lambda 权限创建新角色,然后再次点击创建函数(参见图 2-6)。

图 2-6. 创建 HelloWorld 函数,指定创建一个新角色
Lambda 将在 Lambda 平台内创建一个 Lambda 函数配置,并在短暂等待后将您带到 Lambda 函数的主控制台页面。
如果您向下滚动,您会看到它甚至已经为函数提供了一些默认代码—对我们来说,现在这些代码完全合适。
滚动回顶部,点击测试按钮。这将打开一个名为配置测试事件的对话框—在事件名称框中输入HelloWorldTest,然后点击创建。这将带您回到 Lambda 函数屏幕。现在再次点击测试。
这次 Lambda 将实际执行您的函数,并且会有一个短暂的延迟,因为它正在为代码实例化一个环境。然后您将看到一个执行结果的框—应该会显示函数执行成功!
展开详细信息控件,您将看到从函数返回的值,以及一些其他诊断信息(参见图 2-7)。

图 2-7. HelloWorld 执行
恭喜您—您已经创建并运行了您的第一个 Lambda 函数!
设置您的开发环境
现在您已经尝试了运行函数的一点(无服务器!),我们将转而实际构建和部署 Java Lambda 函数,这种方式更适合快速迭代和自动化。
首先,您需要设置一个本地开发环境。
AWS 命令行界面
如果您以前使用过 AWS CLI 并且已在您的计算机上配置了它,您可以跳过这一步。
安装 AWS CLI
Amazon 和 AWS 都建立在 API 之上。在Amazon API 命令的经典故事中,我们看到“所有团队从现在开始都将通过服务接口公开其数据和功能”,以及“所有服务接口必须从头开始设计,以便能够外部化”。这意味着几乎我们可以通过 AWS Web 控制台 UI 做的任何事情,我们也可以通过 AWS API 和 CLI 完成。
AWS API 是一个大集合的 HTTP 终端点,我们可以调用它们在 AWS 内执行操作。虽然直接调用 API 得到了完全的支持,但由于诸如身份验证/请求签名、正确的序列化等问题,这也显得有些繁琐。因此,AWS 为我们提供了两个工具来简化操作 —— SDK 和 CLI。
软件开发工具包(SDK)是 AWS 提供的库,我们可以在代码中使用它们调用 AWS API,从而简化一些复杂或重复的工作,例如身份验证。我们稍后在本书中使用这些 SDK —— “示例:构建无服务器 API”深入探讨了这个主题。
现在,我们将使用 AWS CLI。CLI 是一个可以从终端使用的工具 —— 它包装了 AWS API,因此几乎可以通过 CLI 访问 API 提供的所有内容。
您可以在 macOS、Windows 和 Linux 上使用 CLI;但是,我们在这里给出的所有示例和建议都是针对 macOS 的。如果您的开发机器使用不同的操作系统,则应将此处的说明与 AWS CLI 文档中指定的内容结合使用。
按照以下说明来 安装 CLI。如果您使用 Mac 和 Homebrew,安装 CLI 就像运行 brew install awscli 一样简单。
要验证 CLI 安装的有效性,请从终端提示符下运行aws --version。它应该返回类似以下的内容:
$ aws --version
aws-cli/1.15.30 Python/3.6.5 Darwin/17.6.0 botocore/1.10.30
准确的输出将取决于您的操作系统,以及其他因素。
获取 AWS CLI 的凭证
使用 AWS CLI 的凭证与您用于登录 AWS Web 控制台的凭证不同。对于 CLI,您需要两个值:一个访问密钥 ID及其密钥访问密钥。如果您已经有了这些值,可以跳过到下一节。
访问密钥 ID 和密钥访问密钥对是分配给IAM 用户的凭证。也可以将密钥和密钥分配给与电子邮件地址关联的帐户根用户,但出于安全原因,AWS 强烈建议不要这样做,我们也一样。
如果您还没有 IAM 用户(因为您使用了根用户登录,或者因为您使用了 SSO),您需要创建一个 IAM 用户。要执行此操作,请转到本章前面访问过的 AWS Web 控制台中的 IAM 控制台。单击用户,并仔细检查屏幕上是否有适合您的用户(参见图 2-8)。

图 2-8. IAM 用户列表
如果您确实需要创建用户,请点击 添加用户。在第一个屏幕上,为您的用户取一个名称,并选择 程序访问 和 AWS 管理控制台访问。然后选择 自定义密码 并输入新密码 —— 这将是使用此新用户登录 AWS Web 控制台的密码,如果您希望这样做的话。取消选中 密码重置(见 图 2-9)。然后点击 下一步:权限。

图 2-9. 添加 IAM 用户
在下一个屏幕上,选择 直接附加现有策略 并选择 管理员访问(见 图 2-10)。为了学习 Lambda,拥有具备完整权限的用户将使我们的生活更加轻松。在真实的生产账户中不应执行此操作。

图 2-10. 添加 IAM 用户权限
点击 下一步:标签,然后在接下来的屏幕上点击 下一步:审核。
在下一个屏幕上,检查详细信息是否与我们刚刚描述的相符,并点击 创建用户。
在最后一个屏幕上,您将获得新用户的编程安全凭证!将访问密钥 ID 和秘密访问密钥(在显示后)复制到一个备忘录中(保持安全),或下载提供的 CSV 文件。最后,点击 关闭。
如果您已经有一个 IAM 用户,但没有编程凭证,或者您丢失了刚创建的账户的凭证,请返回 IAM 控制台中的用户列表,选择用户,然后选择 安全凭证 选项卡。您可以从那里创建新的访问密钥(及相关的秘密访问密钥 ID)。
配置 AWS CLI
现在是时候配置 CLI 了。从终端运行 aws configure。对于前两个字段,请粘贴您从上一节复制的值。对于默认区域名称,请输入与您选择的 AWS 区域相对应的区域代码。您可以在 Web 控制台的下拉菜单中看到区域代码(这些映射也可以在 AWS 文档 中找到)。因为我们在 Web 控制台中选择了 Oregon 作为示例,所以在终端的示例中我们将使用 us-west-2。最后,对于默认输出格式,请输入 json。
警告
如果您已在 CLI 中配置了不同的 AWS 账户,并为本书添加了新账户,则需要创建一个不同的配置文件;否则,上述说明将替换您现有的凭证。使用 aws configure 的 --profile 选项,并查看更多细节 在 AWS 文档中。
要确认您的值,请再次运行 aws configure,您将看到类似以下设置的内容:
$ aws configure
AWS Access Key ID [********************]:
AWS Secret Access Key [********************]:
Default region name [us-west-2]:
Default output format [json]:
快速验证 AWS 配置文件的一种好方法是运行命令 aws iam get-user,其输出应该类似于以下内容,其中 UserName 是正确 IAM 用户的名称:
$ aws iam get-user
{
"User": {
"Path": "/",
"UserName": "book",
"UserId": "AIDA111111111111111111",
"Arn": "arn:aws:iam::181111111111:user/book",
"CreateDate": "2019-10-21T20:27:05Z"
}
}
如果您需要更多帮助,请访问文档。
Java 设置
现在您已经有了本地 AWS 环境,是时候设置 Java 环境了。
AWS Lambda 支持 Java 8 和 Java 11,强烈建议您在本地配置 Lambda 函数时与您使用的 Java SE Development Kit 的主要版本保持一致。大多数操作系统支持安装多个版本的 Java。
如果您尚未安装 Java,则至少有几个选项可供选择:
-
其中一个是 AWS 自家的 Java 发行版——Corretto。Corretto,根据 AWS 的说法,“是一个无成本、多平台、生产就绪的 Open Java Development Kit (OpenJDK) 发行版。”详情请参阅“Amazon Corretto 8 是什么?”了解 Java 8 或者“Amazon Corretto 11 是什么?”了解 Java 11 的安装信息。
-
另一个选择是Oracle 自家的发行版;但是现在这个版本带有许可限制,可能会影响您的使用。
就 Lambda 开发者而言,目前这两个选项的主要区别基本上是法律上的而非技术上的。然而,我们预计 AWS 将会在未来将所有的 Java 环境转移到 Corretto 上,因此如果有疑问,我们建议 Lambda 开发者选择 Corretto Java SDK。
要验证您的 Java 环境,请从终端运行java -version,您应该会看到类似以下内容的输出:
$ java -version
openjdk version "1.8.0_232"
OpenJDK Runtime Environment Corretto-8.232.09.1 (build 1.8.0_232-b09)
OpenJDK 64-Bit Server VM Corretto-8.232.09.1 (build 25.232-b09, mixed mode)
Java 的精确构建版本并不重要(尽管保持与安全补丁的最新状态始终是明智的),但重要的是您有正确的基础版本。
我们还使用 Maven——构建和打包工具。如果您已经安装了 Maven,请确保它是比较更新的版本。如果您还没有安装 Maven 并且使用的是 Mac,那么我们建议使用 Homebrew 安装它——运行brew install maven。否则,请参阅Maven 官网获取安装指南。
打开终端并运行mvn -v来验证您的环境。您应该会看到类似以下内容开头的输出:
$ mvn -v
Apache Maven 3.6.0 (97c98ec64a1fdfee77...
Maven home: /usr/local/Cellar/maven/3.6.0/libexec
Java version: 1.8.0_232, vendor: Amazon.com Inc., runtime: /Library/Java...
Default locale: en_US, platform encoding: UTF-8
OS name: "mac os x", version: "10.14.6", arch: "x86_64", family: "mac"
本书中的任何 3.x 版本的 Maven 都能够满足我们的需求。
最后,您应该能够在您选择的开发编辑器中轻松创建使用 Maven 的 Java 项目。我们使用的是免费版本的IntelliJ IDEA,但您可以随意选择其他编辑器。
AWS SAM CLI 安装
您还需要安装的最后一个工具是 AWS SAM CLI。SAM 代表 Serverless Application Model,我们稍后会探讨它在“CloudFormation 和 Serverless Application Model”中的应用。现在您只需要知道 SAM CLI 是在常规 AWS CLI 的基础上提供一些有用的额外工具。
要安装 SAM,请参考 详细说明。如果时间紧迫,可以跳过关于 Docker 的文档部分,因为起初我们不会使用它们!
警告
我们使用 SAM CLI 的一些功能,这些功能在 2019 年末引入,所以如果你使用的是早期版本,请确保更新它。
Lambda Hello World(正确的方式)
开发环境准备好后,现在是时候创建和部署一个用 Java 编写的 Lambda 函数了。
创建你的第一个 Java Lambda 项目
在构建和部署 Lambda 函数的自动化过程中,有一些“样板代码”是必需的。在本书的过程中,我们将详细介绍所有复杂性,但为了让您快速上手,我们已经创建了一个模板来加快速度。
首先,进入终端并运行以下命令:
$ sam init --location gh:symphoniacloud/sam-init-HelloWorldLambdaJava
这将要求你提供一个 project_name 值,暂时只需按 Enter 使用默认值即可。
然后命令将生成一个项目目录。切换到该目录并查看。你将看到以下文件:
README.md
一些关于如何构建和部署项目的说明
pom.xml
一个 Maven 项目文件
template.yaml
一个 SAM 模板文件——用于将项目部署到 AWS
src/main/java/book/HelloWorld.java
一个 Lambda 函数的源代码
现在打开你选择的 IDE/editor 中的项目。如果你使用的是 Jetbrains IntelliJ IDEA,可以通过运行以下命令来打开:
$ idea pom.xml
在 pom.xml 文件中,如果你愿意的话,将 <groupId> 更改为更适合你自己的值。
现在看一下 示例 2-1,显示的是 src/main/java/book/HelloWorld.java 文件。
示例 2-1. Hello World Lambda(Java 实现)
package book;
public class HelloWorld {
public String handler(String s) {
return "Hello, " + s;
}
}
这个类代表了一个完整的 Java Lambda 函数。很小,不是吗?不要太担心它的内容和原因;我们很快就会讲到。现在,让我们来构建我们的 Lambda 部署工件。
构建 Hello World
我们通过上传一个 ZIP 文件将代码部署到 Lambda 平台,或者在 Java 世界中,我们也可以部署一个 JAR 文件(JAR 文件只是一个包含了一些嵌入式元数据的 ZIP)。现在我们将创建一个 uberjar ——一个包含我们所有代码以及我们的代码需要的所有类路径依赖项的 JAR 文件。
刚刚创建的模板项目已设置好为您创建一个 uberjar。我们现在不会检查它,因为在 第四章 中,我们将更深入地探讨一个生成 Lambda ZIP 文件的更好方法(“组装 ZIP 文件”)。
要构建 JAR 文件,请从项目的工作目录运行 mvn package。这应该在结束时成功完成,并显示以下行:
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
这也应该创建我们的 uberjar。运行 jar tf target/lambda.jar 来列出 JAR 文件的内容。输出应包括 book/HelloWorld.class,这是我们应用程序代码,嵌入在工件中。
创建 Lambda 函数
本章早些时候,我们通过 Web 控制台向您展示了如何创建 Lambda 函数。现在,我们将从终端执行同样的操作。我们将使用sam命令来完成这两个进一步的操作。
在此之前,我们需要在S3 AWS 服务中创建或识别一个暂存桶,以存储临时构建构件。如果您按照 AWS 的 SAM CLI 安装说明或已知道当前 AWS 账户中有这样一个存储桶可用,请随意使用。否则,您可以使用以下命令创建一个,将自己的名称替换为bucketname。请注意,S3 存储桶名称需要在所有 AWS 账户中全局唯一,因此您可能需要尝试几个名称以找到一个可用的。
$ aws s3 mb s3://bucketname
完成这些步骤后,请记录下这个存储桶名称——我们在本书的后续部分会经常使用它,并将其称为$CF_BUCKET。
注意
从现在开始,无论何时看到$CF_BUCKET,请使用刚刚创建的存储桶名称。为什么叫CF?这代表CloudFormation,我们将在第 4 章中详细解释。
或者,如果您更熟悉 Shell 脚本,可以将此存储桶名称分配给名为CF_BUCKET的 Shell 变量,然后您可以直接使用对$CF_BUCKET的引用。
准备好 S3 存储桶后,我们可以创建 Lambda 函数。运行以下命令(在运行mvn package之后):
$ sam deploy \
--s3-bucket $CF_BUCKET \
--stack-name HelloWorldLambdaJava \
--capabilities CAPABILITY_IAM
目前不要过多关注这些内容的含义——我们稍后会进行解释。如果操作正确,控制台输出应以以下内容结尾(尽管您的区域可能不同):
Successfully created/updated stack—HelloWorldLambdaJava in us-west-2
这意味着您的函数已部署并准备就绪,现在让我们运行它。
运行 Lambda 函数
返回 Lambda Web 控制台中的函数列表,您现在应该可以看到列出了两个函数:原始的HelloWorld和一个新的函数,其名称可能类似于HelloWorldLambdaJava-HelloWorldLambda-YF5M2KZHXZF5。如果没有看到新的 Java 函数,请确保您的终端和 Web 控制台的区域设置是同步的。
点击进入新的函数,查看配置屏幕。您会发现,由于函数是使用编译后的构件创建的,因此源代码已不再可用。
要测试此函数,我们需要创建一个新的测试事件。再次点击Test,在配置测试事件屏幕上(图 2-11),给事件命名为HelloWorldJavaEvent。在实际事件主体部分,输入以下内容:
"Java World!"

图 2-11. 配置 Java Lambda 函数的测试事件
点击创建以保存测试事件。
这将带您回到主要的 Lambda 屏幕,并选择新的测试事件(如果没有,请手动选择)。点击Test,您的 Lambda 函数将被执行!(参见图 2-12。)

图 2-12. Java 中 Hello World 的结果
概要
在本章中,您学习了如何登录 AWS Web 控制台并选择一个区域。然后,通过 Web 控制台创建并运行了您的第一个 Lambda 函数。
你还通过设置 AWS CLI、Java、Maven 和 AWS SAM CLI 为 Lambda 开发准备了本地环境。你通过在开发环境中创建项目、构建项目并使用 Amazon 的 SAM 工具部署项目,学习了用 Java 开发 Lambda 函数的基础知识。最后,你现在了解如何通过模拟事件使用 Web 控制台的测试事件机制来进行 Lambda 函数的简单测试。
在下一章中,我们将开始研究 Lambda 的工作原理以及这些原理对 Lambda 代码编写方式的影响。
练习
-
如果您还没有按照本章的逐步说明进行操作,那么现在进行操作是很值得的,因为这是验证您环境的好方法。
-
在
sam deploy时,通过使用不同的stack-name值,创建一个具有稍微不同代码的新版本 Java Lambda 函数。注意您如何在 Web 控制台中选择这些函数之间的区别。
第三章:编程 AWS Lambda 函数
本章内容涉及构建 Lambda 函数的含义—它们是什么样子的,如何配置它们的运行方式,以及如何指定自己的环境配置。通过检查 Lambda 执行环境的核心概念、输入和输出、超时、内存和 CPU,最后,Lambda 如何使用环境变量进行应用程序配置来学习这些主题。
首先,让我们看看 Lambda 函数是如何执行的。系好您的登山靴—是时候探索一番了。
核心概念:运行时模型、调用
在第二章中,您创建了一个 Java 类,将其上传到某个位于“云”中的 Lambda 服务,并神奇地能够执行该代码。您不必考虑操作系统、容器、启动脚本、代码部署到实际主机或 JVM 设置。也不必考虑那些讨厌的“服务器”。那么您的代码是如何执行的呢?
要理解这一点,您首先需要了解 Lambda 执行环境的基础知识,如图 3-1 所示。

图 3-1. Lambda 执行环境
Lambda 执行环境
正如我们在第二章中提到的(参见“安装 AWS CLI”),AWS 的管理和函数操作(通常称为控制平面和数据平面)都广泛使用 API。Lambda 也不例外,为函数的管理和执行提供 API。
每当调用 AWS Lambda API 的invoke命令时,函数都会被执行或调用。这发生在以下时间:
-
当函数由事件源触发时
-
当您在 Web 控制台中使用测试工具包时
-
当您自己调用 Lambda API 的
invoke命令时,通常通过 CLI 或 SDK,从您自己的代码或脚本中。
首次调用函数将启动以下一系列活动链,最终导致您的代码被执行。
首先,Lambda 服务将创建一个主机 Linux 环境—一个轻量级微虚拟机。通常您不需要担心它究竟是何种环境(什么内核,什么发行版等),但如果您关心,亚马逊会公布这些信息。但不要依赖它保持不变—亚马逊可能会频繁更改 Lambda 函数的操作系统,通常是为了您自己的利益,包括自动安全补丁。
一旦主机环境被创建,Lambda 将在其中启动语言运行时—在我们的例子中是 Java 虚拟机。在撰写本文时,JVM 版本将始终为 Java 8 或 Java 11。您必须提供与您选择的 Java 版本兼容的代码给 Lambda。JVM 是以一组环境标志启动的,我们无法更改。
当我们编写代码时,您可能已经注意到没有“main”方法——顶级 Java 应用程序是亚马逊自己的 Java 应用程序服务器,我们将其称为 Lambda Java 运行时;这是下一个要启动的组件。运行时负责顶级错误处理、日志记录等。
当然,Lambda Java 运行时的主要任务是执行我们的代码。调用链的最后几步是:(a)加载我们的 Java 类,并(b)调用我们在部署过程中指定的处理方法。
调用类型
很好——我们的代码已经运行!接下来会发生什么?
为了探索这个问题,让我们开始使用 AWS CLI。在 第二章 中,我们使用了更高级别的 SAM CLI 工具——AWS CLI 更接近 AWS 机器的内部。具体来说,我们将使用 AWS CLI 中用于调用 Lambda 函数的命令:aws lambda invoke。
假设您在 第二章 中运行了示例,让我们从一个小更新开始。打开 template.yaml 文件(我们将从现在开始偶尔称为 SAM 模板),在属性部分中添加一个名为 FunctionName 的新属性,值为 HelloWorldJava,以使资源部分如下所示:
HelloWorldLambda:
Type: AWS::Serverless::Function
Properties:
FunctionName: HelloWorldJava
Runtime: java8
MemorySize: 512
Handler: book.HelloWorld::handler
CodeUri: target/lambda.jar
重新从 第二章 运行 sam deploy 命令。几分钟后应该完成。如果回到 Lambda 控制台,你会看到你的奇怪命名的 Java 函数现在已经重命名为 HelloWorldJava。在大多数实际用例中,我们喜欢使用 AWS 提供的生成名称,但当我们学习 Lambda 时,能够引用更简洁名称的函数会更好。
注意
要使用 Java 11 运行时而不是 Java 8,只需在 SAM 模板中将 Runtime: 属性从 java8 更改为 java11。
让我们回到调用。从终端运行以下命令:
$ aws lambda invoke \
--invocation-type RequestResponse \
--function-name HelloWorldJava \
--payload \"world\" outputfile.txt
这应该返回以下内容:
{
"StatusCode": 200,
"ExecutedVersion": "$LATEST"
}
您可以通过 StatusCode 是 200 来确认一切正常。
通过执行以下命令,还可以查看 Lambda 函数返回的内容:
$ cat outputfile.txt && echo
"Hello, world"
当我们执行 invoke 命令时,Lambda 函数首先被实例化,正如我们在前一节中描述的那样。实例化完成后,Lambda Java 运行时在 JVM 内部调用我们的 Lambda 函数,使用我们传递给 payload 参数的数据——在本例中是字符串 "world"。
我们的代码然后运行。作为提醒,这里是代码:
public String handler(String s) {
return "Hello, " + s;
}
它接受我们的输入("world"),并返回 "Hello, world"。
这里有一个重要但微妙的点。当我们调用 invoke 时,我们指定了 --invocation-type RequestResponse——这意味着我们 同步 调用函数(即 Lambda 运行时调用我们的代码并等待结果)。我们在 “Lambda 应用程序是什么样子?” 中解释了这一点。同步行为 对于像 Web API 这样的场景非常有用。
因为我们同步调用了函数,Lambda 运行时能够将响应返回给我们的终端,这就是保存到 outputfile.txt 的内容。
现在让我们稍微不同地调用函数:
$ aws lambda invoke \
--invocation-type Event \
--function-name HelloWorldJava \
--payload \"world\" outputfile.txt
注意我们已将 --invocation-type 标志更改为 Event。现在的结果如下所示:
{
"StatusCode": 202
}
StatusCode 是 202,而不是 200。在 HTTP 术语中,202 意味着已接受。如果您查看 outputfile.txt,您会发现它是空的。
这一次我们以异步方式调用函数。Lambda 运行时像以前一样调用我们的代码,但它不等待或使用我们代码返回的值——该值会被丢弃。使用异步执行的关键在于我们可以对某些其他函数或服务执行“副作用”。在 “Lambda 应用程序是什么样子?” 中的异步示例中,副作用是将照片的新调整大小版本上传到亚马逊的 S3 服务中。
当您开始使用 Lambda 时,您会发现大多数 Lambda 函数类使用异步调用,支持 Lambda 是一个事件驱动平台的理念。我们将在本书稍后的章节中进一步探讨这一点,当我们开始研究 “Lambda 事件来源” 时。
我们在前两个示例中使用了相同的代码;然而,如果您知道您的 Lambda 函数永远不会被同步使用,则不需要返回值——该方法可以具有 void 返回类型。让我们看一个例子。
首先,将函数的方法更改为以下内容:
public void handler(String s) {
System.out.println("Hello, " + s);
}
注意我们已将返回类型更改为 void,并且现在正在向 System.out 写入消息。
现在我们需要重建和重新部署我们的代码。要做到这一点,请运行与 第二章 中相同的两个命令:
-
mvn package -
sam deploy…
其中 **…** 指的是您之前使用的相同参数。您会经常运行这些命令,所以可能想把它们放入一个脚本中。
现在以 Event 调用类型再次调用代码,您应该会收到另一个 "StatusCode": 202 的响应。但是 System.out 中的消息去哪里了?要理解这一点,让我们快速看一下日志记录。
注意
您现在已经了解足够的关于 mvn、sam 和 aws 命令的知识,可以运行本章剩余的示例。如果出现异常情况,请转到 AWS Web 控制台中的 CloudFormation,删除 HelloWorldLambdaJava 栈,然后重新部署。
日志简介
Lambda 运行时捕获我们的函数写入的任何内容,无论是标准输出还是标准错误流。在 Java 中,这些对应于 System.out 和 System.err。一旦 Lambda 运行时捕获到这些数据,它会将其发送到 CloudWatch Logs。如果您是 AWS 的新手,这需要更详细的解释!
CloudWatch Logs 由几个组件组成。其中主要的是日志捕获服务。它便宜、可靠、易于使用,并且可以处理您可能遇到的所有规模。
一旦 CloudWatch Logs 捕获到日志消息,您有几种方法可以查看或处理它们。最简单的方法是使用 AWS Web 控制台中的 CloudWatch Logs 日志查看器。
有多种方法可以实现这一点,但现在请在 AWS Web 控制台中打开您 Lambda 函数页面(如我们在 “运行 Lambda 函数” 中所示)。如果您点击该页面的监控选项卡,您应该能够看到一个 在 CloudWatch 中查看日志 按钮—点击它,如 图 3-2 所示。

图 3-2. 访问 Lambda 日志
接下来您将看到的内容将取决于 CloudWatch 控制台的工作方式,但如果您尚未看到日志输出,请点击蓝色的 搜索日志组 按钮并向下滚动至最近的日志行。然后您应该能够看到类似 图 3-3 中的内容。

图 3-3. Lambda 日志
注意第二行就是我们从 Lambda 函数中编写的输出。
没有一个优秀的、自重的 Java 程序员会真正使用 System.out.println 进行生产日志记录—日志记录框架提供了更多的灵活性和控制日志行为的功能。我们将在 “日志记录” 中详细讨论日志记录实践。
输入,输出
当执行 Lambda 函数时,它总是会传递一个输入参数,通常称为 事件。在 Lambda 执行环境中,此事件始终是一个 JSON 值,在我们到目前为止的示例中,我们一直在手动创建一个字符串—这个字符串本身就是有效的 JSON。
在实际使用中,Lambda 函数的输入将是一个表示来自某些其他组件或系统的事件的 JSON 对象。例如,它可能是表示 HTTP 请求的详细信息,或者是上传到 S3 存储服务的图像的一些元数据。在本书的后面章节中我们将详细讨论如何将事件源与 Lambda 函数绑定—参见 “Lambda 事件源”。
我们在测试事件中创建的 JSON,或者来自事件源的 JSON,会传递给 Lambda Java 运行时。在大多数用例中,Lambda Java 运行时会自动为我们反序列化此 JSON 负载,并且我们有几种选项来指导此过程。
正如您在前一节中看到的,当我们同步调用一个函数时,我们可以将一个有用的值返回给环境。Lambda Java 运行时会自动将此返回值序列化为 JSON。
Java 运行时如何执行这些序列化和反序列化操作取决于我们在函数签名中指定的类型,因此现在是时候深入了解使 Lambda 函数在静态上有效的因素了。
Lambda 函数方法签名
有效的 Java Lambda 方法必须符合以下四种签名之一:
-
output-type handler-name(input-typeinput) -
output-type handler-name(input-typeinput, Context context) -
voidhandler-name(InputStream is, OutputStream os) -
voidhandler-name(InputStream is, OutputStream os, Context context)
其中:
-
output-type可以是void、Java 原始类型或可 JSON 序列化的类型。 -
input-type是 Java 原始类型或可 JSON 序列化的类型。 -
Context指的是com.amazonaws.services.lambda.runtime.Context(我们将在本章稍后详细描述)。 -
InputStream和OutputStream是指java.io包中的相应类型。 -
handler-name可以是任何有效的 Java 方法名称,并且我们在应用程序的配置中引用它。
Java Lambda 方法可以是实例方法也可以是静态方法,但必须是公共的。
包含 Lambda 函数的类不能是抽象的,并且必须具有无参数构造函数——可以是默认构造函数(即未指定任何构造函数)或显式的无参数构造函数。考虑使用构造函数的主要原因之一是在 Lambda 调用之间缓存数据,这是我们稍后会详细讨论的高级主题—参见 “Caching”。
除了这些限制外,Java Lambda 函数没有静态类型要求。您不需要实现任何接口或基类,尽管如果希望可以这样做。AWS 提供了一个 RequestHandler 接口,如果您想非常明确地指定 Lambda 类的类型,但我们从未发现有必要使用这个接口。此外,您可以扩展自己的类(符合构造函数规则),但我们同样发现这种能力很少有用。
您可以在一个类中定义多个具有不同名称的 Lambda 函数,但我们通常不建议这种风格。由于两个不同的 Lambda 函数永远不会在同一个执行环境中运行,我们发现将每个函数的代码清晰地分开可以让后续的工程师更容易理解。
Lambda 函数在静态上与某些其他应用程序框架相比较简单。前面列出的前两个签名是 Java Lambda 最常见的签名,接下来我们将看看它们。
在 SAM 模板中配置处理函数
到目前为止,我们仅对 SAM 模板文件 template.yaml 进行了一次更改,即更改函数的名称。在我们继续之前,我们需要查看该文件中的另一个属性:Handler。
打开 template.yaml 文件,您会看到 Handler 当前设置为 book.HelloWorld::handler。这意味着对于此 Lambda 函数,Lambda 平台将尝试在名为 book 的包中的名为 HelloWorld 的类中找到一个名为 handler 的方法。
如果你在名为old.macdonald.farm的包中创建了一个名为Cow的新类,并且你有一个名为moomoo的方法作为你的 Lambda 函数,那么你应该将Handler设置为old.macdonald.farm.Cow::moomoo。
有了这些信息,你就可以创建一些新的 Lambda 处理程序了!
基本类型
示例 3-1 展示了一个具有三个不同 Lambda 处理函数的类(是的,我们刚才说过在实际使用中我们不倾向于在一个类中使用多个 Lambda 函数—这里为了简洁起见这样做了!)
示例 3-1. 基本类型的序列化和反序列化
package book;
public class StringIntegerBooleanLambda {
public void handlerString(String s) {
System.out.println("Hello, " + s);
}
public boolean handlerBoolean(boolean input) {
return !input;
}
public boolean handlerInt(int input) {
return input > 100;
}
}
要尝试这段代码,请将新的类StringIntegerBooleanLambda添加到你的源代码树中,更改template.yaml文件中的Handler(例如,改为book.StringIntegerBooleanLambda::handlerString),然后运行你的打包和部署命令。
第一个函数与我们在前一节中描述的相同。我们可以通过使用 JSON 对象"world"调用它进行测试,由于它有一个void返回类型,它适用于异步使用。
提示
从现在开始,当我们在示例中说要调用一个函数时,假设我们是指在没有特别指定的情况下以同步方式调用它。你可以通过在终端调用时使用--invocation-type RequestResponse标志或在 AWS Web 控制台中使用测试功能来实现这一点。
第二个函数可以用布尔值调用—任何 JSON 值true、false、"true"或"false"—它也会返回一个布尔值,在这种情况下是输入的反向。
最后一个函数接受一个整数(可以是 JSON 整数或 JSON 字符串中的数字,例如5或"5"),并返回一个布尔值。
在第二和第三个示例中,我们使用了一个原始类型,但如果你愿意,你可以使用装箱类型。例如,你可以使用java.lang.Integer而不是简单的int。
在所有这些情况中发生的情况是 Lambda Java 运行时代表我们将 JSON 输入反序列化为简单类型。如果传递的事件无法反序列化为指定的参数类型,你将收到一个失败消息,消息开头如下:
An error occurred during JSON parsing: java.lang.RuntimeException
字符串、整数和布尔值是唯一明确记录为支持的基本类型,但通过一些实验,我们发现其他基本类型,如双精度和浮点数,也包括在内。
列表和映射
JSON 还包括数组和对象/属性(参见示例 3-2)。Lambda Java 运行时将自动将其反序列化为 Java List和Map,并且还会将输出的List和Map序列化为 JSON 数组和对象。
示例 3-2. 列表和映射的序列化和反序列化
package book;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.IntStream;
public class ListMapLambda {
public List<Integer> handlerList(List<Integer> input) {
List<Integer> newList = new ArrayList<>();
input.forEach(x -> newList.add(100 + x));
return newList;
}
public Map<String,String> handlerMap(Map<String,String> input) {
Map<String, String> newMap = new HashMap<>();
input.forEach((k, v) -> newMap.put("New Map -> " + k, v));
return newMap;
}
public Map<String,Map<String, Integer>>
handlerNestedCollection(List<Map<String, Integer>> input) {
Map<String, Map<String, Integer>> newMap = new HashMap<>();
IntStream.range(0, input.size())
.forEach(i -> newMap.put("Nested at position " + i, input.get(i)));
return newMap;
}
}
使用 JSON 数组[ 1, 2, 3 ]调用函数handlerList()返回[ 101, 102, 103 ]。使用 JSON 对象{ "a" : "x", "b" : "y"}调用函数handlerMap()返回{ "New Map → a" : "x", "New Map → b" : "y" }。
此外,您可以如预期般使用嵌套的集合;例如,通过调用 handlerNestedCollection() 来执行
[
{ "m" : 1, "n" : 2 },
{ "x" : 8, "y" : 9 }
]
返回
{
"Nested at position 0": { "m" : 1, "n" : 2},
"Nested at position 1": { "x": 8, "y" : 9}
}
最后,您也可以只使用 java.lang.Object 作为输入参数的类型。虽然在生产中很少有用(除非您不关心输入参数的值,有时这是一个有效的用途),但在开发时,如果不知道事件的确切格式,这可能很方便。例如,您可以在参数上使用 .getClass() 来查找它的实际类型,打印出 .toString() 的值等等。稍后我们会展示另一种更好的方法来获取事件的 JSON 结构。
POJO 和生态系统类型
对于非常简单的输入类型,前面的输入类型效果很好。对于更复杂的类型,另一种选择是使用 Lambda Java 运行时的自动 POJO(普通的 Java 对象)序列化。示例 3-3 展示了我们如何同时用于输入和输出。
示例 3-3. POJO 序列化和反序列化
package book;
public class PojoLambda {
public PojoResponse handlerPojo(PojoInput input) {
return new PojoResponse("Input was " + input.getA());
}
public static class PojoInput {
private String a;
public String getA() {
return a;
}
public void setA(String a) {
this.a = a;
}
}
public static class PojoResponse {
private final String b;
PojoResponse(String b) {
this.b = b;
}
public String getB() {
return b;
}
}
}
显然,这只是一个非常简单的案例,但它展示了 POJO 序列化的实际效果。我们可以使用输入 { "a" : "Hello Lambda" } 执行此 Lambda,并返回 { "b" : "Input was Hello Lambda" }。让我们仔细看一下代码。
首先,我们有我们的处理函数 handlerPojo()。它以 PojoInput 类型作为输入,这是我们定义的 POJO 类。POJO 输入类可以是静态嵌套类,就像我们在这里写的一样,也可以是常规(外部)类。重要的是,它们需要有一个空的构造函数,并且必须有字段的 setter,这些 setter 遵循从输入 JSON 中反序列化预期字段的命名规则。如果找不到与 setter 同名的 JSON 字段,则 POJO 字段将保持为 null。输入的 POJO 对象需要是可变的,因为运行时会在实例化后修改它们。
我们的处理函数会检查 POJO 对象,并创建 PojoResponse 类的新实例,然后将其传回 Lambda 运行时。Lambda 运行时通过反射所有的 get… 方法将其序列化为 JSON。POJO 输出类的限制较少——由于它们不是由 Lambda 运行时创建或变异的,因此您可以根据自己的意愿构建它们,也可以使它们成为不可变对象。与输入类一样,POJO 输出类可以是静态嵌套类或常规(外部)类。
对于 POJO 输入和输出类,您可以进一步嵌套 POJO 类,使用相同的规则来序列化/反序列化嵌套的 JSON 对象。此外,您可以在输入和输出中混合使用我们讨论过的 POJO 和集合类型(List 和 Map)。
我们之前给出的示例基本上遵循了您在线上看到的大部分文档:为字段使用 JavaBean 约定。然而,如果您不想在输入类中使用 setter 或在输出类中使用 getter,您也可以使用公共字段。例如,示例 3-4 展示了另一个例子。
示例 3-4. POJO 序列化和反序列化的备选定义
package book;
public class PojoLambda {
public PojoResponse handlerPojo(PojoInput input) {
return new PojoResponse("Input was " + input.c);
}
public static class PojoInput {
public String c;
}
public static class PojoResponse {
public final String d;
PojoResponse(String d) {
this.d = d;
}
}
}
我们可以使用输入 { "c" : "Hello Lambda" } 来执行这个 Lambda 函数,它将返回 { "d" : "Input was Hello Lambda" }。
POJO 输入反序列化的主要用途之一是将 Lambda 函数与 AWS 生态系统 Lambda 事件源之一绑定。以下是一个示例,展示了如何处理上传到 S3 存储服务的对象事件的处理函数:
public void handler(S3Event input) {
// …
}
S3Event是你可以从 AWS 库依赖中访问的一种类型——我们将在“示例:构建无服务器数据流水线”中进一步讨论此问题。你也可以自由构建自己的 POJO 类来处理 AWS 事件。
流
到目前为止,我们讨论的输入/输出类型在实际使用 Lambda 中将对你非常有用,甚至可能涵盖所有场景。但是如果你有一个相当动态和/或复杂的结构,而你不能或不想使用先前的反序列化方法的话,该怎么办?
答案是使用前述列表的选项 3 或 4 的有效签名,利用java.io.InputStream作为事件参数。这使你可以访问传递给 Lambda 函数的原始字节。
使用InputStream的 Lambda 的签名略有不同,因为它始终具有void返回类型。如果将InputStream作为参数,你还必须将java.io.OutputStream作为第二个参数。要从这样的处理函数中返回结果,你需要向OutputStream写入内容。
示例 3-5 展示了一个可以处理流的处理程序。
示例 3-5. 使用流作为处理参数
package book;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
public class StreamLambda {
public void handlerStream(InputStream inputStream, OutputStream outputStream)
throws IOException {
int letter;
while((letter = inputStream.read()) != -1)
{
outputStream.write(Character.toUpperCase(letter));
}
}
}
如果我们使用输入 "Hello World" 执行这个处理程序,它将将 "HELLO WORLD" 写入输出流,这将成为函数的结果。
如果你正在使用InputStream,你可能会想要使用自己的 JSON 操作代码,但我们会将这留给读者作为练习。此外,你应该保持流的良好处理习惯——错误检查、关闭等。
想要了解更多相关内容,请参阅官方文档中关于在处理函数中使用流的说明。
一种特别方便的 Lambda 函数使用场景是在开发时,当你不知道编写代码的事件结构时。示例 3-6 将事件记录到 CloudWatch Logs 中,以便你查看其内容。
示例 3-6. 记录接收到的事件到 CloudWatch Logs 中
package book;
import java.io.InputStream;
import java.io.OutputStream;
public class WhatIsMyLambdaEvent {
public void handler(InputStream is, OutputStream os) {
java.util.Scanner s = new java.util.Scanner(is).useDelimiter("\\A");
System.out.println(s.hasNext() ? s.next() : "No input detected");
}
}
上下文
到目前为止,我们已经涵盖了前述列表中的签名格式 1 和 3,那么 2 和 4 是什么呢?Context对象又是什么?
在我们到目前为止的所有示例中,Lambda 处理程序函数所接收的唯一输入是发生的事件。但这并不是处理程序在处理时唯一能接收的信息。此外,您可以在任何处理程序参数列表的末尾添加一个com.amazonaws.services.lambda.runtime.Context参数,运行时将传入一个您可以使用的有趣对象。让我们看一个例子(示例 3-7)。
示例 3-7. 检查 Context 对象
package book;
import com.amazonaws.services.lambda.runtime.Context;
import java.util.HashMap;
import java.util.Map;
public class ContextLambda {
public Map<String,Object> handler (Object input, Context context) {
Map<String, Object> toReturn = new HashMap<>();
toReturn.put("getMemoryLimitInMB", context.getMemoryLimitInMB() + "");
toReturn.put("getFunctionName",context.getFunctionName());
toReturn.put("getFunctionVersion",context.getFunctionVersion());
toReturn.put("getInvokedFunctionArn",context.getInvokedFunctionArn());
toReturn.put("getAwsRequestId",context.getAwsRequestId());
toReturn.put("getLogStreamName",context.getLogStreamName());
toReturn.put("getLogGroupName",context.getLogGroupName());
toReturn.put("getClientContext",context.getClientContext());
toReturn.put("getIdentity",context.getIdentity());
toReturn.put("getRemainingTimeInMillis",
context.getRemainingTimeInMillis() + "");
return toReturn;
}
}
这是我们第一个需要使用 Java 标准库之外类型的完整示例。我们将在下一章节更详细地讨论依赖关系和打包,但现在请在您的pom.xml文件的根元素下的任何位置添加以下部分:
<dependencies>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-core</artifactId>
<version>1.2.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
当您现在运行mvn package时,它将使用 AWS 提供的核心 Lambda 库来编译您的代码,使您能够使用Context接口。
Context对象为我们提供了有关当前 Lambda 调用的信息。在 Lambda 事件处理过程中,我们可以使用这些信息。当我们调用该示例(传入任何输入事件——它不会被使用)时,我们将得到类似以下结果:
{
"getFunctionName": "ContextLambda",
"getLogStreamName": "2019/07/24/[$LATEST]0f1b1111111111111111111111111111",
"getInvokedFunctionArn":
"arn:aws:lambda:us-west-2:181111111111:function:ContextLambda",
"getIdentity": {
"identityId": "",
"identityPoolId": ""
},
"getRemainingTimeInMillis": "2967",
"getLogGroupName": "/aws/lambda/ContextLambda",
"getLogger": {},
"getFunctionVersion": "$LATEST",
"getMemoryLimitInMB": "512",
"getClientContext": null,
"getAwsRequestId": "2108d0a2-a271-11e8-8e33-cdbf63de49d2"
}
所有不同的Context字段都在AWS 文档中有描述。
在特定事件处理期间,这些字段中的大多数都将保持不变,但getRemainingTimeInMillis()是一个显著的例外。它与超时相关,这是我们接下来要看的内容。
超时
Lambda 函数受可配置的超时限制。您可以在创建函数时指定此超时时间,或者稍后在函数的配置中更新它。
在撰写本文时,最大超时时间为 15 分钟。这意味着单次 Lambda 函数调用的最长运行时间为 15 分钟。这个限制是 AWS 未来可能会增加的,之前也曾增加过——长期以来,最大超时时间为 5 分钟。
到目前为止,我们的示例中没有指定超时设置,因此默认为 3 秒。这意味着如果我们的函数在 3 秒内没有完成执行,则 Lambda Java 运行时将中止它。稍后您将在一个示例中看到这个情况。
在前面的部分中,我们查看了Context对象。调用context.getRemainingTimeInMillis()将告诉您在执行期间的任何给定点剩余多少时间可以运行,然后函数将由运行时中止。后续调用将提供更新的持续时间。如果您正在编写一个相当长寿的 Lambda 并希望在超时发生之前保存任何状态,则这将非常有用。
您可能会问自己一个问题——为什么不总是将超时配置为最大的 900 秒?正如我们将在下一节中进一步探讨的那样,Lambda 的成本主要基于函数运行的时间——如果您的函数最多只能运行 10 秒,那么您不希望十亿次调用花费 90 倍的时间,因为您将被收取 90 倍的费用。
超时 不 包括函数实例化时的时间——换句话说,超时期间不会在函数的 冷启动 期间启动。或者更准确地说,超时仅适用于 Lambda 调用我们的 handler 方法后的时间。我们在 “冷启动” 中进一步讨论了冷启动。
15 分钟的最大超时对 Lambda 函数来说是一个重要的约束——如果您正在编写需要超过 15 分钟的功能,您需要将其拆分为多个协调的 Lambda 函数,或者根本不使用 Lambda。
理论足够了,让我们看看超时是如何发挥作用的。
示例 3-8 展示了一个 Lambda 函数,该函数将查询剩余时间,最终由于超时而失败。
示例 3-8. 使用 Context.getRemainingTimeInMillis() 查看超时
package book;
import com.amazonaws.services.lambda.runtime.Context;
public class TimeoutLambda {
public void handler (Object input, Context context) throws InterruptedException {
while(true) {
Thread.sleep(100);
System.out.println("Context.getRemainingTimeInMillis() : " +
context.getRemainingTimeInMillis());
}
}
}
更新您的 template.yaml 文件,在函数的 Properties 部分添加一个名为 Timeout 的新属性。将值设置为 2——这意味着函数的超时现在是两秒。还要记得更新您的 Handler 属性。
然后按照通常步骤运行您的包和部署步骤。
如果我们在 Web 控制台中使用测试功能执行此操作,它将因“任务在 2.00 秒后超时”而失败。日志输出将如下所示:
START RequestId: 6127fe67-a406-11e8-9030-69649c02a345 Version: $LATEST
Context.getRemainingTimeInMillis() : 1857
Context.getRemainingTimeInMillis() : 1756
... Cut for brevity ...
Context.getRemainingTimeInMillis() : 252
Context.getRemainingTimeInMillis() : 152
Context.getRemainingTimeInMillis() : 51
END RequestId: 6127fe67-a406-11e8-9030-69649c02a345
REPORT RequestId: 6127fe67-a406-11e8-9030-69649c02a345 Duration: 2001.52 ms
Billed Duration: 2000 ms Memory Size: 512 MB Max Memory Used: 51 MB
2019-07-24T21:22:30.076Z 444e6ae0-9217-4cd2-8568-7585ca3fafee
Task timed out after 2.00 seconds
这里我们可以看到 getRemainingTimeInMillis() 方法被查询,正如我们预期的那样,然后函数最终由于 Lambda 的超时而失败。
内存和 CPU
Lambda 函数没有无限量的 RAM,实际上每个函数都配置有一个 memory-size 设置。默认设置为 128MB,但对于生产环境的 Java Lambda 函数来说很少足够,因此您应该将 memory-size 视为每个函数都需要认真考虑的内容。
memory-size 可以小至 64MB,尽管对于 Java Lambda 函数,您可能应该至少使用 256MB。memory-size 必须是 64MB 的倍数。
memory-size 设置非常重要,不仅决定函数可以使用多少内存——它也指定了函数可以获取多少 CPU 力量。实际上,Lambda 函数的 CPU 力量从 64MB 线性扩展到 1792MB。因此,配置为 1024MB RAM 的 Lambda 函数比配置为 512MB RAM 的函数具有两倍的 CPU 力量。
1792MB RAM 的 Lambda 函数获得一个完整的虚拟 CPU 核心——比该设置大的 RAM 设置允许秒级虚拟核心的分数。这值得知道,如果您的代码根本没有多线程,您可能在这种情况下看不到内存设置高于 1792MB 时的 CPU 改进。
注意
我们将讨论 Lambda 执行环境如何与多个线程交互在 “Lambda and Threading”。
但为什么你应该关心这个——为什么不总是将memory-size设置为其最大值 3008MB?原因在于成本。AWS 根据两个主要因素收取 Lambda 函数的费用:
-
函数运行时间,四舍五入到最接近的 100 毫秒
-
函数指定使用的内存量
换句话说,给定相同的执行时间,具有 2GB RAM 的 Lambda 函数的执行成本是具有 1GB RAM 的两倍。或者,具有 512MB RAM 的 Lambda 函数的成本是具有 3008MB 的 17%。这在规模上可能会有很大的差异。
这意味着您应该尽可能使用最少的内存吗?不,那并不总是最好的选择。由于具有两倍于较小函数的内存的函数也具有两倍的 CPU 功率,因此它可能需要一半的时间来执行,这意味着成本是相同的,并且可以更快地完成工作。
调整 Lambda 函数的大小有点艺术性。我们建议您从 512MB 到 1GB 之间进行选择,然后随着函数的增大或需要扩展它们而开始调整。
环境变量
前两节都是关于 Lambda 自己的系统配置——如果您想为自己的应用程序使用配置,该怎么办?
我们可以为我们的 Lambda 函数指定 环境变量。这允许我们在相同代码的不同上下文中更改函数运行方式。例如,通过环境变量指定外部进程的连接设置或安全配置是非常典型的。
让我们试试这个。示例 3-9 显示了一个使用 Java 标准方法读取环境的函数。
示例 3-9. 使用环境变量
package book;
public class EnvVarLambda {
public void handler(Object event) {
String databaseUrl = System.getenv("DATABASE_URL");
if (databaseUrl == null || databaseUrl.isEmpty())
System.out.println("DATABASE_URL is not set");
else
System.out.println("DATABASE_URL is set to: " + databaseUrl);
}
}
更新 template.yaml 文件以指向此新类并执行打包和部署过程。
如果我们运行此函数(使用我们喜欢的任何测试输入),日志输出将包括以下内容:
DATABASE_URL is not set
现在再次更新 template.yaml 文件,使 HelloWorldLambda 部分如下所示(注意你的 YAML 缩进!):
HelloWorldLambda:
Type: AWS::Serverless::Function
Properties:
FunctionName: HelloWorldJava
Runtime: java8
MemorySize: 512
Handler: book.EnvVarLambda::handler
CodeUri: target/lambda.jar
Environment:
Variables:
DATABASE_URL: my-database-url
打包和部署后,如果我们现在测试函数,日志输出将包括这个代替:
DATABASE_URL is set to: my-database-url
我们可以随意更新环境配置。
在使用环境变量时,通常希望存储敏感数据,例如远程服务的访问密钥。有许多安全的 Lambda 使用方式,并在亚马逊的文档中有所解释。
概要
AWS Lambda 的编程模型与您可能习惯的其他模型显着不同。
在本章中,您探讨了编写 Lambda 函数的含义——运行环境是什么,函数如何被调用,以及您可以输入和输出函数的不同方式。
然后,您了解了 Lambda 函数的一些配置方面——超时和内存——以及这些设置的含义。最后,您看到了如何通过环境变量应用自己的应用程序配置。
现在您已经知道如何编程 Lambda 函数,在下一章中,我们将研究 Lambda 操作——打包、部署、安全、监控等内容。
练习
-
花些时间逐步完成本章中的描述——Lambda 与您以前构建和运行 Java 应用程序的方式非常不同。
-
尝试使用
System.err——标准错误流——而不是System.out记录一些内容。日志输出与System.out有何不同?它是否改变了调用函数的结果,无论是异步还是同步? -
故意使用无效输入调用一个函数,以查看前面描述的解析异常:
An error occurred during JSON parsing。您在哪里看到这个错误?它如何影响调用函数的结果,无论是异步还是同步? -
尝试构建自己的 POJO 类型,并使用它们的 JSON 版本调用 Lambda。您更喜欢JavaBean风格,还是公共字段?
-
尝试使用之前描述的
StreamLambda在 Lambda web 控制台中输出整个输入事件与提供的测试事件模板对象之一。 -
尝试将您的一个类转换为使用静态处理器方法,而不是实例方法,以确认它是否同样有效。
第四章:运行 AWS Lambda 函数
本章将介绍一种更高级的构建和打包基于 Java 的 AWS Lambda 函数的方法。我们还将更详细地介绍面向无服务器的 AWS 基础设施即代码工具 SAM 的版本,您在第二章中首次使用过。最后,我们将讨论 Lambda 函数和无服务器应用如何受 AWS 安全模型的影响,以及如何使用 SAM 自动实施无服务器应用的最小特权安全模型。
在继续之前,我们建议您如果尚未这样做,请下载本书的代码示例。
构建和打包
Lambda 平台期望所有用户提供的代码以 ZIP 归档文件的形式提供。根据您使用的运行时和实际业务逻辑,该 ZIP 文件可能包含源代码,或代码和库,或者在 Java 的情况下,已编译的字节码(类文件)和库。
在 Java 生态系统中,我们经常将代码打包成 JAR(Java ARchive)文件,通过 java -jar 命令运行,或者被其他应用程序用作库。事实证明,JAR 文件只是带有一些附加元数据的 ZIP 文件。Lambda 平台不会对 JAR 文件执行任何特殊处理——它将它们视为 ZIP 文件,就像对其他 Lambda 语言运行时一样。
使用像 Maven 这样的工具,我们可以指定代码依赖的其他库,并让 Maven 下载这些库的正确版本(以及它们可能具有的任何传递依赖关系),将我们的代码编译成 Java 类文件,并将所有内容打包到一个单独的 JAR 文件中(通常称为uberjar)。
超级 JAR
尽管在第二章和第三章中使用了超级 jar 方法,但在我们继续之前,值得指出一些它存在的问题。
首先,超级 jar 方法会在目标超级 jar 文件中解压并叠加库。在以下示例中,库 A 包含一个类文件和一个属性文件。库 B 包含一个不同的类文件和一个与库 A 的属性文件同名的属性文件。
$ jar tf LibraryA.jar
book/
book/important.properties
book/A.class
$ jar tf LibraryB.jar
book/
book/important.properties
book/B.class
如果这些 JAR 文件用于创建超级 jar(正如我们在之前的章节中所做的那样),则结果将包含两个类文件和一个属性文件——但是该属性文件来源于哪个源 JAR?
$ jar tf uberjar.jar
book/
book/important.properties # Which properties file is this?
book/A.class
book/B.class
因为 JAR 文件被解压和叠加,所以只有一个属性文件会进入最终的超级 JAR 文件,而且如果不深入了解 Maven 资源转换器的黑暗艺术,很难知道哪一个会获胜。
超级 JAR 方法的第二个主要问题与创建 JAR 文件有关——事实上,从 Maven 构建过程的角度来看,JAR 文件也是可以被 Lambda 运行时使用的 ZIP 文件的一个附属品。从这个 JAR 与 ZIP 的角度来看,有两个特定的问题。其中一个是 JAR 特定的元数据在 Lambda 运行时是无用的(实际上会被忽略)。例如,MANIFEST.MF文件中的Main-Class属性——这是 JAR 文件常见的元数据,在 Lambda 函数的上下文中是毫无意义的。
此外,JAR 文件创建过程本身会在构建过程中引入一定程度的非确定性。例如,工具版本和构建时间戳记录在MANIFEST.MF和pom.properties文件中,这使得无法每次都从相同的源代码可重现地构建相同的 JAR 文件。这种不可重复性会对下游的缓存、部署和安全流程造成严重影响,因此我们希望在可能的情况下避免这种情况。
由于我们实际上并不关心超级 JAR 文件的 JAR 特性,所以考虑根本不使用超级 JAR 过程对我们来说是有意义的。当然,超级 JAR 过程本身并不一定是构建过程中唯一的非确定性源,但我们将稍后处理其余部分。
尽管存在这些缺点,对于简单情况,特别是当 Lambda 函数几乎没有(或没有)第三方依赖时,超级 JAR 过程更简单配置和使用。这在第二章和第三章的示例中就是这种情况,这也是我们在这一点上使用超级 JAR 技术的原因,但对于任何规模较大的 Java 和 Lambda 的实际用途,我们建议采用接下来我们描述的 ZIP 文件方法。
组装 ZIP 文件
因此,在 Java 世界中,我们使用 ZIP 文件作为超级 JAR 文件的替代方案。在这种情况下,归档布局会有所不同,但我们将看到如何通过谨慎的方法避免超级 JAR 的问题,并为 Lambda 平台提供一个可用的工件。我们将讨论如何使用 Maven 来实现这一点,但当然,你可以随意将这种方法翻译成你喜欢的构建工具——结果比过程本身更重要。
为了举一个更有趣的例子,首先我们将在我们的 Maven 构建中为 Lambda 函数添加对 AWS SDK for DynamoDB 的依赖,参见“Lambda Hello World (the Proper Way)”。
向pom.xml文件添加一个dependencies部分:
<dependencies>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-dynamodb</artifactId>
<version>1.11.319</version>
</dependency>
</dependencies>
有了这个依赖项,对于我们简单的 Lambda 函数及其依赖项,期望的 ZIP 文件布局如下:
$ zipinfo -1 target/lambda.zip
META-INF/
book/
book/HelloWorld.class
lib/
lib/aws-java-sdk-core-1.11.319.jar
lib/aws-java-sdk-dynamodb-1.11.319.jar
lib/aws-java-sdk-kms-1.11.319.jar
lib/aws-java-sdk-s3-1.11.319.jar
lib/commons-codec-1.10.jar
lib/commons-logging-1.1.3.jar
lib/httpclient-4.5.5.jar
lib/httpcore-4.4.9.jar
lib/ion-java-1.0.2.jar
lib/jackson-annotations-2.6.0.jar
lib/jackson-core-2.6.7.jar
lib/jackson-databind-2.6.7.1.jar
lib/jackson-dataformat-cbor-2.6.7.jar
lib/jmespath-java-1.11.319.jar
lib/joda-time-2.8.1.jar
除了我们的应用程序代码(book/HelloWorld.class)之外,我们还看到一个包含多个 JAR 文件的lib目录,其中包括 AWS DynamoDB SDK 的一个文件以及每个传递依赖项的文件。
我们可以使用 Maven Assembly 插件构建这个 ZIP 输出。这个插件允许我们向 Maven 构建的特定部分(在这种情况下是package阶段,在这个阶段中,Java 编译过程的结果会与其他资源一起打包成一组输出文件)添加一些特殊的行为。
首先,我们在项目的pom.xml文件中为 Maven Assembly 插件进行了配置,在build部分:
<build>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
<configuration>
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<descriptor>src/assembly/lambda-zip.xml</descriptor>
</descriptors>
<finalName>lambda</finalName>
</configuration>
</plugin>
</plugins>
</build>
这个配置的两个最重要的部分是装配descriptor,它是项目中另一个 XML 文件的路径,以及finalName,它指示插件将我们的输出文件命名为lambda.zip而不是其他名称。稍后我们会看到,选择一个简单的finalName将有助于快速迭代我们的项目,特别是在我们开始使用 Maven 子模块之后。
我们 ZIP 文件的大部分配置实际上位于装配descriptor文件中,这在之前的pom.xml文件中已经引用过。这个assembly配置描述了确切要包含在输出文件中的内容:
<assembly>
<id>lambda-zip</id> 
<formats>
<format>zip</format> 
</formats>
<includeBaseDirectory>false</includeBaseDirectory> 
<dependencySets>
<dependencySet> 
<includes>
<include>${project.groupId}:${project.artifactId}</include>
</includes>
<unpack>true</unpack>
<unpackOptions>
<excludes>
<exclude>META-INF/MANIFEST.MF</exclude>
<exclude>META-INF/maven/**</exclude>
</excludes>
</unpackOptions>
</dependencySet>
<dependencySet> 
<useProjectArtifact>false</useProjectArtifact>
<unpack>false</unpack>
<scope>runtime</scope>
<outputDirectory>lib</outputDirectory> 
</dependencySet>
</dependencySets>
</assembly>
我们给这个装配取了一个唯一的名称,lambda-zip。
输出格式本身将是zip类型。
输出文件将不会有基本目录 — 这意味着当我们解压缩时,ZIP 文件的内容将被解压到当前目录而不是新的子目录中。
第一个dependencySet部分明确包含了我们的应用代码,通过引用项目的groupId和artifactId属性。当我们开始使用 Maven 子模块时,这将需要进行修改。我们的应用代码将会被“解包”。也就是说,它不会被包含在一个 JAR 文件中;而是普通的目录结构和 Java 的.class文件。我们还明确地排除了不必要的META-INF目录。
第二个dependencySet部分处理我们应用的依赖项。我们排除了项目的构件(因为它在第一个dependencySet部分已经处理过了)。我们只包括runtime范围内的依赖项。我们不会解包依赖项,而是将它们作为 JAR 文件保留。
最后,我们不会在输出文件的根目录中包含所有的 JAR 文件,而是将它们全部放入一个lib目录中。
那么,这种复杂的新 Maven 配置如何帮助我们避免与 uberjar 相关的问题?
首先,我们剥离了一些不必要的 META-INF 信息。你会注意到我们有点选择性地做了一些处理 — 有些情况下保留 META-INF 信息(比如“services”)仍然很有价值,因此我们不希望完全摆脱它。
其次,我们已经包含了所有的依赖项,但是作为一个 lib 目录中的独立 JAR 文件。这样可以完全避免文件和路径覆盖问题。每个依赖 JAR 保持自包含。根据 AWS Lambda 的最佳实践文档,这种方法在某种程度上还带来了性能的提升,因为 Lambda 平台解压 ZIP 文件更快,JVM 从 JAR 文件加载类也更快。
可重现的构建
当我们的源代码或依赖关系发生变化时,我们期望部署包(uberjar 或 ZIP 文件)的内容也会随之变化(在运行构建和打包过程后)。然而,当我们的源代码和依赖关系不变时,即使再次执行构建和打包过程,部署包的内容也应保持不变。构建的输出应该是可重复的(例如,确定性的)。这一点很重要,因为下游过程(如部署流水线)通常根据内容的 MD5 哈希是否改变来触发,我们希望避免不必要地触发这些过程。
尽管我们使用 lambda-zip 组件描述符已经去除了自动生成的 MANIFEST.MF 和 pom.properties 文件,但我们仍然没有消除构建过程中所有潜在的不确定性来源。例如,当我们构建我们的应用代码(例如 HelloWorld)时,编译的 Java 类文件的时间戳可能会更改。这些更改后的时间戳会传播到 ZIP 文件中,然后 ZIP 文件内容的哈希值会更改,即使源代码没有变化。
幸运的是,我们的构建过程存在一个简单的 Maven 插件,可以消除这些源中的不确定性。reproducible-build-maven-plugin 可以在构建过程中执行,并且将我们的输出 ZIP 文件完全变为确定性的。它可以配置为 pom.xml 文件中 build 部分的一个 plugin:
<plugin>
<groupId>io.github.zlika</groupId>
<artifactId>reproducible-build-maven-plugin</artifactId>
<version>0.10</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>strip-jar</goal>
</goals>
</execution>
</executions>
</plugin>
现在,当我们多次使用相同的未更改源代码重新构建部署包时,哈希值始终保持不变。您将在下一节中看到这如何影响部署过程。
部署
有许多部署 Lambda 代码的选项。然而,在我们深入探讨之前,值得澄清一下我们所说的 部署 是什么意思。在这种情况下,我们仅仅是指通过 API 或其他服务更新特定 Lambda 函数或一组 Lambda 函数及相关 AWS 资源的代码或配置。我们没有将其定义扩展到包括部署编排(如 AWS CodeDeploy)。
Lambda 代码的部署方法无特定顺序,如下所示:
-
AWS Lambda Web 控制台
-
AWS CloudFormation/Serverless Application Model(SAM)
-
AWS CLI(使用 AWS API)
-
AWS Cloud 开发工具包(CDK)
-
其他由 AWS 开发的框架,如 Amplify 和 Chalice
-
针对主要基于 CloudFormation 构建的无服务器组件的第三方框架,例如 Serverless Framework
-
针对主要基于 AWS API 构建的无服务器组件的第三方工具和框架,例如 Claudia.js 和
lambda-maven-plugin(来自 Maven) -
像 Ansible 或 Terraform 这样的通用第三方基础设施工具
在本书中,我们将讨论前两者(事实上,在第二章和第三章中我们已经涉及了 AWS Lambda Web 控制台和 SAM)。我们还使用 AWS CLI,尽管不是作为部署工具。通过对这些方法有坚实的了解,您应该能够评估其他选项,并决定其中之一是否更适合您的环境和用例。
基础设施即代码
当我们通过 Web 控制台或 CLI 与 AWS 进行交互时,我们是手动创建、更新和销毁基础设施。例如,如果我们使用 AWS Web 控制台创建一个 Lambda 函数,下次我们想要使用相同参数创建 Lambda 函数时,我们仍然必须通过 Web 控制台执行相同的手动操作。这一特性也适用于 CLI。
对于初步开发和实验,这是一个合理的方法。但是,当我们的项目开始积累动力时,手动管理基础设施的方法将成为一种障碍。解决这个问题的一个经过良好验证的方法称为基础设施即代码。
我们不必通过 Web 控制台或 CLI 手动与 AWS 交互,而是可以在 JSON 或 YAML 文件中声明性地指定我们期望的基础设施,并将该文件提交给 AWS 的基础设施即代码服务:CloudFormation。CloudFormation 服务接受我们的输入文件,并代表我们对 AWS 基础设施进行必要的更改,考虑资源依赖关系、先前部署的应用程序版本的当前状态以及各种 AWS 服务的特殊要求和特性。从 CloudFormation 模板文件创建的一组 AWS 资源称为堆栈。
CloudFormation 是 AWS 的专有基础设施即代码服务,但这并不是该领域的唯一选择。与 AWS 兼容的其他热门选择包括 Terraform、Ansible 和 Chef。每个服务都有自己的配置语言和模式,但都实现了基本相同的结果——从配置文件中提供云基础设施。
使用配置文件(而不是在控制台上点点点)的一个关键好处是,这些文件代表了我们的应用基础设施,可以与我们的应用源代码一起进行版本控制。我们可以使用与应用的其他部分相同的版本控制工具,查看我们基础设施的完整变更时间线。此外,我们可以将这些配置文件纳入我们的持续部署流水线中,因此当我们对应用基础设施进行更改时,这些更改可以使用行业标准工具安全地部署,与我们的应用代码一起。
CloudFormation 和 Serverless 应用程序模型
尽管基础设施即代码方法有明显的好处,但 CloudFormation 本身以冗长、笨重和不灵活而闻名。即使是最简单的应用架构的配置文件也很容易超过数百或数千行的 JSON 或 YAML。当处理一个这样大小的现有 CloudFormation 堆栈时,不可避免地会有一种诱惑,即退回到使用 AWS Web 控制台或 CLI。
幸运的是,作为 AWS 无服务器开发人员,我们有幸能够使用称为 Serverless 应用程序模型(SAM)的 CloudFormation 的不同“口味”,我们在第二章和第三章中使用过。这本质上是 CloudFormation 的一个超集,允许我们使用一些特殊的资源类型和快捷方式来表示常见的无服务器组件和应用架构。它还包括一些特殊的 CLI 命令,以简化开发、测试和部署。
这是我们首次在“创建 Lambda 函数”中使用的 SAM 模板,已更新为使用我们的新 ZIP 部署包(请注意,CodeUri后缀已从.jar更改为.zip):
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Chapter 4
Resources:
HelloWorldLambda:
Type: AWS::Serverless::Function
Properties:
Runtime: java8
MemorySize: 512
Handler: book.HelloWorld::handler
CodeUri: target/lambda.zip
我们可以使用第二章中学到的相同 SAM 命令部署新的基于 ZIP 的 Lambda 函数:
$ sam deploy \
--s3-bucket $CF_BUCKET \
--stack-name chapter4-sam \
--capabilities CAPABILITY_IAM
sam deploy首先将我们的部署包上传到 S3,但仅在该包的内容发生更改时才执行此操作。在本章的早些时候,我们花了一些时间设置可重现的构建,以便像此上传过程这样的操作不必在实际上没有发生更改时执行。
在幕后,sam deploy还创建了我们模板的修改版本(也存储在 S3 中),以引用新上传的 S3 位置的工件,而不是本地位置。这一步是必要的,因为 CloudFormation 要求模板中引用的任何工件在部署时都可在 S3 中使用。
小贴士
存储在 S3 中的s3 deploy文件应仅视为部署过程中的临时版本,而不是要保留的应用程序工件。因此,如果您的 SAM S3 存储桶没有用于其他用途,我们建议您在其中设置“生命周期策略”,以便在一段时间后自动删除部署工件——通常我们将其设置为一周。
在上传步骤之后,如果在此 AWS 账户和区域中尚不存在具有提供名称的 CloudFormation 堆栈,则sam deploy命令将创建一个新的 CloudFormation 堆栈。如果堆栈已经存在,sam deploy命令将创建一个 CloudFormation 变更集,其中列出了在执行操作之前将创建、更新或删除的资源。然后,sam deploy命令将应用变更集以更新 CloudFormation 堆栈。
列出堆栈资源时,我们可以看到 CloudFormation 不仅创建了我们的 Lambda 函数,还创建了支持的 IAM 角色和策略(稍后我们将进一步探讨),而无需显式指定它们:
$ aws cloudformation list-stack-resources --stack-name chapter4-sam
{
"StackResourceSummaries": [
{
"LogicalResourceId": "HelloWorldLambda",
"PhysicalResourceId": "chapter4-sam-HelloWorldLambda-1HP15K6524D2E",
"ResourceType": "AWS::Lambda::Function",
"LastUpdatedTimestamp": "2019-07-26T19:16:34.424Z",
"ResourceStatus": "CREATE_COMPLETE",
"DriftInformation": {
"StackResourceDriftStatus": "NOT_CHECKED"
}
},
{
"LogicalResourceId": "HelloWorldLambdaRole",
"PhysicalResourceId":
"chapter4-sam-HelloWorldLambdaRole-1KV86CI9RCXY0",
"ResourceType": "AWS::IAM::Role",
"LastUpdatedTimestamp": "2019-07-26T19:16:30.287Z",
"ResourceStatus": "CREATE_COMPLETE",
"DriftInformation": {
"StackResourceDriftStatus": "NOT_CHECKED"
}
}
]
}
除了 Lambda 函数外,SAM 还包括用于 DynamoDB 表(AWS::Serverless::SimpleTable)和 API 网关(AWS::Serverless::Api)的资源类型。这些资源类型专注于流行的使用案例,可能无法适用于所有应用程序架构。然而,由于 SAM 是 CloudFormation 的超集,我们可以在 SAM 模板中使用普通的 CloudFormation 资源类型。这意味着我们可以在架构中混合和匹配无服务器和“普通”AWS 组件,从而获得两种方法的好处,以及 SAM 的sam deploy命令的幂等 CLI 语义。您将在第五章中看到将 SAM 和 CloudFormation 资源结合在一个模板中的示例。
安全性
安全问题贯穿 AWS 的各个方面。正如您在第二章中学到的那样,我们必须从一开始就处理 AWS 的安全层,称为身份和访问管理(IAM)。然而,我们不打算简单地以最广泛、最不安全的 IAM 权限集运行所有内容来概述细节,而是在本节中稍微深入讲解 Lambda 平台如何由 IAM 控制,以及这如何影响我们的函数与其他 AWS 资源的交互,以及 SAM 如何使构建安全应用程序变得更加简单。
最小权限原则
与传统的单体应用程序不同,在无服务器应用程序中,可能会有数百个独立的 AWS 组件,每个组件具有不同的行为和访问不同的信息的能力。如果我们简单地应用最广泛的安全权限,则每个组件都可以访问 AWS 账户中的每个其他组件和信息。我们在安全策略中留下的每一个漏洞都是信息泄露、丢失、修改或应用程序行为改变的机会。而且,如果一个组件被入侵,整个 AWS 账户(以及其中部署的任何其他应用程序)都面临风险。
我们可以通过将“最小权限”原则应用于我们的安全模型来解决这种风险。简而言之,该原则指出每个应用程序,实际上是每个应用程序的组成部分都应该具有执行其功能所需的最少访问权限。例如,让我们考虑一个从 DynamoDB 表中读取数据的 Lambda 函数。最广泛的权限将允许该 Lambda 函数读取、写入或以其他方式与 AWS 账户中的每个其他组件和信息进行交互。它可以从 S3 存储桶中读取数据,创建新的 Lambda 函数,甚至启动 EC2 实例。如果 Lambda 代码存在错误或漏洞(例如,在解析用户输入时),其行为可能会被改变,并且不受其 IAM 角色的限制。
将最小权限原则应用于此特定 Lambda 函数,将会导致一个 IAM 角色,该角色只允许函数访问 DynamoDB 服务。进一步地,我们可能只允许该函数从 DynamoDB 中读取数据,并移除其写入数据或创建或删除表格的能力。在这种情况下,我们甚至可以进一步限制函数只能基于执行该函数的用户读取单个 DynamoDB 表中的哪些条目。
将最小权限原则应用到我们的 Lambda 函数上后,我们现在将其访问权限限制为仅能执行其工作所需的特定资源。如果 Lambda 函数在某种方式上被攻击或者被入侵,其安全策略仍会限制它仅能读取单个 DynamoDB 表中的特定条目。然而,最小权限原则不仅适用于防止妥协。它也是限制应用程序代码中错误“爆炸半径”的有效手段。
假设我们的 Lambda 函数存在一个 bug,例如使用错误的值来删除数据。在一个开放的安全模型中,这个 bug 可能导致 Lambda 函数删除错误用户的数据!然而,通过为我们的 Lambda 函数应用最小权限原则,我们已经限制了 bug 的“爆炸半径”,因此这个特定问题可能会导致它仅仅无作为或抛出错误。
身份与访问管理
对于成功在 AWS 上构建任何类型的应用程序来说,IAM 的工作知识至关重要。正如我们在前一节讨论的那样,在构建无服务器应用程序时,有效地应用最小权限原则更为重要。IAM 是一个复杂且多方面的服务,在这里我们不可能详尽覆盖所有内容。相反,在本节中,我们只是从构建无服务器应用程序的角度深入探讨 IAM。IAM 在无服务器应用程序中最常见和频繁地发挥作用的地方是执行角色、附加到这些角色的策略,以及附加到特定 AWS 资源的策略。
角色与策略
IAM 角色是可以被 AWS 组件(如 Lambda 函数)扮演的身份。与 IAM 用户不同,角色可以被任何需要它的人(或事物)扮演,并且角色没有长期访问凭证。基于此,我们可以定义 IAM 角色为一个可扮演的身份,并附加一组权限。
“可扮演的身份”这个短语可能让人觉得任何人或任何事物都可以扮演 IAM 角色。如果是这样的话,使用角色就不会真正提供任何好处,因为对于扮演角色或任何给定用户或组件可以承担的操作不会有任何限制。幸运的是,IAM 角色并不是任何人都可以扮演的。在构建角色时,我们必须明确指定谁(或什么)可以扮演该角色。例如,如果我们正在为 Lambda 函数构建一个角色,我们必须明确授予 Lambda 服务(在这种情况下是数据平面)扮演该角色的权限,通过指定以下“信任关系”:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
此声明指定了一个效果(Allow),适用于一个操作(sts:AssumeRole)。然而,更重要的是,它指定了一个主体,即被允许扮演该角色的身份。在这种情况下,我们允许 Lambda 服务的数据平面(lambda.amazonaws.com)扮演这个角色。如果我们尝试将此角色与不同的服务,如 EC2 或 ECS,一起使用,除非我们更改主体,否则将无法正常工作。
现在我们已经确定了谁可以承担角色,我们需要添加权限。IAM 角色本身不具备访问资源或执行操作的任何权限。此外,IAM 的默认行为是拒绝权限,除非在策略中显式允许。这些权限在策略中使用以下结构说明:
-
一个效果(如
Allow或Deny)。 -
一组操作,通常是命名空间到特定的 AWS 服务(比如
logs:PutLogEvents)。 -
一组资源,通常是定义特定 AWS 组件的 Amazon 资源名称(ARN)。不同的服务支持不同级别的资源特定性。例如,DynamoDB 策略可以应用到表级别。
这里是一个允许一组操作针对“logs”服务(即 CloudWatch Logs)的策略示例,并且不限制这些操作对任何特定的“logs”资源:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "*"
}
]
}
我们之前确定了谁可以承担角色(Lambda 服务的数据平面,由主体标识符 lambda.amazonaws.com 指定),以及角色具有的权限。然而,这个角色本身直到附加到 Lambda 函数时才会被使用,我们需要显式配置。也就是说,我们需要告诉 Lambda 服务在执行特定 Lambda 函数时使用这个角色。
Lambda 资源策略。
就像安全和 IAM 的世界还不够复杂一样,AWS 偶尔也使用应用于资源(而不是身份)的 IAM 策略来控制操作和访问。资源策略与基于身份的 IAM 策略相比反转了控制:资源策略说明了其他主体可以对所涉及的资源做什么。特别是,这对于允许不同账户中的主体访问某些资源(如 Lambda 函数或 S3 存储桶)非常有用。
Lambda 函数调用资源策略由一系列语句组成,每个语句指定了一个主体、一组操作和一组资源。这些策略由 Lambda 数据平面使用,用于确定是否允许调用者(例如主体)成功调用函数。这里是一个示例 Lambda 资源策略(也称为函数策略),允许 API 网关服务调用特定函数:
{
"Version": "2012-10-17",
"Id": "default",
"Statement": [
{
"Sid": "Stmt001",
"Effect": "Allow",
"Principal": {
"Service": "apigateway.amazonaws.com"
},
"Action": "lambda:invokeFunction",
"Resource":
"arn:aws:lambda:us-east-1:555555555555:function:MyLambda",
"Condition": {
"ArnLike": {
"AWS:SourceArn": "arn:aws:execute-api:us-east-1:
555555555555:xxx/*/GET/locations"
}
}
}
]
}
在这个策略中,我们还添加了一个条件,更具体地限制了允许的操作来源,只允许具有 ID “xxx” 的 API 网关部署包含 “/GET/locations” 路径。条件是服务特定的,取决于调用者提供的信息。
让我们通过 API 网关调用 Lambda 函数的场景来详细讨论,使用 图 4-1。

图 4-1. Lambda 和 IAM 安全概述。
-
调用者是否有权限调用 API?对于这种情况,我们假设答案是肯定的。有关更多信息,请参阅 API Gateway 文档。
-
API Gateway API 正试图调用 Lambda 函数。Lambda 服务允许这样吗?这由 Lambda 函数调用资源策略控制。
-
当 Lambda 执行时,函数代码应具有什么权限?这由 Lambda 执行角色控制,并且该角色通过与 Lambda 服务的信任关系来假定。
-
Lambda 代码正在尝试将项目放入 DynamoDB 表中。它可以做到吗?这由一个权限控制,它来自附加到 Lambda 执行角色的 IAM 策略。
-
DynamoDB 不使用资源策略,因此任何人(包括 Lambda 函数)的调用都是允许的,只要它们的角色(例如 Lambda 执行角色)允许。
SAM IAM
不幸的是,IAM 的复杂性使其在快速原型设计工作流程中的有效使用有些不协调。将无服务器应用架构加入其中,难怪如此多的 Lambda 执行角色完全开放策略,允许对 AWS 账户中的每个资源进行各种形式的访问。尽管我们很容易认同最小权限原则提供了宝贵的好处,但面对使用 IAM 为数十甚至数百个 AWS 资源实施它的任务,许多本来很有责任心的工程师选择为简单起见放弃安全性。
自动创建的执行角色和资源策略
幸运的是,Serverless Application Model 通过几种不同的方式解决了这个问题。在最简单的情况下,它将根据 SAM 基础设施模板中配置的各种函数和事件源自动创建适当的 Lambda 执行角色和函数策略。这样一来,能够执行 Lambda 函数并允许其他 AWS 服务调用它们的权限问题就能很好地解决。
例如,如果您配置了一个没有触发器的单个 Lambda 函数,SAM 将自动生成一个 Lambda 执行角色,使该函数能够写入 CloudWatch 日志。如果然后将 API Gateway 触发器添加到该 Lambda 函数中,SAM 将生成一个 Lambda 函数调用资源策略,允许 API Gateway 平台调用 Lambda 函数。这将在下一章中为我们的生活带来一些便利!
常见的策略模板
当然,如果您的 Lambda 函数需要在代码中与其他 AWS 服务交互(例如向 DynamoDB 表写入数据),它可能需要额外的权限。对于这些情况,SAM 提供了一些常见的 IAM 策略模板,允许我们简明地指定权限和资源。这些模板在 SAM 部署过程中会扩展,并成为完全指定的 IAM 策略语句。在这里,我们在 SAM 模板中添加了一个 DynamoDB 表。我们使用了一个 SAM 策略模板来允许我们的 Lambda 函数对该 DynamoDB 表执行创建、读取、更新和删除操作(也称为 CRUD 操作)。
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Chapter 4
Resources:
HelloWorldLambda:
Type: AWS::Serverless::Function
Properties:
Runtime: java8
MemorySize: 512
Handler: book.HelloWorld::handler
CodeUri: target/lambda.zip
Policies:
— DynamoDBCrudPolicy:
TableName: !Ref HelloWorldTable 
HelloWorldTable:
Type: AWS::Serverless::SimpleTable
在这里,我们使用了 CloudFormation 内部函数 Ref,它允许我们使用资源的逻辑 ID(在本例中为 HelloWorldTable)作为资源的物理 ID 的占位符(例如 stack-name-HelloWorldTable-ABC123DEF)。CloudFormation 服务将在创建或更新堆栈时解析逻辑 ID 为物理 ID。
摘要
在本章中,我们介绍了以可复制、确定性方式构建和打包 Lambda 代码及其依赖项。我们开始使用 AWS 的 SAM 来以 YAML 代码指定基础设施(例如 Lambda 函数,稍后是 DynamoDB 表)——我们将在第五章中进一步探讨这一点。然后,我们探讨了影响 Lambda 函数的两种不同 IAM 构造:执行角色和资源策略。最后,使用 SAM 而不是原始 CloudFormation 意味着我们不必添加太多额外的 YAML 代码来将最小权限原则应用于 Lambda 函数的 IAM 角色和策略。
现在,我们几乎已经准备好使用 Lambda 和相关工具创建完整的应用程序的基本构建模块。在第五章中,我们将展示如何将 Lambda 函数与事件源绑定,然后构建两个示例应用程序。
练习
-
在本章中,通过将
Handler属性设置为book.HelloWorld::foo来故意配置 Lambda 函数。当函数部署时会发生什么?当您调用函数时会发生什么? -
阅读IAM 参考指南以了解哪些 AWS 服务(和操作)可以具有细粒度的 IAM 权限。
-
如果您想要额外的挑战,在template.yaml文件中将
AWS::Serverless::Function替换为AWS::Lambda::Function。为了 CloudFormation 能够部署您的函数,您还需要进行哪些其他更改?如果遇到困难,您可以通过 CloudFormation Web 控制台查看原始堆栈的后转换模板。
第五章:构建无服务器应用程序
到目前为止,我们已经大量讨论了 Lambda 函数——如何编写程序,如何打包和部署它们,如何处理输入和输出等等。然而,Lambda 的一个重要方面,到目前为止我们还没有涉及太多,那就是 Lambda 函数很少直接从我们在不同系统中编写的代码中被调用。相反,对于 Lambda 的绝大多数用法,我们会配置一个事件源或触发器,它是另一个 AWS 服务,然后让 AWS 代替我们调用我们的 Lambda 函数。
我们在“一个 Lambda 应用程序是什么样子?”中看了一些示例:
-
为了实现 HTTP API,我们将 AWS API Gateway 配置为事件源。
-
为了实现文件处理,我们将 S3 配置为事件源。
有许多不同的 AWS 服务直接与 Lambda 集成,甚至还有更多间接集成的服务。这意味着我们可以构建使用 Lambda 作为计算平台的无服务器应用程序,可以执行广泛范围的任务。
在本章中,我们将介绍如何将事件源与 Lambda 绑定,然后探讨如何使用这种技术构建特定类型的应用程序。在这个过程中,你将学到更多关于如何从前一章的知识构建、打包和部署基于 Lambda 的应用程序的架构知识。
如果你还没有这样做,你可能希望在尝试本章中的任何示例之前下载示例源代码。
Lambda 事件源
正如你刚刚学到的那样,Lambda 的典型使用模式是将函数绑定到事件源。在本节中,我们描述了构建 Lambda 函数以与特定上游服务集成时要遵循的工作流程。
编写代码以处理事件源的输入和输出
当编写 Lambda 函数以响应特定事件源时,你通常首先要做的事情是了解 Lambda 函数将接收到的事件的格式。
我们已经使用过的 SAM CLI 工具有一个有趣的命令可以帮助我们进行这个练习——sam local generate-event。如果你运行这个命令,sam会列出它可以为其生成存根事件的所有服务,然后你可以检查并使用这些事件来驱动你的代码。例如,sam local generate-event的部分输出如下所示:
Commands:
alexa-skills-kit
alexa-smart-home
apigateway
batch
cloudformation
cloudfront
cloudwatch
codecommit
codepipeline
假设我们有兴趣构建一个无服务器的 HTTP API。在这种情况下,我们使用 AWS API Gateway 作为我们的上游事件源。如果我们运行sam local generate-event apigateway,输出将包括以下内容:
Commands:
authorizer Generates an Amazon API Gateway Authorizer Event
aws-proxy Generates an Amazon API Gateway AWS Proxy Event
原来 API Gateway 可以以多种方式与 Lambda 集成。我们通常从列表中想要的是 aws-proxy 事件,其中 API Gateway 充当 Lambda 函数前面的代理服务器,所以让我们试试这个。
$ sam local generate-event apigateway aws-proxy
{
"body": "eyJ0ZXN0IjoiYm9keSJ9",
"resource": "/{proxy+}",
"path": "/path/to/resource",
"httpMethod": "POST",
"isBase64Encoded": true,
"queryStringParameters": {
"foo": "bar"
},
....
这个 JSON 对象是 Lambda 函数从 API Gateway 接收到的典型事件的完整示例。换句话说,当您设置 API Gateway 作为 Lambda 函数的触发器时,传递给 Lambda 函数的事件参数具有此结构。
这个示例事件并不一定帮助您理解与 API Gateway 集成的语义,但它确实给出了您的 Lambda 函数接收到的事件的结构,从而为编写代码提供了坚实的起点。您可以将此 JSON 对象作为灵感,或者更进一步,实际将其嵌入到一个测试中——详见第六章!
因为您现在知道了您的 Lambda 函数接收到的数据格式,所以可以创建一个处理此格式的处理程序签名。还记得“POJOs 和生态系统类型”吗?现在正要发挥作用了。
设置处理程序的一种选项是创建自己的 POJO 输入类型,以适合传入事件的结构,但仅创建您关心的属性字段。例如,如果您只关心 aws-proxy 事件的 path 和 queryStringParameters 属性,则可以创建如下的 POJO:
package book.api;
import java.util.Map;
public class APIGatewayEvent {
public String path;
public Map<String, String> queryStringParameters;
}
第二个选项是使用 AWS 专门为此目的提供的类型库——“AWS Lambda Java Events Library”。如果使用此库,请参阅文档,并查找 Maven Central 中的最新版本。
如果您想要使用此库来处理 aws-proxy 事件,那么您需要首先在 Maven 依赖项中包含一个库。如果尚未包含,请将 <dependencies> 部分添加到您的 pom.xml 文件的根部。否则,请将此 <dependency> 子部分添加到现有的 <dependencies> 部分中:
<dependencies>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-events</artifactId>
<version>2.2.6</version>
</dependency>
</dependencies>
通过进行这些更新,我们可以使用APIGatewayProxyRequestEvent 类作为我们的输入 POJO。
现在我们有一个代表我们的 Lambda 函数将接收的事件的类。接下来,让我们看看如何为将成为函数响应的事件执行相同的活动。正如您从“输入、输出”中所知,这里再次涉及到 POJOs。
SAM CLI 这次帮不上忙,因此您可以查阅AWS 文档来查找有效的输出事件结构并生成自己的输出 POJO 类型,或者您可以再次使用 AWS Lambda Java Events Library。这次,如果要响应 API Gateway 代理事件,请使用 APIGatewayProxyResponseEvent 类(参见“API Gateway Proxy Events”)。
假设您想要构建自己的 POJO 类,并且只想在 HTTP 响应中返回一个 HTTP 状态码和 body。在这种情况下,您的 POJO 可能如下所示:
package book.api;
public class APIGatewayResponse {
public final int statusCode;
public final String body;
public APIGatewayResponse(int statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
}
是否使用 AWS 提供的 POJO 类型或自行编码并没有一个特别明确的选择。目前,出于几个原因,我们默认使用 AWS 库:
-
虽然过去这个库在 Lambda 平台上实际可用的内容上落后很多,但现在 AWS 在保持更新方面做得相当不错。
-
类似地,这个库过去引入了大量的 SDK 依赖项,因此会显著增加您的 artifact 大小。现在这方面得到了很大改进,基础 JAR 文件(对包括 API Gateway 和 SNS 在内的很多事件源都足够)不到 100KB。
尽管如此,编写自己的 POJOs 是一个完全合理的方法——这意味着您部署的 artifact 将会更小,减少了代码的库依赖数量(包括传递依赖),并且增加了代码的简洁性,有助于以后的可维护性。在本章中,我们给出了这两种方法的示例。
一旦你编写好基本的 Lambda 函数,就该进行下一步了——配置事件源以便部署。
配置 Lambda 事件源
就像有多种部署和配置 Lambda 函数的方式(还记得来自“部署”的长列表吗?),配置事件源也有多种方式。然而,由于本书中我们使用 SAM 来部署代码,因此尽可能多地使用 SAM 来配置我们的事件源是有道理的。
让我们继续我们的 API Gateway 示例。在 SAM 中定义 API Gateway 事件源的最简单方法是在您的 template.yaml 中更新 Lambda 函数定义如下:
HelloAPIWorldLambda:
Type: AWS::Serverless::Function
Properties:
Runtime: java8
MemorySize: 512
Handler: book.HelloWorldAPI::handler
CodeUri: target/lambda.zip
Events:
MyApi:
Type: Api
Properties:
Path: /foo
Method: get
看看 Events 键——那里就是魔法发生的地方。在这种情况下,SAM 所做的事情包括创建一堆资源,包括一个全局可访问的 API 端点(我们在本章后面会详细讨论),但它还配置了 API Gateway 来触发您的 Lambda 函数。
SAM 可以直接配置许多不同的事件源。然而,如果它对您的需求不足够,您总是可以降低到更低级别的 CloudFormation 资源。
理解不同的事件源语义
在第一章中我们描述了 Lambda 函数可以以两种方式被调用——同步和异步,并展示了这些不同的调用类型在不同场景中的应用。
不出所料,这意味着至少有两种不同类型的事件源——像 API Gateway 这样的,同步调用 Lambda 函数并等待回复(“同步事件源”),以及异步调用 Lambda 函数并且不等待回复的其他事件源(“异步事件源”)。
在前一组的情况下,您的 Lambda 函数需要返回适当类型的响应,就像我们之前在 API 网关中所做的那样。对于后一组,您的处理函数可以具有 void 返回类型,表明您不返回响应。
实际上,可以方便地说,所有事件源都适合这两种类型中的一种,但不幸的是,有一个小复杂性 —— 还有第三种类型,即流/队列事件源,例如:
-
Kinesis 数据流
-
DynamoDB Streams
-
简单队列服务(SQS)
在这三种情况下,我们都配置 Lambda 平台 以从上游服务中轮询事件,与其他所有事件源不同,其中我们直接从上游服务配置 Lambda 触发器以推送事件到 Lambda。
对于流/队列源的这种反向操作对 Lambda 处理程序编程模型没有影响 —— 方法签名完全相同。例如,以下是 SQS 的 Lambda 处理程序事件格式(请注意 Records 数组):
{
"Records": [
{
"messageId": "19dd0b57-b21e-4ac1-bd88-01bbb068cb78",
"receiptHandle": "MessageReceiptHandle",
"body": "Hello from SQS!",
"attributes": {
"ApproximateReceiveCount": "1",
"SentTimestamp": "1523232000000",
"SenderId": "123456789012",
"ApproximateFirstReceiveTimestamp": "1523232000001"
},
"messageAttributes": {},
"md5OfBody": "7b270e59b47ff90a553787216d55d91d",
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:MyQueue",
"awsRegion": "us-east-1"
}
]
}
表 5-1. Lambda 事件源类型
| 事件源类型 | 事件源 |
|---|---|
| 同步 |
API 网关,Amazon CloudFront(Lambda@Edge),弹性负载均衡(应用程序负载均衡器),Cognito,Lex,Alexa,Kinesis 数据 Firehose
|
| 异步 |
|---|
S3,SNS,Amazon SES,CloudFormation,CloudWatch 日志,CloudWatch 事件,CodeCommit,Config
|
| 流/队列 |
|---|
Kinesis 数据流,DynamoDB Streams,简单队列服务(SQS)
|
流/队列事件源在错误处理方面也有一些不同(参见 “错误处理”)。但目前,我们已经了解了足够的关于事件源的信息,可以探索一些详细的示例。让我们深入研究我们的无服务器 HTTP API。
示例:构建无服务器 API
在第 1 章中,我们简要讨论了 Lambda 如何作为 Web API 的一部分使用。在本节中,我们将展示这是如何构建的。
行为
此应用程序允许客户端向 API 上传天气数据,然后允许其他客户端检索该数据(图 5-2)。

图 5-2. 使用 AWS Lambda 的 Web API
写入路径包括向端点 /events 发出 HTTP POST 请求,并在请求的 body 中包含以下 JSON 数据结构:
{
"locationName":"Brooklyn, NY",
"temperature":91,
"timestamp":1564428897,
"latitude": 40.70,
"longitude": -73.99
}
读取路径包括向端点 /locations 发出 GET 请求,该端点返回我们已保存数据的每个位置的最新天气数据。此数据的格式是一个 JSON 对象列表,格式与写入路径相同。可以添加可选的查询字符串参数 limit 到 GET 请求中,以指定返回的最大记录数。
架构
我们使用 AWS API Gateway 来实现此应用程序的所有 HTTP 元素。 读路径和写路径使用两个不同的 Lambda 函数实现。 这些由 API Gateway 触发。 我们将数据存储在 DynamoDB 表中。 DynamoDB 是亚马逊的“NoSQL”数据库服务。 对于许多无服务器系统来说,它是一个很好的选择,因为:
-
它提供与 Lambda 相同的“轻量级操作”模型——我们配置我们想要的表结构,亚马逊处理所有运行时考虑因素。
-
它可以在全“按需”缩放模式下使用,根据实际使用情况进行上下调整,就像 Lambda 一样。
因为 DynamoDB 是一种 NoSQL 技术,它并不适合所有应用程序,但它绝对是快速入门的一种方式。
在我们这个示例中的 DynamoDB 表中,我们声明了一个名为locationName的主键,并使用“按需”容量控制。
我们将所有这些资源——一个 API 网关定义、两个 Lambda 函数和一个 DynamoDB 表——视为一个统一的“无服务器应用程序”。 我们将代码、配置和基础设施定义作为一个整体部署单元。 尽管这不是一个新的想法,但将数据库封装在服务中是微服务架构的一个相当普遍的想法。
除了添加一个有用的分组外,使用无服务器应用程序的想法还有助于解决一些人在考虑他们在组织中可能拥有的 Lambda 函数数量时的担忧——已经足够困难组织成百上千个微服务,但一家公司可能最终会拥有数千或数万个 Lambda 函数。 我们如何管理所有这些函数? 通过在无服务器应用程序内命名空间化函数,并通过标记或定位这些应用程序的部署版本来按环境/阶段进行分类,我们可以开始为混乱带来一些秩序。 无服务器应用程序的这个概念不仅仅是设计时的考虑——AWS 实际上直接支持它(参见“部署”)。
Lambda Code
注意
在本书的这一点上,我们不讨论错误检查或测试——我们为了例子的清晰性已经做过了。 别担心——这两个重要的主题稍后会在本书中讨论!
我们之前提到,当使用 Lambda 实现应用程序时,你需要做的第一件事情之一就是理解 Lambda 函数将接收的事件格式以及 Lambda 函数应返回的响应格式(如果有)。
我们之前已经检查了 API Gateway 的代理类型。 在这个天气 API 中,我们编写自己的类来进行 POJO 序列化和反序列化,而不是使用 AWS 提供的库。 例子 5-1 和 5-2 足以满足我们对两个 Lambda 函数的需求。
示例 5-1. 用于反序列化 API 请求
package book.api;
import java.util.HashMap;
import java.util.Map;
public class ApiGatewayRequest {
public String body;
public Map<String, String> queryStringParameters = new HashMap<>();
}
示例 5-2. 用于序列化 API 响应
package book.api;
public class ApiGatewayResponse {
public Integer statusCode;
public String body;
public ApiGatewayResponse(Integer statusCode, String body) {
this.statusCode = statusCode;
this.body = body;
}
}
总体上,我们并不推荐一般情况下采用这种方法——请参见前文有关是否使用 AWS POJO 类型库的讨论“编写用于事件源输入和输出的代码”——但我们希望展示两种方法的示例。本章的第二个示例使用了 AWS 库。当您使用 Lambda 构建自己的 HTTP API 的生产实现时,可以将com.amazonaws.services.lambda.runtime.events包中的APIGatewayProxyRequestEvent和APIGatewayProxyResponseEvent类替换为这些 DIY 类。
现在让我们详细查看实现此应用程序所需的代码。我们从写入路径开始。
使用 WeatherEventLambda 上传天气数据
我们知道,处理上传数据的代码大致的骨架如下:
package book.api;
public class WeatherEventLambda {
public ApiGatewayResponse handler(ApiGatewayRequest request) {
// process request
// send response
return new ApiGatewayResponse(200, ..).;
}
}
我们首先需要捕获事件的输入。Lambda 反序列化已经为我们开始了这项工作,而传递给我们函数的ApiGatewayRequest对象的结构如下:
{
"body": "{\"locationName\":\"Brooklyn, NY\", \"temperature\":91,...",
"queryStringParameters": {}
}
在这个 Lambda 函数中,我们并不关心queryStringParameters字段——那将在查询函数中使用——因此我们现在可以忽略它。
那个body字段有点棘手——客户端上传的 JSON 对象仍然序列化为字符串值。这是因为 Lambda 仅对 API Gateway 创建的事件进行了反序列化;它也不能反序列化天气数据的“下一层级”。
不管怎样,我们可以对body进行自己的反序列化,其中一种方法是使用Jackson 库。
一旦我们反序列化了天气数据,我们就可以将其保存到数据库中。示例 5-3 展示了 Lambda 函数的完整代码——您可能还想打开chapter5-api目录中的示例代码。
示例 5-3. WeatherEventLambda 处理程序类
package book.api;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
import com.amazonaws.services.dynamodbv2.document.DynamoDB;
import com.amazonaws.services.dynamodbv2.document.Item;
import com.amazonaws.services.dynamodbv2.document.Table;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
public class WeatherEventLambda {
private final ObjectMapper objectMapper =
new ObjectMapper()
.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false);
private final DynamoDB dynamoDB = new DynamoDB(
AmazonDynamoDBClientBuilder.defaultClient());
private final String tableName = System.getenv("LOCATIONS_TABLE");
public ApiGatewayResponse handler(ApiGatewayRequest request)
throws IOException {
final WeatherEvent weatherEvent = objectMapper.readValue(
request.body,
WeatherEvent.class);
final Table table = dynamoDB.getTable(tableName);
final Item item = new Item()
.withPrimaryKey("locationName", weatherEvent.locationName)
.withDouble("temperature", weatherEvent.temperature)
.withLong("timestamp", weatherEvent.timestamp)
.withDouble("longitude", weatherEvent.longitude)
.withDouble("latitude", weatherEvent.latitude);
table.putItem(item);
return new ApiGatewayResponse(200, weatherEvent.locationName);
}
}
首先,您可以看到我们在处理程序函数外创建了一些实例变量。我们在“扩展”中讨论了为什么要这样做,但总结一下,Lambda 平台通常会多次使用同一个 Lambda 函数实例(虽然不会同时),因此我们可以通过仅为 Lambda 函数实例的生命周期创建某些东西来优化性能。
第一个实例变量是 Jackson 的ObjectMapper,第二个是 DynamoDB SDK。第三个也是最后一个实例变量是我们想要使用的 DynamoDB 中的表名。其精确值来自我们的基础设施模板,因此我们使用环境变量来配置我们的 Lambda 函数,就像我们在“环境变量”中讨论的那样。
类的剩余部分是我们的 Lambda 处理函数。首先,您可以看到签名,与我们正在处理的事件源所期望的类型相符。不过,这里有一个小的额外声明,即我们的 Lambda 处理程序声明可能会抛出异常——这是完全有效的,我们在 “错误处理” 中进一步讨论错误处理。
处理程序的第一行对原始 HTTP 请求的 body 字段中嵌入的天气事件进行反序列化处理。WeatherEvent 在其自己的类中定义,详情见 示例 5-4。
示例 5-4. WeatherEvent 类
package book.api;
public class WeatherEvent {
public String locationName;
public Double temperature;
public Long timestamp;
public Double longitude;
public Double latitude;
public WeatherEvent() {
}
public WeatherEvent(String locationName, Double temperature,
Long timestamp, Double longitude, Double latitude) {
this.locationName = locationName;
this.temperature = temperature;
this.timestamp = timestamp;
this.longitude = longitude;
this.latitude = latitude;
}
}
在这种情况下,Jackson 使用无参构造函数,并根据原始 Lambda 事件的 body 字段中的值填充对象的字段。
现在我们已经捕获了完整的天气事件,我们可以将其保存到数据库中。我们不打算在这里详细介绍如何使用 DynamoDB,但从代码中可以看出:
-
我们使用表名的环境变量来连接到我们想要的表。
-
我们使用 DynamoDB Java SDK 的“文档模型”将数据保存到表中,使用位置名称作为主键。
最后,我们需要返回一个响应。由于到目前为止一切正常(目前为止!),返回 HTTP 200(“OK”)响应是正确的做法,为了让客户端更清楚我们实际做了什么,我们返回保存的位置名称。
这就是我们处理 API 写路径所需的所有代码。现在让我们看看读路径。
使用 WeatherQueryLambda 读取天气数据
如您所料,WeatherQueryLambda 类似于 WeatherEventLambda,但相反。代码详见 示例 5-5。
示例 5-5. WeatherQueryLambda 处理程序类
package book.api;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDB;
import com.amazonaws.services.dynamodbv2.AmazonDynamoDBClientBuilder;
import com.amazonaws.services.dynamodbv2.model.ScanRequest;
import com.amazonaws.services.dynamodbv2.model.ScanResult;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
public class WeatherQueryLambda {
private final ObjectMapper objectMapper = new ObjectMapper();
private final AmazonDynamoDB dynamoDB =
AmazonDynamoDBClientBuilder.defaultClient();
private final String tableName = System.getenv("LOCATIONS_TABLE");
private static final String DEFAULT_LIMIT = "50";
public ApiGatewayResponse handler(ApiGatewayRequest request)
throws IOException {
final String limitParam = request.queryStringParameters == null
? DEFAULT_LIMIT
: request.queryStringParameters.getOrDefault(
"limit", DEFAULT_LIMIT);
final int limit = Integer.parseInt(limitParam);
final ScanRequest scanRequest = new ScanRequest()
.withTableName(tableName)
.withLimit(limit);
final ScanResult scanResult = dynamoDB.scan(scanRequest);
final List<WeatherEvent> events = scanResult.getItems().stream()
.map(item -> new WeatherEvent(
item.get("locationName").getS(),
Double.parseDouble(item.get("temperature").getN()),
Long.parseLong(item.get("timestamp").getN()),
Double.parseDouble(item.get("longitude").getN()),
Double.parseDouble(item.get("latitude").getN())
))
.collect(Collectors.toList());
final String json = objectMapper.writeValueAsString(events);
return new ApiGatewayResponse(200, json);
}
}
我们看到一组类似的实例变量。DynamoDB 的变量略有不同,因为 DynamoDB SDK 的 API,但 Jackson 的变量是相同的,并且再次捕获指定表名的环境变量。
在 WeatherEventLambda 处理程序中,我们关注输入事件的 body 字段。这次我们关注 queryStringParameters 字段,特别是 limit 参数,如果设置了的话。如果设置了,我们就使用它。否则,默认情况下,我们从 DynamoDB 中检索的最大记录数为 50。
接下来的几个语句从 DynamoDB 中读取数据,在此之后,我们将 DynamoDB 结果转换回 WeatherEvent 对象。获取了天气事件之后,我们再次使用 Jackson 创建一个 JSON 字符串响应返回给客户端。
最后,我们发送我们的 API 响应——再次设置 200 OK 作为状态码,但这次将有用的响应放在 body 字段中。
这就是全部的代码了!即使使用 Java 的冗长,我们也有一个完整的 HTTP API,可以读取和写入数据库的值。但是,当然,定义应用程序不仅仅是我们的代码。正如我们在第四章中看到的,我们还需要构建和打包我们的代码。而且我们实际上还需要定义我们的基础设施。
接下来我们来看构建和打包。
使用 AWS SDK BOM 进行构建和打包
在第四章中,我们展示了如何使用 Maven 构建和打包 Lambda 应用程序。在这个示例中,我们将使用我们在那里描述的 ZIP 格式,所以我们需要一个pom.xml文件和一个组件描述文件。后者与我们之前看到的没有什么不同,所以我们在这里忽略它。
让我们快速看一下pom.xml文件,为了简洁起见稍微减少了一些内容:
示例 5-6. HTTP API 的部分 Maven POM 文件
<project>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-bom</artifactId>
<version>1.11.600</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-core</artifactId>
<version>1.2.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-dynamodb</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.10.1</version>
</dependency>
</dependencies>
<!-- Other sections would follow -->
</project>
我们在这里添加的一个元素是自第四章以来的<dependencyManagement>部分。在这个标签中,我们引用了一个名为aws-java-sdk-bom的依赖关系。这个有用的元素是 Maven 的一个特性,称为“材料清单”(BOM),实质上它将一组库的版本依赖项分组。我们在这里使用它是为了确保我们使用的任何 AWS Java SDK 依赖项在版本上保持同步。
在这个特定项目中,我们实际上只使用了一个 AWS Java SDK 库——aws-java-sdk-dynamodb,因此对于这个示例来说使用 BOM 不是很必要。但是许多 Lambda 应用程序使用多个 AWS SDK,因此从稳定的基础开始是很有用的。
您还可以看到我们在<dependency>部分没有定义aws-java-sdk-dynamodb的版本,因为它使用 BOM 中定义的版本。但我们仍然需要声明aws-lambda-java-core的版本,因为它不是 AWS Java SDK 的一部分,因此不在 BOM 中——您可以从其名称中看出来它没有“sdk”。您可以在这篇博客文章中了解更多关于 AWS Java SDK BOM 的信息。
在这个示例中,我们将两个不同的 Lambda 函数的代码收集到一个压缩包中。在本章后面的下一个示例中,我们展示如何将此包拆分为单独的构件。
定义了依赖项更新后,我们可以像往常一样使用mvn package来构建和打包我们的应用程序。
基础设施
我们仍然需要定义的一个元素是我们的基础设施模板。
到目前为止,在本书中我们只定义了 Lambda 资源。现在我们需要定义我们的 API Gateway 和我们的数据库。我们应该如何做?示例 5-7 展示了template.yaml。
示例 5-7. HTTP API 的 SAM 模板
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: chapter5-api
Globals:
Function:
Runtime: java8
MemorySize: 512
Timeout: 25
Environment:
Variables:
LOCATIONS_TABLE: !Ref LocationsTable
Api:
OpenApiVersion: '3.0.1'
Resources:
LocationsTable:
Type: AWS::Serverless::SimpleTable
Properties:
PrimaryKey:
Name: locationName
Type: String
WeatherEventLambda:
Type: AWS::Serverless::Function
Properties:
CodeUri: target/lambda.zip
Handler: book.api.WeatherEventLambda::handler
Policies:
— DynamoDBCrudPolicy:
TableName: !Ref LocationsTable
Events:
ApiEvents:
Type: Api
Properties:
Path: /events
Method: POST
WeatherQueryLambda:
Type: AWS::Serverless::Function
Properties:
CodeUri: target/lambda.zip
Handler: book.api.WeatherQueryLambda::handler
Policies:
— DynamoDBReadPolicy:
TableName: !Ref LocationsTable
Events:
ApiEvents:
Type: Api
Properties:
Path: /locations
Method: GET
让我们从头开始过一遍。
首先我们有我们的 CloudFormation 和 SAM 头文件——这与我们之前见过的没有什么不同。
接下来是一个名为Globals的新顶级部分。Globals是 SAM 的一个代码优化特性,允许我们在应用程序中定义所有相同类型资源的一些常见属性。我们在这里主要用它来定义稍后在文件中声明的两个 Lambda 函数共同的一些属性。我们已经看到了Runtime、MemorySize和Timeout,但我们在Environment键中声明LOCATIONS_TABLE的方式,使用了!Ref字符串,这是新的——我们稍后会回到这一点。请注意,并非所有函数定义的属性都适用于Globals部分,这就是为什么您在Globals中没有看到CodeUri定义的原因。
最后,在Globals部分是 API Gateway 设置的小配置,以使用 SAM 的 API 配置的最新版本。
然后我们进入模板的其余部分,其中包含Resources元素。
第一个是新的——它是AWS::Serverless::SimpleTable类型。这是 SAM 定义 DynamoDB 数据库的方式。对于简单的配置,这在我们的示例中是可以的。
请注意,我们这里所做的并不仅仅是指向一个已经存在的数据库——我们实际上声明要求 CloudFormation 为我们创建一个数据库,并在与我们的 Lambda 函数等组件相同的堆栈中进行管理。我们所做的就是指定我们希望主键字段命名为什么,AWS 将为我们管理表的一切。
我们甚至不给表一个物理名称——CloudFormation 为我们基于堆栈名称、表的逻辑名称LocationsTable以及一些随机生成的唯一性生成一个唯一名称。这一切都很好,但如果我们不知道表的名称,我们怎么能从我们的 Lambda 函数中使用它呢?
这就是我们之前看到的!Ref LocationsTable值的作用。CloudFormation 用该字符串替换 DynamoDB 表的物理名称,因此我们的 Lambda 函数具有指向正确位置的环境变量。
离开 DynamoDB 表后,我们看到了我们两个 Lambda 函数的定义。这些元素包含了我们已经涵盖过的许多概念。我们在 第四章 中看到了Policies部分——请注意,我们通过以下方式支持最小权限原则:
-
仅允许我们的函数访问一个特定的 DynamoDB 表(见再次使用的
!Ref) -
仅为查询数据的 Lambda 函数提供只读访问(通过声明
DynamoDBReadPolicy策略)
我们还在每个 Lambda 函数中看到了Events部分,我们在本章稍作介绍。正如我们当时提到的,这里发生的是 SAM 正在定义一个隐式的 API Gateway,并且将我们的 Lambda 函数与Events部分定义的Path和Method属性附加到该 Gateway。
在许多实际场景中,隐式 API Gateway 配置可能不够满足您的需求,在这种情况下,您可以定义显式的 SAM API Gateway 资源(使用AWS::Serverless::Api类型的资源),或者基础 CloudFormation API Gateway 资源类型。如果您使用这些选项中的第一个选项,您可以在 Lambda 函数的 API Event属性中添加一个 RestApiId属性,以将它们与您自定义的 API 绑定在一起。
您还可以在 CloudFormation/SAM 定义的 API Gateway 中使用 Swagger/Open API。这样,您将获得更好的文档,以及一定程度上的“无需代码”输入验证的机会——但绝对不要依赖 Swagger/API Gateway 作为完整的输入验证器。另外,有些 API Gateway 配置方面的内容只能使用 AWS 自己的OpenAPI 扩展来定义。如果需要的话,我们可以撰写一整本小书,但现在就让你去探索 AWS 文档吧!
这些都有点理论性,但幸运的是,我们已经完成了对模板的查看,所以现在是部署和测试我们的应用程序的时候了!
部署
警告
在此示例中,API 是公开可访问的。虽然这对于实验(因为完整的 API 名称不容易被发现)来说是可以的,但这不是你想永远保留的东西,因为任何人都可以读取和写入这个 API。在生产环境中,您至少希望在写入路径周围添加一些安全性,但这超出了我们将在此处涵盖的范围。
部署应用程序时,请使用与之前完全相同的 sam deploy 命令(如果需要刷新记忆,请查看“CloudFormation 和 Serverless 应用程序模型”)。唯一可能想要更改的是 stack-name,这样你就可以将其部署到一个新的堆栈(例如,ChapterFiveApi)。
一旦 SAM 和 CloudFormation 完成,您就会在 CloudFormation 部分的 AWS Web 控制台中部署一个新的堆栈。我们可以在 CloudFormation 部分看到这一点(参见图 5-3)。

图 5-3. HTTP API 的 CloudFormation 堆栈
CloudFormation 有点低级,因此 AWS 还提供了一种称为Serverless Application的视图,可以在此视图中查看此部署,就像我们之前在“架构”中设计的那样。您可以通过 Lambda 控制台的应用程序选项卡访问此视图(参见图 5-4)。

图 5-4. HTTP API 的无服务器应用程序视图
在此视图中,您可以看到 DynamoDB 表、API Gateway(在 AWS 术语中称为 RestAPI)以及我们的两个 Lambda 函数。如果您点击其中任何资源,您将被带到正确的服务控制台,并进入该资源 — 尝试点击ServerlessRestApi资源。这将带您进入 API Gateway 控制台。在左侧点击 Stages,然后点击 Prod — 您应该会看到类似于 Figure 5-5 的内容。

Figure 5-5. HTTP API 的 API Gateway 视图
Invoke URL 值是您的 API 的公共访问 URL — 记下来,因为您一会儿会需要它。
您还可以在无服务器应用程序视图中看到资源的物理名称具有部分生成/部分随机的结构,正如我们之前讨论的那样。例如,在这种情况下,我们的 DynamoDB 表实际上被命名为 ChapterFiveApi-LocationsTable-WFRRTZNM7JTF。确实,如果我们在 Lambda 控制台中查看此应用程序的两个函数之一,我们可以看到LOCATIONS_TABLE环境变量已正确设置为此值(参见 Figure 5-6)。

Figure 5-6. HTTP API 的 API Gateway 视图
最后,让我们通过调用两个 API 路径来测试我们的部署。为此,您需要从一会儿前的 URL 获取。
首先,让我们发送一些数据。URL 的基础是来自 API Gateway 控制台的 URL,但我们附加 /events。例如,我们可以使用 curl 调用我们的 API,如下所示(请替换为您的 URL):
$ curl -d '{"locationName":"Brooklyn, NY", "temperature":91,
"timestamp":1564428897, "latitude": 40.70, "longitude": -73.99}' \
-H "Content-Type: application/json" \
-X POST https://hnymk3astd.execute-api.us-west-2.amazonaws.com/Prod/events
Brooklyn, NY
$ curl -d '{"locationName":"Oxford, UK", "temperature":64,
"timestamp":1564428898, "latitude": 51.75, "longitude": -1.25}' \
-H "Content-Type: application/json" \
-X POST https://hnymk3astd.execute-api.us-west-2.amazonaws.com/Prod/events
Oxford, UK
这将两个新事件保存到 DynamoDB。您可以通过从无服务器应用程序控制台点击 DynamoDB 表,然后在进入 DynamoDB 控制台后点击Items选项卡来验证这一点(参见 Figure 5-7)。

Figure 5-7. HTTP API 的 DynamoDB 表
现在我们可以使用我们应用程序的最后部分 — 从 API 读取。例如,我们可以再次使用 curl,将 /locations 添加到 API Gateway 控制台的 URL 中:
$ curl https://hnymk3astd.execute-api.us-west-2.amazonaws.com/Prod/locations
[{"locationName":"Oxford, UK","temperature":64.0,"timestamp":1564428898,
"longitude":-1.25,"latitude":51.75},
{"locationName":"Brooklyn, NY","temperature":91.0,
"timestamp":1564428897,"longitude":-73.99,"latitude":40.7}]
正如预期的那样,这将返回我们已存储天气信息的位置列表。
恭喜!您已经构建了您的第一个完整的无服务器应用程序!虽然它只有一个简单的功能,但想象一下它具有的所有非功能能力 — 它可以自动扩展以处理大量负载,然后在不使用时自动缩减,它跨多个可用区具有容错能力,其基础设施会自动更新以包括关键安全补丁,并且除此之外,还有很多其他功能。
现在让我们看一个不同类型的应用程序,使用其他几个不同的 AWS 服务。
示例:构建无服务器数据流水线
在 第一章 中,我们列出了 Lambda 的两个用例(“Lambda 应用是什么样子?”)。第一个是我们刚刚详细描述的 HTTP API——Lambda 的同步使用示例。第二个用例是文件处理——将文件上传到 S3,然后使用 Lambda 处理该文件。
在这个示例中,我们在第二个想法的基础上构建了一个 数据流水线。数据流水线是一种模式,其中我们将多个异步阶段和数据处理分支串在一起。这是一种流行的模式,云资源的可伸缩性为批处理系统提供了实时的替代方案。
此示例的另一个重要元素是,我们将改变应用程序的构建和打包阶段,以创建每个 Lambda 函数的隔离输出工件。随着 Lambda 函数中代码的增加——无论是特定于函数的代码还是作为库导入的代码——部署和启动将变慢。分解打包工件是减轻这种问题的一种有效技术。
让我们开始吧。
行为
这个示例是我们在前一个示例中开始的另一种天气事件系统。这次,一个应用程序将一个 JSON 文件中的“天气事件”列表上传到 S3。数据流水线将处理这个文件,目前的副作用只是将事件记录到 AWS CloudWatch Logs 中(图 5-8)。

图 5-8. 数据流水线示例行为
架构
我们刚刚展示的是此应用程序的 行为 ——架构 还有一些更多的细节(图 5-9)。

图 5-9. 数据流水线示例架构
我们从一个 S3 存储桶开始这个应用程序。将文件上传到 S3,或者按 S3 的术语说是一个 对象,将会(异步地)触发一个 Lambda 函数。这个第一个函数(BulkEventsLambda)将读取天气事件的 JSON 列表,将它们分开成单个事件,并且将每个事件发布到一个 SNS 主题上。这反过来会(再次异步地)触发第二个 Lambda 函数(SingleEventLambda),这个函数将处理每个天气事件。在我们的案例中,这仅仅意味着记录事件。
显然,这种架构对于仅记录上传文件的内容来说过于复杂了!然而,这个示例的重要之处在于它提供了一个应用程序的“行走骨架”,具有完整、可部署的、多阶段数据流水线。您可以将其作为添加有趣处理逻辑的起点。
所有这些组件都被视为一个统一部署的无服务器应用程序,就像我们在 HTTP API 示例中所做的那样。
现在我们将进一步深入讨论架构的每个阶段。
S3
S3 是 AWS 中历史最悠久的服务之一,正如我们在“云的增长”中所描述的。虽然它经常在系统的应用架构中使用,但在部署和操作 AWS 应用程序时也很普遍——在本书中,我们在部署基于 Lambda 的应用程序时已多次使用了 S3。
此外,我们认为 S3 至少在 AWS 上是最早的无服务器 BaaS 产品之一。如果我们回顾第一章中“区分”无服务器的因素,我们可以看到它符合所有标准:
不需要管理长期运行的主机或应用实例
是的——当我们使用 S3 时,我们没有“文件服务器”或其他需要管理的内容。
自动按负载自动扩展和自动供应
是的,我们不需要手动配置 S3 的容量——它会自动扩展总存储空间和流量。
其费用基于精确的使用量,从零使用到高使用
是的!如果您有一个空的存储桶,您不需要支付任何费用。或者,您的费用将取决于存储的字节数量、流量量和存储类别(请参阅下一点)。
以除主机大小/数量以外的术语定义的性能能力
是的,再次确认!S3 的性能能力是您选择的存储类别——您需要多快访问数据。您希望能够更快地访问数据,您就需要支付更多费用。
具有隐式高可用性
是的。S3 在一个区域内的多个可用区之间复制数据。如果一个可用区出现问题,您仍然可以访问所有数据。
由于 S3 是无服务器的,它与 Lambda 是极好的伙伴,尤其是因为它们具有类似的扩展能力。此外,S3 通过允许 Lambda 函数在 S3 存储桶中的数据更改时触发 Lambda 函数,与 Lambda 直接集成。这种以事件驱动方式自动响应 S3 中的变化,而不是从长时间运行的传统进程中轮询 S3 查找变化,从基础设施成本的角度来看更清晰、更易于理解和更高效。
在这两个示例中使用的所有非 Lambda 服务——API 网关、DynamoDB、S3 和 SNS——都是 AWS 生态系统中的无服务器 BaaS 服务。
现在,我们不会在示例中提供将“上传客户端”到 S3,而是使用 AWS 工具来处理上传。在真实应用中,您可以选择允许您的最终用户客户端通过“签名 URL”直接上传到 S3——这是一种“纯”无服务器方法,因为您不仅不运行服务器,实际上还将行为推送到客户端,这可能是您以前在服务器端应用程序中实现的行为。
Lambda 函数
当您稍后查看 Lambda 函数的代码时,您不会遇到任何新东西,因为您已经学到了所有的知识。与第一个示例不同的唯一真正区别是,这些函数不需要返回任何值,因为它们是异步调用的。
也许你心中会有一个问题,为什么我们要将每个事件的处理分别调用到单独的 Lambda 函数中呢?这种模式我们通常称为扇出。或者说,它是“映射-减少”系统中的“映射”部分,使用 Lambda 的原因有几点。
第一个原因是引入并行性。每个 SNS 消息将触发我们的SingleEventLambda函数的新调用。对于 Lambda 函数的每次调用,如果前一次调用未完成,Lambda 平台将自动创建 Lambda 函数的新实例,并调用该实例。在我们的示例应用程序中,如果您上传一个包含一百个事件的文件,而每个事件单独需要至少几秒钟来处理,那么 Lambda 将创建一百个SingleEventLambda实例,并并行处理每个天气事件(图 5-10)。

图 5-10. 数据管道扇出
Lambda 的这种可扩展性非常有价值,我们将在第八章进一步讨论(“扩展”](ch08.html#lambda-scaling))。
引入扇出的第二个原因是,如果每个单独事件的处理时间较长——比如几分钟。在这种情况下,处理一百个天气事件将超过 Lambda 的最大 15 分钟超时限制,但是将每个事件放入其自己的 Lambda 调用中意味着我们可能可以避免超时问题。
还有其他解决 Lambda 超时限制的方法。一种替代方法(有些危险——请参阅以下警告!)是在 Lambda 函数中使用递归调用。在第三章(“超时”](ch03.html#lambda-timeout))中,我们看到可以使用传递给 Lambda 处理程序的Context对象的getRemainingTimeInMillis()方法来跟踪函数直到超时的剩余时间。使用此值的策略是异步直接调用当前正在运行的相同 Lambda 函数,但仅使用剩余要处理的数据。
如果您的数据需要按线性顺序处理,这比“扇出”更好的选择。
警告
当递归调用 Lambda 函数时要小心,因为很容易出现无法停止的情况,可能会出现两种情况:(a) 永远不会停止,和/或 (b) 扩展函数到数百或数千个实例宽度。这两种情况都会严重影响您的 AWS 账单!由于情况 (b),我们建议在极少数情况下,递归 Lambda 调用有意义时,使用低“保留并发”配置(见“保留并发”)。
SNS
SNS 是 AWS 的消息服务之一。一方面,SNS 提供了一个简单的publish-subscribe 消息总线;另一方面,它还提供了发送SMS文本消息和类似的面向人类的消息的能力。在我们的示例中,我们只关心第一个!
SNS 是另一个无服务器服务。您需要负责请求 AWS 创建一个主题,然后 AWS 在幕后处理该主题的所有扩展和操作。
使用 SNS SDK 发布带有字符串内容的消息到主题非常简单,我们稍后会看到。SNS 还有多种订阅类型,但在这个例子中,我们(毫不意外地)只使用 Lambda 订阅类型。其工作原理是,当消息发布到主题时,该主题的所有订阅者都将收到消息。对于 Lambda 来说,Lambda 平台将接收消息,然后异步调用我们与订阅关联的 Lambda 函数。
在我们的示例中,我们希望每次上传文件中的天气事件时都会异步调用 Lambda 函数。我们本可以直接从 Lambda SDK 调用Invoke方法,直接(但异步地)从BatchEventsLambda调用SingleEventLambda,但我们选择了使用 SNS 作为中介——为什么呢?
这是因为我们希望减少两个 Lambda 函数之间的结构耦合。我们希望BatchEventsLambda知道它的责任是分割一批天气事件,但我们不一定希望它涉及接下来这些天气事件的处理。如果稍后决定改变我们的架构,使每个事件由多个消费者处理,或者可能用 AWS Step Functions 服务替代SingleEventLambda,那么BatchEventsLambda的代码就不需要改变。
最后,我们选择了 SNS,因为它在 Lambda 应用程序中简单且普遍存在。AWS 提供了许多其他的消息系统——SQS、Kinesis 和 Event Bridge 就是其中一些例子,你甚至可以使用 S3!选择哪种服务实际上取决于你的应用程序具体的需求,以及每种服务的不同能力。为应用程序选择正确的消息服务可能有些棘手,因此进行适当的研究是值得的。
Lambda 代码
我们的代码由三个类组成。
第一个与我们在第一个示例中相同的WeatherEvent,但复制到一个新的包中,原因稍后将会更加清晰。
使用 BulkEventsLambda 处理批处理
接下来的类是我们的BulkEventsLambda代码。
正如我们已经讨论过的,首先要做的是了解输入事件的格式。
如果我们运行sam local generate-event s3,我们可以看到 S3 可以生成“puts”(创建和更新)和“deletes”事件。我们关心前者,示例事件如下(为了简洁起见做了一些修剪):
{
"Records": [
{
"eventSource": "aws:s3",
"awsRegion": "us-east-1",
"eventTime": "1970-01-01T00:00:00.000Z",
"eventName": "ObjectCreated:Put",
"s3": {
"bucket": {
"name": "example-bucket",
"arn": "arn:aws:s3:::example-bucket"
},
"object": {
"key": "test/key",
"size": 1024
}
}
}
]
}
首先要注意的是,事件包含一个 Records 数组。实际上,S3 只会发送一个包含正好一个元素的数组,但是如果容易这样做,为此进行防御性编码是一个好的实践。
接下来要注意的是,我们知道是哪个对象引起了这个事件——在存储桶 example-bucket 中的 test/key。重要的是要记住,尽管我们经常将其视为文件系统,但 S3 实际上不是文件系统,它是一个键值存储,其中键可以看作是文件系统中的路径。
最后要注意的是,我们并不接收上传对象的内容,我们只知道对象的 位置。在我们的示例应用程序中,我们需要内容,因此我们需要自己从 S3 加载对象。
在这个示例中,我们将使用 aws-lambda-java-events 库中的 S3Event 类作为我们的输入事件 POJO。这个类引用了 aws-java-sdk-s3 SDK 库中的其他类型,因此我们也需要在我们的库依赖中加入它。不过,从希望尽量减少库依赖的角度来看,因为我们在这个类中直接调用了 S3 SDK,所以这是可以接受的。
S3Event 对象及其字段包含了输入事件所需的一切,由于这个函数是异步的,所以没有返回类型。这意味着我们已经完成了 POJO 定义阶段,可以开始编写代码了。
我们将 Example 5-8 的 package 和 import 行省略了,因为它们太多了,但如果你有兴趣看到它们,请下载本书的示例代码。
示例 5-8. BulkEventsLambda.java
public class BulkEventsLambda {
private final ObjectMapper objectMapper =
new ObjectMapper()
.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false);
private final AmazonSNS sns = AmazonSNSClientBuilder.defaultClient();
private final AmazonS3 s3 = AmazonS3ClientBuilder.defaultClient();
private final String snsTopic = System.getenv("FAN_OUT_TOPIC");
public void handler(S3Event event) {
event.getRecords().forEach(this::processS3EventRecord);
}
private void processS3EventRecord(
S3EventNotification.S3EventNotificationRecord record) {
final List<WeatherEvent> weatherEvents = readWeatherEventsFromS3(
record.getS3().getBucket().getName(),
record.getS3().getObject().getKey());
weatherEvents.stream()
.map(this::weatherEventToSnsMessage)
.forEach(message -> sns.publish(snsTopic, message));
System.out.println("Published " + weatherEvents.size()
+ " weather events to SNS");
}
private List<WeatherEvent> readWeatherEventsFromS3(String bucket, String key) {
try {
final S3ObjectInputStream s3is =
s3.getObject(bucket, key).getObjectContent();
final WeatherEvent[] weatherEvents =
objectMapper.readValue(s3is, WeatherEvent[].class);
s3is.close();
return Arrays.asList(weatherEvents);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private String weatherEventToSnsMessage(WeatherEvent weatherEvent) {
try {
return objectMapper.writeValueAsString(weatherEvent);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}
处理方法循环处理 S3Event 中的每个记录。我们知道应该只有一个记录,但如果不是这样,这段代码也能保险地处理。
代码的其余部分的要求相当简单:
-
从 S3 中读取上传的 JSON 对象。
-
将 JSON 对象反序列化为
WeatherEvent对象列表。 -
对于每个
WeatherEvent对象,将其重新序列化为 JSON… -
…然后将其发布到 SNS。
如果您查看代码,您会看到所有这些都得到了表达。我们像在第一个示例中一样使用 Jackson 进行序列化/反序列化。我们两次使用 AWS SDK——一次从 S3 中读取 (s3.getObject()),一次发布到 SNS (sns.publish())。虽然这些是不同的 SDK,每个都需要自己的库依赖,但它们在使用上感觉与之前的 DynamoDB SDK 大致相同。
值得注意的一点是,就像第一个例子中一样,我们在创建与 AWS SDK 的连接时从未提供任何凭据:当我们在AmazonSNSClientBuilder和AmazonS3ClientBuilder上调用defaultClient()时,没有用户名或密码。这是因为在 Lambda 中运行时,Java AWS SDK 默认使用我们为 Lambda 配置的 Lambda 执行角色(我们在“身份和访问管理”中讨论过)。这意味着没有密码可以从我们的源代码中泄漏!
处理单个天气事件使用 SingleEventLambda
进入我们的最后一个类。你现在应该已经掌握了,所以让我们快速过一遍!
首先是输入事件。运行 sam local generate-event sns notification 给我们以下结果,再次略作修整:
{
"Records": [
{
"EventSubscriptionArn": "arn:aws:sns:us-east-1::ExampleTopic",
"Sns": {
"Type": "Notification",
"MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e",
"TopicArn": "arn:aws:sns:us-east-1:123456789012:ExampleTopic",
"Subject": "example subject",
"Message": "example message",
"Timestamp": "1970-01-01T00:00:00.000Z",
}
}
]
}
与 S3 类似,我们的输入事件由单元素记录列表Records组成。在Record内部,以及其中的Sns对象中,有许多字段。在这个例子中,我们关心的是Message,但 SNS 消息还提供了一个Subject字段。
我们再次使用 aws-lambda-java-events 库,就像我们与 BulkEventsLambda 一样,但这次我们要使用 SNSEvent 类。 SNSEvent 不需要任何其他 AWS SDK 类,因此无需向我们的 Maven 依赖中添加任何进一步的库。
同样,这是一种异步事件类型,因此没有需要担心的返回类型。
现在看代码(参见示例 5-9)!这里再次省略了package和import语句,但如果你想看到它们,可以在书的可下载代码中找到。
示例 5-9. SingleEventLambda Handler 类
public class SingleEventLambda {
private final ObjectMapper objectMapper =
new ObjectMapper()
.configure(
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false);
public void handler(SNSEvent event) {
event.getRecords().forEach(this::processSNSRecord);
}
private void processSNSRecord(SNSEvent.SNSRecord snsRecord) {
try {
final WeatherEvent weatherEvent = objectMapper.readValue(
snsRecord.getSNS().getMessage(),
WeatherEvent.class);
System.out.println("Received weather event:");
System.out.println(weatherEvent);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
这次我们的代码更简单了:
-
再次对多个
SNSRecord事件进行防御性编码(尽管应该只有一个)。 -
从 SNS 事件中反序列化
WeatherEvent。 -
记录
WeatherEvent的日志(我们将在第七章更详细地讨论日志记录)。
这次没有提及 SDK,因为输入事件包含了我们关心的所有数据。
使用多模块和隔离的构建和打包
所有代码编写完毕,现在是构建和打包我们的应用程序的时候了。
从流程角度来看,这个例子与我们之前覆盖的内容没有任何不同——我们将在运行 sam deploy 之前运行 mvn package。
不过,这个例子有一个重要的结构性差异——我们为每个 Lambda 函数创建单独的 ZIP 文件构件。每个 ZIP 文件仅包括一个 Lambda 处理程序的类及其所需的库依赖关系。
虽然对于这样大小的应用程序来说做这些有些不必要,但随着你的应用程序变得更大,考虑分解构件是有价值的几个原因:
-
冷启动时间将会缩短(我们将在“冷启动”中详细讨论冷启动)。
-
由于每次部署只上传与更改函数相关的工件(假设使用我们在第四章中介绍的可复制构建插件),因此从本地机器部署的时间通常会减少。
-
为了避免 Lambda 的工件大小限制,您可能需要这样做。
最后一点涉及 Lambda 中(未压缩)函数工件的 250MB 大小限制。如果您有 10 个 Lambda 函数,每个函数都有不同的依赖关系,并且它们的组合(未压缩)工件大小超过 250MB,那么您需要为每个函数分割工件,以确保可以进行部署。
那么我们该如何实现这一点呢?
一种思考方法是,我们实际上正在为我们的无服务器应用程序构建一个非常小的单库。也许你可以将它想象成一个“无服务器应用程序 MiniMono”。常规的单库包含一个仓库中的多个项目;我们的 MiniMono 将包含一个 Maven 项目中的多个 Maven 模块。尽管 Maven 有其缺点,但作为声明多个组件之间的依赖关系及其对外部库依赖的方式,它确实表现得非常好。而 IntelliJ 在解析多模块 Maven 项目方面表现得非常出色。
正确配置多模块 Maven 项目有点繁琐,因此我们将在此逐步进行。我们强烈建议您下载示例代码并在 IntelliJ 中打开它,因为这样更容易理解。
顶层项目
我们的顶层pom.xml文件将类似于示例 5-10。我们已经剪切了一些内容以清楚解释。
示例 5-10。数据管道应用程序的父项目 pom.xml
<project>
<groupId>my.groupId</groupId>
<artifactId>chapter5-Data-Pipeline</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>common-code</module>
<module>bulk-events-stage</module>
<module>single-event-stage</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-bom</artifactId>
<version>1.11.600</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-events</artifactId>
<version>2.2.6</version>
</dependency>
<!-- etc -->
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.1</version>
<executions>
<execution>
<id>001-make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
<configuration>
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<descriptor>src/assembly/lambda-zip.xml</descriptor>
</descriptors>
<finalName>lambda</finalName>
</configuration>
</plugin>
<plugin>
<groupId>io.github.zlika</groupId>
<artifactId>reproducible-build-maven-plugin</artifactId>
<version>0.10</version>
<executions>
<execution>
<id>002-strip-jar</id>
<phase>package</phase>
<goals>
<goal>strip-jar</goal>
</goals>
</execution>
</executions>
<configuration>
<outputDirectory>${project.build.directory}</outputDirectory>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
这里有几个要点:
-
我们在顶层添加了
<packaging>pom</packaging>标签——这表明这是一个多模块项目。 -
我们在
<modules>部分包含模块列表。 -
注意,此时我们并不声明任何模块间的依赖关系。
-
所有我们的外部依赖项(不仅仅是 AWS SDK BOM)都移到了
<dependencyManagement>部分。在此声明整个项目中的所有依赖关系会让生活更轻松,并且保证依赖版本在整个项目中是统一的,但您也不必这样做。 -
我们很快就会看到,模块将声明它们需要哪些外部依赖关系。
-
请注意,我们仍然有我们在第一个示例中讨论过的 AWS SDK BOM。我们将构建插件定义移动到
<pluginManagement>部分,以便模块可以使用它们。 -
组装插件的配置仍然在src/assembly/lambda-zip.xml中,或者您可以使用我们在 Maven Central 为您创建的版本。
-
这里有很多其他“Maven 魔法”的细节我们就不深入讨论了!
有了我们的顶层项目,现在我们可以创建我们的模块了。
这些模块
我们为每个模块创建一个子目录,其名称与项目 pom.xml 中模块列表的各元素相同。
在每个模块子目录中,我们创建一个新的 pom.xml。我们从 common-code 开始,这让我们可以编写被 Lambda 构件共享的代码。在我们的示例中,它包含 WeatherEvent 类。
再次强调,所有这些 Maven 示例都稍作裁剪,请查看书籍源代码获取完整版本。
示例 5-11. common-code 的模块 pom.xml
<project>
<parent>
<groupId>my.groupId</groupId>
<artifactId>chapter5-Data-Pipeline</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>common-code</artifactId>
<build>
<plugins>
<plugin>
<artifactId>reproducible-build-maven-plugin</artifactId>
<groupId>io.github.zlika</groupId>
</plugin>
</plugins>
</build>
</project>
我们声明我们的父级,我们模块的 artifactId(为了明智起见,应与模块名称相同),然后我们声明要使用的构建插件。对于这个模块,我们只创建一个常规的 JAR 文件,只包含模块本身的代码。这意味着我们不需要组装 ZIP 文件,但我们仍然希望利用可重复生成的构建插件。插件的配置来自父 bom 中 <pluginManagement> 部分的定义。
注意,由于此模块目前没有任何依赖项,因此没有 <dependencies> 部分。
接下来,在 bulk-events-stage 子目录中,我们按照 示例 5-12 中所示创建 pom.xml。
示例 5-12. bulk-events-stage 的模块 pom.xml
<project>
<parent>
<groupId>my.groupId</groupId>
<artifactId>chapter5-Data-Pipeline</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<artifactId>bulk-events-stage</artifactId>
<dependencies>
<dependency>
<groupId>my.groupId</groupId>
<artifactId>common-code</artifactId>
<version>${project.parent.version}</version>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-events</artifactId>
</dependency>
<!-- etc. -->
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
</plugin>
<plugin>
<artifactId>reproducible-build-maven-plugin</artifactId>
<groupId>io.github.zlika</groupId>
</plugin>
</plugins>
</build>
</project>
<parent> 部分与 common-code 相同,<artifactId> 遵循之前的规则。
这次我们确实有依赖项。第一个是我们如何声明一个模块间的依赖,本例中是对 common-code 模块的依赖。请注意,我们从父模块中获取版本。然后我们声明所有外部依赖项。请注意,这些依赖项没有版本号—版本号来自父 pom.xml 中的 <dependency-management> 部分(或者从 AWS SDK BOM 中传递获取)。
最后,在 <build> 部分中,我们声明我们的构建插件。这次我们需要创建一个 ZIP 文件(这将是仅用于 BulkEventsLambda 函数的 ZIP 文件),因此我们包含对 maven-assembly-plugin 的引用。再次强调,插件的配置在父 pom.xml 中定义。
single-event-stage pom.xml 看起来几乎与 bulk-events-stage pom.xml 相同,但依赖项较少。
Maven POM 文件完成后,我们在每个模块中创建 src 目录。项目目录树的最终结果如下所示:
.
+--> bulk-events-stage
| +--> src/main/java/book/pipeline/bulk
| | +--> BulkEventsLambda.java
| +--> pom.xml
+--> common-code
| +--> src/main/java/book/pipeline/common
| | +--> WeatherEvent.java
| +--> pom.xml
+--> single-event-stage
| +--> src/main/java/book/pipeline/single
| | +--> SingleEventLambda.java
| +--> pom.xml
+--> src/assembly
| +--> lambda-zip.xml
+--> pom.xml
+--> template.yaml
运行 mvn package 以创建此多模块项目中每个 Lambda 模块目录中的单独 lambda.zip 文件。
由于我们有互不依赖的并行模块,实际上我们可以微调 Maven 的使用以增加构建性能。运行 mvn package -T 1C 将使 Maven 在可以时使用多个操作系统线程,每个核心一个。
基础设施
尽管我们的 Java 项目结构发生了显著变化,但我们的 SAM 模板并没有变化很多。让我们看看它是如何变化的,以及我们在示例 5-13 中使用的其他 AWS 资源。
示例 5-13. 数据流水线的 SAM 模板
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: chapter5-data-pipeline
Globals:
Function:
Runtime: java8
MemorySize: 512
Timeout: 10
Resources:
PipelineStartBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub ${AWS::StackName}-${AWS::AccountId}-${AWS::Region}-start
FanOutTopic:
Type: AWS::SNS::Topic
BulkEventsLambda:
Type: AWS::Serverless::Function
Properties:
CodeUri: bulk-events-stage/target/lambda.zip
Handler: book.pipeline.bulk.BulkEventsLambda::handler
Environment:
Variables:
FAN_OUT_TOPIC: !Ref FanOutTopic
Policies:
— S3ReadPolicy:
BucketName: !Sub ${AWS::StackName}-${AWS::AccountId}-${AWS::Region}-start
— SNSPublishMessagePolicy:
TopicName: !GetAtt FanOutTopic.TopicName
Events:
S3Event:
Type: S3
Properties:
Bucket: !Ref PipelineStartBucket
Events: s3:ObjectCreated:
SingleEventLambda:
Type: AWS::Serverless::Function
Properties:
CodeUri: single-event-stage/target/lambda.zip
Handler: book.pipeline.single.SingleEventLambda::handler
Events:
SnsEvent:
Type: SNS
Properties:
Topic: !Ref FanOutTopic
首先,在我们的记忆中仍然清晰时,让我们看看多模块 Maven 项目引起的差异。唯一的更新是 Lambda 函数的CodeUri属性——在 API 示例中,我们曾经对两个函数都使用相同的target/lambda.zip值,现在对于BulkEventsLambda是bulk-events-stage/target/lambda.zip,对于SingleEventLambda是single-event-stage/target/lambda.zip。
好了,现在让我们回到顶部。
Globals部分这次稍微小了些。这是因为 Lambda 函数之间没有共享的环境变量,而且我们也不需要任何 API 配置。
在Resources下,首先声明了我们的 S3 存储桶。您可以在这里添加很多属性——与访问控制相关的属性尤其受欢迎。我们通常喜欢添加的一件事是服务器端加密以及生命周期策略。但在这里,我们保持默认设置。这里有一件事是显式声明的名称。通常情况下,我们不希望这样做,而是让 CloudFormation 为我们生成一个唯一的名称,但由于 CloudFormation 的 S3 资源的一个恼人的特性,如果我们不声明一个名称,那么我们将与文件的一些其他元素产生循环依赖。
S3 存储桶名称在所有 AWS 区域和账户中必须是全局唯一的。如果您在 us-east-1 区域创建一个名为sheep的存储桶,那么您不能在 us-west-2 中再创建另一个名为sheep的存储桶(除非您首先删除 us-east-1 中的存储桶),并且我根本无法创建名为“sheep”的存储桶。这意味着当您通过像 CloudFormation 这样的自动化工具显式创建存储桶名称时,您需要包含各种上下文唯一的方面,以避免命名冲突。
例如,我们使用以下声明的存储桶名称:
!Sub ${AWS::StackName}-${AWS::AccountId}-${AWS::Region}-start
这里涉及一些 CloudFormation 的智能操作,所以让我们来详细解析一下。
首先,!Sub是另一个内部函数,就像第一个示例中的!Ref一样。!Sub用于替换字符串中的变量。通常您会使用模板参数中声明的变量,但在这种情况下,我们使用 CloudFormation 的伪参数——由 CloudFormation 代表我们定义的变量。假设我创建了一个名为my-stack的堆栈,我们的账户 ID 是 123456,并且我们在 us-west-2 中创建了该堆栈,那么该堆栈中的存储桶名称将是my-stack-123456-us-west-2-start。
下一个资源是我们的 SNS 主题。看——没有属性!SNS 部分可配置,但也可以完全不配置就使用。
然后我们有我们的两个 Lambda 函数。
BulkEventsLambda 具有一个环境变量,引用了 SNS 主题的 Amazon 资源名称 (ARN)。SNS 主题 CloudFormation 文档 告诉我们,在 Topic 资源上调用 !Ref 返回其 ARN。
对于这个 Lambda 函数的安全性,我们需要从 S3 存储桶中读取数据(我们在首次声明存储桶时使用相同的名称),并且我们需要写入(或发布)到 SNS 主题。对于 SNS 主题,安全策略不需要 ARN(这是当我们在主题资源上调用 !Ref 时返回的内容),它需要主题的名称。为了获得该名称,我们使用第三个内置函数 !GetAtt。!GetAtt 允许我们从 CloudFormation 资源中读取次要返回值。同样地,在查看 SNS 文档时,我们可以看到在请求 TopicName 时返回的名称,因此值为 !GetAtt FanOutTopic.TopicName。
最后,对于 BulkEventsLambda,我们需要声明事件源。这是 S3 存储桶,并且我们在 Events 字段中声明我们关心的 S3 事件类型。如果您愿意,您可以在这里进行更详细的描述,例如包括过滤模式,以仅触发特定 S3 键的事件。
正如您所预期的那样,SingleEventLambda 更简单,因为它不调用任何 AWS 资源。对于这个函数,我们只需要声明事件源,即 SNS 主题,它通过主题的 ARN 引用。
部署
部署类似于您之前看到的内容。再次,我们使用无服务器应用程序的原则,将所有组件集体部署。
部署此应用程序有一个小的更改。因为我们在手动定义的 S3 存储桶名称中使用了堆栈名称,所以必须仅使用小写字母(因为 S3 存储桶不能以大写字母命名):
$ sam deploy \
--s3-bucket $CF_BUCKET \
--stack-name chapter-five-data-pipeline \
--capabilities CAPABILITY_IAM
应用程序部署后,您可以通过 Lambda 应用程序控制台或 CloudFormation 控制台探索已部署的组件。图 5-11 展示了 Lambda 应用程序中的外观。

图 5-11. 数据管道的无服务器应用程序视图
单击资源将带您进入 AWS 控制台的各自部分。要测试此应用程序,我们需要将文件上传到 S3。其中一种选项是通过 Web 控制台手动执行此操作。
一个更加自动化的方法如下所示。
首先,查询 CloudFormation 获取 S3 存储桶的名称,并将其分配给一个 shell 变量:
$ PIPELINE_BUCKET="$(aws cloudformation describe-stack-resource \
--stack-name chapter-five-data-pipeline \
--logical-resource-id PipelineStartBucket \
--query 'StackResourceDetail.PhysicalResourceId' \
--output text)"
现在使用 AWS CLI 来上传示例文件:
$ aws s3 cp sampledata.json s3://${PIPELINE_BUCKET}/sampledata.json
现在查看 SingleEventLambda 函数的日志,您会看到,几秒钟后,每个天气事件都将分别记录。
恭喜!您已经构建了第二个无服务器应用程序!
想象一下,通过 AWS 提供的大量服务,可以构建无数种不同类型的无服务器应用程序。而且,这还没有考虑到从 Lambda 调用 AWS 之外的服务的完全有效能力!
我们希望本章为您展示了可能性。仅凭几个文本文件,几分钟或几秒钟就能部署完整的、多组件的应用程序,然后再将其拆除,这构建了一个非常有价值的“应用程序沙盒”环境,也能扩展到真正的生产使用。
摘要
我们从学习如何从其他 AWS 服务触发 Lambda 函数开始本章。理解这一点是接受无服务器架构的重要第一步。
接着,我们探讨了两个示例无服务器应用程序——完全包含的 AWS 资源组。第一个例子是一个基于数据库的 HTTP API,使用了两个同步调用的 Lambda 函数,以及 AWS 服务 API Gateway 和 DynamoDB。
第二个例子是一个由两个异步处理阶段组成的无服务器数据流水线,包括扇出设计。这个例子使用了 Lambda、S3 和 SNS。在这个例子中,我们还探讨了使用多模块 Maven 项目创建“无服务器应用 MiniMono”的方法。
您现在已经掌握了构建无服务器 AWS 应用程序的框架:
-
确定您希望您的应用程序具有的行为。
-
通过选择哪些服务来实现系统的不同方面,并定义这些服务之间的交互,设计您应用程序的架构。
-
编写Lambda 代码来:
-
处理正确的事件类型。
-
在下游服务上执行必要的副作用。
-
在相关情况下,返回正确的响应。
-
-
使用 CloudFormation/SAM 模板配置您的基础设施。
-
使用正确的 AWS 工具进行部署。
到目前为止,我们所有的测试都是非常手动的。我们如何利用自动化测试技术来做得更好?这是我们在下一章中要探讨的内容。
练习
-
另一个很好的 Lambda“入门”事件源是 CloudWatch 定时事件,我们可以使用它来构建“无服务器定时任务”。我们在“示例:Lambda‘定时任务’”中描述了 Lambda 的这种使用方式。建立一个 Lambda 函数,每分钟运行一次,并且暂时只在调用时写出一个日志声明。请参阅SAM 文档了解如何设置此触发器。
-
更新上一个练习中的定时事件 Lambda,以将消息发布到 SNS,类似于本章前面所做的
BulkEventsLambda。更新您的 SNS 主题,以向您的手机发送 SMS 或文本消息(请参阅AWS 文档以了解如何操作)。 -
重新实现本章的数据管道示例,使用 SQS 队列而不是 SNS 主题,在两个 Lambda 之间传递消息。关于此,可以参考 Lambda 文档中的 这里 和 这里。
第六章:测试
一个良好的测试套件,就像房子的坚实基础一样,为我们提供了一个已知的系统行为基准,我们可以在其上放心地构建。这个基准给了我们信心,可以添加功能,修复错误,并进行重构,而不用担心会破坏系统的其他部分。当集成到开发工作流程中时,同样的测试套件还通过更容易维护现有测试和添加新测试来鼓励良好的实践。
当然,基础并非免费。维护测试的努力必须与测试提供的价值相平衡。如果我们把所有精力都花在测试上,就没有剩余精力来处理系统的其他部分了。
对于无服务器应用程序来说,划分有价值测试和脆弱技术债务之间的界限比以往任何时候都更加困难。幸运的是,我们可以使用一个熟悉的模型来帮助考虑这些权衡。
测试金字塔
经典的“测试金字塔”(来自 2009 年迈克·科恩的书《成功的敏捷》中,图 6-1 显示的图 6-1)对我们帮助决定写哪种测试是一个有用的指南。金字塔的比喻说明了在给定切片中测试的数量、这些测试的价值以及编写、运行和维护它们的成本之间的权衡。

图 6-1。测试金字塔
在无服务器世界中进行测试与传统应用程序并没有实质性的区别,特别是在金字塔的基础部分。然而,与由不同组件和服务组成的任何分布式系统一样,更高级别的“端到端”测试更具挑战性。在本章中,我们将从金字塔底部到顶部讨论测试,并且会沿途提供大量示例。
单元测试
金字塔的基础是单元测试 —— 这些测试应该针对我们应用程序的特定组件进行测试,而不依赖于任何外部依赖项(如数据库)。单元测试应该快速执行,并且在开发过程中我们应该能够定期(甚至自动化地)运行它们,配置最小化且无需网络访问。我们应该有足够多的单元测试来确保我们的代码正常工作。单元测试不仅覆盖“正常路径”,还要彻底处理边缘情况和错误处理。即使是一个小应用程序也可能有数十甚至数百个单元测试。
功能测试
金字塔的中间是功能测试。像单元测试一样,这些测试应该快速执行,并且不应依赖外部依赖项。与单元测试不同的是,我们可能需要模拟或存根这些外部依赖项,以满足测试组件的运行时要求。
而不是试图详尽地执行我们代码的每个逻辑分支,我们的功能测试解决组件的主要代码路径,特别关注失败模式。
端到端测试
位于金字塔顶端的是端到端测试。端到端测试向应用程序提交输入(通常通过正常的用户界面或 API),然后对输出或副作用进行断言。与功能测试不同,端到端测试针对完整的应用程序及其所有外部依赖项在类似生产的环境中运行(尽管通常与生产隔离)。
因为端到端测试比功能和单元测试更昂贵(就运行时间和基础设施成本而言),通常您只应测试一些重要的情况。一个很好的经验法则是至少有一个端到端测试覆盖应用程序中最重要的路径(例如,在在线购物应用程序中的购买路径)。
重构以进行测试
我们将使用我们在第五章中构建的无服务器数据流水线作为基础,构建一套单元测试、功能测试和端到端测试。在我们开始之前,让我们做一点重构,使我们的数据流水线 Lambdas 更容易测试。
从前一节中回顾,单元测试会测试我们应用程序的特定组件的具体部分。在我们的情况下,我们指的是构成我们 Lambda 函数的 Java 类中的方法。我们希望编写测试,为某些方法提供输入,并断言这些方法的输出(或副作用)是否符合我们的预期。
首先,让我们回顾一下BulkEventsLambda,牢记测试金字塔的单元和功能切片。这个相对简单的 Lambda 函数与两个外部 AWS 服务(S3 和 SNS)以及序列化和反序列化 JSON 数据进行交互。
重访 BulkEventsLambda
每当文件上传到特定的 S3 存储桶时,就会触发BulkEventsLambda。处理程序方法会使用一个S3Event对象调用。对于该事件中的每个S3EventNotificationRecord,Lambda 会从 S3 存储桶中检索一个 JSON 文件。该 JSON 文件包含零个或多个 JSON 对象。Lambda 将 JSON 文件反序列化为一组WeatherEvent Java 对象。然后,每个 Java 对象都序列化为一个String并发布到一个 SNS 主题。最后,Lambda 函数会向 STDOUT(因此也会向 CloudWatch Logs)写入一个日志条目,指出发送到 SNS 的天气事件的数量。
您在第五章看到的代码是为了清晰而编写和组织的,但不一定是为了便于测试。让我们来看一下BulkEvents Lambda类中的四个方法。
首先,handler方法,接收一个S3Event对象:
public void handler(S3Event event) {
event.getRecords().forEach(this::processS3EventRecord);
}
这是类外唯一可访问的方法——没有重构的话,这意味着对这个类的任何测试必须使用一个S3Event对象调用此方法。此外,该方法具有void返回类型,因此很难断言成功或失败。
接下来,我们看到这个方法为每个传入的事件记录调用了processS3EventRecord:
private void processS3EventRecord(
S3EventNotification.S3EventNotificationRecord record) {
final List<WeatherEvent> weatherEvents = readWeatherEventsFromS3(
record.getS3().getBucket().getName(),
record.getS3().getObject().getKey());
weatherEvents.stream()
.map(this::weatherEventToSnsMessage)
.forEach(message -> sns.publish(snsTopic, message));
System.out.println("Published " + weatherEvents.size()
+ " weather events to SNS");
}
此方法是私有的,因此无法在不将可见性更改为“包私有”(通过删除private关键字)的情况下进行测试。与handler函数一样,它具有 void 返回类型,因此我们进行的任何断言都将是关于方法的副作用而不是方法的返回值。此方法有两个明确的副作用:
-
System.out.println调用。 -
调用
sns.publish方法,向由snsTopic字段命名的主题发送 SNS 消息。由于这是 AWS SDK 调用,必须考虑许多其他环境和系统属性:-
必须设置和正确配置适当的 AWS 配置。
-
配置的 AWS API 端点必须通过网络访问。
-
必须存在命名的 SNS 主题。
-
我们正在使用的 AWS 凭据必须具有写入该 SNS 主题的访问权限。
-
要按照编写的方式调用processS3EventRecord,我们必须提前处理所有这些项目。对于单元测试来说,这是不可接受的开销。
此外,如果我们还想断言processS3EventRecord是否已正确运行,则需要一种方法来确保 SNS 消息已发送到正确的主题。做法之一是在我们的测试过程中订阅 SNS 主题,并等待预期的消息出现。与以前一样,这对于单元测试来说是不可接受的开销。
在 Java 中测试这些副作用的常见方法是使用诸如Mockito之类的工具来模拟或存根负责这些副作用的类。这使我们能够测试我们自己的应用程序类,这些类产生副作用,通过替换诸如 AWS SDK 之类的模拟对象,看起来和行为类似,但允许我们避免实际设置真实的 SNS 主题。使用诸如参数捕获之类的技术,模拟对象还可以保存用于调用它们的参数,这使我们能够断言它们的调用方式——在本例中,我们可以断言sns.publish方法是否使用正确的主题名称和消息进行了调用。
要使用这样的模拟 AWS SDK 对象,我们需要一种将其注入到受测试类中的方式——通常是通过接受适当参数的构造函数完成的。BulkEventsLambda没有这样的构造函数,因此我们需要添加一个构造函数以便能够使用模拟对象。
readWeatherEventsFromS3方法是另一个具有副作用的方法的示例,本例中是远程 API 调用。在这种情况下,它使用 AWS S3 SDK 客户端的getObject调用从 S3 下载数据。
然后将数据反序列化为WeatherEvent对象集合并返回给调用方:
private List<WeatherEvent> readWeatherEventsFromS3(String bucket, String key) {
try {
final S3ObjectInputStream s3is =
s3.getObject(bucket, key).getObjectContent();
final WeatherEvent[] weatherEvents =
objectMapper.readValue(s3is, WeatherEvent[].class);
s3is.close();
return Arrays.asList(weatherEvents);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
这个方法做了两件完全不同的事情——它从 S3 下载数据,并对该数据进行反序列化。这种行为组合使我们难以测试每个功能片段是否独立。如果我们想测试 JSON 反序列化过程中如何处理错误,我们仍然需要确保方法的输入具有正确的 S3 存储桶和密钥,尽管这些信息与 JSON 处理无关。
最后,weatherEventToSnsMessage是一个应该很容易测试的方法示例(如果在BulkEventsLambda类外部可见的话)。它接受一个Weather Event对象并返回一个String,并且不会造成任何副作用。
重构 BulkEventsLambda
在审查了BulkEventsLambda中的四种方法之后,以下是一些可以更好地实现单元测试和功能测试的方法:
-
通过构造函数参数启用模拟 AWS SDK 类的注入。
-
隔离副作用,因此大多数方法可以在不使用模拟的情况下进行测试。
-
将方法拆分开来,使大多数方法只做一件事情。
添加构造函数
在牢记这些事情的情况下,让我们从添加一些构造函数开始:
public BulkEventsLambda() {
this(AmazonSNSClientBuilder.defaultClient(),
AmazonS3ClientBuilder.defaultClient());
}
public BulkEventsLambda(AmazonSNS sns, AmazonS3 s3) {
this.sns = sns;
this.s3 = s3;
this.snsTopic = System.getenv(FAN_OUT_TOPIC_ENV);
if (this.snsTopic == null) {
throw new RuntimeException(
String.format("%s must be set", FAN_OUT_TOPIC_ENV));
}
}
现在我们有了两个构造函数。正如我们在第三章中学到的那样,默认的无参数构造函数将在第一次运行我们的函数时由 Lambda 运行时调用。该默认构造函数创建了一个 AWS SDK SNS 客户端和一个 S3 客户端,并将这两个对象传递给第二个构造函数(这种技术称为构造函数链)。
第二个构造函数以这些客户端对象为参数。在测试中,我们可以使用这个构造函数来实例化具有模拟 AWS SDK 客户端的BulkEventsLambda类。该第二个构造函数还读取FAN_OUT_TOPIC环境变量,如果没有设置,则抛出异常。
隔离副作用
我们从BulkEventsLambda审查中注意到了三个副作用:
-
从 S3 下载 JSON 文件。
-
向 SNS 主题发布消息。
-
写入一条日志到 STDOUT。
前两个对测试环境有一些先决条件,减慢了测试执行速度,并使编写测试变得更加复杂。虽然我们肯定要测试这些副作用(同时使用模拟和实际的 AWS 服务),但将它们隔离到尽可能少的方法中将有助于使我们的单元测试简单而快速。
在那个基础上,让我们看一下两种新方法,以隔离 AWS 的副作用:
private void publishToSns(String message) {
sns.publish(snsTopic, message);
}
private InputStream getObjectFromS3(
S3EventNotification.S3EventNotificationRecord record) {
String bucket = record.getS3().getBucket().getName();
String key = record.getS3().getObject().getKey();
return s3.getObject(bucket, key).getObjectContent();
}
第一个方法publishToSns接受一个String参数并向 SNS 主题发布消息。第二个getObjectFromS3接受一个S3EventNotificationRecord并从 S3 下载相应的文件。
现在从重构的handler方法中调用这两种方法,这是实现副作用隔离的实际位置:
public void handler(S3Event event) {
List<WeatherEvent> events = event.getRecords().stream()
.map(this::getObjectFromS3)
.map(this::readWeatherEvents)
.flatMap(List::stream)
.collect(Collectors.toList());
// Serialize and publish WeatherEvent messages to SNS
events.stream()
.map(this::weatherEventToSnsMessage)
.forEach(this::publishToSns);
System.out.println("Published " + events.size()
+ " weather events to SNS");
}
在这个新的handler方法中还有更多的工作,但现在只需注意getObjectFromS3和publishToSns是从这里调用的(其他地方没有)。
分割方法
除了隔离我们的副作用外,新的handler方法现在也包含了我们大部分的处理逻辑。这看起来可能与我们的目标相反,但这种“粘合”逻辑协调了许多更简单、单一用途的方法,这些方法更容易进行单元测试。在这种情况下,readWeatherEvents方法不再需要访问 S3(或模拟的 S3 客户端)。它的唯一目的是将InputStream反序列化为一组WeatherEvent对象,并处理错误(通过重新抛出RuntimeException来停止 Lambda 函数)。
List<WeatherEvent> readWeatherEvents(InputStream inputStream) {
try (InputStream is = inputStream) {
return Arrays.asList(
objectMapper.readValue(is, WeatherEvent[].class));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
注意,我们现在使用了 Java 的try-with-resources特性来自动关闭输入流。我们还从weatherEventToSnsMessage方法中移除了private关键字,这样我们的测试类可以根据需要访问它们两者。
测试 BulkEventsLambda
现在我们已经重构了代码,让我们为BulkEventsLambda添加一些单元测试。
单元测试
这些测试完全隔离了副作用 —— 我们无需配置或连接到任何 AWS 服务或其他外部依赖项。这种隔离也意味着这些测试执行速度快,仅需几毫秒。尽管BulkEventsLambda相当简单,我们只有几个测试,但是即使以这种风格编写数百个单元测试,也可以在几秒钟内运行。
这是BulkEventsLambda的readWeatherEvents方法的一个单元测试:
public class BulkEventsLambdaUnitTest {
@Test
public void testReadWeatherEvents() {
// Fixture data
InputStream inputStream =
getClass().getResourceAsStream("/bulk_data.json");
// Construct Lambda function class, and invoke
BulkEventsLambda lambda =
new BulkEventsLambda(null, null);
List<WeatherEvent> weatherEvents =
lambda.readWeatherEvents(inputStream);
// Assert
Assert.assertEquals(3, weatherEvents.size());
Assert.assertEquals("Brooklyn, NY",
weatherEvents.get(0).locationName);
Assert.assertEquals(91.0,
weatherEvents.get(0).temperature, 0.0);
Assert.assertEquals(1564428897L,
weatherEvents.get(0).timestamp, 0);
Assert.assertEquals(40.7,
weatherEvents.get(0).latitude, 0.0);
Assert.assertEquals(-73.99,
weatherEvents.get(0).longitude, 0.0);
Assert.assertEquals("Oxford, UK",
weatherEvents.get(1).locationName);
Assert.assertEquals(64.0,
weatherEvents.get(1).temperature, 0.0);
Assert.assertEquals(1564428897L,
weatherEvents.get(1).timestamp, 0);
Assert.assertEquals(51.75,
weatherEvents.get(1).latitude, 0.0);
Assert.assertEquals(-1.25,
weatherEvents.get(1).longitude, 0.0);
Assert.assertEquals("Charlottesville, VA",
weatherEvents.get(2).locationName);
Assert.assertEquals(87.0,
weatherEvents.get(2).temperature, 0.0);
Assert.assertEquals(1564428897L,
weatherEvents.get(2).timestamp, 0);
Assert.assertEquals(38.02,
weatherEvents.get(2).latitude, 0.0);
Assert.assertEquals(-78.47,
weatherEvents.get(2).longitude, 0.0);
}
}
为了方便起见,我们从磁盘上的 JSON 文件中读取输入数据。然后我们创建了一个BulkEventsLambda的实例 —— 请注意,我们只是简单地传入null作为 SNS 和 S3 客户端,因为在这个测试中它们根本不需要。调用了readWeatherEvents方法,并断言它产生了正确的对象。
我们甚至可以用更少的代码来测试失败的情况:
public class BulkEventsLambdaUnitTest {
@Rule
public ExpectedException thrown = ExpectedException.none();
@Rule
public EnvironmentVariables environment = new EnvironmentVariables();
@Test
public void testReadWeatherEventsBadData() {
// Fixture data
InputStream inputStream =
getClass().getResourceAsStream("/bad_data.json");
// Expect exception
thrown.expect(RuntimeException.class);
thrown.expectCause(
CoreMatchers.instanceOf(InvalidFormatException.class));
thrown.expectMessage(
"Can not deserialize value of type java.lang.Long from String");
// Invoke
BulkEventsLambda lambda = new BulkEventsLambda(null, null);
lambda.readWeatherEvents(inputStream);
}
}
这里我们使用了一个Junit 规则,来断言我们的方法是否抛出了预期类型的异常。
就单元测试而言,这些都是简单有效的。对于更复杂的 Lambda 函数,我们可能会有数十个这样的测试,以测试尽可能多的逻辑路径和边缘情况。
功能测试
与单元测试类似,我们希望我们的功能测试在不连接到 AWS 的情况下运行。然而,与单元测试不同的是,我们希望将 Lambda 函数作为单个组件进行测试,这意味着我们必须让我们的代码认为它正在与云端通信!为了完成这种欺骗和欺骗的壮举,我们将使用 Mockito 来构建 AWS SDK 客户端的“模拟”实例,配置为返回预先安排的响应以响应方法调用。例如,如果我们的代码调用 S3 客户端的getObject方法,我们的模拟将返回一个包含固定测试数据的S3Object。
这是一个“正常路径”的功能测试:
public class BulkEventsLambdaFunctionalTest {
@Test
public void testHandler() throws IOException {
// Set up mock AWS SDK clients
AmazonSNS mockSNS = Mockito.mock(AmazonSNS.class);
AmazonS3 mockS3 = Mockito.mock(AmazonS3.class);
// Fixture S3 event
S3Event s3Event = objectMapper
.readValue(getClass()
.getResourceAsStream("/s3_event.json"), S3Event.class);
String bucket =
s3Event.getRecords().get(0).getS3().getBucket().getName();
String key =
s3Event.getRecords().get(0).getS3().getObject().getKey();
// Fixture S3 return value
S3Object s3Object = new S3Object();
s3Object.setObjectContent(
getClass().getResourceAsStream(String.format("/%s", key)));
Mockito.when(mockS3.getObject(bucket, key)).thenReturn(s3Object);
// Fixture environment
String topic = "test-topic";
environment.set(BulkEventsLambda.FAN_OUT_TOPIC_ENV, topic);
// Construct Lambda function class, and invoke handler
BulkEventsLambda lambda = new BulkEventsLambda(mockSNS, mockS3);
lambda.handler(s3Event);
// Capture outbound SNS messages
ArgumentCaptor<String> topics =
ArgumentCaptor.forClass(String.class);
ArgumentCaptor<String> messages =
ArgumentCaptor.forClass(String.class);
Mockito.verify(mockSNS,
Mockito.times(3)).publish(topics.capture(),
messages.capture());
// Assert
Assert.assertArrayEquals(
new String[]{topic, topic, topic},
topics.getAllValues().toArray());
Assert.assertArrayEquals(new String[]{
"{\"locationName\":\"Brooklyn, NY\",\"temperature\":91.0,"
+ "\"timestamp\":1564428897,\"longitude\":-73.99,"
+ "\"latitude\":40.7}",
"{\"locationName\":\"Oxford, UK\",\"temperature\":64.0,"
+ "\"timestamp\":1564428898,\"longitude\":-1.25,"
+ "\"latitude\":51.75}",
"{\"locationName\":\"Charlottesville, VA\",\"temperature\":87.0,"
+ "\"timestamp\":1564428899,\"longitude\":-78.47,"
+ "\"latitude\":38.02}"
}, messages.getAllValues().toArray());
}
}
首先要注意的是,这个测试比我们的单元测试长得多。大部分额外的代码用于设置模拟对象并配置环境,使得我们的 Lambda 函数的handler方法认为自己在云中运行。
第二点需要注意的是,我们正在从磁盘上的文件中读取输入数据。s3_event.json是使用此sam命令生成的文件:
$ sam local generate-event s3 put > src/test/resources/s3_event.json
然后,我们将key字段更改为引用另一个本地文件,bulk_data.json,它表示将存储在 S3 上的天气数据:
{
"Records": [
{
...
"s3": {
"bucket": {
"name": "example-bucket",
...
},
"object": {
"key": "bulk_data.json",
}
}
}
]
}
当调用s3.getObject方法时,我们的模拟 S3 客户端返回bulk_data.json文件的内容,而我们的 Lambda 函数对此一无所知。
最后,我们想要断言BulkEventsLambda向 SNS 发布消息,但实际上并不向 AWS 发送消息。在这里,我们使用我们的模拟 SNS 客户端,并捕获传递给sns.publish方法的参数。如果该方法以正确的参数调用了预期次数,我们的测试就会通过。
另一个功能测试断言,如果 Lambda 函数接收到不良输入数据,则会引发异常。最后一个测试断言,如果未设置FAN_OUT_TOPIC环境变量,则会引发异常。
这些功能测试编写起来更复杂,运行时间稍长,但它们确保了BulkEventsLambda在 Lambda 运行时调用handler函数并传递S3Event对象时的行为符合我们的预期。
端到端测试
通过我们的一系列单元测试和功能测试所获得的信心,我们可以将最复杂和成本最高的测试方法集中在应用程序的关键路径上。我们还可以利用基础设施即代码的方法部署我们的无服务器应用程序及其基础设施的完整版本到 AWS,专门用于运行端到端测试。当测试成功完成时,我们将进行清理和拆除。
要运行端到端测试,我们只需执行mvn verify命令。这使用了 Maven Failsafe 插件,它会查找以**IT*结尾的测试类,并使用 JUnit 运行它们。在这种情况下,IT 代表集成测试,但这只是 Maven 的命名惯例,我们可以配置 Failsafe 插件使用不同的后缀。
对于我们的端到端测试,我们确切地按照其在生产中的使用方式来运行我们的应用程序。我们将一个 JSON 文件上传到 S3 存储桶,然后断言Single EventLambda生成了正确的 CloudWatch Logs 输出。从测试的角度来看,我们的无服务器应用程序就是一个黑盒子。
这是测试方法的主体:
@Test
public void endToEndTest() throws InterruptedException {
String bucketName = resolvePhysicalId("PipelineStartBucket");
String key = UUID.randomUUID().toString();
File file = new File(getClass().getResource("/bulk_data.json").getFile());
// 1\. Upload bulk_data file to S3
s3.putObject(bucketName, key, file);
// 2\. Check for executions of SingleEventLambda
Thread.sleep(30000);
String singleEventLambda = resolvePhysicalId("SingleEventLambda");
Set<String> logMessages = getLogMessages(singleEventLambda);
Assert.assertThat(logMessages, CoreMatchers.hasItems(
"WeatherEvent{locationName='Brooklyn, NY', temperature=91.0, "
+ "timestamp=1564428897, longitude=-73.99, latitude=40.7}",
"WeatherEvent{locationName='Oxford, UK', temperature=64.0, "
+ "timestamp=1564428898, longitude=-1.25, latitude=51.75}",
"WeatherEvent{locationName='Charlottesville, VA', temperature=87.0, "
+ "timestamp=1564428899, longitude=-78.47, latitude=38.02}"
));
// 3\. Delete object from S3 bucket (to allow a clean CloudFormation teardown)
s3.deleteObject(bucketName, key);
// 4\. Delete Lambda log groups
logs.deleteLogGroup(
new DeleteLogGroupRequest(getLogGroup(singleEventLambda)));
String bulkEventsLambda = resolvePhysicalId("BulkEventsLambda");
logs.deleteLogGroup(
new DeleteLogGroupRequest(getLogGroup(bulkEventsLambda)));
}
从这个例子中有几个值得注意的点:
-
测试解析了 S3 存储桶的实际名称(在 AWS 术语中称为“物理 ID”),该技术用于资源发现非常有用,因为它允许我们部署命名堆栈而不明确指定资源的名称(或者将堆栈名称用作资源名称的一部分)。这意味着我们可以在同一账户和区域中多次部署同一应用,并使用不同的堆栈名称进行 CloudFormation 堆栈部署。
-
为了简单起见,我们的测试在检查
SingleEventLambda是否执行之前简单地休眠 30 秒。另一种方法是主动轮询 CloudWatch 日志,这种方法更可靠,但显然更复杂。 -
我们在测试方法结束时清理一些资源。我们这样做是为了在测试失败时,这些资源仍然可用于我们对测试失败的调查。如果我们使用了 JUnit 的
@After功能,即使测试失败,也会进行清理,从而阻碍调查工作。
现在你已经看到了测试方法本身,让我们看看如何设置和拆除测试基础设施。我们需要确保 S3 存储桶、SNS 主题和 Lambda 函数已经准备好以便我们的测试运行,但我们不希望单独创建这些资源。相反,我们想使用与生产环境相同的 SAM template.yaml文件。
对于这个例子,我们使用 Maven 的“exec”插件来连接到构建生命周期的“pre-integration”阶段,这将在端到端测试之前执行。在这里使用 Maven 并不可怕。你可以很容易地使用简单的 Shell 脚本或 Makefile 来做同样的事情。重要的是我们使用与生产环境相同的template.yaml文件,如果可能的话,使用相同的 AWS CLI 命令来部署我们的应用。
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<executions>
<execution>
<id>001-sam-deploy</id>
<phase>pre-integration-test</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<basedir>${project.parent.basedir}</basedir>
<executable>sam</executable>
<arguments>
<argument>deploy</argument>
<argument>--s3-bucket</argument>
<argument>${integration.test.code.bucket}</argument>
<argument>--stack-name</argument>
<argument>${integration.test.stack.name}</argument>
<argument>--capabilities</argument>
<argument>CAPABILITY_IAM</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
需要多行 XML 来描述,但在此示例中,我们使用与我们在第五章中使用的相同参数调用 SAM CLI 二进制文件。
${integration.test.code.bucket}和${integration.test.stack.name}属性来自顶层pom.xml文件,并且定义如下:
<properties>
<maven.build.timestamp.format>
yyyyMMddHHmmss
</maven.build.timestamp.format>
<integration.test.code.bucket>
${env.CF_BUCKET}
</integration.test.code.bucket>
<integration.test.stack.name>
chapter6-it-${maven.build.timestamp}
</integration.test.stack.name>
</properties>
我们的 Maven 过程使用${integration.test.code.bucket}的值来填充$CF_BUCKET环境变量的值,我们在前几章中已经使用过它。${maven.build.timestamp.format} pom.xml 文档告诉 Maven 构建一个人类可读的数值时间戳,然后我们将其用作${integration.test.stack.name}的一部分。这为我们提供了一个(几乎)唯一的 CloudFormation 堆栈名称,因此可以在同一 AWS 账户和区域中同时运行多个端到端测试(只要它们不是在同一秒钟开始!)。
在这个 Maven 配置中看不到任何 AWS 凭据。由 Maven 的“exec”插件启动的进程将自动获取环境变量,因此这将使用我们在过去几章中一直在使用的 AWS 环境变量,而无需我们进一步配置。
在大多数情况下,您应该为您的测试环境使用单独的 AWS 帐户,以隔离测试基础设施和数据。要在这里实现这一点,只需通过环境变量提供不同的 AWS 凭据集。
在我们的端到端测试运行后,CloudFormation 栈的拆除工作以相同的方式进行,作为 Maven 的“post-integration-test”生命周期阶段的一部分:
<execution>
<id>001-cfn-delete</id>
<phase>post-integration-test</phase>
<goals>
<goal>exec</goal>
</goals>
<configuration>
<basedir>${project.parent.basedir}</basedir>
<executable>aws</executable>
<arguments>
<argument>cloudformation</argument>
<argument>delete-stack</argument>
<argument>--stack-name</argument>
<argument>${integration.test.stack.name}</argument>
</arguments>
</configuration>
</execution>
现在我们已经达到了测试金字塔的顶端。端到端测试带来了很多价值:它部署并运行整个应用程序。它测试了关键路径,就像在生产环境中执行的那样。然而,随着这个价值而来的是相当高的成本——我们需要大量额外的配置和设置以及拆除代码,以确保测试可以重复运行,并且不倾向于特定的 AWS 帐户或区域。尽管有这些努力,这个测试仍然容易受到供应商故障、环境变化以及在全球网络上操作固有的不确定行为的影响。
换句话说,与单元测试和功能测试相比,我们的端到端测试更加脆弱且维护成本高昂。因此,您应尽量少写端到端测试,并且更多地依赖成本较低的测试来全面测试您的应用程序。
本地云测试
多年来,一个良好的开发工作流的固有和不可动摇的特性是能够在不触及任何外部资源的情况下在本地运行整个应用程序或系统。对于传统的桌面或服务器应用程序,这可能意味着只运行应用程序本身,或者可能包括应用程序和数据库。对于 web 应用程序,需求列表可能包括反向代理、Web 服务器和作业队列。
但是,当我们开始使用供应商管理的云服务时会发生什么呢?我们最初的反应可能是尝试实现与以前相同的完全本地开发工作流程,使用像 localstack 和 sam local(“sam local invoke”)这样的工具。这种方法起初可能看似可行,但很快就会与云优先架构相冲突,在这种架构中,我们希望充分利用由云供应商提供的可扩展、可靠、完全托管的服务。最重要的是,我们不希望将服务选择限制为仅允许我们的开发工作流程。这是本末倒置的问题!
在一个由供应商管理的云服务的世界里,完全本地开发存在哪些困难?根本问题是保真度:一个服务的本地版本(比如 S3 或 DynamoDB 或 Lambda)要具有与云版本相同的属性是完全不可能的。即使供应商(在这种情况下是 AWS)提供了本地模拟,它仍然会存在以下至少一些问题:
-
缺少功能
-
不同的(或者不存在的)控制平面行为(例如,创建 DynamoDB 表)
-
不同的扩展行为
-
不同的延迟(例如,与云服务相比,本地模拟的延迟非常低)
-
不同的故障模式
-
不同的(或者没有)安全控制
一次又一次地遇到这些问题后,我们倡导本章中采用的务实测试方法。我们广泛依赖单元测试来验证特定功能片段的行为,并且在开发各个 Lambda 函数时使用这些测试来快速迭代。功能测试使用模拟或存根来代替 AWS SDK 客户端和其他外部依赖项来执行 Lambda 函数的功能。最后,几个完整的端到端测试让我们在云中执行整个应用程序,使用相同的 SAM 基础设施模板和 CLI 命令,就像我们在生产环境中使用的那样。
云测试环境
对于我们在本章中描述的单元测试和功能测试,具有 Java、Maven 和您喜欢的 IDE 的本地环境将非常满足要求。对于端到端测试,您需要访问一个 AWS 账户。这对于单个开发人员在隔离环境中工作是非常简单的,但是当作为较大团队的一部分工作时,情况可能会变得更加复杂。
当您作为较大团队的一部分工作时,最佳的云资源工作方式是什么?我们发现一个好的起点是让每个开发人员拥有一个隔离的开发账户,并且整个团队为每个共享集成环境(例如,开发,测试,暂存)拥有一个账户。当依赖于真正共享的资源(如数据库或 S3 存储桶)时,情况可能会变得棘手,但总的来说,在快速开发过程中保持隔离可以防止从意外删除到资源争用等一系列问题。
严格的基础设施即代码方法使得在多个账户中管理资源变得更加容易。更进一步,基础设施即代码方法设置构建流水线意味着在新账户中部署无服务器应用可能就像部署一个代表构建流水线的单个 CloudFormation 栈那样简单,然后该栈将获取最新的源代码并部署应用程序。
概要
测试无服务器应用与测试传统应用没有本质区别——关键是找到覆盖范围、复杂性、成本和价值的平衡,并将我们的测试方法扩展到团队中使用。
在本章中,您学习到了测试金字塔如何指导您在无服务器应用程序中的测试策略。我们重构了我们的 Lambda 代码,以便轻松进行单元测试,并且能够在没有网络连接的情况下进行功能测试。端到端测试展示了基础设施即代码方法的有效性,以及测试分布式应用程序固有的高复杂度。
您看到了试图在本地运行云服务面临各种问题,特别是在本地无法达到的准确性。如果您想测试基于云的应用程序,最终您必须在云中实际运行它!最后,为了团队有效地工作,开发人员应该拥有隔离的云账户,团队应该有共享的集成环境。
通过测试,我们现在有信心我们的应用程序会按预期行事。在下一章中,我们将探讨如何通过日志记录、度量和跟踪来了解我们部署的应用程序的行为。
练习
本章的代码和测试涉及 S3 和 SNS。为第五章的应用编写一个集成测试,该测试使用 Java 从部署的 API Gateway 发出 HTTP 调用,然后断言其响应(及副作用)。如果有余力,可以使用Java 11 的新原生 HTTP 客户端!
第七章:日志记录、指标和跟踪
在本章中,我们将探讨如何通过日志记录、指标和跟踪来增强 Lambda 函数的可观察性。通过日志记录,您将学习如何从 Lambda 函数执行期间发生的特定事件中获取信息。平台和业务指标将揭示我们无服务器应用程序的运行健康状态。最后,分布式跟踪将让您看到请求如何流向组成我们架构的不同托管服务和组件。
我们将使用第五章的天气 API 来探索 AWS 无服务器应用程序中可用的广泛的日志记录、指标和跟踪选项。类似于我们在第六章中对数据管道所做的更改,您将注意到天气 API 的 Lambda 函数已经重构为使用aws-lambda-java-events库。
日志记录
根据以下日志消息,我们能推断出生成它的应用程序的状态是什么?
Recorded a temperature of 78 F from Brooklyn, NY
我们知道一些数据的值(温度测量和位置),但不知道其他太多。这些数据是何时接收或处理的?在我们应用程序的更大上下文中,哪个请求生成了这些数据?哪个 Java 类和方法产生了这条日志消息?我们如何将其与其他可能相关的日志消息进行关联?
从本质上讲,这是一条没有帮助的日志消息。它缺乏上下文和具体性。如果像这样的消息被重复数百或数千次(可能使用不同的温度或位置值),它将失去意义。当我们的日志消息是散文(例如句子或短语)时,如果不使用正则表达式或模式匹配,解析它们会更加困难。
在探索 Lambda 函数中的日志记录时,请记住高价值日志消息的几个属性:
数据丰富
我们希望捕获尽可能多的数据,既可行又具有成本效益。我们拥有的数据越多,就越不需要在事后返回并添加更多日志记录。
高基数
使特定日志消息唯一的数据值尤为重要。例如,像请求 ID 这样的字段将具有大量唯一值,而像线程优先级这样的字段可能不会(尤其是在单线程 Lambda 函数中)。
可机读
使用 JSON 或其他易于机器读取的标准化格式(无需自定义解析逻辑)将通过下游工具简化分析。
CloudWatch Logs
CloudWatch Logs 正如其名称所示,是 AWS 的日志收集、聚合和处理服务。通过各种机制,它接收来自应用程序和其他 AWS 服务的日志数据,并通过 Web 控制台以及 API 使这些数据可访问。
CloudWatch Logs 的两个主要组织组件是日志组和日志流。日志组是一组相关日志流的顶层分组。日志流是一系列日志消息的列表,通常来自单个应用程序或函数实例。
Lambda 和 CloudWatch Logs
在无服务器应用程序中,默认情况下每个 Lambda 函数有一个日志组,其中包含许多日志流。每个日志流包含特定函数实例的所有函数调用的日志消息。回顾第三章,Lambda 运行时会捕获写入标准输出(Java 中的System.out)或标准错误(System.err)的任何内容,并将该信息转发给 CloudWatch Logs。
Lambda 函数的日志输出如下所示:
START RequestId: 6127fe67-a406-11e8-9030-69649c02a345
Version: $LATEST
Recorded a temperature of 78 F from Brooklyn, NY
END RequestId: 6127fe67-a406-11e8-9030-69649c02a345
REPORT RequestId: 6127fe67-a406-11e8-9030-69649c02a345
Duration: 2001.52 ms
Billed Duration: 2000 ms
Memory Size: 512 MB
Max Memory Used: 51 MB
START、END和REPORT行是 Lambda 平台自动添加的。特别感兴趣的是带有 UUID 值标记为RequestId的值。这是每次请求的Lambda 函数调用都唯一的标识符。日志中重复的RequestId值最常见的来源是当我们的函数出现错误并且平台重试执行时(参见“错误处理”)。除此之外,由于 Lambda 平台(像大多数分布式系统一样)具有“至少一次”语义,即使没有错误,平台偶尔也可能多次使用相同的RequestId值调用函数(我们在“至少一次传递”中研究了这种行为)。
LambdaLogger
上面START和END行之间的日志行是使用System.out.println生成的。这是从简单的 Lambda 函数开始记录的一个完全合理的方法,但还有几种其他选项可以提供合理的行为和定制的组合。其中的第一种选择是 AWS 提供的LambdaLogger类。
此记录器通过 Lambda Context对象访问,因此我们需要修改我们的WeatherEvent Lambda 处理函数以包括该参数,如下所示:
public class WeatherEventLambda {
…
public APIGatewayProxyResponseEvent handler(
APIGatewayProxyRequestEvent request,
Context context
) throws IOException {
context.getLogger().log("Request received");
…
}
}
此日志语句的输出看起来就像是使用System.out.println生成的一样:
START RequestId: 4f40a12b-1112-4b3a-94a9-89031d57defa Version: $LATEST
Request received
END RequestId: 4f40a12b-1112-4b3a-94a9-89031d57defa
当输出包含换行符(例如堆栈跟踪)时,您可以看到LambdaLogger与System println方法之间的区别:
public class WeatherEventLambda {
…
public APIGatewayProxyResponseEvent handler(
APIGatewayProxyRequestEvent request,
Context context
) throws IOException {
StringWriter stringWriter = new StringWriter();
Exception e = new Exception();
e.printStackTrace(new PrintWriter(stringWriter));
context.getLogger().log(stringWriter);
…
}
}
使用System.err.println打印的堆栈跟踪会生成多行 CloudWatch Logs 条目(图 7-1)。

图 7-1. 使用 System.err.println 在 CloudWatch Logs 中输出的堆栈跟踪
使用 LambdaLogger,该堆栈跟踪是单个条目(可以在 Web 控制台中展开,如图 7-2 所示)。
仅这一特性就足以使用LambdaLogger而不是System.out.println或System.err.println,特别是在打印异常堆栈跟踪时。

图 7-2. 使用 LambdaLogger 在 CloudWatch Logs 中输出堆栈跟踪
Java 日志框架
LambdaLogger 对于简单的 Lambda 函数通常已经足够了。然而,正如本章后面将要介绍的,定制日志输出以满足特定需求,比如捕获业务指标或生成应用程序警报,通常更为有用。虽然可以使用 Java 的标准库(比如 String.format)生成这种类型的输出,但使用像 Log4J 或 Java Commons Logging 这样的现有日志框架会更容易。这些框架提供了诸如日志级别、基于属性或文件的配置以及各种输出格式等便利功能。它们还可以轻松地在每条日志消息中包含相关的系统和应用程序上下文(如 AWS 请求 ID)。
当 Lambda 首次推出时,AWS 提供了一个非常旧且不支持的 Log4J 版本的自定义 appender。在基于 Lambda 的无服务器应用程序中使用这个旧版本的流行日志框架使得集成新的日志功能变得困难。因此,我们花费了相当多的时间和精力为 Lambda 函数构建了一个更现代化的日志解决方案,称为 lambda-monitoring,它使用了 SLF4J 和 Logback。
然而,AWS 现在提供了一个库,其中包含一个自定义的日志 appender,它在底层使用 LambdaLogger,适用于最新版本的 Log4J2。我们现在建议按照 AWS 在 Lambda 文档的 Java logging section 中概述的方式进行设置。设置这种日志记录方法只需添加几个额外的依赖项、添加一个 log4j2.xml 配置文件,然后在我们的代码中使用 org.apache.logging.log4j.Logger。
这里是我们的 Weather API 项目的 pom.xml 添加部分:
<dependencies>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-lambda-java-log4j2</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.12.1</version>
</dependency>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-api</artifactId>
<version>2.12.1</version>
</dependency>
</dependencies>
log4j2.xml 配置文件对于使用过 Log4J 的人来说应该很熟悉。它使用 AWS 提供的 Lambda appender,并允许自定义日志模式:
<?xml version="1.0" encoding="UTF-8"?>
<Configuration packages="com.amazonaws.services.lambda.runtime.log4j2">
<Appenders>
<Lambda name="Lambda">
<PatternLayout>
<pattern>
%d{yyyy-MM-dd HH:mm:ss} %X{AWSRequestId} %-5p %c{1}:%L—%m%n
</pattern>
</PatternLayout>
</Lambda>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="Lambda"/>
</Root>
</Loggers>
</Configuration>
注意日志模式包括 Lambda 请求 ID(%X{AWSRequestId})。在我们之前的日志示例中,大多数输出行中并没有包含该请求 ID —— 它只在调用的开头和结尾出现。通过在每一行中包含它,我们可以将每个输出片段与特定请求关联起来,这在使用其他工具检查这些日志或进行离线分析时非常有帮助。
在我们的 Lambda 函数中,我们设置了日志记录器并使用其 error 方法记录了一个 ERROR level 的消息以及异常信息:
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class WeatherEventLambda {
private static Logger logger = LogManager.getLogger();
…
public APIGatewayProxyResponseEvent handler(
APIGatewayProxyRequestEvent request, Context context)
throws IOException {
Exception e = new Exception("Test exception");
logger.error("Log4J logger", e);
...
}
}
Lambda Log4J2 appender 的输出显示在 图 7-3 中。

图 7-3. 使用 Log4J2 在 CloudWatch Logs 中输出堆栈跟踪
它包括时间戳、AWS 请求 ID、日志级别(本例中为 ERROR)、调用日志方法的文件和行,以及正确格式化的异常。我们可以使用 Log4J 提供的桥接库将其他日志框架的日志消息路由到我们的 Log4J appender。这种技术最有用的应用之一,至少对于我们的 WeatherEventLambda 来说,是深入了解使用 Apache Commons Logging(以前称为 Jakarta Commons Logging 或 JCL)的 AWS Java SDK 的行为。
首先,我们将 Log4J JCL 桥接库添加到我们 pom.xml 文件的 dependencies 部分:
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-jcl</artifactId>
<version>2.12.1</version>
</dependency>
接下来,我们在 log4j2.xml 文件的 Loggers 部分启用调试日志:
<Loggers>
<Root level="debug">
<AppenderRef ref="Lambda"/>
</Root>
</Loggers>
现在我们可以看到来自 AWS Java SDK 的详细日志信息(参见图 7-4)。

图 7-4. AWS SDK 的详细调试日志
我们可能不希望始终获取此信息,但是如果出现问题,调试时这将非常有用——在本例中,我们确切地看到了 DynamoDB PutItem API 调用的正文内容。
通过使用更复杂的日志框架,我们可以更深入地了解围绕日志输出的上下文。我们可以使用请求 ID 将不同 Lambda 请求的日志分开。使用日志级别,我们可以了解某些日志行是否表示错误,或者关于应用程序状态的警告,或者其他行是否可以忽略(或稍后分析),因为它们包含大量但不太相关的调试信息。
结构化日志
如前一节所述,我们的日志系统捕获了大量有用的信息和上下文,准备用于检查和改进我们的应用程序。
然而,当我们需要从这些大量的日志数据中提取某些值时,通常很难访问,查询起来很棘手,而且由于实际消息仍然基本上是自由形式的文本,通常必须使用一系列难以理解的正则表达式来精确查找您正在寻找的行。虽然有一些标准化的格式已经为某些空格或制表符分隔字段的值建立了约定,但不可避免地,正则表达式会在下游流程和工具中出现。
我们可以使用一种称为结构化日志的技术,而不是继续使用自由文本方式,标准化我们的日志输出,并通过标准查询语言轻松搜索所有日志。
以这条 JSON 日志条目为例:
{
"thread": "main",
"level": "INFO",
"loggerName": "book.api.WeatherEventLambda",
"message": {
"locationName": "Brooklyn, NY",
"action": "record",
"temperature": 78,
"timestamp": 1564506117
},
"endOfBatch": false,
"loggerFqcn": "org.apache.logging.log4j.spi.AbstractLogger",
"instant": {
"epochSecond": 1564506117,
"nanoOfSecond": 400000000
},
"contextMap": {
"AWSRequestId": "d814bbbe-559b-4798-aee0-31ddf9235a76"
},
"threadId": 1,
"threadPriority": 5
}
我们可以使用 JSON 路径规范来提取信息,而不是依赖字段顺序。例如,如果我们想提取 temperature 字段,我们可以使用 JSON 路径 .message.temperature。CloudWatch Logs 服务支持在 Web 控制台中进行搜索(参见图 7-5),以及创建 Metric Filters,我们稍后会在本章中讨论。

图 7-5. 使用 JSON Path 表达式在 CloudWatch Logs Web 控制台中进行搜索
Java 中的结构化日志记录
现在我们理解了使用 JSON 格式进行结构化日志记录的好处,不幸的是,在尝试从基于 Java 的 Lambda 函数记录 JSON 时,我们立即遇到了困难。Java 中的 JSON 处理以冗长而出名,为构建日志输出添加大量样板代码似乎不是正确的方式。
幸运的是,我们可以使用 Log4J2 生成 JSON 格式的日志输出(Log4J2 JSONLayout)。以下 log4j2.xml 配置将启用输出到STDOUT的 JSON 格式化输出,这对于我们的 Lambda 函数意味着输出将被发送到 CloudWatch Logs:
<?xml version="1.0" encoding="UTF-8"?>
<Configuration packages="com.amazonaws.services.lambda.runtime.log4j2">
<Appenders>
<Lambda name="Lambda">
<JsonLayout
compact="true"
eventEol="true"
objectMessageAsJsonObject="true"
properties="true"/>
</Lambda>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="Lambda"/>
</Root>
</Loggers>
</Configuration>
在我们的 Lambda 代码中,我们将 Log4J2 日志记录器设置为静态字段:
...
private static Logger logger = LogManager.getLogger();
...
不再像Recorded a temperature of 78 F from Brooklyn, NY这样记录字符串,我们将构建一个包含键和值的Map,如下所示:
HashMap<Object, Object> message = new HashMap<>();
message.put("action", "record");
message.put("locationName", weatherEvent.locationName);
message.put("temperature", weatherEvent.temperature);
message.put("timestamp", weatherEvent.timestamp);
logger.info(new ObjectMessage(message));
这是那条日志行的输出:
{
"thread": "main",
"level": "INFO",
"loggerName": "book.api.WeatherEventLambda",
"message": {
"locationName": "Brooklyn, NY",
"action": "record",
"temperature": 78,
"timestamp": 1564506117
},
"endOfBatch": false,
"loggerFqcn": "org.apache.logging.log4j.spi.AbstractLogger",
"instant": {
"epochSecond": 1564506117,
"nanoOfSecond": 400000000
},
"contextMap": {
"AWSRequestId": "d814bbbe-559b-4798-aee0-31ddf9235a76"
},
"threadId": 1,
"threadPriority": 5
}
值得注意的一个警告是,与我们的应用程序相关的信息在message键下,但淹没在其他输出中。不幸的是,大部分输出都是 Log4J2 JsonLayout 固有的,因此我们无法在没有一些工作的情况下移除它。正如我们将在下一节看到的那样,然而,使用 JSON 格式化的日志事件的好处远远超过增加的冗长。
CloudWatch Logs Insights
结构化日志使我们能够使用更复杂的工具来分析我们的日志,无论是实时还是事后。虽然原始的 CloudWatch Logs Web 控制台对使用 JSONPath 表达式查询日志数据有一定支持(如前所示),但真正复杂的分析直到最近才需要直接下载日志或将其转发到另一个服务。
CloudWatch Logs Insights 是 CloudWatch Logs 生态系统的新成员,提供强大的搜索引擎和专门的查询语言,非常适合分析结构化日志。继续我们之前章节的示例 JSON 日志行,现在让我们假设我们有一个月的每小时数据已经记录到 CloudWatch Logs。我们可能希望对该日志数据进行一些快速分析,查看每天的最低、平均和最高温度,但仅限于 Brooklyn。
以下 CloudWatch Logs Insights 查询正好实现了这一点:
filter message.action = "record"
and message.locationName = "Brooklyn, NY"
| fields date_floor(concat(message.timestamp, "000"), 1d) as Day,
message.temperature
| stats min(message.temperature) as Low,
avg(message.temperature) as Average,
max(message.temperature) as High by Day
| order by Day asc
让我们逐行查看这个查询在做什么:
-
首先,我们将数据筛选到具有
message.action字段中值为record和message.locationName字段中值为“Brooklyn, NY”的日志事件。 -
在第二行中,我们提取了
message.timestamp字段,并在传递给date_floor方法之前在末尾添加了三个零,这样可以用给定日期的最早时间戳值替换时间戳值(因为需要添加零以表示毫秒)。我们还提取了message.temperature字段。 -
第三行计算了
message.temperature字段在一天的日志事件中的最小值、平均值和最大值。 -
最后一行按天对数据进行排序,从最早的一天开始。
我们可以在 CloudWatch Logs Insights Web 控制台中看到此查询的结果(参见图 7-6)。

图 7-6. CloudWatch Logs Insights
这些结果可以导出为 CSV 文件,或使用内置的可视化工具绘制图表(参见图 7-7)。
关于 CloudWatch Logs Insights,需要记住一些注意事项。首先,尽管该工具可以有效地用于对日志数据进行即席探索,但目前还不能直接生成额外的自定义指标或其他数据产品(尽管我们将看到如何从 JSON 日志数据生成自定义指标的方法!)。但是,它提供了一个 API 接口用于运行查询和访问结果,因此可以自行解决问题。最后但同样重要的是,查询的定价是基于扫描的数据量。

图 7-7. CloudWatch Logs Insights 可视化
指标
日志消息是对系统在特定时间点状态的离散快照。而指标则旨在在一段时间内产生系统状态的更高级别视图。虽然单个指标是时间点的快照,但一系列指标显示了系统在运行过程中的趋势和行为,长时间内的表现。
CloudWatch 指标
CloudWatch 指标是 AWS 的指标存储服务。它从大多数 AWS 服务接收指标。在最基本的层次上,指标只是一组按时间排序的数据点。例如,在某一时刻,传统服务器的 CPU 负载可能为 64%。几秒钟后,它可能是 65%。在给定的时间段内,可以计算指标的最小值、最大值和其他统计数据(例如百分位数)。
指标按命名空间(例如 /aws/lambda)和指标名称(例如 WeatherEventLambda)分组。指标也可以有相关的维度,这些维度只是更细粒度的标识符,例如在跟踪非服务器应用程序中的应用程序错误的指标中,一个维度可能是服务器 IP。
CloudWatch 指标是监控 AWS 服务及我们自己应用行为的主要工具。
Lambda 平台指标
AWS 提供了许多功能和账户级别的指标,用于监控无服务器应用程序的整体健康和可用性。我们将这些称为平台指标,因为它们由 Lambda 平台提供,无需额外配置。
对于各个函数,Lambda 平台提供以下指标:
调用次数
函数被调用的次数(无论成功与否)。
限流
平台限流平台尝试函数调用次数。
Errors
函数调用返回错误次数。
Duration
函数开始执行到停止之间的“经过的墙钟时间”的毫秒数。此指标还支持百分位数。
ConcurrentExecutions
特定时间点函数的并发执行次数。
对于由 Kinesis 或 DynamoDB 流事件源调用的函数,IteratorAge指标跟踪函数接收记录批次与该批次中最后一条记录写入流之间的毫秒数。该指标有效地显示了 Lambda 函数在特定时间点在流中落后的程度。
对于配置了死信队列(DLQ)的函数,当函数无法将消息写入 DLQ 时会增加DeadLetterErrors指标(有关 DLQ 的更多信息,请参见“错误处理”)。
此外,平台会跨账户和地区聚合Invocations、Throttles、Errors、Duration和ConcurrentExecutions这些指标。UnreservedConcurrentExecutions指标会聚合账户和地区中所有未指定自定义并发限制的函数的并发执行次数。
Lambda 平台生成的指标还包括以下额外维度:FunctionName、Resource(例如函数版本或别名)和ExecutedVersion(用于别名调用,在下一章中讨论)。提到的每个函数级指标都可以具有这些维度。
业务指标
平台指标和应用程序日志是监控无服务器应用程序的重要工具,但在评估我们的应用程序是否正确和完全执行其业务功能方面并不有用。例如,捕获 Lambda 执行持续时间的指标有助于捕获意外的性能问题,但它并不告诉我们 Lambda 函数(或整个应用程序)是否正确处理了客户事件。另一方面,捕获为我们最受欢迎的位置成功处理的天气事件数量的指标告诉我们,无论底层技术实现如何,应用程序(或至少与处理天气事件相关的部分)都在正确工作。
这些业务指标不仅可以作为我们业务逻辑的脉搏检测,也可以作为不依赖于具体实现或平台的聚合指标。以我们之前的例子为例,如果 Lambda 执行时间增加了,这意味着什么?我们只是在处理更多的数据,还是配置或代码变更影响了函数的性能?这真的重要吗?然而,如果我们的应用处理的天气事件数量意外减少,我们知道有些问题,并且需要立即调查。
在传统应用中,我们可能直接使用 CloudWatch 指标 API,通过使用PutMetricData API 调用在生成这些自定义指标时主动推送。更复杂的应用程序可能会定期以小批量推送指标。
Lambda 函数有两个特性使PutMetricData方法难以使用。首先,Lambda 函数可以快速扩展到数百或数千个并发执行。CloudWatch 指标 API 会对PutMetricData调用进行限流(CloudWatch 限制),因此,试图持久化重要数据的行为可能导致指标丢失。其次,由于 Lambda 函数是短暂的,几乎没有机会或好处可以在单个执行期间批处理指标。不能保证后续执行会在相同的运行时实例中进行,因此跨调用进行批处理是不可靠的。
幸运的是,CloudWatch 指标有两个功能以可扩展且可靠的方式处理此情况,通过完全将 CloudWatch 指标数据的生成移出 Lambda 执行的过程。第一个和最新的功能称为CloudWatch 嵌入式指标格式,它使用特殊的日志格式自动创建指标。这种特殊的日志格式目前 Log4J 还不支持(除非进行大量额外的工作),因此我们不会在这里使用它,但在其他情况下,这是在 Lambda 中生成指标的首选方法。
另一个功能,CloudWatch 指标过滤器,也可以使用 CloudWatch 日志数据生成指标。与嵌入式指标格式不同,它可以访问列格式和任意嵌套的 JSON 结构中的数据。这使得它成为我们这种情况的更好选择,因为我们不能轻松地将 JSON 键添加到日志语句的顶层。它通过扫描 CloudWatch 日志并将指标分批推送到 CloudWatch 指标服务来生成指标数据。
我们使用结构化日志记录使得设置度量过滤器变得简单,只需将以下内容添加到我们的template.yaml文件中:
BrooklynWeatherMetricFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Sub "/aws/lambda/${WeatherEventLambda}"
FilterPattern: '{$.message.locationName = "Brooklyn, NY"}'
MetricTransformations:
— MetricValue: "1"
MetricNamespace: WeatherApi
MetricName: BrooklynWeatherEventCount
DefaultValue: "0"
每当 JSON 日志行包含message.locationName字段为“纽约布鲁克林”时,此指标过滤器将增加BrooklynWeatherEventCount指标。我们可以通过 CloudWatch Metrics Web 控制台访问和可视化此指标,也可以像处理常规平台指标一样配置 CloudWatch 告警和操作。
在这个例子中,每次事件发生时我们有效地增加一个计数器,但在适当的情况下也可以(根据捕获日志行的数据)使用实际值。有关更多详情,请参阅MetricFilter MetricTransformation文档。
告警
与所有 CloudWatch 指标一样,我们可以使用数据来建立警报,以便在出现问题时发出警告。至少,我们建议为Errors和Throttles平台指标设置警报,如果不是基于每个帐户的设置,则至少为生产函数设置。
对于由 Kinesis 或 DynamoDB 流事件源触发的函数,IteratorAge指标是函数是否跟上流事件数量的关键指示(这取决于流中的分片数、Lambda 事件源中配置的批量大小、ParallelizationFactor以及 Lambda 函数本身的性能)。
在上一节中我们配置的BrooklynWeatherEventCount指标,以下是关联的 CloudWatch 告警的配置方式。如果该指标值在 60 秒内降至零(表示我们停止接收“纽约布鲁克林”的天气事件),则此告警将通过 SNS 消息提醒我们:
BrooklynWeatherAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
Namespace: WeatherApi
MetricName: BrooklynWeatherEventCount
Statistic: Sum
ComparisonOperator: LessThanThreshold
Threshold: 1
Period: 60
EvaluationPeriods: 1
TreatMissingData: breaching
ActionsEnabled: True
AlarmActions:
— !Ref BrooklynWeatherAlarmTopic
BrooklynWeatherAlarmTopic:
Type: AWS::SNS::Topic
图 7-8 展示了在 CloudWatch Web 控制台中查看该告警的视图。

图 7-8. BrooklynWeatherAlarm CloudWatch 告警
当前告警“触发”时生成的 SNS 消息可用于发送通知电子邮件,或触发像PagerDuty这样的第三方警报系统。
与 Lambda 函数和 DynamoDB 表等应用组件一样,我们强烈建议将 CloudWatch 指标过滤器、告警和所有其他基础设施都保存在与其他所有内容相同的template.yaml文件中。这不仅允许我们利用模板内部引用和依赖关系,还能将我们的指标和告警配置与应用程序紧密地联系在一起。如果您不希望为堆栈的开发版本生成这些运行资源,可以使用CloudFormation 的Conditions功能。
分布式跟踪
到目前为止,我们介绍的度量和日志功能为我们提供了关于单个应用组件(如 Lambda 函数)的洞察力。然而,在涉及到许多组件的复杂应用中,我们很难将日志输出和度量数据拼凑成一个请求流,例如涉及 API Gateway、两个 Lambda 函数和 DynamoDB 表的情况。
幸运的是,AWS 的分布式追踪服务 X-Ray 正好可以处理这种用例。该服务基本上会为进入或由我们的应用程序生成的事件“打标记”,并在这些事件流经我们的应用程序时进行跟踪。当标记的事件触发 Lambda 函数时,X-Ray 可以跟踪 Lambda 函数所进行的外部服务调用,并将有关这些调用的信息添加到跟踪中。如果调用的服务也启用了 X-Ray,则跟踪将继续进行。通过这种方式,X-Ray 不仅跟踪特定事件,还生成了我们应用程序中所有组件的服务映射及其相互交互的图。
对于 AWS Lambda,有两种模式用于 X-Ray 追踪。第一种是 PassThrough,这意味着如果触发 Lambda 函数的事件已经被 X-Ray “标记”,则 Lambda 函数的调用将由 X-Ray 追踪。如果触发事件尚未被标记,则 Lambda 不会记录任何跟踪信息。相反,Active 追踪会主动将 X-Ray 跟踪 ID 添加到所有 Lambda 调用中。
在以下示例中,我们已启用 API Gateway 的追踪,该功能将为传入事件添加 X-Ray 跟踪 ID。Lambda 函数配置为 PassThrough 模式,因此当它由 API Gateway 的标记事件触发时,它将将该跟踪 ID 传播到下游服务。请注意,如果 Lambda 的 IAM 执行角色具有向 X-Ray 服务发送数据的权限,则默认情况下启用 PassThrough 模式;否则,如我们在此处所做的那样,可以显式配置(在这种情况下,SAM 将向 Lambda 执行角色添加适当的权限)。
这是我们 SAM template.yaml 文件中的 Globals 部分,从 第五章 更新以启用 API Gateway 追踪:
Globals:
Function:
Runtime: java8
MemorySize: 512
Timeout: 25
Environment:
Variables:
LOCATIONS_TABLE: !Ref LocationsTable
Tracing: PassThrough
Api:
OpenApiVersion: '3.0.1'
TracingEnabled: true
启用追踪功能后,我们还可以将 X-Ray 库添加到我们的 pom.xml 文件中。通过添加这些库,我们将在 Lambda 函数与 DynamoDB 和 SNS 等服务交互时享受到 X-Ray 追踪的好处,而无需修改我们的 Java 代码。
像 AWS SDK 一样,X-Ray 提供了一个材料清单(BOM),可以确保我们项目中使用的所有 X-Ray 库的版本保持同步。要使用 X-Ray BOM,请将其添加到顶层 pom.xml 文件的 <dependencyManagement> 部分:
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-xray-recorder-sdk-bom</artifactId>
<version>2.3.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
现在我们需要添加三个 X-Ray 库,这些库将为我们的基于 Java 的 Lambda 函数进行仪器化:
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-xray-recorder-sdk-core</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-xray-recorder-sdk-aws-sdk</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-xray-recorder-sdk-aws-sdk-instrumentor</artifactId>
</dependency>
图 7-9 展示了我们 API 的 X-Ray 服务地图,来自 第五章,展示了 API Gateway、Lambda 平台、Lambda 函数和 DynamoDB 表:

图 7-9. X-Ray 服务地图
我们还可以查看一个单独事件的追踪(在本例中为我们的 HTTP POST),该事件经过 API Gateway、Lambda 和 DynamoDB(图 7-10)。

图 7-10. X-Ray 追踪
查找错误
当我们的 Lambda 函数抛出错误时会发生什么?我们可以通过 X-Ray 控制台调查错误,通过服务地图和跟踪界面两种方式。
首先,让我们通过移除 WeatherEvent Lambda 访问 DynamoDB 的权限,向该 Lambda 函数引入一个错误:
WeatherEventLambda:
Type: AWS::Serverless::Function
Properties:
CodeUri: target/lambda.zip
Handler: book.api.WeatherEventLambda::handler
# Policies:
# — DynamoDBCrudPolicy:
# TableName: !Ref LocationsTable
Events:
ApiEvents:
Type: Api
Properties:
Path: /events
Method: POST
在部署我们的无服务器应用程序堆栈后,我们可以向 /events 端点发送一个 HTTP POST 事件。当 WeatherEvent Lambda 尝试将该事件写入 DynamoDB 时,它失败并抛出异常。在此之后的 X-Ray 服务地图显示如下(图 7-11)。

图 7-11. X-Ray 服务地图显示的错误
并且当我们深入研究导致错误的具体请求时,我们可以看到我们的 POST 请求返回了一个 HTTP 502 错误(图 7-12)。

图 7-12. X-Ray 追踪显示的错误
然后,我们可以通过悬停在显示 Lambda 调用轨迹部分的错误图标上,轻松看到导致 Lambda 函数失败的具体 Java 异常(图 7-13)。

图 7-13. X-Ray 追踪显示的 Java 异常
点击后,我们可以从 X-Ray 追踪控制台完整地查看堆栈跟踪,即从 图 7-14 开始。

图 7-14. X-Ray 显示的 Java 异常堆栈跟踪
总结
在本章中,我们介绍了多种方式,可以详细了解我们的无服务器应用程序的执行和功能,无论是在单个函数或组件级别,还是作为完整应用程序。我们展示了如何使用结构化 JSON 日志记录实现可观察性,并使我们能够从高度可扩展的 Lambda 函数中提取有意义的业务指标,而无需超负荷使用 CloudWatch API。
最后,我们向我们的 Maven pom.xml 添加了一些依赖项,并解锁了完整功能的分布式跟踪能力,这不仅追踪单个请求,还自动构建了我们无服务器应用程序的所有组件地图,并允许我们轻松地深入错误或意外行为。
现在基础知识已经介绍完毕,在下一章中,我们将深入探讨高级 Lambda 技术,使我们的生产无服务器系统更加强大和可靠。
练习
-
本章基于第五章的 API 网关代码进行构建。在来自第六章的更新数据流水线代码中添加 X-Ray 仪器,观察与 SNS 和 S3 的交互如何显示在 X-Ray 控制台中。
-
除了像本章所做的那样增加一个度量标准外,CloudWatch Logs 度量过滤器可以解析日志行中的度量值。使用这种技术为纽约布鲁克林的温度生成 CloudWatch Logs 度量标准。为了额外加分,当温度低于 32 华氏度时,添加一个警报!
第八章:高级 AWS Lambda
随着我们接近本书的结尾,是时候学习一些 Lambda 的方面了,这些方面对于构建可用于生产的应用程序至关重要——例如错误处理、扩展以及 Lambda 的一些能力,我们并非总是使用,但在需要时很重要。
错误处理
到目前为止,我们所有的示例都生活在没有系统故障和没有人在编写代码时犯错误的美好世界中。当然,在现实世界中,事情会出错,任何有用的生产应用程序和架构都需要处理错误发生的时间,无论是在我们的代码中还是在我们依赖的系统中。
由于 AWS Lambda 是一个“平台”,在处理错误时有一定的限制和行为,本节我们将深入探讨可以发生哪些类型的错误,在哪些情境中发生以及我们如何处理它们。作为语言说明,我们将“错误”和“异常”这两个词互换使用,没有 Java 世界中两个术语之间的微妙差别。
错误类别
在使用 Lambda 时,可能会出现几种不同类别的错误。主要错误如下,按照事件处理过程中可能发生的时间顺序大致排列如下:
-
初始化 Lambda 函数时出现的错误(加载我们的代码、定位处理程序或函数签名时的问题)
-
将输入解析为指定函数参数时出现的错误
-
与外部下游服务(数据库等)通信时出现的错误。
-
在 Lambda 函数内部生成的错误(无论是在其代码中还是在其直接环境中,例如内存不足的问题)
-
函数超时引起的错误
我们可以将错误分为已处理错误和未处理错误两类另一种方法。
例如,让我们考虑与下游微服务通过 HTTP 进行通信并且它抛出错误的情况。在这种情况下,我们可以选择在 Lambda 函数内部捕获错误并在那里处理(已处理错误),或者让错误传播到环境中(未处理错误)。
或者,假设我们在 Lambda 配置中指定了一个不正确的方法名。在这种情况下,我们无法在 Lambda 函数代码中捕获错误,因此这始终是一个未处理错误。
如果我们在代码中自行处理错误,那么 Lambda 实际上与我们的特定错误处理策略无关。我们可以选择像日志记录到标准错误一样,但正如我们在第七章中所看到的,Lambda 将标准错误与标准输出视为相同,如果内容发送到其中,不会引发任何警报。
因此,在 Lambda 处理错误时,所有的微妙之处都在于未处理的错误——即通过未捕获的异常将错误传递给 Lambda 运行时或外部发生的错误。这些错误会发生什么?有趣的是,这显著取决于触发 Lambda 函数的事件源类型,现在我们将详细探讨这一点。
Lambda 错误处理的各种行为
Lambda 根据触发调用的事件源来处理错误。我们在第五章中列出了每一种事件源类型(表 5-1):
-
同步事件源(例如,API 网关)
-
异步事件源(例如,S3 和 SNS)
-
流/队列事件源(例如,Kinesis 数据流和 SQS)
这些类别中的每一个都有一个不同的模型来处理 Lambda 函数抛出的错误,如下所示。
同步事件源
这是最简单的模型。对于以这种方式调用的 Lambda 函数,错误将向上传播到调用者,并且不会执行自动重试。错误如何暴露给上游客户端取决于调用 Lambda 函数的具体方式,因此您应该在代码中尝试强制错误,以查看此类问题如何暴露。
例如,如果 API 网关是事件源,那么 Lambda 函数抛出的错误将导致错误被发送回 API 网关。API 网关随后向原始请求者返回一个 500 的 HTTP 响应。
异步事件源
由于此调用模型是异步的或事件导向的,没有上游调用者可以对错误执行任何有用的操作,因此 Lambda 具有更复杂的错误处理模型。
首先,如果在这种调用模型中检测到错误,则 Lambda 将(默认情况下)重试处理事件多达两次(总共三次尝试),并在重试之间设置延迟(具体延迟未记录,但稍后我们将看到一个示例)。
如果 Lambda 函数在所有重试尝试失败时,事件将被发布到函数的错误目标和/或死信队列(如果已配置);否则,事件将被丢弃和丢失。
流/队列事件源
在没有配置错误处理策略的情况下(参见“处理 Kinesis 和 DynamoDB 流错误”),如果在处理来自流/队列事件源的事件时,错误向上冒泡到 Lambda 运行时,则 Lambda 将持续重试该事件,直到(a)上游源中的失败事件过期或(b)问题解决。这意味着流或队列的处理实际上被阻塞,直到错误解决。请注意,在使用扩展到多个分片的流时,存在特定的细微差别,如果适用,请建议进行研究。
在考虑 Lambda 错误处理时,以下文档页面非常有用:
深入了解异步事件源错误
异步事件源是 Lambda 的一种常见使用方式,并且具有复杂的错误处理模型,因此让我们通过一个例子更深入地了解这个主题。
重试
我们从以下代码开始:
package book;
import com.amazonaws.services.lambda.runtime.events.S3Event;
public class S3ErroringLambda {
public void handler(S3Event event) {
System.out.println("Received new S3 event");
throw new RuntimeException("This function unable to process S3 Events");
}
}
我们以与第五章中BatchEvents Lambda函数相同的方式将其与 S3 存储桶连接,稍后我们将看到该 SAM 模板。
如果我们将文件上传到与此函数关联的 S3 存储桶中,我们在日志中看到图 8-1。
注意,Lambda 尝试处理 S3 事件共三次——首次在 20:44:00,然后约一分钟后,再约两分钟后。这是 Lambda 为异步事件源承诺的三次事件处理尝试。
我们能够使用单独的 CloudFormation 资源配置 Lambda 将执行的重试次数——0、1 或 2 次。例如,让我们配置 Lambda 不对SingleEventLambda函数进行任何重试,该函数来自于“示例:构建无服务器数据管道”。我们可以向应用程序模板添加以下资源:
SingleEventInvokeConfig:
Type: AWS::Lambda::EventInvokeConfig
Properties:
FunctionName: !Ref SingleEventLambda
Qualifier: "$LATEST"
MaximumRetryAttempts: 0

图 8-1. S3 错误期间的 Lambda 日志
如果我们不做进一步的更改,Lambda 在所有重试(如果有)完成后将不会再执行任何操作——将会记录关于原始事件的简要数据,但最终将被丢弃。对于像 S3 这样的情况,这并不太糟糕——我们随时可以稍后列出 S3 中的所有对象。但对于其他事件源来说,如果在修复错误原因后无法重新生成事件,则可能会成为问题。这个问题有两种解决方案——DLQ 和目标。DLQ 已存在较长时间,因此我们将首先描述它们,但目标具有更多功能。
死信队列
Lambda 提供了自动转发事件的功能(对于失败所有重试的异步源)到死信队列(DLQ)。此 DLQ 可以是 SNS 主题或 SQS 队列。一旦事件进入 SNS 或 SQS,您可以立即处理,或稍后手动处理(对于 SQS 而言)。例如,您可以注册一个单独的 Lambda 函数作为 SNS 主题的监听器,将失败的事件副本发布到操作 Slack 频道进行手动处理。
DLQ 可以与 Lambda 函数的所有其他属性一起配置。例如,我们可以向我们的示例应用程序添加一个 DLQ,并且还可以添加一个 DLQ 处理函数,使用 SAM 模板。
示例 8-1. 带有 DLQ 和 DLQ 监听器的 SAM 模板
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: chapter8-s3-errors
Resources:
DLQ:
Type: AWS::SNS::Topic
ErrorTriggeringBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub ${AWS::AccountId}-${AWS::Region}-errortrigger
S3ErroringLambda:
Type: AWS::Serverless::Function
Properties:
Runtime: java8
MemorySize: 512
Handler: book.S3ErroringLambda::handler
CodeUri: target/lambda.zip
DeadLetterQueue:
Type: SNS
TargetArn: !Ref DLQ
Events:
S3Event:
Type: S3
Properties:
Bucket: !Ref ErrorTriggeringBucket
Events: s3:ObjectCreated:*
DLQProcessingLambda:
Type: AWS::Serverless::Function
Properties:
Runtime: java8
MemorySize: 512
Handler: book.DLQProcessingLambda::handler
CodeUri: target/lambda.zip
Events:
SnsEvent:
Type: SNS
Properties:
Topic: !Ref DLQ
这里需要注意的重要元素如下:
-
我们定义自己的 SNS 主题以充当 DLQ。
-
在应用程序函数(
S3ErroringLambda)内部,我们告诉 Lambda 我们希望为该函数设置 DLQ,其类型为 SNS,并且 DLQ 消息应发送到我们在此模板中创建的主题。 -
我们还定义了一个单独的函数(
DLQProcessingLambda),该函数由发送到 DLQ 的事件触发。
我们的DLQProcessingLambda代码如下:
package book;
import com.amazonaws.services.lambda.runtime.events.SNSEvent;
public class DLQProcessingLambda {
public void handler(SNSEvent event) {
event.getRecords().forEach(snsRecord ->
System.out.println("Received DLQ event: " + snsRecord.toString())
);
}
}
现在,如果我们向 S3 上传文件,我们会在DLQProcessing Lambda的日志中看到以下内容,显示了对S3ErroringLambda的最终交付尝试后的处理:
Received DLQ event: {sns: {messageAttributes:
{RequestID={type: String,value: ff294606-e377-4bad-8f2a-4c5f88042656},
ErrorCode={type: String,value: 200}, ...
发送到 DLQ 处理函数的事件包括失败的完整原始事件,允许您稍后保存并处理。它还包括原始事件的RequestID,允许您在应用程序 Lambda 函数的日志中搜索有关出错原因的线索。
虽然在这个示例中,我们将所有 DLQ 资源包含在应用程序模板中,但您可以选择在应用程序外使用资源,因此跨应用程序共享这些 DLQ 元素。
目标
在 2019 年底,AWS 推出了一个用于捕获失败事件的 DLQ 替代方案:destinations。目标实际上比 DLQ 更强大,因为您可以捕获错误和成功处理的异步事件。
此外,目标支持比 DLQ 更多类型的目标。支持 SNS 和 SQS,就像它们与 DLQ 一样,但您还可以直接路由到另一个 Lambda 函数(跳过消息总线部分)或 EventBridge。
要配置目标,我们使用与之前配置重试计数时创建的AWS::Lambda::EventInvokeConfig资源相同类型的资源(参见“重试”)。例如,让我们用目标替换前面示例中的 DLQ:
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: chapter8-s3-errors
Resources:
ErrorTriggeringBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub ${AWS::AccountId}-${AWS::Region}-errortrigger
S3ErroringLambda:
Type: AWS::Serverless::Function
Properties:
Runtime: java8
MemorySize: 512
Handler: book.S3ErroringLambda::handler
CodeUri: target/lambda.zip
Events:
S3Event:
Type: S3
Properties:
Bucket: !Ref ErrorTriggeringBucket
Events: s3:ObjectCreated:*
Policies:
— LambdaInvokePolicy:
FunctionName: !Ref ErrorProcessingLambda
ErrorProcessingLambda:
Type: AWS::Serverless::Function
Properties:
Runtime: java8
MemorySize: 512
Handler: book.ErrorProcessingLambda::handler
CodeUri: target/lambda.zip
S3ErroringLambdaInvokeConfig:
Type: AWS::Lambda::EventInvokeConfig
Properties:
FunctionName: !Ref S3ErroringLambda
Qualifier: "$LATEST"
DestinationConfig:
OnFailure:
Destination: !GetAtt ErrorProcessingLambda.Arn
从这个示例中可以注意到几个方面:
-
没有显式的队列或主题。
-
最后,目标定义了当
S3ErroringLambda失败时,我们希望将事件发送到ErrorProcessingLambda。 -
应用程序函数需要被授予调用错误处理函数的权限,我们通过
S3Erroring Lambda资源的Policies属性启用此权限。
发送到ErrorProcessingLambda的事件与发送到 DLQ 的事件类型不同。在撰写本文时,aws-lambda-java-events库尚未更新以包含目标类型,并且由于发送对象中字段的不幸命名,反序列化这些类型非常棘手。希望到您阅读本书时,这些问题已得到解决!
目标可能会取代大多数 DLQ 的使用方式,我们还对看到如何使用目标的OnSuccess版本来构建有趣的解决方案感兴趣。
处理 Kinesis 和 DynamoDB 流错误
2019 年末,AWS 向 Kinesis 和 DynamoDB 流事件源添加了许多故障处理功能。这些新功能使得可以避免“毒丸”场景,其中单个不良记录可能会阻塞流(或分片)处理长达一周(取决于流保留记录的时间)。
故障处理功能可以通过 SAM(或 CloudFormation)进行配置,并且在 Lambda 函数无法处理来自 Kinesis 或 DynamoDB 流的记录批次时应用。新功能如下所示:
函数错误的二分法
这个功能不是简单地重试整个记录批次以用于失败的 Lambda 调用,而是将批次分成两部分。这些较小的批次将分别重试。这种方法可以自动将故障缩小到导致问题的任何个别记录,并且可以通过其他错误处理功能处理这些记录。
最大记录年龄
这指示 Lambda 函数跳过早于指定的最大记录年龄的记录(可以从 60 秒到 7 天)。
最大重试尝试次数
此功能将失败的批次重试可配置的次数,然后将有关批次记录的信息发送到配置的失败目标(列表中的下一个特性)。
失败时的目标
这是一个将接收有关失败批次信息的 SNS 主题或 SQS 队列。请注意,它不接收实际失败的记录——这些记录必须在它们过期之前从流中提取。
一个全面的错误处理方法可以(并且应该)结合所有这些特性。例如,一组失败的记录可以被分割(可能多次),直到有一个导致失败的单一记录批次。这个单一记录批次可能会重试 10 次,或者直到记录达到 15 分钟之后,此时批次的详细信息(包含其单个失败记录)将被发送到一个 SNS 主题。一个独立的 Lambda 可以订阅该 SNS 主题,自动从流中检索失败的记录,并将其存储在 S3 中以供后续调查。
使用 X-Ray 跟踪错误
如果您使用 AWS X-Ray(讨论见“分布式跟踪”),它将能够显示组件图中发生错误的位置。有关更多详细信息,请参阅“查找错误”和 X-Ray 文档。
错误处理策略
因此,考虑到我们现在对错误的所有了解,以及 Lambda 在处理它们时的能力和行为,我们应该如何选择处理错误?
对于未处理的错误,我们应该设置监控(参见“警报”),当错误发生时,我们可能需要某种形式的手动干预。这种紧急性取决于上下文,也取决于事件源的类型——请记住,在流/队列事件源的情况下,直到错误被清除之前,处理都会被阻塞。
对于处理过的错误,我们有一个有趣的选择。我们应该处理错误并重新抛出,还是捕获错误并清晰地退出函数?再次强调,这将取决于上下文和调用类型,但以下是一些思考。
对于同步事件源,您可能希望向原始调用者返回某种错误。通常情况下,您会希望在 Lambda 代码中明确地执行此操作,并返回格式良好的错误。然而,这里的一个问题是 Lambda 不知道这是否是一个错误,因此您需要手动跟踪此度量。让同步调用的 Lambda 中未处理的错误冒出的问题在于,您无法控制返回给上游客户端的错误。
对于异步事件源,您要做的事情很大程度上取决于您是否想要使用 DLQ 或目的地。如果是这样,那么让错误冒出或抛出自定义错误,然后在处理来自 DLQ/目的地的消息的过程中处理错误通常不会有什么坏处。如果不使用 DLQ/目的地,则在代码内发生错误时,您可能至少希望记录失败的输入事件。
对于 Kinesis 和 DynamoDB 流事件源,使用之前描述的某种故障处理功能允许在某些记录导致错误时继续处理。通过正确配置的失败时目的地,这是一种有效的错误处理策略,尽管这假定您的应用程序可以安全地处理可能无序的记录。如果不是这种情况,请考虑省略故障处理功能,并依赖平台的自动重试行为(在这种情况下,将阻塞处理直到错误解决或记录过期)。
对于 SQS,通常希望在代码内部处理错误,否则会阻塞后续处理。一个有效的方法是在处理函数中放置一个顶层的try-catch块。在这个块中,您可以设置自己的重试策略或记录失败事件并清晰地退出函数。在某些情况下,您确实希望阻止进一步的事件处理,直到导致错误的问题解决,此时您可以从顶层的 try-catch 块中抛出一个新错误,并使用平台的自动重试行为。
扩展
在第五章中,我们提到了 Lambda 最宝贵的一个方面之一——其能够在没有任何努力的情况下自动扩展(参见图 5-10)。在数据管道示例中,我们利用这种自动扩展能力实现了“扇出”模式——并行处理许多小事件。
这是 Lambda 扩展模型的关键——如果当前所有函数实例在收到新事件时都在使用中,则 Lambda 将自动创建一个新实例,扩展该函数,以处理新事件。
最终,在一段不活动时间之后,函数实例将被收回,扩缩容函数。
从成本的角度来看,Lambda 保证我们仅在处理事件时收费,因此以串行方式处理一百个 Lambda 事件在一个函数实例中与在一百个实例中并行处理它们的成本相同(在冷启动中可能存在额外的时间成本,我们稍后在本章中描述)。
当然,Lambda 的扩展是有限制的,我们稍后会详细讨论,但首先让我们来看一下 Lambda 的神奇自动扩展。
观察 Lambda 的扩展
让我们从以下代码开始:
package book;
public class MyLambda {
private static final String instanceID =
java.util.UUID.randomUUID().toString();
public String handler(String input) {
return "This is function instance " + instanceID;
}
}
函数处理程序类的静态和实例成员会每个函数实例实例化一次。我们稍后在冷启动部分进一步讨论这一点。因此,如果我们连续五次调用前面的代码,它将始终为 instanceID 成员返回相同的值。
现在让我们稍微修改一下代码,加入一个 sleep 语句:
package book;
public class MyLambda {
private static final String instanceID =
java.util.UUID.randomUUID().toString();
public String handler(String input) throws Exception {
Thread.sleep(5000);
return "This is function instance " + instanceID;
}
}
确保如果您部署此代码,请包括至少六秒的 Timeout 配置;否则,您将看到超时错误的一个很好的例子!
现在并行多次调用该函数。一种方法是在多个终端标签页中运行相同的 aws lambda invoke 命令。根据您在导航终端会话时的快速程度,您将看到不同的容器 ID 用于不同的调用。
之所以能够观察到这种行为,是因为当 Lambda 收到第二个请求来调用您的函数时,之前用于第一个请求的容器仍在处理该请求,因此 Lambda 会创建一个新实例来处理第二个请求,自动扩展容量。如果您的速度足够快,这种新实例的创建也会发生在第三和第四个请求上。
这是直接调用 Lambda 函数的一个示例,但当 Lambda 被大多数事件源(包括 API Gateway、S3 或 SNS)调用时,我们看到相同的扩展行为,即当一个 Lambda 函数实例不足以跟上事件负载时,神奇的自动扩展,毫不费力!
缩放限制和限速
AWS 并不是一个无限的计算机,Lambda 的扩展是有限制的。亚马逊限制每个 AWS 帐户、每个区域的所有函数的并发执行次数。在撰写本文时,默认情况下,此限制为一千次,但您可以提出支持请求以增加此限制。部分原因是因为生活在物质宇宙的物理限制,部分原因是为了避免您的 AWS 账单激增到天文数字!
如果达到此限制,您将开始经历限流,您将因账户级别的 Throttles CloudWatch 指标为 Lambda 函数突然显示大于零的数量而知道这一点。这使其成为设置 CloudWatch 警报的优秀指标(我们在 “指标” 中讨论了内置指标和警报)。
当您的函数被限流时,AWS 表现出的行为类似于函数抛出错误时的行为(我们在本章前面讨论过的“Lambda 错误处理的各种行为”——“Lambda 错误行为”)——换句话说,这取决于事件源的类型。总结:
-
对于同步事件源(例如 API Gateway),Lambda 将将限流视为错误,并作为 HTTP 状态码 500 错误返回给调用者。
-
对于异步事件源(例如 S3),Lambda 默认会在最多六个小时内重试调用您的 Lambda 函数。可以通过例如使用
AWS::Lambda::EventInvokeConfigCloudFormation 资源 的MaximumEventAgeInSeconds属性进行配置,如我们在 “重试” 中介绍的那样。 -
对于流/队列事件源(例如 Kinesis),Lambda 将阻塞并重试,直到成功或数据过期。
基于流的源还可能有其他缩放限制,例如基于流的分片数量和配置的 ParallelizationFactor。
由于 Lambda 并发限制是账户级别的,特别需要注意的一个方面是,一个扩展特别广的 Lambda 函数可能会影响同一 AWS 账户+地区中的每个其他 Lambda 函数。因此,强烈建议至少在生产和测试中使用单独的 AWS 账户——由于负载测试针对分级环境而故意造成 DoS(拒绝服务)攻击您的生产应用程序是一种特别尴尬的情况,需要解释清楚!
但是除了生产与测试账户分离之外,我们还建议在 AWS “组织” 内使用不同的 AWS “子账户” 为生态系统中的不同 “服务” 进行隔离,以进一步避免账户范围限制的问题。
突发限制
提及的限制和限流是指您的 Lambda 函数可用的总容量。然而,偶尔还需注意另一个限制——突发限制。这指的是您的 Lambda 函数可以扩展的速度(而不是范围)。默认情况下,Lambda 可以每分钟最多扩展一个函数到 500 个实例,可能在开始时有一个小的增加。如果您的工作负载比这更快地爆发(我们见过一些能做到的),那么您需要注意突发限制,并可能考虑请求 AWS 增加您的突发限制。
保留并发限制
我们刚才提到过一个 Lambda 函数,它的扩展特别广,可能会通过使用所有可用的并发量来影响账户中的其他函数。Lambda 有一个工具可以帮助解决这个问题——可选的 保留并发量 配置,可以应用于函数的配置中。
设置一个保留的并发值会做两件事:
-
它保证该特定函数将始终具有该可用并发量,而不管账户中的其他函数在做什么。
-
它将该函数限制在不超过该并发量的范围内。
这个第二个特性有一些有用的好处,我们在 “解决方案:使用保留的并发管理扩展” 中讨论过。
如果你正在使用 SAM 来定义应用程序的基础设施,你可以使用 AWS::Serverless::Function 资源类型的 ReservedConcurrentExecutions 属性来声明一个保留的并发设置。
线程安全
由于 Lambda 的扩展模型,我们可以保证每个函数实例在任何时候最多只处理一个事件。换句话说,在函数的运行时,你永远不需要担心多个事件同时被处理,更不用说在函数对象实例内部了。因此,除非你自己创建了任何线程,Lambda 编程是完全线程安全的。
垂直扩展
Lambda 几乎所有的扩展能力都是“水平”的——即,它能够扩展以处理多个事件并行处理。这与“垂直”扩展相对应——即通过增加单个节点的计算能力来处理更多的负载。
Lambda 还有一个基本的垂直扩展选项,但是它是通过内存配置来实现的。我们在 “内存和 CPU” 中讨论过这个问题。
版本和别名,流量转移
在你迄今为止对 Lambda 进行的实验中,你可能偶尔会看到字符串“$LATEST”出现。这是对 Lambda 函数的 版本 的引用。不过,版本远不止于 $LATEST,所以让我们来看看吧。
Lambda 版本
每当我们部署了新的配置或新代码到我们的 Lambda 函数中,我们总是覆盖之前的内容。旧的函数已经过时,新函数永存。
然而,Lambda 支持保留这些旧函数,如果你愿意的话,这是通过 Lambda 函数版本控制这个功能来实现的。
如果不显式使用版本控制,Lambda 在任何时候都只有一个版本的函数。它的名称是“$LATEST”,你可以明确引用它;或者,如果你不指定版本(或别名,我们马上就会看到的),你也隐含地引用了“$LATEST”。
当你创建或更新一个函数时,可以在当时或之后某个时间点对该函数进行版本快照。版本的标识符是一个线性计数器,从 1 开始。你无法编辑一个版本,这意味着只有从当前的$LATEST版本创建版本化快照才有意义。
调用函数的一个版本时,可以通过将:VERSION-IDENTIFIER添加到其 ARN 中显式调用它,或者如果使用 AWS CLI,则可以在aws lambda invoke命令的--qualifier VERSION-IDENTIFIER参数中添加它。
可以使用各种 AWS CLI 命令或 Web 控制台创建版本。不能直接使用 SAM 显式创建版本,但在使用别名时可以隐式创建版本,我们接下来会解释这一点。
Lambda 别名
尽管可以显式引用 Lambda 函数的编号版本,但在使用版本时,更典型的是使用别名。别名是指向 Lambda 版本的命名指针——可以是$LATEST,也可以是一个数字化的快照版本。可以随时更新别名以指向不同的版本。例如,您可以从$LATEST开始,但随后指向特定版本以增加别名的稳定性。
您以与函数版本完全相同的方式调用函数的别名——通过在 ARN 中指定它或在 CLI 的--qualifier参数中指定它。可以配置事件源以指向特定的别名,并且如果基础别名更新以指向新版本,则来自源的事件将流向该新版本。
在使用 SAM 部署 Lambda 函数时,可以定义一个别名,该别名会自动更新以指向最新发布的版本。您可以通过添加AutoPublishAlias属性并提供别名名称作为值来实现这一点。
然而,使用 SAM 时有一种更强大的使用别名的方式。
流量转移
如果在 SAM 中使用 Lambda 函数的AutoPublishAlias属性,则来自事件源的所有事件将立即路由到函数的新版本。如果出现问题,您可以手动更新别名以指向前一个版本。
Lambda 和 SAM 还具有通过首先给予分流流量的机能来改善此流程的功能,将一些流量发送到新版本,一些流量发送到旧版本。这意味着如果发生问题,并且需要回滚,则并非所有流量都受到问题的影响。
第二个改进是,如果检测到错误,可以自动执行回滚,您可以定义如何以几种不同的方式计算错误。
有许多移动部件涉及使其工作—Lambda 别名、Lambda 别名更新策略以及使用AWS CodeDeploy服务。幸运的是,SAM 能很好地将所有这些包装起来,以便你不需要担心所有这些繁琐的细节。你主要需要做的是在 SAM 模板中的 Lambda 函数中添加一个DeploymentPreference属性,这在详细文档中有说明。
使用流量转移时需要做出的选择是如何将你的流量转移到新别名上。这可以分为四个选项:
一次性全部
虽然这乍一看可能与AutoPublishAlias相同,但实际上它更加强大,因为你有机会通过“钩子”自动回滚部署,我们稍后将描述。这是 Lambda 的蓝绿部署的完全自动化实现。
金丝雀
向新版本发送少量流量,如果有效,则发送剩余流量;否则,回滚。
线性
与金丝雀类似,但向新版本发送逐渐增加的流量百分比,仍允许回滚。
自定义
决定如何在旧别名和新别名之间分配流量由你自己决定。
正如我们之前提到的,此功能的一个强大元素是可以通过两种不同的机制实现自动回滚—钩子和警报。
钩子触发的回滚适用于任何之前的方案。你可以定义预流量钩子和/或后流量钩子。这些钩子只是其他 Lambda 函数,它们将运行它们需要的任何逻辑来决定部署是否成功—无论是在任何流量路由到新别名之前还是在所有流量转移后。
警报适用于提供逐渐流量转移的方案。你可以定义任意数量的CloudWatch 警报(我们在“警报”中讨论过),如果其中任何警报转换为警报状态,则将执行回滚到原始别名。
欲了解有关 Lambda 流量转移的更多详细信息,请参阅SAM 文档。
何时(不)使用版本和别名
Lambda 的流量转移能力非常强大,如果你在 Lambda 代码的上游尚未使用金丝雀发布方案,那么它可能对你有所帮助。
然而,除了流量转移之外,我们尽量避免使用版本和别名。我们发现它们通常增加了不必要的复杂性,而我们更倾向于使用其他技术。例如,对于代码的开发和生产版本的分离,我们更喜欢使用不同的部署堆栈。对于“回滚”代码,我们更倾向于使用快速运行的部署管道,并在源代码库中进行回滚,通过管道触发新的提交。
注意
偶尔您会看到一些事件源使用并推荐使用 Lambda 别名。其中一个例子是将 Lambda 与AWS 应用负载均衡器(ALB)集成时。
如果您使用版本和别名,请注意除了之前提到的函数实例警告之外的一些“陷阱”:
-
版本不会自动清理,因此定期删除旧版本很重要。否则,您可能会发现自己达到账户级别的“函数和层存储”限制,即 75GB。
-
当您在使用 CloudWatch 指标时,请确保您明确指定要查看数据的版本或别名,因为 AWS Web 控制台中默认的 CloudWatch 指标视图在使用版本和别名时有点奇怪。
冷启动
现在我们来讨论冷启动这个棘手的问题。根据您与谁交流的不同,冷启动可能是 Lambda 开发者生活中的一个小注脚,也可能是阻止 Lambda 被视为有效计算平台的一个完全阻碍因素。我们发现如何最好地处理冷启动在这两个极端之间——值得深入理解和严谨对待,但在大多数情况下并非不可抗拒的因素。
但是冷启动是什么,何时发生,它们会产生什么影响,以及我们如何减轻它们的影响?关于冷启动有很多恐惧、不确定性和怀疑(FUD),我们希望在这里消除其中一些 FUD。让我们深入探讨。
什么是冷启动?
回顾第三章,我们探讨了当第一次调用 Lambda 函数时发生的活动链(图 3-1)——从启动主机 Linux 环境到调用我们的处理函数。在这两个活动之间,JVM 将被启动,Lambda Java 运行时将被启动,我们的代码将被加载,根据我们 Lambda 函数的具体特性,可能会发生更多其他活动。我们将这个链条总称为冷启动,它导致我们的 Lambda 函数的新实例(执行环境、运行时和我们的代码)可以处理事件。
这里一个重要的观点是,所有这些活动都发生在我们的 Lambda 函数被调用时,而不是之前。换句话说,Lambda 不仅在部署 Lambda 代码时创建函数实例,而是根据需要创建它们。
然而,冷启动是特殊事件,而不是每次调用都会发生的事情,因为通常 Lambda 不会为每个触发函数的事件执行冷启动。这是因为一旦我们的函数执行完毕,Lambda 可以冻结实例并保留一段时间,以防接下来会有另一个事件发生。如果很快又发生了一个事件,Lambda 将解冻实例并用事件调用它。对于许多 Lambda 函数来说,冷启动实际上不到 1%的时间发生,但了解它们发生的时机仍然很有用。
冷启动何时发生?
当没有现有的函数实例可用来处理事件时,冷启动是必要的。这种情况发生在以下时候:
-
当 Lambda 函数的代码或配置更改时(包括首次部署函数的第一个版本时)
-
当所有之前的实例因为不活跃而被销毁
-
当所有之前的实例因“老化”而被“清理”时
-
当 Lambda 需要扩展,因为所有当前函数的实例都在处理事件
让我们更详细地看看这四种发生情况。
-
当我们首次部署我们的函数时,Lambda 会创建一个我们函数的实例,正如我们已经见过的那样。然而,每当我们部署函数代码的新版本,或者更改函数的 Lambda 配置后,Lambda 也会创建一个新的实例当函数被调用。这样的配置不仅涵盖环境变量,还包括运行时方面,如超时设置,内存设置,DLQ 等。
这个推论是 Lambda 函数的一个实例无论被调用多少次,保证都有相同的代码和配置。
-
Lambda 会保留函数实例一段时间,以防会有“快”事件发生。关于“快”具体的定义没有文档说明,但可能在几分钟到几小时之间(并不一定是固定的)。换句话说,如果您的函数处理一个事件,然后一分钟后又发生了另一个事件,第二个事件很有可能使用同一个函数实例来处理第一个事件。然而,如果事件之间有一天或更长的时间间隔,您的函数很可能每次事件都会经历冷启动。过去,有些人使用“ping hack”来解决这个问题,并保持其函数“活跃”,但在 2019 年底,AWS 推出了预置并发(见“预置并发”)来解决这种问题。
-
即使您的 Lambda 事件非常活跃,亚马逊也不会永远保留实例,即使它们每隔几秒钟被使用。AWS 保留实例的时间在撰写本文时为五到六小时,之后将被销毁。
-
最后,如果函数的所有当前实例都在忙于处理事件并且 Lambda“扩展”,就像我们在本章前面描述的那样,冷启动将会发生。
识别冷启动
什么时候可以判断发生了冷启动呢?有很多种方法可以做到这一点,以下是其中一些。
首先,你会注意到延迟急剧增加。冷启动通常会使函数的延迟增加从 100 毫秒到 10 秒不等,具体取决于函数的组成。因此,如果你的函数通常需要的时间少于这个范围,冷启动在函数延迟指标中将很容易看到。
接下来,由于 Lambda 的日志记录方式,你将能够知道何时发生了冷启动。正如我们在“Lambda 和 CloudWatch Logs”中讨论的那样,当 Lambda 函数记录日志时,输出将被捕获在 CloudWatch Logs 中。一个函数的所有日志输出都在一个 CloudWatch Log group中可用,但是每个函数实例将写入日志 stream中的一个单独的流,位于日志组内。因此,如果你看到日志组中的日志流数量增加,那么你就知道发生了冷启动。
此外,你可以在代码中自行跟踪冷启动。由于包装处理程序的 Java 对象仅在实际函数运行时的每个实例中实例化一次,任何实例成员或静态成员初始化都将发生在冷启动时,并且在函数实例的生命周期内再也不会发生。因此,如果在代码中添加构造函数或静态初始化程序,它将仅在函数经历冷启动时调用。你可以在处理程序类构造函数中添加显式日志记录,以查看函数日志中发生的冷启动。或者,我们在本章前面看到了识别冷启动的示例。
你还可以使用 X-Ray 和一些第三方 Lambda 监控工具来识别冷启动。
冷启动的影响
到目前为止,我们已经描述了什么是冷启动,它们何时发生以及如何识别它们。但是,为什么你要关心冷启动呢?
正如我们在前一节中提到的,识别冷启动的一种方法是,当发生冷启动时,你通常会在事件处理中看到延迟急剧增加,这也是人们最关心的原因。虽然一个小型 Lambda 函数的端到端延迟在正常情况下可能为 50 毫秒,但是冷启动可能会增加至少200 毫秒到这个数量,而且根据各种因素,可能会增加秒数,甚至十几秒。冷启动增加延迟的原因是因为在创建函数实例期间需要进行的所有步骤。
这是否意味着我们总是需要关心冷启动呢?这在很大程度上取决于你的 Lambda 函数在做什么。
例如,假设您的函数是异步处理在 S3 中创建的对象,并且您对处理这些对象需要花费几分钟的时间并不在意。在这种情况下,您是否关心冷启动?可能不会。特别是当考虑到 S3 并没有保证事件的亚秒级交付时。
下面是另一个例子,您可能不会太在意冷启动:假设您有一个函数正在处理来自 Kinesis 的消息,每个事件处理大约需要 100 毫秒,通常总是有足够的数据使您的 Lambda 函数保持繁忙状态。在这种情况下,您的一个 Lambda 函数实例可能会处理 200,000 个事件,然后被“清除”。换句话说,在 Lambda 调用中,冷启动可能仅影响 0.0005%。即使冷启动使启动延迟增加了 10 秒,考虑到在实例的生命周期内对这段时间的摊销,很可能您在这种情况下会接受这样的影响。
另一方面,假设你正在构建一个 Web 应用程序,并且有一个特定的元素调用了一个 Lambda 函数,但该函数在 AWS 中每小时只被调用一次。这可能意味着每次调用函数时都会出现冷启动。进一步说,假设对于这个特定的函数,冷启动的开销是五秒钟。这会成为问题吗?可能会。如果是这样,这个开销能够减少吗?也许可以,在下一节我们将讨论这个问题。
尽管关于冷启动的关注几乎总是涉及延迟开销,但也要注意,如果您的函数在启动时从下游资源加载数据,那么每次发生冷启动时它都会这样做。在考虑 Lambda 函数对下游资源影响时,特别是在部署后所有实例都进行冷启动时,您可能需要考虑这一点。
缓解冷启动
Lambda 总是会发生冷启动,除非我们使用预置并发(在下一节中描述),这样的冷启动将会时不时地影响我们函数的性能。如果冷启动给您带来问题,那么有各种技术可以减少它们的影响。但请确保它们确实给您带来问题—就像其他形式的性能优化一样,您希望只在真正需要时才进行这项工作。
减少构件大小
在减少冷启动影响中,最有效的工具通常是减少我们代码构件的大小。我们可以通过两种主要方式实现这一点:
-
减少我们自己代码在构件中的量,只保留 Lambda 函数所需的部分(其中“量”指的是大小和类的数量)。
-
精简依赖项,使得构件中仅存储 Lambda 函数所需的库。
这里还有几种后续技术。首先,为每个 Lambda 函数创建一个不同的构件,并为每个构件执行任务。这是我们在第五章中所做的努力的目的,当时我们创建了多模块 Maven 项目。
其次,如果你想进一步优化库的依赖关系,那么考虑将依赖的库拆分为仅包含你需要的代码。甚至可以在你自己的代码中重新实现库的功能。显然,这需要一些正确和安全地完成的工作,但对你来说可能是一个有用的技术。
这些技术通过两种方式减少了冷启动的问题。首先,启动运行时之前需要复制和解压的文件更少。但更重要的是,运行时需要加载和初始化的代码更少。
所有这些技术在现代服务器端软件开发中都有些不同寻常。我们习惯于可以任意向项目中添加依赖项,创建多百兆字节的部署文件,而 Maven 或 NPM 则“下载互联网”。在传统的服务器端开发中,这通常足够了,因为磁盘空间便宜,网络快速,最重要的是,我们不太关心服务器的启动时间,至少不会在这里或那里几秒钟的顺序上。
但是对于函数即服务(FaaS),尤其是 Lambda,我们对启动时间的关注程度要高得多,因此我们需要更审慎地构建和打包我们的软件。
为了在 JVM 项目中减少依赖关系,你可能希望考虑使用Apache Maven 依赖插件,它将报告项目中依赖项的使用情况,或者类似的工具。
使用更高效的加载速度的打包格式
正如我们在第四章中所提到的,AWS 建议使用 ZIP 文件方法打包 Lambda 函数,而不是使用 uberjar 方法,因为这样可以减少 Lambda 解压部署文件所需的时间。
减少启动逻辑
在本章后面,我们将讨论 Lambda 函数中的状态问题。不管你之前听到了什么,Lambda 函数并不是无状态的;只是在思考状态时有一个不同寻常的模型。
Lambda 函数中一个非常常见的做法是在首次调用函数时创建或加载各种资源。在第五章的示例中,我们在一定程度上看到了这一点,当时我们初始化了序列化库和 SDK。然而,对于某些函数来说,理解这一思想并创建一个大型的本地缓存,从其他资源加载,以更快地处理实例生命周期中的事件是有意义的。
这样的启动逻辑并非免费,会增加冷启动时间。如果你在冷启动时加载初始资源,你可能会发现在改善后续调用性能与初始调用时间之间需要做出权衡。如果可能的话,你可能希望考虑是否可以逐渐在一系列初始调用中“预热”函数的本地缓存。
警告
缓慢启动的一个主要原因是使用像 Spring 这样的应用框架。正如我们稍后讨论的(见 “Lambda 和 Java 应用框架”),我们强烈反对在 Lambda 中使用这样的框架。如果冷启动给你造成了问题,并且你正在使用应用框架,那么我们建议你首先调查是否可以从 Lambda 函数中移除该框架。
语言选择
另一个可能影响冷启动时间的领域是语言运行时的选择。JavaScript、Python 和 Go 启动所需的时间比 JVM 或 .NET 运行时少。因此,如果你编写的是不经常调用的小函数,并且你关心尽可能减少冷启动影响,你可能会希望在其他开发方面相等的情况下选择 JavaScript、Python 或 Go 而不是 Java。
由于启动时间的差异,我们经常听到人们在一般情况下将 JVM 和 .NET 运行时作为 Lambda 运行时而忽略,但这是一种短视的观点。例如,在我们早些时候描述的 Kinesis 处理函数的情况中,如果平均情况下 JVM 函数处理一个事件需要 80 毫秒,而 JavaScript 等效函数需要 120 毫秒呢?在这种情况下,你的代码的 JavaScript 版本运行成本将是 JVM 版本的两倍(因为计费 Lambda 时间会向上取整到下一个 100 毫秒)。在这种情况下,JavaScript 可能不是运行时的正确选择。
完全可以在 Lambda 中使用替代(非 Java)JVM 语言(我们将在本章末尾进一步讨论)。但要记住的一点是,通常这些语言都带有自己的“语言运行时”和库,这两者都会增加冷启动时间。
最后,在选择语言这个话题上,当涉及到语言对冷启动或事件处理性能的影响时,保持一些视角是值得的。在语言选择中,最重要的因素是你如何有效地构建和维护你的代码——软件开发中的人为因素。与 Lambda 语言运行时之间的运行时性能差异相比,成本可能微不足道。
内存和 CPU
函数配置的某些方面也会影响冷启动时间。其中一个主要例子是你选择的 MemorySize 设置。更大的内存设置也会提供更多的 CPU 资源,因此较大的内存设置可能会加快 JVM 代码的 JIT 编译时间。
注意
直到 2019 年底,Lambda 函数的另一个配置设置可能会显著增加冷启动时间,即是否使用虚拟私有云(VPC)。 我们稍后在本章中详细讨论 VPC,但目前您需要知道的是,如果您在任何地方看到有关因 VPC 导致 Lambda 启动时间恶化的警告文档,请放心,此问题现在已经解决。 有关 AWS 改进此问题的更多详细信息,请参见此文章。
预配并发
2019 年底,AWS 宣布了一项新的 Lambda 功能——预配并发。 预配并发(PC)允许工程师有效地“预热”Lambda 函数,从而消除(几乎)所有冷启动的影响。 在我们描述如何使用此功能之前,请注意以下一些重要的警告:
-
PC 会破坏 Lambda 的基于请求的成本模型。 使用 PC,您无论是否调用函数都需要付费。 因此,使用带有 PC 的 Lambda 抵消了无服务器的主要好处之一:成本可以缩减到零(请参阅“Lambda 实现的 FaaS”)。
-
为了避免支付与峰值使用相关的成本,您需要手动配置带有 PC 的 AWS 自动缩放(请参阅此 AWS 博客文章以了解如何实现此操作)。 这会增加您的额外运维工作量。
-
PC 会增加显著的部署时间开销。 在我们的实验中,在撰写本文时,部署具有设置为 1 的 PC 的 Lambda 函数的开销约为四分钟。 使用设置为 10 或 100 的情况约为七分钟。
-
PC 需要使用版本或别名,我们在本章早些时候描述了它们(请参阅“版本和别名,流量转移”)。 如我们在该部分中提到的,我们不建议在大多数情况下使用版本或别名,因为它们带来了额外的复杂性。
警告
鉴于这些重大注意事项,我们的建议是,只有在您绝对需要时才使用预配并发。 正如我们在本节摘要中提到的,我们发现,大多数最初关注冷启动的团队在开始在生产中大规模使用 Lambda 后,发现冷启动实际上没有什么效果,特别是如果团队遵循本章关于冷启动缓解的其他建议。
现在,我们告诉您为什么几乎肯定不应该使用预配并发,让我们谈谈它是什么!
PC,最简单地说,是一个数值(n),告诉 Lambda 平台始终保持至少 n 个函数执行环境处于“热”状态。 这里的“热”意味着执行环境已创建,并且已实例化您的 Lambda 函数处理程序代码。 事实上,在预热期间执行了整个执行链(请参阅图 3-1),除了实际调用处理程序方法。
由于在 PC 环境下,Lambda 不会调用未预热的函数(除了我们稍后描述的一个关于扩展的细节),这确保了您不会有任何性能影响的冷启动!换句话说,所有函数调用都将在其常规的“预热”时间内响应。
PC 的另一个好处是,它仅在部署配置中定义——您不需要更改代码即可使用它(尽管您可能想要更改代码,我们将在稍后描述关于代码实例化的内容)。
让我们来看一个例子。假设我们在 SAM 模板中配置了以下函数:
HelloWorldLambda:
Type: AWS::Serverless::Function
Properties:
Runtime: java8
MemorySize: 512
Handler: book.HelloWorld::handler
CodeUri: target/lambda.zip
AutoPublishAlias: live
ProvisionedConcurrencyConfig:
ProvisionedConcurrentExecutions: 1
新增的内容在这里是最后三行。首先,您会看到我们正在使用别名——PC 要求为每个版本或别名配置ProvisionedConcurrentExecutions值。我们不能为$LATEST——默认版本配置ProvisionedConcurrentExecutions值。
在这个例子中,我们还指定要始终有一个实例的 Lambda 函数预热。
当我们首次部署此函数时,Lambda 将实例化 Java 类HelloWorld,其中包含我们的处理程序,甚至在发生任何调用之前。然后,当接收到函数的事件时,Lambda 将调用这个预热的函数。当我们重新部署函数时,Lambda 将继续将请求路由到旧版本(预热),并且只有在为该版本创建的所有预置实例之后才开始使用新版本。再次强调,这确保了函数调用不受冷启动的影响。
提示
在其他第三方 Lambda 文档中,您可能会看到建议使用次要的定时“ping”函数来调用应用程序函数,以避免冷启动。PC,在设置为 1 的情况下,几乎在任何情况下都是这种机制的更有效替代品。
现在,让我们讨论您应该注意的一些细节。
首先是定价。正如提到的,在写作时,PC 与常规的“按需”Lambda 有着不同的成本模型。如“Lambda 的成本有多高?”中所述,按需 Lambda 的成本基于您的 Lambda 函数接收了多少请求以及 Lambda 函数执行的时间(持续时间)。对于 PC,您仍需支付请求成本,以及一个(较小的)持续时间成本,但您还需为函数部署期间的整个时间支付费用,而不仅仅是处理请求时。
让我们继续探讨“Lambda 的成本有多高?”中的内容,特别是针对 Web API 的示例。我们仅使用按需 Lambda 的成本估算为每月$21.60。使用预置并发的成本是多少呢?
同样,我们将假设 512 MB RAM,少于 100 ms 来处理请求和 864,000 次/天的情况。让我们从使用 PC 值为 10 开始,因为这是我们预计的峰值。在这种情况下,我们的 Lambda 成本如下:
-
请求成本每月保持不变为$5.18。
-
持续成本为 0.1 × 864000 × 0.5 × $0.000009722 = $0.42/天,或$12.60/月。
-
预置并发成本为 10 × 0.000004167 × 0.5 × 86400 = $1.80/天,或$54/月。
因此,总成本已经从每月约$22 增加到每月$72 的三倍多。哎呀!
现在,这很可能是一个“最坏的情况”,因为我们将 PC 设置为峰值。我们的一个选择是为 PC 手动配置自动扩展。这在AWS 博客介绍 PC中有描述。假设这样做意味着我们的 PC 配置平均约为 2。在这种情况下,我们的总成本为每月$29。这仍然比按需贵 30%,而且现在我们还增加了管理 PC 自动扩展的复杂性。
在某些场景中,如果您有非常一致的使用模式,那么按需使用可能比按需使用更便宜,但在大多数情况下,您应该期望支付显著的额外开销以使用按需。
与成本相关的另一个问题是,您可能希望针对开发和生产使用不同的配置,以避免为开发环境支付“全天候”成本。您可以使用 CloudFormation 技术来实现这一点,但这会增加额外的心理负担。
关于成本的讨论就到此为止。让我们转移到另一个主题!
如果在某个时间点您的 PC 配置有更多的调用次数,会发生什么情况?正如我们在本章前面所讨论的,Lambda 始终会增加活动执行环境的数量以满足负载。例如,假设 Lambda 需要为您的函数使用第 11 个执行环境,但您的 PC 设置为 10——现在会发生什么?在这种情况下,Lambda 将以“传统”的按需模式为额外负载启动新的执行环境。您将按通常的按需方式收取此额外容量的费用,但请注意——使用该新额外环境的第一个事件也会以正常方式产生冷启动延迟!
最后,快速注意一下如何充分利用按需计算。在过去几年中,AWS 在减少平台冷启动开销方面表现出色,因此按需计算的主要目的大多是缓解应用程序的开销——即实例化语言运行时、代码和处理程序类所需的时间。最后一个元素——类实例化——非常重要,因为在预热期间会调用您的处理程序类构造函数。因此,您应该尽可能将应用程序设置移至类和对象实例化时间,而不是在处理程序方法本身中执行此操作。我们在本书中始终使用此模式,但如果您使用按需计算,则尤为重要。
鉴于我们对使用按需计算的所有严重警告,我们什么时候建议使用它呢?以下是我们可以想象使用按需计算的几种情况:
-
当您的 Lambda 函数调用非常不频繁(例如每小时一次或更长时间),而且您希望快速返回(亚秒级),并且愿意承担成本开销时。
-
如果你的应用程序具有极端的“突发”规模场景(请参阅“突发限制”),Lambda 无法默认处理,则可以预热足够的容量。
-
如果你的函数本身在代码级别有显着的冷启动时间(例如,几秒钟),这对于应用性能来说是不够的,并且你没有其他方法来缓解这种情况。如果你在 Lambda 代码中使用了一个庞大的应用程序框架,这种情况很典型。
冷启动摘要
冷启动可能不是你需要花太多精力的事情,这取决于你如何使用 Lambda,但这绝对是一个你应该了解的话题,因为冷启动是如何被缓解的通常与我们通常构建和打包系统的方式相反。
我们之前提到过关于冷启动的FUD,而冷启动也经常因实际上与冷启动无关的延迟问题而被“抛弃”。如果你担心延迟,请执行适当的延迟分析——确保你的实际问题不是,例如,你的代码如何与下游系统交互。
还要确保随着时间的推移继续测试延迟,特别是如果你因为冷启动而排除了 Lambda 的某种用法。AWS 在 Lambda 平台的这一部分已经做出了,并且正在继续做出重大改进。
根据我们的经验,当团队第一次使用 Lambda 时,冷启动会引起他们的关注,特别是在开发负载波动较大时,但一旦他们看到 Lambda 在生产负载下的表现,他们通常再也不会担心冷启动了。
状态
几乎任何应用程序都需要考虑状态。这种状态可能是持久的——换句话说,它捕获了需要满足后续请求的数据。或者,它可以是缓存状态——数据的副本,用于提高性能,持久化版本存储在其他位置。
尽管它偶尔会被认为是无状态的,Lambda 实际上不是无状态——数据可以在请求期间和跨请求期间存储在内存和磁盘上。
内存中的状态通过处理程序方法的对象和类成员可用——加载到这些成员中的任何数据在下次调用该函数实例时都可用,而且 Lambda 函数可以最多有 3GB RAM(其中一部分将被 Lambda 运行时使用)。
Lambda 函数实例还可以访问/tmp中的 512MB 本地磁盘存储。虽然这种状态不会自动在函数实例之间共享,但它将在同一函数实例的后续调用中再次可用。
然而,Lambda 的运行时模型的性质显著影响了这种状态如何被使用。
持久应用程序状态
Lambda 创建函数实例的方式,特别是它的扩展方式,对架构有重要影响。例如,我们绝对不能保证同一上游客户端的连续请求将由同一函数实例处理。Lambda 函数没有“客户端亲和性”。
这意味着我们不能假设在 Lambda 函数中一个请求中本地可用的任何状态(内存中或本地磁盘上)将在后续请求中可用。无论我们的函数是否扩展,这都是真实的——扩展只是强调这一点。
因此,我们想要在 Lambda 函数调用之间保留的所有持久应用程序状态都必须是外部化的。换句话说,这意味着我们想要在个别调用之外保留的任何状态都必须要么存储在我们的 Lambda 函数下游——在数据库、外部文件存储或其他下游服务中——要么在同步调用函数的情况下返回给调用者。
这听起来可能是一个巨大的限制,但事实上,这种构建服务器端软件的方式并不新鲜。多年来,许多人一直在宣扬12 因素架构的优点,这种将状态外部化的方式体现在该范例的第六因素中。
话虽如此,这绝对是 Lambda 的一个限制,并且可能需要您对要迁移到 Lambda 的现有应用程序进行重大重新架构。这也可能意味着一些需要对状态进行特别低延迟访问的应用程序(例如,游戏服务器)不适合 Lambda,也不适合需要大量数据集在内存中以达到足够性能的应用程序。
人们常用的一些服务用于外部化他们与 Lambda 的应用程序状态:
DynamoDB
DynamoDB 是 AWS 的 NoSQL 数据库。我们在“示例:构建无服务器 API”中的 API 示例中使用了 DynamoDB。DynamoDB 的好处是它快速、操作和配置相对容易,并且具有非常相似的扩展属性。DynamoDB 的主要缺点是建模数据可能会变得棘手。
RDS
AWS 有各种关系型数据库,它们都被分组到关系/SQL 数据库服务(RDS)家族中,并且所有这些数据库都可以从 Lambda 中使用。在这个家族中相对新的一个选项是Aurora Serverless——Amazon 自己的Aurora MySQL 和 Postgres 引擎的自动扩展版本,专为无服务器应用程序而设计。使用 SQL 数据库而不是 NoSQL 数据库的好处是几十年来构建这种应用程序的经验。相对于 DynamoDB,缺点通常是更高的延迟和更多的操作开销(非无服务器 RDS)。
S3
简单存储服务(S3)——我们在本书中多次使用过——可以用作 Lambda 的数据存储。它易于使用,但在与某些数据库服务相比,查询能力有限,而且延迟并不低,除非您还使用 Amazon Athena。
ElastiCache
AWS 作为其 ElastiCache 家族的一部分提供了 Redis 持久缓存应用的托管版本。在这四个选项中,ElastiCache 通常提供最快的性能,但由于它不是真正的无服务器服务,因此需要一些操作开销。
自定义下游服务
或者,您可以选择在下游服务中实现自己的内存持久化,采用传统设计。
AWS 在这一领域继续进行有趣的发展,我们建议您在选择持久化解决方案时调查所有最近宣布的进展。
缓存
尽管我们不能依赖 Lambda 的状态能力来实现持久的应用程序状态,但我们绝对可以将其用于缓存数据,这些数据也可以存储在其他位置。换句话说,虽然我们无法保证一个 Lambda 函数实例将被多次调用,但根据调用频率,我们确实知道它可能会。因此,缓存状态是 Lambda 本地存储的候选项。
我们可以使用 Lambda 的内存或磁盘位置来缓存数据。例如,假设我们始终需要一组相当及时的参考数据来处理事件,但“相当及时”的定义是“在最后一天内有效”。在这种情况下,我们可以在函数实例的第一次调用时加载参考数据,然后将该数据存储在本地的静态或实例成员变量中。请记住,我们的处理函数实例对象将仅在运行时环境中实例化一次。
另一个例子,假设我们希望在执行过程中调用外部程序或库 —— Lambda 为我们提供了一个完整的 Linux 环境来执行此操作。该程序/库可能太大,无法适应 Lambda 代码存储库(未压缩时最多限制为 250MB)甚至 Lambda 层(请参见本章稍后有关层的部分)。因此,我们可以在函数实例首次需要它时,将外部代码从 S3 复制到 /tmp,然后对于后续对该实例的请求,代码将已经在本地可用。
这两个示例都涉及由数据块组成的状态——应用程序数据或库和可执行文件。我们 Lambda 应用程序中的另一种形式的状态是代码本身的运行时结构,包括表示与外部服务连接的结构。这些运行时结构在函数调用时可能需要一定时间来创建,在服务连接的情况下可能需要时间来初始化,例如身份验证程序。在 Lambda 中,我们经常会将这些结构存储在比方法调用本身生命周期更长的程序元素中——在 Java 中,这意味着将它们存储在实例或静态成员中。
我们在本书的早些时候展示了这些例子。例如,在第五章的示例 5-3 中,我们将以下内容存储在实例成员中:
-
ObjectMapper实例,因为这是一个需要一定时间来实例化的程序结构 -
DynamoDB 客户端,它是连接到外部 DynamoDB 服务的连接
虽然我们通常出于性能原因在某些情况下使用这种形式的对象缓存,但它也可以显著提高我们整个系统的成本效益——详见“Lambda 运行模型及对下游系统成本影响”了解更多详情。
有时 Lambda 自身的状态能力是不足的——例如,我们的总缓存状态可能太大而无法放入内存,加载速度在冷启动期间太慢,或者需要频繁更新(在 Lambda 函数中更新本地缓存版本是一个棘手的事情,虽然可以做到)。在这种情况下,您可以选择使用前一节中提到的持久化服务作为缓存解决方案。
Lambda 和 Java 应用程序框架
注意
到目前为止,本书大部分指导都是关于如何使用 AWS Lambda,途中也有一些警告。现在我们将稍作偏离,谈谈一些不建议做的事情。
在过去的二十年中,使用某种容器和/或框架构建服务器端 Java 应用程序非常普遍。早在 2000 年代初,“Java 企业版”(J2EE)非常流行,像 WebLogic、WebSphere 和 JBoss 这样的应用服务器允许您使用 Enterprise JavaBeans(EJB)或 Servlet 框架构建应用程序。如果您那时不在,我们可以从个人经验向您保证,这并不是一件有趣的事情。
人们意识到这些大型服务器通常难以控制和/或昂贵,因此它们在很大程度上被更“轻量级”的替代品所取代,其中 Spring 是最常见的。当然,Spring 本身也在发展中演变为 Spring Boot,人们还使用各种 Java Web 框架来构建应用程序。
因为我们行业中有很多关于如何使用这些工具构建“Java 应用程序”的机构知识,因此有很大的诱惑继续使用它们,并将运行时从运行中的进程移植到 Lambda 函数中。AWS 甚至投入了大量精力支持正是这种思维方式,通过 无服务器 Java 容器 项目。
尽管我们钦佩 AWS 以这种方式“接人待物”的愿望,但我们强烈不建议在使用 Lambda 构建应用程序时使用大多数 Java 框架,原因如下。
首先,在单个 Lambda 函数中构建完整的应用程序违背了 Lambda 的基本理念。Lambda 函数应该是小型、独立、短暂的函数,是事件驱动的,并且被设计为接受特定的输入事件。“Java 应用程序”,相反,实际上是具有生命周期和状态的服务器,通常设计用于处理多种类型的请求。如果你在构建迷你服务器,那就不是在考虑无服务器的方式了。
其次,大多数应用服务器假设从一个请求到另一个请求存在一定的共享状态。虽然可以不按这种方式工作,但在这些环境中这并不是一种自然的工作方式。
我们认为这是一个坏主意的另一个原因是,它削弱了其他 AWS 无服务器服务提供的价值。例如,在前面提到的 AWS 项目中,使用了 API Gateway,但是在“全代理”模式下。这里有一个来自 Spring Boot 示例 的 SAM 模板片段:
Resources:
PetStoreFunction:
Type: AWS::Serverless::Function
Properties:
Events:
GetResource:
Type: Api
Properties:
Path: /{proxy+}
Method: any
以这种方式使用 API Gateway 意味着所有请求,无论路径如何,都将发送到一个 Lambda 函数,并且需要在 Lambda 函数中实现路由行为。虽然 Spring Boot 可以做到这一点,(a) API Gateway 将免费提供这个功能,而且 (b) 将它保留在 Lambda 函数中会使你的 Java 代码变得混乱。
本书前面我们提到,总体上我们对使用过多 API Gateway 功能持谨慎态度;例如,参见 “API Gateway 代理事件” 中关于请求和响应映射的讨论。然而,我们认为去除路由通常是在抽象出 API Gateway 使用过程中走得太远的一步。
正如我们在冷启动部分讨论过的那样,应用程序框架通常会减慢函数的初始化速度。虽然有些人可能会认为这是使用预置并发的好理由,但我们认为这只是一个权宜之计,而不是解决方案。
最后,基于容器和框架的应用程序往往具有大型的可分发构件——部分原因是因为依赖的库的数量,部分原因又是因为这类应用程序通常实现了许多功能。在整本书中,我们一直在试图通过最小化依赖关系,并将应用程序划分为多个可分发元素,以保持我们的 Lambda 函数干净而精简。使用应用程序框架与此思维方式背道而驰。
总而言之,以这种方式构建 Java Lambda 应用程序实际上是一个“方枘圆凿的问题”。是的,你可以让它工作,但这样做效率低下,并且如果你以这种方式工作,你将无法获得 Lambda 的所有好处。有一种真正的危险,即在 Lambda 的价值上达到“局部最大值”,并假设没有进一步的好处。
因此,如果我们不推荐使用这些框架,我们建议您如何使用您辛苦获得的知识和技能呢?
通常,我们发现程序员切换到“纯”Lambda 开发并不需要太长时间来摆脱他们过去习惯于的框架。只编写处理程序函数会带来一种“轻盈感”。此外,将旧的 Java 代码带到项目中并没有什么问题,只要它没有太多依赖于应用程序框架。如果您可以将您的领域逻辑提取为仅表达您业务需求的内容,那么您就走在了正确的道路上。
同样,使用“依赖注入”(DI)的理念仍然可以,这通常由框架提供。您可以选择“手工制作”这种 DI(我们的偏好),就像您在一些示例中看到的那样(请参见“添加构造函数”)。或者,您可以尝试使用框架仅提供依赖注入,而不使用它们通常附带的其他功能。
虚拟专用云
到目前为止,在我们的所有示例中,由 Lambda 函数调用的任何外部资源都是通过 HTTPS/“第 7 层”认证进行保护的。例如,当我们在示例 5-3 中的无服务器 API 示例中调用 DynamoDB 时,该连接仅通过从我们的 Lambda 函数传递给 DynamoDB 的凭据进行保护。
换句话说,DynamoDB 不是一个“防火墙”服务——它对互联网开放,并且任何其他地方的互联网上的任何机器都可以连接到它。
虽然这个“无防火墙”的新世界正在加速发展,但仍然有许多情况下,Lambda 函数将需要连接到一个被某种 IP 地址限制保护的资源。AWS 中完成这种操作的常见方法是使用 VPC。
VPC 比我们在本书中迄今讨论的任何其他内容都要低级。它们需要了解诸如 IP 地址、弹性网络接口(ENIs)、CIDR 块和安全组之类的东西,还向我们展示了 AWS 区域由多个 AZ 组成的事实。换句话说,“此处有龙!”
Lambda 函数可以配置为能够访问 VPC。Lambda 函数需要这样做的三个典型原因是:
-
要能够访问 RDS SQL 数据库(参见 图 8-2)
-
要能够访问 ElastiCache
-
要能够使用基于 IP/VPC 的安全性调用在容器集群上运行的内部微服务

图 8-2. 连接到 VPC 以访问 RDS 数据库的 Lambda
只有当 Lambda 实际需要时,才应配置 Lambda 使用 VPC。添加 VPC 不是“免费”的 —— 它会影响其他系统,改变 Lambda 与其他服务交互的行为方式,并给您的配置和架构增加复杂性。
此外,我们建议仅在以下情况下配置 Lambda 使用 VPC:(a) 您理解 VPC 并了解这样做的影响,或者 (b) 您已与组织中了解此要求的其他团队讨论过。
在本节的其余部分中,我们假设您对 VPC 有一个广泛的理解,但不一定了解 Lambda 和 VPC 的任何具体信息。因此,有一些 VPC 术语,如 ENIs 和安全组,我们会提及但不会解释。
使用 VPC 的 Lambda 的架构上的注意事项
即使在启用 Lambda 使用 VPC 之前,还有一些事项需要注意,这可能会改变您的想法!
首先,在您的 VPC 配置中指定的每个 子网 都是特定于一个 AZ 的。Lambda 的一个好处是,到目前为止,我们完全忽略了 AZ。如果您正在使用 Lambda + VPC,您需要确保配置足够多的子网,涵盖足够多的 AZ,以便您继续拥有所需的高可用性(HA)水平。
其次,当配置 Lambda 函数使用 VPC 时,那么 所有 来自该 Lambda 的网络流量都将通过 VPC 路由。这意味着,如果您的 Lambda 函数正在使用非-VPC AWS 资源(如 S3)或正在使用 AWS 外部 的资源,则您需要考虑这些资源的网络路由,就像您对 VPC 内的任何其他服务一样。例如,对于 S3,您可能需要设置一个 VPC 终端节点,而对于外部服务,则需要确保您的 NAT 网关配置正确。
配置 Lambda 使用 VPC
您已经阅读了所有警告,并确定了要使用的子网和安全组。现在,您如何实际配置 Lambda 来使用 VPC?
幸运的是,SAM 来帮忙了,而且使这变得相当简单。通过查看 AWS 提供的 示例(稍作裁剪),我们可以看到您需要对每个 Lambda 函数进行的更改:
AWSTemplateFormatVersion : '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Parameters:
SecurityGroupIds:
Type: List<AWS::EC2::SecurityGroup::Id>
Description: Security Group IDs that Lambda will use
VpcSubnetIds:
Type: List<AWS::EC2::Subnet::Id>
Description: VPC Subnet IDs that Lambda will use (min 2 for HA)
Resources:
HelloWorldFunction:
Type: AWS::Serverless::Function
Properties:
Policies:
— VPCAccessPolicy: {}
VpcConfig:
SecurityGroupIds: !Ref SecurityGroupIds
SubnetIds: !Ref VpcSubnetIds
总之,您需要:
-
为 Lambda 函数添加权限以附加到 VPC(例如通过使用
VPC AccessPolicy) -
添加 VPC 配置,包括安全组 ID 列表和子网 ID
就是这样了!这个特定示例假设你将使用CloudFormation 参数在部署时传递实际的安全组和子网 ID,但你也可以随意在模板中硬编码它们。
替代方案
如果我们所有的严重警告都足以让你不再使用带有 Lambda 的 VPC,那么你应该做什么?以下是几种方法。
第一种方法是使用不需要 VPC 的大致等效服务。例如,如果你打算使用 VPC 来访问 RDS 数据库,考虑改用 DynamoDB(尽管我们承认 DynamoDB 不是关系型数据库!)。或者考虑使用 Aurora 无服务器和其Data API。
接下来是重新架构你的解决方案。例如,是否可以使用消息总线作为中介,而不是直接调用下游资源?
第三个——如果你需要连接的是一个内部服务,那么考虑给该内部服务添加一个“第 7 层”认证边界。一种方法是向内部服务添加一个 API Gateway(或者如果它已经有一个,更新现有的 API Gateway),然后使用 API Gateway 的IAM/Sigv4 认证方案。
最后,如果你无法修改你的服务,你可以做类似于前面的想法,但在这种情况下使用API Gateway 作为代理到你的下游服务。
当然,还有一个选择——等待并看看 AWS 接下来会推出什么!例如,我们提到的无服务器 Aurora 的数据 API 是相对较新的,这表明可能会推出更多功能,帮助 Lambda 开发者避免 VPC 的危险!
层和运行时
如果你在 AWS Web 控制台中查看 Lambda 函数之一,现在你几乎知道那里的每一样东西都是用来做什么的了。角色、环境变量、内存、VPCs、DLQs、保留并发等等。然而,对于你们中观察力敏锐的人来说,你会看到页面顶部有一些到目前为止遗漏的内容:层。为了结束本章,我们将解释层是什么,为什么你(作为 Java 开发者)可能不会太在意它们,以及它们与另一种称为自定义运行时的能力有什么关系。
什么是层?
正如你现在所知,通常情况下,当你部署一个 Lambda 函数的新版本时,你会将代码及其所有依赖项打包成一个 ZIP 文件,并上传到 Lambda 服务。然而,随着依赖项的增加,这个构件变得越来越大,部署速度变慢。能不能有一个方法可以加快这个过程呢?
这就是 Lambda 层的用武之地。层是您 Lambda 函数的部署资源的一部分,与函数本身分开部署。如果您的层保持不变,那么当您部署 Lambda 函数时,您只需部署不在层内的代码更改。
这里有一个例子。假设您正在实现来自第一章(“文件处理”](ch01.html#file-processing-example)的照片处理示例,假设您 Lambda 函数实际执行图像处理的部分使用像ImageMagick这样的第三方工具。
现在,ImageMagick 可能是一个很少更改的依赖项。使用 Lambda 层,您可以定义一个层(它只是一个包含任何所需内容的 ZIP 文件),其中包含 ImageMagick 工具,然后在照片处理 Lambda 中引用该层。现在,当您更新 Lambda 函数时,您只需上传自己的代码,而不是同时上传 ImageMagick 和代码。
提示
ImageMagick 通常通过从应用程序调用外部进程而不是通过库 API 调用来使用。从 Lambda 函数内部调用外部进程是完全可以的——Lambda 运行时是一个完整的 Linux 环境。
层的另一个有用方面是,您可以在 Lambda 函数之间以及其他 AWS 账户之间共享层 —— 实际上,层可以公开共享。
何时使用层,何时不使用层
当层被宣布时,Lambda 使用世界的某些部分非常兴奋,因为他们认为层是 Lambda 函数的一种通用依赖系统。对于使用 Python 语言的人来说尤为如此,因为 Python 的依赖管理工具对某些人(例如,您的作者!)来说可能有点棘手。然而,尽管存在某些缺陷,Java 生态系统在依赖管理方面有着非常强大的表现能力。
我们认为有些特定情况下层非常有用。然而,我们对全面采用它们也有一些顾虑,例如:
-
由于层是在上传函数后与 Lambda 函数结合的,因此在测试时使用的依赖版本与部署版本可能不同。对我们来说,这是一种(通常是)不必要的协调头疼问题,需要加以管理。
-
Lambda 函数仅限于可以使用的层数(五层),因此如果您有超过五个依赖项,您仍然需要使用本地部署工具,那么为什么要增加层的额外复杂性呢?
-
层并不特别提供任何功能上的好处 —— 它们是一种部署优化工具(我们将讨论跨切面行为作为此的一个警告)。
-
特别是在开发 Java Lambda 时,Java 非常擅长定义其“独立世界”。例如,在 Java 中,通常只依赖于在 JVM 中运行的第三方代码,而不是调用系统库或可执行文件。基于此,以及 Maven 依赖的普遍性,可以轻松地在不使用 Lambda 层的 Java 应用中拥有一个统一的依赖管理系统。
-
有些人喜欢层可以手动更新函数而无需部署函数本身的事实。我们个人坚信,除非有特殊情况,将任何变更部署到生产环境的最佳方式是通过自动化持续交付过程,因此更改应用程序库依赖与配置模板层依赖的区别几乎总是无关紧要。
如果不指出层可以发挥作用的地方,我们会觉得有所遗漏。
首先,如果 Lambda 函数执行的部分与应用程序无关,而更多与组织的横切技术平台相关,则使用层作为替代部署路径可能会有用。例如,假设有一个需要运行的安全流程,但就应用程序开发人员而言,它只是一个“发出并忘记”的调用。在这种情况下,将该代码发布为一个层,并能够查询组织中所有 Lambda 函数配置,并确保它们使用正确版本的层,有助于组织治理。
另一个层次特别有用的地方是依赖是一个很大且很少更改的系统二进制文件。在这种情况下,使用层的额外复杂性可能值得改进部署速度的价值,特别是如果使用该层的函数的部署次数每天达到数百次或更多。
这第二种情况的一个有用示例是 Lambda 函数使用自定义运行时,我们现在将进行探讨。
自定义运行时
在本书中,除了我们的第一个例子使用了 Node 10 运行时之外,我们一直在使用 Java Lambda 运行时。AWS 提供了与不同编程语言相关联的多种运行时,并且此列表经常更新。
但是,如果您想使用 AWS 不支持的语言或运行时会发生什么?例如,如果您有一些 Cobol 代码要在 Lambda 函数中运行怎么办?或者,更可能的是,如果您想运行一个高度定制的 JVM,而不是 AWS 提供的那一个?
答案在于使用自定义运行时。自定义运行时是在 Lambda 执行环境中运行的 Linux 进程,可以处理 Lambda 事件。有一个特定的执行模型需要自定义运行时满足,但基本思想是当 Lambda 平台启动运行时实例时,它会配置一个实例特定的 URL,以便查询下一个要处理的事件。换句话说,自定义运行时使用轮询架构。
作为 Java 开发者,你通常很少需要或需要为生产使用使用自定义运行时。其原因有两点:
-
自定义运行时代码本身需要成为函数部署的一部分资产。虽然您可以将运行时打包到 Lambda 层中以避免在每次部署时上传它,但它仍会使用您的250MB 总解压缩部署包大小限制中的一部分。如果要运行自定义 JVM,则大多数 JVM 将会占用相当一部分空间,因此这将减少可用于应用代码的空间。
-
您需要在自定义运行时中重新实现许多 AWS 标准运行时中已经实现的内容,例如事件和响应的反序列化/序列化、错误处理等。
话虽如此,对于某些规模的组织来说,构建一个处理各种组织平台相关任务的自定义运行时可能会使 Lambda 开发变得更加高效,但我们建议在投入使用之前进行彻底分析!
摘要
在本章中,我们深入探讨了 Lambda 的一些高级方面。一些行为和配置在您将无服务器应用程序部署到生产环境时将至关重要。
您了解了以下内容:
-
Lambda 的各种不同的错误处理策略以及您可能选择配置和编程函数来处理错误的方式
-
Lambda 如何在您无需任何努力的情况下自动扩展的解放方式,您如何控制这种扩展,并且在多线程编程背景下这种行为意味着什么
-
Lambda 版本和别名是什么,以及如何使用它们进行“流量转移”方式发布新功能
-
冷启动是什么时候发生的,是否应该担心它们,以及如何减少它们对应用程序的影响(如果需要的话)
-
如何考虑 Lambda 开发中的持久性和缓存状态
-
如何将 Lambda 与 AWS VPCs 配合使用
-
Lambda 层和自定义运行时是什么,以及何时考虑使用它们。
在下一章中,我们将继续讨论 Lambda 的更高级方面,但这次是在 Lambda 如何与其他服务交互的背景下。
练习
-
在“示例:构建无服务器 API”中更新
WeatherQueryLambda以抛出异常。在尝试调用 API 时会看到什么行为? -
如果你已经按照第五章的练习实现了使用 SQS 队列,那么请更新从 SQS 读取的 Lambda 函数以抛出异常。Lambda 的重试行为符合你的预期吗?
-
研究后台线程和 Lambda 之间的交互——从第二章的“Hello World”示例开始(参见“Lambda Hello World(正确的方式)”),在处理程序中使用
ScheduledExecutorService及其scheduleAtFixedRate方法来重复记录接收到的事件。会发生什么?尝试使用一些Thread.sleep语句。 -
更新“示例:构建无服务器 API”以使用流量转移,从
Linear10PercentEvery10Minutes部署偏好开始。 -
扩展任务:如果你在 JVM 上使用不同的语言编程——比如 Clojure、Kotlin 或 Scala——尝试在其中一种语言中构建一个 Lambda 函数。
第九章:高级无服务器架构
在 第八章 中,我们讨论了 Lambda 的一些更高级的方面,这些方面在您开始将应用程序投入生产时变得重要。在本章中,我们继续这一主题,更广泛地探讨 Lambda 对架构的影响。
无服务器架构中的“陷阱”
首先,我们来看看无服务器架构的各个领域,如果不考虑它们可能会给您带来问题,并针对您的情况提供不同的解决方案。
至少一次交付
Lambda 平台保证,当上游事件源触发 Lambda 函数,或者另一个应用显式调用 Lambda 的 invoke API 调用 时,相应的 Lambda 函数将被调用。但平台不保证 函数将被调用的次数:即使没有错误发生,“偶尔,您的函数可能会多次接收相同的事件”。这就是“至少一次交付”,这是因为 Lambda 平台是分布式系统的原因。
大多数情况下,Lambda 函数每个事件只会被调用一次。但有时(远少于 1% 的时间),Lambda 函数会被多次调用。这为什么会成为问题?如何处理这种行为?让我们来看一下。
示例:Lambda 的“cron job”
如果您在工业界开发软件已经足够长的时间,您可能会遇到运行多个“cron job”(定时任务,可能每小时或每天运行一次)的服务器主机。因为这些任务通常不会一直运行,因此仅在每个主机上运行一个任务是低效的,因此在一个主机上运行多种类型的任务非常典型。这样做更有效率,但可能会引起运维方面的头痛:依赖冲突、所有权不确定性、安全问题等。
您可以将许多类型的定时任务作为 Lambda 函数实现。要获得 cron 的调度行为,可以使用 CloudWatch Scheduled Event 作为触发器。SAM 为您提供了一种 简洁的语法 来指定这一功能的触发器,并且甚至可以使用 cron 语法来指定 调度表达式。使用 Lambda 作为 cron 平台有各种好处,包括解决前面段落中的所有运维头痛。
使用 Lambda 实现定时任务的主要缺点是,如果函数运行时间超过 15 分钟(Lambda 的最大超时时间)或者需要超过 3GB 的内存。在这两种情况下,如果无法将任务分解为较小的块,则可能需要考虑使用 Step Functions 和/或者 Fargate。
但是,使用 Lambda 还有另一个缺点:非常非常地偶尔您的定时任务可能会在其计划时间或附近运行多次。通常这不会是一个值得考虑的问题——也许您的任务是一个清理工作,两次执行相同的清理操作略微低效但功能上是正确的。然而,有时这可能是一个很大的问题——如果您的任务是计算月度抵押利息,您不希望向客户收取两次费用。
Lambda 的这种至少一次交付特性适用于所有事件源和调用,不仅限于定时事件。幸运的是,有多种方法来解决这个问题。
解决方案:构建一个幂等系统。
面对这个问题的第一个,通常也是最好的解决方案是构建一个幂等系统。我们说这通常是最好的解决方案,是因为它接受我们在使用 Lambda 时构建分布式系统的思想。与其绕过或忽视分布式系统的特性,我们积极设计来与其协同工作。
当特定操作可以应用一次或多次,并且无论应用多少次都具有相同效果时,系统是幂等的。考虑到任何分布式架构时,幂等性是一个非常普遍的要求,更不用说无服务器架构了。
一个幂等操作的例子是将文件上传到 S3(忽略任何可能的触发器!)。无论您将同一文件上传到相同位置一次还是十次,最终结果是预期的键中 S3 中存储的正确字节。
当函数的任何重大副作用本身是幂等时,我们可以使用 Lambda 构建一个幂等系统。例如,如果我们的 Lambda 函数将文件上传到 S3,则 Lambda + S3 的整个系统是幂等的。类似地,如果您在写入数据库时可以使用upsert操作(“更新或插入”),如 DynamoDB 的UpdateItem方法,来创建幂等性。最后,如果您调用任何外部 API,则可能需要查看它们是否提供幂等操作。
解决方案:接受重复,并在问题出现时进行处理。
有时处理可能发生的多次调用的一个完全合理的方法是意识到它可能会发生,并接受它,特别是因为它发生得如此之少。例如,假设您有一个定时任务生成报告然后将其电邮发送到公司内部邮件列表。如果偶尔发送两次电子邮件,您是否在意?也许不会。
同样地,也许构建一个幂等系统的工作量是显著的,但是处理非常偶尔的任务重复的影响实际上是简单和廉价的。在这种情况下,与其内置幂等性,也许更好的方法是监控某个事件的多次运行,然后有一个手动或自动的任务在它发生时执行清理。
解决方案:检查之前的处理
如果重复的副作用从不可接受,但是您的 Lambda 函数还使用了不具有幂等操作的下游系统,那么您有另一种解决此问题的方式。关键在于使您的 Lambda 函数本身具有幂等性,而不是依赖下游组件提供幂等性。
但是,您如何做到这一点,知道 Lambda 可能会多次为同一事件调用函数?关键在于还要知道,即使 Lambda 为同一事件调用多次函数,Lambda 附加到事件的AWS 请求 ID将对每次调用都相同。我们可以通过在我们的处理程序方法中调用.getAwsRequestId()来读取 AWS 请求 ID,我们可以选择接受Context对象。
假设我们可以跟踪这些请求 ID,我们将知道我们以前是否见过某个请求 ID,如果是,则可以选择丢弃第二次调用,从而保证总体语义上的“仅一次”保证。
现在,我们只需要一种方法来检查每次函数调用是否已经看到过请求 ID。因为理论上事件的多个函数调用可能重叠,所以我们需要一个原子性的来源来提供这种能力,这表明使用数据库会有所帮助。
DynamoDB 可以通过其条件写入功能为我们提供这一点。在一个简单的场景中,我们可以有一张只有request_id主键的表;我们可以在处理程序开始时尝试写入该表,使用事件的请求 ID;如果 DynamoDB 操作失败,则立即停止执行;否则,我们可以像往常一样继续 Lambda 的功能,知道这是第一次处理事件(参见图 9-1)。

图 9-1. 使用 DynamoDB 检查以前的事件
如果您选择沿着这条路走,您的实际解决方案可能会有一些细微差别。例如,如果发生错误,您可以选择在 DynamoDB 中删除行(以便继续使用 Lambda 的重试语义 - 重试的事件也将具有相同的 AWS 请求 ID!)。或者,您可以选择更复杂的“带超时的锁定”风格行为,以允许第一个调用失败时的重叠调用。
使用这种解决方案时,还需要考虑一些 DynamoDB 的问题。例如,您可能希望在表上设置存活时间(TTL)属性,以在一段时间后自动删除行以保持清洁,通常设置为一天或一周。另外,您可能需要考虑 Lambda 函数的预期吞吐量,并使用它来分析 DynamoDB 表的成本 —— 如果成本太高,您可能需要选择另一种解决方案。此类替代方案包括使用 SQL 数据库;构建自己的(非 Lambda)服务来管理此重复操作;或者,在极端情况下,将 Lambda 完全替换为具有更传统计算平台的特定功能。
Lambda 扩展对下游系统的影响
在第八章中,我们看到了 Lambda 的“神奇”自动扩展(“扩展”)。简单总结一下,Lambda 将自动创建所需数量的函数实例及其环境,以处理所有待处理的事件。默认情况下,它会创建每个帐户最多一千个 Lambda 实例,并且如果您要求 AWS 增加您的限制,它还会创建更多。
总的来说,这通常是一个非常有用的功能,也是人们发现 Lambda 有价值的关键原因之一。但是,如果您的 Lambda 函数与下游系统进行交互(大多数情况下都是如此!),那么您需要考虑这种扩展如何影响这些系统。作为一项练习,让我们考虑第五章中的示例。
在 “示例:构建无服务器 API” 中,我们有两个函数 —— WeatherEventLambda 和 WeatherQueryLambda —— 都调用了 DynamoDB。我们需要知道 DynamoDB 是否能够处理存在的任何上游 Lambda 实例的负载。由于我们使用了 DynamoDB 的“按需”容量模式,我们知道事实上情况确实如此。
在 “示例:构建无服务器数据管道” 中,我们还有两个函数 —— BulkEventsLambda 和 SingleEventLambda。BulkEventsLambda 调用 SNS,具体来说是为了发布消息,因此我们可以查看AWS 服务限制文档以了解我们可以向 SNS API 发出多少发布调用。该页面说限制在每秒 300 到 30,000 “事务”之间,取决于我们所在的地区。
我们可以使用这些数据来判断我们是否认为 SNS 能够处理我们从 Lambda 函数上可能产生的负载。此外,文档指出这是一个软限制——换句话说,我们可以请求 AWS 为我们增加它。值得知道的是,如果我们超过了限制,那么我们对 SNS 的使用将受到限制——我们可以将此错误通过 Lambda 函数传递回去,作为未处理的错误,从而使用 Lambda 的重试机制。另外,值得一提的是这是一个账户范围的限制,因此,如果我们的 Lambda 函数导致我们达到了 SNS API 的限制,同一账户中使用 SNS 的任何其他组件也将受到限制。
SingleEventLambda 只通过 Lambda 运行时间接调用 CloudWatch Logs。CloudWatch Logs 有限制,但它们非常高,所以目前我们将假设它具有足够的容量。
总之,我们在这些示例中使用的服务可以扩展到高吞吐量。这应该不足为奇——这些示例被设计为无服务器架构的好例子。
然而,如果你正在使用的下游系统要么(a)不像你的 Lambda 函数可能会扩展的那么多,要么(b)不像你的 Lambda 函数可能会扩展的那么快,那会发生什么?(a)的一个例子可能是下游关系型数据库——它可能只设计用于一百个并发连接,而五百个连接可能会给它带来严重的问题。 (b)的一个例子可能是使用基于 EC2 的自动缩放的下游微服务——这里该服务最终可能会扩展到足以处理意外负载,但 Lambda 可以在秒内扩展,而不是 EC2,后者将在分钟内扩展。
在这两种情况下,Lambda 函数的不可预见的扩展可能会对下游系统产生性能影响。通常,如果出现这样的问题,则这些效果也会被这些系统的其他客户感受到,而不仅仅是产生负载的 Lambda 函数。由于这个原因,你应该始终考虑 Lambda 对下游系统的扩展的影响。有多种可能的解决方案来处理这个问题。
解决方案:使用相似的扩展基础设施
一种解决方案是,在可能的情况下,使用具有与 Lambda 本身相似的扩展行为和容量的下游系统。我们选择 DynamoDB 和 SNS 在第五章的示例部分是部分由于这个设计动机。同样,有时我们可能会选择积极迁移离开某些解决方案,正是因为扩展的考虑。例如,如果我们可以轻松地从 RDS 数据库切换到使用 DynamoDB,那么这样做可能是有道理的。
解决方案:管理上游的扩展
另一个解决 Lambda 扩展超出下游系统的问题的方法是确保它根本不需要扩展,或者换句话说,限制触发执行的事件数量。如果您正在实施公司内部的无服务器 API,则可能意味着确保 API 的客户端不要发出过多的请求。
某些 Lambda 事件源还提供了帮助管理规模的功能。API 网关具有速率限制(具有 使用计划 和 节流限制),Lambda 的 SQS 集成允许您配置批量大小 (https://oreil.ly/LxNTp)。
解决方案:使用保留并发管理扩展性
如果您无法在上游管理规模,但仍希望限制函数的规模,可以使用 Lambda 的保留并发功能,我们在 “保留并发” 中进行了介绍。
当使用保留并发时,Lambda 平台将最多按照您配置的数量扩展函数。例如,如果将保留并发设置为 10,则在任何时候最多运行 10 个 Lambda 函数实例。在这种情况下,如果已有 10 个 Lambda 实例正在处理事件,当另一个事件到达时,您的函数会被节流,就像我们在 第八章 中讨论过的那样。
当您的事件源(如 SNS 或 S3)可能会轻松产生“突发”事件时,这种规模限制非常适用——使用保留并发意味着这些事件会在一段时间内处理,而不是立即全部处理。由于 Lambda 对于节流错误和异步来源的重试能力,您可以确保所有事件最终会被处理,只要在六小时内可以处理完毕。
您应该了解有关保留并发的一项行为是它不仅限制并发——它通过从全局 Lambda 并发池中移除配置的数量来保证并发。如果您有 20 个函数,每个函数的保留并发为 50,假设全局并发限制为 1,000,则将没有更多容量用于其他 Lambda 函数。全局并发限制可以增加,但这是一个需要记住执行的手动任务。
解决方案:有意构建混合解决方案
最后一个想法是有意“混合”构建解决方案(而不是意外混合解决方案),包括无服务器和传统组件。
例如,如果您使用 Lambda 和亚马逊的(非无服务器)RDS SQL 数据库服务,而没有考虑扩展性问题,我们将这称为“意外”混合解决方案。然而,如果您考虑了如何通过 Lambda 更有效地使用您的 RDS 数据库,那么我们将其称为“故意”混合。并且明确一点——我们认为某些架构解决方案由于像 DynamoDB 这样的服务和 Lambda 本身的性质,混合使用无服务器和非无服务器组件会更好。
让我们考虑一个例子,您通过 Lambda 函数将数据注入关系数据库,可能是在 API Gateway 的背后(参见图 9-2)。

图 9-2. 从 Lambda 函数直接写入关系数据库
此设计的一个问题是,如果您有太多的入站请求,那么您可能会过载您的下游数据库。
您可能首先考虑的解决方案是为支持 API 的 Lambda 函数添加保留并发,但问题在于现在您的上游客户端将不得不处理由并发限制引起的节流问题。
因此,更好的解决方案可能是引入一个消息主题、一个新的 Lambda 函数,并在第二个 Lambda 函数上使用保留并发(参见图 9-3)。

图 9-3. 通过主题从 Lambda 函数间接写入关系数据库
使用这种设计,例如,您的 API Lambda 函数仍然可以执行输入验证,必要时向客户端返回错误消息。但是,它不会直接写入数据库,而是会将消息发布到一个主题,例如,使用 SNS,在假设您的消息系统可以比数据库更有效地处理突然负载的情况下。然后,该消息的监听者将是另一个 Lambda 函数,其工作纯粹是执行数据库写入(或“upsert”,以处理重复调用!)。但这次 Lambda 函数可以应用保留并发以保护数据库,同时利用 AWS 内部的重试语义,而不是要求原始外部客户端执行重试。
虽然这种结果设计具有更多的移动部件,但它成功解决了扩展性问题,同时仍然混合使用了无服务器和非无服务器组件。
小贴士
2019 年底,亚马逊宣布了RDS 代理服务。截至撰写本文时,该服务仍处于“预览”阶段,因此发布到普遍可用(GA)时的许多细节和功能尚未明确。然而,它肯定会在连接 Lambda 到 RDS 的一些讨论中帮助解决一些问题。
Lambda 事件源的“细则”
本章的前几节讨论的是 Lambda 本身的微妙架构问题。由于 Lambda 上游存在的服务的细微差别,还有其他领域可能会影响无服务器设计。就像“至少一次”交付不是您在关于 Lambda 的第一篇文档中看到的核心内容一样,只有通过深入探索文档或艰辛的经验,您才能发现这些服务的一些微妙差别。
当您开始超越任何 Lambda 事件源的“试验性”阶段时,请尽可能阅读关于您正在使用的服务的所有 AWS 文档。也要寻找非 AWS 的文章——尽管它们不具权威性,有时是错误的,但有时候它们可以在架构上推动您朝着可能否则不会考虑的方向前进。
由无服务器思维启用的新架构模式
有时,当我们构建无服务器系统时,从某种距离来看,我们的架构可能并不比使用容器或虚拟机(VM)设计的方式看起来有多不同。“云原生”架构并不仅仅是 Kubernetes 的专属领域,无论您之前听到过什么!
例如,我们构建的无服务器 API 回溯到“示例:构建无服务器 API”,从“黑盒”视角来看,看起来就像任何其他微服务风格的 API。事实上,我们可以用运行在容器中的应用程序替换 Lambda 函数,从架构上讲,系统将会非常相似。
随着无服务器开始成熟,我们看到了一些新的架构模式,这些模式要么在传统服务中没有意义,要么甚至是不可能的。我们在第五章中提到过其中一种,当我们讨论“无 Lambda 的无服务器”时。在本章的结束部分,我们将看看其他几种模式,使用 Lambda,打破进入新领域。
使用无服务器应用程序库发布组件
在本书中我们多次提到“无服务器应用程序”——作为一个单元部署的组件集合。我们有我们的无服务器 API,使用 API Gateway,两个 Lambda 函数和一个 DynamoDB 表,全部作为一个单元分组。我们使用 Serverless Application Model(SAM)模板定义了这些资源集合。
AWS 提供了一种通过无服务器应用程序库(SAR)重用和共享这些 SAM 应用程序的方式。使用 SAR,您可以发布您的应用程序,然后稍后可以部署它,多次部署到不同的区域、帐户甚至不同的组织,如果您选择将 SAR 应用程序公开可用的话。
传统上,您可能有分发的代码或一个环境无关的部署配置。使用 SAR,代码(通过打包的 Lambda 函数)、基础架构定义和(可参数化的)部署配置全部捆绑在一个可共享的、版本化的 组件中。
SAR 应用程序可以通过几种不同的部署方式部署,这使它们在不同情况下都很有用。
首先,它们可以部署为 独立应用程序,就像您直接调用 sam deploy 一样,而不是使用 SAR。当您希望在多个位置或跨多个帐户或组织部署相同的应用程序时,这很有用。在这种情况下,SAR 在某种程度上就像应用程序部署模板的存储库,但通过打包代码,它还包括实际的应用程序代码。
此类用途的 SAR 应用示例在公共 SAR 存储库中数不胜数——对于希望简化客户将集成组件部署到其 AWS 帐户的第三方软件提供商来说,这尤其有用。例如,这是来自 DataDog 的日志转发器。
SAR 应用程序也可以作为其他 父级 无服务器应用程序中的 嵌入式 组件通过CloudFormation 嵌套堆栈使用。SAM 通过 AWS::Serverless::Application 资源类型 实现了 SAR 组件的嵌套。当以这种方式使用 SAR 时,您正在将高级组件抽象为 SAR 应用程序,并在多个应用程序中实例化这些组件。以这种方式使用 SAR 有点像在基于容器的应用程序中使用 “旁车”,但没有旁车需要的低级网络通信模式。
这些嵌套组件可能包括可以直接调用的 Lambda 函数,也可能是通过父应用程序(例如,通过 SAR 也许也包含在其中的 SNS 主题)间接调用的。或者,这些嵌套组件可能根本不包含任何函数,而是仅定义基础资源。一个很好的例子是标准化监控资源的 SAR 应用程序。
通常情况下,我们更喜欢嵌入式部署方案,即使父应用程序中没有其他组件。这是因为部署 SAR 应用程序以及可以在模板文件中作为 AWS::Serverless::Application 资源的一部分定义的参数值与部署任何其他 SAM 定义的无服务器应用程序没有任何区别。此外,如果您选择更新已部署的 SAR 应用程序的 版本,那么这也可以像任何其他模板更新一样在版本控制中跟踪。
SAR 应用程序 可以进行安全设置,以便仅对特定 AWS 组织中的帐户可访问,因此它们是定义可在整个公司中使用的标准组件的绝佳方式。 使用此功能的示例包括 API Gateway 的自定义授权程序、标准运行组件(例如警报、日志过滤器和仪表板)以及消息传递式跨服务通信的常见模式的使用。
SAR 确实有一些限制。 例如,您无法在其中使用所有 CloudFormation 资源类型(例如,EC2 实例)。 但是,这是一种有趣的构建、部署和组合基于 Lambda 的应用程序的方式。
有关如何将 SAM 应用程序发布到 SAR 的详细信息,请参阅文档,有关部署 SAR 应用程序的详细信息,请参阅前面链接的 AWS::Serverless::Application 资源类型。
全球分布式应用程序
在很久以前(即大约 15 年前),我们大多数构建基于服务器的应用程序的人通常对我们的软件实际运行的地点有一个相当清楚的概念,至少在一百米左右,甚至更近。 我们可以准确指出数据中心、服务器房间,甚至我们的代码正在运行的机架或个别机器。
然后“云”出现了,我们对应用程序地理部署的理解变得有点,嗯,模糊了。 例如,使用 EC2,我们大致知道我们的代码正在“北弗吉尼亚”或“爱尔兰”等地区运行,我们还知道两台服务器在同一数据中心运行时,通过它们的可用性区域(AZ)位置。 但是我们极少有可能能够在地图上指出软件运行的建筑物。
无服务器计算立即将我们的考虑半径进一步扩大。 现在我们只考虑区域——AZ 概念隐藏在抽象之中。
知道应用程序运行在何处的原因之一是考虑可用性。 当我们在数据中心运行应用程序时,我们需要知道如果数据中心失去互联网连接,那么我们的应用程序将不可用。
对于许多公司,特别是习惯于部署到一个数据中心的公司来说,云提供的这种区域级别的可用性已经足够了,特别是由于无服务器服务保证了区域内的高可用性。
但是如果你想要思考更大的问题呢? 例如,如果您希望即使 AWS 的整个区域变得不稳定,也能保证应用程序的弹性? 这种情况时有发生——只要与使用 us-east-1 的人交谈至少有几年的人。 好消息是 AWS 很少出现任何类型的跨区域中断。 绝大多数 AWS 的停机时间都限制在一个区域。
或者,不仅仅关注可用性,如果您的用户分布在世界各地,从圣保罗到首尔,您希望他们所有人都能低延迟访问您的应用程序,那怎么办呢?
云中存在多区域后,解决这些问题就变得可能。然而,在多个区域中运行应用程序是复杂的,并且随着增加更多区域,成本可能会变得很高。
然而,Serverless 可显著简化和降低这个问题的成本。现在可以在全球多个区域部署您的应用程序,而不会增加太多复杂性,也不会破坏您的预算。
全球部署
当您在 SAM 模板中定义应用程序时,通常不会将任何特定于区域的资源硬编码。如果您需要在 CloudFormation 字符串中引用堆栈部署的区域(例如我们在 第五章 中的数据管道示例中所做的),我们建议使用 AWS::Region 伪参数。对于需要访问的任何特定于区域的资源,我们建议通过 CloudFormation 参数引用这些资源。
使用这些技术,您可以以与区域无关的方式定义您的应用程序模板,并将其部署到任意数量的 AWS 区域。
实际上,将您的应用程序部署到多个区域并不像我们希望的那样容易。例如,使用 CloudFormation 部署应用程序(例如使用 sam deploy)时,在模板文件中引用的 CodeUri 属性中的任何包必须在部署的同一区域内的 S3 存储桶中可用。因此,如果您希望将应用程序部署到多个区域,则其打包的构件需要在多个 S3 存储桶中可用,每个区域一个。这并不是无法解决的小问题,但这是您需要考虑的事情。
AWS 通过在 CodePipeline 中启用 “跨区域操作”,改善了多区域部署的体验。CodePipeline 是亚马逊的 “持续交付” 编排工具,允许我们定义项目的源代码存储库;通过调用 CodeBuild 构建和打包应用程序;最后使用 SAM/CloudFormation 部署应用程序。CodePipeline 实际上是在本书中我们手动运行的命令之上的自动化系统。它将做的远不止这些——这里的流程只是一个示例。
“跨区域操作” 在 CodePipeline 中允许您并行部署到多个区域,目前支持 CodePipeline 的区域数量。这意味着一个 CD 流水线可以将应用程序部署到美国、欧洲、日本和南美。
设置所有这些仍然有些棘手。有关更多信息,请参阅我们在 Github 上的 示例项目。
另一个有助于多区域部署的工具是无服务器应用程序存储库,我们在前一节中描述过。当您通过一个区域将应用程序发布到 SAR 时,它将在全球所有区域提供。在撰写本文时,这仅适用于公开共享的应用程序,但我们希望这个功能很快也能适用于私有应用程序。
本地化连接,具备故障转移能力
一旦您在全球范围内部署了您的应用程序,用户如何连接到他们附近的版本呢?毕竟,全球部署的一个重要目的是接受光速有限的事实,因此将用户的请求路由到他们客户端附近的应用程序的最接近地理版本,为用户提供尽可能低延迟的体验。
一种方法是在客户端内部硬编码区域特定位置,通常是一个 DNS 主机名。这有点粗糙,但有时是有效的,特别是对于组织内部的应用程序。
另一个通常更好的选择是,因为它可以 动态 适应用户的位置,是采用亚马逊的 Route53 DNS 服务,特别是其 地理位置 功能。例如,如果用户通过部署在三个不同区域并行的 API 网关连接到您的应用程序,那么您可以在 Route53 中设置 DNS,使用户连接到距离他们最近的 API 网关所在的区域。
由于您在这一点上已经在使用 Route53 的一些高级功能,您可以进一步使用 健康检查和 DNS 故障转移。通过 Route53 的这一特性,如果用户最接近的应用程序版本不可用,那么 Route53 将将该用户重定向到下一个 最 近可用的应用程序版本。
现在我们有我们应用程序的主动-主动版本 和 本地化路由。我们构建了一个既具有弹性 又 性能更好的应用程序。到目前为止,我们的应用程序架构没有更新,只有操作性的更新。然而,我们确实应该面对房间里的大象。
全局状态
我们之前说过,无服务器使得可以将您的应用程序部署到全球多个区域,而几乎不增加复杂性。我们刚刚描述了部署过程本身,并讨论了用户如何通过互联网访问您的应用程序。
然而,全球应用程序的一个重要关注点是如何处理状态。最简单的解决方案是将您的状态仅放在一个区域,并将使用该状态的服务部署到多个区域中(图 9-4)。

图 9-4. 多个计算区域和一个数据库区域
这是内容传递网络(CDN)使用的相同模型——世界某处有一个“起点”,然后 CDN 在全球的数十甚至数百个“点位”上缓存状态。
这对于可缓存状态是可以接受的,但不可缓存情况怎么办呢?
在这种情况下,单区域状态模型崩溃,因为所有您的区域将调用集中数据库区域的每个请求。您失去了本地化延迟的好处,并且面临区域性故障的风险。
幸运的是,AWS 和其他主要云提供商现在提供全球复制的数据库。AWS 上的一个很好的例子是DynamoDB 全局表。假设您正在使用第五章中的无服务器 API 模式——您可以将设计中的 DynamoDB 表从该示例替换为全局表。然后,您可以将您的 API 快乐地部署到全球多个地区,AWS 将为您安全地在全球范围内移动数据。这为您提供了弹性和改进的用户延迟,因为 DynamoDB 的表复制是异步进行的(见图 9-5)。

图 9-5. 具有复制数据库的多个区域
AWS 确实对全球表收取额外费用,但与在每个地区建立状态复制系统相比,费用并不是太高。
按使用付费
关于成本问题,当涉及到多区域部署时,无服务器计算真正确定交易的地方在这里。在第一章中,我们说无服务器服务的一个具体区别是它“根据精确的使用情况收费,从零使用到高使用。”这不仅适用于一个区域,而是跨区域。
例如,假设您已经将 Lambda 应用程序部署到三个地区,因为您希望有两个备份地区用于灾难恢复。如果您只使用其中一个地区,那么您只需支付该地区中 Lambda 使用的费用——您在其他两个地区的备份版本是免费的!这与任何其他计算范式有很大的不同。
另一方面,假设您从一个地区部署应用程序开始,然后将您的 API Gateway + Lambda 应用程序部署到十个地区,使用我们之前讨论过的地理位置 DNS 路由。如果您这样做,您的 Lambda 账单不会改变——无论您在一个地区还是十个地区运行,因为 Lambda 仍然只按您函数中发生的活动量收费。您之前的使用量没有增加;现在只是分布在十个地区之间。
我们认为,与传统平台相比,这种极其不同的成本模型将使全球分布式应用比过去更加普遍。
注意
在 Lambda 成本“没有变化”的观点上,这里有一个小小的警告。AWS 可能会根据不同地区对 Lambda 收费略有不同。然而,这是区域特定定价的一部分,而不是因为在多个地区运行应用程序。
边缘计算/“无区域”
到目前为止,在本节中我们讨论的示例都是关于在全球多个地区部署,但它们仍然要求我们理解亚马逊的整个云被划分为这些不同的地区。
如果你根本不需要考虑地区会怎么样?如果你能够将你的代码部署到一个全球服务,并且 AWS 只需执行运行代码所需的一切操作,以提供用户最佳的延迟,并确保即使一个位置下线也能保持可用性?
结果证明,这种未来的疯狂想法已经实现了。有点像。首先,AWS 已经有一些被称为“全球服务”的服务——IAM 和 Route53 就是其中两个。但 AWS 的 CloudFront:AWS 的 CDN 也是。虽然 CloudFront 做了你期望的任何其他 CDN 所做的事情——缓存 HTTP 流量以加快网站速度——它还具有通过名为 Lambda@Edge 的服务调用特殊类别的 Lambda 函数的能力。
Lambda@Edge 函数与 Lambda 函数大多类似——它们具有相同的运行时模型和大多数相同的部署工具。当你部署一个 Lambda@Edge 函数时,AWS 会在全球范围内复制你的代码,因此你的应用程序真正变成了“无区域”。
然而,Lambda@Edge 有许多显著的限制,包括:
-
唯一可用的事件源是 CloudFront 本身——因此,你只能在 CloudFront 发布中的 HTTP 请求处理过程中运行 Lambda@Edge。
-
Lambda@Edge 函数在撰写本文时,只能用 Node 或 Python 编写。
-
Lambda@Edge 环境相比常规 Lambda 函数在内存、CPU 和超时方面有更多限制。
Lambda@Edge 函数令人着迷,即使在撰写本文时,它们也非常适合解决特定问题。但更重要的是,它们指向了真正全球化的云计算未来,其中局部性完全抽象化。如果 AWS 能够将 Lambda@Edge 的能力更接近常规 Lambda,那么作为架构师和开发者,我们将摆脱区域思维的道路已经走得很远。也许当人们在火星上运行应用程序时,我们仍然需要考虑局部性,但这距离还有几年。Lambda 承诺无服务器,但并非无行星!
摘要
当我们构建无服务器系统时,我们在代码和运维方面的投入减少了,但其中一部分工作需要用更多的架构思考来代替,特别是关于我们正在使用的托管服务的能力和限制方面。在本章中,您更详细地了解了其中一些问题,并审视了一些缓解方法。
无服务器计算还提供了完全新的软件架构方式。您了解到了两个这样的概念——无服务器应用程序仓库和全球分布的应用程序。随着 Lambda 和无服务器技术的进化,在未来几年中,我们预计会看到更多新的应用程序架构模型的出现。
练习
-
更新从“示例:构建无服务器数据管道”中的数据管道示例——将
SingleEventLambda的预留并发设置为 1。现在上传示例数据——如果需要,请向sampledata.json文件中添加几个更多的元素以产生限流现象。使用 Lambda Web 控制台中的“限流”行为将预留并发设置为零。 -
更新“示例:构建无服务器 API”以使用 DynamoDB 全局表——确保将表本身分离到自己的 CloudFormation 堆栈中!然后将 API 组件(及其 Lambda 函数)部署到多个区域。您能够将数据写入一个区域然后从另一个区域读取吗?
第十章:结论
本书的目标是让您了解在 AWS 上使用无服务器技术构建和运行应用程序的含义,其中 AWS Lambda 是这些系统的核心。我们希望您有信心这样做,了解到 Java 在无服务器世界中确实是一个一流的语言选择。
我们鼓励您反思本书中我们试图强调的一些要点:
-
最重要的是,要知道在无服务器系统中尝试各种想法是快速且廉价的。如果有疑问,就进行实验吧!
-
请记住 Lambda 代码只是“代码”。Lambda 不是一个框架,也不是传统意义上的“应用服务器”——您的 Lambda 函数只是处理 JSON 事件的小片段 Java 代码。这使得在 IDE 中进行单元测试和增量开发变得快速和灵活。同样,尽量不要使用不必要的为其他运行时模型设计的库和框架使您的函数臃肿。
-
自动化构建和部署函数到 AWS 云的脚本编写。您希望能够在处理生产事件的相同环境中快速迭代。使用本书中介绍的 Maven、SAM 和 CloudFormation 技术来实现这一点。
-
正如我们在 第六章 中展示的那样,大部分测试时间应花在快速单元测试和功能测试上,这些测试在一个 JVM 中与待测试的函数一起在本地运行,但也要投资于自动化的端到端测试,这些测试可以在 Lambda 平台上运行您的函数。
-
尽量使每个 Lambda 函数专注于解决一个任务。只需包含处理每个函数自身事件所需的代码和库。必要时,使用我们在 “使用多模块和隔离构建和打包” 中描述的代码共享。
-
不要害怕冷启动!通常情况下,一旦您的应用程序投入生产,它们就不会成为您的问题,或者如果有必要,您可以使用一个或多个补救技术。
-
适当地保护您的无服务器应用程序,考虑到最小特权原则,使用 AWS IAM。您的组织可能会部署数千个 Lambda 函数,因此您希望减少每个函数的影响范围,以减少错误或可能的恶意意图的影响。
-
请记住,在 Lambda 这个新世界中,日志记录和度量工作有所不同。尽量使用结构化日志记录;请记住,您希望能够观察到整个系统的行为,而不仅仅是单个函数。考虑哪些度量标志最能反映系统的健康状态,就您的用户而言。
-
在构建无服务器应用程序时,要采用“事件驱动”的思维方式。即使是同步调用的函数,也要考虑每次调用如何代表消息从一个组件传递到下一个组件。然后思考如何尽可能地使系统异步化。
-
不一定要放弃非无服务器服务。像关系数据库这样的东西可能仍然是您解决特定问题的最佳方式,特别是如果它们已经存在于您较大的生态系统中。但请仔细考虑在一个处理规模非常不同的世界中如何使用它们。
-
最后,无服务器不仅仅是 Lambda——考虑如何依靠 AWS 和其他提供商的 BaaS 产品来减少您需要编写和操作的代码量。即使您已经确定了特定的服务,也要调查其所有功能——它可能有一些隐藏的宝藏,可以为您节省数天甚至数周的工作时间。
希望您喜欢这本书,发现它有价值,并希望在未来的几个月和年中继续为您提供有用的资源。我们将继续撰写和讲述 Lambda 和其他 AWS 技术的学习和应用。
您可以在以下位置找到我们的工作:
-
在 Twitter 上https://twitter.com/symphoniacloud,https://twitter.com/johnchapin,和https://twitter.com/mikebroberts
-
我们的网站在https://www.symphonia.io
-
我们的 GitHub 存储库在https://github.com/symphoniacloud
当然,我们很乐意听到您的进展。请随时通过 johnandmike@symphonia.io 联系我们。
感谢阅读,加入无服务器行列吧!


浙公网安备 33010602011771号