其实原计划是先搞定Spark的数据系统以后再来看这部分的,但是在分析数据系统的过程中发现这部分代码要是不搞清除很难继续搞下去,所以就暂时让它插个队了。
启动集群
关于集群启动的入口我就不详说了,看一下sbin/start-all.sh基本上也就清楚了,这里涉及到的角色是master和worker,它们组成了spark集群中的“管理员”角色。
启动Driver
其实这个流程前面的文档中也有涉及,只是在这一方面并没有很深入,我没记错的话当时在分析TaskScheduler时有简略提过:TaskScheduler所配对的(只谈standalone这种哈)的backend是一个SparkDeploySchedulerBackend对象,它首先会执行其父类CoarseGrainedSchedulerBackend的start方法,这个方法我已经在前文中分析过了,然而在父类的start方法执行完毕后,它本身的start逻辑就要开始执行了,我们来看一下:
override def start() {
super.start()
// The endpoint for executors to talk to us
val driverUrl = "akka.tcp://%s@%s:%s/user/%s".format(
SparkEnv.driverActorSystemName,
conf.get("spark.driver.host"),
conf.get("spark.driver.port"),
CoarseGrainedSchedulerBackend.ACTOR_NAME)
val args = Seq(driverUrl, "{{EXECUTOR_ID}}", "{{HOSTNAME}}", "{{CORES}}", "{{WORKER_URL}}")
val extraJavaOpts = sc.conf.getOption("spark.executor.extraJavaOptions")
.map(Utils.splitCommandString).getOrElse(Seq.empty)
val classPathEntries = sc.conf.getOption("spark.executor.extraClassPath").toSeq.flatMap { cp =>
cp.split(java.io.File.pathSeparator)
}
val libraryPathEntries =
sc.conf.getOption("spark.executor.extraLibraryPath").toSeq.flatMap { cp =>
cp.split(java.io.File.pathSeparator)
}
// Start executors with a few necessary configs for registering with the scheduler
val sparkJavaOpts = Utils.sparkJavaOpts(conf, SparkConf.isExecutorStartupConf)
val javaOpts = sparkJavaOpts ++ extraJavaOpts
val command = Command("org.apache.spark.executor.CoarseGrainedExecutorBackend",
args, sc.executorEnvs, classPathEntries, libraryPathEntries, javaOpts)
val appDesc = new ApplicationDescription(sc.appName, maxCores, sc.executorMemory, command,
sc.ui.appUIAddress, sc.eventLogger.map(_.logDir))
client = new AppClient(sc.env.actorSystem, masters, appDesc, this, conf)
client.start()
}
可以看到,先是初始化一堆环境变量、启动命令之类的东西,然后创建一个AppClient对象,调用其start方法,完工。这里有一个很明显的线索就是command,这尼马就是executor的启动命令啊,随便起一个spark-shell然后jps一把一看,所谓的executor就是这个类。
现在有了线索,那么我们就可以很欢乐地跟着AppClient的start方法走下去啦。start方法直接起了一个ClientActor线程,这个线程的preStart方法里调用了registerWithMaster这个方法......跟着跟着,可以发现,这条路的终点是丢了个RegisterApplication消息给Master。
我们把镜头切换到Master这一端。Master收到这条消息后首先是在自己内部属性中注册这个新加进来的application,然后这里出现了一个神奇的东西:persistenceEngine。由于本文只是看一下几个核心进程而不是深入了解整个Spark的容错机制,所以这里稍微提一下不做深入:PersistenceEngine是用来持久化集群状态的,比如worker和application这样的信息,持久化所带来的当然是当问题出现以后,可以读出这些状态信息来做恢复。再深入一点可以看到基本上有两种手段,一种是持久化到zookeeper,另一种是持久化到filesystem(当然这里还有一个打酱油的Blackhole方式,实际上就是什么都不做,占个位置而已)。
知道了PersistenceEngine的用途后,app到了这里,就自然会把信息添加进去方便master进行持久化。在完成了这一步后,master先往回发一个RegisteredApplication消息,然后执行了scheduler方法。
先看往回发消息后的动作:AppClient收到master的返回消息后,从消息中取出master分配的app ID,进行记录,然后就没有然后了。
Standalone Master的schedule
那么Master的scheduler做了什么呢?master的scheduler是一个非常重要的方法。这个方法用来为等待中的application调度资源,所以在可用资源发生变化或有新application加入时都会被调用。
private def schedule() {
if (state != RecoveryState.ALIVE) { return }
// First schedule drivers, they take strict precedence over applications
val shuffledWorkers = Random.shuffle(workers) // Randomization helps balance drivers
for (worker <- shuffledWorkers if worker.state == WorkerState.ALIVE) {
for (driver <- List(waitingDrivers: _*)) { // iterate over a copy of waitingDrivers
if (worker.memoryFree >= driver.desc.mem && worker.coresFree >= driver.desc.cores) {
launchDriver(worker, driver)
waitingDrivers -= driver
}
}
}
// Right now this is a very simple FIFO scheduler. We keep trying to fit in the first app
// in the queue, then the second app, etc.
if (spreadOutApps) {
// Try to spread out each app among all the nodes, until it has all its cores
for (app <- waitingApps if app.coresLeft > 0) {
val usableWorkers = workers.toArray.filter(_.state == WorkerState.ALIVE)
.filter(canUse(app, _)).sortBy(_.coresFree).reverse
val numUsable = usableWorkers.length
val assigned = new Array[Int](numUsable) // Number of cores to give on each node
var toAssign = math.min(app.coresLeft, usableWorkers.map(_.coresFree).sum)
var pos = 0
while (toAssign > 0) {
if (usableWorkers(pos).coresFree - assigned(pos) > 0) {
toAssign -= 1
assigned(pos) += 1
}
pos = (pos + 1) % numUsable
}
// Now that we've decided how many cores to give on each node, let's actually give them
for (pos <- 0 until numUsable) {
if (assigned(pos) > 0) {
val exec = app.addExecutor(usableWorkers(pos), assigned(pos))
launchExecutor(usableWorkers(pos), exec)
app.state = ApplicationState.RUNNING
}
}
}
} else {
// Pack each app into as few nodes as possible until we've assigned all its cores
for (worker <- workers if worker.coresFree > 0 && worker.state == WorkerState.ALIVE) {
for (app <- waitingApps if app.coresLeft > 0) {
if (canUse(app, worker)) {
val coresToUse = math.min(worker.coresFree, app.coresLeft)
if (coresToUse > 0) {
val exec = app.addExecutor(worker, coresToUse)
launchExecutor(worker, exec)
app.state = ApplicationState.RUNNING
}
}
}
}
}
}
这个方法的前半部分是一个嵌套的for循环,经过嵌套地遍历可用worker和等待中的driver,使driver能在满足其资源需求的worker(内存和core都满足)上进行launch。这部分代码十分简单,只为driver服务。如果我们自己写的driver是通过Spark-submit来提交的,那么这个driver看样子就会被放到master来分配实际位置了。但这种方法简单带来的后果就是有些对于资源要求高的driver在有很多对于资源要求低的driver被提交的情况下会饿死。不过我刚刚看了一下github,貌似最近在这块代码上对于这个问题有所修改,大家可以自己上去围观一下。其实再往深入跟一下,可以发现这个waitingDrivers只在收到RequestSubmitDriver消息后才会添加,而这个消息只在Client类启动时被发送,再往上推一步,这个Client类只有在SparkSubmit类提交Application时才会被启动。总结一下,如果利用SparkSubmit来提交app或者自己进行类似实现,那么提上去的driver理论上会被master分配到一个worker上去跑。
再来看后半部分:后半部分是对app在worker上分配executor的逻辑,这里分了两种方式。第一种方式:对于被提交上来的application,采用先进先分配的策略,尝试在当前alive且有空闲core的worker上为这个application分配executor。在分配时,里面用了一个while,开始在可用的worker上轮流地为这个app绑核,但每次只分配一个core,通过不断地循环直到app所有需要的core得到满足或者没有可用的core时结束。这里存在一次调用schedule时集群所拥有的空闲core少于application所要求的core的情况,这种情况下剩余的core会在下次schedule被触发时进行满足。分配完成后通过调用launchExecutor方法向worker发送消息,启动executor。
第二种方式:对于等待中的app也采用先进先出的方式进行调度,与第一种的区别在于:第一种的循环分配方式会尽可能地把application所需要的executor打散到集群中各个节点上去,而第二种方式对于一个worker,尽量把application的executor都塞进去,如果塞不下,再把没塞进去的部分(剩余还没得到满足的core)塞进下一个worker,以此类推。另外,spark默认采用第一种方式。
启动executor
上一节已经看到master在对application分配好executor以后,就向worker发送消息启动executor。worker在收到LaunchExecutor命令后,先创建并启动一个ExecutorRunner作为要启动的executor的manager,由它来真正启动一个executor,然后在内部注册被占用的core和memory,并向master发送一个回复,报告executor的状态。master在收到这个回复后也会将消息转发给application的driver。
ExecutorRunner在这里担当的是一个executor管理器的角色,与executor呈一对一的关系。其不仅要管理executor的启动与关闭,同时也保留了executor的状态。在启动executor时,内部会创建一个线程,由这个线程来启动Executor进程,并管理其打印输出。这个线程的生命周期会持续到executor进程结束为止。而具体启动一个什么样的executor,就由最开始SparkDeploySchedulerBackend定义的executor类名称决定。这里就以standalone模式下CoarseGrainedExecutorBackend类型为例子来看看executor。
这个类包含了main方法以及一组actor消息监听逻辑。在被启动后,做的事情无非就是环境配置和actor系统的初始化及连接,此外,还会向driver发起一次注册。这里也可以清除地看到,executor是直接与driver端进行通信的。另外,backend中会保存一个Executor的实例,在收到RegisteredExecutor(此消息为driver收到executor注册后的一个反馈消息)消息后这个实例被初始化,backend在这里担当了一个接收消息,调用Executor中逻辑的角色。
加载Task
Executor的内部也有一个SparkEnv对象,以"非driver的方式被创建",用以连接spark的其他子系统。Executor在收到LaunchTask消息后开始加载task。在Executor内部维护了一个线程池,加载task时会先创建一个TaskRunner,然后把这个TaskRunner添加到线程池开始执行。
在TaskRunner的run方法中,除了一些环境的初始化和时间戳以及状态更新,最重要的就是反序列化收到的Task。这里包括Task对象,Task所用到的file,Task所依赖的jar。反序列化出来的Task对象的run方法被调用后,得到task的运行结果。运行结果被序列化后包装到一个DirectTaskResult对象里面,这个对象再度被序列化后调用BlockManager的putBytes来存储(前文有提过这个方法),只是storageLevel是MEMORY_AND_DISK_SER,这就意味着在spark中,对于task计算的中间结果如果内存不够的话就会丢到disk上。最后,task执行完毕后,向driver端发送一个StatusUpdate消息表明任务执行完毕。driver端收到这个消息后(TaskScheduler)标记对应的core重新为free,然后再次执行makeOffers为其他等待中的Task分配资源。
总结
本文档通过启动以及加载任务两个流程稍微跟了一下几个集群角色的部分代码,包括master、worker、executor,也顺便补完了之前对于任务提交流程的分析,增加讲解了executor端的动作。更详细的分析在日后用到的时候再展开。