亚马逊AWS官方博客

使用机架感知功能降低 HAQM MSK 流量成本

本文主要介绍 HAQM MSK 流量成本优化的最佳实践,通过 KIP-392 实现 Consumer(消费端)对 MSK 节点在同 Availability Zone(AZ,可用区)内消息就近读取,可以降低跨可用区数据传输成本、提升消费端的消费效率。

HAQM MSK 拓扑结构

HAQM MSK 是完全托管、高度可用的 Apache Kafka 服务,为了保证 MSK 服务自身的高可用性,MSK 的多个 Kafka 节点均衡分布在一个 AWS Region 的多个可用区中,这样有效避免了底层硬件的单点故障,或可用区级别故障导致的 MSK 服务不可用。此外,MSK 在开源的 Kafka 的基础上做了一些优化和增强,在 100% 兼容 Kafka 的基础上,还额外提供分层存储、自动存储扩展、自动分区管理、MSK Serverless、节点配置灵活调整以及 AWS Graviton CPU 等特色功能;这些功能给客户带来了高性能、低成本、灵活可靠、免运维的 Kafka 使用体验。

当我们创建 MSK 的时候,我们需要选择可用区的数量(至少为 2),并指定每个可用区中的节点数量,因此 MSK 的节点总是在可用区中处于均衡分布状态。

如下图是 3 个可用区,每个可用区中 2 个节点的 MSK 集群结构:

当我们按照 MSK 最佳实践MSK 可靠性最佳实践为 Kafka 中的主题设置了分区的默认 replication factor 为 3、in-sync replicas 为 2 时,Producer(写入端)向 MSK 写入的消息会先送达到分区主副本所在的节点,然后消息会在 MSK 集群内部自动向另外的 2 台分区副本所在的节点同步;只有这个分区所处的 3 台节点中的至少 2 个确认写入这条消息时,才被认为是 committed。通过设置 MSK 集群中主题的分区多副本备份和多数副本确认机制,结合写入端的 acks=all,就能保证 MSK 集群中消息的可靠性。

写入端写入消息的示意图如下:

在默认情况下,消费 MSK 中的消息和开源的 Apache Kafka 并无差别,写入端向主题中发送的消息可以大致均衡的分布在这个主题的多个分区上;每个分区的主副本分区负责承载写入端的写操作和消费端的读操作,其他的副本分区在 Kafka 集群内部同步主副本的消息,只负责保证数据的可靠性,但是不承载读写操作压力。

经过上面的拓扑分析,我们可以发现在理想情况下,上述的结构可以在多个节点间均匀的分散读写压力,但是也有 3 个细节问题:

  1. 存在跨可用区流量成本。结合结合 network traffic cost 我们可以发现,对写入端产生的一批消息而言,大致有 2/3 的消息是跨可用区被消费端消费的。理论上当可用区为 n 时,可能有 1-1/n 的消息被消费时产生额外的跨可用区流量成本。
  2. 当消费端跨可用区读取消息时,可能会有轻微的网络延迟上升。
  3. 极端情况下,如果用户对消息的分区 key 设置不合理时会产生分区倾斜,主副本分区会承载更大的读写压力,并加剧上述 1、2 项问题。当然在任意情况下,我们都要合理设置分区 key 来避免倾斜问题。

HAQM MSK 支持 KIP-392 机架感知

KIP-392 在 Apache Kafka 2.4 中被引入,旨在通过机架感知来为消费端提供同机架内的消息消费能力;这会降低消费端跨机架消费消息的网络延迟、充分利用同机架更高的网络带宽提升消费速度。HAQM MSK 从 2.4.1 开始引入 KIP-392 特性并提供 KIP-480 粘性分区器提升消费组分区分配效率,详情请看:HAQM MSK 增加对 Apache Kafka 版本 2.4.1 的支持

利用 KIP-392 新特性,MSK 集群的消费端可以在同可用区内就近消费分区中的数据,这个特性允许消费端从副本分区所在的节点进行数据拉取并消费,而以往消费端只能在主副本分区上进行数据拉取。

通过上图,我们可以发现,消费端消费消息的流量可以保持在同可用区内,理论上这将消费端产生的流量费降为 0。在某些情况下,消费端所在的可用区中没有节点,或者节点短暂处于维护期时,这个消费端的消费逻辑可以回退到标准的 Apache Kafka 状态。

使用 spring integration kafka 完成就近读取

要正确启用 KIP-392,我们必须让节点和消费端间完成 rack 协商,这需要节点和消费端都拥有正确的 rack id 消息。在 HAQM MSK 中,这个 rack id 就是可用区 id,实际上可用区 id 是 HAQM EC2 实例元数据的一部分,这里我们后续再展开讲解。

为 MSK 节点 开启 KIP-392 机架感知

在 MSK 中,节点自带了可用区 id,我们还需要先设置好 MSK 集群配置,加上 replica.selector.class=org.apache.kafka.common.replica.RackAwareReplicaSelector 来让 MSK 集群使用 RackAwareReplicaSelector 正确匹配到消费端传来的 rack id。配置修改好后,需要我们重启 MSK 集群生效配置。

为消费端开启 KIP-392 机架感知

按照 KIP-392 的规范,在节点设置好 RackAwareReplicaSelector 后,消费端只需要传入 rack id 即可,目前大多数语言的 Kafka 客户端都支持该功能,比如 java、go、python 等。

下面我以 java 为例进行演示。首先,一个 AWS 上运行的程序无论是在 EC2 还是容器或者其他的地方,它的宿主机必然是处于某一个 aws region 内的某个可用区的。但是为了保证高可用性,我们一般会对这个程序进行多可用区部署,程序所在的可用区 id 就是一个变量,所以我们无法将可用区 id 写死在配置文件里面,或者在编译阶段固定下来。最佳的方式是在程序启动时做到动态加载。

我们可以使用 AWS SDK 或者直接使用 http 访问 EC2 实例的实例元数据来获取可用区 id;对 AWS Java SDK v2 而言,我们可以使用 Ec2MetadataClient 来读取它。

具体的核心代码为:

Ec2MetadataClient ec2MetadataClient = Ec2MetadataClient.create()
Ec2MetadataResponse response = ec2MetadataClient.get("/latest/metadata/placement/availability-zone-id");
String availabilityZoneId = response.asString();

在上图中,我使用核心代码读取了程序所在的 ec2 宿主机上的 metadata 后,得到了可用区 id,上述代码得到可用区 id 后立即将它设置为环境变量,名称为 AVAILABILITY_ZONE_ID,代码片段会在 spring 程序容器加载前执行,发生于 Kafka 客户端初始化前。

然后我们在 spring 的 application.yaml 配置文件中设置 client rack,spring 启动时会读取 AVAILABILITY_ZONE_ID 环境变量来初始化 Kafka 客户端,这样就完成了全部的 KIP-392 设置,如下图红色部分。图中绿色部分也是推荐的最佳实践,通过开启写入端消息压缩,可以在牺牲少量 CPU 的情况下减少客户端和 MSK 节点间的数据传输量、减少 EC2 的带宽消耗、减少 MSK 的数据存储成本。

最后我们将 org.apache.kafka.clients.consumer.internals.AbstractFetch 的日志设置为 debug 级别,这会打印关键的调试信息。

最后编译后运行的日志如下:

启动时程序打印出了消费端的连接设置,我们可以看到,正确地得到了可用区 id。

持续观察 debug 日志,我们会看到消费端会不断地在同可用区 id 内拉取 committed 后的消息。

总结

KIP-392 是一个很实用的 Kafka 特性,当我们消费 MSK 中的消息时通过它提供的机架感知技术可以实现就近数据拉取。和以前从主副本分区拉取相比,它减少了主副本分区所在的节点的压力,并消除了跨可用区流量费,是一个非常经济高效的流量降本技术。如果结合消息压缩功能,使用 MSK 的整体流量成本会进一步的降低。

本文参考

本篇作者

罗新宇

亚马逊云科技解决方案架构师,在架构设计与开发领域有非常丰富的实践经验,目前致力于 Serverless 在云原生架构中的应用。