Spark独立集群下Application提交过程分析

使用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提交。

  • 如果是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命令,命令的参数包括driverIdApplicationDescription(见上文)。

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和内存分别为:

WorkerCPU core内存
worker1104096
worker2104096
worker3104096

现在想对一个Application进行调度,因此需要在以上3个Worker中App分配Executor资源,具体为App分配多少个Executor和Core,还有在哪个Worker上分配由App的以下几个参数和spreadOut属性来控制。

- memoryPerExecutor:指每个执行器需要的内存大小;
- coresPerExecutor:每个执行器需要的core数;
- maxCores:该APP最大内核数限制;
- executorLimit:该APP拥有的执行器数限制;
  1. 没有最大核心和执行器总数限制

    {
     "memoryPerExecutor":1024,
     "coresPerExecutor":2,
     "maxCores":None,
     "executorLimit":None
    }
    • 如果spreadOut为true,那么将以轮询遍历Worker的方式进行分配,由于没有maxCores和执行器限制,那么将会一直分配,直到Worker资源耗尽为止,分配的结果如下

      注:

      • 2/1024:表示分配了2个core,并启动一个新的执行器,占用1024MB内存;
      • 2/-:表示只在原先的执行器上加2个core。
    序号Worker1Worker2Worker3
    12/10242/10242/1024
    22/10242/10242/1024
    32/10242/10242/1024
    42/10242/10242/1024
    总计8/40968/40968/4096

    由于每个Worker的内存耗尽,所以最终每个Worker为该APP启动了4个执行器,分配了8个内核,所以总共占用内存为4096*3MB,总内核数为8*3个。

    • 如果spreadOut为false,分配的结果和上面相同,只不过是按纵向分配,只有Worker1资源分配完后,才分配Worker2上的资源。
  2. 没有执行器数限制

    {
       "memoryPerExecutor":1024,
        "coresPerExecutor":2,
        "maxCores":4,
        "executorLimit":None
    }
    • 如果spreadOut为true
    序号Worker1Worker2Worker3
    12/10242/1024-/-
    总计2/10242/1024-/-

    由于当前app达到了maxCores限制,所以只有Worker1和Worker2为APP分别启动了1个执行器,分配了2个内核,所以总共占用内存为2*1024MB,总内核数为4。

    • 如果spreadOut为false
    序号Worker1Worker2Worker3
    12/1024-/--/-
    22/1024-/--/-
    总计4/2048-/--/-

    由于当前app达到了maxCores限制,所以只有Worker1为APP分别启动了2个执行器,分配了4个内核,所以总共占用内存为2*1024MB,总内核数为4。

  3. 没有最大核心数限制和每个Worker只启动一个执行器

    {
       "memoryPerExecutor":1024,
        "coresPerExecutor":None,
        "maxCores":None,
        "executorLimit":4
    }
    • 如果spreadOut为true
    序号Worker1Worker2Worker3
    11/10241/10241/1024
    21/-
    总计2/10241/10241/1024

    由于达到了执行器数限制,所以最终Worker1为当前app分配了2个内核,启动了一个执行器,占用1024MB内存;Worker2为当前app分配了1个内核,启动了一个执行器,占用1024MB内存;Worker3为当前app分配了1个内核,启动了一个执行器,占用1024MB内存;

    • 如果spreadOut为false
    序号Worker1Worker2Worker3
    11/10241/10241/1024
    21/-1/-1/-
    31/-1/-1/-
    41/-1/-1/-
    51/-1/-1/-
    61/-1/-1/-
    71/-1/-1/-
    81/-1/-1/-
    91/-1/-1/-
    101/-1/-1/-
    总计10/102410/102410/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,驱动器发来的任务都会提交到该执行器的线程池中运行。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值