《架构师》2022年4月
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

“神奇”的AWS无服务器开发体验

作者 Akos Krivachy 译者 王强

当开发者想要开发无服务器和云原生应用时,一个常见的问题是:这方面的开发体验到底怎么样呢?这个问题很很重要,因为良好的开发体验和快捷的反馈通道会让开发者更开心、更有生产力,从而能够快速交付特性。

由于我们在建立Plain时有意缩小规模,所以我们必须有出色的开发体验。我们需要确保公司聘请的少数几位工程师能够在保持高质量的同时快速交付产品特性,产生最大的影响力。

2021年我们有了时间去思考这个问题的解决方案,因为Plain是从头开始建立的。在我们选择常用的技术栈时,一方面要考虑到公司每天都会做变更,另一方面我们希望基础平台能够支持未来5-10年的业务成长与成就。这意味着平台应该能以较低的成本大规模运行我们的服务,而不需要专门划分出一个部门来管理自主研发的基础设施。

作出这些决策背后的理由肯定需要单独写文章来谈了,不过我们最终决定完全投入无服务器和云原生、全栈TypeScript,并使用AWS作为我们的云供应商,因为它足够成熟也非常流行。我们认为,使用AWS的专有服务是一种可以接受的供应商锁定权衡,因为相比更换云供应商的自由度来说,从这一决策中获得的价值是更高的。我确实看到过一些公司花了大量精力去尝试做跨云(cloud-agnostic,云不可知),但实际上并没有从中得到任何现实收益。

无服务器开发的独特之处

无服务器应用程序的开发和测试工作有一些独特的要素。与传统开发相比的一大区别在于,你最终会使用大量云服务,并会尽量把责任卸载给无服务器解决方案。

就AWS Lambda而言,这意味着你最后往往会使用API Gateway、DynamoDB、SQS、SNS、S3、EventBridge、ElastiCache等来构建你的应用。使用这么多服务需要开发、测试和部署大量配置、权限和基础设施。如果你只关心你的lambda代码的测试工作,那么就会略过很大一部分特性。如果你不验证你的基础设施,可能会遇到以下情况:

•  缺少一个SQS或Lambda函数的S3 trigger
•  缺少一个将事件路由到正确目标的EventBridge规则
•  使用一个新的AWS服务时缺少Lambda IAM角色更新
•  API Gateway中的CORS或授权器配置不正确

你要回答的一个最重要的问题是:你想要什么时候发现这些错误

  1. 在编写和运行你的测试时?
  2. 在进行特性开发和开发人员手动尝试他们的特性时?
  3. 在你通过一些E2E集成测试套件运行的持续集成中?
  4. 在一个共享的部署环境中,如开发或暂存?
  5. 或者在最坏的情况下:在生产环境中?

我们的选择是越早越好:在编写和运行测试时。这意味着,“你应该mock云的依赖项还是拥抱云“这个争论其实并不是一个问题。如果让我们的Lambda使用AWS mock或一些localhost仿真,在部署时还是很难做到无条件正常运行。Gareth McCumskey的“为什么无服务器的本地部署是一种反模式”这篇博文为“模拟还是上云“的争论给出了很好的答案,我强烈推荐大家阅读。

云端开发带来的最大影响是需要互联网接入来编写代码。虽然这对某些公司或人们来说可能是一个不可接受的权衡,但对我们这家远程优先的公司来说,我们本来就需要互联网接入来与同事沟通,因此很少会出现无法接入网络的情况。

本着我们希望进行云端开发,而们开始评估各种工具和技术,以找出适合我们的方法。

🌈神奇的🌈堆栈

那么,我们神奇的AWS无服务器开发体验是什么样子的呢?从高层来看,以下内容构成了开发体验的关键部分:

•  每位开发人员都有自己的个人AWS账户
•  用AWS CDK来开发我们的基础设施,用无服务器栈(SST)来获得非常快速的反馈通道
•  编写明显多于单元测试的集成测试
•  全栈TypeScript

采用这些技术和实践产生了相当出色的开发体验。

个人AWS账户

完全转向无服务器后,每位开发人员就必须有自己的个人沙盒AWS账户。如前所述,构建大多数特性时,仅仅编写代码是不够的,还有大量的基础设施需要开发、修改和测试。拥有个人的AWS账户让每位开发人员都可以进行实验和开发工作,而不会影响其他工程师或像开发或暂存这样的共享环境。再结合我们强大的基础设施即代码,每个人都可以拥有一份生产环境的个人克隆版本。

你可能会想:这不是很贵吗?我们是不是要向AWS支付数百美元?不,无服务器解决方案不是这样的!真正的无服务器解决方案都是按使用量付费的,所以如果你的AWS账户没有任何活动(例如在工程师不工作的晚上和周末),那么你就不会支付一分钱。这方面有一些例外,如S3存储、DynamoDB存储、RDS存储、Route53托管区等费用,但它们一般没几个钱。

例如,Plain公司1月份为我们的7个开发者账户支付的账单总计150美元,而我们每个人都有自己的生产环境克隆,因此开发速度大幅提升,相比之下这点费用真不算什么。通常情况下,每位开发人员涉及的最大成本是我们的关系数据库:Amazon Aurora Serverless v1 PostgreSQL。在开发过程中,当它收到请求时会自动扩展,并在无活动30分钟后降至零。

每个开发者账户的AWS用量总账单。

我的AWS账户的使用量明细

(注意:CloudWatch的高额费用是由于在1月份评估了可观察性工具和平台)

AWS CDK和SST

由于我们的所有特性都相当依赖云资源,因此将我们的基础设施定义为代码和版本控制是一个硬性要求。我们最初研究了Terraform、Pulumi、Serverless Framework、AWS SAM等工具,但它们要么要求我们学习新的编程或模板语言,要么开发者对整个特性生命周期的体验达不到我们的期望。

2021年3月,我们偶然发现了Serverless Stack(SST),当时它还是0.9.11版本。他们的实时lambda重载特性和建立在AWS CDK上的特点一下子就吸引了我们。SST和AWS CDK原生支持TypeScript,所以它很好地满足了我们对TypeScript全栈的渴望。

实时lambda开发允许我们编写Lambda代码,并使用实时的AWS服务运行我们的集成测试,反馈循环只需2-3秒。SST将你的lambda替换成一个垫片,通过Websockets将所有Lambda调用代理到你的本地开发者机器上,它可以调用其他AWS服务并返回响应。本地运行时使用AWS Lambda执行角色的权限,并对真正的服务调用AWS API,所以当变更部署到生产环境时我们会很有信心它能正常运行。总的来说,这意味着与mocking或仿真相比,我们能极快地发现基础设施问题。

实时lambda开发架构概述。(来源:docs.serverless-stack.com

这种设置的好处是我们可以轻松做到真正的全栈开发。我们可以将React前端应用指向个人AWS账户部署的API Gateway URL,并同时改变前端和后端,两个代码库都可以实时重载。鉴于一切部署都在使用与生产环境相同的AWS服务,我们的前端应用程序不需要调整就能完全正常工作。

虽然选择在一个(当时)相对未知的工具上建立我们的后端堆栈是有一点风险的,但我们知道我们有AWS CDK这个逃生舱口。如果我们遇到SST不支持或我们不喜欢的东西,还可以使用非常成熟的AWS CDK构造。这使我们在SST的奇妙开发体验与AWS CDK的成熟度、特性丰富度和第一方支持之间取得了最佳平衡。

Serverless Stack也有一些非常棒的特性,比如说:

•  在Lambda代码中加入断点,在本地IDE中调试。这得益于-increase-timeout标志,该标志将所有Lambda超时时间增加到15分钟。如果你对此感兴趣可以查看这里的文档视频
•  检测基础设施变更并提示你部署它们,即尽可能地接近实时重载。部署仍然需要一些时间,因为在底层它是Cloudformation。
•  一个基于Web的控制台(SST控制台),可以可视化你的堆栈、Lambda、S3桶,还有回放单个Lambda事件的能力。
•  自动导出已删除的Cloudformation堆栈输出:我们以前曾多次遇到这种情况,有时我们注意到的时候已经太晚了,所以很麻烦。
•  一个不断增长的构造库

每当我们遇到问题、有疑问或特性请求时,SST的Slack社区都能提供很大帮助。FrankJayDax和社区总是很乐意帮助我们。我强烈建议大家尝试一下SST,因为很难找到如此好用的东西了。

测试

一开始我们就有一个野心,就是对我们的测试套件能有充分的信心。如果我们的CI是绿色的,那么应该就可以安全地将该变更部署到生产环境中——这正是我们在合并到主分支时所做的事情。为了实现这一目标,我们决定将测试工作集中在一个强大的集成测试套件上,而不是对单个lambda函数或小代码块分别进行单元测试。这似乎是不好的实践,或者是违背了传统的测试金字塔原则。但当我们遇到像无服务器这样的阶梯式创新时,有必要对现有的实践提出质疑,看看这些实践是否仍那么有意义。

要明确的是:我们确实会在有意义的地方写单元测试。如果我们有一些业务逻辑或计算,那么就会写一个详尽的单元测试套件。一个例子是我们的核心客户状态机对所有可能的状态和状态转换都有单元测试。但是像SQL查询、AWS API调用或我们的GraphQL请求这样的单元测试是绝对不可能写的,因为它不会带来什么实际的保证。你最终要测试的是大量的实现细节,而维护高质量的mock或仿真需要投入很大资源,并不值得。

拿数字说话,我们目前的测试套件比例是30%单元测试和70%集成测试用例。

我们的集成测试是以一种合理的方式设计和编写的,它们速度够快,主要测试行为而非实现。这意味着我们尽量避免断言内部实现细节,例如DynamoDB或RDS中存储的数据。相反,我们专注于验证外部(从Lambda的角度)可见的行为,如API响应或正在发布的事件。对于我们的事件,我们的原则是只测试一个已经发布的事件,而不是断言所有下游消费者。我们为每个消费者编写单独的集成测试。这也要求我们在代码中保持合理的领域边界,以确保每个领域都可以独立测试。

集成测试的边界

这种编写测试的方式也有一个好处,就是能够针对共享环境运行。我们目前有一个完整的集成测试套件,在部署后合并到主环境时针对我们的开发环境运行,并按计划检测flaky测试。没有什么能阻止我们在生产环境中也运行这些完全一样的测试。理论上,我们可以删除100%的代码,用Delphi重写所有的Lambda,只要我们的集成测试套件通过就可以把它发布到生产环境。(注意:我们还没有尝试过这件事,也不打算在短时间内这样做)。

一个典型的GraphQL API查询或突变的集成测试大致上会做以下工作:

  1. 从认证的用户池中请求一个用户(我们遇到了一些配额和身份提供者的限制)
  2. 创建一个新的工作区,以便有一个干净的状态
  3. 设置测试的状态,如创建一个客户、发送一个聊天信息等
  4. 进行GraphQL查询
  5. 断言GraphQL响应
  6. 在突变的情况下:断言任何应该被发布的事件

describe('create issue mutation', () => {

it('should create an issue', async () => {

// Given: workspace + customer + issue type

const testWorkspace = await testData.newWorkspace();

const ctx = await testData.testAggregateContext({ testWorkspace });

const issueType = await issueAggregate.createIssueType(ctx, {

publicName: 'Run of the mill issues',

});

const customer = await customerAggregate.createCustomer(ctx, factories.newCustomer());

// When we make GraphQL Mutation

const res = await testWorkspace.owner.graphqlClient.request(CREATE_ISSUE_GQL_MUTATION, {

input: { issueTypeId: issueType.id, customerId: customer.id },

});

// Then:

// 1. Expect a successful response:

expect(res).toStrictEqual({

createIssue: {

issue: {

id: jestExpecters.isId('i'),

issueType: { id: issueType.id },

customer: { id: customer.id },

status: IssueStatus.Open,

issueKey: 'I-1',

},

error: null,

},

});

// 2. Expect an event to be published:

await testEvents.expectEvents(testWorkspace, [

jestExpecters.standardEventStructure({

actor: testWorkspace.owner,

payload: {

eventType: 'domain.issue.issue_created',

version: 1,

issue: res.createIssue.issue,

},

}),

]);

});

});

一个典型的EventBridge事件监听器集成测试会:

  1. 设置任何所需的状态(这在很大程度上取决于具体的Lambda)。
  2. 在总线上发布一个EventBridge事件
  3. 等待并期待副作用的出现,这可能是:

另一个EventBridge事件被发布

数据存储中的状态被更新(如DynamoDB、RDS、S3)

如果你曾写过任何集成测试,一定会在脑子里大喊:运行这些东西一定很慢!它们肯定比单元测试慢,但也不是慢得让人无法忍受。由于我们使用的所有服务都是无服务器的,而且我们确保集成测试有0个共享状态,所以我们有能力并行运行所有的测试。我们还没有达到这样的优化程度,但举例来说,我们的CI并行度为40,在2分钟内就能在110个测试套件中运行656个测试用例,对我们应用的每个角落进行详尽的集成测试。

来自我们CI的集成测试套件结果

集成测试的不稳定性是我们积极解决的另一个问题,为此我们在工作周内按计划运行测试。一旦遇到测试失败,我们就会跳出来,追踪问题的根源。这也需要我们重新思考,并把某些东西(如GraphQL订阅)的测试调整成一种稳健和可靠的方式。

我们才刚刚开始研究我们的集成测试设置,这个话题绝对值得另起一篇文章。也就是说,鉴于我们的API是产品的一个关键部分,对每一个GraphQL查询和突变的整合进行测试是至关重要的。我们认为,就算测试套件稍慢一些,但对特性或变更能正确运行有更高的信心就足够值得了。

全栈TypeScript

虽然使用全栈TypeScript并不是在AWS上拥有良好开发体验的严格必要条件,但它确实让我们的团队获得了更高的效率。无需学习新的语言就能在前端、后端和基础设施代码之间来回切换,这对团队的每位成员来说都是非常宝贵的体验。

在开发后端代码时你仍然需要学习AWS服务,但这在使用任何东西时都是很自然的需求。你同样需要了解CSS/HTML来开发前端Web应用。有了TypeScript中的SST和CDK,在你弄清楚自己想使用哪些AWS服务后,TypeScript类型和编辑器的自动完成特性会引导你定义正确的基础设施。

我们的大部分后端代码库都在一个单一的单体仓库中,并使用了一些库,如pnpmzodtrue-mythswc,来让我们的代码更容易编写——未来的文章中会有更多介绍。

实践

那么,这在实践中是什么样子的呢?让我们来看看一个变更该怎么做:

(视频见原文)

在这个例子中,我们通过我们的核心GraphQL API在Plain中创建了一个工作空间。这验证了E2E的API调用是有效的:

•  用户从我们的身份提供者那里获取了一个有效的JWT
•  AWS API Gateway处理了GraphQL请求并验证了JWT的有效性。
•  GraphQL Lambda在我们的Aurora Serverless PostgreSQL数据库中创建了一个新的工作区,并向EventBridge发布了一个事件
•  这验证了Lambda具有正确的IAM权限,可以从PostgreSQL中读/写并发布到EventBridge
•  一个成功的响应被返回到客户端

总结

有了这些技术和实践,我们就可以专注于发布特性了:

•  由于每个人都有自己的AWS账户,所以不会影响到其他工程师
•  有了SST和实时lambda开发,我们可以使用实时的AWS服务实现快速的反馈循环,知道它在部署时可以正常工作
•  利用CDK轻松开发无服务器基础设施
•  因为有我们的集成测试,所以我们对正确性有很高的信心
•  在前端、后端和基础设施之间切换时,不必学习不同的编程或模板语言

我们还能做的更好吗?改进的余地肯定还有,但我认为这已经是相当🌈神奇的🌈体验了!如果你有任何问题,或者知道如何让我们的堆栈变得更好,请在Twitter上 @builtwithplain或我@akoskrivachy,与我们联系。

如果你对我们的🌈神奇🌈技术栈感兴趣,请在Plain的工作页面上查看我们目前的职位空缺。

原文链接

https://journal.plain.com/posts/2022-02-08-a-magical-aws-serverless-developer-experience/