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 | 普通文件 | 否 | 配置文件、字典、模型文件、查找表 |
--jars | jar 包 | 是(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的支持更完善,支持通配符和目录分发,值得升级后试试。