最近几周连续有朋友问同一个问题:Spark源码和原理看了不少,一到自己动手搭集群就卡壳,尤其是"该选哪种部署方式"这一步。我给的回答一直很简单——学习阶段别急着上YARN或K8S,先用Spark Standalone分布式搭建把整个资源调度过程跑通,性价比最高。这篇教程(系列第九篇)就是基于Spark 3.2在CentOS7上完整搭建Standalone分布式集群的实战记录,踩过的坑、每个配置项背后的原因都会写清楚,适合正准备从单机local模式跨到分布式环境的读者,也适合那些搭完集群但总报各种诡异问题、想一次性弄明白的人。
1. 为什么我建议用Standalone模式做Spark 3.2入门集群
1.1 三种部署模式的第一层取舍
先纠正一个常见的认知误区:很多人觉得学Spark就应该直接奔着生产环境的标准去,一上来就搭YARN甚至K8S,结果环境还没跑通,先被资源管理器本身的部署给劝退了。实际学习阶段的诉求和线上完全不一样——你需要的是最快速度看到一个Task怎么被调度到多台机器上执行,而不是先搞懂数百项的容器编排配置。
Spark本身支持四种部署方式:local、Standalone、YARN、K8S/Mesos。local模式只能单机跑,理解不了"分布式"到底分布在哪里;YARN需要额外维护HDFS和ResourceManager,一套Hadoop环境搭下来少说两个小时;K8S就更重了,里面牵扯的资源模型和Spark自身完全隔离。而Standalone模式最大的优势是Spark自带完整的Master-Worker调度体系,不需要任何外部组件,解压配置就能起集群。
从原理层面讲,Standalone模式下的Master节点负责接收作业请求、给Worker分配Executor,Worker节点负责启动Executor进程并汇报心跳。这套调度模型跟YARN其实很像,只是实现得朴素很多——但对你理解"Driver请求资源、Executor执行任务、Task并行度"这些核心概念来说,朴素反而更友好,因为你能直接看到每一步发生了什么。
1.2 这次搭建的集群规划与角色分配
我这次准备了三台CentOS7虚拟机,节点命名统一用node101、node102、node103,内存各4G、双核CPU。三台是底线,一台Master加两台Worker,分布式特征才能完整呈现——比如一台Worker挂了作业还能不能继续跑、Executor在多个节点上如何横向分布,这些场景两台Worker才能玩得开。
角色分配如下:
| 节点 | 角色 | 核心职责 |
|---|---|---|
| node101 | Master + Worker | 资源调度中枢,同时参与计算 |
| node102 | Worker | 启动Executor执行任务 |
| node103 | Worker | 启动Executor执行任务 |
为什么不单独用一台纯Master?原因很简单:三台机器每台只有4G内存,如果Master单独占一台,剩下的两台Worker各4G内存,跑几个像样的测试作业就吃紧了。我自己测试下来,让Master节点兼任Worker,只要SPARK_WORKER_INSTANCES设置合理(后面会讲),对调度稳定性几乎没有影响,但能多出整整一台机器的计算资源。当然如果你是8G内存以上的机器,纯Master节点分工更干净,排查问题时角色边界也更清晰。
2. 环境准备阶段三个绕不过的坑
2.1 JDK版本与Spark 3.2的兼容性
这一步看着简单,实际是整篇搭建过程中最容易埋雷的地方。Spark 3.2官方支持JDK 8和JDK 11,听起来很宽松,但当你用yum直接装OpenJDK的默认版本时,CentOS7默认源可能会给你装上openjdk 1.8.0的某个老版本。这本身没问题,问题出在版本号细节:Spark 3.2需要JDK 8u201及以上版本,否则部分Java类在编译或运行时会出现奇怪的UnsupportedClassVersionError。
我的建议是一律手动安装固定版本。在三台机器上分别执行:
yum install -y java-1.8.0-openjdk java-1.8.0-openjdk-devel装完立刻验证版本,确认输出中不是过旧的小版本号:
java -version然后是JAVA_HOME环境变量。这一步容易被忽略的是:OpenJDK通过yum安装后路径通常是/usr/lib/jvm/java-1.8.0-openjdk,但这个路径是个软链接,实际指向可能随版本变化。最稳妥的做法是先readlink -f拿到真实路径,再写进/etc/profile:
export JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk export PATH=$JAVA_HOME/bin:$PATH配完执行source /etc/profile,三台机器都要配。很多Worker节点启动失败,最后查来查去就是JAVA_HOME没生效——Spark的start脚本检测不到Java,会直接报JAVA_HOME is not set,但报错位置非常隐蔽,经常被淹没在启动日志里。
2.2 SSH免密登录与主机名映射
Spark的start-all.sh脚本会通过SSH远程登录到workers文件里列出的节点去逐个启动Worker,如果每一次登录都要输密码,脚本会直接卡死在半路。所以Master到所有Worker节点(包括自己)的SSH免密登录是整个集群启动的大前提。
配置过程分三步,我在每个节点上把~/.ssh/id_rsa生成好:
ssh-keygen -t rsa -P '' -f ~/.ssh/id_rsa然后在node101上把公钥分发到所有节点,包括node101自身:
ssh-copy-id node101 ssh-copy-id node102 ssh-copy-id node103这里有一个我当年栽过的跟头:如果你规划Master也承担Worker角色,一定记得ssh-copy-id node101也要执行。因为start-all.sh在启动本地Master之后,还会再SSH连一次本机去启动Worker,如果本机免密没配好,你会在启动日志里看到Master已经起来了但Worker全部失败。
然后是/etc/hosts配置,三台机器上都要写同样的内容,否则Spark节点之间通过主机名通信会解析失败。我这里用内网示例IP做演示,你替换成自己的实际IP即可:
192.168.1.101 node101 192.168.1.102 node102 192.168.1.103 node103配完hosts之后需要重新登录一次SSH会话,否则当前终端的hostname解析缓存不会刷新。
2.3 防火墙与SELinux的取舍
这节内容在真实场景里争议很大,但对我来说结论很明确:如果是自己测试的虚拟机环境,直接systemctl stop firewalld并禁止开机自启,能省掉后面一连串的端口排查时间。
systemctl stop firewalld systemctl disable firewalldSELinux同理,测试环境建议先setenforce 0临时关闭。我之前遇到过一种极其隐蔽的情况:防火墙开着但Spark的8080端口已经手动放行,Web UI也能正常访问,结果作业提交一直卡在Waiting for resources,查了半天才发现是SELinux拦截了Worker和Master之间的7077端口通信,日志里却只显示连接超时。说实话这类权限拦截在真实线上环境应该仔细分析再决定,但学习阶段完全没必要在这一环浪费时间,先关掉,等集群跑顺了再回头研究加固也不迟。
3. 解压配置:Spark 3.2与老版本的一个关键差异
3.1 目录结构与spark-env.sh逐项拆解
下载spark-3.2.1-bin-hadoop3.2版本的安装包,解压到指定目录后,建议第一时间把目录改名成简短路径,不然$SPARK_HOME环境变量要写很长一串:
tar -zxvf spark-3.2.1-bin-hadoop3.2.tgz mv spark-3.2.1-bin-hadoop3.2 /opt/spark然后配置SPARK_HOME并加入PATH,三台机器同步:
export SPARK_HOME=/opt/spark export PATH=$SPARK_HOME/bin:$SPARK_HOME/sbin:$PATH紧接着是全文最关键的一个文件:$SPARK_HOME/conf/spark-env.sh。默认只有spark-env.sh.template模板,先复制一份再改:
cp $SPARK_HOME/conf/spark-env.sh.template $SPARK_HOME/conf/spark-env.sh我最终用的配置如下,每项的用途逐个说明:
export JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk export SPARK_MASTER_HOST=node101 export SPARK_MASTER_PORT=7077 export SPARK_WORKER_CORES=2 export SPARK_WORKER_MEMORY=3g export SPARK_WORKER_INSTANCES=1SPARK_MASTER_HOST:Master绑定的主机名,必须写能被所有节点解析到的主机名或IP,写成域名或localhost会导致Worker无法注册。SPARK_MASTER_PORT:Master与Worker通信的RPC端口,默认7077,一般不用改。SPARK_WORKER_CORES:每个Worker最多可分配给Executor的CPU核数。SPARK_WORKER_MEMORY:每个Worker可供Spark作业使用的总内存,注意是"可供Spark使用",不是机器物理内存总额。后面讲资源调优时这里是个大坑。SPARK_WORKER_INSTANCES:每台节点上启动的Worker进程实例数量。我固定设成1,避免一个节点上多个Worker进程互相抢资源。
这里多提醒一句:SPARK_WORKER_MEMORY不要贪。我测试机上物理内存4G,给Worker分配3g已经算激进了,因为你还要给操作系统、Master进程、后续提交作业的当前进程留余量。如果设成4g,Worker启动时大概率正常,第一个作业提交上来就内存耗尽,整个节点卡死。
3.2 workers文件:别再用Slaves了
这是Spark 3.2和老版本最明显的差异之一:老版本用来声明Worker节点清单的文件叫slaves,但从Spark 3.0起这个文件已经改名为workers,老教程里让你改slaves文件的操作在3.2版本下完全不生效。我当时刚从老版本的习惯迁移过来,愣是照着一个旧文配了配置,结果start-all.sh只起了Master,一个Worker都没起来。
正确操作是把$SPARK_HOME/conf/workers.template复制为workers,然后在里面逐行写节点名:
node101 node102 node103值得注意的一点是:通常教程会建议Master节点不要写入workers文件,但我这里由于Master作为Worker参与计算,所以三台节点都在清单里。如果你的规划是纯Master节点,那就只在workers里写Worker节点清单。
3.3 配置文件同步与目录权限
配置改完之后,最省事的方式是把整个Spark目录同步到其他节点,而不是一台一台手动去改:
scp -r /opt/spark root@node102:/opt/ scp -r /opt/spark root@node103:/opt/这里有一个常见问题:解压后的Spark目录默认属主是当前用户,如果你在node101上用root操作,但其他节点的用户权限不一致,会导致启动脚本执行时Permission Denied。建议在三台机器上统一指定一个运维用户(或者统一用root测试),然后:
chown -R root:root /opt/spark配置文件分发完毕后,最好在每台节点上检查一下workers文件有没有被意外覆盖。
4. 启动集群:确认的不只是进程还有调度
4.1 启动顺序:先Master再Worker
很多人直接跑sbin/start-all.sh一把梭,我相信你在很多教程里也看到过这个写法。但这个脚本做的事情是:先在当前节点启动Master,再根据workers文件去挨个启动Worker。它默认认为你的workers文件已经配好、SSH免密已经生效,如果这两点没满足,报错会非常迷惑。
我的建议是分步启动,方便定位问题。先在node101上启动Master:
sbin/start-master.sh启动成功后用jps应该能看到Master进程。然后再次在node101上启动所有Worker:
sbin/start-workers.sh看到每个节点上都出现了Worker进程,说明注册成功。这里我特意用start-workers.sh而不是start-all.sh,逻辑上更清晰:把启动Master和启动Worker两个步骤解耦,哪一步出了问题,一眼就能定位到是Master的配置问题还是Worker节点的通信问题。
4.2 Web UI与日志:验证集群真正健康
进程起来不代表集群真的健康。Spark Standalone提供两个Web UI页面,是判断集群状态最直观的手段:
- Master UI:
http://node101:8080 - Worker UI:
http://node102:8081(每个Worker节点独立端口)
打开Master UI后,重点看两个信息:第一,Workers表格里有没有出现node101、node102、node103三行记录;第二,每行记录的Alive状态是否为1。如果节点数量对不上,或者状态反复变化,说明网络通信链路有问题,需要回头检查hosts和防火墙。
日志的查看位置也值得记一下:$SPARK_HOME/logs/目录下,Master和Worker的日志文件按日期滚动生成。启动Worker失败时的具体原因,比如"Unable to connect to Master"、"Executor registration timed out",基本都能在这个目录下找到。
4.3 提交第一个正式作业并追踪任务
集群起来后,用Spark自带的examples包里的Pi计算程序做一次完整的端到端验证:
$SPARK_HOME/bin/spark-submit \ --master spark://node101:7077 \ --deploy-mode client \ --executor-memory 2g \ --executor-cores 2 \ --total-executor-cores 4 \ --class org.apache.spark.examples.SparkPi \ $SPARK_HOME/examples/jars/spark-examples_2.12-3.2.1.jar 100执行成功后刷新Master UI,应该能在Running Applications或Completed Applications区域看到这个作业的记录。点进去还能看到每个Executor分布在哪个Worker节点上、每个Task的执行时间。这个界面值得多研究一下,它能帮你直观理解"两个Worker节点各分配了一个Executor,每个Executor里并行跑着两个Task"这件事,比任何原理图都有说服力。
5. Standalone模式下的资源调优与真实踩坑
5.1 Worker内存与Executor内存的关系
我先用一个真实场景来说:刚开始我把SPARK_WORKER_MEMORY设成3g,然后用--executor-memory 3g提交作业,结果集群调度作业时直接报错。原因其实很直接——每个Worker的3g内存并不是一个整体,Executor在这个上限里面申请资源,但并不能一次性把所有内存都占满。
Standalone模式下,每个Worker节点向Master上报自己能提供的内存资源,然后Master把这个资源池按Executor粒度切分。Executor内存由--executor-memory决定,但它只是Executor的堆内存,实际操作中Spark还会在堆外分配一些内存,所以一个Executor实际占用的物理内存会略高于你指定的值。当Worker可分配的总量不足以容纳你申请的所有Executor时,作业就永远处于Waiting for resources状态。
我在三节点测试环境里总结出一个安全配比:Worker总内存3g,提交作业时--executor-memory设置在2g左右,--executor-cores不超过2,这样每个Worker能容纳一个Executor,整体集群还能留出一个备用槽位。这套配比的关键是:Worker的可用内存必须大于Executors申请总量的峰值,否则作业要么排队等到天荒地老,要么直接内存溢出。
5.2 spark-submit提交参数的正确姿势
节省篇幅,我直接给出一套跨节点跑分布式作业的标准姿势。注意两个关键词,一个是资源相关参数,另一个是部署模式:
--executor-memory:单个Executor的堆内存--executor-cores:单个Executor使用的CPU核数--total-executor-cores:整个作业最多占用的总CPU核数--deploy-mode client:Driver运行在提交作业的客户端
我见过很多人在这里犯混淆:以为--executor-memory设置得越大越好,或者以为--total-executor-cores是"每台机器的核数",导致任务数量远多于实际可供分配的核数,大量Task排队。正确的理解是:total-executor-cores是全局上限,Master会在所有Worker节点上统筹资源来满足你申请的需求。
还有一个细节:因为集群里每台Worker报了3g可用内存,如果你用--executor-memory 2g --total-executor-cores 4这种组合,Master会在两台Worker上各启动一个Executor,每个Executor占2g核2,资源刚好分配干净。但如果再叠加--executor-memory 2g --total-executor-cores 6,按照每个Worker当前的空闲核数,Master最多只能按4核调度,多出来的2核会一直等待——这不是死机,而是正常的资源供需关系在起作用。
5.3 一次Executor内存溢出的复盘
有一次提交一个稍重的测试作业,我直接把--executor-memory提到了3g,想着既然SPARK_WORKER_MEMORY给了3g就能用满。跑起来不到一分钟,节点上的物理内存直接被打满,紧接着Executor进程被操作系统OOM Killer干掉。日志里能看到一行含糊的Killed,而不是Java堆内存溢出。
复盘后问题有三层:第一层,Worker总内存3g与Executor申请3g相等,看上去够用,但Spark Executor除了堆内存还有约300M的堆外开销;第二层,容器进程不是JVM一个,还有代码缓存、线程栈等额外内存;第三层,机器物理内存4g中,Worker占3g后留给操作系统和Master的只剩1g,这显然不够。所以Executor内存填满Worker上限是极端危险操作,留出至少20%的余量才是正确做法。之后我把SPARK_WORKER_MEMORY改回3g,提交参数用--executor-memory 2g,一切恢复正常。
6. 排障手册:Standalone集群最常见三个故障
6.1 Worker进程反复消失:先看日志再动配置
最典型的现象是:start-workers.sh执行时Worker进程短暂出现,过几秒又从jps里消失了。绝大多数人第一反应是去改spark-env.sh里的一大堆参数,但我踩过这个坑之后养成了一个习惯——任何进程存活性问题,先打开日志确认原因,再决定动哪里。
Worker日志路径固定,命令是:
tail -100 $SPARK_HOME/logs/spark-root-worker-*.out日志里如果出现Failed to connect to master,大概率是Master地址问题,检查SPARK_MASTER_HOST是否在Worker节点的/etc/hosts里能解析;如果出现Master has no available resources,则是内存分配不够;如果出现Lost connection to master,很可能是网络不稳定或防火墙中断了定时心跳。每次看到有人从配置文件开始排查,我内心都会替那个日志文件觉得委屈——它里面写得清清楚楚。
6.2 Web UI打不开 / 任务卡住:端口与网络的排查顺序
Web UI打不开这个现象,我见过三种不同原因:本地能开远端开不了、所有节点都开不了、Master能开Worker开不了。排查顺序建议是固定的:
- 用
ss -tlnp | grep 8080确认Master进程确实在监听端口 - 用
telnet node101 8080测试端口连通性 - 检查防火墙是否拦截了8080/8081/7077端口
任务卡住不执行的排查则是另一套逻辑:打开Master UI先看集群资源是不是已经用满,再看作业页面的Executor列表。如果Executor一直处于Launching状态,十有八九是Worker节点资源不足或者网络通信有延迟;如果Executor正常但Task全在Pending,问题通常在CPU核数分配上,回到第5节讲的参数关系去调整。
6.3 master URL细节:提交作业时最常见的拼写坑
spark-submit --master spark://node101:7077 ...这里的spark://协议前缀是必须写的,省略或写错都会让客户端认不出Master地址。另外--deploy-mode默认是client模式,这个模式要求客户端机器能通过网络访问到所有Executor节点——如果你在一个和集群网络隔离的本机提交作业,即使URL写对,Executor反向连接Driver时也可能失败,表现为作业提交后Task全失败。
一个容易被忽视的细节:node101这个主机名必须在客户端机器上也解析到正确IP,否则客户端连不上。跨网络提交时直接写IP更稳妥,比如spark://192.168.1.101:7077。这个坑在单机测试时根本暴露不出来,一旦脱离集群内部环境,立刻就会撞上。
最后说点个人体会。Standalone模式虽然最终生产环境可能不会直接用,但它给我建立的资源调度直觉,在后来排查YARN上各种分配问题时帮了大忙——理解了Executor和Worker的边界、理解了客户端模式的通信路径,再看别的资源管理器,只是换了一层壳而已。如果你现在也在学习Spark的路上,我建议配置一次这个集群,然后跑到Web UI里反复观察Task的分布,这比直接背十遍调度源码都有用。