随着互联网的告诉发展,每天会有大量的数据产生,传统的数据分析手段已经不能满足现在的需求。Apache Spark 是专为大规模数据处理而设计的快速通用的计算引擎,现在形成一个高速发展应用广泛的生态系统。Spark 由于其基于内存的计算模式,能够很好的应用于各个应用场景,包括离线数据分析、在线 数据分析和机器学习等。要想深入的了解 Spark,学习其源码实现是必不可少的过程。
在本场 Chat 中,会讲到如下内容:
- Spark 任务如何提交的?
- Spark 的 Job 提交过程
- Spark 的 DAGScheduler 构建
- Spark 的 TaskScheduler 构建
- Spark 的 Executor 构建
适合人群: 对希望深入理解 Spark 有兴趣的技术人员
1. Spark-Submit 提交任务
1.1 总览
本节主要从整体上对 Spark 提交任务的流程。spark 应用程序可以以 Client 模式和 Cluster 启动,区别在于 Client 模式下的 Driver 是在执行 spark-submit 命令节点上启动的,而 Cluster 模式下是 Master 随机选择的一台 Worker 通过 DriverWrapper 来启动 Driver 的。
整个任务提交的流程大致如下所示:
- 通过 spark-submit 提交会调用 SparkSubmit 类,SparkSubmit 类里通过反射调用 Client,Client 与 Master 通信来 SubmitDriver,收到成功回复后退出 JVM(SparkSubmit 进程退出)。
- Master 收到 SubmitDriver 后会随机选择一台能满足 driver 资源需求的 Worker,然后与对应 Worker 通信发送启动 driver 的消息。Worker 收到消息后根据 driver 的信息等来拼接成 linux 命令来启动 DriverWrapper,在该类里面再启动 driver,最后将 Driver 执行状态返回给 Master。
- driver 启动后接下来就是注册 APP,在 SparkContext 启动过程中会通过创建 AppClient 并与 Master 通信要求注册 application。
- Master 收到消息后会去调度执行这个 application,通过调度算法获取该 application 需要在哪些 Worker 上启动 executor,接着与对应的 Worker 通信发送启动 Executor 的消息。
- Worker 收到消息后通过拼接 linux 命令,启动了 CoarseGrainedExecutorBackend 进程,接着向 Driver 通信进行 Executor 的注册,成功注册后会在 CoarseGrainedExecutorBackend 中创建 Executor 对象。接着就是 job 的执行。
1.2. 申请创建 Driver
我们通常会通过 shell 执行 spark-submit 命令来提交 spark 应用程序,该脚本的运行命令如下所示:
./bin/spark-submit \ --class <main-class> \ --master <master-url> \ --deploy-mode <deploy-mode> \ --conf <key>=<value> \ ... # other options <application-jar> \ [application-arguments]
一些常用的选项是:--class:应用程序的入口点,main 函数所在的类(例如 org.apache.spark.examples.SparkPi)--master:群集的主网址(例如 spark://23.195.26.187:7077)--deploy-mode:是否将驱动程序部署在工作节点(cluster)上,或作为外部客户机(client)本地部署(默认值:client)--conf:Key = value 格式的任意 Spark 配置属性。对于包含空格的值,用引号括起“key = value”(参见示例)。application-jar:包含应用程序和所有依赖关系的捆绑 jar 的路径。该 URL 必须在集群内全局可见,例如 hdfs://路径或所有节点上存在的 file://路径。application-arguments:参数传递给主类的 main 方法(如果有的话)常见的部署策略是从与您的工作机器物理上位于的网关机器提交应用程序(例如,独立的 EC2 集群中的主节点)。在此设置中,client 模式是适当的。在 client 模式下,驱动程序直接在 spark-submit 过程中启动,该过程充当集群的客户端。应用程序的输入和输出连接到控制台。因此,该模式特别适用于涉及 REPL(例如 Spark shell)的应用。
通过查看 spark-submit 脚本可以发现,其实际是使用自定义的参数运行 Spark 中的 org.apache.spark.deploy.SparkSubmit 类,下面我们从 SparkSubmit 的 main 函数开始分析,其主要源代码如下所示:
override def main(args: Array[String]): Unit = { val submit = new SparkSubmit() { self =>.......... } submit.doSubmit(args) }
从代码中可以看出,其首先创建 SparkSubmit 类的实例,并调用 doSubmit 方法,传入我们设置的一些参数。doSubmit 的相关代码如下所示:
def doSubmit(args: Array[String]): Unit = { // 初始化日志(如果尚未完成。跟踪应用程序启动之前是否需要重置日志记录。 val uninitLog = initializeLogIfNecessary(true, silent = true) //解析传入的参数并封装成 SparkSubmitArguments 对象 val appArgs = parseArguments(args) if (appArgs.verbose) { logInfo(appArgs.toString) } appArgs.action match {//匹配提交的任务类型 case SparkSubmitAction.SUBMIT => submit(appArgs, uninitLog) case SparkSubmitAction.KILL => kill(appArgs) case SparkSubmitAction.REQUEST_STATUS => requestStatus(appArgs) case SparkSubmitAction.PRINT_VERSION => printVersion() } }
上述代码中关键部分在于针对不同的任务类型,来执行不同操作,在这里我们是提交任务,所以匹配的是 SparkSubmitAction.SUBMIT,那么将会调用 submit 方法,其关键代码如下:
private def submit(args: SparkSubmitArguments, uninitLog: Boolean): Unit = { def doRunMain(): Unit = { if (args.proxyUser != null) {//查看是否设置用户权限?? val proxyUser = UserGroupInformation.createProxyUser(args.proxyUser, UserGroupInformation.getCurrentUser()) try { proxyUser.doAs(new PrivilegedExceptionAction[Unit]() {//使用当前用户来运行程序,可能会权限不够 override def run(): Unit = { runMain(args, uninitLog) } }) } catch { .......... } } else { runMain(args, uninitLog) } } if (args.isStandaloneCluster && args.useRest) {//判断是否是 StandaloneCluster 部署模式,并且使用基于 REST 的方式 try { logInfo("Running Spark using the REST application submission protocol.") doRunMain() } catch { ................ } // 在所有其他模式下,只需按准备好的方式运行主类 } else { doRunMain() } }
从上述代码中可以看出,在 submit 方法中首先对部署模式进行判断,但其最终都是调用内部的 doRunMain 方法,在 doRunMain 方法中首先会考虑用户权限的问题,如果设置了权限,则按照给定的权限执行任务,否则按照普通方式执行。两者都调用了 runMain 方法,其关键代码如下所示:
private def runMain(args: SparkSubmitArguments, uninitLog: Boolean): Unit = { //准备提交应用程序的环境,根据传递的参数获取参数 val (childArgs, childClasspath, sparkConf, childMainClass) = prepareSubmitEnvironment(args) ................... val loader = getSubmitClassLoader(sparkConf) //添加 jar 包 for (jar <- childClasspath) { addJarToClasspath(jar, loader) } var mainClass: Class[_] = null try { //通过反射来获取应用程序子类 mainClass = Utils.classForName(childMainClass) } catch { ............. } //根据刚才获取的类来创建实例。不同的部署模式具体实例不同,但是都是 SparkApplication 的子类 val app: SparkApplication = if (classOf[SparkApplication].isAssignableFrom(mainClass)) { mainClass.getConstructor().newInstance().asInstanceOf[SparkApplication] } else { new JavaMainApplication(mainClass) } ................ try { //调用 start 方法,来启动应用程序 app.start(childArgs.toArray, sparkConf) } catch { ............. } }
runMain 方法中首先会调用 prepareSubmitEnvironment 方法来获取提交应用程序需要的一些参数,其中 childMainClass 是应用程序主类,部署模式不同加载的主类不同。由于本篇博客是基于 Standalone Cluster 部署模式的,下面给出 prepareSubmitEnvironment 方法中关于该部署模式的 childMainClass 赋值语句:
private[deploy] val REST_CLUSTER_SUBMIT_CLASS = classOf[RestSubmissionClientApp].getName()private[deploy] val STANDALONE_CLUSTER_SUBMIT_CLASS = classOf[ClientApp].getName()if (args.isStandaloneCluster) { if (args.useRest) { childMainClass = REST_CLUSTER_SUBMIT_CLASS childArgs += (args.primaryResource, args.mainClass) } else { // In legacy standalone cluster mode, use Client as a wrapper around the user class childMainClass = STANDALONE_CLUSTER_SUBMIT_CLASS if (args.supervise) { childArgs += "--supervise" } Option(args.driverMemory).foreach { m => childArgs += ("--memory", m) } Option(args.driverCores).foreach { c => childArgs += ("--cores", c) } childArgs += "launch" childArgs += (args.master, args.primaryResource, args.mainClass) } if (args.childArgs != null) { childArgs ++= args.childArgs } }
从上述代码中可以看出,StandaloneCluster 集群模式也会分为两种情况,分别是使用 Rest 和不使用 Rest,本博客中以不使用 Rest 为例进行介绍。我们可以看出,当不使用 Rest 时,childMainClass 所指定的主类为 ClientApp。回到 runMain 方法中,当获取提交应用程序需要的配置之后,首先通过反射来获取应用程序子类,然后创建该类的实例对象,并且调用 start 方法启动应用程序。下面给出 ClientApp 中 start 方法的源代码:
override def start(args: Array[String], conf: SparkConf): Unit = { //将参数封装为 ClientArguments 对象 val driverArgs = new ClientArguments(args) //设置 RPC 请求等待时间(过期时间) if (!conf.contains(RPC_ASK_TIMEOUT)) { conf.set(RPC_ASK_TIMEOUT, "10s") } //日志级别 Logger.getRootLogger.setLevel(driverArgs.logLevel) //创建 RPC 运行环境 val rpcEnv = RpcEnv.create("driverClient", Utils.localHostName(), 0, conf, new SecurityManager(conf)) //设置并获取 Master 端的 RPC 通信端点 val masterEndpoints = driverArgs.masters.map(RpcAddress.fromSparkURL). map(rpcEnv.setupEndpointRef(_, Master.ENDPOINT_NAME)) //创建并设置 client 的通信端点 ClientEndpoint rpcEnv.setupEndpoint("client", new ClientEndpoint(rpcEnv, driverArgs, masterEndpoints, conf)) //等待终止 rpcEnv.awaitTermination() }
从上面代码中可以看出,ClientApp 的 start 方法首先将参数封装成 ClientArguments,然后创建 RPC 运行环境并设置 Master 的 RPC 通信端点,最后创建并设置 Client 端的通信端点 ClientEndpoint。创建 ClientEndpoint 之后会首先调用其 onStart 方法,具体代码如下:
override def onStart(): Unit = { driverArgs.cmd match { case "launch" => //执行主类 val mainClass = "org.apache.spark.deploy.worker.DriverWrapper" //获取并封装 Driver 启动时所需要的参数配置 val classPathConf = config.DRIVER_CLASS_PATH.key val classPathEntries = getProperty(classPathConf, conf).toSeq.flatMap { cp => cp.split(java.io.File.pathSeparator) } val libraryPathConf = config.DRIVER_LIBRARY_PATH.key val libraryPathEntries = getProperty(libraryPathConf, conf).toSeq.flatMap { cp => cp.split(java.io.File.pathSeparator) } val extraJavaOptsConf = config.DRIVER_JAVA_OPTIONS.key val extraJavaOpts = getProperty(extraJavaOptsConf, conf) .map(Utils.splitCommandString).getOrElse(Seq.empty) val sparkJavaOpts = Utils.sparkJavaOpts(conf) val javaOpts = sparkJavaOpts ++ extraJavaOpts //获取并封装 Command 命令,用于后续启动 Driver val command = new Command(mainClass, Seq("{
{WORKER_URL}}", "{
{USER_JAR}}", driverArgs.mainClass) ++ driverArgs.driverOptions, sys.env, classPathEntries, libraryPathEntries, javaOpts) val driverResourceReqs = ResourceUtils.parseResourceRequirements(conf, config.SPARK_DRIVER_PREFIX) //将参数配置封装成 DriverDescription 对象 val driverDescription = new DriverDescription( driverArgs.jarUrl, driverArgs.memory, driverArgs.cores, driverArgs.supervise, command, driverResourceReqs) //发送消息给 Master 并且将返回结果异步转发给自己 asyncSendToMasterAndForwardReply[SubmitDriverResponse]( //向 Master 提交请求提交 Driver RequestSubmitDriver(driverDescription)) case "kill" => val driverId = driverArgs.driverId asyncSendToMasterAndForwardReply[KillDriverResponse](RequestKillDriver(driverId)) } }
从上述代码中可以看出,onStart 方法中配置了 Driver 启动时的主类以及一些参数配置,然后利用 RPC 通信方式向 Master 发送启动 Driver 的消息 RequestSubmitDriver,到此也就完成了申请创建 Driver 过程,将上述部分过程总结一下可以画出下面的时序图。其中 Maser 端的代码在下一节分析。
1.3. 创建 Driver
在第 1 部分我们分析了从 shell 命令提交任务到向 Master 申请创建 Driver 的过程,在本节中我们详细分析 Driver 的创建过程,首先 master 端收到 RequestSubmitDriver 消息之后的会具体创建 Driver,其关键代码如下所示:
override def receiveAndReply(context: RpcCallContext): PartialFunction[Any, Unit] = { case RequestSubmitDriver(description) => if (state != RecoveryState.ALIVE) {//判断当前 Master 状态,不处于活跃状态则不能启动 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) //将创建 Driver 所需要的配置封装成 DriverInfo(逻辑上创建 Driver) val driver = createDriver(description) //持久化存储该 Driver persistenceEngine.addDriver(driver) //将新创建的 Driver 加入待分配资源队列 waitingDrivers += driver drivers.add(driver) //实际分配资源 schedule() //向 Client 端返回消息 context.reply(SubmitDriverResponse(self, true, Some(driver.id), s"Driver successfully submitted as ${driver.id}")) } }
Master 端收到 RequestSubmitDriver 消息之后,首先判断 Master 的状态,只有处于活跃状态才可以创建 Driver。然后将创建 Driver 所需要的配置封装成 DriverInfo,这其实逻辑上创建 Driver。之后持久化存储该 Driver,以便于出错之后重新创建。将新创建的 Driver 加入待分配资源队列等待后续分配资源。最后调用 schedule 方法来进行资源分配,分配完资源后会将结果返回给 Client 端。下面分析 schedule 方法中资源分配的关键代码:
private def schedule(): Unit = { //Master 状态不为 Alive 直接返回 if (state != RecoveryState.ALIVE) { return } //随机打乱 works,防止在同一个 works 上启动太多的 app,与此同时过滤出 Alive 状态的 works val shuffledAliveWorkers = Random.shuffle(workers.toSeq.filter(_.state == WorkerState.ALIVE)) val numWorkersAlive = shuffledAliveWorkers.size //当前最后一个分配的 work 下标 var curPos = 0 /** * 我们以轮循方式为每个等待的 Driver 分配 work。 对于每个 Driver,我们从分配给 Driver 的最后一个 work 开始, * 然后继续进行,直到我们遍历所有处于活跃状态的 work。 * */ var launched = false var isClusterIdle = true var numWorkersVisited = 0 while (numWorkersVisited < numWorkersAlive && !launched) {//遍历所有的 work,直到 driver 启动 val worker = shuffledAliveWorkers(curPos) //该 work 上没有启动 driver 和 executor isClusterIdle = worker.drivers.isEmpty && worker.executors.isEmpty numWorkersVisited += 1 //判断当前 work 资源能否启动该 driver if (canLaunchDriver(worker, driver.desc)) { //向该 work 请求 driver 启动需要的资源 val allocated = worker.acquireResources(driver.desc.resourceReqs) //给 driver 分配申请好的资源 driver.withResources(allocated) //启动 driver launchDriver(worker, driver) //从等待队列中删除该 driver waitingDrivers -= driver //标识启动成功 launched = true } //更新下标,如同一个循环列表 curPos = (curPos + 1) % numWorkersAlive } if (!launched && isClusterIdle) { logWarning(s"Driver ${driver.id} requires more resource than any of Workers could have.") } } //启动 Executor,在这里不进行介绍 startExecutorsOnWorkers() }
上述一段代码与应用程序分配资源相同,在前面的博客中有详细介绍,代码中也给出了具体注释,就不进行具体分析。在给 Driver 分配完资源后会调用 launchDriver 方法来启动 Driver,下面我们分析 launchDriver 中的关键代码:
private def launchDriver(worker: WorkerInfo, driver: DriverInfo): Unit = { logInfo("Launching driver " + driver.id + " on worker " + worker.id) //在该 work 中添加 driver worker.addDriver(driver) //设置 driver 的 worker driver.worker = Some(worker) //向 worker 端发送启动 driver 请求 worker.endpoint.send(LaunchDriver(driver.id, driver.desc, driver.resources)) //设置 friver 状态 driver.state = DriverState.RUNNING }
launchDriver 方法中并没有实际完成 Driver 的启动,其仅仅设置 driver 启动的 worker 和 driver 状态,然后会向具体分配资源的 worker 发送启动 Driver 消息 launchDriver,下面就来看看 Worker 端的处理过程,首先看 Worker 接受消息之后的处理步骤:
case LaunchDriver(driverId, driverDesc, resources_) => logInfo(s"Asked to launch driver $driverId") //创建 DriverRunner 实例 val driver = new DriverRunner( conf, driverId, workDir, sparkHome, driverDesc.copy(command = Worker.maybeUpdateSSLSettings(driverDesc.command, conf)), self, workerUri, securityMgr, resources_) //添加映射关系 drivers(driverId) = driver //启动 driver driver.start() //更新本 work 使用的资源 coresUsed += driverDesc.cores memoryUsed += driverDesc.mem addResourcesUsed(resources_)
worker 接收到 LaunchDriver 消息之后,首先会创建一个 DriverRunner 对象用于启动 driver,然后调用其 start 方法启动 driver,启动完成之后会更新本 worker 的资源信息。下面就具体看看 DriverRunner 的 start 方法。
private[worker] def start() = { //启动线程用于创建和管理 driver new Thread("DriverRunner for " + driverId) { override def run(): Unit = { var shutdownHook: AnyRef = null try { //用于杀死 Driver shutdownHook = ShutdownHookManager.addShutdownHook { () => logInfo(s"Worker shutting down, killing driver $driverId") kill() } //准备 driver 需要的 jar 包并运行 driver val exitCode = prepareAndRunDriver() //根据是否被强制终止并设置退出代码来设置最终状态 finalState = if (exitCode == 0) { Some(DriverState.FINISHED) } else if (killed) { Some(DriverState.KILLED) } else { Some(DriverState.FAILED) } } catch { case e: Exception => kill() finalState = Some(DriverState.ERROR) finalException = Some(e) } finally { if (shutdownHook != null) { ShutdownHookManager.removeShutdownHook(