使用spark-submit启动应用
一旦应用程序打包完毕,那么就可以使用spark-submit
脚本提交并启动应用。应用运行需要的属性配置可以通过命令行参数和默认属性配置文件./conf/spark-defaults.conf
提供(具体属性含义详见Submitting Applications)。使用如下命令提交Application的命令,并且下文的所有相关信息都与基于该命令。
# 以集群部署模式将应用提交到Spark独立群集上,并使用监视器。
./bin/spark-submit \
--class cn.vibrancy.spark.ComplexJob \
--master spark://master0:7077 \
--deploy-mode cluster \
--supervise \
--executor-memory 20G \
--total-executor-cores 100 \
--verbose \
/home/deploy/complex-job.jar
./bin/spark-submit
脚本内容如下(具体的脚本调用链见前一篇文章Spark集群启动过程分析),可知是通过SparkSubmit
类来提交。
if [ -z "${SPARK_HOME}" ]; then
source "$(dirname "$0")"/find-spark-home
fi
# disable randomized hash for string in Python 3.3+
export PYTHONHASHSEED=0
exec "${SPARK_HOME}"/bin/spark-class org.apache.spark.deploy.SparkSubmit "$@"
SparkSubmit分析
SparkSubmit是启动Spark应用程序的网关,它用于解析参数、处理设置类路径与相关的Spark依赖关系,并为不同的集群管理器和Spark支持的发布模式之上提供一个层。SparkSubmit会调用prepareSubmitEnviromeny
方法准备运行环境,并返回用户类的启动参数、类路径、系统属性和childMainClass。该childMainClass就是上面提到层,对应于应用程序的DriverClient,具体使用的那个DriverClient是由集群管理器和发布模式决定的。
如果是client发布模式,那么直接在SparkSubmit运行的线程启动用户类;
如果是Spark独立集群,并且是cluster发布模式
- 使用传统的RPC提交网关
org.apache.spark.deploy.Client
作为用户类的包装器来启动用户类; - 在Spark1.3之后,默认可以使用REST客户端来提交应用程序,那么所有Spark参数都将通过
system properties
传递给客户端,并且childMainClass为org.apache.spark.deploy.rest.RestSubmissionClient
;
但是如果用户没有在Master上启动REST端点服务器,那么使用传统RPC提交。
- 使用传统的RPC提交网关
如果是YARN集群,则使用
org.apache.spark.deploy.yarn.Client
来提交启动用户类;如果是Mesos集群,那么只支持使用REST客户端来提交应用程序;
Main class(childClasspath):
- org.apache.spark.deploy.Client | org.apache.spark.deploy.rest.RestSubmissionClient
Arguments(childArgs):
- --memory | file:/home/deploy/complex-job.jar
- 1g | cn.vibrancy.spark.ComplexJob
- launch
- spark://master0:7077
- file:/home/deploy/complex-job.jar
- cn.vibrancy.spark.ComplexJob
System properties(sysProps):
- (spark.driver.memory, 1g)
- (spark.eventLog.enabled, true)
- (spark.history.ui.port, 7777)
- (SPARK_SUBMIT, true)
- (spark.serializer, org.apache.spark.serializer.KryoSerializer)
- (spark.app.name, cn.vibrancy.spark.ComplexJob)
- (spark.history.fs.logDirectory, /tmp/spark/logs/history)
- (spark.driver.supervise, false)
- (spark.jars, file:/home/deploy/complex-job.jar)
- (spark.submit.deployMode, cluster)
- (spark.history.fs.update.interval, 5)
- (spark.eventLog.dir, /tmp/spark/logs/event)
- (spark.master, spark://master0:7077)
Classpath elements(childClasspath):
当运行环境相关的参数准备好之后,就可以执行childMainClass的main
方法了,本文主要对传统模式RPC提交进行分析。
使用RPC客户端提交应用
集群架构
启动驱动器
ClientEndpoint启动之后,会向Master发送RequestSubmitDriver
的一个请求,请求的参数如下:
{
"jarUrl":"file:/home/deploy/complex-job.jar",
"mem":"1g",
"cores":1,
"supervise":false,
"command":{
"mainClass":"org.apache.spark.deploy.worker.DriverWrapper",
"arguments":["{{WORKER_URL}}","{{USER_JAR}}","cn.vibrancy.spark.ComplexJob"],
"environment":{
"spark.driver.memory":"1g",
"spark.eventLog.enabled":true,
"spark.history.ui.port":7777,
...
},
"classPathEntries":[],
"libraryPathEntries":[],
"javaOpts":[]
}
}
Master接收到请求之后,创建Driver相关的数据结构,并将driver加入到等待调度的waitingDrivers,之后调用schedule()
方法。
override def receiveAndReply(context: RpcCallContext): PartialFunction[Any, Unit] = {
case RequestSubmitDriver(description) =>
if (state != RecoveryState.ALIVE) {
val msg = s"${Utils.BACKUP_STANDALONE_MASTER_PREFIX}: $state. " +
"Can only accept driver submissions in ALIVE state."
context.reply(SubmitDriverResponse(self, false, None, msg))
} else {
logInfo("Driver submitted " + description.command.mainClass)
val driver = createDriver(description)
persistenceEngine.addDriver(driver)
waitingDrivers += driver
drivers.add(driver)
schedule()
context.reply(SubmitDriverResponse(self, true, Some(driver.id),
s"Driver successfully submitted as ${driver.id}"))
}
schedule()
方法会选择一个Worker来运行驱动器程序,具体选择哪一个Worker,只需要资源满足驱动器需求即可。选中一个Worker之后,会向Worker发送LaunchDriver命令,命令的参数包括driverId
和ApplicationDescription
(见上文)。
Worker接收请求之后,会创建一个DriverRunner对象,该对象内部会创建一个ProcessBuilder,使用ProgressBuilder创建一个独立进程执行命令来启动用户类,具体的启动命令如下:
/opt/jdk/1.8.0_151/bin/java
-cp /opt/spark/2.2.0/conf/:/opt/spark/2.2.0/jars/
-Xmx1024M
-Dspark.serializer=org.apache.spark.serializer.KryoSerializer
-Dspark.eventLog.enabled=true
-Dspark.driver.supervise=false
-Dspark.submit.deployMode=cluster
-Dspark.app.name=cn.vibrancy.spark.ComplexJob
-Dspark.eventLog.dir=/tmp/spark/logs/event
-Dspark.jars=file:/home/deploy/complex-job.jar
-Dspark.driver.memory=1g
-Dspark.rpc.askTimeout=10s
-Dspark.history.fs.update.interval=5
-Dspark.history.fs.logDirectory=/tmp/spark/logs/history
-Dspark.history.ui.port=7777
-Dspark.master=spark://master0:7077
org.apache.spark.deploy.worker.DriverWrapper spark://Worker@192.168.11.21:40755 /tmp/spark/work/driver-20171111011116-0006/complex-job.jar cn.vibrancy.spark.ComplexJob
从启动命令可知,独立进程会通过DriverWrapper的main方法来启动用户类。
SparkContext的创建
用户类的main方法执行后,会涉及到SparkContext的创建,具体创建的组件可以看上面的架构图,这里只是简单的介绍一下在StandaloneSchedulerBackend
启动后与Master通信来启动驱动器的过程。
接着会创建一个StandaloneAppClient
,它是应用程序与Spark独立集群模式的接口,appClient会向当前的RpcEnv注册一个ClientEndpoint,负责与Master进行交互。ClientEndpoint启动后,会向masters进行注册,发送RegisterApplication请求。
Master接收到请求之后,会分配一个appId给当前的Application,并将应用程序相关的信息加入到Master内部的数据结构中,并回复appClient应用注册成功。接着会调用schedule()
方法对application进行调度,分配执行器。
在Workers上调度执行器的算法
private def startExecutorsOnWorkers(): Unit = {
// 目前是一个非常简单的FIFO调度器
for (app <- waitingApps if app.coresLeft > 0) {
val coresPerExecutor: Option[Int] = app.desc.coresPerExecutor
// 过滤掉那些没有足够资源启动Executor的Worker
val usableWorkers = workers.toArray
.filter(_.state == WorkerState.ALIVE)
.filter(worker => worker.memoryFree >= app.desc.memoryPerExecutorMB
&& worker.coresFree >= coresPerExecutor.getOrElse(1))
.sortBy(_.coresFree).reverse
// 调度执行器在Worker上运行,并返回一个分配给每个worker的核心数的数组
val assignedCores = scheduleExecutorsOnWorkers(app, usableWorkers, spreadOutApps)
// 既然我们已经决定了在每个 Worker 上分配多少核心,现在让我们开始进行分配
for (pos <- usableWorkers.indices if assignedCores(pos) > 0) {
allocateWorkerResourceToExecutors(app, assignedCores(pos), coresPerExecutor, usableWorkers(pos))
}
}
}
startExecutorsOnWorkers
用于在Workers上为Application启动一组执行器,方法的主要逻辑包括执行器的分配和执行器的启动两部分。
执行器的分配
为app分配执行器需要使用scheduleExecutorsOnWorkers
,该方法会接受当前调度的app、当前可用的workers和是否允许跨节点之间执行轮询调度的布尔值为参数,会返回一个分配给每个worker的core数的数组。
该方法的目的是为一个application在一组可用的Workers上进行调度,调度的意思是为app分配可以运行的执行器,那么这个执行器具体是怎么分配的呢?是在同一个Worker上启动多个执行器,还是在多个Worker上分别启动一个?Spark提供了两种调度模式:
- 尝试将applications的执行器尽可能多分散到的不同的Worker当中,轮询Worker来分配;
- 相反,尽可能在较少的Worker上运行他们,将执行器集中到一个Worker中。
第一种调度模式主要针对于数据本地性优化策略,并且是默认的调度模式。
另外,分配给每个执行器的CPU core是可配置的,并且官方建议配置corePerExecutor,好处在于:
- 当明确的设置时(corePerExecutor),如果Worker有足够的CPU和内存,那么来自相同应用的多个执行器就有可能在同一个Worker中执行。
- 否则,每个执行器或获取一个Worker上所有可能的CPU,在这种情况下,在每个Worker上仅有一个执行器(Worker只为当前APP启动一个执行器,还有可能运行其他app的执行器)。
private def scheduleExecutorsOnWorkers(app: ApplicationInfo, usableWorkers: Array[WorkerInfo], spreadOutApps: Boolean): Array[Int] = {
// 当前app执行器需要的core数目
val coresPerExecutor = app.desc.coresPerExecutor
// 如果没有设置 执行器使用的core(coresPerExecutor) ,则默认为1.
val minCoresPerExecutor = coresPerExecutor.getOrElse(1)
// 如果 coresPerExecutor 没有指定,则说明Executor将获取一个Worker上的所有可用的CPU,那么一个Worker上就只能运行一个执行器。
val oneExecutorPerWorker = coresPerExecutor.isEmpty
// 每个执行器使用的内存
val memoryPerExecutor = app.desc.memoryPerExecutorMB
// 当前可分配的Worker的个数
val numUsable = usableWorkers.length
// 每个Worker已分配的cores
val assignedCores = new Array[Int](numUsable)
// 每个Worker已分配需要启动的执行器个数
val assignedExecutors = new Array[Int](numUsable)
// 为当前App分配的core,具体值取 app还需要的core数量 和 当前Workers剩余可用core数量 的最小值。
var coresToAssign = math.min(app.coresLeft, usableWorkers.map(_.coresFree).sum)
/**
* 用于判断指定的Worker能否为该app启动一个执行器。
*
* @param pos usableWorkers的索引
* @return
*/
def canLaunchExecutor(pos: Int): Boolean = {
// 如果app还未分配的core大于minCoresPerExecutor,说明还需要进行调度,keepScheduling=true。
val keepScheduling = coresToAssign >= minCoresPerExecutor
// 如果当前Worker剩余的core大于minCoresPerExecutor,那么enoughCores为true,说明有足够的core。
val enoughCores = usableWorkers(pos).coresFree - assignedCores(pos) >= minCoresPerExecutor
/**
* 判断是否在当前Worker上运行一个新的执行器
* - 如果允许在一个Worker下运行多个执行器(设置了coresPerExecutor参数,oneExecutorPerWorker为false),那么可以在当前Worker下运行一个新的执行器;
* - 否则,判断当前Worker是否已经分配过执行器,如果执行器为空,那么启动一个新的执行器,
* - 否则,直接在已有的一个执行器下面增加 core
*/
val launchingNewExecutor = !oneExecutorPerWorker || assignedExecutors(pos) == 0
if (launchingNewExecutor) {
// 当前Worker上分配的执行器占用的内存。
val assignedMemory = assignedExecutors(pos) * memoryPerExecutor
// 当前Work的总内存- 已分配执行器占用的内存 = 剩余的内存
val enoughMemory = usableWorkers(pos).memoryFree - assignedMemory >= memoryPerExecutor
// 判断当前app的执行器数目是否超出限制,方法是 已有的执行器数目 + 当前分配的执行器数目 < app.executorLimit。
val underLimit = assignedExecutors.sum + app.executors.size < app.executorLimit
// 如果以下有一个条件不满足,那么当前Worker就不能为该app调度执行器。
keepScheduling && enoughCores && enoughMemory && underLimit
} else {
// 我们将core添加到现有的执行器中,因此不需要检查内存和执行器的限制
keepScheduling && enoughCores
}
}
// 一直启动执行器,直到没有更多的Workers能够容纳任何执行器,或者已经满足了application's的限制要求。
var freeWorkers = (0 until numUsable).filter(canLaunchExecutor)
while (freeWorkers.nonEmpty) {
freeWorkers.foreach { pos =>
var keepScheduling = true
while (keepScheduling && canLaunchExecutor(pos)) {
coresToAssign -= minCoresPerExecutor
assignedCores(pos) += minCoresPerExecutor // 记录当前Worker已分配的core数
// 如果我们为每个Worker都启动一个执行器,那么每次迭代都分配给一个core给执行器
// 否则,每次迭代给一个执行器加core。
if (oneExecutorPerWorker) {
assignedExecutors(pos) = 1
} else {
assignedExecutors(pos) += 1
}
/**
* 将一个application扩散意味着,尽可能跨多个 workers 分配 Executor。
* 如果spreadOutApps为false,那么应该在当前 Worker 上保持调度,直到资源耗尽为止;
* 否则,直接移动到下一个Worker。
*/
if (spreadOutApps) {
keepScheduling = false
}
}
}
freeWorkers = freeWorkers.filter(canLaunchExecutor)
}
assignedCores
}
分配算法演示
假设有3个可用Workers,拥有的CPU和内存分别为:
Worker | CPU core | 内存 |
---|---|---|
worker1 | 10 | 4096 |
worker2 | 10 | 4096 |
worker3 | 10 | 4096 |
现在想对一个Application进行调度,因此需要在以上3个Worker中App分配Executor资源,具体为App分配多少个Executor和Core,还有在哪个Worker上分配由App的以下几个参数和spreadOut属性来控制。
- memoryPerExecutor:指每个执行器需要的内存大小;
- coresPerExecutor:每个执行器需要的core数;
- maxCores:该APP最大内核数限制;
- executorLimit:该APP拥有的执行器数限制;
没有最大核心和执行器总数限制
{ "memoryPerExecutor":1024, "coresPerExecutor":2, "maxCores":None, "executorLimit":None }
如果spreadOut为true,那么将以轮询遍历Worker的方式进行分配,由于没有maxCores和执行器限制,那么将会一直分配,直到Worker资源耗尽为止,分配的结果如下
注:
- 2/1024:表示分配了2个core,并启动一个新的执行器,占用1024MB内存;
- 2/-:表示只在原先的执行器上加2个core。
序号 Worker1 Worker2 Worker3 1 2/1024 2/1024 2/1024 2 2/1024 2/1024 2/1024 3 2/1024 2/1024 2/1024 4 2/1024 2/1024 2/1024 总计 8/4096 8/4096 8/4096 由于每个Worker的内存耗尽,所以最终每个Worker为该APP启动了4个执行器,分配了8个内核,所以总共占用内存为4096*3MB,总内核数为8*3个。
- 如果spreadOut为false,分配的结果和上面相同,只不过是按纵向分配,只有Worker1资源分配完后,才分配Worker2上的资源。
没有执行器数限制
{ "memoryPerExecutor":1024, "coresPerExecutor":2, "maxCores":4, "executorLimit":None }
- 如果spreadOut为true
序号 Worker1 Worker2 Worker3 1 2/1024 2/1024 -/- 总计 2/1024 2/1024 -/- 由于当前app达到了maxCores限制,所以只有Worker1和Worker2为APP分别启动了1个执行器,分配了2个内核,所以总共占用内存为2*1024MB,总内核数为4。
- 如果spreadOut为false
序号 Worker1 Worker2 Worker3 1 2/1024 -/- -/- 2 2/1024 -/- -/- 总计 4/2048 -/- -/- 由于当前app达到了maxCores限制,所以只有Worker1为APP分别启动了2个执行器,分配了4个内核,所以总共占用内存为2*1024MB,总内核数为4。
没有最大核心数限制和每个Worker只启动一个执行器
{ "memoryPerExecutor":1024, "coresPerExecutor":None, "maxCores":None, "executorLimit":4 }
- 如果spreadOut为true
序号 Worker1 Worker2 Worker3 1 1/1024 1/1024 1/1024 2 1/- 总计 2/1024 1/1024 1/1024 由于达到了执行器数限制,所以最终Worker1为当前app分配了2个内核,启动了一个执行器,占用1024MB内存;Worker2为当前app分配了1个内核,启动了一个执行器,占用1024MB内存;Worker3为当前app分配了1个内核,启动了一个执行器,占用1024MB内存;
- 如果spreadOut为false
序号 Worker1 Worker2 Worker3 1 1/1024 1/1024 1/1024 2 1/- 1/- 1/- 3 1/- 1/- 1/- 4 1/- 1/- 1/- 5 1/- 1/- 1/- 6 1/- 1/- 1/- 7 1/- 1/- 1/- 8 1/- 1/- 1/- 9 1/- 1/- 1/- 10 1/- 1/- 1/- 总计 10/1024 10/1024 10/1024 由于将worker上的所有core耗尽,最终结束了分配,每个Worker分配的资源如上表。
所以结论是:如果为spreadOut为 true,则以轮询一组Worker的方式为APP分配执行器资源,否则只有当前Worker的资源耗尽才会去找下一个Worker分配资源。另外,如果coresPerExecutor没有定义,则说明一个该Worker只为当前APP分配一个执行器,然后在当前执行器下增加core的数目;否则,每次循环时Worker都会为APP启动一个新的执行器,并分配coresPerExecutor个内核,最后当Worker的资源分配完毕,或者已分配给APP的资源满足达到上限时则分配完毕。
执行器的启动
当scheduleExecutorsOnWorkers
执行完毕后,会返回一个分配给每个worker的核心数的数组,现在开始启动执行器。
/**
* 将一个worker的资源分配给一个或多个执行器。
*
* @param app 执行器所属的app应用程序信息
* @param assignedCores 当前worker为app分配的core数
* @param coresPerExecutor 每个执行器占用的core
* @param worker the worker info
*/
private def allocateWorkerResourceToExecutors(app: ApplicationInfo,
assignedCores: Int,
coresPerExecutor: Option[Int],
worker: WorkerInfo): Unit = {
// 如果用户定义了 coresPerExecutor,则使用assignedCores/coresPerExecutor得到执行器数
// 否则,启动一个执行器,它拥有这个worker的所有已分配的内核。
val numExecutors = coresPerExecutor.map {
assignedCores / _
}.getOrElse(1)
val coresToAssign = coresPerExecutor.getOrElse(assignedCores)
for (i <- 1 to numExecutors) {
val exec = app.addExecutor(worker, coresToAssign)
launchExecutor(worker, exec)
app.state = ApplicationState.RUNNING
}
}
接着向Worker发送LaunchExecutor的命令,在Worker节点上启动一个执行器进程。
private def launchExecutor(worker: WorkerInfo, exec: ExecutorDesc): Unit = {
logInfo("Launching executor " + exec.fullId + " on worker " + worker.id)
worker.addExecutor(exec)
worker.endpoint.send(LaunchExecutor(masterUrl, exec.application.id, exec.id, exec.application.desc, exec.cores, exec.memory))
exec.application.driver.send(ExecutorAdded(exec.id, worker.id, worker.hostPort, exec.cores, exec.memory))
}
Worker接收到命令后,会创建一个ExecutorRunner对象,该对象内部会创建一个ProcessBuilder,使用ProgressBuilder根据命令创建一个独立进程来启动用户类,具体的启动命令如下:
/opt/jdk/1.8.0_151/bin/java
-cp /opt/spark/2.2.0/conf/:/opt/spark/2.2.0/jars/*
-Xmx1024M
-Dspark.driver.port=41021
-Dspark.history.ui.port=7777
-Dspark.rpc.askTimeout=10s
org.apache.spark.executor.CoarseGrainedExecutorBackend
--driver-url spark://CoarseGrainedScheduler@192.168.11.20:41021
--executor-id 0
--hostname 192.168.11.21
--cores 2
--app-id app-20171111070948-0000
--worker-url spark://Worker@192.168.11.21:36327
可见ProcessBuilder会通过以上命令执行CoarseGrainedExecutorBackend对象的main
方法。接着会调用run
方法运行CoarseGrainedExecutorBackend,在run方法中,需要从Driver拉取一些属性配置,调用SparkEnv.createExecutorEnv
创建执行器端的SparkEnv,最后创建CoarseGrainedExecutorBackend对象,并注册到RpcEnv中。
CoarseGrainedExecutorBackend启动后会立即向驱动器进行执行器注册RegisterExecutor。
CoarseGrainedSchedulerBackend.DriverEndpoint接收到RegisterExecutor命令后,会将当前的执行器信息封装为一个ExecutorData数据结构,存放到executorDataMap中,之后向执行器回复RegisteredExecutor。
CoarseGrainedExecutorBackend接收到驱动器回复的RegisteredExecutor之后,会立即创建一个Executor对象,代表着一个执行器的启动。
至此,一个Application已经提交完毕,并且驱动器和执行器也已经启动。执行器就会等待驱动器提交Job,驱动器发来的任务都会提交到该执行器的线程池中运行。