去年接手一台临时分配的Linux服务器,要求在上面跑PySpark,版本组合直接给定:Hadoop 3.1.3 + Spark 3.4.4。刚听到这个组合我的第一反应是“这有什么难的”,无非就是解压、改配置、启动服务。结果真正动起来才发现,这套版本组合有几个很容易被忽略的兼容性问题,特别是Spark预编译包内置的Hadoop客户端版本和目标Hadoop集群版本不一致时,PySpark连HDFS会非常痛苦。
这篇文章把我完整的配置过程、踩过的坑、最后的验证方式都拿出来分享。如果你是第一次在Linux上搭PySpark环境,或者正准备用Hadoop 3.1.3配Spark 3.4.4,这篇内容基本就是一条可以直接照着走的路。文中涉及的所有命令和配置我都在这台机器上实测过,你可以放心参考。
1. 这台机器的版本组合为什么这么难受
1.1 先说结论:整个链路的关键瓶颈在JAR包
很多教程会告诉你“下载Spark发行包,解压就能用”,这句话在纯本地模式(local模式)下是对的,但一旦你的Spark需要读写HDFS,问题立刻就来了。
Spark 3.4.4官方提供的预编译包分两类:spark-3.4.4-bin-hadoop3和spark-3.4.4-bin-without-hadoop。前者内部自带了针对某个Hadoop小版本编译好的客户端JAR,后者则完全不带Hadoop相关依赖。关键是,Spark 3.4.4的预编译Hadoop3包,内置的Hadoop客户端版本并不是Hadoop 3.1.3,而是Hadoop 3.3.4。
也就是说,你拿着Spark 3.4.4预编译包直接去连Hadoop 3.1.3的NameNode,相当于用一个比较新的客户端去连一个比较旧的服务端。Hadoop的RPC协议在2.x和3.x之间有明确版本差异,而3.1.x和3.3.x之间也存在IPC版本不兼容的风险。我在实际连接时遇到过很典型的报错,服务端和客户端互相认为对方协议版本不匹配,具体表现就是Spark作业一启动就抛IPC版本不能通信之类的IOException,或者HDFS客户端初始化直接卡死。
1.2 Python版本与JDK版本的最大公约数
这套组合里还有第二层约束:Hadoop 3.1.3官方推荐使用Java 8,Spark 3.4.4则支持Java 8/11/17。为了两边都稳,最保险的选择是JDK 8。如果你在机器上装了JDK 17,让Hadoop 3.1.3跑起来会非常痛苦,因为很多老版本Hadoop的内部代码对高版本JDK的兼容性并不好。
PySpark这边,Spark 3.4.4要求Python 3.8及以上,我当时用的是Python 3.9,没有任何问题。如果你还在用Python 3.6或者更老的版本,建议先升级再继续。整条链路的版本关系可以这样理解:Python提供程序入口,Spark负责计算调度,Hadoop提供分布式存储,而JDK是Spark和Hadoop共同的地基。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境预处理:JDK、SSH免密与跑Hadoop的专用用户
2.1 选择OpenJDK 8的理由与几个坑
先确认系统里有没有Java环境:
bash复制java -version
javac -version
如果没有,就直接装OpenJDK 8。CentOS系可以用yum install -y java-1.8.0-openjdk-devel,Debian/Ubuntu系可以用apt install -y openjdk-8-jdk。某些较新的Ubuntu版本默认源里没有OpenJDK 8,需要先添加对应仓库再装。装完之后一定要确认一下实际安装路径,因为后面配置JAVA_HOME要用。
我见过不少人在这一步直接复制网上的/usr/lib/jvm/java-8-openjdk-amd64路径,结果自己机器上实际不是这个路径,最后所有脚本都起不来。建议用下面这条命令找真实路径:
bash复制readlink -f $(which java)
输出结果去掉末尾的/bin/java,就是你的JAVA_HOME。
安装完成后,最好在/etc/profile或者用户自己的~/.bashrc里写清楚:
bash复制export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64
export PATH=$PATH:$JAVA_HOME/bin
改完记得source ~/.bashrc,然后重新开一个终端窗口测试。很多人配置完不重新加载环境变量,后面Hadoop脚本傻傻找不到Java,报JAVA_HOME is not set,查半天才发现终端压根没刷新。
另一个容易忽略的点是时区。Hadoop很多日志和监控界面默认用系统时区,如果机器时区设置不对,日志排查时会非常别扭,顺手把timedatectl set-timezone Asia/Shanghai之类的事做掉。
2.2 用非root用户跑Hadoop,顺手把SSH免密做了
Hadoop系列进程强烈不建议直接用root跑,虽然能跑,但会出现各种权限和脚本判断上的怪问题。我在这台机器上专门建了一个用户,就叫hduser,分配给Hadoop和Spark使用。
bash复制useradd -m -s /bin/bash hduser
passwd hduser
创建完毕之后,切换到该用户,配置SSH免密登录。因为Hadoop伪分布式模式下,NameNode启动时需要通过SSH连接到localhost启动DataNode等进程,如果每次都要输密码,自动化脚本根本跑不动。
bash复制su - hduser
ssh-keygen -t rsa -P '' -f ~/.ssh/id_rsa
cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
ssh localhost
如果执行ssh localhost不需要输入密码,说明免密配置成功。这里有个小坑:authorized_keys和~/.ssh目录的权限必须严格,否则SSH会直接忽略这个文件。~/.ssh目录通常需要700权限,authorized_keys文件600权限。
3. Hadoop 3.1.3单机HDFS拉起:一份最简可用的配置
3.1 core-site.xml与hdfs-site.xml怎么改
Hadoop解压到/opt/hadoop-3.1.3之后,把归属改给hduser:
bash复制chown -R hduser:hduser /opt/hadoop-3.1.3
配置目录在/opt/hadoop-3.1.3/etc/hadoop。先改core-site.xml,这是Hadoop的核心配置,里面最重要的就是fs.defaultFS,它决定了默认文件系统地址。我用的是9000端口:
xml复制<configuration>
<property>
<name>fs.defaultFS</name>
<value>hdfs://localhost:9000</value>
</property>
<property>
<name>hadoop.tmp.dir</name>
<value>/opt/hadoop-3.1.3/tmp</value>
</property>
</configuration>
hadoop.tmp.dir建议明确指定,否则默认会落在系统/tmp下,一旦机器重启清理临时文件,NameNode的元数据很可能直接丢失,这是新手的经典噩梦。
然后是hdfs-site.xml。单机伪分布式环境,副本数必须设成1,否则只有一个DataNode的情况下,3个副本永远凑不齐,HDFS会一直报告文件处于UNDER_REPLICATED状态:
xml复制<configuration>
<property>
<name>dfs.replication</name>
<value>1</value>
</property>
<property>
<name>dfs.namenode.name.dir</name>
<value>file:///opt/hadoop-3.1.3/data/namenode</value>
</property>
<property>
<name>dfs.datanode.data.dir</name>
<value>file:///opt/hadoop-3.1.3/data/datanode</value>
</property>
</configuration>
3.2 格式化NameNode、启动与验证
配置改完后,第一次启动前要给NameNode做格式化。这个操作会初始化文件系统的元数据目录,格式化的命令很简单:
bash复制su - hduser
/opt/hadoop-3.1.3/bin/hdfs namenode -format
执行成功后会看到很多输出,最后有SHUTDOWN_MSG之类的信息。这里必须强调:格式化只能做一次。如果后续重新格式化了NameNode,DataNode的clusterID不会自动同步,启动时会发现DataNode无法注册到NameNode,报Incompatible clusterIDs错误。解决办法是把data/datanode目录下的内容删掉再重启DataNode,或者手动对齐clusterID,但最省心的方式就是别重复格式化。
启动HDFS:
bash复制/opt/hadoop-3.1.3/sbin/start-dfs.sh
运行后分别执行jps,能看到NameNode、DataNode、SecondaryNameNode三个进程基本就算起来了。HDFS的Web管理界面默认跑在9870端口,浏览器打开能看到文件系统总览和DataNode状态。
如果进程没起来,第一时间去看/opt/hadoop-3.1.3/logs/目录下对应的日志文件。我把启动失败的常见原因列在下面:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 启动时SSH报错 | 免密没配置好 | 重新检查sshd配置和权限 |
| NameNode起不来 | hadoop.tmp.dir目录权限不足 |
确认为hduser所有,或重新格式化 |
| DataNode起不来 | clusterID不一致 | 清掉数据目录中的datanode子目录并重启 |
| 9000端口被占用 | 有别的进程占用了RPC端口 | 修改core-site.xml端口或释放占用 |
| 9870打不开 | 防火墙 | 开放端口或临时关闭防火墙 |
3.3 验证HDFS读写
HDFS起来后,先做最基础的读写测试。创建目录、上传文件、查看文件,这一套走通,后面PySpark读数据才有底气:
bash复制/opt/hadoop-3.1.3/bin/hdfs dfs -mkdir -p /user/hduser/test
echo "hello hadoop spark pyspark hdfs" > /tmp/hello.txt
/opt/hadoop-3.1.3/bin/hdfs dfs -put /tmp/hello.txt /user/hduser/test/hello.txt
/opt/hadoop-3.1.3/bin/hdfs dfs -ls /user/hduser/test
看到上传的文件出现在列表里,HDFS这一层就通了。
4. Spark 3.4.4安装:不换包就要被IPC版本教育
4.1 下载预编译包后最容易忽略的Hadoop客户端版本
Spark解压到/opt/spark-3.4.4,同样把归属改掉。这里关键的选择在于:到底下载spark-3.4.4-bin-hadoop3还是spark-3.4.4-bin-without-hadoop。
如果你用的是预编译的bin-hadoop3包,请先看一下Spark的jars目录:
bash复制ls /opt/spark-3.4.4/jars | grep hadoop-client
你会发现里面是类似hadoop-client-api-3.3.4.jar和hadoop-client-runtime-3.3.4.jar这样的文件。这个3.3.4的Hadoop客户端,就是刚才说的隐性问题来源。直接拿它去连Hadoop 3.1.3,大概率会栽跟头。
4.2 方案一:替换Spark jars目录下的Hadoop客户端jar
如果你手里已经下载了bin-hadoop3包,又不想换包重新下载,可以手动替换Spark jars目录下的Hadoop客户端JAR。操作思路是把Spark自带的3.3.4相关JAR备份,然后把Hadoop 3.1.3安装目录中对应版本的JAR复制进来。
从Hadoop 3.1.3里找客户端JAR:
bash复制find /opt/hadoop-3.1.3 -name "hadoop-client-api-*.jar"
find /opt/hadoop-3.1.3 -name "hadoop-client-runtime-*.jar"
找到后备份Spark中原来的文件,再复制进来。替换之后,不要直接启动,先把Spark的jars目录重新扫描一遍,确认没有同时残留两个版本的hadoop-client-api,否则classpath加载顺序很容易出问题。这个方法不是我最推荐的,因为Hadoop不同版本之间的依赖库不一定只是这两个JAR文件的差异,替换后可能出现其他间接依赖缺失,排查起来比较费神。
4.3 方案二:用without-hadoop包配合hadoop classpath(推荐)
我更推荐另一条路:下载spark-3.4.4-bin-without-hadoop这个发行包,然后让Spark通过环境变量直接加载Hadoop 3.1.3的classpath。这样Spark内部就不会再依赖某个固定版本的Hadoop客户端,而是完全复用你机器上Hadoop安装目录里的那一堆JAR,版本天然对齐。
做法很简单,在/opt/spark-3.4.4/conf/spark-env.sh里配置:
bash复制export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64
export HADOOP_HOME=/opt/hadoop-3.1.3
export SPARK_DIST_CLASSPATH=$(/opt/hadoop-3.1.3/bin/hadoop classpath)
hadoop classpath命令会把Hadoop运行所需的全部依赖路径一次性输出,Spark把这些路径作为自己的classpath,就不会出现客户端版本错乱的问题。这也是很多生产环境的标准做法,既省心又干净。如果你也想让Spark默认使用Hadoop的配置文件,可以再加一行:
bash复制export HADOOP_CONF_DIR=/opt/hadoop-3.1.3/etc/hadoop
4.4 spark-env.sh里的关键环境变量
spark-env.sh是从模板复制出来的:
bash复制cd /opt/spark-3.4.4/conf
cp spark-env.sh.template spark-env.sh
里面除了JAVA_HOME、HADOOP_HOME、SPARK_DIST_CLASSPATH之外,如果后面想跑Spark Standalone集群,还需要设置SPARK_MASTER_HOST和SPARK_MASTER_PORT。目前单机阶段暂时不用管。
验证Spark是否正常的一个简单办法是跑一下它的自带示例:
bash复制/opt/spark-3.4.4/bin/run-example SparkPi 10
程序能算出π的近似值,说明Spark本身能跑,接下来只需要把PySpark链路上剩下的Python部分接好。
5. PySpark运行时链路:pip包、SPARK_HOME与HADOOP_CONF_DIR
5.1 用发行版还是pip包,选清楚才能少折腾
PySpark有两种常见使用方式。一种是把Spark发行包里的bin/spark-submit和bin/pyspark当成命令行工具直接使用;另一种是pip install pyspark,把PySpark当作一个Python库安装到当前环境里,然后在自己的Python脚本里import pyspark。
要注意的是,pip install pyspark==3.4.4装出来的PySpark,本身也是一个完整的Spark发行包,里面同样带有自己的Hadoop客户端JAR。如果你不额外配置,这个pip包自带的Hadoop客户端版本依然是3.3.4,照样会踩到前面说的兼容性问题。
我建议的组合是:Spark用without-hadoop发行包,PySpark用pip安装,然后让Python环境内的pyspark模块通过SPARK_HOME定位到/opt/spark-3.4.4。这样Spark的计算引擎用的是我们配置好的发行包,Python代码里的import pyspark又能正常工作。
bash复制pip install pyspark==3.4.4
5.2 环境变量组合与py4j路径的坑
PySpark的进程通信依赖py4j,如果PYTHONPATH没有指对,或者Spark发行包缺少py4j的zip包,会直接报找不到类或找不到模块的问题。
推荐在~/.bashrc里配置这样一组环境变量:
bash复制export SPARK_HOME=/opt/spark-3.4.4
export HADOOP_CONF_DIR=/opt/hadoop-3.1.3/etc/hadoop
export PYTHONPATH=$SPARK_HOME/python:$SPARK_HOME/python/lib/py4j-0.10.9.7-src.zip
export PYSPARK_PYTHON=python3
export PYSPARK_DRIVER_PYTHON=python3
export PATH=$PATH:$SPARK_HOME/bin:$SPARK_HOME/sbin
先说py4j那个路径。不同Spark小版本对应的py4j版本可能不一样,最稳妥的办法是列出来看:
bash复制ls /opt/spark-3.4.4/python/lib/
找到实际存在的py4j zip包名字,再填进PYTHONPATH。另外PYSPARK_PYTHON和PYSPARK_DRIVER_PYTHON这两个变量要指向同一个可用的Python解释器路径,否则driver和worker的Python版本不一致,会出现Python in worker has different version之类的问题。如果用的是虚拟环境,就把PYSPARK_PYTHON指向对应虚拟环境里的Python绝对路径。
5.3 HADOOP_CONF_DIR为什么必须指向Hadoop的etc/hadoop
HADOOP_CONF_DIR这个变量很多时候容易被忽略,但我认为它决定了PySpark能不能正确连上HDFS。因为PySpark在解析hdfs://路径时,需要读取Hadoop的配置来确定NameNode地址、端口、副本数等信息。
如果不设置HADOOP_CONF_DIR,Spark会退回到它自带的默认配置,一般默认端口是8020或者根本没有指向你的HDFS服务。结果就是你明明在core-site.xml里写了9000端口,PySpark却跑去连8020,然后报Failed to locate the namenode。
HADOOP_CONF_DIR指向的是Hadoop的配置目录,也就是包含core-site.xml和hdfs-site.xml的那个目录:
bash复制export HADOOP_CONF_DIR=/opt/hadoop-3.1.3/etc/hadoop
设置完之后,建议用一个小脚本快速验证环境是否齐整:
bash复制python3 -c "import pyspark; print(pyspark.__version__)"
能在当前Python环境里正常打印出3.4.4,说明PySpark库安装成功。如果提示找不到pyspark模块,大概率是Python环境没切换对,或者pip装到了别的解释器里。
6. 端到端实测:让PySpark真正读HDFS里的文件
6.1 上传测试文件与第一次读取的完整步骤
环境准备完毕后,我创建了一个真实的测试脚本,脚本的任务比较简单,就是从HDFS读取文本文件,然后统计每个单词出现的次数。
先确保HDFS里有测试数据文件。前面已经上传过hello.txt,我把它内容改丰富一点再传一次:
bash复制cat > /tmp/words.txt << 'EOF'
hadoop is a distributed storage system
spark is a fast computing engine
pyspark combines spark with python
EOF
/opt/hadoop-3.1.3/bin/hdfs dfs -put -f /tmp/words.txt /user/hduser/test/words.txt
然后写一个独立的Python脚本wc_hdfs.py:
python复制from pyspark.sql import SparkSession
spark = SparkSession.builder \
.appName("HDFSWordCount") \
.getOrCreate()
words_path = "hdfs://localhost:9000/user/hduser/test/words.txt"
lines = spark.sparkContext.textFile(words_path)
print("文件总行数:", lines.count())
word_counts = lines.flatMap(lambda line: line.split()) \
.map(lambda word: (word, 1)) \
.reduceByKey(lambda a, b: a + b)
for word, count in word_counts.collect():
print(f"{word}: {count}")
spark.stop()
执行方式有两种,都可以试试:
直接调用Spark的提交脚本:
bash复制/opt/spark-3.4.4/bin/spark-submit wc_hdfs.py
如果用python3 wc_hdfs.py直接运行,那就要确保前面说的环境变量都已经在当前终端中生效。日志末尾能看到统计结果,常见的输出就是每个单词和它的出现次数。关键点是,hdfs://localhost:9000的URI能正常解析,说明Spark确实通过Hadoop客户端连接上了NameNode。
6.2 读取hdfs://路径时的报错排查清单
端到端跑通之前,我在这台机器上至少撞见了四五种报错,这里整理成一张清单,方便你对照排查:
| 报错关键词 | 一般原因 | 处理办法 |
|---|---|---|
Server IPC version ... cannot communicate with client version ... |
Spark内置Hadoop客户端版本和HDFS不匹配 | 换without-hadoop包并配置hadoop classpath |
Failed to locate the namenode: [localhost:8020] |
fs.defaultFS端口不是9000,或HADOOP_CONF_DIR没生效 |
检查core-site.xml,确认HADOOP_CONF_DIR |
Connection refused on port 9000 |
NameNode没起来 | 看jps,启动start-dfs.sh |
File does not exist: hdfs://localhost:9000/... |
上传路径不对或文件名写错 | 用hdfs dfs -ls确认路径 |
Could not locate executable null/bin/winutils.exe |
这不是Linux下的问题,只有Windows环境会遇到 | Linux端忽略或检查环境变量是否误设 |
Python in worker has different version |
PYSPARK_PYTHON指向错误 |
统一driver和worker的Python解释器路径 |
6.3 如何判断任务真的走了HDFS
有些人跑完wordcount之后还是不确定数据到底从哪里来,因为同样的脚本如果数据放在本地文件系统也能跑。这里有三个验证点。
第一个,在Python脚本里显式写hdfs://localhost:9000开头的路径,如果数据是本地文件系统,这个URI本身就会报错;第二个,打开NameNode Web界面,在文件浏览功能里能看到/user/hduser/test/words.txt的访问时间和副本状态;第三个更直接,先修改HDFS里的words.txt内容,把某个单词删掉,再重新跑一次脚本,如果统计结果跟着变了,说明Spark读取的是HDFS里的最新数据,而不是缓存或本地副本。
我建议每次配置完环境都保留这个wordcount脚本,它既是环境健康检查,也是一个通用的冒烟测试。不管以后做什么复杂项目,先跑通这个最小闭环,环境有没有问题一目了然。
7. 从单机走向多节点:配置上必须同步改掉的地方
7.1 伪分布式与分布式集群的差异点
前面的配置全部是基于单机伪分布式的,也就是说HDFS和Spark都跑在同一台机器上。如果后面要扩展成真正意义上的多节点集群,配置上有几个地方必须同步调整。
首先是core-site.xml里的fs.defaultFS,不能再写localhost,要改成主节点的内网主机名或IP地址:
xml复制<property>
<name>fs.defaultFS</name>
<value>hdfs://namenode-host:9000</value>
</property>
hdfs-site.xml里的dfs.namenode.name.dir和dfs.datanode.data.dir也要按每台机器的角色分别配置。主节点放NameNode目录,数据节点放DataNode目录。副本数dfs.replication可以根据数据节点数量设置,如果只有3个节点,设为2或3都行,但不要超过节点总数。
Spark那边,spark-env.sh也需要增加:
bash复制export SPARK_MASTER_HOST=namenode-host
export SPARK_MASTER_PORT=7077
启动时用Spark的start-master.sh和start-worker.sh拉起主从进程,PySpark脚本里的master地址也要从默认的local改成spark://namenode-host:7077。
7.2 新增Worker节点需要复制什么过去
新增一台Spark Worker节点时,至少要把整个Spark发行包目录复制过去,并保证该节点有相同的JDK、Python版本、Hadoop配置文件。更准确地说,worker节点上必须有以下三样东西:Spark自身程序、一份指向HDFS的配置文件(可以通过HADOOP_CONF_DIR指向本机Hadoop安装目录)、Python解释器。
有人会问,worker节点要不要装完整Hadoop?如果只是为了访问HDFS,不要求在该节点上启动NameNode或DataNode,那么只需要安装一个Hadoop客户端即可,不需要启动任何HDFS守护进程。关键是hadoop classpath命令能在该节点上正常输出,且HADOOP_CONF_DIR指向的core-site.xml和hdfs-site.xml能连接回主节点。
还有一个多节点环境容易踩的坑:Spark会通过SPARK_MASTER_HOST连接主节点,而worker节点上的/etc/hosts如果没配好主机名解析,会导致worker注册不上。提前把所有节点的主机名和IP写进各自的/etc/hosts,能省掉一堆奇怪的通迅问题。
最后说一下个人体会。这套Hadoop 3.1.3加Spark 3.4.4的配置,说难不难,但如果你忽略“Spark内置Hadoop客户端版本”和“HADOOP_CONF_DIR”这两个关键点,会被各种莫名其妙的问题耗掉一个下午。只要把JAR版本对齐、配置文件指对、Python环境理顺,接下来的事情就顺理成章了。PySpark环境一旦通了,后面再折腾数据开发、任务调度、SQL读写都会轻松很多。
