饿了么服务注册中⼼ (Huskar) 发展历程

1,520 阅读14分钟
原文链接: zhuanlan.zhihu.com

背景介绍

在一个公司的业务变得复杂,开发人员规模也开始扩张之后,服务化就成为了业务与组织架构双重解耦的一种途径。服务化的同时,流量路径也在变复杂——从原本的流量经接入层单向流到业务层、持久层,变成了任意两个内部服务之间都可能存在同步调用(RPC)或异步调用(消息队列),那么如何设计流量的路径拓扑就成为了服务化选型的一部分。

除去早期的一些总线型拓扑方案(以 ESB 为代表),星状拓扑(中心 Router 代理所有服务互调流量)和网状拓扑(服务和服务点对点直接调用)更多地被作为当代服务化的候选项。这两种方式在服务发现上分别对应所谓的 Server-side service discovery patternClient-side service discovery pattern

饿了么在早期开始做服务化的时候,即已选定了 service mesh 这种网状的服务拓扑。这种选择有它的优势性:

  1. 相比中心代理,服务和服务点对点直通减少了对内网带宽的浪费。例如在容器化或虚拟化的场景下,调用方和被调方可能就在同一台宿主机上。这时中心代理的方案让调用流量舍近求远,还要到 Router 上“绕行”一次。
  2. 这个选择也省去了运维一个集中式代理的成本和一些缺陷。放下 HA 问题不说,仅 seamless deployment 就较难做到,例如 A 服务发布时中心 Router 代理做 reload 需要掐断 B 服务的连接。

但这种选择也提高了整个 SOA 体系对服务注册、服务发现设施的依赖 —— 服务注册中心变成了所有服务的不可降级依赖。一旦注册中心故障,所有服务调用都会被影响。因为我们的生产环境不是一个完全静态的环境,变更无时无刻不在发生,即使没有人为的发布,也会有机器的上下线、容器的自动漂移等。这些都依赖服务注册中心通过 注册-发现 的途径“告知”所有服务调用方。

这也是饿了么将服务注册发现中心作为一个重要中间件建设的原因。

现存方案

业界常见的服务注册-发现解决方案,许多是基于 ZooKeeper、etcd 或者 consul 这一类分布式协调器的。其中一个可能的原因是它们提供分布式副本的同时也提供时序一致的外观——可以理解为分布式环境下的“原子性”。这符合服务注册中心吞吐不会太大、数据总量不会太大,但是数据需要安全、不能丢,并且必须做到“能注册的都要能被发现”的需求特点。

饿了么早期(只有 Python 服务的时候),也选择了 ZooKeeper 作为服务注册和发现的解决方案,这应该和很多开源 SOA 框架(例如 Dubbo)的解决方案类似。注册,即将服务节点写入到 ZooKeeper 中对应 app_id 和 cluster 的目录下,写入成功则视为服务启动成功;发现,即通过 ZooKeeper 的 watch 原语监听对方 app_id 和 cluster 之下的实例节点,发现更新则写入到本地文件系统缓存,供客户的的连接池取用。

后来饿了么引入了 Java 和 Go 服务,因为不想重复实现这套 ZooKeeper Recipe(写过 ZooKeeper Recipe 的同学应该会知道,写一个稳定且无竞态条件的 Recipe 还是有些挑战的),故把 Python SDK 实现的这套逻辑包装成了一个简易的 HTTP 服务,以 HTTP comet polling 的方式向 Java、Go 等服务“推送”来达到服务发现的目的。

这种方案简易、可行,并且很长一段时间满足了我们的需求。但它存在严重隐患,并给过我们一个 P2 级别的严重事故。这让我们理解了 Netflix 为何舍 ZooKeeper 而研发 Eureka,也促成了我们选取 ZooKeeper Recipe as a Service 这条路径前行。

进化路径

容载 —— 为什么不能直连 ZooKeeper

Netflix 研发 Eureka 时,在其 Roadmap 里有过一个很到位的描述:服务注册-发现中心的容载能力是有显著特点的,服务注册的容载需求相对固定,因为一个 IDC 能够容纳的服务数量相对固定(可能没考虑容器化之后小容器数量多的问题,但是这个也可以认为系数的差异也算某种程度的固定);而服务发现的容载需求是很难预知的,因为无法事前预知线上服务的依赖关系变化[1]。举个栗子,A 服务每多依赖一个服务,服务发现的容载需求就增加了一份,而 A 服务依赖多少个服务这是随业务迭代而变化的,两千多个服务每日都在发布,中间件团队不可能提前去预知和规划一共多少依赖。

投射到 ZooKeeper 上,除了奇数个决议成员(在饿了么是 5 个成员)负责“写入”,即服务注册,“读取” 都由 observer 节点提供,看起来这是一种容易 scale 的方法。ZooKeeper 的 observer 节点不参与决议,遇到写请求就转发给 leader 发起投票,遇到读请求才本地解决。但是这样有两个问题。

首先,是 ZooKeeper 的决议成员本身就不一定是高可用的。假设 ZooKeeper 决议成员节点遇到运维操作需要重启,或者遇到了 Full GC,或者更甚 leader 和 observer 之间出现网络分区,都会导致 observer 和 leader 失联。在 observer 超出 ticket 周期没能成功心跳,发现自己和和 leader 失联时,就会将自己停止服务。这和服务发现“高可用”的要求不符。我们更希望在注册功能损毁时,能保留“最后一刻“的快照,继续接纳只读的服务发现请求。

另一个问题则相对第一个问题更严重 —— observer 在接管服务发现请求的同时,会给服务注册设施(参与投票的 leader 和 followers)带来额外压力。ZooKeeper 客户端连上一个 observer,是需要建立一个 session 的,这个 session 用来作为事务日志标识,也用来判定临时节点何时归西,以此来提供 ZooKeeper 实现分布式锁、分布式成员管理等实现的可能。也正因为如此,session 的创建需要写入事务日志,也即交给 leader 投票。在一些网络抖动的场景下,大量客户端和 ZooKeeper 的 session 断开,网络恢复后这些客户端会在瞬间重新建立它们的 session。这些重建 session 的请求虽是打到 observer 上,但 observer 自己没有“写“的能力,只能全盘交给 leader 处理。leader 写入事务日志不仅是”排队“的,还需要多轮 IO 交给 followers 参与投票,响应速度可想而知会很慢。当请求慢到一定程度时,客户端就会超时。超时了并不能 sleep 一个随机秒数再重建,因为服务发现不可用了是会影响调用的,于是只好立刻重建 session,这时就把 ZooKeeper 决议成员的 outstanding 队列排得更长,形成恶性循环。此时如果有业务方发布,并且没有遵守逐节点灰度发布的策略,那么就会造成节点重启后无法拉起。因为 ZooKeeper 已经不可写入了,节点不能注册自然也就不能拉起。我们遇到这个问题时,很不幸就有一个关键业务的发布没有遵守灰度策略,于是导致关键服务长时间不可用,最终酿成了一个 P2 的事故,给公司带来业务损失。

我们事后复盘时分析,我们事发时有部分 observer 跨城域连接到了主集群上,在长距离的网络抖动时触发了这个恶性循环的启动条件。经过事故,我们得出了一个结论:用 ZooKeeper 做服务发现没问题,但是在服务数量非常大的时候,我们不能再让服务自己直接通过一个 SDK 去连接 ZooKeeper。所以我们后续用了几个月的时间,下线了 Python 服务直连 ZooKeeper 的 SDK,把它改造成了和 Java Go 服务一样,连接一个 HTTP 的中间件来注册和发现服务。这个 HTTP 中间件内部封装了 ZooKeeper Recipe,由它来连接 ZooKeeper 集群。ZooKeeper observer 节点非常鸡肋,我们也不要了,直接让这个 HTTP 服务连接决议集群。因为这个 HTTP 中间件(下文称服务注册中心)本身节点数目有限,不会在服务发现这个问题上给 ZooKeeper 集群造成太大的压力。即,这个服务注册中心承担了一个类似于消息队列中 fanout 的角色。

Huskar - ZooKeeper Recipe as a Service

Huskar 是我们服务注册中心的项目名,这个项目历史较长,在我加入饿了么之前已经立项了。最早设立这个项目的目的是将 Python 实现的 ZooKeeper Recipe 提供给 Java 和 Go 服务使用,在我们踩坑得出“服务不应该直连 ZooKeeper”的结论之后,它就逐步发展成了全公司统一的服务注册中心。

Huskar 的实现是 Python 应用中典型的 Gevent + Gunicorn 选型[2][3],整体是多进程 + 进程内协程的方式处理多路 IO,这个整体是基于一个内部应用框架(Vespene, a.k.a zeus-core)做的。ZooKeeper 客户端这部分选取了 Kazoo 这个开源实现[4]。

我接手这个项目的时候,它已经实现了基本的功能,但是有一系列问题 —— 代码质量不统一,测试覆盖率低等。考虑到这个项目本身是快速出原型的结果,这些问题其实也可以理解。但有一个问题较为严重,我们把它排在第一优先级来解决,即它的服务发现部分实现存在 race condition。这个服务用 HTTP comet 向客户端“推送”客户端所订阅服务的“变更”事件,这也就决定了它需要监听多个 app_id 的多个 cluster 产生事件。这是一个递归监听的操作,而递归监听原语是 ZooKeeper 所没有的(etcd 好像吸取了这个教训,设计了内置的递归监听原语),所以需要借助 Recipe 来实现。原本 Huskar 是用 Kazoo 内置的 ChildrenWatch 和 DataWatch 两个现成 Recipe 来组合实现递归监听,这种设计在和 ZooKeeper 是网络通信的背景下,不可避免地会出现丢失消息、消息乱序等现象。这个是无法满足一个可靠的服务发现中间件的需求的。

我们调研解决方案时,优先考虑了参考社区较为成熟的 ZooKeeper Recipe 库,即原本由 Netflix 开发,后续交给 Apache 基金会的 Curator。Curator 中有一个叫 TreeCache 的 Recipe 很好地匹配了这种需求:它也是监听 children 再触发 children 的 data watch,然而它并不直接将 watch 事件下发,反而是借此来维护一份 in-memory 的 snapshot 并在 ZooKeeper session 可靠时维持它的更新。更新发生时,通过比对 in-memory snapshot 中的元信息(例如修改 Znode 的 zxid)来决定真正下发的更新事件。这种做法很完美地匹配了服务发现这个场景:它能保证一个时序正确、最终一致的事件流,能在 ZooKeeper session 可靠时持续维持更新,在 session 丢失时保持“最后一刻”的快照可读,在 session 又恢复时通过比对 snapshot 精确地推出一份体现差值的事件。

因此,将 TreeCache 这种设计包装到 Huskar 服务里,可以解决使用 ZooKeeper 做服务发现遇到的几个重点问题:

  1. 高可用、最终一致:TreeCache 本身提供保证;
  2. 针对服务发现可 scale:包装成 HTTP 服务的方式大幅度缩减了 ZooKeeper session 数量,不至对决议成员造成压力;在服务发现的容量不足时,Huskar 作为弱状态(因为 snapshot 的存在,不是完全的无状态)服务,可以自由横向扩容。

于是我们就剩下了最后一个问题,我们历(作)史(死)原因用了 Python 来实现 Huskar,Kazoo 里并没有 TreeCache 的实现。于是可以预见到,我们最后把 TreeCache 移植到 Kazoo 上了[5](掩面)。

效果评价

迄今为止,Huskar,改造后的服务注册和发现中心,已经托管了饿了么全公司两千多个服务,具体到其中实例、配置项、开关的数目已经接近百万。

饿了么目前有两个双活机房,对应部署了两份独立 Huskar 集群,下层的 ZooKeeper 借助北京技术创新部研发的复制中间件来做非强一致的跨地域双向复制。

服务发现的容载量方面,Huskar 在每个机房的日常 HTTP comet 长连接数目约五万,并且这个数字还在持续增长。发布高峰时,推送的事件数目约 2kps。

自改造方案上线为止,我们发现了 TreeCache 实现的一些问题,将其修复并将补丁一道回馈给了上游。目前 Huskar 的年 uptime 为 99.996%,并保持了零事故记录。

未来

Huskar 达成了稳定性的目标之后,我的注意力就开始较多地放在了其后台(公司的服务配置中心)产品化方面,以增强我们的服务治理能力。用我们总监的话来说,SOA 是易开发难治理,可能 80% 的精力都花在了治理而不是开发上,这一点在产品化、平台化程度不足时尤其显著。

而 Huskar 作为所有服务的一个中心“控制器”,不应当只满足于服务注册和发现。借用网络工程的术语,如果说 SOA 服务框架(或者 sidecar 进程)中的连接池、软负载等是 SOA 中负责流量递达的“数据平面”,那么 Huskar 在其中的角色就应该是“控制平面”。

我们已经在这方面开始了不少尝试,有一些已经落地了。比如说,服务发现中节点的变更是 Huskar 的 HTTP comet 下发推送的,那么在中心化的配置控制下,Huskar 来决定 A 服务请求发现 B 服务时,推送 B 服务哪个集群下的成员列表,那么就达到了控制线上“集群路由”的效果。我们以前调用一个服务需要指定它的 app_id 和集群,现在改成上报我自己的 app_id 和我要调用的 app_id,流量打到哪个集群完全由 Huskar 这个“控制平面”来决定。那么服务的提供方也好,保障站点稳定性的 SRE 团队也要,在改变集群流量策略的时候,可以做到对调用方完全无感。

但是,我们的尝试还很早期。业界的一些服务控制平面是可以做到极细粒度的流量管控能力的,可以用来做 Canary 发布等,甚至可以和发布平台、容器平台打通,自动控制逐步扩大新 codebase 接入的流量,遇到问题自动回退等。关注服务化领域的同学应该会知道我说的是 Netflix[6],饿了么离这种业界标杆还有很长距离,这是我们今后要努力的方向。

作者介绍

张江阁(松鼠奥利奥 & tonyseek),2016 年 3 月加入饿了么,效力于框架工具部。目前在北京组了一个包括自己在内三个人的项目团队,负责服务注册和配置管理中心(Huskar)项目。目前较关注配置治理、流量治理等 SOA 相关话题,并在试图践行技术中间件产品化的 Technology Product Designer/Manager 实践。

引用