1. 从一行日志说起:为什么Container申请总在YARN队列里卡住
第一次在生产环境排查一个Spark任务"卡在ACCEPTED状态十几分钟不动"的问题时,我把ResourceManager、NodeManager、ApplicationMaster三端的日志翻了个底朝天,最后发现问题出在spark.yarn.executor.memoryOverhead这个参数上——申请的内存超过了队列单Container上限,RM一直在等资源,而AM又没把等待逻辑打得太清楚。那次之后我才真正下决心把Spark on YARN这条资源申请链路从源码层面捋一遍。
这篇内容聊的就是这条链路:一个Spark Application提交到YARN之后,ApplicationMaster是怎么向ResourceManager申请Container的,拿到Container之后又是怎么在NodeManager上把Executor进程拉起来的。涉及的核心类包括ApplicationMaster、YarnAllocator、ExecutorRunnable、ContainerLauncher、NMClientImpl等,属于Spark源码里"资源调度"这条主线。
适合谁看?如果你已经能跑通Spark on YARN的任务,但对"为什么我的Executor迟迟起不来""为什么AM申请了100个Container只拿到30个""为什么Container分配了却启动失败"这类问题只能靠猜,那这篇就是写给你的。我会尽量把源码调用链和实际现象对应起来讲,而不是干巴巴地贴代码。
需要提前说明的是,下面涉及的源码分析基于Spark 3.x分支的常见实现(2.4到3.5之间这条链路的核心结构变化不大),不同小版本在类名和方法签名上可能有细微差异,但整体设计思路是一致的。文中提到的参数、日志关键字、排查路径都来自实际生产环境的常见实践,具体数值请结合你自己的集群规格调整。
2. 提交之后发生了什么:AM启动到YarnAllocator诞生的完整链路
2.1 从SparkSubmit到ApplicationMaster的那一跳
很多人对Spark on YARN的理解停在"client模式还是cluster模式"这个层面,但真正决定资源申请行为的分水岭,是ApplicationMaster进程启动之后。在cluster模式下,SparkSubmit最终会通过Client类向YARN提交应用,RM在某个NodeManager上拉起一个Container来跑ApplicationMaster这个主类。这个AM进程内部会做几件事:初始化SparkEnv、创建ApplicationMaster对象、启动AMRMClientAsync和NMClientAsync两个异步客户端、然后进入runDriver或者runExecutorLauncher分支。
关键点在于:AM和RM之间的通信是通过AMRMClientAsync这个异步封装完成的。它内部有一个心跳线程,默认间隔由yarn.resourcemanager.am-heartbeat-interval-ms控制(常见配置是1000ms)。每次心跳,AM会把当前"待满足的资源请求"打包发给RM,RM返回这一轮"新分配的Container列表"和"完成的Container列表"。这个"请求-响应"模型是理解后面所有逻辑的基础——Spark不是主动去要资源,而是把需求登记在RM那里,等RM在心跳响应里把Container塞回来。
2.2 YarnAllocator:资源申请的真正大脑
ApplicationMaster本身并不直接管资源,它把这件事委托给了YarnAllocator。这个类是整个资源申请链路的核心,你可以把它理解成一个"资源账本+调度策略执行器"。它维护了几个关键状态:
executorIdToContainer:已经分配出去的Container和对应Executor ID的映射allocatedHostToContainersMap:按主机维度统计已分配的ContainerpendingAllocate:这一轮需要向RM申请的资源列表targetNumExecutors:期望的Executor总数numExecutorsRunning、numExecutorsPending:运行中和等待中的Executor数量
YarnAllocator的构造过程里会读取一大堆配置:spark.executor.instances(期望Executor数)、spark.executor.cores、spark.executor.memory、spark.yarn.executor.memoryOverhead、spark.yarn.am.memory等等。这些配置最终会被换算成YARN能理解的Resource对象——也就是memory(MB)和vCores两个维度。
这里有个容易被忽略的细节:Executor申请的内存 = executor.memory + memoryOverhead + 可能的offHeap。比如你配了spark.executor.memory=4g、spark.yarn.executor.memoryOverhead=1g,那实际向RM申请的是5GB左右(还要加上一些对齐开销)。很多人调优时只盯着executor.memory,结果Container申请被队列上限卡住,就是因为没算上overhead。
2.3 资源请求是怎么"登记"到RM的
YarnAllocator内部有一个updateResourceRequests()方法,它会在每次心跳前被调用,把当前需要申请的资源整理成AMRMClient.ContainerRequest列表。每个请求包含:
- 资源规格(memory + vCores)
- 优先级(priority)
- 节点偏好(可选,比如
spark.yarn.locality.wait相关的本地性偏好) - relaxLocality标志(是否允许放宽本地性)
然后通过amClient.addContainerRequest()逐个登记。注意,登记是幂等的——如果某个请求已经登记过且还没被满足,重复登记不会产生额外请求。这也是为什么你在日志里会看到AM反复打印"Requesting containers",但RM那边只认一份需求。
提示:如果你在RM的Web UI上看到某个应用的"Pending Resources"一直不降,说明AM登记的需求还没被满足,问题多半在队列容量、节点可用资源或者调度器策略上,而不是Spark本身。
2.4 心跳响应里回来的Container怎么处理
每次心跳,AMRMClientAsync的回调onContainersAllocated会被触发,把RM分配的Container列表交给YarnAllocator。YarnAllocator会做几件事:
- 过滤掉已经不需要的Container(比如任务已经完成、或者超过targetNumExecutors)
- 为每个Container分配一个Executor ID(形如
executor-1、executor-2) - 记录到
executorIdToContainer和allocatedHostToContainersMap - 更新
numExecutorsRunning计数 - 把Container交给
ContainerLauncher去启动
如果分配的Container数量超过了当前需要(比如任务在缩容),YarnAllocator会直接调用amClient.releaseAssignedContainer()把多余的还回去。这个"多退少补"的逻辑是动态资源分配(Dynamic Allocation)能工作的基础。
3. Container到手不等于Executor跑起来:启动阶段的三个关键角色
3.1 ContainerLauncher:启动动作的分发器
Container分配回来之后,真正负责"把Executor进程拉起来"的是ContainerLauncher。它是一个抽象类,在YARN场景下的实现是YarnContainerLauncher。它的核心方法是launchContainer(Container, ExecutorRunnable),内部会把启动任务丢进一个线程池(launcherPool)异步执行。
为什么要异步?因为启动一个Container涉及和NodeManager的网络通信,如果串行执行,一个慢节点会拖垮整个AM的心跳节奏。线程池大小由spark.yarn.containerLauncherMaxThreads控制,默认是25。如果你的任务需要一次性拉起几百个Executor,这个值可能需要调大,否则启动动作会排队,表现为"Container分配了但Executor迟迟不出现"。
3.2 ExecutorRunnable:真正拼装启动命令的地方
ExecutorRunnable是启动逻辑的核心。它的run()方法会做这几件事:
- 通过
prepareCommand()构造出完整的启动命令 - 通过
NMClient向NodeManager发起startContainer请求 - 注册
ContainerListener监听启动结果
prepareCommand()里拼出来的命令大致长这样(简化版):
/bin/java -server \ -Xmx4096m \ -Djava.io.tmpdir=/tmp/... \ -Dspark.executor.port=... \ org.apache.spark.executor.CoarseGrainedExecutorBackend \ --driver-url spark://CoarseGrainedScheduler@driver-host:port \ --executor-id 1 \ --hostname node-host \ --cores 4 \ --app-id application_xxx \ --resourceProfileId 0这里有几个关键点值得展开:
--driver-url:Executor启动后要反向连接Driver,这个地址必须从Driver侧可达。如果集群有多个网卡或者NAT,这里经常出问题,表现为Executor启动后连不上Driver然后自杀。--cores:告诉Executor它能用几个核,这个值来自spark.executor.cores,也是YARN调度时占用的vCore数。--resourceProfileId:Spark 3.1之后引入的ResourceProfile机制,用于支持更细粒度的资源规格(比如带GPU的Executor)。
3.3 NMClient:和NodeManager的最后一公里
NMClient(准确说是NMClientAsyncImpl)负责和NodeManager通信。它通过startContainer方法把启动请求发给NM,NM收到后会:
- 校验Container是否合法(是否属于这个应用、是否还没启动过)
- 在本地创建Container的工作目录
- 执行AM指定的启动命令
- 把启动结果通过
ContainerLauncherEvent回调给AM
这里有个非常实用的排查点:NM的container-launch.log和userlogs/<container-id>/目录。如果Executor启动失败,NM会在这里留下最原始的错误信息,比AM侧的日志更接近真相。我遇到过好几次"Executor启动即退出",最后都是在NM的userlogs里看到java.lang.OutOfMemoryError或者No such file or directory这类底层错误。
3.4 启动成功之后:Executor如何向Driver注册
Executor进程起来之后,CoarseGrainedExecutorBackend会通过--driver-url连上Driver的CoarseGrainedSchedulerBackend,发送RegisterExecutor消息。Driver收到后:
- 检查这个Executor ID是否合法
- 在
executorDataMap里登记 - 回复
RegisteredExecutor - Executor收到回复后创建真正的
Executor对象,开始等待Task
只有走到这一步,Executor才算真正"可用"。在此之前,即使Container已经分配、进程已经启动,Driver侧看到的numExecutors仍然是0。这也是为什么有时候你在YARN UI上看到Container在跑,但Spark UI上Executor列表是空的——多半卡在注册这一步。
4. 那些年踩过的坑:资源申请与启动阶段的典型故障排查
4.1 Container一直PENDING:从队列容量到本地性等待
这是最常见的一类问题。现象是AM日志里反复打印"Requesting containers",但onContainersAllocated迟迟不触发。排查顺序建议这样走:
| 排查点 | 观察位置 | 典型原因 |
|---|---|---|
| 队列容量 | RM Web UI的Queue页面 | 队列已满,或单Container超过队列上限 |
| 节点可用资源 | RM Web UI的Nodes页面 | 所有节点内存/vCore都不够 |
| 本地性等待 | AM日志中的locality关键字 | spark.locality.wait设太大,在等特定节点 |
| 调度器策略 | RM日志 | FIFO/Capacity/Fair策略下的排队 |
| 资源规格 | AM日志中的Requesting行 | 申请的内存超过节点单Container上限 |
其中本地性等待是最隐蔽的。Spark默认会为每个请求设置节点偏好,如果spark.locality.wait(默认3s)和spark.locality.wait.node等参数设得过大,AM会一直等"理想节点"而不接受其他节点上的Container。生产环境里我一般建议把spark.locality.wait调到1s以内,或者干脆设为0,让资源尽快到位,用数据本地性换启动速度。
4.2 Container分配了但启动失败:三类高频原因
Container分配成功(onContainersAllocated触发)但Executor没起来,通常有三类原因:
第一类是资源超限。NM在启动Container前会做一次本地校验,如果Container申请的内存超过了NM实际可用内存(比如NM配置的yarn.nodemanager.resource.memory-mb比RM认为的小),启动会被拒绝。这类问题在NM日志里会看到Container ... is running beyond physical memory limits或者Invalid resource request。
第二类是环境问题。比如Container工作目录磁盘满、java命令不在PATH里、spark.yarn.appMasterEnv.JAVA_HOME没配对。这类问题在NM的container-launch.log里能看到最直接的报错。
第三类是命令拼接错误。比如classpath里有特殊字符、参数里有空格没转义。这类问题比较少见,但一旦出现很难查,建议在AM日志里把prepareCommand拼出来的完整命令打出来对照。
4.3 Executor启动后立刻退出:注册失败与心跳超时
Executor进程起来了,但几秒后就消失,Spark UI上Executor列表一闪而过。这种情况八成是注册失败或心跳超时。
注册失败的典型日志是CoarseGrainedExecutorBackend: Failed to connect to driver或者Driver is not responding。原因可能是:
--driver-url里的地址Executor侧不可达(网络隔离、多网卡选错)- Driver的
spark.driver.host配错,导致广播出去的地址不对 - 防火墙拦截了Driver的RPC端口
心跳超时则是另一种:Executor注册成功了,但后续心跳发不出去,Driver认为它失联,主动kill掉。相关参数是spark.executor.heartbeatInterval(默认10s)和spark.network.timeout(默认120s)。如果集群网络抖动严重,可以适当调大这两个值,但别调太大,否则真正的故障会被掩盖。
注意:Executor被Driver主动kill和Executor自己崩溃,在YARN UI上都表现为Container退出,但日志位置不同。前者在Driver日志里能看到
Removing executor,后者在NM的userlogs里能看到进程退出码。
4.4 动态资源分配下的"申请-释放"抖动
开了spark.dynamicAllocation.enabled之后,YarnAllocator会根据待处理Task数量动态调整targetNumExecutors。如果spark.dynamicAllocation.sustainedSchedulerBacklogTimeout和spark.dynamicAllocation.schedulerBacklogTimeout设得太小,会出现"刚申请完就释放、刚释放又申请"的抖动,表现为Container频繁起停,浪费大量启动开销。
我的经验是:schedulerBacklogTimeout不要低于3s,sustainedSchedulerBacklogTimeout不要低于10s,给调度器足够的观察窗口。同时spark.dynamicAllocation.minExecutors要设一个合理下限,避免任务低谷期把Executor全释放掉,下一个波峰又要重新拉起。
5. 参数调优与源码阅读的实用建议
5.1 内存参数的正确算法
很多人调Executor内存是"拍脑袋",其实有个明确的算法。向YARN申请的Container内存是:
containerMemory = spark.executor.memory + spark.yarn.executor.memoryOverhead + spark.memory.offHeap.size (如果启用)其中memoryOverhead默认是max(executor.memory * 0.1, 384MB)。也就是说,如果你配spark.executor.memory=8g,overhead默认至少是819MB,实际申请约8.8GB。这个值必须小于队列的单Container上限和节点的单Container上限,否则申请永远无法满足。
反过来,spark.executor.memory本身还要分给Execution和Storage两块,由spark.memory.fraction(默认0.6)控制。所以真正能用于缓存和shuffle的内存是executor.memory * 0.6左右。调优时要把这条链路算清楚,别只看表面数字。
5.2 阅读这条源码链路的推荐路径
如果你想自己啃源码,我建议按这个顺序读,不容易迷路:
ApplicationMaster.scala的run()方法——看AM启动流程YarnAllocator.scala的构造函数和updateResourceRequests()——看资源请求怎么组织YarnAllocator.scala的handleAllocatedContainers()——看Container回来怎么处理ExecutorRunnable.scala的run()和prepareCommand()——看启动命令怎么拼YarnContainerLauncher.scala——看启动动作怎么异步分发CoarseGrainedExecutorBackend.scala的onStart()——看Executor侧怎么注册
读的时候建议配合实际任务的日志一起看,把日志里的关键字和源码里的打印语句对应起来,理解会快很多。比如看到Launching container就知道走到了ExecutorRunnable,看到Registered executor就知道注册成功了。
5.3 几个容易被忽视的配置项
除了上面提到的,还有几个配置在实际生产中很关键:
spark.yarn.am.cores:AM自己占用的核数,默认1。如果AM要做大量调度,可以调到2。spark.yarn.maxAppAttempts:AM失败重试次数,默认等于yarn.resourcemanager.am.max-attempts。调太大可能导致故障应用反复重试。spark.yarn.executor.failuresValidityInterval:Executor失败的有效期窗口,配合spark.yarn.max.executor.failures使用,避免偶发失败导致整个应用退出。spark.yarn.queue:指定队列,多队列集群里必须配,否则默认队列可能容量很小。
这些参数单独看都很简单,但组合起来对任务稳定性的影响很大。我的习惯是给每个业务线维护一份"基线配置",新任务从基线改起,避免每次都从默认值开始踩坑。
5.4 一个真实的排查案例复盘
最后分享一个我印象比较深的案例。某次一个批处理任务,200个Executor只起来了30个,剩下的Container分配了但启动失败。排查过程是这样的:
第一步,看AM日志,发现大量Container ... failed to launch,但没有具体原因。第二步,去NM的container-launch.log,看到Cannot create directory /data/yarn/nm/usercache/...。第三步,登录那台NM,发现/data分区磁盘满了。第四步,清理磁盘后重跑,问题消失。
这个案例的教训是:AM侧的日志往往只告诉你"失败了",真正的原因在NM侧。排查Container启动问题时,一定要养成"AM日志定位失败Container ID,NM日志找具体原因"的习惯。另外,NM的本地磁盘使用率应该纳入日常监控,别等出问题才发现。
再补一个经验:如果你的集群经常出现Container启动慢,可以检查一下spark.yarn.containerLauncherMaxThreads和NM的yarn.nodemanager.container-manager.thread-count。这两个值如果偏小,会成为启动阶段的瓶颈,表现为"资源明明够,但Executor就是起得慢"。