很多人第一次上手 Spark 的时候,习惯直接从官网把二进制包下载下来,解压、启动本地模式,跑通一个 WordCount 就以为万事大吉。可真到了生产环境,尤其是要把作业稳定地提交到 Hadoop 集群上,让 YARN 来统一分配资源,中间需要补的课远比想象中多。这篇文章不是 Spark API 教程,也不是性能调优大全,而是解决最实在的一个问题:怎样从零开始,把 Spark 配置成能够顺畅跑在 YARN 上的状态。适合刚接触 Spark、准备把作业从本地或 Standalone 模式迁到 YARN 的读者,也适合那些配置完一直报错、搞不清头和尾的同行。
我接下来会按照实际部署路径来讲:先说清为什么选 YARN,再讲环境准备、Spark 侧配置、YARN 侧配置、spark-submit 提交,最后把常见问题排查思路和我的配置心得放出来。整个流程我默认你至少有一个可用的 Hadoop 集群,版本基于 Hadoop 3.x + Spark 3.5 这条主流组合,如果你的版本不同,我会在相应位置标出需要注意的差异。
1. 为什么非要把 Spark 交给 YARN:不只是多租户那么简单
1.1 Spark Standalone 模式与 YARN 模式的核心差别
很多人最初用的 Spark Standalone 模式,逻辑上最简单:Spark 自己起 Master 和 Worker,自己管理资源,作业提交后由 Driver 向 Master 申请 Executor。这套模式适合小集群、开发环境、或者不想依赖 Hadoop 的场景,地头蛇一样的自管自。但问题也很明显:它跟 Hadoop 生态里别的组件(比如 Hive、MapReduce、Flink)是各管各的资源池,集群总资源被硬切成好几块的话,空闲浪费,繁忙排队,Idle 和 Busy 永远没法互相填补。
YARN 模式则不然。Spark 在 YARN 里只是一个 ApplicationMaster,向 YARN 的 ResourceManager 申请容器,由 NodeManager 在物理节点上拉起 Executor 进程。资源配额、队列、优先级、抢占、隔离都由 YARN 统一掌控。这样 Spark 任务和同一集群里的其他计算引擎共享同一份资源池,白天跑 Spark SQL,晚上跑离线 MapReduce,凌晨还能跑 Flink 流任务,互相不打架。对于大多数公司来说,Hadoop 集群本来就重资源投入,能让一套集群承载多种计算负载,远比多集群维护省钱省心。
1.2 YARN 模式下资源隔离和任务调度的实际收益
YARN 的资源隔离分为内存隔离和 CPU 隔离。内存隔离是硬隔离,NodeManager 会监控每个容器的物理内存,一旦超出申请阈值就强制杀死进程,避免一个坏任务拖垮整台机器。CPU 隔离则更多依赖内核调度,通过 cgroup 限制核心数,阻止某个任务把节点 CPU 全部占满。这些机制在 Standalone 模式下要么没有,要么实现得很弱,生产环境最怕的"一个写坏的 SQL 把整个集群 OOM"在 YARN 上基本能被兜住。
另一个收益是日志和生命周期的统一管理。YARN 的 Application 状态、启停、重试、日志聚合都有一套标准流程,Spark 作业挂在 YARN 上之后,你可以通过yarn application -list看到所有任务,通过yarn logs拉取运行在任意节点上的日志,而不用像 Standalone 那样逐台登录到 Worker 节点翻日志。这种可运维性在只有几十个任务的测试环境体现不出来,等作业规模到了每天几百上千个,就会变成救命稻草。
1.3 一个典型业务场景:为什么我从 Standalone 切换到 YARN
我以前负责过一个面向业务线的报表调度项目,最初跑在 Standalone 上,三个 Worker 各给 16G 内存。当时只跑 Spark SQL,倒也相安无事。后来数据组引入了实时同步组件、离线清洗任务,都要吃集群资源。为了避免互相干扰,运维同事给每套引擎划分了固定内存,结果 Spark 这边任务高峰期内存不够,那边 Flink 的槽位又闲着七成。每次双十一式的报表集中产出时,我都得手工调整 Worker 的 spark-env 参数,然后重启集群,上下游 QA 都得受牵连。
后来我们把计算资源整体并入 YARN,Spark 以 YARN 模式提交,不再单独维护 Standalone 组。资源是弹性的,业务 A 的队列空闲时,业务 B 可以把任务排过去;任务多了,队列之间还能配置抢占策略。迁移过程不算复杂,但当时走了不少弯路,我在这篇文章里会把每个步骤和容易踩的坑都写清楚,尤其是一些看似不关键实则致命的配置细节。
2. 环境准备:JDK、Hadoop 客户端和主机网络里的隐形地雷
2.1 JDK 与 Hadoop 版本矩阵:版本对不上是最大的坑
Spark 本身是 JVM 系软件,对 JDK 版本比较敏感。Spark 3.5 官方支持 Java 8/11/17,但我实际推荐 Java 8(对应 Hadoop 3.x 的默认编译版本)或 Java 11,两者在生产环境都有大量验证案例。如果你的集群用的是 Hadoop 3.3.x,那么 JDK 8 基本是最省心的选择;如果你已经把基础组件升级到 JDK 17,务必确认 Spark 的 Scala 版本(2.12/2.13)和 Hadoop 客户端兼容,否则反序列化、反射调用各种奇怪报错会层出不穷。
除了 JDK,Spark 和 Hadoop 的版本也得对齐。Spark 官方预编译的二进制包分成"含 Hadoop 3"和"含 Hadoop 2"两类,下载时不要选错。如果你用的 Hadoop 是 3.2、3.3,就选spark-3.5.x-bin-hadoop3这个包;如果周边生态还停在 CDH 风格的 Hadoop 2.7 时代,那得选 hadoop2 的包,否则提交时客户端调用 HDFS 和 YARN 的协议会出现 UnsupportedOperationException 或 class not found。这里强调一个原则:Spark 客户端只负责把作业描述发给集群,YARN 和 HDFS 跑在 Hadoop 版本上,客户端版本跟服务端版本差太远就会扯皮,所以尽量让 Spark 自带的 hadoop-client 版本与你的集群主版本保持一致。
2.2 Hadoop 客户端配置:没有它 Spark 找不到 YARN 的 ResourceManager
很多人以为在 Spark 所在机器上装好 Spark 就能提交到 YARN,其实还差关键一步:Spark 需要能够读到 Hadoop 集群的配置文件,才知道 ResourceManager、NameNode 在哪,以及如何连接到 HDFS。这一步靠环境变量HADOOP_CONF_DIR或YARN_CONF_DIR指向一个包含core-site.xml、hdfs-site.xml、yarn-site.xml的目录。我的习惯是在每台跑 Spark 客户端的机器上,把 Hadoop 安装目录里的 etc/hadoop 路径配到SPARK_HOME/conf/spark-env.sh里。
如果这个变量没有设置,或者仅仅设了 Hadoop 安装目录却忘了把配置文件目录指过去,最常见的报错就是java.lang.IllegalArgumentException: java.net.UnknownHostException: rm-cluster,或者ApplicationMaster启动后一直无法连接到 ResourceManager。另外要提醒一点:HADOOP_CONF_DIR不只是给 Spark 客户端本机用的。当你用--deploy-mode cluster提交时,Spark 会把 HADOOP_CONF_DIR 中的core-site.xml、yarn-site.xml等作为作业环境的一部分传给 NodeManager,由 NodeManager 据此启动 ApplicationMaster。所以如果配置有误,你在提交机上看到的报错时间和集群侧实际出错时间可能差好几分钟,排查起来很容易绕远路。
注意:
SPARK_DIST_CLASSPATH这个环境变量也容易遗漏。有些版本的 Spark 想要加载 Hadoop 的类时,找不到路径会报NoClassDefFoundError: org/apache/hadoop/yarn/util/ConverterUtils。你可以在 spark-env.sh 里手动让它等于 Hadoop 安装目录下share/hadoop/common/lib、share/hadoop/mapreduce、share/hadoop/yarn等依赖的拼装结果,也可以直接用 export 的方式设置。省略这一步,我第一次部署时就踩了接近一小时的坑。
2.3 主机名与端口:本地调试一时爽,提交集群火葬场
YARN 模式对集群节点的主机名解析要求非常高。ResourceManager 所在的机器向 NodeManager 注册时用的是/etc/hosts里的一段 IP 映射,因此所有节点的主机名必须能在本机被解析到正确的 IP。最容易出现的问题是:本地调试时你把yarn-site.xml里的 RM 地址配成了localhost,本机能跑通,换到另一台提交机就提示连接被拒。我的建议是,一律在集群内使用内网 FQDN 主机名,并保证跨节点ping和telnet连通相关端口。
端口方面,至少你要确认几个核心端口可通:ResourceManager 的 Web 端口 8088、调度器端口 8030、ApplicationManager 端口 8031、ResourceTracker 端口 8032、以及 NodeManager 的端口 8042。如果集群有防火墙,记得放通这些。还有一个不太被注意但特别容易踩坑的点是 Spark UI 端口和 Executor 之间的本地通信端口。Spark 在 YARN 上运行时,Driver 会绑定一个本地随机端口用于接收 Executor 的反向注册,如果提交机或 Driver 所在节点有防火墙策略,Executor 会一直处于 RUNNING 但注册不上来,最后因心跳超时被 YARN 干掉。我的习惯是在 spark-defaults.conf 里显式设置spark.driver.host为可被集群内访问的内网 IP,并给spark.driver.bindAddress指到同一地址,避免它随机绑定到 127.0.0.1。
3. Spark 安装与 spark-env.sh 配置:把每个参数写明白
3.1 下载、解压与目录规划:建议把 SPARK_HOME 和 HADOOP_CONF_DIR 理顺
拿到 Spark 压缩包后,先别急着解压跑命令。我建议在/opt下创建统一目录,比如/opt/spark,并将压缩包解压成明确带版本号的目录,然后建一个软链spark-home指向当前版本。这么做的好处是后续升级 Spark 小版本时只动软链,不需要改一堆脚本里的绝对路径。随后在/etc/profile或/root/.bashrc中导出SPARK_HOME、JAVA_HOME、HADOOP_HOME、HADOOP_CONF_DIR,注意这些导出语句要在互相引用的顺序上调整,比如HADOOP_CONF_DIR="$HADOOP_HOME/etc/hadoop"。
目录规划里,还有两个目录容易被忽略:Spark 的本地临时目录和事件日志目录。YARN 模式下,Spark 的 Executor 在 NodeManager 的本地目录中运行,但 Driver 端如果需要缓存 shuffle 数据,也会使用本地临时空间。我一般会在/data/spark-tmp建立一个大磁盘目录,然后在spark-defaults.conf里用spark.local.dir=/data/spark-tmp指定,避免默认的/tmp被系统盘写满。
3.2 spark-env.sh 里的关键参数:内存、核数、日志目录、依赖路径
spark-env.sh是 Spark 启动脚本读取的环境变量文件。配置前先把里面被注释掉的示例看一遍,通常默认内容会把你带偏。我到现在每次部署都会把下面这些参数核对一遍:
export JAVA_HOME=/usr/local/java/jdk1.8.0_202 export SPARK_HOME=/opt/spark/spark-home export HADOOP_HOME=/opt/hadoop/hadoop-home export HADOOP_CONF_DIR=$HADOOP_HOME/etc/hadoop export SPARK_DIST_CLASSPATH=$(hadoop classpath) export YARN_CONF_DIR=$HADOOP_CONF_DIR export SPARK_LOCAL_DIRS=/data/spark-tmp export SPARK_LOG_DIR=/var/log/spark其中SPARK_DIST_CLASSPATH为什么这么重要?因为 Spark 启动脚本会用它在 driver 和 executor 上构造 classpath。hadoop classpath命令会输出 Hadoop 所有 jar 包的完整路径,省去手工拼接的麻烦。如果你不想在每台机器上装 Hadoop 客户端,也可以把 Hadoop 依赖目录挂载或同步过去,但那样更麻烦,建议老老实实装完整客户端。SPARK_LOG_DIR也不要省,默认写在$SPARK_HOME/logs,如果$SPARK_HOME所在分区不够大,日志写满后 Spark 就不会工作。
3.3 spark-defaults.conf 中的提交默认值:少写重复参数
spark-defaults.conf提供了默认提交参数,这样每次执行spark-submit时不用重复指定相同参数。我的基础模板如下:
spark.master yarn spark.submit.deployMode client spark.yarn.queue root.default spark.driver.memory 4g spark.driver.cores 2 spark.executor.memory 8g spark.executor.cores 4 spark.sql.shuffle.partitions 200 spark.eventLog.enabled true spark.eventLog.dir hdfs://mycluster/spark-history spark.history.fs.logDirectory hdfs://mycluster/spark-history注意spark.master=yarn只解决默认提交模式,如果用户在提交命令里显式写了--master,这里的默认值会被覆盖。spark.submit.deployMode默认client,如果你希望作业的 AppMaster 跑在 YARN 容器里,就要改成cluster。很多人会把这两条参数想象成黑白分明的关系,实际它们可以任意组合,举例来说--master yarn --deploy-mode cluster表示 Driver 也由 YARN 拉起,--master yarn --deploy-mode client表示 Driver 留在提交机本地。
3.4 首次验证:用 spark-shell 连接 YARN,看它到底落到哪些机器
配置完成后,不要一上来就提交大作业。我习惯先用一个空任务验证 Spark 到底能不能在 YARN 上拉起 AppMaster。命令是spark-shell --master yarn --deploy-mode client --num-executors 1 --executor-cores 1 --executor-memory 1g。观察点有三个:第一,执行后能否进入 Scala REPL,能进说明 AppMaster 启动成功;第二,在 YARN 的 ResourceManager Web 页面或命令行执行yarn application -list,看 Application 状态是否为 ACCEPTED 或 RUNNING;第三,执行一个sc.parallelize(1 to 10).count()后,去 NodeManager 所在机器检查是否存在 SparkExecutors 进程。
如果第一步就失败,大概率是 classpath 或权限问题。如果 AppMaster 能启动但 Executor 起不来,重点看 NodeManager 日志和 YARN 隔离机制。这一步跑通之后,基础配置就算真稳了,后面只是参数调优的问题。
4. YARN 侧配置:队列、调度器与应用标签,别让作业抢成一锅粥
4.1 容量调度器与公平调度器:选型的底层逻辑
YARN 自带的调度器有三种:FIFO、Capacity Scheduler、Fair Scheduler。生产上基本在 Capacity 和 Fair 之间选。容量调度器是 Hadoop 默认调度器,它的核心思想是给不同队列分配保证容量,队列内部再用层级树管理子队列。适合组织架构相对固定、每个业务线需要明确资源下限的场景。公平调度器则以作业为单位,让提交到同一队列的多个作业尽量平均使用资源,适合即席查询较多的场景,作业多时不会出现一个任务占着资源闲时不释放的问题。
我在实际项目中用的是容量调度器,因为报表任务、数据同步、交互查询分属不同业务组,各自有明确的 SLA,宁可牺牲一些峰值弹性也要保证核心任务的资源底数。公平调度器更适合资源池模式,但它的队列配置和抢占语义理解成本更高,对新手并不友好。如果你刚开始上手,默认容量调度器就够了,不要因为名字里带"公平"就觉得更高级。
4.2 配置队列和 ACL:给不同业务线画好地盘
容量调度器在capacity-scheduler.xml中配置,默认有一个root.default队列,所有未指定队列的作业都进这里。只用一个 default 队列也能跑,但作业一多就会互相干扰,所以至少建议拆成三个队列:root.default留给测试和小任务,root.batch给离线批处理,root.realtime给准实时任务。核心配置片段长这样:
<property> <name>yarn.scheduler.capacity.root.queues</name> <value>default,batch,realtime</value> </property> <property> <name>yarn.scheduler.capacity.root.batch.capacity</name> <value>50</value> </property> <property> <name>yarn.scheduler.capacity.root.realtime.capacity</name> <value>30</value> </property> <property> <name>yarn.scheduler.capacity.root.default.capacity</name> <value>20</value> </property>注意容量调度器要求同级队列的 capacity 之和为 100,但各队列还有maximum-capacity控制最大资源占用。我一般把 realtime 的 maximum-capacity 设到 80,batch 设为 70,default 设为 30。这样某条队列空闲时,其他队列可以借资源,但高峰时又不会被彻底抢走。改完配置后通过yarn rmadmin -refreshQueues热加载,无需重启集群。重新加载后可以用yarn scheduler命令查看配置生效情况,如果某个队列没出现,检查 XML 是否写错队列层级。
4.3 应用标签与节点分区:让 Spark 任务跑到指定的优质机器
有些场景下,你希望 Spark 任务只运行在挂着超大内存盘的某些节点上,其他普通节点留给常规 MapReduce。YARN 的节点分区(Partition)和应用标签(Application Tag)能实现这种物理层面的调度约束。在yarn-site.xml中设置 NodeManager 的yarn.nodemanager.node-labels.provider并给节点配置 label,比如label=high_mem,然后在capacity-scheduler.xml给指定队列设置可访问的节点标签。Spark 客户端提交时通过--conf spark.yarn.am.nodeLabelExpression=high_mem将 ApplicationMaster 限定到对应分区,Executor 所在的容器则需要配合队列的节点标签配置。
不过我要提醒一句:节点标签是双刃剑。设置不当会导致队列可用节点变成 0,任务一直 ACCEPTED。我在测试环境就遇到过给 root.realtime 队列设置了仅可访问 high_mem 分区,而 high_mem 分区只包含一台还在维护的机器,所有作业就卡死了。如果你刚开始配置,可以先不用标签,等基础功能稳定后再加;或者只在非关键测试队列里验证语义。
4.4 本地目录与磁盘问题:YARN 聚簇日志依赖的目录要先建好
YARN 的日志聚合功能(log-aggregation)默认是关闭的,如果不打开,Spark 作业结束后的日志散落在 NodeManager 所在的各个节点,排障非常痛苦。在yarn-site.xml中建议开启:
<property> <name>yarn.log-aggregation-enable</name> <value>true</value> </property> <property> <name>yarn.nodemanager.log-dirs</name> <value>/data/logs/nm</value> </property> <property> <name>yarn.log-aggregation.retain-seconds</name> <value>604800</value> </property>日志聚合开启后,NodeManager 会定期把容器日志上传到 HDFS 指定目录,然后就可以用yarn logs -applicationId xxx拉取。注意yarn.nodemanager.log-dirs所在磁盘要足够大,因为它会存放容器运行时的临时日志。而本地目录yarn.nodemanager.local-dirs同理,它存放容器启动所需的本地化资源、jar 包以及 Spark 的 shuffle 输出,如果磁盘不够或者目录存在多块磁盘混合使用,建议写多路径逗号分隔,让 YARN 自己轮询选择。
5. spark-submit 提交到 YARN:命令参数和内存模型的一次性讲透
5.1 spark-submit 完整示例:从 client 模式到 cluster 模式的对比
先把两个最常用的提交方式摆出来。Client 模式适合调试,Driver 在提交机本地,日志直接打到控制台:
spark-submit \ --master yarn \ --deploy-mode client \ --queue root.batch \ --driver-memory 4g \ --driver-cores 2 \ --num-executors 10 \ --executor-memory 8g \ --executor-cores 4 \ --conf spark.yarn.archive=hdfs://mycluster/spark-jars/spark-archive.zip \ /path/to/job.pyCluster 模式适合生产调度,Driver 跑在 YARN 容器里,提交命令立刻返回,日志靠 YARN 拉取,命令结构几乎一样,只是把--deploy-mode改成cluster。为什么生产推荐 cluster?因为 Driver 一旦落在提交机上,提交机就成了单点,客户端的网络波动、内存不足会影响所有任务。而 cluster 模式下提交机只负责上传 job 和触发,之后可以关闭,容错性更好。
如果你觉得spark.yarn.archive陌生,简单解释一下:Spark 分发到 YARN 各节点需要把所有依赖打成 zip 上传到 HDFS,然后在每个容器里下载。你可以不配,那么每次提交都会把一大堆 jar 包重新上传到 HDFS 临时目录,耗时长且浪费带宽。提前用$SPARK_HOME/bin/spark-archive.sh建成一个spark-archive.zip放到 HDFS,然后指定spark.yarn.archive,能显著加快提交速度。我就是因为这个参数没配,每次提交时要多等几十秒,小作业比例一高,整体调度效率很难看。
5.2 内存参数理不清?driver、executor、executorOnMaster 的关系
Spark 在 YARN 上的内存模型可以拆成三层:Driver 请求的 YARN 容器内存、Executor 请求的容器内存、以及 Spark 内部运行时内存。你在命令里写的--driver-memory 4g,真正向 YARN 申请时还要加上spark.driver.memoryOverhead,默认是 driver-memory 的 10%,最小值 384m;Executor 同理,spark.executor.memory加上spark.executor.memoryOverhead才是 YARN 容器申请值。如果你设置的总额超过 YARN 队列允许的最大容器内存,ApplicationMaster 会被拒绝。
所以配置内存前,先明确一个节点物理内存和可用 yarn 容器内存的关系。比如 NodeManager 所在机器物理内存 64G,YARN 的yarn.nodemanager.resource.memory-mb设为 56G,单个容器最大yarn.scheduler.maximum-allocation-mb设 16G,那么 executor 申请总内存别超过 16G,否则任务提交后一直处于 ACCEPTED,调度器实际无法分配这个容器。我一般把 executor memory 设为 8G,overhead 默认 10%(约 0.8G),总 8.8G,比较稳妥。如果spark.executor.memoryOverhead没设置,默认取 executor memory 的 10%,但遇到堆外内存需求高的场景,比如 Python UDF 或者大量读 ORC,要手动把 overhead 调大一半以上。
5.3 动态分配:开还是不开,用多少 sizeinfer 如何决定
如果你觉得固定 10 个 Executor 太死板,可以考虑动态分配。开启后 Spark 会根据积压任务自动调整 Executor 数量:
spark.dynamicAllocation.enabled=true spark.dynamicAllocation.initialExecutors=2 spark.dynamicAllocation.minExecutors=2 spark.dynamicAllocation.maxExecutors=20 spark.shuffle.service.enabled=true注意最后一行的spark.shuffle.service.enabled,在 YARN 上开动态分配必须开启 NodeManager 的辅助服务,否则 Executor 退出后 shuffle 文件会被清理,后续任务的读取会直接报错。YARN 侧需要配置yarn.nodemanager.aux-services为spark_shuffle,然后yarn nodemanager -config验证。这个联动关系很容易被忽略,我见过不少作业开始报java.io.FileNotFoundException: /data/spark-tmp/xxx BlockId_xxx,看起来像磁盘问题,其实是因为动态分配关了 shuffle service。
5.4 日志获取:yarn logs 命令把你的日志从聚簇里拉出来
提交到 YARN 后,排障不是靠控制台,而是靠yarn logs。第一步用yarn application -list找到 Application ID。然后:
yarn logs -applicationId application_1690000000000_0001 \ > /tmp/spark_app.log 2>&1如果配置了-logFiles或-am选项,可以只拉 AppMaster 日志。如果发现 HDFS 上没有聚合日志,先检查yarn.log-aggregation-enable是否为 true,再检查 HDFS 根目录空间是否足够。有一点容易忽略:yarn logs 默认只读取已聚合完成的日志,作业如果还在 RUNNING,它拿不到最新数据,这时只能去 NodeManager 那台机器的yarn.nodemanager.log-dirs对应 container 目录里看实时 stdout/stderr。
6. 实战排障:Spark on YARN 最常见的五个错误现场
6.1 ApplicationMaster 启动失败:多半是 classpath 或端口被占用
报错现象通常是提交后不久yarn application -status里 AppMaster 状态为 FAILED,但日志中没有明确的异常堆栈。第一步先去看 ResourceManager 的rm.log或者用yarn logs -applicationId拿 AppMaster 启动日志。导致 FAILED 的原因里,ClassNotFoundException: org.apache.hadoop.yarn.util.Apps很见到,这就是我在 2.2 节强调的SPARK_DIST_CLASSPATH没指对,或者 HADOOP_CONF_DIR 指向的 Hadoop 版本和 Spark 内置依赖不一致。
另一个常见原因是 AppMaster 要绑定的主机端口被占用。YARN 模式下 AppMaster 会起一个 Web UI,默认端口随机,你也可能在配置中固定了spark.appMasterEnv.SPARK_LOCAL_IP。如果那台机器上有多个启用分布式缓存的任务,端口随机碰撞的概率不低。我的做法是在提交时给spark.yarn.am.extraJavaOptions加上-Dspark.ui.port,指定一个高位空闲端口,同时在集群安全组里放通相应网段,问题基本能排查掉。
6.2 Executor 丢失或瞬间退出:内存校验和 Java 版本嫌疑
Executor 进程被 YARN 杀死是最经典的问题。一眼看上去日志里只有 NodeManager 的Container killed by ApplicationMaster或者Exception from container-launch。这时候优先去查stderr,如果里面出现Container killed on request. Exit code is 137,这是 JVM 进程被系统 OOM Killer 干掉的典型信号。绝大多数场景是 Executor 实际占用的物理内存超过了 YARN 容器允许的上限。此时不是盲目调大--executor-memory,而是检查你的代码里有没有大量堆外缓存、RDD 持久化级别是否用了 MEMORY_AND_DISK,或者把spark.executor.memoryOverhead适当加大。
如果 JVM 直接显示OutOfMemoryError: Java heap space,则是 Spark 内部的执行内存不够,优先考虑调整spark.executor.memory或者减小单个 Executor 的并发核数spark.executor.cores,让每个 Executor 更"小而重"。如果两类 OOM 同时出现,那多半是整台节点资源申请过满,建议降低yarn.scheduler.maximum-allocation-mb或调整队列 max-capacity。
6.3 作业一直处于 ACCEPTED 状态的排查链路
作业提交后卡在 ACCEPTED 到 RUNNING 之间,一般是 YARN 没有找到可用资源。你要做的第一件事是去 ResourceManager 页面看被拒绝的队列和资源余量。如果队列容量还有剩余,但容器一直无法分配,检查yarn.scheduler.maximum-allocation-mb是否小于你申请的总内存;如果单 Executor 申请 16G,而最大容器只有 8G,那无论怎么等都没用。用命令确认:
yarn node -show-details 127.0.0.1:8042会显示该节点的总资源、已用资源、可用资源。如果可用资源正常但任务始终 ACCEPTED,还有一种可能是 YARN AppMaster 只被分配到半个容器,打不到你的分区标签。这时去yarn application -status里看AppMaster的Host节点,再检查代码里的SetNodeLabel是否落在正确队列。
6.4 倾斜和资源碎片:调整分区数和 executor 规格的实战策略
Spark 作业慢,往往不是线程资源不够,而是数据倾斜和资源碎片。先说分区数:Spark 默认从 HDFS 读文件时分区数和块数挂钩,但 shuffle 之后如果分区全部集中在少数 Executor,任务耗时会出现长尾。我的经验是先用repartition把分区数设为executor 数 * 单 executor 核数 * 2~3,再观察 Task 耗时分布。如果倾斜集中某个 key,那就得走两阶段聚合或者 salting key,这属于另一篇文章的主题。
资源碎片指集群剩余内存总和不少,但单个节点剩余内存都不足以启动一个新 Executor。比如你把 Executor 内存设成 12G 而大多数节点只剩 8G,任务就只能排队。遇到这种情况最简单的方法是把--executor-memory调小、--num-executors调大,让 Executor 规格更贴近单节点剩余资源。同时留意spark.executor.cores,块数太多时 Executor 内任务并发不均匀,也会出现节点资源碎片。
6.5 权限问题与错误配置:常见 UnsatisfiedLinkError 和 AccessControlException
Spark 提交到 YARN 后你的作业默认以当前 Linux 用户身份去访问 HDFS。如果业务账号没有对应目录的写权限,或者跨队列写数据但队列有 ACL 限制,就会抛出org.apache.hadoop.security.AccessControlException。解决办法不是关权限,而是要让 Spark 使用正确的身份连接,比如用 Kerberos 认证的--principal/--keytab,或者在 HDFS 侧给作业用户建好目录 ACL。我在项目里就发生过一次数据同步任务因为用户组不对,把所有输出写到了/tmp下,后来改名清理时非常尴尬。
UnsatisfiedLinkError多出现在调用本地库的场景,比如读取某种加密格式或者压缩库,原因是spark.executorEnv.LD_LIBRARY_PATH没有把 Hadoop native 库目录加进去。如果只是偶然触发,可以给提交命令加上:
--conf spark.executorEnv.LD_LIBRARY_PATH=$HADOOP_HOME/lib/native如果所有 Executor 都触发,多半是 NodeManager 机器上的 native 库缺失或版本不匹配,建议在每台节点上安装对应的 Hadoop native 包。
7. 从跑通到跑好:我实际用下来的几个配置心得
7.1 先固定机器,再谈动态:一个较稳妥的过渡路线
如果你以前只用 Standalone 或者本地模式,第一次迁到 YARN 时,我建议不要急着上动态分配、标签调度这些高端操作。先把--num-executors、--executor-memory、--executor-cores写死,跑一周,让任务稳定运行、日志也能正常聚合。确认 YARN 的资源模型和队列划分符合预期后,再逐步开启动态分配,把最小和最大 Executor 设置得保守一点。我见过很多同事一上来就把 maxExecutors 调到 100,然后某个凌晨跑数据回刷的时候把队列资源撑爆,把其他业务线全拖垮。
7.2 日志和监控才是真正"配置"出来的:HistoryServer 和 Prometheus
配置到位不等于万事大吉,你还要能看到作业表现。Spark History Server 是必开的一环。在 spark-defaults.conf 里打开spark.eventLog.enabled=true并指定 HDFS 目录后,启动sbin/start-historyserver.sh,就能在 Web 页面上看到每个 Application 的 Executor 使用曲线和 GC 时间。否则作业一跑完 Spark UI 就没了,你只能靠 yarn logs 毫无头绪地翻。
另一个建议是接 Prometheus 监控。Spark 3.x 自带spark.ui.prometheus.enabled,把 driver 和 executor 的指标拉出来,再接入 Grafana 配置资源利用率看板。我自己用过之后发现最有用的一块指标是spark_executor_peakJvmMemory和spark_executor_hundredthPercentileTotalGcTime,前者帮你判断 overhead 是否设得够,后者提示你是否应该减小 Executor 内存以降低 GC 频率。
7.3 Spark 内存比例调节:spark.memory.fraction 与 spark.memory.storageFraction 的实际意义
Spark 3.x 默认spark.memory.fraction=0.6,意思是 executor JVM 堆中有 60% 用作执行和缓存存储的统一内存;剩下的 40% 留给用户代码、保留区域等。这 60% 里,默认spark.memory.storageFraction=0.5,意味着存储(RDD cache)最多占统一内存的一半,另一半留给执行(shuffle、join、agg)。如果你有大量缓存需求,可以把 storageFraction 调高,比如 0.7;如果你的作业重度 shuffle,执行内存容易不够,可以考虑调高内存比例到 0.7,但给用户代码留的空间就会变小。
我遇到过一种情况:某个任务把大表广播并显式cache(),但 cache 的数据根本不进入 storage 区,任务反复触发重放日志检查。后来发现是我把 memoryFraction 调得太低,storage 区被挤没了。所以记得:调内存比例没有普适方案,必须配合 spark-submit 的观测数据来改。
7.4 最后一点建议:压测脚本和配置管理
我强烈建议你给每个 Spark 作业准备好一套配置模板和压测脚本。比如写一个spark-submit-perf-test.sh,接收--num-executors、--executor-memory、--shuffle-partitions等参数,跑固定数据集,输出执行时间和峰值资源。这样每次优化配置时,都能靠客观数据对比,而不是拍脑袋。配置管理上,尽量用环境变量或模板渲染工具管理 spark-defaults.conf,避免在 N 台机器上手工改文件。我记得有一次某台机器因为配置漂移,导致同一个作业在机器 A 上跑 5 分钟,在机器 B 上跑 35 分钟,排查到最后就是 spark.executor.memory 被某次手工覆盖写错了。
我个人目前维护的实践是:spark-env.sh 只保留 JVM 环境相关变量,业务提交参数全部写在项目里独立的 defaults.conf 文件,提交时用--properties-file单独带上。这个文件走代码仓库版本管理,每次变更都有记录,回滚也方便。你在实际动手配置 Spark on YARN 的时候,不妨也先搭一个这种可重复的模板,至少能帮你把问题发生时的排查范围缩小到"参数写得对不对",而不是"哪个机器的配置漂了"。
说到底,Spark on YARN 的配置并不是晦涩的魔法,它不过是一套"让 YARN 认识 Spark,让 Spark 知道 YARN"的握手协议。只要环境变量、配置文件、资源约束这三条线理清楚,跑通只是时间问题;跑好后,你省下的将是无数个熬夜值班排查任务的深夜。