近日,Kafka 社区在 Wiki 空间上提交了一项新的改进提案“KIP-500: Replace ZooKeeper with a Self-Managed Metadata Quorum”,为了消除 Kafka 对 ZooKeeper 的依赖,该提案建议用自管理的元数据仲裁机制替换原来的 ZooKeeper 组件。
新提案一出即在推特上引发热烈讨论,网友纷纷表示欢迎且非常期待这项改动可以尽快用于生产部署环境,大部分人都赞同组件越少越好,并认为这会让部署变得更加容易;当然也有网友担心,移除 ZooKeeper 后,Kafka 会像 ElasticSearch 那样在集群发现、首领选举、故障检测方面出现各种问题。据了解,该提案目前仍处于讨论阶段,在通过社区讨论后才会有 PR 能被 Merge。如果顺利通过,这将会是 Kafka 近期比较大的一个变动,对于原生云架构运维等都会带来影响。
动机
目前,Kafka 使用 ZooKeeper 来保存与分区和代理相关的元数据,并选举出一个代理作为集群控制器。不过,Kafka 开发团队想要消除对 Zookeeper 的依赖,这样就可以以更可伸缩和更健壮的方式来管理元数据,从而支持更多的分区,还能够简化 Kafka 的部署和配置。
通过事件流的方式来管理状态确实有它的好处,比如用一个数字(即偏移量)来描述消费者在事件流中的处理位置。多个消费者通过处理比当前偏移量更新的事件快速地达到最新的状态。日志在事件之间建立了清晰的顺序,并确保消费者总是沿着一个时间轴移动。
在用户享受这些好处的同时,Kafka 却被忽略了。元数据变更被视为独立的变更,彼此之间没有联系。当控制器将状态变更通知(例如 LeaderAndIsrRequest)推送给集群中的其他代理时,有些代理可能会收到,但不是全部。控制器可能会重试几次,但最终还是会放弃,这可能会让代理处于不一致的状态。
更糟糕的是,虽然 ZooKeeper 被用来保存记录,但 ZooKeeper 中的状态通常与控制器内存中的状态不一致。例如,当首领分区在 ZooKeeper 中修改了 ISR 时,控制器通常会在很长一段时间内不知道这些更新。虽然控制器可以设置一次性的 watcher,但出于性能方面的考虑,设置 watcher 的次数是有限的。watcher 在触发时不会告诉控制器当前的状态,它只会告诉控制器状态已经发生了变化。当控制器重新读取 znode 并设置新的 watcher 时,状态可能与 watcher 触发时的状态不一样。如果不设置 watcher,控制器可能根本不知道发生了什么变化。在某些情况下,只能通过重启控制器来解决状态不一致问题。
元数据不应该被保存在单独的系统中,而应该直接保存在 Kafka 集群里,这样就可以避免所有因控制器状态和 Zookeeper 状态不一致而导致的问题。代理不应该接受变更通知,而是从事件日志中获取元数据事件。这样可以确保元数据变更始终以相同的顺序到达。代理可以将元数据保存在本地文件中,在重新启动时,它们只需要读取发生变化的内容,不需要读取所有的状态,这样就可以支持更多的分区,同时减少 CPU 消耗。
简化部署和配置
ZooKeeper 是一个独立的系统,有自己的配置文件语法、管理工具和部署模式。为了部署 Kafka,系统管理员需要学习如何管理和部署两个独立的分布式系统。对于管理员来说,这可能是一项艰巨的任务,特别是如果他们不太熟悉如何部署 Java 服务。统一的系统部署和配置将极大地改善 Kafka 的运维体验,有助于扩大其应用范围。
因为 Kafka 和 Zookeeper 的配置是分开的,所以很容易出错。例如,管理员可能在 Kafka 上设置了 SASL,并错误地认为这样就可以保护所有通过网络传输的数据。但事实上,为了保证数据安全,还需要在 ZooKeeper 系统中配置安全性。统一这两个系统的配置将会得到一个统一的安全配置模型。
最后,Kafka 将来可能会支持单节点部署模式。对于那些想快速测试 Kafka 但又不想启动多个守护进程的人来说,这是非常有用的,而移除对 ZooKeeper 的依赖有助于实现这个想法。
新的架构
概览
目前,Kafka 集群通常包含多个代理节点和 ZooKeeper 仲裁节点。上图中有 4 个代理节点和 3 个 ZooKeeper 节点。控制器(橙色)从 ZooKeeper 仲裁节点加载状态。从控制器到其他代理节点的线表示控制器向它们推送更新,比如 LeaderAndIsr 和 UpdateMetadata 消息。
注意,上图省略掉了一些东西。控制器之外的其他代理也可以与 Zookeeper 通信,所以应该从每个代理到 ZooKeeper 都画一条线,但画太多线会让图表看起来太复杂。另一个问题是,外部命令行工具可以不通过控制器直接修改 ZooKeeper 中的状态,所以很难知道控制器内存中的状态是否真正反映了 ZooKeeper 中的状态。
在新的架构中,三个控制器节点代替了原先的三个 ZooKeeper 节点。控制器节点和代理节点运行在单独的 JVM 中。控制器节点选举出一个首领负责处理元数据。控制器不会将更新推送给代理,而是让代理从首领控制器获取元数据更新,所以箭头从代理指向了控制器,而不是从控制器指向代理。
控制器仲裁
控制器节点包含了一个 Raft 仲裁节点,负责管理元数据日志。这个日志包含了集群元数据的变更信息。原先保存在 ZooKeeper 中的所有内容,例如主题、分区、ISRs、配置等等,都将被保存在这个日志中。
控制器节点基于 Raft 算法选举首领,不依赖任何外部系统。选举出的首领叫作主控制器。主控制器处理所有来自代理的 RPC。从控制器从主控制器复制数据,并在主控制器发生故障时充当热备份。
和 ZooKeeper 一样,Raft 需要大多数节点可用才能继续运行。因此,一个三节点的控制器集群可以忍受一个节点出现故障,一个五节点的控制器集群可以允许两个节点出现故障,并以此类推。
控制器定期将元数据快照写入磁盘。虽然从概念上看这类似于压缩,但代码路径却有所不同,因为新的架构可以直接从内存中读取状态,而不是从磁盘中重新读取日志。
代理的元数据管理
代理将通过新的 MetadataFetch API 从主控制器获取更新,而不是让控制器向代理推送更新。
MetadataFetch 类似于 fetch 请求。与 fetch 请求一样,代理将跟踪上次获取数据的偏移量,并且只从主控制器获取更新的更新。
代理将获取的元数据保存到磁盘上,这样代理就可以快速启动,即使有数十万甚至数百万个分区(请注意,由于这种持久化机制是一种优化,所以有可能不会在第一个版本中出现)。
大多数情况下,代理只需要获取增量更新,而不是完整的状态更新。不过,如果代理落后主控制器太多,或者代理根本没有缓存元数据,那么主控制器将会向代理发送完整的元数据镜像,而不是增量更新。
代理将定期向主控制器请求元数据更新。这种请求同时作为心跳,让控制器知道代理还活着。
代理状态机
目前,代理在启动时会在 Zookeeper 中注册自己。这个注册动作完成了两件事:让代理知道自己是否被选为控制器,也让其他节点知道如何与被选为控制器的节点通信。
在移除 ZooKeeper 之后,代理将通过 MetadataFetch API 在控制器仲裁节点上注册自己,而不是在 ZooKeeper 中。
目前,如果代理丢失了与 ZooKeeper 之前的会话,控制器会将其从集群元数据中删除。在移除 ZooKeeper 之后,如果代理在足够长的时间内没有发送元数据心跳,主控制器将从集群元数据中删除代理。
目前,与 ZooKeeper 保持连接但与主控制器隔离的代理可以继续服务用户请求,但不会接收到任何元数据更新,这可能会导致一致性问题。例如,配置了 acks=1 的生产者可能继续向首领(但这个首领可能已经不是首领了)发送数据,而且无法接收到 LeaderAndIsrRequest 通知。
在移除了 ZooKeeper 之后,集群成员关系与元数据更新被集成在一起。如果代理无法接收元数据更新,就不能继续作为集群的成员。
代理的状态
离线
当代理进程处于离线状态时,它要么不运行,要么正在执行启动所需的任务,例如初始化 JVM 或恢复日志。
受防护
当代理处于受防护状态时,它不会对来自客户端的 RPC 做出响应。代理在启动或尝试获取最新元数据时会处于受防护状态。如果无法与主控制器取得联系,代理将重新进入受防护状态。受防护的代理不应该被包含在发送给客户端的元数据中。
在线
处于在线状态的代理可以响应来自客户端的请求。
停止中
代理在收到 SIGINT 时会进入停止状态,表明系统管理员想要关闭代理。
停止中的代理仍在运行,同时试图将首领分区从该代理迁移出去。
最后,控制器通过 MetadataFetchResponse 返回一个特殊的代码,让代理最终变成离线状态。另外一种情况是如果无法在预先确定的时间内迁移首领,代理也将被关闭。
一些已有的 API 现在只能用于控制器
之前直接操作 ZooKeeper 的一些操作将变成控制器操作。例如,修改配置、修改存储在默认 Authorizer 中的 ACL,等等。
新版本的客户端应该将这些操作请求直接发送给控制器。这个变更应该向后兼容,同时适用于旧集群和新集群。为了与旧客户端保持兼容,代理将把这些请求转发给主控制器。
新的控制器 API
对于某些场景,需要创建新的 API 来替换之前通过 ZooKeeper 完成的操作。例如,当分区首领想要修改同步副本集时,之前是直接修改 ZooKeeper,但在移除了 ZooKeeper 后,首领将向控制器发送 RPC。
移除直接访问 ZooKeeper 的脚本
目前,有一些工具和脚本直接与 ZooKeeper 通信。在移除 ZooKeeper 之后,这些工具必须使用 Kafka API。所幸的是,“KIP-4:命令行和集中管理操作”在几年前就开始移除直接访问 ZooKeeper 的能力,这项工作现在几乎接近尾声了。
兼容性、弃用和迁移计划
客户端兼容性
我们将保持与现有 Kafka 客户端的兼容性。在某些情况下,现有客户端将采用效率较低的代码路径。例如,代理可能需要将请求转发给控制器。
桥接版本
实现兼容性的总体计划是发布“桥接版本”。桥接版本隔离了对 ZooKeeper 依赖。任意 Kafka 版本都可以升级到桥接版本,并从桥接版本升级到无 ZooKeeper 的版本。从早期版本升级到无 ZooKeeper 版本必须分两个步骤进行:首先,必须升级到桥接版本,然后再升级到无 ZooKeeper 版本。
滚动升级
从桥接版本进行滚动升级需要经过几个步骤。
升级到桥接版本
集群必须先升级到桥接版本。
启动控制器仲裁节点
原先的 ZooKeeper 仲裁地址被用来配置控制器仲裁节点。在确定了控制器仲裁节点后,主控制器将把节点信息输入/brokers/id,并用 ID 来覆盖/controller 节点。这样可以防止任何未升级的代理节点在后续滚动升级过程中成为控制器。
在接管了/controller 节点后,主控制器将继续加载 ZooKeeper 的完整状态。它将把这些信息写到仲裁元数据存储中。在此之后,这些元数据存储数据(而不是保存在 ZooKeeper 中的数据)被视为元数据仲裁信息。
在桥接版本中,工具和非控制器代理都不会修改 ZooKeeper,所以不用担心在加载过程中 ZooKeeper 状态会被修改。
新控制器将会监控 ZooKeeper,看看是不是有遗留代理节点注册。它知道如何在过渡期间将遗留的“push”元数据请求发送到这些节点。
滚动代理节点
代理节点将被滚动更新。新的代理节点将不与 ZooKeeper 通信。如果配置中还保留了 ZooKeeper 服务器地址的配置信息,它们将被忽略。
滚动控制器仲裁节点
在最后一个代理节点被更新后,就不再需要 ZooKeeper 了。控制器仲裁节点将移除与 ZooKeeper 有关的配置信息,然后滚动控制器仲裁节点,以便将这些信息完全删除。
被拒绝的方案
组合控制器和代理节点
一般来说,代理和控制器可以运行在同一个 JVM 中,这样就可以使用更少的 JVM。
但将它们分开也有一些好处。一个是 Kafka 管理员更熟悉这种部署模型。如果之前使用了一定数量的 ZooKeeper 节点,那么可以直接升级到拥有相同数量的控制器节点,而无需重新调整集群大小或拓扑。
另一个原因是为了避免出现不均衡的负载。随着元数据数量的增加,提供元数据的节点将相应地承受更大的负载。在执行再均衡或分区分配时,同等对待控制器节点和其他节点就变得不太现实。使用独立的节点可以避免当前控制器被某些代理的负载中断。对于负载很小的集群来说,这不是个问题,所以系统管理员可以让控制器和代理运行在相同的 JVM 中。
可插拔的共识组件
元数据存储层可以被做成可插拔的,这样就可以使用 ZooKeeper 以外的系统。例如,可以在 etcd、Consul 或类似的系统中存储元数据。
但这种做法不符合移除 ZooKeeper 的两个主要目标。因为这些外部系统与 ZooKeeper 具有类似的 API 和设计目标,无法将元数据视为事件日志,部署和配置仍然比较复杂。
支持多种元数据存储将不可避免地减少每种选项的测试工作。在进行系统测试时将不得不为每一种可能的存储机制提供测试,这将大大增加测试所需的资源,或者只能向用户提供测试不足的版本。
此外,如果要支持多种元数据存储,将不得不使用“最小公分母”API,换句话说就是只能使用所有元数据存储都支持的 API。在现实当中,这将导致系统优化变得非常困难。
Kafka 重大版本更新回顾
英文原文:
评论