异步 API 会有很多的优势,比如解耦、可扩展和弹性等。但是,正如俗话所言,“世上没有免费的午餐”,我们需要考虑在客户端和服务器端所增加的复杂性。
要获取异步操作的状态往往需要客户端定期轮询结果。这种操作会导致客户端和服务器端的资源浪费。
本文提供了一种将轮询部分重定向到 Amazon Simple Storage Service(S3)的方案。
S3 是一个由公有云提供商亚马逊云科技(Amazon Web Services)管理的高可用、可扩展和安全的对象存储服务。
我们将会展现一个使用 AWS Lambda 函数的 serverless 实现,但是如果你想使用 S3 的话,并不是强制要使用 AWS Lambda 函数。
举例来说,你还可以使用 Docker 容器。
Serverless 异步 API
在亚马逊云科技平台上,异步 API 的典型的 serverless 实现会涉及到 Amazon API Gateway、一些 lambda 函数、一个 SQS 队列以及我们本例中所用到的 NoSQL 键-值数据库:DynamoDB。在下图中,我们可以看到整体的架构:
为了简单起见,我们的 API 只有一个资源,通过 POST 到“/order”可以创建一个新的订单,通过 GET 到“/order/{id}”
可以检索订单。我们假设创建订单会消耗一定的时间,所以请求是异步的。客户端调用该端点并得到一个订单的 id。借助这个 id,它们必须要轮询 GET 端点来检查该订单何时创建完成。当然,如果客户端有一个可以被调用的回调端点或者它们能够在订单创建完成之后,接收到通知的话,那就没有必要使用轮询了。
尽管每隔一秒钟或差不多的时间去调用一个端点是很容易的,但这是一个无效的过程,会浪费客户端和服务器端的资源。除此之外,有些客户端无法实现 webhook 端点,无法消费通知,或者没有足够的时间来实现这些机制。
消除服务器端资源浪费的一种方式就是将轮询委托给亚马逊云科技提供的托管服务。我们可以使用 Amazon Simple Storage Service(S3)来实现这一点。
使用 Amazon S3 实现轮询
Amazon S3 是亚马逊云科技云供应商最早提供的服务之一。它是一个对象存储服务,提供了高可扩展性、高可用性和高性能。它的结构在某种程度上模拟了一个文件系统,其中会使用桶来盛放对象,所谓的对象也就是文件以及描述该文件的元数据。
我们可以使用 S3 将异步操作的状态存储为一个 JSON 文件,API 的客户端会调用该服务,而不是轮询我们的 API。通过这种方式,客户端检查状态更新的所有流量会被重定向到 S3 API 上,而不是我们自己的 API 上。
为了避免向我们的 API 客户端传播证书或其他的认证机制,我们将会使用S3的预签名URL(presigned URL)特性。默认情况下,所有的桶和文件都是私有的。但是,在限定的时间内,我们可以使用预签名 URL 共享一些文件(不需要暴露安全凭证和权限)。
收到 POST 请求的 lambda 函数会生成包含操作状态的预签名 URL,并将其返回给客户端。这个 S3 的文件名也会作为一个属性添加到要发送至 SQS 的消息中,这样的话,负责进行处理的部分在需要更新状态的时候就可以引用它的值。
AWS SDK 提供了生成这些预签名 URL 的功能。在下面 Python 代码的样例中,我们会得到一个访问对象的 GET URL,对象的 key 是OBJECT_KEY
且位于BUCKET_NAME
S3 桶中,该 URL 会在十分钟内过期:
使用其他编程语言的样例,请参考文档。
注意,这个功能也可以在 Docker 容器和自托管的应用中使用。如果你无法使用某种 AWS SDK(Java、.NET、Ruby、PHP、Node.js、Python 或 Go)的话,还可以采用AWS S3 REST API或AWS Command Line Interface。并不是必须要使用 serverless lambda 函数。
在返回预签名 URL 以便于进行轮询的 lambda 函数中,我们还可以在响应中包含一个预估的时间,即客户端在什么时候可以开始询问操作的状态。这个时间预估可以基于 SQS 队列中消息的大致数量、in-flight 状态的消息的大致数量(业已发送到客户端但尚未删除,或尚未达到消息的可见性过期时间),以及处理一个请求的平均时间。下面我们可以看到一个 Python 的例子,说明如何从 SQS 队列中获得这些数字:
当使用 S3 来存储异步操作的状态时,较新的状态会被更频繁地查询,而旧的状态在一段时间后可能就完全不会再被读取了。因此,根据使用情况,你可以利用 S3 提供的不同存储类别。在写这篇文章的时候,AWS 提供的不同类别和成本如下所示(仅限于 Ireland 区域):
对象存储的管理是通过 S3 生命周期规则实现的。例如,我们可以声明一个规则,让文件在 S3 Standard 中存在十天,然后转移到 S3 Standard-IA,30 天后将其删除或者转移至 S3 Glacier Deep Archive 中。生命周期可以通过 Amazon S3 控制台、REST API、AWS SDK 和 AWS CLI 进行配置。关于这方面的更多信息,请参阅文档。
安全方面的考虑因素
虽然在默认情况下,S3 中所有的文件和桶都是私有的,但是创建预签名 URL 会允许在限定的时间范围内访问这些文件。获取了预签名 URL 的所有人都能读取状态文件。因此,与 API 的通信应该只允许通过 HTTPS 来实现,状态文件中不要存储任何的敏感数据,并且这些文件的时间限制要设置地越短越好,当然,不能短于实际操作所要占用的时间。
另外一个额外的安全防护可以在 S3 侧执行,也就是只允许特定 IP 范围进行访问。这可以通过在桶上添加策略来实现,在文档页面我们可以看到相关的例子。
如果预签名 URL 的机制对你的使用场景来说不够安全的话,那么在这种情况下,你可以使用 AWS Security Token Service(AWS STS)创建临时的安全凭证,并将其提供给你的客户端,这种临时安全凭证可以控制对 S3 操作状态文件的访问。对于联合身份验证(identity federation),AWS STS 支持企业级联合身份验证(自定义身份代理或 SAML 2.0)和 Web 联合身份验证(使用 Google、Facebook、Amazon 或任意兼容 OpenID Connect 的身份识别供应商)。关于这方面的更多信息,请查阅他们的文档。
收益分析
将轮询功能委托给 S3 能够让主服务只处理实际的业务逻辑请求,而不用持续地检查更新。这样的话,我们的 serverless 样例就会产生更少的函数调用,而且对 DynamoDB 的读取容量单元消耗也会更少。
尽管 AWS Lambda 函数的扩展速度非常快,并且可以处理大量的并发请求,但是你依然需要考虑并发的限制。根据区域的不同,初始的流量暴增限制是 500 到 3000,这一限制适用于账户中的所有函数。我们让轮询不去消耗并发量,这样就会为其他的函数留下更多的容量。关于 lambda 函数限制的完整列表,请查阅AWS的文档。
其他浪费的资源是 DynamoDB 的读取请求单元。每个读取单元代表了一次强一致性的读取请求,或者两个最终一致的读取请求,因为每个条目最多只能有 4KB。另外,如果你的表配置成了 provisioned 模式的话,这意味着你会声明读取容量单元的数量,这样的话,有些请求可能会被限流。DynamoDB 还有一种 On-Demand 模式,在这种模式下,容量会随着流量进行调整。令人遗憾的是,轮询只会产生带来副作用的业务流量。
成本的收益会在请求达到 100 万的时候开始显现。对于几十万级别的请求来讲,差异并不大。我们下面会看到一个成本计算的样例。
我们以 10 万个请求为例,并假设每个请求平均会有 10 个轮询请求,因此共有 100 万个轮询请求。如下的计算是使用AWS Pricing Calculator针对 Ireland AWS 区域进行的计算。
API Gateway REST API 的成本计算很简单:1,000,000 个请求 x 0.0000035000 美元 = 3.50 美元
对于 lambda 函数,我们假设平均执行时间是 500 毫秒,并分配 256MB 的内存:
1,000,000 请求 x 500 毫秒 x 0.001(毫秒到秒的转换系数)= 500,000.00 的计算总量(秒)
0.25 GB x 500,000.00 秒 = 125,000.00 的计算总量(GB-s)
125,000.00 GB-s x 0.0000166667 美元 = 2.08 美元(每月计算费用)
1,000,000 请求 x 0.0000002 美元 = 0.20 美元(每月计算费用)
lambda 的总成本:2.08 美元 + 0.20 美元 = 2.28 美元
对于 DynamoDB,我们估算的平均条目大小是 10KB,我们将会使用最终一致的读取。
平均条目大小为 10 KB / 4 KB = 每个条目需要 2.50 个读取请求
四舍五入(2.500000000) = 每个条目需要 3 个读取请求
1,000,000 个读取 x 1 个最终一致的分区 x 0.5 个最终一致的读数请求单元 x 每个条目所需的读取请求单元数为 3 = 1,500,000.00 为实现最终一致性读取所需的读取请求单元
从 Dynamo 进行读取的总成本:总的读取请求单元 1,500,000.00 x 0.000000283 美元=0.42 美元的读取请求成本
轮询请求的总成本将会是:3.50(API Gateway) + 2.28(Lambda) + 0.42(从 DynamoDB 的读取) = 6.2 美元
这个成本略微有些高估了,因为 lambda 函数的响应时间可能会少于 500 毫秒,为它们提供 128MB 的内存可能就足够了。
对于 S3,我们预估使用每月 1GB(100,000 x 10 KB)的 Standard 存储:
1 GB x 0.0230000000 美元 = 0.02 美元
100,000 个对 S3 存储的 PUT 请求 x 每个请求 0.000005 美元 = 0.50 美元
每月 1,000,000 个 GET 请求 x 每个请求 0.0000004 美元 = 0.40 美元
0.023 美元 + 0.40 美元 + 0.50 美元 = 0.92 美元(总的 S3 Standard 存储,数据请求和 S3 查找的成本)
S3 数据传输,outbound 的互联网流量,1 GB 的 tiered 价格:
1 GB x 每 GB 的 0 美元 = 0.00 美元
0 GB x 每 GB 的 0.09 美元 = 0.00 美元
S3 总成本:0.92 美元 + 0.00 美元 = 0.92 美元
请注意,为了尽可能让对比更接近实际情况,这些计算只包含了实际请求相关的成本。因此,所有其他的额外成本没有包含进去,比如 DynamoDB 的存储成本。
成本差异不是很大。但是,我们将它列在了这里,这样你可以大致了解如何进行计算。
缺点
将轮询转移到 S3 有这么多的好处,但它也给整个解决方案增加了额外的复杂性。我们需要涉及另一个服务,即 S3,并为每个操作创建一个预签名的 URL。如果状态文件包含任何敏感信息的话,这个解决方案可能会增加更高的风险,因为任何得到预签名 URL 的人都可以访问这些信息。如果有来自许多客户端的大量调用,并且他们会在很短的间隔内进行轮询时,本文所提到的大部分的收益将会兑现。在只有少量调用的情况下,主 API 也可以处理轮询流量,而不需要使用 S3。
总结
这篇文章展示了如何使用 AWS S3 来处理来自异步 API 的轮询流量。如果你无法实现通知策略,并且客户端需要轮询来获取操作结果的话,那么 S3 可以是一个很好的候选方案,它能够将轮询的调用从主 API 中迁移出来。我们需要为每个操作生成一个 S3 预签名的 URL,并将其返回给客户端,以便于客户端调用它,这样的话,计算资源就能处理应用程序的主业务逻辑,而不必通过 API 调用检查操作的状态。
文章中的例子展现了一个 Serverless 的 API。但是,这种机制也可以用于其他类型的应用中,比如托管在 Docker 容器、虚拟机中的应用,甚至自托管的应用。对于短时间内大量调用的场景,其好处会显现出来。如果只是几个客户端不时地进行调用,那么在解决方案中再增加一个系统可能并不是高效的办法。
作者简介:
Cristian Gherghinescu 自 2006 年以来一直在软件开发领域工作。他目前在挪威的 Visma 公司担任软件架构师。Cristian 从 C#和 Java EE 开始其职业生涯,现在专注于将当前的解决方案迁移到亚马逊云科技平台上。最近,他开始热衷于 Serverless 的解决方案。
原文链接:
Serverless Solution to Offload Polling for Asynchronous Operation Status Using Amazon S3
活动推荐:
2023年9月3-5日,「QCon全球软件开发大会·北京站」 将在北京•富力万丽酒店举办。此次大会以「启航·AIGC软件工程变革」为主题,策划了大前端融合提效、大模型应用落地、面向 AI 的存储、AIGC 浪潮下的研发效能提升、LLMOps、异构算力、微服务架构治理、业务安全技术、构建未来软件的编程语言、FinOps 等近30个精彩专题。咨询购票可联系票务经理 18514549229(微信同手机号)。
评论