做实时计算和数据平台这一行,Spark部署是逃不掉的基础功。我之前在某数据平台项目上,把Spark从单机本地模式一路升级到Standalone集群模式,顺带把高可用(HA)也完整配好,并且做了案例测试,整个流程走下来差不多两天时间。说实话,网上关于Spark HA的教程不少,但大部分停留在“把配置贴出来”的层面,真正把本地模式、Standalone集群、ZooKeeper协调、故障切换测试串成一条线来讲的文章并不多。这篇文章就按我实际部署的顺序,把Spark本地模式怎么跑、Standalone集群怎么搭、HA怎么配、案例测试怎么做,一步不落地写清楚,同时把配置命令背后的原因和踩过的坑也一并交代。适合准备搭Spark集群的开发者,也适合那些已经能跑起Spark但还没解决Master单点问题、想上高可用的朋友。
1. 为什么先跑通本地模式,再上Standalone高可用
1.1 本地模式:入门正确,生产只是起点
Spark的本地模式是最轻量的一种运行方式,它不需要任何集群资源,只要有一台装了JDK的机器就行。local[N]的意思是使用本地N个线程来执行任务,local[*]则直接使用这台机器的全部CPU核数。刚接触Spark的人,如果第一件事就是去搭集群,很容易被各种分布式概念绕晕,而本地模式可以让你先把RDD、DataFrame、Spark SQL这些核心计算逻辑跑通。
本地模式的定位是开发和测试,不是生产运行。它没有真正的Master和Worker进程,没有资源调度,没有跨节点的Shuffle优化,也没有故障转移能力。我见过有人图省事,把定时跑批的Spark作业挂在本地模式上,结果物理机一重启或者内存一紧张,整个任务就没了。本地模式能帮你验证“代码逻辑对不对”,但验证不了“提交到集群以后调度器能不能正常分配资源”、“某个Executor挂掉之后任务会不会自动重试”。所以要上高可用,一定得先把Standalone集群模型搞明白。
1.2 Standalone集群与HA的必要性
Standalone模式是Spark自带的集群调度方式,主要由Master和Worker两类角色组成。Master负责接收作业请求、分配资源和调度任务,Worker负责实际执行任务并汇报状态。如果你只是跑纯Spark作业,不依赖Hadoop的HDFS和YARN调度,Standalone比YARN模式省心,部署组件少,出错面小。
Standalone集群最怕什么?最怕唯一的Master进程挂掉。单Master部署下,一旦Master所在节点宕机、进程被误杀或者网络分裂,整个集群就没有了调度中枢,所有已经提交的作业要么等待,要么直接失败。HA要解决的核心问题,就是让集群在Master这个单点故障出现之后,仍然能够自动地把集群管理工作接管过来,保证已经运行的任务不中断,新的任务还能继续提交。
1.3 两种HA方案怎么选
Spark Standalone官方提供了两种高可用方案。一种是基于文件系统的手动恢复,Master把应用和Worker的状态写到本地或共享目录,Master挂了之后需要由人工启动一个新的Master进程,并从原目录恢复状态,这种方案本质上做不到自动故障转移。另一种是基于ZooKeeper的自动恢复,多个Master在ZooKeeper上注册,由ZooKeeper负责选举出唯一的Active Master,Active故障后Standby自动接管。
我在部署时直接选了ZooKeeper方案,原因很直接:生产环境故障恢复不能靠人守着,自动切换才是高可用的意义。两种方案的差异可以用一个表格来对比:
| 对比项 | 单Master | 文件系统HA | ZooKeeper HA |
|---|---|---|---|
| 自动故障转移 | 无 | 无,手动拉起 | 有,自动选举 |
| 需要额外组件 | 无 | 仅本地/共享目录 | ZooKeeper集群 |
| 状态存储位置 | Master内存 | 本地/共享文件 | ZooKeeper节点 |
| 恢复时间 | 需要人工介入 | 分钟级 | 秒级到几十秒 |
| 适用场景 | 开发联调 | 测试环境 | 生产环境 |
熟悉ZooKeeper的人都知道,它本身也是要靠集群来保证可用性的,所以HA的部署不是只配一个ZooKeeper实例,而是至少三个节点组成一个仲裁集群。下面的实操部分,我就以三个节点的ZooKeeper集群为基础,讲解整套搭建流程。
2. 本地模式部署:从零把Spark跑起来
2.1 环境准备与安装步骤
本地模式虽然简单,但环境变量和JDK版本这些基础问题还是有不少人栽跟头。先把整套安装步骤列出来:
- 安装JDK。Spark 3.x版本建议使用JDK 8或JDK 11,我用的是JDK 8,原因是大多数国内数据平台当时的线上环境都以JDK 8为主,兼容性最好。
- 下载Spark二进制安装包,解压到
/opt/spark目录。 - 配置环境变量
SPARK_HOME和PATH,同时确保JAVA_HOME指向正确的JDK路径。 - 执行
spark-shell --master local[2]验证安装是否成功。
bash环境变量配置示例如下:
export JAVA_HOME=/opt/jdk1.8 export SPARK_HOME=/opt/spark export PATH=$SPARK_HOME/bin:$JAVA_HOME/bin:$PATH配置完之后,运行java -version和spark-shell --version确认环境生效。这里有个细节:如果机器上装了多个JDK,一定要检查JAVA_HOME到底指向哪个版本,避免Spark启动时报Unsupported class file major version这类错误。
2.2 本地模式案例验证
本地模式最简单的验证方式是直接跑Spark官方自带的示例。我用的是SparkPi这个经典案例,它通过蒙特卡洛方法随机采样来计算Pi的近似值,任务量可以调整,非常适合验证环境是否正常:
/opt/spark/bin/spark-submit \ --class org.apache.spark.examples.SparkPi \ --master local[2] \ /opt/spark/examples/jars/spark-examples_2.12-3.3.2.jar 10这里local[2]表示只用本机两个CPU线程来跑,最后的10表示采样次数,数字越大计算越久。跑完能看到终端输出类似Pi is roughly 3.141592653589793的结果。如果你换成local[*],Spark会把本机所有可用的CPU核数都用上,观察任务并行度的时候这个区别很明显。
我还建议顺手跑一个简单的RDD词频统计,确认Scala或Python的API调用链路是通的。用spark-shell执行下面这段代码:
val rdd = sc.textFile("file:///opt/spark/data/test.txt") rdd.flatMap(_.split(" ")).map(word => (word, 1)).reduceByKey(_ + _).collect().foreach(println)这一步的意义在于,它验证了从文件读取、RDD转换、Shuffle聚合到结果输出的完整链路。本地模式能把代码层面的问题都暴露出来,等你上了集群,排查范围就可以锁定在调度和资源上,而不是业务逻辑本身。
2.3 本地模式几个容易忽略的点
本地模式有几个细节新手很容易忽视。第一,local[N]的N并不代表Executor数量,它只是JVM内部的线程数,所以你用jps查看进程时,看到的还是一个SparkSubmit进程,并没有独立的Worker进程。第二,local[2]在任务量很小的时候可能比local[1]还慢,因为线程切换也有成本,不要盲目调大N。第三,本地模式里也可以使用--master local-cluster[2,1,1024]来模拟一个小型伪集群,即在一台机器上启动多个进程模拟Master和Worker,但我不建议把它用在生产或性能测试,它和真实Standalone集群的调度行为仍有差异。
本地模式给我最大的价值是:它让我在进入集群部署之前,就已经对Spark的运行日志格式、提交命令参数、失败重试机制这些基础动作有了手感。带着这些基础去配Standalone集群,效率会高很多。
3. Standalone高可用集群部署实操
3.1 节点规划与ZooKeeper集群准备
在实际部署之前,先规划节点。我这里用的是四台虚拟机的模拟环境,生产环境也完全可以套用这套规划逻辑。为了方便描述,把节点命名为node01、node02、node03,这三个节点组成了ZooKeeper仲裁集群。Spark Master部署在node01和node02上,形成双Master的HA结构,三个节点同时都运行Spark Worker进程。
| 节点名称 | ZooKeeper | Spark Master | Spark Worker |
|---|---|---|---|
| node01 | 是 | 是 | 是 |
| node02 | 是 | 是 | 是 |
| node03 | 是 | 否 | 是 |
ZooKeeper集群为什么必须是奇数个?因为ZooKeeper选举时需要过半数的节点投票才能产生Leader。三节点集群允许挂一个节点,五节点集群允许挂两个,而如果只部署两个节点,任何一个节点出问题都没法形成多数,集群直接不可用。所以要么三台,要么五台,别用两台。
ZooKeeper的部署不算复杂,先把三台机器的/etc/hosts都配好主机名映射,然后下载ZooKeeper解压到/opt/zookeeper。每个节点上修改conf/zoo.cfg:
tickTime=2000 initLimit=10 syncLimit=5 dataDir=/data/zookeeper clientPort=2181 server.1=node01:2888:3888 server.2=node02:2888:3888 server.3=node03:2888:3888然后在/data/zookeeper目录下创建名为myid的文件,内容分别是1、2、3,对应各节点的编号。这个myid必须和zoo.cfg里server.N的后缀数字一致,否则ZooKeeper找不到自己在集群中的位置。
启动顺序也有讲究,逐个节点执行:
/opt/zookeeper/bin/zkServer.sh start然后通过/opt/zookeeper/bin/zkServer.sh status查看角色。正常情况下,三台机器中会有一台显示Mode: leader,另外两台显示Mode: follower。如果三台都显示standalone或者报连接错误,说明myid、hosts或者dataDir配置有问题。
ZooKeeper起来之后,我还专门用zkCli.sh连上去检查了根节点,确认2181端口能正常访问。这一步虽然基础,但对后面排查Spark HA问题时非常关键,因为如果ZooKeeper集群本身就不健康,Spark Master的选举和状态存储都会跟着出问题。
3.2 Spark端配置:spark-env.sh与defaults.conf
Spark的HA配置核心在conf/spark-env.sh。这套配置需要在node01和node02两台Master节点上都设置,而且两个节点上的SPARK_DAEMON_JAVA_OPTS必须一致,尤其是spark.deploy.zookeeper.url,要写全三个ZooKeeper节点的地址和端口。
export JAVA_HOME=/opt/jdk1.8 export SPARK_HOME=/opt/spark export SPARK_MASTER_PORT=7077 export SPARK_MASTER_WEBUI_PORT=8080 export SPARK_WORKER_CORES=4 export SPARK_WORKER_MEMORY=8g export SPARK_DAEMON_JAVA_OPTS="-Dspark.deploy.recoveryMode=ZOOKEEPER -Dspark.deploy.zookeeper.url=node01:2181,node02:2181,node03:2181 -Dspark.deploy.zookeeper.dir=/spark_ha"有几个参数要特别说明。spark.deploy.recoveryMode=ZOOKEEPER是告诉Spark启用ZooKeeper模式的状态恢复;spark.deploy.zookeeper.url是ZooKeeper集群的连接串,多个地址用逗号分隔;spark.deploy.zookeeper.dir是Spark在ZooKeeper上存储元数据的根节点,默认是/spark,我建议改成/spark_ha这样有明确语义的路径,方便后期单独清理。
这里有个非常容易踩的坑:SPARK_DAEMON_JAVA_OPTS不是写在spark-defaults.conf里,而是通过-D前缀传成JVM系统属性。因为Master进程是单独启动的JVM进程,它启动时需要读取这些系统属性来决定自己的恢复模式。如果把这个配置写到spark-defaults.conf里,它只会影响作业提交端的配置,Master启动时根本不会去读,结果就是UI界面依然显示单Master状态,故障切换完全失效。
在conf/workers文件里,把所有Worker节点主机名写上,一行一个:
node01 node02 node03还要注意conf/spark-env.sh里的SPARK_MASTER_HOST。如果两台Master节点都用同一份配置,不要把SPARK_MASTER_HOST写死成node01,否则node02上的Master进程会一直试图绑定node01的地址,启动必然失败。正确做法是每个Master节点各自把SPARK_MASTER_HOST设置成自己的主机名,或者干脆不设置,让Spark使用本机解析出来的主机名。
3.3 启动集群与验证
集群启动不能直接在所有节点上统一执行start-all.sh完事,因为那样只会在一台机器上启动Master,另外一台Master不会自动起来。正确方式是在node01和node02上分别执行start-master.sh,然后在每个Worker节点上执行start-worker.sh,或者在其中一台Master节点上执行start-all.sh,它会根据workers列表把Worker都拉起来,但Master还是只启动本地这个。为了稳妥,我两台Master都手动执行了start-master.sh,Worker则通过start-all.sh统一拉起。
启动完成后,用jps查看进程状态。正常情况应该是:node01上有QuorumPeerMain、Master、Worker三种进程;node02同样如此;node03上有QuorumPeerMain和Worker。如果某个节点缺少QuorumPeerMain,说明ZooKeeper没起来;如果缺少Master,确认是否忘了手动执行start-master.sh。
打开Spark Web UI确认HA状态,这是最直观的验证方式。访问http://node01:8080,如果配置正确,页面顶部会显示Status: ACTIVE;访问http://node02:8080,会显示Status: STANDBY。两个页面上的Worker列表都应该能正常显示,这说明Worker已经同时向两个Master完成了注册。我还见过一种情况:页面显示Status: RECOVERING,这说明Master正在从ZooKeeper恢复状态,如果长时间停在RECOVERING不跳转,就要检查spark.deploy.zookeeper.url是否写对,以及ZooKeeper集群是否真的可用。
4. 高可用案例测试:故障切换全过程实录
4.1 测试案例准备:SparkPi与词频统计
环境部署完,高可用到底管不管用,必须用真实的作业来验证。我准备了两个案例,一个是SparkPi计算,一个是文本词频统计,两者各有侧重。SparkPi是CPU密集型的批任务,跑起来时间长,适合观察Master切换对运行中作业的影响;词频统计涉及文件读取和Shuffle聚合,能验证恢复之后的集群能否正常调度新作业。
提交SparkPi时,--master参数是关键,一定要把两个Master的地址都写进去:
/opt/spark/bin/spark-submit \ --class org.apache.spark.examples.SparkPi \ --master spark://node01:7077,node02:7077 \ --deploy-mode client \ /opt/spark/examples/jars/spark-examples_2.12-3.3.2.jar 1000这里spark://node01:7077,node02:7077这个格式很多人第一眼会不习惯,它跟单地址的区别就在于多了个逗号。Spark提交客户端看到这个格式后,会向两个Master都注册这个应用,这样当Active Master挂掉时,客户端可以自动failover到Standby Master。如果你只填了spark://node01:7077,那么node01上的Active Master一死,客户端就无法感知到node02的存在,后面新提交的任务基本都会失败,这不是HA没配好,而是客户端本身就没接入HA机制。
第三个Worker节点上,我准备了一个/opt/spark/data/words.txt文件,用来跑词频统计。这段逻辑直接用spark-shell执行:
val rdd = sc.textFile("file:///opt/spark/data/words.txt") rdd.flatMap(_.split(" ")).map(word => (word, 1)).reduceByKey(_ + _).count()spark-shell本身也是通过SparkSubmit提交的作业,所以连接到集群的Master地址同样使用双Master格式。这样做的好处是,即使Master发生了切换,当前shell会话里的Driver进程也不会中断。
4.2 kill掉Active Master的完整切换记录
整个测试里最有价值的就是故障切换这一步。我的操作流程是:先确认node01是Active Master,node02是Standby Master,然后开启SparkPi任务,等任务运行到一半时,直接执行kill -9杀掉node01上的Master进程。
杀掉Master之后,我同时做了两件事:一是盯着另外一个终端看node02的Master日志,二是观察SparkPi的作业输出。node02的日志在logs/spark-*-org.apache.spark.deploy.master.Master-1-*.out文件里,记录中很快出现状态恢复的迹象。等待一段时间后,node02的Web UI页面状态会从STANDBY变成ACTIVE,中间可能会短暂经过RECOVERING状态,这是Master在从ZooKeeper中读取集群元数据。
注意一个细节:如果你看到状态一直是RECOVERING,不要急着反复重启Master,先看日志里有没有连接ZooKeeper超时的报错。我遇到过一次,原因是防火墙把2888和3888端口挡了,ZooKeeper虽然显示三个进程都在,但Leader选举迟迟不成功,Spark自然也就无法从ZooKeeper里恢复状态。
在SparkPi这边,任务并没有因为Master被杀而立即失败。Driver依然在不断打印进度日志,计算继续执行,这说明正在运行的任务在Master切换的过程中没有断掉。最终作业正常结束,输出了Pi is roughly 3.141....。这个结果说明,Spark应用一旦进入运行状态,Driver和Executor之间已经建立了直接的通信通道,Master的存在感会明显下降,所以Master切换对已经在跑的任务影响有限。
为了进一步验证新提交作业的能力,我在Master切换完成后,又用同样的方式提交了一个新的SparkPi作业。这次应用的注册目标是node02,提交客户端通过双Master地址自动找到了新的Active Master,作业正常分配到了节点上的Worker并运行完成。这一步验证了新作业在故障切换之后能够持续提交,才算是真正闭环。
4.3 恢复原先Master后的行为
切换完成后还有一个问题:原来的node01重启之后,是直接抢占Active角色,还是乖乖当Standby?我在node01上重新执行了start-master.sh,等它起来之后打开Web UI,发现它的状态显示为STANDBY,node02继续保持ACTIVE。
这个设计对生产环境非常重要。Master角色的切换不是靠“后启动的进程优先”,而是由ZooKeeper的选举机制来保证的,已经当选的Active Master在没有明显故障的情况下不会被新加入的Master抢走。如果原Master重启之后会自动抢占,那么故障切换过程中集群就会反复抖动,反而影响稳定性。所以,统一在集群配置中把多个Master视为对等节点是完全没问题的,ZooKeeper会保证只有一个Active。
重新把node01并入集群后,我还做了一轮小验证:向集群提交一个新的作业,这次故意把--master里的地址顺序反过来,把node02放在前面,确认两个Master地址的先后顺序不影响作业提交。实测结果正常,顺序没有影响。
4.4 测试结论与边界情况
通过这一套案例测试,Standalone高可用功能基本验证通过。结论可以概括为三点:第一,ZooKeeper方案下双Master可以自动选举,Active Master挂掉后Standby能接管;第二,运行中的Spark作业在Master切换过程中保持正常,Driver没有断;第三,提交端如果使用双Master地址列表,新作业能在切换后继续提交。
但我还要说清楚边界情况。Master切换对长时间运行的批任务影响不大,但对正在提交阶段的作业可能会有影响,比如作业刚提交、Driver还在向Master注册应用的那一两秒内Master突然挂了,客户端可能报连接拒绝,需要重试提交。另外,如果你的作业是通过管理平台或调度系统统一提交的,这些平台里的Master地址往往配置的是单节点,需要同时考虑它们在HA场景下的自动重连机制,不能只把Spark层面的HA做完就认为万事大吉。
5. 常见问题排查与部署避坑手册
5.1 高频问题速查表
部署过程中最容易出问题的点,我整理成一张排查表,方便遇到问题直接对照:
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| Master UI显示不到Worker列表 | Worker端防火墙未放行7077或8080端口 | 在Worker节点执行telnet MasterIP 7077测试连通性 |
| Master一直处于RECOVERING状态 | ZooKeeper选举异常或zookeeper.url配错 | 检查zkServer.sh status模式,查看Master日志 |
| Spark进程启动后立即退出 | JAVA_HOME未设置或JDK版本不兼容 | 在启动用户下执行java -version确认版本 |
| 提交作业时报Master URL未注册 | 提交端只配了单个Master地址 | 在--master参数中使用逗号分隔的双地址列表 |
| Worker向Master注册失败 | 两个节点之间时间不同步 | 执行date命令对比,部署NTP服务同步时间 |
SPARK_DAEMON_JAVA_OPTS配置似乎不生效 | 配置写进了defaults.conf而非spark-env.sh | 用`ps -ef |
| node01重启后自动抢占Active角色 | ZooKeeper选举机制本身不允许抢占 | 确认UI显示STANDBY,无需干预 |
| 各Worker显示的Executor内存不符合预期 | Worker未设置SPARK_WORKER_MEMORY | 检查spark-env.sh是否有拼写错误 |
5.2 几条独立的排查思路
排查Spark集群问题,我的习惯是“先看进程,再看日志,最后看网络”。jps一眼能看到每个节点的Java进程是否齐全,比对着文档猜要快得多。日志方面,Master的日志在$SPARK_HOME/logs/目录下,文件名带master字样,Worker日志带worker字样。故障切换失败的时候,优先去翻Standby Master的日志,而不是Active Master的,因为切换逻辑主要发生在原本的Standby节点上。
网络层面的排查同样重要。很多HA问题不是Spark配置错,而是节点间网络不通,ZooKeeper端口2181、2888、3888,Spark端口7077、8080都有可能被防火墙隔离。我习惯用telnet做连通性测试,如果某个端口连不上,先用ss -lntp确认进程监听的端口是否正常。
5.3 部署细节与优化建议
最后分享几个部署中的优化经验和容易忽略的细节。
第一,集群里所有节点的/etc/hosts必须保持一致。如果node01的hosts里有node02的映射,而node02自己没有,那么Master之间互相寻找对方时就会出现解析失败。这里尤其注意,hostname解析不要依赖内网DNS,除非你非常确定DNS在故障切换期间依然稳定。
第二,启动脚本建议新增一个统一的启停封装脚本。直接敲start-master.sh容易漏掉某个节点,我写了一个简单的shell脚本,依次完成ZooKeeper节点检查、Master启动、Worker启动、状态检查四个步骤。这样每次环境重启后都能快速恢复集群。
第三,Worker的SPARK_WORKER_CORES和SPARK_WORKER_MEMORY要根据机器实际规格配置,不要盲目写死大数值。我在node03上设置过SPARK_WORKER_MEMORY=16g,但机器本身只有16GB内存,加上系统开销,结果Executor经常被操作系统OOM杀掉。对于测试环境,保守一点反而是稳妥的。
第四,建议给ZooKeeper的数据目录做定期备份。Spark HA的元数据存在ZooKeeper上,如果整个ZooKeeper集群的数据目录损坏,恢复起来会非常被动。我在实测环境中就直接把/data/zookeeper/version-2目录做了快照,后面即使误操作也能回滚。
部署这套环境之后,我个人的体会是:Spark的高可用并不是一个孤立配置,而是由ZooKeeper集群健康度、Master选举机制、Worker注册路径、客户端提交方式四部分共同决定的。任何一环掉链子,整个HA都会变成纸上谈兵。最后再提醒一句,如果你所在的环境里已经有任务调度平台,一定要确认调度平台连接Spark集群的Master地址列表是完整的双地址,而不是隐藏了故障风险的单地址,这个细节比你在Spark端多配几个参数更重要。