作者:徐佳
本文为原创文章,转载请注明作者及出处
在介绍 Juice 之前,我想先聊一聊 Mesos,Mesos 被称为 2 层调度框架,是因为 Master 通过内部的 Allocator 完成 Master->Framework 的第一层调度,再由 Framework 通过调度器完成对于资源 -> 任务的分配,这个过程称为第二层调度。
About MesosFramework
先来看一看 Mesos&Framework 的整体架构图:
Mesos 的 Framework 分为 2 部分组成,分别为调度器和执行器。
调度器被称为 Scheduler,从 Mesos1.0 版本开始,官方提供了基于 HTTP 的RestAPI 供外部调用并进行二次开发。
Scheduler 用于处理 Master 端发起的回调事件(资源列表并加载任务、任务状态通知等),进行相应处理。Agent 接收到 Master 分配的任务时,会根据任务的 container-type 进行不同的处理,当处理默认 container-type=’Mesos’ 时,先检查 Framework 所对应的 Executor 进程是否启动,如果没有启动则会先启动 Executor 进程,然后再提交任务到该 Executor 去执行,当运行一个container-type=’Docker’ 的任务时,则启动 Docker Executor 进行处理,程序的运行状态完全取决于 Docker 内部的处理及返回值。
MesosFramework 交互 API
交互分为 2 部分 API,分别 为SchedulerAPI (http://mesos.apache.org/documentation/latest/scheduler-http-api/) 与ExecutorAPI(http://mesos.apache.org/documentation/latest/Executor-http-api/), 每个 API 都会以 TYPE 来区分,具体的处理流程如下:
-
Scheduler 提交一个请求(type=’SUBSCRIBE’)到 Master(http://master-ip:5050/api/v1/scheduler), 并需要设置 ’subscribe.framework_info.id’,该 ID由 Scheduler 生成,在一个 Mesos 集群中必须保证唯一,Mesos 以此FrameworkID 来区分各个 Framework 所提交的任务,发送完毕后,Scheduler端等待 Master 的 ’SUBSCRIBE’ 回调事件,Master 的返回事件被定义在 event对象中,event.type 为 ’SUBSCRIBE’ (注意:’SUBSCRIBE’ 请求发起后,Scheduler 与 Master 端会保持会话连接(keep-alive),Master 端主动发起的事件回调都会通过该连接通知到 Scheduler)。(scheduler-http-api 中接口 ’SUBSCRIBE’)
-
Master 主动发起 ’OFFERS’ 事件回调,通知 Scheduler 目前集群可分配使用资源,事件的 event.type 为 ’OFFERS’。(scheduler-http-api 中接口’OFFERS’)
-
Scheduler 调用 resourcesOffer 为 Offers 安排 Tasks。当完成任务分配后,主动发起 ’ACCEPT’ 事件请求到 Master 端告知 Offers-Tasks 列表。(scheduler-http-api 中接口 ’ACCEPT’)
-
Master 接收到 Scheduler 的任务请求后,将任务发送到 OfferId 对应的Agent 中去执行任务。
-
Agent 接收到任务,检查任务对应的 Executor 是否启动,如启动,则调用该 Executor 执行任务,如未启动,则调用 lauchExecutor() 创建 Executor对象并执行 initialize() 初始化 Executor,Executor 初始化过程中会调用RegisterExecutorMessage 在 Agent 上注册,之后便接受任务开始执行。(Executor-http-api 中接口 ’LAUNCH’)
-
Executor 执行完毕或错误时通知 Agent 任务的 task_status。(Executor-http-api 中接口 ’UPDATE’)
-
Agent 再同步 task_status 给 Master,Master 则调用 ’UPDATE’ 事件回调,通知 Scheduler 更新任务状态。(scheduler-http-api 中接口 ’UPDATE’)
-
Scheduler 确认后发送 ’ACKNOWLEDGE’ 请求告知 Master 任务状态已确认。(scheduler-http-api 中接口 ’ACKNOWLEDGE’)
任务状态标示及Agent宕机处理
对于一个任务的运行状态,Mesos 定义了 13 种 TASK_STATUS 来标示,常用的有以下几种:
TASK_STAGING-任务准备状态,该任务已有Master分配给Slave,但Slave还未运行时的状态。
TASK_RUNNING-任务已在Agent上运行。
TASK_FINISHED-任务已运行完毕。
TASK_KILLED-任务被主动终止,调用scheduler-http-api中'KILL'接口。
TASK_FAILED-任务执行失败。
TASK_LOST-任务丢失,通常发生在Slave宕机。
当 Agent 宕机导致 TASK_LOST 时,Mesos 又是怎么来处理的呢?
在 Master 和 Agent 之间,一般都是由 Master 主动向每一个 Agent 发送 Ping消息,如果在设定时间内(flag.slave_ping_timeout,默认 15s)没有收到Agent 的回复,并且达到一定次数(flag.max_slave_ping_timeouts,默认次数为 5),那么 Master 会操作以下几个步骤:
-
将该 Agent 从 Master 中删除,此时该 Agent 的资源将不会再分配给Scheduler。
-
遍历该 Agent 上运行的所有任务,向对应的 Framework 发送任务的Task_Lost 状态更新,同时把这些任务从 Master 中删除。
-
遍历该 Agent 上的所有 Executor,并删除。
-
触发 Recind Offer,把这个 Agent 上已经分配给 Scheduler 的 Offer 撤销。
-
把这个 Agent 从 master 的 Replicated log 中删除(Mesos Master 依赖Replicated log 中的部分持久化集群配置信息进行 failer over/recovery)。
使用 Marathon 可以方便的发布及部署应用
目前有很多基于 MesosFramework 的开源框架,例如 Marathon。我们在生产环境中已经使用了 Marathon 框架,一般用它来运行 long-run service/application,依靠 marathon 来管理应用服务,它支持应用服务自动/手动起停、水平扩展、健康检查等。我们依靠 jenkins+docker+marathon 完成服务的自动化发布及部署。
Why Juice
下面来讲下我基于 MesosFramework 所开发的一套框架-Juice。(开源地址:https://github.com/HujiangTechnology/Juice.git)
在开发 Juice 之前,我公司所有的音视频转码切片任务都是基于一个叫TaskCenter 的队列分配框架,该框架并不具备分布式调度的功能(资源分配),所以集群的资源利用率一直是个问题。因此,我们想开发一套基于以下三点的新框架来替代老的 TaskCenter。
-
一个任务调度型的框架,需要对资源(硬件)尽可能的做到最大的利用率。
-
框架必须可运行各种类型的任务。
-
平台必须是稳定的。
凭借对 Marathon 的使用经验,以及对于 Mesos 相关文档的查阅,我们决定基于 MesosFramework 来开发一套任务调度型的框架,Mesos 与 Framework 的特性刚才已经说过了,而我们将所需要执行的任务封在 Docker 中去执行,那么对于框架本身来说,就不用关心任务的类型了。
这样业务的边界和框架的边界就变得很清晰,对于 Framework 来说,运行一个 Docker 任务也很方便,刚才说过 Mesos 内置了 DockerExecutor 可以完美的启动 Docker 任务,这样,我们的框架在 Agent 端所需要的开发就非常的少。
Juice 框架在这样的背景下开始了开发的历程,我们对于它的定位是一套分布式任务云系统,这里为什么要称为任务云系统呢?
因为对于调用者来说,使用 Juice,只要做 2 件事情:把要做的任务打成 Docker 镜像并 push 到 docke r仓库中,然后向 Juice 提交一个 Docker 类型的任务。其它的,交给 Juice 去完成就可以了,调用者不用关心任务会在哪台物理机上被执行,只需要关心任务本身的执行状况。
Juice 架构
除此,Juice 有以下一些特点,Juice 框架分为 Juice-Rest(Juice 交互 API 层,可以完成外界对于 Juice Task 的 CRUD 操作)和 Juice-Service(Juice 核心层,负责与 MesosMaster 之间的交互,资源分配、任务提交、任务状态更新等),在一套基于 Juice 框架的应用系统中,通常部署 1-N 个 Juice-Rest(取决于系统的 TPS),以及 N 个 Juice-Service(Juice-Service 分主从模式,为 1 主多从,by zookeeper),对于同一个 Mesos 集群来说,可以部署 1-N 套Juice 框架,以 FrameworkID 来区分,需要部署多套的话在 Juice-Service 的配置文件中设置 mesos.framework.tag 为不同的值即可。

Juice-Rest参数设置
Juice-Rest 采用 Spring-Boot 编写(Juice-API 接口参见:https://github.com/HujiangTechnology/Juice/blob/master/doc/api_document.md), 处理外界发起的对任务 CURD 操作,当提交一个任务到 Juice-Rest 时,需要设置一些参数,比如:
example to run docker:
{
"callbackUrl":"http://www.XXXXXXXX.com/v5/tasks/callback",
"taskName":"demo-task",
"env":{"name":"environment","value":"dev"},
"args":["this is a test"],
"container":{
"docker":{
"image":"dockerhub.XXXX.com/demo-slice"
},
"type":"DOCKER"
}
}
其中 Container 中的 type 目前仅支持 ’Docker’,我们没有加入 ’Mesos’ 类型的Container 模式是因为目前项目组内部的服务已经都基于 Docker 化,但是预留了 ’Mesos’ 类型,在未来可以支持 ’Mesos’ 类型的任务。
Commands 模式支持运行 Linux 命令行命令和 Shell 脚本,比如:
"commands":"/home/app/entrypoint.sh"
这里支持 Commands 模式的原因有 2 点:
-
有时调用方可能只是想在某台制定的 Agent 上运行一个脚本。
-
公司内部其他有些项目组还在使用 Jar 包启动的模式,预留一个 Shell 脚本的入口可以对这些项目产生支持。
env 设置示例,设置运行的任务环境为 dev:
"env":{"name":"environment","value":"dev"}
args 设置示例,设置文件路径:
"args":["/tid/res/test.mp4"]
PS:使用 Commands 模式时不支持 args 选项。
此外,Juice-Rest 支持用户自定义资源大小(目前版本仅支持自定义 CPU、内存),如需要指定资源,需在请求接口中配置 resources 对象,否则,将会使用默认的资源大小运行任务。Juice-Rest 支持资源约束(constrains),即满足在特定 Host 或 Rack_id 标签的 Agent 上运行某任务,设置接口中 constrains 对象字段即可。
Juice 所使用的中间件(MQ、DB等)
下面讲一下 Rest 层的处理模型,当外界发起一个任务请求时,Juice-Rest 接收到任务后,并不是直接提交到 Juice-Service 层,而是做了以下 2 件事情:
-
将任务放入 MQ 中。(目前 Juice 使用 Redis-List 来作为默认的 Queue,采用 LPUSH、RPOP 的模式,先进先出。)
为什么选择使用 Redis 中的 List 作为 Queue 而没有选择其他诸如 rabbitmq、kafka 这些呢?
首先,Redis 相对来说是一个比较轻量级的中间件,而且 HA 方案比较成熟。同时,在我看来,队列中的最佳任务 wait 数量是应该 <10000 的,否则,任务的执行周期将会被拉得很长,以我公司的Juice系统来举例,由于处理的都是耗时的音视频转码切片任务,通常情况下 10000 个任务的排队等候时间会在几个小时以上,所以当任务数量很大时,考虑扩大集群的处理能力而不是把过多的任务积压在队列中。
基于此,选择 Redis-List 相对其他的传统MQ来说没有什么劣势。考虑到一些特殊情况,Juice 也允许用户实现 CacheUtils 接口使用其他 MQ 替换 Redis-List)。
-
纪录 Tasks 信息到 Juice-Tasks 表中,相当于数据落地。后续版本会基于此实现任务重试机制(目前的 1.1.0 内部开发版本已实现),或者在 failover切换后完成任务恢复,此功能在后续 1.2.0 版本中考虑加入。(目前数据库使用 MySql)。
当 Juice-Rest 接受并完成任务提交后会返回给调用方一个 Long 型 18 位数字(JuiceID,全局唯一)作为凭证号。当任务完成后,Juice-Rest 会主动发起回调请求,通知调用方该任务的运行结果(以此 JuiceID 作为业务凭证),前提是调用方必须设置 callbackUrl。同时,调用方可以使用该 JuiceID 对进行任务查询、终止等操作。
另外,在 Juice-Rest 层单独维护一个线程池来处理由 Juice-service 端返回的任务状态信息 Task_status。
Juice-Service内部处理流程
Juice-Service 可以看作是一个 MesosFramework,与 Master 之间通讯协议采用 ProtoBuf,每一种事件请求都通过对应类型的 Call 产生,这里 Juice-Service启动时会发出 Subscribe 请求,由 SubscribeCall() 方法产生 requestBody,采用 OKHTTP 发送,并维持与 Master 之间的长连接
private void connecting() throws Exception {
InputStream stream = null;
Response res = null;
try {
Protos.Call call = subscribeCall();
res = Restty.create(getUrl())
.addAccept(protocol.mediaType())
.addMediaType(protocol.mediaType())
.addKeepAlive()
.requestBody(protocol.getSendBytes(call))
.post();
streamId = res.header(STREAM_ID);
stream = res.body().byteStream();
log.info("send subscribe, frameworkId : " + frameworkId + " , url " + getUrl() + ", streamId : " + streamId);
log.debug("subscribe call : " + call);
if (null == stream) {
log.warn("stream is null");
throw new DriverException("stream is null");
}
while (true) {
int size = SendUtils.readChunkSize(stream);
byte[] event = SendUtils.readChunk(stream, size);
onEvent(event);
}
} catch (Exception e) {
log.error("service handle error, due to : " + e);
throw e;
} finally {
if (null != stream) {
stream.close();
}
if (null != res) {
res.close();
}
streamId = null;
}
}
之后便进入 while 循环,当 Master 端的通知事件发生时,调用 onEvent() 方法执行。
Mesos 的回调事件中,需要特别处理的主要事件由以下几种:
-
SUBSCRIBED:Juice 框架在接收到此事件后将注册到 Master 中的FrameworkID 纪录到数据库 Juice_framework 表中。
-
OFFERS:当 Juice-Service 接收到该类型事件时,便会进入资源/任务分配环节,分配任务资源并提交到 MesosMaster。
-
UPDATE:当 Agent 处理完任务时,任务会由 Executor->Agent->Master->Juice-Service 来完成任务的状态通知。Juice-Service 会将结果塞入 result-list 中。
-
ERROR:框架产生问题,通常这样的问题分两种,一种是比较严重的,例如 Juice-Service 使用了一个已经被 Master 端移除的 FrameworkID,则Master 会返回 ”framework has been removed” 的错误信息,Juice-Service此时会抛出 UnrecoverException 错误:
throw new UnrecoverException(message, true)
Juice-Service 在处理 UnrecoverException 类的错误时会 Reset 服务,当第二个参数为 True 时,会重新生成一个新的 FrameworkID。
而当其他类型的错误,比如 Master 和 Juice-Service 之间的长链接中断,仅仅Reset 服务。
下面我想详细来说说第二步,我们先来看下 ’OFFERS’ 请求处理代码段:
private void onEvent(byte[] bytes) {
....
switch (event.getType()) {
...
case OFFERS:
try {
event.getOffers().getOffersList().stream()
.filter(of -> {
if (SchedulerService.filterAndAddAttrSys(of, attrMap)) {
return true;
}
declines.add(of.getId());
return false;
})
.forEach(
of -> {
List<TaskInfo> tasks = newArrayList();
String offerId = of.getId().getValue();
try {
SchedulerService.handleOffers(killMap, support, of, attrMap.get(offerId), declines, tasks);
} catch (Exception e) {
declines.add(of.getId());
tasks.forEach(
t -> {
AuxiliaryService.getTaskErrors()
.push(new TaskResult(com.hujiang.juice.common.model.Task.splitTaskNameId(t.getTaskId().getValue())
, ERROR, "task failed due to exception!"));
}
);
tasks.clear();
}
if (tasks.size() > 0) {
AuxiliaryService.acceptOffer(protocol, streamId, of.getId(), frameworkId, tasks, getUrl());
}
}
);
if (declines.size() > 0) {
AuxiliaryService.declineOffer(protocol, streamId, frameworkId, SchedulerCalls.decline(frameworkId, declines), getUrl());
}
long end = System.currentTimeMillis();
} finally {
declines.clear();
attrMap.clear();
}
break;
...
}
}
该段代码是分配 Offer-tasks 的核心代码,来看几个方法:
-
SchedulerService.filterAndAddAttrSys(),该方法作用是过滤不符合的 OFFER,我们知道在 Mesos 的 Agent 中是可以通过配置 Attr 来使一些机器跑特殊的任务,而这里的过滤正是基于该特性,比如我们设置了该 Juice-Service 只使用包含以下 Attr 属性的资源时(在配置文件application.properties 中)
mesos.framework.attr=lms,qa,mid|big经过了 SchedulerService.filterAndAddAttrSys() 方法的过滤,符合以上 attr的资源会被选取执行任务。同时不符合的 Offer 会加入 declines List,通过AuxiliaryServic.declineOffer() 一次性发送给 Master 告知忽略。Agent 的 attr 设置通过 /etc/mesos-slave/attributes 来设置。这个文件通常为这样的:
cat /etc/mesos-slave/attributes
bz:xx;
env:xx;
size:xx;
rack_id:xx;
dc:xx -
SchedulerService.handleOffers(),该方法实现了原先 MesosFramework 中的 resourceOffer 的功能,对 Offer 进行 Tasks 分配,最后产生 TaskInfo List,由 AuxiliaryService.acceptOffer() 发送给 Master 通知处理任务。
注意:Master 在发送完 Offer 事件通知后会一直处于 wait 状态,直到Framework 端调用 Accept call(AuxiliaryService.acceptOffer()) 或 Decline call(AuxiliaryServic.declineOffer()) 来告知 Master 资源是否使用后才会通知下一个 Framework 去分配资源。(默认 Master 会一直等待,如果没有通知,则 Mesos 集群中的资源利用率将可能达到 100%,可以通过在 Master端设置 Timeout 来避免这个问题。)
在 Juice-Service 内部,当 SchedulerDriver 与 Master 产生交互后,Juice-Service 的处理逻辑由 SchedulerService 以及 AuxiliaryService 来实现。SchedulerService 处理 Juice 的主要逻辑,比如资源分配算法、任务优先级算法,所有 Master 回调事件处理方法都定义在 SchedulerService 中。
AuxiliaryService 维护几组线程池,完成各自任务,刚才看到的AuxiliaryService.acceptOffer() 和 AuxiliaryServic.declineOffer(),都是通过调用 AuxiliaryServic 中的 send-pool 去完成 call 的发送,另外还有一些管理类的任务(比如实时查询任务状态、终止正在运行的任务等等)通过auxiliary-pool 去完成。所以,AuxiliaryServic 的调用都是异步的。

Juice 中各种队列的功能介绍
刚才介绍了 Juice 的任务在 JuiceRest 提交时是被放入了一个 MQ 中,这个 MQ 在 Juice-Service 中被称为 juice.task.queue。除此之外,还有另外几个MQ,分别是 juice.task.retry.queue、juice.task.result.queue、juice.management.queue。
下面来分别说说这些 Queue 的用处:
-
juice.task.retry.queue:Juice-Service 在取任务时是按照每一个 Offer 轮询分配的,当一个 Offer 在分配资源时,假如从 MQ 中 R-POP 出来的任务不满足该 Offer 时(比如 need-resources 大于该 Offer 的 max offer value时,或者存在 constrains,当前的 offer 和指定执行任务的 offer 不 match时),这时,Juice-Service 的做法是将当前任务放入 juice.task.retry.queue中,等待下一次 Offer 分配时,优先从 juice.task.retry.queue 获取任务并分配。
这里涉及到 Juice 内部获取任务 Queue 的优先级,我用了一个比较简单的方式,即每次分配一个新的 Offer 资源时,先从 juice.task.retry.queue 中取出一定数目的任务(CACHE_TRIES = 5),当还有剩余资源时,则从juice.task.queue 中取任务,直到撑满这个 Offer。
另外,处于 juice.task.retry.queue 会有淘汰机制,目前的任务淘汰机制遵循2 点,当先触发以下某一项时,则该任务会认为失败,任务的 Task_status被设置为 Task_Failed,放入 juice.task.result.queue,任务的淘汰算法如下:
1.过期时间淘汰制,任务处于juice.task.result.queue的时长>TASK_RETRY_EXPIRE_TIME,则淘汰(DEFAULT_TASK_RETRY_EXPIRE_TIME = 86400秒)。
2.大于最大检索次数,任务被取出检索但没有被执行达到最大检索次数>MAX_RESERVED,则淘汰(DEFAULT_MAX_RESERVED = 1024)。 -
juice.task.result.queue:任务结果队列,Juice-Service 在得到一个任务的状态后(不一定是最终状态),将任务的 TaskResult 对象放入juice.task.result.queue,Juice-Rest 端从该队列取出 TaskResult,如果已经是任务的最终状态,比如 Task_Finished 或者 Task_Failed,则通过外部在提交任务时所填写的 callbackUrl 回调调用方告知任务状态。
-
juice.management.queue:管理类队列,支持放入 Reconcile 类或 Kill 类的任务,由 AuxiliaryService 发起任务的查询同步或 Kill 一个正在执行的任务。
通过SDK提交一个任务
目前开源的 Juice 版本,已经提供了完整的 SDK 来完成对于 Juice-Rest 之间的交互,以下是提交一个 Docker 任务的示例:
@Test
public void submitsDocker() {
Submits submitsDocker = Submits.create()
.setDockerImage("dockerhub.XXXX.com/demo-slice")
.setTaskName("demo-slice")
.addArgs("/10002/res/L2.mp4")
.addEnv("environment", "dev")
.addResources(2.0, 2048.0);
Long taskId = JuiceClient.create("http://your-juice-rest-host/v1/tasks", "your-system-id-in-string")
.setOperations(submitsDocker)
.handle();
if(null != taskId) {
System.out.println("submitsDocker, taskId --> " + taskId);
}
}
总结及未来
目前 Juice 1.1.0 开源版本已经处于测试阶段,新版本除修复一些 Bug 之外,还增加了 2 个新功能:
-
增加了任务插队功能,可以通过在传入参数中设置 priority=1 来提高一个任务的执行优先级,该任务会被置于处理队列的最前端。
-
任务失败自动重试功能,设置传入参数 retry=1,任务失败会自动重试,最多重试 3 次。
面对复杂的业务需求,Juice 目前的版本还有一些特性/功能不支持,对于此,最好的方式是请大家 Fork 这个项目的 Git,或直接联系本人,大家一起来把Juice 做好。
Q&A:
Q:Juice 与 elastic job 有哪些差异?
A:我本身对于 elastic job 并不算太熟悉,就随便说几点,如果有错还请各位纠正:
首先 Juice 与 elastic-job-cloud 都基于 mesos,资源-任务分配这块 elastic-job用了 Fenzo(netflix),而 Juice 是自己开发的调度算法。
Juice 在作业调用时不需要作业注册,只要上传任务的镜像(Docker)到仓库及任务触发。而 elastic-job 需要注册作业。
Juice 在 Rest-Api 接口上近乎完全和 marathon 一致,方便一些使用惯 marathon 部署 service 的用户。
Juice 目前版本并不支持作业分片。
Q:能详细介绍下任务资源分配这一块的算法吗?
A:之前已经简单介绍过了,通过接收 ’OFFERS’ 事件触发相关任务-资源分配的代码块。
由于得到的 Offer 对象实际为一个列表,处理逻辑会循环为每一个 Offer 分配具体的任务,而每个 Offer 的任务列表总资源(CPU,Memory等)必需小于 Offer resources * RESOURCES_USE_THRESHOLD(资源使用阈值,可通过配置文件 resources.use.threshold 设置,默认 0.8),每分配完一个 Offer 的task_infos 后,便生成 Accept Call 由发送线程池进行发送处理,整个过程都是异步非阻塞的。
Q:所有的任务都存档在 Docker 里面对于一些临时的任务如何处理?
A:临时的任务确实会产生一些垃圾的镜像,需要定期对 Docker 仓库进行清理,一般设置清理周期为 1 个月。
Q:任务系统是是否有帮助用户完成 Docker 封装的操作?
A:目前没有,所以使用者必需会一些 Docker 的基本操作,至少要会打镜像,提交镜像等。当然,像一些 Docker 的设置,比如挂载 volume,网络(bridge、host)等可以在提交任务时通过参数设置。
Q:Mesos 和 kubernetes 的优劣势是什么?
A:其实我主要使用 Mesos,Mesos 相对 K8S 应该是一套更重的系统,Mesos更像是个分布式操作系统,而 K8S 在容器编排方面更有优势(Pod之类)。
End
推荐阅读

