serf 是出自 Hashicorp 的开源项目, 实现了去中心化的 gossip(八卦) 协议,其中 gossip 协议定义了一种类似病毒感染的消息传播过程。 一些著名的开源项目,如 Docker 和 Consul,网络管理和服务发现的核心组件是基于 serf 实现的,然而它们背后的 serf 似乎还鲜为人知,一方面其复杂的理论以及不完善的文档让人望而却步;另一方面,gossip 协议天然的数据弱一致性也制约了 serf 的使用场景。
本文希望从 serf 背后的分布式系统理论和部分源码实现出发,为项目中 serf 的使用带来一些启发,分为四个部分:
- serf 初体验
- serf 背后的分布式系统理论
- serf 部分源码分析
- serf 集群参数与调优
SERF 初体验
serf 提供了一种轻量级的方式来管理去中心化集群,并基于这个集群提供了 UserEvent 和 Query 等接口,处理一些用户层的事件,如服务发现、自动化部署等。本节通过具体的例子,介绍 serf 中的一些基本特性。
集群管理
基于 serf 搭建去中心化集群非常简单:在每个节点上启动 serf agent,然后通过每个 agent 上的 rpc 接口(或使用 serf 命令行工具),就可以让 agent 快速建立连接并形成集群。
图 1.1.1 启动独立的 serf agent
(点击放大图像)
图1.1.1 中启动了三个独立的serf agent,配置分别为:
Node
Bind Address
RPC Address
node1
127.0.0.1:5001
127.0.0.1:7473
node2
127.0.0.1:5002
127.0.0.1:7474
node3
127.0.0.1:5003
127.0.0.1:7475
顺便提一下,agent 启动时需要指定两个 address,bind address 和 rpc address,分别提供集群间的通信接口和客户端操作集群的接口,serf 默认采用 UDP 来广播 gossip 消息,因此在实际网络中部署时,需要为 Bind Address 配置相应的防火墙规则。另外还有一个 advertise address,如果 agent 跑在容器等需要 NAT 的网络环境中时,可以使用 advertise address 对集群中其他节点暴露自己。
启动了 serf agent 之后,这些 agent 还没有建立集群,相互之间也没有通信,紧接着要做的是让这些 agent 连接起来成为一个集群,图 1.1.1 中启动了三个节点,接下来分两步让这三个节点建立连接,形成去中性化集群:
- node2 join node1 (如图 1.1.2)
- node1 join node3 (如图 1.1.3)
图 1.1.2 node2 join node1
(点击放大图像)
再次说明一下,rpc addr 是客户端控制agent 的接口,而bind addr 才是集群间互相通信的接口,因此这里node2 join node1,实际上是通过node2 的rpc 接口告诉node2“join node1,它的bind address 是127.0.0.1:5001”。
图1.1.3 node1 join node3
(点击放大图像)
经过图1.1.2 和1.1.3 中的简单两步,我们就建立了node1,2,3 间的去中心化集群,每个节点都维护了集群的状态信息。有人可能好奇集群中如果有节点挂掉,重启的话,其他节点能不能再次找到它呢?为了模拟这种故障重启的行为,图1.1.4 中直接将node2 进程kill 掉。
图1.1.4 直接kill 掉node2
(点击放大图像)
很快集群中的节点就发现node2 可能挂了,然后就开始了suspect 的过程,大家互相传播了几次node2 挂掉的“八卦消息”之后,就判定这个节点很可能真的挂了,就开始定期去尝试重新连接这个节点,让它重新加入集群。
图1.1.5 重新启动node2
(点击放大图像)
图1.1.5 中node2 重启后,自动加入到集群中来了,集群对每个节点都抱着“不抛弃不放弃”的原则,每个节点都可能承担故障节点的恢复,整个集群不再依赖有限的几个master 节点。
User Event 和 Query
UserEvent 和 Query 是 serf 基于去中心化集群提供的高效消息封装。UserEvent 是一些单向的不需要集群反馈的消息,而 Query 是双向的,需要集群给出反馈(QueryResponse)的消息。向集群发送消息可以针对任意节点进行,然后节点间可以互相传播这些消息。
serf 中可以为 Event 和 Query 指定 Handler,serf 命令行中定义的 Handler 可以是任何一条 shell 命令,serf 会将 Event/Query 的一些上下文以环境变量形式传入;如果在 Go 程序中引入 serf API,也可以实现自定义的 Handler,非常灵活。
图 1.2.1 为 node1,2,3 指定不同的 Event Handler,并建立节点之间的连接形成集群
(点击放大图像)
图1.2.1 中为node1,2,3 分别指定了Event 和Query 的Handler 和Tag 来声明消息的广播范围
Node
Tag
Event Handler
Query Handler
node1
role=apiserver
echo node1 >> node1.log
echo hello,node1
node2
role=ui
echo node2 >> node2.log
echo hello,node2
node2
role=apiserver
echo node3 >> node3.log
echo hello,node3
图 1.2.2 中发送一个 log Event,所有节点都会处理该 Event,向对应的日志文件中写入一段文本
(点击放大图像)
图1.2.3 中向role=ui 的节点发送一个Query,agent 处理完消息之后将结果传回客户端
(点击放大图像)
为了保证一般性,这里选择向集群中的 node1 发送该 Query,而 Query 实际的处理节点是 node2,最终可见返回的结果和日志中只有 node2 处理了该信息。
通过 UserEvent/Query 与 Tag 的组合,可以实现很多灵活的监控、日志收集和自动化运维工具。
小结
本节中的一些例子展示了 serf 的扩展性和 Event、Query 的使用场景,然而这些问题似乎在中心化的系统里也完全可以解决,那么去中心化集群的到底有什么优势让 Docker 和 Consul 选择用 serf 来实现其核心的服务发现和网络配置管理模块?
常见的中心化集群中,集群的状态是由有限的 master 节点维护的,通常使用 heart-beat 协议来更新和同步 master 和 slave 节点之间的状态和配置信息:
- master 需要 slave 的 heart-beat 来维护 slave 的状态
- slave 需要定时从 master 同步集群的配置变化
在集群规模较小时,去中心化集群与中心化集群相比并没有明显优势。
去中心化系统更多的是面向大规模分布式平台,当集群规模增加到数万甚至数十万节点时,主从模型的信息传播给节点和网络都会带来不小的负载,集群状态管理的效率和正确性也会随着规模的增大而下降,这就是为什么很多基于“主-从”模型的中心化系统都有集群规模的上限。
例如 Kubernetes 的集群规模上限为 6k 左右(v1.6+ 的官方数据),原因很简单,因为 Kubernetes 整个系统都受限于它背后的 etcd 集群的处理能力(QPS 为万级别),在 Kubernetes 的上一个版本,集群规模上限只有 2k(v1.6+ 里面容量增加可能是因为 etcd3 里面引入了 rpc 的接口,通信更高效了),如果超过这个规模,集群状态就无法保证有效同步,可能会出现各种不预期的行为。而基于 serf 的去中心化集群就没有这种限制,集群处理能力可以做到近似线性的扩展,效率和可靠性基本不受集群规模的影响。
从这个角度来看来,Docker 的集群管理潜力比今天竞争者如 Kubernetes 要大得多,基于 serf 去中心化的 Docker Swarm 平台基本不受集群规模的影响,在节点规模达到数十万时依旧可以很好的运转,这是 Kubernetes 之类的平台所做不到的。
serf 背后的分布式系统理论
serf 可以从功能上,自上往下分为三个层次:
- 客户端接口:提供 rpc 接口来处理客户端对 serf 集群的输入,和格式化的输出
- 消息中间件:封装了各种消息,包含集群管理和 UserEvent,Query 等的处理逻辑
- gossip 协议层:封装了 gossip 协议和基本的集群管理操作
本节简单介绍 gossip 协议层背后的分布式系统理论。
vivaldi algorithm
在去中心化系统中,信息是通过节点之间互相传播完成的,如果每个消息都向整个集群广播,会形成严重的网络风暴,这是需要严格避免的。那么如何在避免网络风暴的前提下,同时保证消息传递的效率和可靠性呢?Vivaldi 算法就是为了解决这个问题提出的。
Vivaldi 算法通过启发式的学习算法,在集群通信的过程中计算节点之间的 RTT,并动态学习网络的拓扑结构,每个节点可以选择离自己“最近”的 N 个节点进行广播,这样就可以最大程度减少网络风暴的出现,调整 N 可以修改信息的传播速度和效率以及对网络带宽的影响。
SWIM 协议
SWIM(Scalable Weakly-consistent Infection-style Process Group Membership Protocol) 是在去中心化系统中节点状态监测的协议,定义了 Failure 和 Suspicion 等状态的监测协议和节点之间的消息传输模型。节点之间可以相互“告知”集群中所有节点的状态,当出现短暂的节点状态失效时,集群中会互相同步 suspicion(疑似失效)状态并持续观察该“问题”节点的状态,并在多个节点都确认了该失效节点的状态后最终更新其状态为 Failure,这也减少了集群中因为偶然网络环境干扰导致的状态误判。
Lamport timestamps
分布式系统中要解决的另外一个问题就是事件顺序性的问题,无论是中心化系统还是去中心化系统,都需要判断数据的时效性。物理时间是不可靠的,物理时间微小的偏差就可能造成程序中重大逻辑错误。
考虑有两个客户端 A 和 B,发请求获取数据库的锁,那么数据库服务器应该把锁给谁呢?为了公平起见,通常使用客户端发请求的时间来判断“谁先谁后”,那么紧接着需要面临的问题就是物理时钟不可靠,不可靠的物理时钟可能导致本该分配给 A 的锁却最终被分配给了 B。在分布式系统中,事件顺序的判断就变得尤为关键。
试想在分布式系统中,如果一个节点短暂的失效,断开了集群,稍后又加入集群,那么其之前可能记忆了自己的一些状态,重新加入集群后可能会广播一些过期和重复的消息,如果集群中无法识别消息的时效性,那么这些过期的、重复的消息可能就会被重复、错误的执行。
Lamport Timestamp 为集群中每个事件都设定一个物理时钟无关的逻辑时钟,通过一种弱一致性的手段判断事件的因果关系。节点可以通过 Lamport Timestamp 过滤重复、过期的事件。
serf 部分源码分析
前面介绍的例子中,通过使用 serf 提供的命令行工具来管理集群和消息。在 Go 程序中,可以使用 serf 为应用提供原生的集群管理和去中心化的消息中间件。
在 Docker libnetwork 中就使用了 serf 来管理容器化部署相关的 ARP 配置,在我看来这种比较野的路子确实需要一些“脑洞”。
本节总结了 serf 的部分源码实现,希望能为对 serf 感兴趣的团队的技术人提供一些思路。
memberlist 封装了 serf 中的 gossip 协议层,并暴露了一些接口,让上层的应用与 gossip 协议层进行交互,本节介绍使用 serf 的源码 (d787b2e8f72b5da48c43b84c2617234401084050)。
将 serf 自上往下划分为两个层次:
- serf 层:实现对协议层接口的封装,包含对集群状态管理以及 Event 和 Query 的封装
- gossip 协议层:处理集群检测、广播、以及信息安全相关的问题
其中 gossip 协议层暴露了 Delegate 接口,serf 层通过实现 Delegate 接口来扩展协议层的功能。
图 3.1.1 Delegate 的定义
(点击放大图像)
Delegate 接口提供了 5 个回调函数接口,下面介绍 serf 中的实现。
NodeMeta
在 serf 中可以为每个节点设置具体的 tag,用来过滤集群中节点。
图 3.2.1 NodeMeta 将节点的 tag 信息发布到集群中
(点击放大图像)
NotifyMsg
NotifyMsg 是 serf 中整个消息中间件的核心接口,gossip 协议层所有消息都会回调 NotifyMsg。
图 3.3.1 NotifyMsg 实现
(点击放大图像)
这里只关注两种用户层消息的处理机制,即UserEvent 和Query(QueryResponse),其中UserEvent 实现了集群中不需要反馈的、单向通信,Query 和QueryResponse 实现了集群中的双向通信。
先看一下当节点收到一个事件时的处理逻辑。
图3.3.2 handleUserEvent 接收事件并进行预处理
(点击放大图像)
在传递给 EventCh 之前 handlerUserEvent 做了一些预处理:
- 舍弃过期事件
- 舍弃已经处理过的事件
- 接收一个事件并加入 recent cache(以便识别该事件是否在当前节点已经被处理过)
- 将事件通过 channel 发送给后续的 Handler
顺带说一下 serf 中“最近处理过事件”的缓存机制,serf 初始化一个节点时会定义一个最近事件的缓冲池,是 N 个不限长度的队列,缓存了 N 个连续 Lamport Timestamp 的事件,通过对其 Lamport Timestamp 取 N 的模来获取缓存中队列,并加入队列尾。
handleQuery 发送数据时的逻辑和 handleUserEvent 是一样的,这里不再赘述。
定义消息有效性是“去中心化”集群中消息正确性的重要保障,“去中心化”集群中随时可以因为各种原因导致集群中一些节点和集群断开,当它们再次加入集群后有可能本地的一些状态没来得及与集群同步,就会出现广播了过期和重复消息的情况。 对过期和重复消息的容错机制是“去中心化”集群需要具备的关键能力之一。
另外值得一提的是,serf 配置中的几个 channel,上面的代码中涉及其中的 EventCh,既所有的 UserEvent 会被转发至这个 channel,而后续的处理逻辑,即自定义的 handler 就可以来处理这些事件,这就是前面提到的 serf 中可以自定义 event handler 的问题。
serf 集群参数与调优
构建 serf 集群涉及以下几个变量,实际应用中需要针对集群规模和当前的网络环境对这些参数进行调整,让集群获得较好的收敛速率:
- gossip interval: 向集群广播消息的时间间隔
- gossip fanout: 控制 gossip 协议层向集群中多少个相邻节点广播消息
(点击放大图像)
图4.1 serf 集群收敛仿真
(点击放大图像)
可见集群收敛速率几乎不受受集群规模影响,即使集群节点数达到 10w,集群也能在数秒内快速收敛。
注:这里的仿真结果是针对特定的平台参数,即丢包率,节点失效概率等参数进行的,实际应用中可以利用这里给出的公式,结合平台实际情况进行仿真。
总结
在现代分布式系统中,无论是工具还是应用模块,相互之间通信的能力显得越来越重要,serf 提供了一种弱一致性的沟通渠道,让应用模块之间直接“沟通”,提升整个软件平台和服务的自主性。serf 对我的吸引力来自它的两方面潜力:
- 简单、高效、可靠的去中心化集群管理
- 可以和 Go 程序整合,为 Go 程序模块提供原生的、不依赖外部存储的集群管理和通信能力
尽管如此,serf 和去中心化系统不是“银弹”,在生产中应用 serf 时需要进行周详的考虑和严谨的规划,还需要针对平台具体情况对集群参数进行调整 。相信 serf 和去中心系统在未来会为技术领域创造新的机遇和挑战。
关于作者
杨谕黔,FreeWheel 基础架构部高级软件工程师。 目前主要从事服务化框架、容器化平台相关的研发与推广。关注和感兴趣的技术主要有 Golang、Docker、Kubernetes 等。
感谢郭蕾对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ , @丁晓昀),微信(微信号: InfoQChina )关注我们。
评论