spark集群通过spark的start-all.sh脚本进行启动,所以首先我们看一下该脚本的内容,该脚本内容很简单,它会通过调用相同目录下的start-master.sh脚本启动spark Master服务,调用start-slaves.sh脚本启动spark Worker服务。大家注意start-master.sh脚本只会在调用该脚本的机器启动Mater服务,如果Master是HA的话,需要到另外的配置为Master的机器调用start-master.sh脚本启动。所以start-all.sh脚本只能启动非HA的spark集群的所有Master和Worker服务,这和Hadoop的脚本有些区别,不知道社区是故意为之还是因为其他的理由。
接下来我们分析一下spark集群启动相关的脚本的内容。
start-all.sh脚本片段
if [ -z "${SPARK_HOME}" ]; then
export SPARK_HOME="$(cd "`dirname "$0"`"/..; pwd)"
fi
# 加载spark的配置的环境变量,主要加载SPARK_HOME、
# SPARK_CONF_DIR和一下python相关的环境变量
. "${SPARK_HOME}/sbin/spark-config.sh"
# 调用start-master.sh脚本启动spark的master服务,
# 且Master服务在该脚本被调用的机器上启动
"${SPARK_HOME}/sbin"/start-master.sh
# 调用start-slaves.sh脚本启动所用的Worker服务,
# 该脚本会调用slaves.sh脚本,通过ssh发送Worker服务启动命令
"${SPARK_HOME}/sbin"/start-slaves.sh
接下来分别对Master和Worker服务的启动流程进行分析
1、Master启动
我们从start-master.sh脚本开始一步一步分析。
spark-master.sh脚本片段
# 启动Master服务需要调用的Scala主类
CLASS="org.apache.spark.deploy.master.Master"
# start-master.sh脚本会调用spark-daemon.sh脚本,spark-daemon.sh脚本,
# spark-daemon.sh脚本又会调用spark-class.sh脚本,并将参数传到该脚本,
# spark-class.sh脚本会调用Spark的org.apache.spark.launcher.Main类对不同的CLASS、不同的OS生成合适的启动服务进程的java命令
# Usage: spark-daemon.sh [--config <conf-dir>] (start|stop|submit|status) <spark-command> <spark-instance-number> <args...>
"${SPARK_HOME}/sbin"/spark-daemon.sh start $CLASS 1 \
--host $SPARK_MASTER_HOST --port $SPARK_MASTER_PORT --webui-port $SPARK_MASTER_WEBUI_PORT \
$ORIGINAL_ARGS
spark-daemon.sh脚本和spark-class.sh脚本就不再分析,感兴趣的可以看一下。接下来看一下org.apache.spark.deploy.master.Master类的main方法,看一下Master的启动流程
def main(argStrings: Array[String]) {
// 注册Master进程的Logger对象,用于处理该进程的“TERM”,“HUP”,“INT”事件,并打印事件到日志
Utils.initDaemon(log)
// 创建SparkConf对象,该对象会加载Spark的所有配置参数,并保存在ConcurrentHashMap对象中
val conf = new SparkConf
// 创建MasterArguments对象,该对象会对Master的参数进行解析
val args = new MasterArguments(argStrings, conf)
// 创建一个RPC环境,并启动Master的RPC端点服务且注册到该RPC环境中
val (rpcEnv, _, _) = startRpcEnvAndEndpoint(args.host, args.port, args.webUiPort, conf)
// 阻塞等待RPC环境结束
rpcEnv.awaitTermination()
}
接下来,看一下startRpcEnvAndEndpoint()方法,该方法会创建RPC环境和Master端点服务的注册流程,RpcEnv底层就是一个Netty实现的Socket服务,通过不同的rpc endpoint注册信息进行路由,对于不同的Socket请求调用不同的处理逻辑。Spark从2.+开始集群的RPC抛弃了Akka,通过Netty实现了一套简化版的Akka RPC框架。后边会分析具体的实现,在这里只要知道它是一个RPC服务即可。
def startRpcEnvAndEndpoint(
host: String,
port: Int,
webUiPort: Int,
conf: SparkConf): (RpcEnv, Int, Option[Int]) = {
// 创建SecurityManager对象,负责Spark不同组件之间的的安全通行
val securityMgr = new SecurityManager(conf)
// 创建RPCEnv,用于RPC服务,spark从2.+开始实现了自己的基于Netty的类Akka的RPC框架,不再使用Akka
val rpcEnv = RpcEnv.create(SYSTEM_NAME, host, port, conf, securityMgr)
// 创建、注册Master服务的RPC端点并启动
val masterEndpoint = rpcEnv.setupEndpoint(ENDPOINT_NAME,
new Master(rpcEnv, rpcEnv.address, webUiPort, securityMgr, conf))
// 向Master发送同步RPC调用,获取Master服务的端口号
val portsResponse = masterEndpoint.askSync[BoundPortsResponse](BoundPortsRequest)
(rpcEnv, portsResponse.webUIPort, portsResponse.restPort)
}
貌似到执行完startRpcEnvAndEndpoint()方法就结束了,但是事实上不是这样的。
前面说了,Spark RPC服务和Akka类似,所以在Master endpoint启动后RPC框架会立即调用该endpoint的onStart()方法,因此Master的启动还没有结束,接下来继续看一下Master类的onStart()方法有什么东东吧
override def onStart(): Unit = {
// 打印Master启动日志
logInfo("Starting Spark master at " + masterUrl)
logInfo(s"Running Spark version ${org.apache.spark.SPARK_VERSION}")
// 创建MasterWebUI服务,并绑定端口启动,其实底层就是一个基于Jetty的web服务,
// 而这个就是我们在浏览器输入spark_master_url:spark_master_port访问的web服务
webUi = new MasterWebUI(this, webUiPort)
webUi.bind()
masterWebUiUrl = "http://" + masterPublicAddress + ":" + webUi.boundPort
// 如果使用反向代理,masterWebUI地址使用代理地址
if (reverseProxy) {
masterWebUiUrl = conf.get("spark.ui.reverseProxyUrl", masterWebUiUrl)
logInfo(s"Spark Master is acting as a reverse proxy. Master, Workers and " +
s"Applications UIs are available at $masterWebUiUrl")
}
// 启动一个定时任务向master发送rpc调用,检查是否有Worker心跳超时,默认每隔60秒检查一次,
// 可以通过spark.worker.timeout配置项进行配置
checkForWorkerTimeOutTask = forwardMessageThread.scheduleAtFixedRate(new Runnable {
override def run(): Unit = Utils.tryLogNonFatalError {
self.send(CheckForWorkerTimeOut)
}
}, 0, WORKER_TIMEOUT_MS, TimeUnit.MILLISECONDS)
// 集群在Standalone模式下如果使用RestServer的方式来提交app,
// 启动该Rest Server服务。默认使用该模式提交app
if (restServerEnabled) {
val port = conf.getInt("spark.master.rest.port", 6066)
restServer = Some(new StandaloneRestServer(address.host, port, conf, self, masterUrl))
}
// 启动StandaloneRestServer服务,底层使用Jetty实现的Restful服务,主要用于接收app的提交
// 具体提交app流程,后面会有专题进行分析
restServerBoundPort = restServer.map(_.start())
// 向master的测量系统注册master的测量数据源
masterMetricsSystem.registerSource(masterSource)
// 启动master的测量系统,用于收集Master服务的各种数据,
// 然后将收集的数据输出到Spark Web页面进行显示
masterMetricsSystem.start()
// 启动app的测量系统
applicationMetricsSystem.start()
// masterwebUI获取master、app的测量系统的ServletHandler,用于将master和app的状态展示在masterwebUI上
masterMetricsSystem.getServletHandlers.foreach(webUi.attachHandler)
applicationMetricsSystem.getServletHandlers.foreach(webUi.attachHandler)
// 创建JavaSerializer对象,用于序列化数据
val serializer = new JavaSerializer(conf)
// 获取配置的Master的容错机制,spark目前支持Zookeeper、文件系统、和自定义的HA容错机制
val (persistenceEngine_, leaderElectionAgent_) = RECOVERY_MODE match {
case "ZOOKEEPER" =>
logInfo("Persisting recovery state to ZooKeeper")
val zkFactory =
new ZooKeeperRecoveryModeFactory(conf, serializer)
(zkFactory.createPersistenceEngine(), zkFactory.createLeaderElectionAgent(this))
case "FILESYSTEM" =>
val fsFactory =
new FileSystemRecoveryModeFactory(conf, serializer)
(fsFactory.createPersistenceEngine(), fsFactory.createLeaderElectionAgent(this))
case "CUSTOM" =>
val clazz = Utils.classForName(conf.get("spark.deploy.recoveryMode.factory"))
val factory = clazz.getConstructor(classOf[SparkConf], classOf[Serializer])
.newInstance(conf, serializer)
.asInstanceOf[StandaloneRecoveryModeFactory]
(factory.createPersistenceEngine(), factory.createLeaderElectionAgent(this))
case _ =>
(new BlackHolePersistenceEngine(), new MonarchyLeaderAgent(this))
}
persistenceEngine = persistenceEngine_
leaderElectionAgent = leaderElectionAgent_
}
然后,看一下Master是如何检查Worker的通信超时的,Spark基于netty的RPC框架对于send()方法,会有receive()方法响应send()方法的响应,send()方法调用不需要有响应信息;与之对应,还有一个ask()方法,会有receiveAndReply()方法进行响应,且需要返回响应信息,而且是异步的。所以对于self.send(CheckForWorkerTimeOut)调用,需要看一下receive()方法的响应逻辑。
// 使用Scala的模式匹配,处理CheckForWorkerTimeOut请求
case CheckForWorkerTimeOut => {
// 处理超时的Worker,是个方法调用
timeOutDeadWorkers()
}
接下来,看一下timeOutDeadWorkers()方法
private def timeOutDeadWorkers() {
val currentTime = System.currentTimeMillis()
// 过滤出最后一次心跳时间距离当前时间的时间差值大于指定超时时间的worker
val toRemove = workers.filter(_.lastHeartbeat < currentTime - WORKER_TIMEOUT_MS).toArray
// 将状态不为DEAD的且心跳超时的Worker的状态置位DEAD
// 如果worker状态是DEAD,但超出spark.dead.worker.persistence参数配置的监测次数,将worker从Masterworker列表中移除
for (worker <- toRemove) {
if (worker.state != WorkerState.DEAD) {
logWarning("Removing %s because we got no heartbeat in %d seconds".format(
worker.id, WORKER_TIMEOUT_MS / 1000))
removeWorker(worker)
} else {
if (worker.lastHeartbeat < currentTime - ((REAPER_ITERATIONS + 1) * WORKER_TIMEOUT_MS)) {
workers -= worker
}
}
}
}
至此,Master的启动就完成了,接下来看一下Worker的启动吧。
2、Worker启动
启动脚本我们就不看了,无非就是通过slaves配置文件获取Worker的所有宿主机,然后发送ssh命令启动Worker服务
最终会调用java命令调用org.apache.spark.deploy.worker.Worker类的main方法。
def main(argStrings: Array[String]) {
// 和Master一样,注册Daemon进程的Logger对象,用于处理该进程的“TERM”,“HUP”,“INT”事件,并打印到日志
Utils.initDaemon(log)
// 创建SparkConf对象
val conf = new SparkConf
// 创建WorkerArguments对象,用于解析Worker的参数
val args = new WorkerArguments(argStrings, conf)
// 创建Worker的RpcEnv,启动Worker endpoint服务并注册到RpcEnv中
val rpcEnv = startRpcEnvAndEndpoint(args.host, args.port, args.webUiPort, args.cores,
args.memory, args.masters, args.workDir, conf = conf)
// 阻塞等待Worker的RPC服务结束
rpcEnv.awaitTermination()
}
def startRpcEnvAndEndpoint(
host: String,
port: Int,
webUiPort: Int,
cores: Int,
memory: Int,
masterUrls: Array[String],
workDir: String,
workerNumber: Option[Int] = None,
conf: SparkConf = new SparkConf): RpcEnv = {
// 拼接RpcEnv的systemName,因为可能一台机器启动多个worker实例,
// 所以需要拼上该worker的编号
val systemName = SYSTEM_NAME + workerNumber.map(_.toString).getOrElse("")
// 创建安全管理器,负责Spark不同模块的安全访问
val securityMgr = new SecurityManager(conf)
// 创建Worker的RpcEnv
val rpcEnv = RpcEnv.create(systemName, host, port, conf, securityMgr)
// 获取spark的master url
val masterAddresses = masterUrls.map(RpcAddress.fromSparkURL(_))
// 启动worker endpoint服务并注册到worker的RpcEnv中
rpcEnv.setupEndpoint(ENDPOINT_NAME, new Worker(rpcEnv, webUiPort, cores, memory,
masterAddresses, ENDPOINT_NAME, workDir, conf, securityMgr))
rpcEnv
}
Worker的启动流程和Master的差不太多,主要的区别在onStart()方法,
下面看一下Worker的onStart()方法的代码
override def onStart() {
// 此时的Worker还没有向Master注册,所以registered为false
assert(!registered)
// 打印启动日志
logInfo("Starting Spark worker %s:%d with %d cores, %s RAM".format(
host, port, cores, Utils.megabytesToString(memory)))
logInfo(s"Running Spark version ${org.apache.spark.SPARK_VERSION}")
logInfo("Spark home: " + sparkHome)
// 创建Worker的工作目录,用于存储一些app相关的信息,
// 默认为$SPARK_HOME/work,
// 也可以通过SPARK_WORKER_DIR环境变量执行配置,
// 该目录默认不会进行清理,时间长了会造成磁盘空间不足,
// 可以通过spark.worker.cleanup.enabled参数,配置为true使Spark进行清理
createWorkDir()
// 启动Worker的shuffleService用于spark的shuffle服务,默认是禁用的。可以通过spark.shuffle.service.enabled启用。
// 该对象是ExternalShuffleService类的实例
shuffleService.startIfEnabled()
// 创建WorkerWebUI,绑定端口并启动
webUi = new WorkerWebUI(this, workDir, webUiPort)
webUi.bind()
workerWebUiUrl = s"http://$publicAddress:${webUi.boundPort}”
// 向master注册worker
registerWithMaster()
// 向测量系统注册Worker的数据源,启动测量系统,然后将测量系统的ServletHandler注册到WorkerWebUI中
metricsSystem.registerSource(workerSource)
metricsSystem.start()
metricsSystem.getServletHandlers.foreach(webUi.attachHandler)
}
接下来分析Worker端向Master注册自己的代码
private def registerWithMaster() {
// 根据worker是否是第一次尝试向Master注册,分为不同的处理逻辑
registrationRetryTimer match {
case None =>
// Worker首次尝试向Master注册自己
registered = false
// 尝试注册Worker到所有的Master节点,并返回一个JFuture对象的数组,
// 用于可以随时取消worker注册请求的线程,对于JFuture不懂的可以自行google Java Future,这是异步的,不会阻塞
registerMasterFutures = tryRegisterAllMasters()
connectionAttemptCount = 0
// 启动一个线程每隔一段时间检查worker的注册状态
// 1、 如果Worker已经注册,它会取消最后一次向Master注册的请求
// 2、如果worker注册失败,或者active Master死掉,它会向新的Active Master注册自己
// 如果尝试次数达到阈值,则提高注册间隔时间
registrationRetryTimer = Some(forwordMessageScheduler.scheduleAtFixedRate(
new Runnable {
override def run(): Unit = Utils.tryLogNonFatalError {
Option(self).foreach(_.send(ReregisterWithMaster))
}
},
INITIAL_REGISTRATION_RETRY_INTERVAL_SECONDS,
INITIAL_REGISTRATION_RETRY_INTERVAL_SECONDS,
TimeUnit.SECONDS))
case Some(_) =>
logInfo("Not spawning another attempt to register with the master, since there is an" +
" attempt scheduled already.")
}
}
然后我们看一下tryRegisterAllMasters(),每有一个Master节点会启动一个线程向对应的Master发送RegisterWorker RPC调用,向Master注册该Worker的信息
private def tryRegisterAllMasters(): Array[JFuture[_]] = {
masterRpcAddresses.map { masterAddress =>
// 提交线程向Master注册Worker
registerMasterThreadPool.submit(new Runnable {
override def run(): Unit = {
try {
logInfo("Connecting to master " + masterAddress + "…")
// 通过RPCEnv获取Master的EndpointRef对象,既Master的RPC引用,用于发送RPC调用
val masterEndpoint = rpcEnv.setupEndpointRef(masterAddress, Master.ENDPOINT_NAME)
// 向Master发送worker注册信息,其实就是一个RPC调用,该方法就一行代码,马上上代码
sendRegisterMessageToMaster(masterEndpoint)
} catch {
case ie: InterruptedException => // Cancelled
case NonFatal(e) => logWarning(s"Failed to connect to master $masterAddress", e)
}
}
})
}
}
private def sendRegisterMessageToMaster(masterEndpoint: RpcEndpointRef): Unit = {
// 向Master节点发送RPC调用,注册Worker,这也是Worker注册真正的代码,就一行解决
masterEndpoint.send(RegisterWorker(workerId, host, port, self, cores, memory, workerWebUiUrl))
}
接下来,看一下Master又是如何处理Worker的RegisterWorker注册请求的,
这是匹配到Worker发送的RPC调用的代码片段
case RegisterWorker(id, workerHost, workerPort, workerRef, cores, memory, workerWebUiUrl) =>
logInfo("Registering worker %s:%d with %d cores, %s RAM".format(
workerHost, workerPort, cores, Utils.megabytesToString(memory)))
// 如果Master是Standby的,向Worker节点发送一个RPC调用,
// 告诉他我不是管事的,我是替补,完事,worker也不会做任何处理
if (state == RecoveryState.STANDBY) {
workerRef.send(MasterInStandby)
} else if (idToWorker.contains(id)) {
// Worker id已经注册过,你这是想冒名顶替啊,Master不认,想浑水摸鱼,
// Master回给Worker一个id重复的注册失败信息,Worker收到消息,
// 骂了句娘(打印了Worker注册失败和失败原因),然后饮恨而亡(退出了)
workerRef.send(RegisterWorkerFailed("Duplicate worker ID"))
} else {
// 总有幸运的Worker,穿过层层筛选,终于可以注册了
// 创建一个WorkerInfo对象,相当于省份证,保存了该Worker的需要Master知道的所有信息
val worker = new WorkerInfo(id, workerHost, workerPort, cores, memory,
workerRef, workerWebUiUrl)
// 注册worker
if (registerWorker(worker)) {
// 注册成功,将worker信息加到持久引擎保存,用于容错
persistenceEngine.addWorker(worker)
// 通知worker注册Worker成功,之后worker会启动一个线程,向Master发送心跳,
// 证明该worker一直是活着的,然后master和worker就会建立通信
workerRef.send(RegisteredWorker(self, masterWebUiUrl))
// master的调度逻辑,很重要,后边会讲到,
// 注册Worker时此方法只是空跑一次,不会做任何处理
schedule()
} else {
// 注册失败,Master告诉worker,相同地址的worker还活着呢,你就来代替,大胆。
// worker没有办法,骂娘,退出
val workerAddress = worker.endpoint.address
logWarning("Worker registration failed. Attempted to re-register worker at same " +
"address: " + workerAddress)
workerRef.send(RegisterWorkerFailed("Attempted to re-register worker at same address: "
+ workerAddress))
}
}
看一下registerWorker()方法,看一下Master如何持有Worker的信息的
private def registerWorker(worker: WorkerInfo): Boolean = {
// 找出master持有的注册的worker列表中和该worker host、端口相同,但是已经死掉的Worker,然后将该worker从列表中除名
workers.filter { w =>
(w.host == worker.host && w.port == worker.port) && (w.state == WorkerState.DEAD)
}.foreach { w =>
workers -= w
}
val workerAddress = worker.endpoint.address
if (addressToWorker.contains(workerAddress)) {
val oldWorker = addressToWorker(workerAddress)
if (oldWorker.state == WorkerState.UNKNOWN) {
// 如果worker注册的地址存在,并且原来的worker状态未知,则j将该worker状态值为DEAD,以便移除该worker并接收新的worker,不要被该方法的名字迷惑,它不会真的将worker从列表中删除
removeWorker(oldWorker)
} else {
logInfo("Attempted to re-register worker at same address: " + workerAddress)
return false
}
}
// 如果你能走到这里,恭喜该worker,你可以登记注册了
// 1.将worker信息对象存入worker列表中
workers += worker
// 2.将worker信息存入map中,key为worker的id
idToWorker(worker.id) = worker
// 3.将worker信息存入另一个map中,key为worker的地址,
// 三步注册完成
addressToWorker(workerAddress) = worker
if (reverseProxy) {
webUi.addProxyTargets(worker.id, worker.webUiAddress)
}
true
}
下边看一下Worker如何处理Master返回的不同注册响应
private def handleRegisterResponse(msg: RegisterWorkerResponse): Unit = synchronized {
msg match {
// 注册成功
case RegisteredWorker(masterRef, masterWebUiUrl, masterAddress) =>
if (preferConfiguredMasterAddress) {
logInfo("Successfully registered with master " + masterAddress.toSparkURL)
} else {
logInfo("Successfully registered with master " + masterRef.address.toSparkURL)
}
// 将Worker的状态设置为已注册
registered = true
// 更新master地址
changeMaster(masterRef, masterWebUiUrl, masterAddress)
// 启动定时线程,Worker定时向Master发送心跳
forwordMessageScheduler.scheduleAtFixedRate(new Runnable {
override def run(): Unit = Utils.tryLogNonFatalError {
self.send(SendHeartbeat)
}
}, 0, HEARTBEAT_MILLIS, TimeUnit.MILLISECONDS)
// 启动定时清理Worker工作目录线程
if (CLEANUP_ENABLED) {
logInfo(
s"Worker cleanup enabled; old application directories will be deleted in: $workDir")
forwordMessageScheduler.scheduleAtFixedRate(new Runnable {
override def run(): Unit = Utils.tryLogNonFatalError {
self.send(WorkDirCleanup)
}
}, CLEANUP_INTERVAL_MILLIS, CLEANUP_INTERVAL_MILLIS, TimeUnit.MILLISECONDS)
}
// 获取该Worker持有的Executor信息
val execs = executors.values.map { e =>
new ExecutorDescription(e.appId, e.execId, e.cores, e.state)
}
// 同步Master中该Worker的executor和app状态,如果对应的executor和app在Master的executor和app列表中不存在,
// Master会向Worker发送KillExecutor、KillDriver RPC调用,命令Worker kill掉对应的executor和app的Driver
masterRef.send(WorkerLatestState(workerId, execs.toList, drivers.keys.toSeq))
case RegisterWorkerFailed(message) =>
// 注册失败
if (!registered) {
logError("Worker registration failed: " + message)
System.exit(1)
}
case MasterInStandby =>
// Ignore. Master not yet ready.
}
}
至此,Worker的启动,并向Master注册的过程也分析完了,接下来在看一下Master与Worker之间的心跳过程。
3、心跳
刚才分析Worker向Master注册成功代码,Master会向Worker发送RegisteredWorker RPC调用,Worker响应该调用请求,其中Worker会启动一个定时线程,该线程定时向该Worker发送SendHeartbeat RPC调用,命令Worker向Master发送心跳,具体代码如下:
forwordMessageScheduler.scheduleAtFixedRate(new Runnable {
override def run(): Unit = Utils.tryLogNonFatalError {
// 向Worker发送SendHeartbeat RPC请求,命令Worker向Master发送心跳
self.send(SendHeartbeat)
}
}, 0, HEARTBEAT_MILLIS, TimeUnit.MILLISECONDS)
这是receive()方法的响应SendHeartbeat的代码片段,很简单,如果Worker和Master已经建立连接,则向Master发送Heartbeat RPC调用。
case SendHeartbeat =>
// 直接看sendToMaster()方法
if (connected) { sendToMaster(Heartbeat(workerId, self)) }
具体看一下sendToMaster()方法,只是简单的通过Master endpoint引用发送Heartbeat RPC调用,开始一次心跳过程
private def sendToMaster(message: Any): Unit = {
master match {
case Some(masterRef) => masterRef.send(message)
case None =>
logWarning(
s"Dropping $message because the connection to master has not yet been established")
}
}
Master处理Heartbeat的请求的逻辑比较简单
case Heartbeat(workerId, worker) =>
idToWorker.get(workerId) match {
// 该Worker已经注册,更新该Worker的最新一次心跳时间
case Some(workerInfo) =>
workerInfo.lastHeartbeat = System.currentTimeMillis()
// 该Worker没有注册,向该Worker发送ReconnectWorker RPC调用,命令Worker注册
case None =>
if (workers.map(_.id).contains(workerId)) {
logWarning(s"Got heartbeat from unregistered worker $workerId." +
" Asking it to re-register.")
// Worker没有注册,命令Worker注册,Worker端响应逻辑和Worker注册相同,都是调用registerWithMaster()方法
worker.send(ReconnectWorker(masterUrl))
} else {
logWarning(s"Got heartbeat from unregistered worker $workerId." +
" This worker was never registered, so ignoring the heartbeat.")
}
}
至此,整个spark就成功启动了。