迷思
首发于迷思

分布式系统中的监工:Overseer

最近从无趣的工作中发现了有趣的事情,工作和业余时间都扑了些精力上去,本待上周末最终的成果出来后再写文章的,无奈事情太多,代码还没写完,二月上旬已过,再不写文章春节就过去了,所以这次程序君先上车,再补票。

需求

事情是这样的:两周前同事催促我升级我之前做的一个轮子 merlin - 见我去年的文章:停下来,歇口气,造轮子

在那篇文章里我提到了为什么会有需要做这样一个内部的 release 构建工具。自那时起,merlin 为我们内部的几个 elixir service 的 release 保驾护航几个月,总体表现不错。然而,当时在需求和设计上的一些缺陷,导致这款产品有这些问题:

  1. 太依赖 github release —— 如果不生成新的 release,就无法自动构建。这对依赖 staging 进行集成测试的服务不友好。使用者需要在 pull request 里升级版本(才能生成一个 build)。因此,多个开发者间需要在 pull request 中把版本错开,很不方便。开发频繁的时候,我们一天的 patch version(SemVar 里第三位),五个十个地狂跳,比旧金山 TaxiCab 的计价器跳动还要可怕。
  2. merlin 使用的两台机器都是 t2.medium,一次 release build(还包括一次 release upgrade build)花费十来分钟。当构建繁忙的时候,在队列后面的请求要很久才能排到(Latency 不友好)。

所以我要在下一个版本中,将这些问题解决。初步的考虑是,当构建请求来临时,启动一个强大的 spot instance,处理构建任务,构建完成并上传 S3 后,spot instance 自行了断。构建请求可以是来自 github release message(兼容上一版本),也可以是 API —— 进而,我们可以制作 CLI 工具,让用户在 shell 下对任意 git commit 触发构建。有了粗浅的想法后,我们理一理需求:

  1. 用户(包括 github)可以通过 API 触发构建
  2. 构建被触发后,启动一个 spot instance(或 ECS fargate,不过 spot instance 实在太便宜了,如 C5.large $0.03/hour,所以 ECS 没有啥价格优势)
  3. spot instance 基于一个 prebuild 的 AMI(如果 ECS,则 docker)启动,AMI 里包含处理构建的软件。这样启动起来之后,就能自动处理构建任务
  4. 描述构建任务的 metadata 放置于 spot instance 启动时的 user data 中,构建软件通过 http://169.254.169.254/latest/user-data 访问之
  5. 构建过程中可能要发送一些 telemetry 到 merlin
  6. 构建完成,把状态和构建的信息(比如 tarball 在哪里)发回给 merlin,然后自尽

这个需求算是比较清晰,实现起来也没有什么难点,无非就是时间问题 —— 对于像我们这样的 startup 来说,它是可以立即撸起袖子干活,逢山开路遇水填桥的那种活儿。

merlin 之前的坑是我埋的,这个业务即不性感,也不紧急;backend 的队友们都扑在一些 visibility 高的,光是名字听起来就热血沸腾的项目上,腾不出手,且我也不舍得就这么浪费他们的时间 —— 所以我只能 eat my own dogshit。我这人白天瞎忙,晚上躲懒 —— 除非有什么能戳到 G 点让我不吃不喝不睡觉也要搞的创意,否则像 merlin 这种一眼就从头看到脚,没有太多挑战的项目,激发不出我的小宇宙。于是需求定下,反正也不着急,我就懒懒地,有一搭没一搭地在脑海中想着。

事实证明,这种懒散,而非全力以赴,促成了我更多,更深的思考。有功夫我把整个思考的过程撰写成文,相信对大家也能有小小的启发。

实现

在上面的需求中,merlin 由一个服务被拆成了两个部分:control plane 和 data plane(请饶恕一个曾经的网络工程师对区分路径的这种骨子里的执着)。简单来说,control plane 负责派活和监控,是个 scheduler,类似于老鸨;data plane 负责干活,是一堆 resource,就好像苏小小,柳如是,李师师们。而一个个构建任务,是要完成的 task,就是赵佶,柳永,阮郁等的不期而至。

把 merlin 的需求稍稍泛化一下:

  1. 调用者可以通过 API 触发一个 task
  2. Control plane 接到 task 后,分配到 data plane 上的某个 resource 上执行
  3. data plane 向 control plane 汇总 telemetry
  4. data plane 完成 task 之后,向 control plane 汇报结果,进入到 idle 状态等待下次调度

为了符合社会主义核心价值观,我们换个比喻:Control plane 类似于 erlang/OTP 里的 Supervisor;data plane 类似于 GenServer。对于 erlang 不太熟悉的同学可以看我的文章:上帝说:要有一门面向未来的语言,于是有了 erlang。你不必理解代码,但需要理解思想。

然而,erlang/OTP 里的 Supervisor 只负责启动和监控 process,如果要启动和监控 node,有很多问题:

  1. 如何在 cloud 里动态启动一个节点?
  2. 如何让这个节点自动加入到 cluster 里?
  3. 如何让这个节点有运行 task 所必须的软件?
  4. control plane 如何和 data plane 方便地通信?
  5. 如何把上面的所有细节屏蔽起来,启动和监控一个节点,像 Supervisor 启动和监控一个 GenServer 一样简单,且对程序员友好?

1/2/3 如果解决,4 可以直接通过封装 RPC 解决。

2 我们上文中提过 —— 我们可以通过给新启动的 instance 提供 UserData 来解决 —— 在 AWS 里,当我们启动一个新的 instance,可以预设一些 json 数据进去,本地访问 http://169.254.169.254/latest/user-data 即可获得,因而,我们可以把 cluster 的 cookie,control plane node 的 node name 都放进去,以便于新的节点可以自己加入 cluster。

我们看 1 和 3。最简单解决 1/3 的方法是使用 prebuild AMI —— 把所有相关的,处理 data plane 的软件都烧到 AMI 里,用 request-spot-instance 的 AWS API 创建节点即可。不过,这意味着每次 data plane 的代码改变,我们都要重烧 AMI,即便烧 AMI 的动作 CI 自动化处理了,每次 control plane 还是需要确保使用正确的 AMI 启动 data plane。有些麻烦。

程序员最不爽的就是麻烦。虚心使人进步,麻烦让程序员创新。咋办?我们能不能做个 loader,把一个编译好的 module,甚至一个 release 动态加载到远端的一个 node 上?

bingo!这是一个好问题,而好问题的价值远胜于好的答案。于是大概两周前的一个周末,我写了几百行代码,做了一个初始版本的 ex_loader。见 github: tubitv/ex_loader。代码已开源,MIT license。

ex_loader 让你可以很简单地干这样的事情:

{:ok, module} = ExLoader.load_module("hello.beam", :"awesome-node@awesome.io")

:ok = ExLoader.load_release("https://awesome.io/example_complex_app.tar.gz", :"awesome-node@awesome.io")


你即使不理解 elixir 代码,大概也能猜到第一句它将一个本地的 module 加载到同一个 cluster 里的叫 awesome-node@awesome.io 的节点上;第二句,则将一个在某个 website 上的 erlang release,加载到相同的节点上。

Joe Armstrong 曾经在一次会议上开心地谈到过他自己会在 erlang node 上运行很多空的,什么也不做,也不知道该做什么的 process,但当他有需要的时候,让这些 process 加载新的 module,就摇身一变让其成为拥有某种特定功能 process。ex_loader 在此基础上更进一步,你可以开一些空的 erlang node,有需要的时候,让这些 node 加载你想让其运行的 release,使其成为特定功能的 server。

ex_loader 简化了 control plane 往 data plane 发布软件的工作,我们有了一个更好的解决 1 和 3 的方案。然而,我们还没有触及到上文中所提到的 5。

这就是 Overseer,一个新的,类比 Supervisor 的 OTP behavior。我们先看怎么用 Overseer:

local_adapter = {Overseer.Adapters.Local, [prefix: "test_local_"]}
opts = [
strategy: :simple_one_for_one,
max_nodes: 10
]
release = {:release, OverseerTest.Utils.get_fixture_path("apps/tarball/example_app.tar.gz")}
MyOverseer.start_link({local_adapter, release, opts}, name: MyOverseer)
MyOverseer.start_child()

定义一个 Overseer 很简单:

defmodule MyOverseer do
 use Overseer
 require Logger
 
  def start_link(spec, options) do
    Overseer.start_link(__MODULE__, spec, options)
  end
 
  def init(_) do
    {:ok, %{}}
  end
  
  def handle_connected(node, state) do
    Logger.info("node #{node} up: state #{inspect(state)}")
    {:ok, state}
  end
 
  def handle_disconnected(node, state) do
    Logger.info("node #{node} down: state #{inspect(state)}")
    {:ok, state}
  end

  def handle_telemetry(_data, state) do
    {:ok, state}
  end

  def handle_terminated(_node, state) do
    {:ok, state}
  end
  
  def handle_event(_event, _node, state) do
    {:ok, state}
  end
end

我们大概讲讲 Overseer 干些什么:

  1. start_link:启动时,它接受一些参数,关于我们要启动的 node 的 spec。node 目前支持两种 adapter,local 和 ec2。我将其做成 adapter,是为了日后支持更多类型的 node(比如 ECS)。strategy 目前仅支持 simple_one_for_one,亦即所有 node 使用相同的 spec,在需要的时候由 Overseer 创建。
  2. start_child:Overseer 可以根据预置的 spec 启动一个 node —— 比如 ec2 spot instance。这个 node 启动成功之后,初始的代码会使用 UserData 里面的 node_name 和 cookie 连接 Overseer。当 Overseer 监测到一个 node_up 的消息后,会在内部创建一个 Labor 的数据结构,并且把 spec 里面定义的 release 发给这个 labor node 加载和运行。
  3. pairing:比 Supervisor 复杂的是,Overseer 不但需要监听 node up / down 的事件,做相应的决策(比如重启一个新的 node)外,还需要接受 node 传过来的 telemetry,所以 Overseer 所在的 process 要和 labor node 上面的某个 process 建立起关系。我把这个过程称作为 pairing,类比蓝牙设备间的配对。当 start_child 成功后,Overseer 会把自己的 pid 发送给 labor node 上的一个指定的接口,然后 labor node 会在这个接口里显示地给 Overseer 发送 pair 请求,之后,两个 process 就 link 起来。
  4. 作为一个类似于 Supervisor 的 GenServer,Overseer 把 labort node 监控的细节和状态机都屏蔽掉,只暴露 connected / disconnected / telemetry 等一些上层软件关心的事件。

下图是大概一周前我手绘的 sequential diagram,当时名字还不叫 Overseer,叫 GenConnector,但基本思路一致:


Overseer 的源码会在这几天完成后释出,敬请期待。

有了 ex_loader 和 Overseer,merlin 剩下要做的事情就简单很多了:把代码库分割成 control plane 和 data plane,control plane 用 Overseer,data plane 沿用之前的代码,稍作修改后我们就有了一个分布式的,可以随意 scale 的构建系统。

最妙的是,ex_loader 和 Overseer 虽为 merlin 而生,却由于不错的抽象程度,能适用于几乎任何 control plane + dynamic data plane 的这种分布式任务处理结构。在我之前的思考中,其实还更进一步,将这个系统设计成了一个叫 Fleet / Carrier / Fighter 结构的分布式系统,Carrier 是 Fleet 的 labor node,Fighter 是 Carrier 的 labor node,类比 Star War 中的帝国舰队。在这个蓝图中,merlin 只是 Fleet 的一个 Carrier 而已(这个估计短期没工夫实现):



好了,不说废话了,我还是抓紧写代码去。提前祝各位叔叔阿姨哥哥姐姐弟弟妹妹,春节快乐!也祝各位同处本命年「伏吟」的小伙伴们,狗年红红火火,不犯太岁!:)

编辑于 2018-02-13

文章被以下专栏收录