ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spark on Yarn 文件分发机制详解:--files、--jars 与 SparkFiles 实战

Spark on Yarn 文件分发机制详解:--files、--jars 与 SparkFiles 实战

1. 搞懂 Spark on Yarn 的文件分发机制

1.1 为什么本地文件在集群上会“消失”

很多刚接触 Spark on Yarn 的朋友都踩过这个坑:本地开发时用spark-submit提交任务,代码里写了个new File("config.properties")或者getClass.getResourceAsStream("/data.json"),在 IDE 里跑得好好的,一提交到 Yarn 集群就报FileNotFoundException。这不是代码写错了,而是没搞明白 Spark 在集群模式下的运行模型。

Spark on Yarn 有两种部署模式:client和cluster。在client模式下,Driver 跑在提交任务的机器上,所以 Driver 端的本地文件路径还能访问到;但一旦切到cluster模式,Driver 被分配到集群某个 NodeManager 上启动,这时候你提交机器上的本地文件对 Driver 来说根本不存在。更关键的是,Executor 分散在不同节点上,它们各自的工作目录是独立的,默认情况下根本看不到你本地的任何文件。

所以核心问题就变成了:怎么把文件从提交端“带”到集群里,并让 Driver 和 Executor 都能按预期路径读到它。Spark 提供的答案就是--files、--jars、--archives这几个参数,配合SparkFiles机制来完成分发。

1.2 --files、--jars、--archives 到底有什么区别

这三个参数经常被混用,但它们的定位完全不同,选错了就会出现“文件传上去了却读不到”的尴尬。

参数分发对象是否加入 classpath典型用途
--files普通文件否配置文件、字典、模型文件、查找表
--jarsjar 包是(Driver 和 Executor)第三方依赖、UDF 依赖
--archives压缩包否(需手动处理)Python 虚拟环境、包含多文件的目录

--files是最常用的,它会把文件分发到每个 Executor 的工作目录,同时也会分发到 Driver 的工作目录。注意这里说的是“工作目录”,不是 classpath,所以你不能用getResourceAsStream直接读,得用SparkFiles.get()拿到实际路径。

--jars会把 jar 加入 classpath,适合放依赖库。--archives通常用来分发.zip、.tar.gz这类打包好的目录,比如 Python 环境或者一整个资源文件夹,分发后需要自己解压或者通过SparkFiles.get定位。

提示:--files分发的文件默认不会解压,也不会自动加入 classpath。如果你想让配置文件能被getResourceAsStream读到,需要额外处理,后面会讲。

1.3 SparkFiles 的工作原理

SparkFiles是org.apache.spark.SparkFiles这个类,它提供了两个关键方法:get(filename)返回文件在本地磁盘的绝对路径,getRootDirectory()返回分发文件的根目录。它的底层逻辑是:Spark 在 Driver 端把文件放到一个临时目录,然后通过 Yarn 的本地资源分发机制(LocalResource)把文件传到每个 Executor 的本地目录,Executor 启动时会把SparkFiles的根目录设置好。

这里有个容易忽略的点:SparkFiles.get()只能在 Driver 端和 Executor 端调用,不能在提交端的 shell 里调用。而且它返回的路径是每个节点上的本地路径,不同节点的路径可能不一样,所以你不能把这个路径硬编码到配置里,必须在代码运行时动态获取。

另外,--files支持逗号分隔的多个文件,也支持指定别名,比如--files local.txt#remote.txt,这样分发后文件名就变成了remote.txt,SparkFiles.get("remote.txt")就能拿到。这个别名机制在文件名冲突或者想统一命名时非常有用。

2. 上传文件并读取的完整实操

2.1 准备测试文件和提交脚本

先造一个简单的场景:一个 CSV 配置文件,里面存了一些阈值参数,Driver 要读它来决定任务逻辑,Executor 也要读它来做数据过滤。文件内容如下:

key,value threshold,100 batch_size,500 mode,strict

保存为app.conf。然后写一个简单的 PySpark 脚本test_files.py:

from pyspark import SparkContext, SparkFiles from pyspark.sql import SparkSession import os spark = SparkSession.builder.appName("FileDistributionTest").getOrCreate() sc = spark.sparkContext # Driver 端读取 driver_path = SparkFiles.get("app.conf") print("Driver file path:", driver_path) with open(driver_path, "r") as f: print("Driver content:", f.read()) # Executor 端读取 def read_conf(partition): path = SparkFiles.get("app.conf") with open(path, "r") as f: content = f.read() return [(os.uname()[1], content)] rdd = sc.parallelize(range(4), 4) result = rdd.mapPartitions(read_conf).collect() for host, content in result: print(f"Executor on {host} read: {content[:30]}...") spark.stop()

提交命令:

spark-submit \ --master yarn \ --deploy-mode cluster \ --files app.conf \ test_files.py

注意这里用了cluster模式,最能暴露文件分发的问题。如果你用client模式,Driver 端读的是提交机器的本地文件,可能掩盖问题。

2.2 验证文件是否真的分发成功

提交后,在 Yarn 的 ApplicationMaster 日志里能看到 Driver 打印的路径,类似/tmp/spark-xxxx/userFiles-xxxx/app.conf。这个路径是 Driver 所在节点的本地路径。Executor 的日志里也会打印各自的路径,通常也是/tmp/spark-xxxx/...这种形式。

如果你发现 Executor 报FileNotFoundException,先检查三件事:第一,--files参数有没有写对,文件名是否和代码里SparkFiles.get()的参数一致;第二,提交命令里--files的路径是相对于提交机器的,确保文件存在;第三,如果用了别名,代码里要用别名而不是原文件名。

实测下来,--files对相对路径和绝对路径都支持,但推荐用绝对路径,避免因为工作目录变化导致找不到文件。另外,如果文件很大(比如几百 MB),分发会拖慢任务启动,这时候要考虑用分布式文件系统(如 HDFS)而不是--files。

2.3 在 Executor 中读取文件的正确姿势

Executor 端读文件有个坑:不能用getClass.getResourceAsStream,因为--files分发的文件不在 classpath 里。必须用SparkFiles.get()拿到绝对路径,然后用标准的文件 IO 读。

如果你用的是 Scala 或 Java,代码类似:

val path = SparkFiles.get("app.conf") val source = scala.io.Source.fromFile(path) val content = source.mkString source.close()

Python 里就是前面示例的写法。注意SparkFiles.get()在 Executor 端调用时,返回的是该 Executor 所在节点的本地路径,所以每个 Executor 拿到的路径可能不同,但都能正确读到文件。

还有一个细节:--files分发的文件在每个 Executor 上只有一份,多个 task 共享。如果你的文件很大,多个 task 同时读会有 IO 竞争,这时候可以考虑用广播变量(Broadcast)把文件内容广播出去,而不是每个 task 都读磁盘。广播变量适合小文件(比如几 MB 以内),大文件还是走--files或者 HDFS。

3. 进阶场景:配置文件、模型文件和 Python 环境

3.1 分发配置文件并支持动态更新

实际项目里,配置文件经常需要根据环境切换。比如开发环境用dev.conf,生产环境用prod.conf。这时候可以用--files的别名机制:

spark-submit \ --master yarn \ --deploy-mode cluster \ --files /path/to/prod.conf#app.conf \ test_files.py

这样代码里统一用SparkFiles.get("app.conf"),不用改代码就能切换配置。如果配置文件需要频繁更新,每次提交都要重新分发,这时候可以考虑把配置放到 HDFS 或者配置中心,程序启动时拉取,而不是用--files。

另一个技巧:如果配置文件里有敏感信息(比如密码),不要用--files,因为文件会落到每个节点的本地磁盘,可能被其他用户读到。这种情况应该用环境变量或者密钥管理服务。

3.2 分发模型文件并在 Executor 中加载

机器学习场景经常需要分发模型文件(比如.pkl、.h5、.pt)。这些文件通常比较大,用--files分发时要注意几点:第一,文件大小超过几百 MB 时,分发时间会显著增加,建议放到 HDFS 或对象存储,程序里直接读;第二,如果模型文件需要被多个 Executor 加载,每个 Executor 都会从 Driver 拉取一份,网络开销不小;第三,模型加载通常比较耗时,建议用mapPartitions而不是map,这样每个 partition 只加载一次模型,而不是每条记录加载一次。

示例代码:

def predict(partition): import pickle model_path = SparkFiles.get("model.pkl") with open(model_path, "rb") as f: model = pickle.load(f) for row in partition: yield model.predict(row) rdd.mapPartitions(predict)

这里用mapPartitions是关键,很多新手用map导致模型被反复加载,性能差几十倍。

3.3 用 --archives 分发 Python 虚拟环境

PySpark 任务经常依赖第三方库,如果集群节点上没有这些库,可以用--archives分发一个打包好的 Python 环境。步骤是:本地用virtualenv或conda创建环境,打包成.tar.gz,然后提交时指定:

spark-submit \ --master yarn \ --deploy-mode cluster \ --archives /path/to/env.tar.gz#env \ --conf spark.pyspark.python=./env/bin/python \ test_files.py

注意#env是别名,解压后目录名就是env。spark.pyspark.python指定 Executor 使用的 Python 路径,这里用相对路径./env/bin/python,因为--archives解压后的目录就在工作目录下。

这个方案有个坑:打包的 Python 环境必须和集群节点的操作系统兼容,比如都是 Linux x86_64,否则会出现二进制不兼容。另外,环境包不要太大,否则分发慢。实测下来,一个包含 pandas、numpy、scikit-learn 的环境大概 200-300 MB,分发时间在可接受范围内。

4. 常见问题排查与避坑指南

4.1 文件找不到的几种典型情况

现象可能原因解决方法
Driver 报 FileNotFoundException--files没写或路径错误检查提交命令,用绝对路径
Executor 报 FileNotFoundException代码里用了本地路径而非 SparkFiles.get改用 SparkFiles.get
文件名不对用了别名但代码里用原文件名统一别名和代码参数
文件内容为空分发的是空文件或路径指向目录检查源文件
权限拒绝文件权限不对确保文件可读

最常见的是第二种:Driver 端能读到,Executor 端读不到。这是因为 Driver 端可能用了getResourceAsStream或者相对路径,而 Executor 端必须用SparkFiles.get。记住一个原则:只要文件是通过--files分发的,读取时一律用SparkFiles.get。

4.2 大文件分发的性能问题

--files的分发机制是:Driver 从提交端拿到文件,然后每个 Executor 从 Driver 拉取。如果文件是 1 GB,有 100 个 Executor,那就是 100 GB 的网络传输,非常慢。这时候应该把文件放到 HDFS 或 S3,程序里直接读分布式文件系统,而不是用--files。

判断标准:文件小于 10 MB,用--files没问题;10-100 MB,可以考虑,但要注意 Executor 数量;超过 100 MB,强烈建议用分布式存储。另外,如果文件是只读的,可以放到 HDFS 并设置合适的副本数,这样每个节点都能本地读,避免网络传输。

4.3 文件路径在不同模式下的差异

client模式和cluster模式下,SparkFiles.get返回的路径都是本地路径,但 Driver 的位置不同。client模式下 Driver 在提交机器上,SparkFiles.get返回的是提交机器上的临时目录;cluster模式下 Driver 在集群节点上,返回的是那个节点的临时目录。所以如果你在 Driver 端用SparkFiles.get读文件,两种模式都能工作,但路径不同。

还有一个容易忽略的点:--files分发的文件在任务结束后会被清理吗?答案是会的,Spark 会在 Application 结束时清理临时目录。所以不要指望任务结束后还能在节点上找到这些文件。如果需要持久化,得自己写到 HDFS 或对象存储。

4.4 实操心得与避坑清单

  • 始终用SparkFiles.get读--files分发的文件,不要用相对路径或getResourceAsStream。
  • 提交命令里的--files路径用绝对路径,避免工作目录变化导致找不到文件。
  • 文件名冲突时用别名,比如--files a.conf#app.conf,代码里统一用app.conf。
  • 大文件不要用--files,改用 HDFS 或对象存储。
  • 模型加载用mapPartitions,避免每条记录重复加载。
  • Python 环境用--archives,注意操作系统兼容性。
  • 敏感信息不要用--files,改用环境变量或密钥管理。
  • 测试时先用cluster模式,能暴露更多问题。

踩过几次坑之后,我总结了一个简单的判断流程:先问文件多大,小于 10 MB 用--files;再问是否所有节点都要读,是的话用--files或 HDFS;最后问是否包含敏感信息,是的话换方案。按这个流程走,基本不会出大问题。

这个内容后续还可以这样扩展:如果你用的是 Spark on Kubernetes,文件分发的机制类似但细节不同,--files会通过 ConfigMap 或 EmptyDir 挂载,读取方式还是SparkFiles.get。另外,Spark 3.x 对--files的支持更完善,支持通配符和目录分发,值得升级后试试。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进