我从一开始就走了一个弯路:以为Spark的HA就是“同时拉起两个Master进程”,一主一备,挂了就自动切。结果真正在standalone集群模式里把Master进程杀掉之后,等了十分钟任务还是卡在提交端,日志里反复报连接被拒。后来翻源码、看官方文档、反复烧了几轮环境,才把本地模式、standalone集群模式和基于ZooKeeper的高可用部署之间的逻辑彻底理顺。
这篇文章就以Spark本地模式和standalone集群模式为起点,从部署到HA,再到案例测试一条线走通。内容不追求面面俱到,重点解决三个实际问题:本地模式到底适合做什么、standalone集群怎么配置不容易翻车、Master挂了之后作业凭什么能恢复。适合正在搭Spark测试环境、准备上生产、或者对HA原理一知半解的朋友,也适合第一次部署就遇到“启动即报错”的初学者对照排查。
1. 先弄清楚两件事:Local与Standalone的定位差异
1.1 Local模式是调试环境,不是运行环境
先用一句话总结:Spark本地模式把Driver、Executor、调度器全部塞进同一个JVM进程里,你在一台笔记本上跑,它就像一个单机版的Spark环境。
我第一次接触本地模式时,直接在命令行敲了spark-shell --master local,看到的是一堆日志,然后可以执行各种转换操作。这个模式适合什么?适合写业务逻辑、调试数据清洗代码、测试UDF、跑小规模样例数据。它的核心价值是“快”——不需要启动任何服务,不用配集群,一条命令就能进入交互式环境。
但本地模式有一个非常容易误导人的点:它不是Spark分布式能力的完整体现。local[2]表示用2个线程模拟并行执行,local[*]表示用机器所有可用的CPU核心。线程之间不跨进程通信,也不存在真正意义上的网络传输。你用local[*]跑通了代码,不代表同一段代码在standalone集群里也一定能跑通,中间至少还隔着“数据分布性”和“资源隔离”这两道坎。
我在本地模式下吃过的亏是:代码里用了大量按线程数调优的参数,比如repartition(10),在本地机器上怎么跑都正常,部署到集群后发现数据分布和并行度完全不是一回事。后来我就把本地模式严格当作“逻辑验证环境”,所有跟分布式相关的验证一律放到standalone集群上做。
1.2 Standalone集群模式的结构与Local的根本差异
Standalone是Spark自带的集群管理模式,不依赖第三方资源调度器。它的结构是经典的Master/Worker主从架构:
- Master节点:负责接收任务提交、调度资源、管理Worker和应用的生命周期。
- Worker节点:负责启动Executor进程,真正执行计算任务。
在Standalone集群里,Driver可以运行在提交端(client模式),也可以运行在集群内部(cluster模式)。但不管是哪种方式,Executor都是独立进程,在Worker节点上启动。这就和Local模式有了本质区别:本地模式的Executor是线程,Standalone的Executor是进程,资源隔离、故障边界、网络通信都完全不同。
对比一下两种模式的属性,会更直观:
| 对比项 | Local模式 | Standalone集群模式 |
|---|---|---|
| 部署复杂度 | 无部署,本地JVM | 需要Master/Worker节点 |
| Driver位置 | 和Executor同进程 | Driver在提交端或集群内 |
| Executor运行形态 | 线程 | 独立进程 |
| 资源管理 | 无 | Master统一调度 |
| 适用场景 | 代码调试、小数据验证 | 多节点分布式计算 |
| 高可用能力 | 无 | 可基于ZooKeeper实现 |
理解了这两者的差异,再去看集群部署和HA,思路会清晰很多。下面直接从部署开始。
2. Standalone集群的物理搭建与内存、CPU参数
2.1 节点规划与网络准备
Standalone集群无论规模大小,第一个要做得扎实的事是网络规划。很多人喜欢在hosts文件里用短主机名,比如master1、node2,然后配置SPARK_MASTER_HOST=master1。这个做法本身没问题,但要注意一点:Worker节点需要能通过这个主机名反向访问到节点,如果hosts文件只在Master上配置了,Worker上解析不了,就会出现“Worker可以注册成功,但Executor启动后无法回连Driver”的诡异问题。
我在测试环境里用的是两个Master节点加两个Worker节点,规格如下:
spark-master-1:Master候选节点,负责平时接收任务。spark-master-2:Master候选节点,正常情况下处于Standby状态。spark-worker-1、spark-worker-2:两个Worker节点,承担实际计算。
所有节点之间保证内网互通,并且所有节点的/etc/hosts保持一致。这一步不做,后面排查困扰的就不是Spark本身,而是你自己的网络配置。
另外建议给每个节点配一个独立用户,比如spark用户,专门用来启动Spark相关进程。用root启动Spark虽然也能跑,但在某些环境和安全限制下会有问题,而且日志目录权限混乱之后排查起来很麻烦。我习惯所有Spark进程、日志目录、jar包目录都统一归到spark用户下,这个习惯后来帮了我大忙。
2.2 spark-env.sh核心配置与第一轮踩坑
Spark的配置集中在$SPARK_HOME/conf/spark-env.sh。这是整个部署过程的核心,也是最容易踩坑的地方。
我给测试环境的spark-env.sh做了如下配置:
export SPARK_MASTER_HOST=spark-master-1 export SPARK_MASTER_PORT=7077 export SPARK_DAEMON_MEMORY=1g export SPARK_WORKER_CORES=8 export SPARK_WORKER_MEMORY=12g export SPARK_DAEMON_JAVA_OPTS="-Dspark.deploy.recoveryMode=ZOOKEEPER -Dspark.deploy.zookeeper.url=zk1:2181,zk2:2181,zk3:2181 -Dspark.deploy.zookeeper.dir=/spark-ha"这里每一个参数值得说一下:
SPARK_MASTER_HOST:Master监听的主机名。在HA场景下,两个Master候选节点在主启动时分别指定自己,而不是都写同一个IP。SPARK_MASTER_PORT:默认7077,客户端连接时使用的端口。一般不用改,但要确保防火墙放行。SPARK_DAEMON_MEMORY:Master/Worker守护进程自身的内存。不需要太大,1G足够,因为真正干活的是Executor。SPARK_WORKER_CORES:每个Worker节点可分配给Executor的CPU核数。这个值不要写满物理核数,建议留出1到2核给操作系统和Worker守护进程自己用。SPARK_WORKER_MEMORY:每个Worker节点可分配给Executor的总内存。注意这是“池子”大小,不是单个Executor的内存。如果写满机器物理内存,很容易在启动多个Executor时直接把系统内存挤爆。
SPARK_WORKER_MEMORY这个参数我非常想重点强调。它决定了Worker节点最多能启动多少Executor。比如一个Worker节点物理内存是16G,你给了12G给Spark,那么后续提交任务时,--executor-memory和--total-executor-cores上申请的Executor总量不能超过这个池子。如果申请了3个Executor各2G,总共6G,那是可以的;如果再提交一个任务申请20G,Master会调度不出资源,任务会一直处于等待状态。
最典型的错误是:在Worker节点上设置了SPARK_WORKER_MEMORY=16g,然后还跑着操作系统、监控进程、可能的数据库客户端。一旦Spark真的把16G内存全部用完,系统就会开始使用swap,整个节点性能急剧下降,甚至触发OOM。所以我养成一个习惯:池子内存最多给物理内存的70%到80%,剩下的是保命余量。
配置完成后,在各节点启动Standalone模式的集群服务。直接执行$SPARK_HOME/sbin/start-all.sh虽然方便,但要注意:如果你有两台Master候选节点,这个脚本只会启动SPARK_MASTER_HOST指定的那一个Master。在HA场景下,我建议手动分开启动,后面会详细说。
3. HA怎么做:ZooKeeper的“备胎”设计逻辑
3.1 Master单点故障意味着什么
Standalone集群模式下,Master是整个集群的调度中枢。所有任务提交、Worker注册、Executor分配都经过Master。一旦Master进程挂了,整个集群会变成什么状态?
答案是:已经运行中的Executor会继续跑,但新的任务提交无法完成,新的Worker注册也无人认领。这是因为Executor和Driver之间已经建立了直接通信通道,数据计算不依赖Master;但如果有新的应用要申请资源,没有Master来接管和调度,整个集群就瘫痪了。
很多人第一次部署时不会想这个问题,因为测试环境的Master很少挂。但稍微想象一下生产调度场景,就明白单点故障是不能接受的。Spark官网也专门强调:默认情况下Standalone集群存在Master单点风险,需要做高可用。
3.2 为什么是ZooKeeper而不是双机热备脚本
要实现Master高可用,最简单粗暴的思路是“两个Master进程互相监控,挂了就拉起对方”。这种脚本方案看起来可行,实际上有一个致命的问题:两个Master同时存活时,谁说了算?如果两个Master都在接收Worker注册,都在分配Executor,集群的元数据就会分叉,这就是典型的“双主脑裂”(split-brain)问题。
ZooKeeper的作用是解决这个“谁说了算”的问题。它的核心机制是分布式一致性选主。两个Master进程启动后同时去ZooKeeper注册,ZooKeeper会保证只有一个Master成为Active Leader,另一个保持Standby状态。Active Master会把自己的元数据——比如哪些Application注册了、哪些Executor在跑、Worker列表——持久化到ZooKeeper的指定目录里。一旦Active Master异常退出,ZooKeeper的Session超时机制触发,Standby Master接管成为新Active,然后从ZooKeeper恢复元数据,继续接管集群。
这个过程有一个特性需要事先心理准备:切换不是瞬间完成的。它需要等待ZooKeeper会话超时,需要重新选举,还需要等待Worker重新向新Master注册。在我自己的测试环境里,从杀死旧Master到新Master恢复调度,一般需要几十秒。如果你期待的是毫秒级切换,这个方案做不到;但对于大多数分布式任务来说,几十秒的调度中断完全在可接受范围内。
ZooKeeper方案里还有一个细节值得注意:Active Master持久化到ZooKeeper的不仅是“我是谁”,还包括应用状态和Executor状况。新Master接管之后,已经完成的Application的状态、正在运行的Application的注册信息都能恢复过来。这也是为什么我强调不要用文件系统模式的恢复方式,虽然Spark也提供了spark.deploy.recoveryMode=FILESYSTEM,它只能做单台Master的重启恢复,没有真正的Standby节点,不满足HA要求。
4. HA配置步骤与启动顺序
4.1 配置spark-env.sh与ZK目录
在上一章提到了SPARK_DAEMON_JAVA_OPTS里配置了三个ZooKeeper相关参数。这里需要明确一下它们的含义:
spark.deploy.recoveryMode=ZOOKEEPER:指定恢复模式是基于ZooKeeper的。spark.deploy.zookeeper.url=zk1:2181,zk2:2181,zk3:2181:ZooKeeper集群的连接地址。如果你的ZooKeeper数量很少,至少3台起步,因为ZooKeeper需要过半选举,2台在挂了一台后依然无法形成大多数。spark.deploy.zookeeper.dir=/spark-ha:Spark在ZooKeeper上保存元数据的根目录。这个路径可以自定义,但所有Master节点必须保持一致。最好先手动创建好并保证Spark用户有写权限,否则启动时会报权限不足。
有一点非常关键:这些配置是写在SPARK_DAEMON_JAVA_OPTS里的,意味着只有Spark守护进程会读取。如果你用spark-submit提交任务时指望在客户端传--conf spark.deploy.recoveryMode=ZOOKEEPER,那就理解偏了。HA是针对Master守护进程而言的,不是针对客户端提交命令的。
所有参与HA的Master节点都需要配置相同的spark-env.sh。Worker节点最好也保持一致,因为在极端情况下Worker可能会被提升为候选节点,或者至少保证状态一致,避免配置漂移。
4.2 启动顺序清单与状态确认
启动顺序非常讲究。我的建议是严格按以下顺序来:
- 启动ZooKeeper集群,并确认所有ZooKeeper节点都是正常的。
- 在
spark-master-1上启动第一个Master进程。 - 在
spark-master-2上启动第二个Master进程。 - 在所有Worker节点上启动Worker进程。
为什么要先启动ZooKeeper再启动Master?因为Master启动时就会去连接ZooKeeper并注册自己。如果ZooKeeper没有起来,Master启动后连不上ZooKeeper,进程会直接退出或者进入非健康状态。我之前踩过一次坑,以为Master会在ZooKeeper可用后再自动重连,结果日志里明确写着连接失败,进程直接退出。
手动启动Master可以用以下命令:
# 在 spark-master-1 上执行 $SPARK_HOME/sbin/start-master.sh # 在 spark-master-2 上执行 $SPARK_HOME/sbin/start-master.sh -h spark-master-2-h参数是指定Master监听的地址,如果不传,默认使用SPARK_MASTER_HOST。所以两台机器上最好各自把SPARK_MASTER_HOST配置成自己的主机名,或者启动时用-h显式指定。
启动Worker的命令:
$SPARK_HOME/sbin/start-worker.sh spark://spark-master-1:7077,spark://spark-master-2:7077这里有一个很实用的技巧:start-worker.sh后面可以跟多个Master地址,用逗号分隔。这样Worker在启动后如果发现第一个Master不可用,会自动注册到第二个Master上。如果只写了一个Master地址,一旦这台Master挂了,即使ZooKeeper完成了切换,这个Worker也不会自动去找新Master,除非你重启Worker。
启动完成后,验证状态的最直接方式是访问Master Web UI。默认端口是8080。正常情况下:
- 在
spark-master-1的Web UI上,应该能看到Status: ALIVE,并且下方有Worker显示为ALIVE。 - 在
spark-master-2的Web UI上,应该能看到Status: STANDBY。
如果两台都显示ALIVE且都在接收Worker注册,那说明配置有问题,多半是ZooKeeper没连上或者recoveryMode没生效。检查方式很简单:看Master日志的开头,是否输出了Recovery mode set to ZOOKEEPER。
用jps命令也能快速确认进程状态:
jps -l在Master节点上会出现org.apache.spark.deploy.master.Master进程,在Worker节点上会出现org.apache.spark.deploy.worker.Worker进程。
5. 案例测试:从WordCount到Master切换演练
5.1 准备测试数据:分布式读取的路径陷阱
配置好集群和HA之后,就到了案例测试环节。我建议准备两个测试:一个是纯计算型的任务,用来验证HA切换;一个是真实数据读取型的任务,用来验证分布式数据访问。
先说说真实数据读取的坑。很多人用WordCount做测试时,直接在spark-textFile("file:///tmp/input.txt")里填一个本机路径,然后本地模式跑通了,很开心。换成standalone集群模式提交,结果报一堆FileNotFound错误。
原因其实不复杂:textFile("file:///...")这个路径是Executor所在节点上的路径,不是Driver所在节点的路径。数据文件只有一份,放在提交节点上,那么同在那个节点上的Executor能读到,其他Worker节点上的Executor读不到。如果一个Task被调度到了没有这个文件的Worker上,Job就会失败。
所以案例测试我用了两种数据来源:
- 如果测试环境有HDFS或其他分布式存储,直接把数据文件放上去,路径用
hdfs:///tmp/wc_input.txt,这样所有Executor天然共享。 - 如果不想引入分布式存储,就必须把同一份文件复制到每个Worker节点的相同路径下。这对我这种临时测试环境来说反而更快。
WordCount的提交命令如下:
$SPARK_HOME/bin/spark-submit \ --master spark://spark-master-1:7077,spark://spark-master-2:7077 \ --deploy-mode client \ --executor-memory 1g \ --total-executor-cores 4 \ /home/spark/wc.py其中wc.py是一个简单的PySpark脚本:
from pyspark.sql import SparkSession spark = SparkSession.builder.appName("HaWordCount").getOrCreate() sc = spark.sparkContext lines = sc.textFile("/tmp/wc_input.txt") counts = ( lines.flatMap(lambda line: line.split(" ")) .map(lambda word: (word, 1)) .reduceByKey(lambda a, b: a + b) ) print(counts.collect()) spark.stop()这个测试的核心目的是验证分布式数据访问。执行成功后,对比每个Worker节点上产生的Task数量,就能确认数据确实分散到多台机器上计算了。
5.2 提交作业并观察HA切换
接着做FA切换演练。我建议先用Spark自带示例程序,因为它不需要外部数据文件,切换过程不容易被其他因素干扰。
$SPARK_HOME/bin/spark-submit \ --class org.apache.spark.examples.SparkPi \ --master spark://spark-master-1:7077,spark://spark-master-2:7077 \ --deploy-mode client \ --executor-memory 1g \ --total-executor-cores 4 \ $SPARK_HOME/examples/jars/spark-examples_2.12-3.5.0.jar 100正常情况下,提交后会在Master Web UI的“Running Applications”里看到这个任务,然后很快变成“Completed Applications”,输出结果里会出现类似“Pi is roughly 3.141...”的文本。
接下来做切换实验。我在spark-master-1上找到Master进程的PID:
jps -l复制Master的PID,然后强制杀掉:
kill -9 <master-pid>杀掉之后的一瞬间,新任务提交肯定是会失败的,因为第一个URL连不上,ZK还需要等待会话超时。这时候不要慌,也不需要重启任何东西。观察spark-master-2的Web UI,大约几十秒后,状态会从STANDBY变成ALIVE。
这个“几十秒”经常让第一次做测试的人误以为集群挂了。我建议在等待期间顺便看看Master日志,尤其是spark-master-2的日志,里面会有明显的Leader选举和状态恢复记录。日志会告诉你做了什么,而不是让你干等。
等新Master变成ALIVE之后,再次提交SparkPi任务。这次提交命令里的--master参数还是两个地址,第一个失效了,客户端会尝试第二个地址。任务能够成功运行,说明HA切换生效了。
5.3 长任务存活验证:真的不受Master影响吗
我相信很多人会想:切换成功了,但正在运行的长任务会不会被中断?这个需要专门验证,因为它是HA里最容易被误解的部分。
我的验证方法是写一个简单的长任务示例,在每个分区里睡眠足够长的时间,让任务在Master被杀后依然处于运行状态:
import org.apache.spark.sql.SparkSession object LongJob { def main(args: Array[String]): Unit = { val spark = SparkSession.builder() .appName("LongJob") .getOrCreate() spark.sparkContext .parallelize(1 to 100, 10) .mapPartitions { it => Thread.sleep(300 * 1000) it } .count() spark.stop() } }提交这个任务之后,任务会进入Running状态。这时候我在另一个窗口执行kill -9杀掉Active Master。如果HA设计生效,已经在跑的Executor不会因为Master被杀而停止,因为Executor与Driver之间的通信是独立于Master的。整个任务会继续执行,直到正常结束。
我做完这个验证之后,才真正理解了Spark HA的边界:HA保证的是Master的调度可用性,不是应用的失败重试。如果Master挂掉时,某个Executor因为节点本身故障也已经挂了,那么应用是否能够继续,取决于应用自身的容错机制和Executor重新分配的时机,这不是Master切换本身能保证的。
6. 我在反复部署中踩过的坑和养成的检查习惯
6.1 部署期最容易翻车的三类问题
第一类问题:ZooKeeper没有先启动,Master直接退出。这个我前面提到过,但值得再强调一次。判断方法非常直接:看Master日志开头,如果有类似“Failed to connect to ZooKeeper”的报错,那基本就是顺序错了。
第二类问题:Worker的SPARK_WORKER_MEMORY配得太大,Executor启动不了。我遇到过提交任务后所有Executor都在PENDING,日志里提示“no more resources available”,排查半天发现是Worker可用内存只剩几百兆。后来我给Worker预留了充足的操作系统内存,这个现象就消失了。
第三类问题:SPARK_MASTER_HOST配置成localhost导致的注册混乱。在单机测试时配置成localhost没问题,但真正搭集群时,如果Master的SPARK_MASTER_HOST=localhost,Worker连接时使用的地址就会变成localhost,其他机器根本访问不到。配置成内网IP或者统一主机名,能规避大量连接问题。
6.2 运维期值得养成的三个检查习惯
第一个习惯:每次启动完集群,先看Web UI,再提交任务。不要一上来就跑spark-submit,然后报错了才回头查。看Web UI的ALIVE/STANDBY状态,看Worker的ALIVE状态,花一分钟能省一小时。
第二个习惯:查看日志要形成固定路径依赖。Master日志在$SPARK_HOME/logs/spark-<user>-org.apache.spark.deploy.master.Master-1-<host>.out,Worker日志类似。HA切换时,重点看备用Master的日志。平时排查任务卡住时,重点看Worker日志,因为很多Executor启动失败的信息只在Worker日志里。
第三个习惯:周期性地用zkCli检查ZooKeeper目录。比如用ls /spark-ha确认目录结构存在,用get查看Active Master写入的元数据。这些操作能让你直观感知HA的真实状态。无需害怕把ZK弄坏,只读操作一般都很安全。
最后分享一个我在多次部署中形成的体会:Spark的HA配置本身不复杂,复杂的是理解“谁的职责是谁的边界”。Master负责调度,不负责计算;ZooKeeper负责选主,不负责任务恢复;Executor和Driver之间的通信链路,才是任务能持续跑下去的原因。想明白这条边界,部署和排查都会非常顺利。