Spark从打包到提交全流程:本地测试与YARN集群实战避坑指南
做Spark开发写代码只是第一步把那堆依赖缠在一起的工程打成一个能跑的jar包再正确交到集群手里这中间踩过的坑比业务逻辑本身还多。这篇就专门讲清楚一件事从工程打包到本地测试再到集群提交的完整链路怎么走通。先说个很多人上来就搞错的点Spark应用在什么时候才需要打成jar包。如果你只是在IDE里点了Run本质上是IDE用classpath帮你把所有依赖和代码加载进了JVM那是开发态的调试行为。而一旦要脱离IDE、脱离你本机的环境不管是放到服务器上用spark-submit跑还是提交到YARN集群都需要一个包含你业务代码、能被Spark的Driver和Executor加载执行的jar包。所谓“打jar包”核心目的就是把你写的代码和它依赖的东西统一封装成一个可交付的工件。下面直接按实际执行顺序拆先讲清楚打包前要注意的代码结构再分别讲本地测试和两种集群提交模式最后整理一堆实操中才会碰到的坑。1. 打包前的准备工程结构和依赖不被坑的关键点很多教程一上来就教你怎么执行mvn package但我在项目里发现真正让打包失败或者提交后ClassNotFound的往往不是命令问题而是工程本身的问题。1.1 选对构建工具和Spark依赖的scope构建工具主流就两个Maven和sbt。做Java或者愿意用Java写的Maven居多用Scala的两边都常见。我自己长期用Maven因为它的依赖管理对大多数团队来说更直观出问题能搜到的资料也多。这里有一个决定性细节Spark相关依赖的scope必须设置成provided。比如你引入spark-core、spark-sql如果你的pom.xml写成默认的compile打包的时候会把Spark本身那几十上百兆的依赖全部塞进jar包里。Spark运行环境里自带那套运行时依赖塞进去不但让jar包巨大无比还可能因为版本冲突导致提交后出现各种奇怪的ClassNotFoundException或者方法签名不匹配。正确写法是这样dependency groupIdorg.apache.spark/groupId artifactIdspark-sql_2.12/artifactId version3.5.0/version scopeprovided/scope /dependencyprovided的含义是编译的时候需要它但运行时不打包它因为运行环境提供。这把scope改掉jar包体积能缩到十分之一甚至更小。1.2 业务代码要不要用spark-submit依赖第三方库的方式打包还涉及一个问题你的程序有没有强依赖的第三方库。比如你读了MySQL用到了MySQL的JDBC驱动。这种依赖不能设成provided必须打进去。有两个处理手段用maven-shade-plugin做fat jar把所有第三方依赖合并进一个jar。提交的时候用--jars参数附带外部jar。我的建议是尽量用shade插件做合并jar别到处散落外部jar。因为集群上外部jar的文件路径管理很容易出混乱某人改了一个目录另一个作业没跟着同步排查起来非常恼火。一个典型的shade插件配置如下plugin groupIdorg.apache.shade/groupId artifactIdmaven-shade-plugin/artifactId version3.5.0/version executions execution phasepackage/phase goals goalshade/goal /goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.demo.SparkSubmitJob/mainClass /transformer /transformers filters filter artifact*:*/artifact excludes excludeMETA-INF/*.SF/exclude excludeMETA-INF/*.DSA/exclude excludeMETA-INF/*.RSA/exclude /excludes /filter /filters /configuration /execution /executions /plugin那个mainClass一定要写对否则用spark-submit提交时加上--class参数也能找到入口类但如果打成jar后有人直接用java -jar来跑就找不到入口了。另外排除掉META-INF下的签名文件是必须的那些.SF和.RSA文件会导致安全类加载错误真实项目里因为这个报错的情况我见过不下十次。1.3 入口函数设计对两种提交模式的影响提交Spark应用有人习惯在main方法里自己new SparkSession也有人依赖SparkSubmit自动创建。这里面有个容易迷惑的地方尤其刚接触的人你把一个jar包提交到集群到底是谁在跑main方法。需要理解Spark是分布式框架两套进程体系。提交应用后集群会启动一个Driver进程它负责解析你的SparkContext/SparkSession、生成任务计划、调度Executor去执行。你的main方法是在Driver里跑的。而jar包是同步分发给集群上的各个节点用的Executor上要执行你代码里的算子逻辑也需要加载你的类。所以入口函数写什么在两种模式下的效果有差异local模式下Driver和Executor都在同一个JVM里就是一个普通进程。yarn-client模式下Driver跑在提交作业的客户端机器上Executor跑在集群节点上。yarn-cluster模式下Driver也跑在集群节点上客户端提交完就可以关掉本地终端。写代码时main函数里不要做太多本地环境假设比如硬编码的绝对路径、依赖当前机器的环境变量这些在不同运行模式下都会因为Driver位置不同而失效。保持入口干净解析参数、构建SparkSession、执行业务逻辑。2. 本地运行测试在IDE里把逻辑跑通再谈提交很多问题如果在本地调试阶段暴露出来代价只是几秒钟的重启。一旦打到集群上每查一次问题跑一轮任务都是按分钟甚至小时来计的。所以千万别跳过本地测试直接提交集群。2.1 IDEA中直接运行和SparkSession的master设置在IDE里直接右键运行main方法SparkSession构建时的master可以设置成local[*]这样Spark会在当前进程内模拟多线程执行。比如val spark SparkSession.builder() .appName(LocalTestJob) .master(local[*]) .enableHiveSupport() .getOrCreate()local后面加不加[*]含义不一样local单线程执行适合快速测试单个任务片段。local[*]按本机CPU核心数决定并发线程数更接近集群并行效果。local[4]固定4个线程。我本地调试一般用local[*]但要注意本地跑和集群跑在某些行为上确实有差异比如数据倾斜在本地可能根本暴露不出来shuffle的机制也受单机内存限制。本地测试的核心目的是验证业务逻辑正确性不能替代集群上的性能和稳定性验证。2.2 本地测试的数据准备和结果对比做本地测试时尽量准备一些能覆盖边界情况的样本数据。我见过最典型的翻车现场本地测试用了一个极小的数据文件一切正常到了集群换成全量数据一天的任务跑下来各种OOM、掉线。这往往是因为本地样本数据量太小掩盖了代码里潜在的笛卡尔积、过度collect等问题。本地测试里还有一个很实用的手段把最终结果写在本地文件再和预期结果做diff。别只在控制台打印几十行就觉得通过。比如你要做一个聚合分析本地输出到一个路径resultDF.write.mode(overwrite) .option(header, true) .csv(/tmp/spark_local_result)然后用diff工具对比预期文件和控制台确认无误再进集群环节。整个过程就是把代码调试成本尽量留在本地。2.3 本地跑通后用spark-submit本地模式做一次真实验证本地IDE运行通过只说明逻辑对。你还需要验证一件事这个jar包能不能被Spark正常加载执行。因为IDE运行是IDE提供的classpath而spark-submit用的是你打的jar。两者有差异比如某些依赖没有正确打包、资源文件夹缺失IDE里看不出来一提交就露馅。我自己习惯在服务器上先跑一次local模式的spark-submit./bin/spark-submit \ --master local[*] \ --class com.demo.SparkSubmitJob \ /path/to/my-job-assembly-1.0.jar \ --input /tmp/test_data.csv \ --output /tmp/test_output注意这个命令需要在你Spark安装目录下执行或者把spark-submit加到PATH里。此时master是local整个过程不涉及YARN集群只在一台机器的Spark进程里模拟运行。这个步骤能高效验证jar包完整性、依赖完整性、入口类是否能加载。本地模式提交还有一个额外的价值如果你们有持续集成环境可以把这一步挂在构建流程里每次提交代码后自动用jar包跑冒烟测试抓那种“编译通过但跑不起来”的低级问题。3. jar包集群模式提交yarn-client和yarn-cluster到底怎么选这是整个环节里最关键的部分。选错模式轻则日志看不到、重则Driver直接被杀死找不到原因。我把两种模式的机制和实操中面对的差异都讲透。3.1 Spark on YARN两种模式的本质区别Spark提交到YARN集群有yarn-client和yarn-cluster两种。网上资料不少但很多写得云里雾里。我用自己的话来拆解。yarn-client模式Driver进程运行在你执行spark-submit命令的那台机器上。提交命令发出后App Master向YARN申请Executor资源Executor跑在集群的NodeManager节点上但任务调度和结果汇总都在你的客户端机器上完成。yarn-cluster模式Driver进程也在集群内的某个节点上运行由App Master启动。客户端机器提交命令后Driver和Executor全部在集群内部客户端不参与运行时逻辑。用生活化的类比yarn-client像是你在自家客厅里当指挥工人都在远处的工地上干活你需要持续通过电话听他们汇报yarn-cluster像是把指挥也派到工地现场你在家只管下单后续不用再操心。3.2 yarn-client模式的提交命令和适用场景yarn-client的提交命令长这样./bin/spark-submit \ --master yarn \ --deploy-mode client \ --class com.demo.SparkSubmitJob \ --num-executors 4 \ --executor-memory 8g \ --executor-cores 4 \ /path/to/my-job-assembly-1.0.jar \ --input hdfs:///data/input.csv \ --output hdfs:///data/output这里需要注意--master yarn表示提交到YARN。--deploy-mode client就是选择客户端模式。--num-executors、--executor-memory、--executor-cores分别指定Executor数量、单个Executor内存和CPU核数。yarn-client的最直接好处是Driver在你本地日志直接打到你的终端调试方便。你CtrlC中断任务Driver就退出执行中的任务也会被终止。这个特性对做交互式分析、临时任务、需要实时盯着日志的场景特别合适。但不利点也明显客户端机器不能断网、不能关终端一旦网络断了或者终端关了Driver挂了整个作业就得重来。3.3 yarn-cluster模式的提交命令和关键区别./bin/spark-submit \ --master yarn \ --deploy-mode cluster \ --class com.demo.SparkSubmitJob \ --num-executors 4 \ --executor-memory 8g \ --executor-cores 4 \ /path/to/my-job-assembly-1.0.jar \ --input hdfs:///data/input.csv \ --output hdfs:///data/output对比可见命令结构几乎一样只是--deploy-mode从client换成了cluster。yarn-cluster下提交命令执行完拿到了Application ID之后终端就不再有实时任务日志了可以安全地退出终端。任务日志集中在YARN的聚合日志Container聚合日志里查询方式是yarn logs -applicationId application_1699999999999_0001这是生产环境的主方案。因为生产作业通常要跑几小时甚至更久没人能守着终端。跑批作业全部该用yarn-cluster。区别记忆调试看日志用client生产跑批用cluster。这个选择不是喜好是两种模式不同架构决定的必然结论。3.4 Driver日志去哪了——初用cluster模式最懵的地方第一次用yarn-cluster模式99%的人都会找半天日志。在client模式下任务日志直接滚在终端屏幕上很直观。到了cluster模式终端只打印一个Application ID。很多人这时候慌了以为任务失败了。实际上任务日志分两类Spark的AppMaster日志和container日志。如果任务整体失败了用yarn logs能看到完整记录。但有一个细节任务如果还在运行中yarn logs这个命令不一定能拿到实时日志因为YARN的日志聚合是任务结束后才完成的。想实时看一种办法是去ResourceManager的Web UI页面找到对应Application的页面点进Executor列表查看当时运行中的Executor日志。还有一种就是加日志推送配置但这涉及额外的日志系统对接不在这次范围内展开。3.5 参数选择的计算思路提交时那堆--executor-memory、--num-executors到底怎么定很多人直接抄别人的作业。抄作业不是不行但要理解底层逻辑。需要先知道集群大概有多少资源比如你的YARN队列分到了20个CPU核和80GB内存。你要做的是在尽量利用资源的前提下不把某个节点的内存打爆。一般思路是这样给每个Executor分配的内存里要留一部分给Spark内部的开销比如off-heap内存、JVM自身的开销。实际中可以按executor-memory的10%左右额外预留。假设你决定每个Executor用4GB内存Executor数量可以粗略按可用总内存除以单Executor内存来算但要留出系统余量。如果跑了几个任务后发现GC时间过长或者task失败频繁就到ResourceManager页面上观察每个节点的资源使用情况再调整Executor内存和数量。实际生产里我见过最无语的配置是把executor-memory设成10GB而单个节点只有12GB物理内存结果每个节点上只要分配一个Executor系统就内存不足直接OOM kill任务反复失败。3.6 额外文件怎么分发--files和--jars生产任务里经常需要附带配置文件或依赖jar比如MySQL驱动、Hive配置文件、自定义的.properties文件。spark-submit的--files就是干这个的。./bin/spark-submit \ --master yarn \ --deploy-mode cluster \ --files /path/to/config.properties#config.properties \ --jars /path/to/mysql-connector-java-8.0.26.jar \ --class com.demo.SparkSubmitJob \ ...--files的#号后面指定的是在运行环境中这个文件显示的名字用处是让代码里通过相对路径引用它。比如代码里写new FileInputStream(config.properties)就能读到这个随作业分发的配置文件。小心一个坑--files带的文件会被分发到每个Executor的工作目录但Driver加载这个文件的方式和Executor不同。有些代码在Driver初始化阶段就读取配置这个文件其实在Driver的当前工作目录里也有但不同部署模式下的工作目录位置不一样稳妥做法是代码里读取的时候用全路径拼接System.getProperty(user.dir)或者在SparkConf里通过spark.yarn.dist.files拿实际路径。4. 常见问题与排查技巧实录这块内容比前面的命令更重要因为很多问题书本上没有全是真实环境里碰出来的。4.1 提交后一直卡在ACCEPTED状态任务提交到YARN集群后一直停在ACCEPTED状态迟迟不进入RUNNING。这种多半原因不是Spark代码的问题而是资源没申请下来。排查路径是先看YARN的队列资源情况。进入ResourceManager的Web UI看你的队列当前可用内存和核数。如果队列资源满了你来晚了一步只能等前面的作业结束或者换队列。还有一种情况是提交时指定的资源超过了队列上限比如队列最多允许8个Executor你指定了16个YARN会一直等待实际资源可用不会直接报错看起来就是卡住不动。4.2 跑着跑着Executor丢失任务反复重试这个现象通常伴随着日志里的“Lost executor”或“Container killed by YARN for exceeding memory limits”。YARN会监控Container的内存使用一旦超过申请的阈值直接杀掉进程。处理方式分两步第一步调大executor-memory或调整spark.memory.fraction等配置第二步排查代码里有没有不自觉的把大量数据collect到Driver或缓存了超大RDD后没清理。后者我遇到过一次印象深刻的案例一个团队的业务代码里在某步操作后调用了rdd.collect()打印统计信息数据量一大直接把Driver撑爆一用cluster模式跑就OOM但在本地小数据量下跑一切正常。4.3 jar包里没有主类或类找不到提交后直接报“ClassNotFound”或者“Cannot find main class”这类错误大概率不是Spark的问题而是打包环节出漏子了。回头检查pom.xml里的shade配置确认mainClass写对了确认对应的类在target目录下的class文件里存在。还有一种情况是Scala版本不匹配。你用的是Scala 2.12的Spark但代码里引用了某个只支持Scala 2.11的第三方库编译能过但运行期就会因为Scala版本不同导致类找不到或者方法找不到。4.4 本地运行正常、集群上就报错这个问题的根源多数在环境差异。本地没有的数据或配置文件集群上没有本地能访问的某些路径集群节点访问不了。举一个真实例子某个同事的代码里硬编码了读取本地某个目录下的字典文件本地测试没有任何问题提交到集群上后Executor都分布在多台机器上那台机器上根本没有这个文件。这个问题的解法就是回到前面说的--files去分发文件而不是依赖Executor所在机器上的本地目录。4.5 通过YARN日志定位问题的一线方法真实排查中日志就是破案现场。整理几个最实用的日志获取路径场景日志查看方式client模式终端直接看同时也可以去YARN Web UI看cluster模式运行中ResourceManager Web UI进入Application的页面查Container日志cluster模式结束后yarn logs -applicationId app_id只有部分task失败看对应Executor的stderrDriver日志找不到到Application页面里找AppMaster对应的Container日志启动参数里还可以加上--verbose让提交过程打印更多调试信息能看到实际生效的配置项、分发文件的路径等定位配置层面的问题很有效。4.6 常见问题速查症状优先排查方向解决思路任务卡在ACCEPTEDYARN队列资源不足看队列资源调整等待或换队列Executor OOM被杀内存配置或代码优化调大executor-memory排查collect和缓存滥用类找不到打包产物检查shade插件、Scala版本、provided范围本地好集群报错环境差异检查文件路径、依赖、配置文件的分布日志找不到日志通道未选对按上表对照场景取日志5. 实操心得我反复调整后沉淀的一套落地流程这部分没有标准命令集是个人经验沉淀下来的一套操作顺序能让流程稳定不少。第一本地IDE跑通最开始但不要直接用IDE里打的jar。把构建交给Maven的package阶段用shade插件打出来。很多人急于求成直接在IDE的target目录里找artifact但那个默认jar常常没有合并依赖提交集群后各种缺依赖。统一用mvn clean package出来的jar配合shade插件配置好这样每次构建产物结构是一致的。第二jar包提交到集群之前一定在Spark所在机器上用local模式先跑一遍。这一步花费的时间很少但价值巨大。它能过滤掉多少问题呢打个比方没有这步直接上了集群出了问题你要去YARN拉日志、分析Container状态、查Executor的stderr一套下来至少半小时起步。而在本地跑一次有问题直接在终端上就看到StackTrace几秒就定位到代码问题。第三生产提交脚本固定下来不要每次都手敲参数。我习惯把提交命令写成一个shell脚本每次只改输入路径和输出路径。这样参数不会因为手误而改动团队其他人也可以直接复用脚本跑任务减少交流成本。第四资源参数方面留出buffer。宁可每次任务多申请一点资源不要卡着最低标准申请。因为数据量是波动的今天够用的配置明天可能就差一点就爆了留下余量能大幅度降低晚上被电话叫醒的概率。最后说一个很容易被忽视的小技巧给Spark作业命名时用有意义的应用名。appName在YARN的Web UI里会显示出来如果作业挂了或者跑得异常别人在平台上看到名字基本就能明白是哪个业务的任务。我见过太多叫“test”“untitled”的作业运行状态异常时根本不知道谁该负责排查。给作业起一个规范的、可读的名字是在降低整个团队的协作成本。其实Spark打包和提交这件事本质上不复杂做多了就知道核心就那么几个决策点依赖怎么处理、模式怎么选、参数怎么调、日志怎么拿。把这几个决策点想明白之后不管面对什么任务场景提交起来都能心里有底。