☰
Spark on YARN Container分配与Executor启动全链路源码解析
2026/10/17 3:19:25 网站建设 项目流程

1. 从一个调度延迟问题说起:为什么要啃 Spark on YARN 的 Container 分配链路

线上集群跑一个批处理任务,提交之后 ApplicationMaster 起来了,但 Executor 迟迟不分配,日志里只有一行Waiting for resources...,一等等了七八分钟。这种场景我相信做过 Spark on YARN 的人都遇到过。大多数人第一反应是去看 YARN 队列资源够不够、有没有别的任务在抢,但真正把问题定位到“Container 申请策略”和“Executor 启动握手”这两个环节的人并不多。原因很简单:Spark 提交到 YARN 之后,中间隔了 ResourceManager、NodeManager、ApplicationMaster、CoarseGrainedExecutorBackend 好几层,日志分散在多个地方,不把这条链路吃透,排查就是碰运气。

这篇内容就是围绕Spark on YARN 模式下 Container 资源申请分配、Executor 启动这条核心链路展开的。我会从源码层面把 ApplicationMaster 怎么向 ResourceManager 要资源、要多少、怎么退避重试,到 NodeManager 分配 Container 之后 Executor 怎么被拉起来、怎么反向注册回 Driver,整条链路拆开讲。适合已经用过 Spark 提交任务、但对底层调度机制一知半解的开发者和运维人员,也适合正在做资源调度优化、想把集群利用率提上去的工程师。读完你至少能搞清楚三件事:Container 是怎么被申请出来的、Executor 启动卡住时该看哪几个日志、以及哪些参数真正影响分配效率。

我自己的经验是,光看官方文档里那几行配置说明根本不够用,因为文档不会告诉你spark.yarn.executor.memoryOverhead和 YARN 的yarn.scheduler.minimum-allocation-mb之间如果对不齐会发生什么,也不会告诉你 AM 的numExecutorsPending在什么情况下会一直不下降。这些坑,只有把源码链路走一遍才能心里有数。

2. 整体设计思路拆解:ApplicationMaster 如何扮演资源谈判者的角色

2.1 两种运行模式下的角色差异

Spark on YARN 分两种模式:YARN Client 模式和YARN Cluster 模式。这两种模式下 ApplicationMaster 的职责完全不同,理解这一点是理解整条链路的前提。

在 Client 模式下,Driver 跑在提交任务的本地机器上,ApplicationMaster 只负责向 ResourceManager 申请 Container,然后告诉 Executor 去连哪个 Driver。这种模式下 AM 是个纯粹的“资源中介”,逻辑相对轻。而在 Cluster 模式下,Driver 本身就跑在 AM 里面,AM 既要申请资源,又要跑 SparkContext,还要处理 Executor 的反向注册。生产环境绝大多数用的是 Cluster 模式,因为 Client 模式下提交机器一断,整个任务就挂了。

我下面讲的链路以 Cluster 模式为主,因为这条链路更完整,Client 模式只是少了 Driver 启动那一段。

2.2 为什么采用“异步申请 + 轮询等待”的模型

Spark 的 ApplicationMaster 向 ResourceManager 申请资源,用的不是“一次要够”的同步模型,而是异步申请 + 心跳轮询的模型。具体来说,AM 启动后会注册一个AMRMClientAsync,通过它和 ResourceManager 保持心跳,每次心跳带上本次要新增的 Container 请求(addContainerRequest)和已经不需要的释放请求(releaseAssignedContainer)。

为什么这么设计?因为 YARN 的资源是动态的,集群里可能同时有几十个应用在抢资源,ResourceManager 不可能立刻满足你的全部请求。如果 AM 同步阻塞等资源,那 AM 就没法处理其他事情了,比如 Executor 的注册、任务的失败重试。所以 Spark 选择把资源申请做成异步的,AM 主线程继续跑自己的逻辑,资源到位了通过回调通知。

这个模型带来的一个直接后果就是:资源申请是分批到位的,不是一次性到位的。你申请 100 个 Executor,可能第一批只给你 10 个,过一会儿再给 20 个。这也是为什么有时候你看到 Executor 数量是慢慢涨上去的,而不是一下子全出来。

2.3 申请策略:一次性申请还是分批申请

这里有个很关键的取舍。Spark 在申请 Executor 的时候,默认是一次性把所有需要的 Executor 都申请出去(除非开了spark.dynamicAllocation.enabled)。也就是说,如果你要 100 个 Executor,AM 会在第一次心跳时就把 100 个 ContainerRequest 全部发给 ResourceManager。

为什么不一批一批要?因为分批要会导致任务启动变慢。假设你分 10 批要,每批之间要等一个心跳周期(默认 1 秒),那光申请资源就要多花 10 秒。而且 YARN 的调度器本身会做公平调度,你一次性把需求报上去,调度器能更好地做全局规划。

但一次性申请也有代价:如果集群资源紧张,你的 100 个请求可能长时间得不到满足,AM 会一直处于“等待资源”的状态。这时候就需要退避重试机制来避免 AM 疯狂空转。

2.4 退避机制的设计考量

Spark 的 AM 在资源没到位的时候,不是傻等着,而是有一个指数退避的逻辑。具体在ApplicationMaster的runExecutorLauncher和runDriver方法里,会调用waitForSparkContextInitialized或者类似的等待逻辑,配合spark.yarn.am.waitTime等参数控制等待节奏。

退避的核心目的是:减少无效的心跳压力。如果 AM 每秒都去问 ResourceManager“资源好了没”,在集群规模大的时候会给 RM 带来很大压力。所以 Spark 会在连续几次没拿到资源后,拉长重试间隔。这个逻辑在源码里体现为AMRMClientAsync的回调onContainersAllocated和onContainersCompleted的配合。

我实测下来,这个退避机制在集群繁忙时特别重要。有一次集群负载很高,一个任务等了 15 分钟才拿到全部资源,但因为退避做得好,AM 本身几乎没有额外开销,日志也很干净。

3. 核心细节解析:Container 申请与 Executor 启动的关键环节

3.1 ContainerRequest 的构造:资源规格怎么算

申请 Container 的第一步是构造ContainerRequest。这个对象里最关键的几个字段是:资源规格(Resource)、优先级(Priority)、节点约束(节点标签/机架感知)、Container 数量。

资源规格怎么算?这是很多人容易搞错的地方。Spark 申请 Executor Container 时,内存不是简单等于spark.executor.memory,而是要加上堆外内存(Overhead)。源码里在ExecutorRunnable和ApplicationMaster的资源计算逻辑中,实际申请的内存是:

executorMemory + executorMemoryOverhead + pysparkMemory(如果是PySpark)

其中executorMemoryOverhead默认是max(384MB, 0.1 * executorMemory)。也就是说,如果你设spark.executor.memory=4g,那实际申请的内存是4g + max(384MB, 409.6MB) = 4g + 409.6MB ≈ 4.4g。

这里有个大坑:YARN 的yarn.scheduler.minimum-allocation-mb会向上取整。假设你申请 4.4g,而 YARN 的最小分配单元是 1024MB,那实际会给你 5 个单元,也就是 5120MB。如果你没算清楚这个,很容易出现“明明申请了 4g,怎么占了 5g”的情况。

CPU 的算法相对简单,就是spark.executor.cores,但要注意 YARN 的yarn.scheduler.minimum-allocation-vcores也会影响实际分配。

3.2 节点约束与机架感知

Spark 在申请 Container 时,默认不做节点约束,也就是“哪里有空闲资源就给哪里”。但如果你开了spark.locality.wait相关的配置,或者用了 HDFS 的机架感知,Spark 会倾向于把 Executor 分配到数据所在的节点。

源码里这个逻辑体现在ApplicationMaster的getContainerRequest方法中,会根据spark.yarn.executor.nodeLabelExpression等参数决定是否加节点标签约束。节点标签是 YARN 的一个高级特性,可以把集群节点分组,比如“高内存组”“高 CPU 组”,然后让 Spark 任务只跑在特定组里。

我个人的建议是:除非你有明确的资源隔离需求,否则不要轻易加节点标签约束。因为一旦加了约束,可用的节点池就变小了,资源等待时间会明显变长。我见过一个案例,某团队给任务加了节点标签,结果集群里符合标签的节点只有 3 个,任务排队排了半小时。

3.3 Container 分配回调:onContainersAllocated 做了什么

当 ResourceManager 决定给你 Container 之后,会通过AMRMClientAsync的回调onContainersAllocated通知 AM。这个回调里做的事情非常关键,我把它拆成几步:

  1. 过滤无效 Container:检查 Container 的节点是否在黑名单里(比如之前失败的节点),如果是就释放掉。
  2. 匹配请求:把分配到的 Container 和之前发出的 ContainerRequest 做匹配,确认这个 Container 是给 Executor 的还是给 Driver 的。
  3. 启动 Executor 线程:为每个 Container 创建一个ExecutorRunnable,丢到线程池里执行。这个 Runnable 负责在 NodeManager 上启动 Executor 进程。
  4. 更新待分配计数:把numExecutorsPending减一,表示又有一个 Executor 有着落了。

这里有个细节:onContainersAllocated 是在 AMRMClientAsync 的回调线程里执行的,不是主线程。所以如果这个回调里做的事情太多(比如启动 Executor 很慢),会阻塞后续的心跳。Spark 的做法是把 Executor 启动放到单独的线程池里,回调线程只负责创建任务,不负责执行。

3.4 ExecutorRunnable:Executor 进程是怎么被拉起来的

ExecutorRunnable是 Executor 启动的核心类。它的run方法里做的事情,简单说就是:通过 NodeManager 的 ContainerManagementProtocol 启动一个 Container,在 Container 里执行一条启动 Executor 的命令。

具体步骤:

  1. 构造启动上下文:包括 Executor 的 JVM 参数、classpath、环境变量、要连接的 Driver 地址等。
  2. 准备本地资源:把 Spark 的 jar 包、配置文件等通过LocalResource的方式挂载到 Container 的工作目录。这一步很关键,因为 Executor 需要这些文件才能跑起来。
  3. 调用 startContainer:通过 RPC 调用 NodeManager 的startContainer方法,把启动上下文传过去。
  4. 等待启动结果:NodeManager 返回成功后,Executor 进程就开始在 Container 里跑了。

这里最容易出问题的是本地资源准备。如果 jar 包路径不对、权限不对,或者 HDFS 上的文件读不到,Executor 就会启动失败。日志里通常会报Failed to localize或者Could not find or load main class。

3.5 Executor 反向注册:怎么和 Driver 建立连接

Executor 进程启动之后,第一件事是向 Driver 反向注册。这个注册是通过CoarseGrainedExecutorBackend的onStart方法发起的,它会向 Driver 的CoarseGrainedSchedulerBackend发送RegisterExecutor消息。

Driver 收到注册请求后,会做几件事:

  1. 检查 Executor 是否重复注册:如果同一个 Executor ID 注册两次,会拒绝。
  2. 记录 Executor 信息:把 Executor 的地址、资源信息存到executorDataMap里。
  3. 回复注册成功:给 Executor 发RegisteredExecutor消息。
  4. 更新资源状态:通知 TaskScheduler 有新的 Executor 可用了。

只有注册成功之后,Executor 才会开始接受任务。如果注册失败,Executor 会重试,重试几次还不行就退出。这时候你在 AM 日志里会看到Executor ... failed to register之类的错误。

4. 实操过程:从提交任务到 Executor 就绪的完整链路

4.1 提交任务:spark-submit 背后发生了什么

当你执行spark-submit --master yarn --deploy-mode cluster的时候,客户端做的事情其实不多:

  1. 解析参数:把命令行参数和配置文件合并,生成SparkConf。
  2. 上传资源:把 Spark 的 jar 包、用户 jar 包、配置文件上传到 HDFS 的 staging 目录。
  3. 创建 ApplicationSubmissionContext:构造 YARN 应用的提交上下文,包括 AM 的 Container 规格、队列、优先级等。
  4. 提交应用:通过 YARN Client 把应用提交给 ResourceManager。

提交完成之后,客户端就可以退出了(Cluster 模式下),剩下的活都是 AM 干的。

这里有个实操细节:staging 目录的清理。Spark 默认会在任务结束后清理 staging 目录,但如果任务异常退出,可能清理不掉,时间长了 HDFS 上会堆一堆垃圾。建议定期检查spark.yarn.stagingDir配置的目录,手动清理旧文件。

4.2 AM 启动:第一个 Container 是怎么来的

ResourceManager 收到应用提交后,会先分配一个 Container 给 ApplicationMaster。这个 Container 的规格由spark.yarn.am.memory和spark.yarn.am.cores决定(Cluster 模式下是spark.driver.memory和spark.driver.cores)。

AM 启动后,执行ApplicationMaster.main方法,里面会根据部署模式选择runExecutorLauncher或runDriver。Cluster 模式下走runDriver,里面会:

  1. 创建 AMRMClient:注册到 ResourceManager,开始心跳。
  2. 创建 NMClient:用于后续和 NodeManager 通信。
  3. 启动用户程序:也就是你的 Spark 应用代码,创建 SparkContext。
  4. 申请 Executor 资源:根据配置计算需要的 Executor 数量,发出 ContainerRequest。

这一步的日志在 AM 的 stderr 里,通常可以通过 YARN 的 Web UI 或者yarn logs -applicationId查看。

4.3 资源申请:ContainerRequest 的发出与等待

SparkContext 创建之后,CoarseGrainedSchedulerBackend会调用requestExecutors方法,计算出需要的 Executor 总数,然后通过ApplicationMaster的addContainerRequest发出请求。

请求发出后,AM 进入等待状态。等待的逻辑在ApplicationMaster的runDriver方法里,会循环检查numExecutorsPending是否为零。如果不为零,就继续等,同时通过心跳和 ResourceManager 交互。

这里有个参数很关键:spark.yarn.am.waitTime,默认是 100 秒。这个参数控制 AM 在启动 SparkContext 之前等待的时间。如果你的应用启动很慢,可能需要调大这个值。

4.4 Container 分配:从 RM 到 NM 的接力

ResourceManager 的调度器(Capacity Scheduler 或 Fair Scheduler)会根据队列资源、应用优先级、节点可用资源等因素,决定给哪个应用分配 Container。分配决策做出后,RM 会把 Container 信息通过心跳响应发给 AM。

AM 收到onContainersAllocated回调后,为每个 Container 创建ExecutorRunnable,丢到线程池。ExecutorRunnable会通过NMClient调用 NodeManager 的startContainer方法。

NodeManager 收到请求后,会在本地创建 Container 的工作目录,准备好 LocalResource,然后启动 Executor 进程。启动命令大致是这样的:

/bin/java -server -Xmx4096m -Djava.io.tmpdir=... \ -cp ... org.apache.spark.executor.CoarseGrainedExecutorBackend \ --driver-url spark://CoarseGrainedScheduler@driver-host:port \ --executor-id 1 \ --hostname executor-host \ --cores 4 \ --app-id application_xxx \ --worker-url yarn://...

这个命令里的参数都是从 AM 传过来的,任何一个参数不对,Executor 都起不来。

4.5 Executor 启动与注册:最后一步的握手

Executor 进程启动后,CoarseGrainedExecutorBackend的main方法会解析参数,创建Executor对象,然后向 Driver 发送RegisterExecutor消息。

Driver 收到消息后,检查通过,回复RegisteredExecutor。Executor 收到回复后,创建一个线程池,开始等待任务。至此,一个 Executor 就算完全就绪了。

整个链路走完,从提交任务到第一个 Executor 就绪,在资源充足的情况下通常需要 10 到 30 秒。如果资源紧张,可能几分钟甚至更久。

5. 常见问题与排查技巧实录

5.1 Executor 一直不分配:先看这三个地方

Executor 迟迟不分配是最常见的问题。我的排查顺序是:

  1. 看 YARN 队列资源:通过 YARN Web UI 或者yarn queue -status查看队列的可用资源、已用资源、待分配资源。如果队列资源已经满了,那就是资源不够,只能等或者加资源。
  2. 看 AM 日志:AM 日志里会打印Waiting for resources或者numExecutorsPending: N,N 一直不降就说明资源没到位。
  3. 看 ResourceManager 日志:如果 RM 日志里有Skipping allocation或者Cannot allocate之类的信息,说明调度器层面有问题,可能是队列配置、ACL 权限等。

我遇到过一次,队列资源明明够,但 Executor 就是不分。最后发现是队列的maximum-am-resource-percent配置太小,导致 AM 占用的资源比例超了,新的 Container 分配不出去。这个参数控制队列里 AM 能占用的最大资源比例,默认是 10%,如果 AM 多了,就会卡住。

5.2 Executor 启动失败:日志在哪看

Executor 启动失败的原因很多,日志分散在几个地方:

  • AM 日志:会记录ExecutorRunnable的执行结果,如果startContainer失败,这里会有错误。
  • NodeManager 日志:会记录 Container 的启动过程,如果本地资源准备失败,这里会有详细信息。
  • Executor 自己的日志:如果 Executor 进程起来了但很快退出,它的 stderr 会在 Container 的日志目录里,通常可以通过 YARN Web UI 的 Container 链接找到。

常见的失败原因和对应日志关键词:

失败原因日志关键词解决方法
内存不足Container killed by YARN for exceeding memory limits调大spark.executor.memoryOverhead
jar 包找不到Could not find or load main class检查spark.yarn.jars配置
本地资源本地化失败Failed to localize检查 HDFS 权限和网络
Driver 地址不对Connection refused检查spark.driver.host配置
注册超时Executor registration timeout调大spark.executor.heartbeatInterval

5.3 内存超限被 kill:Overhead 到底该设多少

Container killed by YARN for exceeding memory limits这个错误我见过太多次了。根本原因是Executor 实际用的内存超过了申请的 Container 内存。

YARN 的 Container 内存监控是物理内存 + 虚拟内存双重监控。Spark 的 Executor 除了堆内存,还有堆外内存、JVM 元空间、线程栈等开销。如果spark.executor.memoryOverhead设得太小,就容易被 kill。

我的经验值是:Overhead 至少设成 executor memory 的 15%,如果用了 PySpark 或者有大量堆外操作,设成 20% 到 25%。比如spark.executor.memory=8g,那spark.executor.memoryOverhead至少设 1.2g,保险起见设 2g。

另外,YARN 的yarn.nodemanager.vmem-check-enabled如果开着,虚拟内存超限也会 kill Container。有些 JVM 的虚拟内存占用比物理内存大很多,这时候可以考虑关掉虚拟内存检查,但生产环境要谨慎。

5.4 Executor 注册失败:网络和配置的坑

Executor 启动成功但注册失败,通常和网络有关。常见原因:

  • Driver 地址不可达:Cluster 模式下 Driver 跑在 AM 里,AM 的地址是 YARN 分配的,如果spark.driver.host配置不对,Executor 就连不上。
  • 端口被防火墙挡了:Driver 的 RPC 端口需要 Executor 能访问,如果集群有防火墙规则,要确保端口开放。
  • 心跳超时:spark.executor.heartbeatInterval默认 10 秒,如果网络延迟大,可能超时。可以调大到 30 秒。

我踩过一次坑:集群开了网络策略,AM 和 Executor 不在同一个网段,结果 Executor 一直注册不上。后来在 AM 启动参数里加了spark.driver.bindAddress和spark.driver.host的显式配置才解决。

5.5 动态资源分配下的特殊问题

开了spark.dynamicAllocation.enabled之后,Executor 的申请和释放是动态的,问题也更复杂:

  • Executor 被释放后任务重算:如果 Executor 被释放时还有 shuffle 数据没被消费,任务会重算。需要配spark.dynamicAllocation.shuffleTracking.enabled或者外部 shuffle 服务。
  • 最小 Executor 数不生效:spark.dynamicAllocation.minExecutors控制最小 Executor 数,但如果资源紧张,可能连最小数都保不住。
  • 申请延迟:动态分配下,Executor 是按需申请的,第一个任务可能会等一会儿才有 Executor。

动态分配适合负载波动大的场景,但如果你的任务对延迟敏感,建议还是用固定 Executor 数。

6. 参数调优与集群适配的实战经验

6.1 关键参数速查与推荐值

下面这张表是我在实际项目中总结的常用参数和推荐值,供参考:

参数默认值推荐值说明
spark.executor.memory1g4g-8g根据任务类型调整
spark.executor.memoryOverheadmax(384MB, 0.1*memory)0.15-0.25*memory防止内存超限
spark.executor.cores14-5太多会导致 HDFS 吞吐下降
spark.yarn.am.memory1g2g-4gCluster 模式下是 driver memory
spark.yarn.am.waitTime100s100s-300s应用启动慢时调大
spark.locality.wait3s3s-10s数据本地性等待时间
spark.executor.heartbeatInterval10s10s-30s网络差时调大

6.2 集群资源对不齐的典型场景

场景一:YARN 最小分配单元大于 Spark 申请值。比如 YARN 配了yarn.scheduler.minimum-allocation-mb=2048,而你申请spark.executor.memory=1g,那实际会给你 2g。这时候你的 Executor 数量会比预期少一半。

场景二:队列最大资源限制。队列配了yarn.scheduler.maximum-allocation-mb=8192,而你申请 16g,那 Container 根本分配不出去,AM 会一直等。

场景三:节点可用资源碎片化。集群里每个节点剩 2g 内存,但你要 4g 的 Container,那就分配不出来。这时候要么调小 Executor 内存,要么等大节点空出来。

6.3 提高分配效率的几个技巧

  1. 合理设置 Executor 数量:不要一次申请太多,超过集群容量会导致长时间等待。可以先申请一部分,跑起来再动态加。
  2. 避免过度使用节点标签:节点标签会缩小可用节点池,除非必要不要用。
  3. 调整调度器队列配置:如果队列资源经常不够,考虑调整队列的容量和最大容量。
  4. 监控 AM 的等待时间:通过 Spark 的 Metrics 或者 YARN 的 Timeline Server 监控 AM 等待资源的时间,及时发现瓶颈。

6.4 日志排查的黄金路径

最后分享一个我常用的日志排查路径:

  1. YARN Web UI:找到应用,看 AM 的日志链接。
  2. AM 日志:搜索numExecutorsPending、Waiting for resources、onContainersAllocated。
  3. NodeManager 日志:根据 Container ID 找到对应的 NM 日志,搜索startContainer、localize。
  4. Executor 日志:在 Container 日志目录里找stderr和stdout,搜索RegisterExecutor、ERROR。

这条路径走下来,90% 的 Executor 启动问题都能定位到。

我个人在实际操作中的体会是,Spark on YARN 的 Container 分配和 Executor 启动这条链路,难点不在于代码有多复杂,而在于涉及的组件多、日志分散、参数相互影响。把这条链路吃透之后,再遇到资源分配问题,基本能做到看一眼日志就知道卡在哪一环。另外一个小技巧:在测试环境把spark.yarn.am.waitTime调大一点,给 AM 更多时间收集日志,排查起来会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询