分布式系统中的监工:Overseer
最近从无趣的工作中发现了有趣的事情,工作和业余时间都扑了些精力上去,本待上周末最终的成果出来后再写文章的,无奈事情太多,代码还没写完,二月上旬已过,再不写文章春节就过去了,所以这次程序君先上车,再补票。
需求
事情是这样的:两周前同事催促我升级我之前做的一个轮子 merlin - 见我去年的文章:停下来,歇口气,造轮子。
在那篇文章里我提到了为什么会有需要做这样一个内部的 release 构建工具。自那时起,merlin 为我们内部的几个 elixir service 的 release 保驾护航几个月,总体表现不错。然而,当时在需求和设计上的一些缺陷,导致这款产品有这些问题:
- 太依赖 github release —— 如果不生成新的 release,就无法自动构建。这对依赖 staging 进行集成测试的服务不友好。使用者需要在 pull request 里升级版本(才能生成一个 build)。因此,多个开发者间需要在 pull request 中把版本错开,很不方便。开发频繁的时候,我们一天的 patch version(SemVar 里第三位),五个十个地狂跳,比旧金山 TaxiCab 的计价器跳动还要可怕。
- merlin 使用的两台机器都是 t2.medium,一次 release build(还包括一次 release upgrade build)花费十来分钟。当构建繁忙的时候,在队列后面的请求要很久才能排到(Latency 不友好)。
所以我要在下一个版本中,将这些问题解决。初步的考虑是,当构建请求来临时,启动一个强大的 spot instance,处理构建任务,构建完成并上传 S3 后,spot instance 自行了断。构建请求可以是来自 github release message(兼容上一版本),也可以是 API —— 进而,我们可以制作 CLI 工具,让用户在 shell 下对任意 git commit 触发构建。有了粗浅的想法后,我们理一理需求:
- 用户(包括 github)可以通过 API 触发构建
- 构建被触发后,启动一个 spot instance(或 ECS fargate,不过 spot instance 实在太便宜了,如 C5.large $0.03/hour,所以 ECS 没有啥价格优势)
- spot instance 基于一个 prebuild 的 AMI(如果 ECS,则 docker)启动,AMI 里包含处理构建的软件。这样启动起来之后,就能自动处理构建任务
- 描述构建任务的 metadata 放置于 spot instance 启动时的 user data 中,构建软件通过
http://169.254.169.254/latest/user-data
访问之 - 构建过程中可能要发送一些 telemetry 到 merlin
- 构建完成,把状态和构建的信息(比如 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 的需求稍稍泛化一下:
- 调用者可以通过 API 触发一个 task
- Control plane 接到 task 后,分配到 data plane 上的某个 resource 上执行
- data plane 向 control plane 汇总 telemetry
- data plane 完成 task 之后,向 control plane 汇报结果,进入到 idle 状态等待下次调度
为了符合社会主义核心价值观,我们换个比喻:Control plane 类似于 erlang/OTP 里的 Supervisor;data plane 类似于 GenServer。对于 erlang 不太熟悉的同学可以看我的文章:上帝说:要有一门面向未来的语言,于是有了 erlang。你不必理解代码,但需要理解思想。
然而,erlang/OTP 里的 Supervisor 只负责启动和监控 process,如果要启动和监控 node,有很多问题:
- 如何在 cloud 里动态启动一个节点?
- 如何让这个节点自动加入到 cluster 里?
- 如何让这个节点有运行 task 所必须的软件?
- control plane 如何和 data plane 方便地通信?
- 如何把上面的所有细节屏蔽起来,启动和监控一个节点,像 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} endend
我们大概讲讲 Overseer 干些什么:
- start_link:启动时,它接受一些参数,关于我们要启动的 node 的 spec。node 目前支持两种 adapter,local 和 ec2。我将其做成 adapter,是为了日后支持更多类型的 node(比如 ECS)。strategy 目前仅支持 simple_one_for_one,亦即所有 node 使用相同的 spec,在需要的时候由 Overseer 创建。
- start_child:Overseer 可以根据预置的 spec 启动一个 node —— 比如 ec2 spot instance。这个 node 启动成功之后,初始的代码会使用 UserData 里面的 node_name 和 cookie 连接 Overseer。当 Overseer 监测到一个 node_up 的消息后,会在内部创建一个 Labor 的数据结构,并且把 spec 里面定义的 release 发给这个 labor node 加载和运行。
- 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 起来。
- 作为一个类似于 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 而已(这个估计短期没工夫实现):
好了,不说废话了,我还是抓紧写代码去。提前祝各位叔叔阿姨哥哥姐姐弟弟妹妹,春节快乐!也祝各位同处本命年「伏吟」的小伙伴们,狗年红红火火,不犯太岁!:)
- golang简单tls协议用法完整示例
- spark开发环境详细教程1:IntelliJ IDEA使用详细说明
- MySQL数据库(五):索引
- hdu----(1466)计算直线的交点数(dp)
- golang模板template自定义函数用法示例
- 程序员你为什么这么累【续】:编写简陋的接口调用框架 - 动态代理学习
- hdu---(Tell me the area)(几何/三角形面积以及圆面积的一些知识)
- MySQL数据库(六):体系结构和存储引擎
- hdu----(2222)Keywords Search(trie树)
- MySQL数据库(七):数据导出与导入
- flume与kafka整合高可靠教程
- Oracle 12c系列(一)|多租户容器数据库
- Spring Security入门(三):密码加密
- MySQL数据库(八):表记录的基本操作(增删改查)
- JavaScript 教程
- JavaScript 编辑工具
- JavaScript 与HTML
- JavaScript 与Java
- JavaScript 数据结构
- JavaScript 基本数据类型
- JavaScript 特殊数据类型
- JavaScript 运算符
- JavaScript typeof 运算符
- JavaScript 表达式
- JavaScript 类型转换
- JavaScript 基本语法
- JavaScript 注释
- Javascript 基本处理流程
- Javascript 选择结构
- Javascript if 语句
- Javascript if 语句的嵌套
- Javascript switch 语句
- Javascript 循环结构
- Javascript 循环结构实例
- Javascript 跳转语句
- Javascript 控制语句总结
- Javascript 函数介绍
- Javascript 函数的定义
- Javascript 函数调用
- Javascript 几种特殊的函数
- JavaScript 内置函数简介
- Javascript eval() 函数
- Javascript isFinite() 函数
- Javascript isNaN() 函数
- parseInt() 与 parseFloat()
- escape() 与 unescape()
- Javascript 字符串介绍
- Javascript length属性
- javascript 字符串函数
- Javascript 日期对象简介
- Javascript 日期对象用途
- Date 对象属性和方法
- Javascript 数组是什么
- Javascript 创建数组
- Javascript 数组赋值与取值
- Javascript 数组属性和方法
- Python基于traceback模块获取异常信息
- PHP实现微信商户支付企业付款到零钱功能
- PHP调用微博接口实现微博登录的办法示例
- thinkphp3.2同时连接两个数据库的简单方法
- php实现微信企业付款到个人零钱功能
- php中对象引用和复制实例分析
- php中上传文件的的解决方案
- PHP实现与java 通信的插件使用教程
- thinkPHP5框架接口写法简单示例
- php实现数组重复数字统计实例
- php提取微信账单的有效信息
- php使用pecl方式安装扩展操作示例
- RSA实现JS前端加密与PHP后端解密功能示例
- Laravel源码解析之路由的使用和示例详解
- Linux中crontab输出重定向不生效问题的解决办法