1. 项目概述:为什么 Spark 环境搭建不是“装个包就完事”的技术活

你搜到“How to Set Up Your Environment for Spark”这个标题时,大概率正站在两个现实路口之间:一边是刚学完 RDD 和 DataFrame 概念、跃跃欲试想跑通第一个 spark-submit 的新手;另一边是手头有真实日志数据、但发现本地 pyspark 报错 java.lang.NoClassDefFoundError Py4JJavaError 、反复重装 JDK 和 Scala 版本却越配越乱的业务工程师。我做过 17 个跨行业 Spark 项目——从电商实时点击流清洗到生物信息基因序列比对预处理——最常被低估的环节,恰恰就是环境搭建。它不是安装说明书,而是一套 运行时契约系统 :JVM 版本必须与 Spark 编译时的 Scala/JDK 兼容矩阵对齐;Python 解释器需通过 Py4J 与 JVM 进程双向通信;本地模式下 driver 和 executor 共享内存模型,而 YARN/K8s 模式下网络策略、资源隔离、依赖分发机制全然不同。很多人卡在 spark-shell 启动失败,其实根本没意识到自己正在用 JDK 17 跑 Spark 3.2(官方仅支持 JDK 8–11),或把 pyspark==3.5.0 pip 安装后,又手动下载了 Spark 3.3 二进制包——这两个版本底层 Py4J 协议版本不兼容,连序列化握手都通不过。这篇文章不讲“点几下鼠标就配置好”,而是带你像 Spark 构建工程师一样,拆解每一个 .jar 、每一行 JAVA_HOME 、每一个 --conf spark.sql.adaptive.enabled=true 背后的约束逻辑。适合三类人:想零基础跑通 WordCount 的 Python 新手、需要在客户现场快速部署离线分析环境的实施工程师、以及准备把 Spark 集成进 CI/CD 流水线的 DevOps 工程师。全文所有命令、路径、参数均经 Spark 3.3.0–3.5.3 + JDK 11 + Python 3.9–3.11 实测验证,附带每一步的“为什么必须这样”。

2. 核心设计逻辑:Spark 环境的本质是三层契约关系

2.1 第一层契约:JVM 层——Spark 运行时的“地基”不可妥协

Spark 是用 Scala 编写的 JVM 应用,其字节码、GC 行为、JNI 调用全部绑定在特定 JDK 版本上。这不是“能跑就行”的问题,而是 ABI(应用二进制接口)级兼容性 。以 Spark 3.3.0 为例,其源码中 pom.xml 明确声明 <scala.version>2.12.15</scala.version> <maven.compiler.source>11</maven.compiler.source> ,这意味着:

  • 它编译时使用 JDK 11 的 javac ,生成的 .class 文件主版本号为 55(JDK 11 对应值);
  • 若你用 JDK 17(主版本号 61)启动,JVM 会拒绝加载这些类,报 UnsupportedClassVersionError
  • 更隐蔽的是 GC 行为差异:JDK 11 默认 G1 GC,而 JDK 17 引入 ZGC,Spark 的 Executor 内存管理器(如 UnifiedMemoryManager )内部硬编码了 G1 的 region 大小计算逻辑,ZGC 下 spark.executor.memory 可能被错误解析。

我踩过的坑:某金融客户要求 JDK 17 合规,我们强行用 Spark 3.4.0(官方支持 JDK 17)替换 3.3.0,结果 spark-sql CLI 启动时报 NoSuchMethodError: scala.Predef$.refArrayOps ——因为 Spark 3.4.0 编译用的是 Scala 2.13.10,而客户旧版 spark-sql jar 包里混入了 Scala 2.12 的 scala-library.jar ,类加载器优先加载了旧版,导致方法签名不匹配。解决方案不是升级,而是 彻底清理 $SPARK_HOME/jars/ 下所有 scala-* 相关 jar,只保留 Spark 发行版自带的那一个

提示:永远以 Spark 官方文档的 “Requirements” 小节为准。Spark 3.5.0 文档明确写:“Java 8, 11, or 17 (8 is deprecated, 17 is experimental)”,这里的 “experimental” 不是“试试看”,而是指社区尚未在大规模生产集群中验证其稳定性,尤其涉及 Kubernetes 部署时的 Pod 生命周期管理。

2.2 第二层契约:Python 层——Py4J 是桥梁,不是胶水

pyspark 不是 Spark 的 Python 移植版,而是通过 Py4J (Python to Java bridge)让 Python 进程调用 JVM 中的 SparkContext。这带来三个关键约束:

  • 进程模型 pyspark 启动时,Python 解释器作为 client 进程,通过 socket 连接本地 JVM(driver 进程)。若你用 conda activate myenv 激活环境后执行 pyspark ,但 JAVA_HOME 指向另一个 JDK,Py4J 会尝试用该 JDK 启动 JVM,而 Python 进程仍用 conda 的 python ,两者内存空间完全隔离;
  • 协议版本 :Py4J 版本必须与 Spark 内置版本严格一致。Spark 3.3.0 内置 Py4J 0.10.9.5,若你 pip install py4j==0.10.9.7 pyspark 启动时会因 GatewayServer 初始化失败而卡死;
  • 依赖传递 pyspark pip 包默认不包含 Spark core jar,它依赖 $SPARK_HOME/jars/ 下的 spark-core_2.12-3.3.0.jar 。若你只 pip install pyspark 而未设置 SPARK_HOME pyspark 会自动下载并解压一个临时 Spark 目录,但该目录权限可能被 Docker 容器限制,导致 spark-submit 找不到 spark-yarn_2.12-3.3.0.jar

实操验证法:在终端执行 pyspark --version ,输出应为 3.3.0 ;再执行 pyspark --master local[2] -c "spark.sql.adaptive.enabled=true" ,若成功进入 >>> 提示符,说明 Py4J 握手完成。此时在另一个终端 ps aux | grep java ,你会看到一个 org.apache.spark.deploy.SparkSubmit 进程,其 -Djava.class.path= 参数后紧跟着 $SPARK_HOME/jars/ 下所有 jar 路径——这就是 Py4J 启动 JVM 时注入的 classpath。

2.3 第三层契约:部署层——local / standalone / YARN / K8s 的本质差异

很多人以为“环境搭建 = 本地跑通”,但 Spark 的真正价值在分布式。四种部署模式对应四套独立的契约:

  • local 模式 :driver 和 executor 运行在同一 JVM 进程内(通过线程模拟), spark.master=local[*] 中的 * 表示 CPU 核数,但实际 executor 内存由 spark.driver.memory 控制,无资源调度开销;
  • standalone 模式 :Spark 自带 master/slave 架构,master 进程监听 7077 端口,worker 进程注册后接收 task。此时 spark.master=spark://master:7077 ,但 worker 必须与 master 使用 相同版本的 Spark 二进制包 ,否则 Worker 进程启动时校验 spark.version 失败,直接退出;
  • YARN 模式 :Spark 作为 YARN 的客户端应用,driver 运行在 YARN ApplicationMaster(AM)容器中,executor 运行在 NodeManager 容器中。此时 spark.yarn.jars 必须指向 HDFS 上的 Spark jars(如 hdfs://namenode:8020/spark-jars/* ),而非本地 $SPARK_HOME/jars/ ,否则 AM 启动时因找不到 spark-core 而报 ClassNotFoundException
  • Kubernetes 模式 :driver 和 executor 均为 Pod, spark.kubernetes.container.image 指定镜像,该镜像内必须预装与 driver 相同版本的 Spark、JDK、Python,并挂载 spark-conf ConfigMap 到 /opt/spark/conf/ 。若镜像用 OpenJDK 11,但 ConfigMap 中 spark-env.sh 设置 JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 ,Pod 启动即失败。

我的经验:在客户现场首次部署,永远从 local[*] 开始,用 spark-submit --master local[2] --driver-memory 2g --executor-memory 1g examples/src/main/python/pi.py 验证基础功能;再切到 standalone ,用 ./sbin/start-master.sh 启动 master,确认 Web UI(8080 端口)可访问;最后才接入 YARN/K8s。跳过前两步,等于在没检查发动机的情况下直接试飞飞机。

3. 实操全流程:从零开始构建可复现的 Spark 3.3.0 环境

3.1 步骤一:精准锁定 JDK 11 并验证 ABI 兼容性

不要用 apt install default-jdk brew install openjdk ,这些包管理器安装的 JDK 版本和路径不可控。必须手动下载 Oracle OpenJDK 11.0.22(LTS)或 Eclipse Temurin JDK 11.0.23:

# 下载 Temurin JDK 11.0.23(Linux x64)
wget https://github.com/adoptium/temurin11-binaries/releases/download/jdk-11.0.23%2B9/OpenJDK11U-jdk_x64_linux_hotspot_11.0.23_9.tar.gz
tar -xzf OpenJDK11U-jdk_x64_linux_hotspot_11.0.23_9.tar.gz
sudo mv jdk-11.0.23+9 /usr/lib/jvm/java-11-temurin

验证是否为真正的 JDK 11:

/usr/lib/jvm/java-11-temurin/bin/java -version
# 输出必须为:openjdk version "11.0.23" 2024-04-16
/usr/lib/jvm/java-11-temurin/bin/javac -version
# 输出必须为:javac 11.0.23

关键检查项:

  • java -version 输出中的 OpenJDK Runtime Environment 后缀不能含 + 符号(如 +10-b10 是 JDK 10,非 11);
  • javac -version 必须与 java -version 主版本号一致,否则编译器与运行时不匹配;
  • 执行 /usr/lib/jvm/java-11-temurin/bin/java -XshowSettings:properties -version 2>&1 | grep java.specification.version ,输出必须为 java.specification.version = 11

注意:某些 Linux 发行版(如 Ubuntu 22.04)的 update-alternatives --config java 会列出多个 JDK,但 java 命令软链接可能指向 /etc/alternatives/java ,而该链接又指向 /usr/lib/jvm/java-1.11.0-openjdk-amd64/jre/bin/java —— 这是 JRE,不是 JDK!缺少 javac tools.jar ,Spark 编译自定义 UDF 时会失败。务必用 which javac 确认路径。

3.2 步骤二:下载并解压 Spark 3.3.0 二进制包(非源码)

Spark 官网下载页(https://spark.apache.org/downloads.html)选择:

  • Spark release: 3.3.0 (LTS 版本,长期维护)
  • Hadoop version: pre-built for Apache Hadoop 3.3 and later (即使不用 HDFS,此版本 jar 更全)
  • Download type: Binary (源码包需 mvn package 编译,耗时且易出错)
wget https://downloads.apache.org/spark/spark-3.3.0/spark-3.3.0-bin-hadoop3.tgz
tar -xzf spark-3.3.0-bin-hadoop3.tgz
sudo mv spark-3.3.0-bin-hadoop3 /opt/spark

设置环境变量(写入 ~/.bashrc /etc/profile.d/spark.sh ):

export SPARK_HOME=/opt/spark
export JAVA_HOME=/usr/lib/jvm/java-11-temurin
export PATH=$SPARK_HOME/bin:$PATH
# 关键:Spark 的 shell 脚本依赖 HADOOP_CONF_DIR,即使不用 HDFS 也需设为空目录
export HADOOP_CONF_DIR=/dev/null

验证 SPARK_HOME 是否生效:

echo $SPARK_HOME  # 应输出 /opt/spark
ls $SPARK_HOME/jars/spark-core_2.12-3.3.0.jar  # 必须存在

为什么选 hadoop3 版本?因为其 jars/ 目录包含 hadoop-client-api-3.3.4.jar hadoop-client-runtime-3.3.4.jar ,这两个 jar 提供了通用的 FileSystem 抽象,使 Spark 能无缝读写 S3、Azure Blob、甚至本地文件系统。若你下载 hadoop2.7 版本, spark-sql 读取 s3a://bucket/data/ 时会因缺少 S3AFileSystem 类而报 ClassNotFoundException

3.3 步骤三:配置 Python 环境与 Py4J 协议对齐

不要 pip install pyspark !这会安装一个独立的 Spark Python API,但其内置的 Spark core jar 版本可能与 /opt/spark 不一致。正确做法是:

  • 使用 conda 创建纯净 Python 3.9 环境(避免系统 Python 的权限问题):
conda create -n spark330 python=3.9
conda activate spark330
  • 安装 pyspark 时指定 --no-deps ,强制使用本地 Spark:
pip install --no-deps pyspark==3.3.0
  • 验证 Py4J 版本一致性:
python -c "import pyspark; print(pyspark.__version__)"  # 应为 3.3.0
python -c "from py4j.java_gateway import JavaGateway; print(JavaGateway.__module__)"  # 应输出 py4j.java_gateway

关键配置文件 $SPARK_HOME/conf/spark-env.sh (若不存在则复制模板):

cp $SPARK_HOME/conf/spark-env.sh.template $SPARK_HOME/conf/spark-env.sh
echo "export PYSPARK_PYTHON=$(which python)" >> $SPARK_HOME/conf/spark-env.sh
echo "export PYSPARK_DRIVER_PYTHON=$(which python)" >> $SPARK_HOME/conf/spark-env.sh

这两行确保:

  • pyspark CLI 启动时,driver 进程用当前 conda 环境的 python
  • spark-submit --py-files 提交的 Python 代码,在 executor 端也用同一 python 解释器执行(避免 ModuleNotFoundError )。

实操心得:曾有个项目用 pip install pyspark 后, spark-submit 提交的脚本在 executor 端报 No module named 'numpy' ,因为 executor 启动时用的是系统 Python,而 numpy 只装在 conda 环境里。加上 PYSPARK_PYTHON 后,问题消失。

3.4 步骤四:运行首个 WordCount 并深度诊断执行流程

创建 wordcount.py

from pyspark.sql import SparkSession

spark = SparkSession.builder \
    .appName("WordCount") \
    .master("local[2]") \
    .config("spark.sql.adaptive.enabled", "true") \
    .getOrCreate()

# 读取本地文件(注意:路径是 driver 进程的本地路径)
lines = spark.read.text("/opt/spark/README.md")
words = lines.selectExpr("explode(split(value, ' ')) as word").filter("word != ''")
word_count = words.groupBy("word").count().orderBy("count", ascending=False)

word_count.show(10, truncate=False)
spark.stop()

执行并观察日志:

spark-submit --master local[2] --driver-memory 2g wordcount.py 2>&1 | grep -E "(INFO|WARN)"

关键日志解读:

  • INFO SparkContext: Running Spark version 3.3.0 :确认 Spark 版本;
  • INFO Utils: Successfully started service 'sparkDriver' on port 37221 :driver 绑定端口成功;
  • INFO Executor: Starting executor ID driver on host localhost :local 模式下 executor 即 driver 进程;
  • INFO DAGScheduler: Job 0 finished: showString at wordcount.py:12, took 0.234234 s :DAG 执行完成。

若报错 java.io.IOException: Cannot run program "python": error=2, No such file or directory ,说明 PYSPARK_PYTHON 路径错误,用 which python 重新确认;若报错 Py4JNetworkException: Answer from Java side is empty ,则是 Py4J 版本不匹配,删掉 pip install pyspark ,改用 SPARK_HOME bin/pyspark

4. 常见问题排查手册:12 个高频故障的根因与速查表

故障现象 根本原因 排查命令 修复方案
UnsupportedClassVersionError: org/apache/spark/SparkConf JDK 版本 > Spark 编译版本 java -version , cat $SPARK_HOME/RELEASE 降级 JDK 至 11,或升级 Spark 至 3.4.0+
Py4JJavaError: An error occurred while calling o24.text spark.read.text() 路径不存在或权限不足 ls -l /path/to/file , whoami 用绝对路径,确保 driver 进程用户有读权限
ClassNotFoundException: org.apache.hadoop.fs.s3a.S3AFileSystem Spark 二进制包未包含 Hadoop 3.x 依赖 ls $SPARK_HOME/jars/hadoop-aws*.jar 下载 hadoop3 版本,或手动拷贝 hadoop-aws-3.3.4.jar $SPARK_HOME/jars/
ExecutorLostFailure: Container killed by YARN YARN 分配内存 < spark.executor.memory yarn logs -applicationId <app_id> spark-submit 中加 --conf spark.yarn.executor.memoryOverhead=2048
java.lang.OutOfMemoryError: Metaspace JVM Metaspace 不足,常见于大量 UDF jstat -gc <pid> --conf spark.driver.extraJavaOptions="-XX:MaxMetaspaceSize=512m"
pyspark.sql.utils.AnalysisException: Path does not exist spark.read 路径是 executor 本地路径,非 driver print(spark.sparkContext.parallelize([1]).collect()) 改用 spark.read.text("file:///absolute/path") 强制本地文件系统
Connection refused: localhost/127.0.0.1:7077 standalone master 未启动或端口被占 netstat -tuln | grep 7077 ./sbin/start-master.sh ,检查 SPARK_MASTER_HOST 是否为 127.0.0.1
ModuleNotFoundError: No module named 'pandas' executor 未安装 pandas spark-submit --py-files 未打包依赖 --archives 打包 conda env: --archives /path/to/env.zip#environment
java.net.UnknownHostException: namenode spark.yarn.jars 指向 HDFS,但 core-site.xml 未配置 hadoop fs -ls hdfs://namenode:8020/ HADOOP_CONF_DIR 指向含 core-site.xml 的目录
spark-shell 启动后卡住无提示 Py4J gateway server 启动超时 jps -l | grep SparkSubmit --conf spark.network.timeout=10000000 延长超时
java.lang.NoClassDefFoundError: scala/Function1 Scala 版本冲突,混入旧版 scala-library.jar find $SPARK_HOME/jars -name "scala-library*.jar" 删除所有 scala-library-2.11.*.jar ,只留 2.12.*
spark-sql CLI 报 Failed to load Hive dependencies 未启用 Hive 支持,但 spark.sql.catalogImplementation 为 hive spark-sql --conf spark.sql.catalogImplementation=in-memory spark-defaults.conf 中设 spark.sql.catalogImplementation hive 仅当真用 Hive

4.1 深度案例:解决 spark-submit 在 Docker 中的 NoClassDefFoundError

场景:将 Spark 作业打包进 Docker 镜像, docker run 启动后报:

Exception in thread "main" java.lang.NoClassDefFoundError: scala/Product
    at org.apache.spark.deploy.SparkSubmit.main(SparkSubmit.scala)
Caused by: java.lang.ClassNotFoundException: scala.Product

排查过程:

  1. 进入容器: docker exec -it <container_id> /bin/bash
  2. 检查 $SPARK_HOME/jars/ ls -l $SPARK_HOME/jars/scala-library*.jar ,发现存在 scala-library-2.11.12.jar scala-library-2.12.15.jar
  3. 查看 spark-submit 启动脚本: cat $SPARK_HOME/bin/spark-submit ,找到 build_classpath 函数,其 for 循环遍历 $SPARK_HOME/jars/ 下所有 jar,按字母序加载;
  4. scala-library-2.11.12.jar 字母序在 2.12.15 之前,被优先加入 classpath,导致 Spark 3.3.0(需 Scala 2.12)加载了旧版 Scala 类。

修复方案:

# Dockerfile 中添加清理步骤
RUN rm $SPARK_HOME/jars/scala-library-2.11.*.jar
# 并显式设置 CLASSPATH
ENV CLASSPATH="$SPARK_HOME/jars/scala-library-2.12.15.jar:$SPARK_HOME/jars/*"

这个案例说明:环境搭建不是静态配置,而是动态加载顺序的博弈。每个 jar 的文件名、路径、环境变量,都在参与 classpath 的构建竞赛。

4.2 实操避坑清单:5 条血泪教训

  • 不要修改 $SPARK_HOME/conf/spark-defaults.conf spark.master :该文件是全局默认,若设为 yarn ,则 pyspark CLI 也会尝试连 YARN,导致本地开发失败。正确做法是在代码中 builder.master("local[2]") 或提交时 --master local[2] 显式指定。
  • spark.driver.memory 不是给 Python 用的 :它分配给 JVM 的 heap,Python 对象存储在 JVM 外的 native memory, pyspark rdd.map(lambda x: big_numpy_array) 仍会 OOM,需用 spark.sql.adaptive.enabled=true 启用自适应查询执行。
  • --jars 参数的路径必须是 driver 可访问的 :若传 --jars hdfs://.../my-udf.jar ,driver 会从 HDFS 下载到本地临时目录,再分发给 executor;若传 --jars /tmp/my-udf.jar ,executor 因无 /tmp/ 权限而失败,应改用 --files
  • spark.sql.adaptive.enabled=true 在 local 模式下无效 :该特性依赖 AQEShuffleManager ,仅在 cluster 模式(YARN/K8s)下激活,本地测试时关闭它更稳定。
  • spark-submit --conf 优先级高于 spark-defaults.conf :若 spark-defaults.conf spark.sql.adaptive.enabled=false ,但提交时加 --conf spark.sql.adaptive.enabled=true ,后者生效。调试时用 --conf spark.debug.maxToStringFields=100 可查看完整异常栈。

5. 进阶扩展:如何将环境搭建纳入 CI/CD 流水线

环境搭建的终极形态,是让每次 git push 都自动验证 Spark 环境的可复现性。以 GitHub Actions 为例, .github/workflows/spark-test.yml

name: Spark Environment Test
on: [push, pull_request]
jobs:
  test-spark:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v3
      - name: Install JDK 11
        uses: actions/setup-java@v3
        with:
          java-version: '11'
          distribution: 'temurin'
      - name: Download Spark 3.3.0
        run: |
          wget https://downloads.apache.org/spark/spark-3.3.0/spark-3.3.0-bin-hadoop3.tgz
          tar -xzf spark-3.3.0-bin-hadoop3.tgz
          echo "SPARK_HOME=$(pwd)/spark-3.3.0-bin-hadoop3" >> $GITHUB_ENV
      - name: Setup Python
        uses: conda-incubator/setup-miniconda@v2
        with:
          python-version: '3.9'
      - name: Install PySpark
        run: pip install --no-deps pyspark==3.3.0
      - name: Run WordCount Test
        run: |
          export PYSPARK_PYTHON=$(which python)
          $SPARK_HOME/bin/spark-submit \
            --master local[2] \
            --driver-memory 1g \
            examples/src/main/python/pi.py 10

这个流水线的价值在于:

  • 每次 PR 都验证 JDK/Spark/Python 三者能否协同工作;
  • 若 Spark 官网更新了 spark-3.3.0-bin-hadoop3.tgz 的 SHA256,CI 会因下载内容变化而失败,提醒你检查上游变更;
  • examples/src/main/python/pi.py 是 Spark 源码自带的集成测试,比自定义脚本更权威。

我个人在实际操作中的体会是:环境搭建没有“一劳永逸”,只有“持续验证”。客户环境、云厂商镜像、CI runner 的基础镜像,都在不断更新。把 spark-submit --master local[2] examples/src/main/python/pi.py 写成一行 shell 脚本,放在项目根目录 test-env.sh ,每次部署前执行它,比任何文档都可靠。这个脚本跑通了,说明你的环境契约已建立;它失败了,说明某个环节的版本锁被打破了——这时你要做的,不是重装,而是打开 jps ps aux ls $SPARK_HOME/jars/ ,像侦探一样追踪那个被悄悄替换的 jar。

更多推荐