news 2026/10/17 7:28:12

Spark on YARN资源申请与Executor启动源码解析及调优实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spark on YARN资源申请与Executor启动源码解析及调优实践

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:按主机维度统计已分配的Container
  • pendingAllocate:这一轮需要向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会做几件事:

  1. 过滤掉已经不需要的Container(比如任务已经完成、或者超过targetNumExecutors)
  2. 为每个Container分配一个Executor ID(形如executor-1、executor-2)
  3. 记录到executorIdToContainer和allocatedHostToContainersMap
  4. 更新numExecutorsRunning计数
  5. 把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()方法会做这几件事:

  1. 通过prepareCommand()构造出完整的启动命令
  2. 通过NMClient向NodeManager发起startContainer请求
  3. 注册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收到后会:

  1. 校验Container是否合法(是否属于这个应用、是否还没启动过)
  2. 在本地创建Container的工作目录
  3. 执行AM指定的启动命令
  4. 把启动结果通过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收到后:

  1. 检查这个Executor ID是否合法
  2. 在executorDataMap里登记
  3. 回复RegisteredExecutor
  4. 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 阅读这条源码链路的推荐路径

如果你想自己啃源码,我建议按这个顺序读,不容易迷路:

  1. ApplicationMaster.scala的run()方法——看AM启动流程
  2. YarnAllocator.scala的构造函数和updateResourceRequests()——看资源请求怎么组织
  3. YarnAllocator.scala的handleAllocatedContainers()——看Container回来怎么处理
  4. ExecutorRunnable.scala的run()和prepareCommand()——看启动命令怎么拼
  5. YarnContainerLauncher.scala——看启动动作怎么异步分发
  6. 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就是起得慢"。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 7:27:51

RAG 2.0实战:构建生产级AI原生投诉处理系统

前阵子帮一位做跨境电商的朋友收拾他们那套客户投诉处理流程&#xff0c;几千条工单堆在系统里&#xff0c;人工回复平均要等四五个小时&#xff0c;用户满意度一路往下掉。他们之前也试过让大模型直接接客服&#xff0c;结果更惨——模型一本正经地承诺“您的包裹将在明天送达…

作者头像 李华
网站建设 2026/10/17 7:27:09

建筑生转AI产品经理:两周上岸实战指南

1. 转行这件事&#xff0c;先想清楚再动手前两天有个学建筑的学弟找我聊&#xff0c;说看到网上有人“0基础建筑生2周上岸AI产品经理”&#xff0c;问我是不是真的。我听完笑了笑&#xff0c;告诉他&#xff1a;这事儿是真的&#xff0c;但“0基础”和“2周上岸”这几个字&…

作者头像 李华
网站建设 2026/10/17 7:25:32

Qoder本地AI编程工作台:重构IDE开发范式的核心原理与实战

1. 项目概述&#xff1a;这不是“平替”&#xff0c;而是重新定义本地AI编程工作流Qoder 这个名字最近在开发者圈子里冒得很快&#xff0c;尤其当大家搜“Codex 平替”时&#xff0c;它几乎必然出现在前三条结果里。但说实话&#xff0c;我第一次看到这个标题时心里是皱眉的——…

作者头像 李华
网站建设 2026/10/17 7:25:26

石头科技AWE2026全场景清洁矩阵,智能清洁技术升级解析

如果你关注家电和智能清洁这个赛道&#xff0c;那今年这份从AWE2026展场传出来的信号&#xff0c;确实值得坐下来认真聊一聊。石头科技这次把扫地机器人、洗地机和商用清洁三大品类的新品放到同一场展会里集中亮相&#xff0c;整体看下来&#xff0c;不只是“发了几台新机器”这…

作者头像 李华
网站建设 2026/10/17 7:24:10

用 VS Code 开发 Spring Boot 实战指南:环境配置、热重载与调试技巧

这两年我写 Spring Boot 的方式&#xff0c;从商业 IDE 换成了 VS Code&#xff0c;而且越用越顺手。刚开始纯粹觉得它轻&#xff0c;打开快&#xff0c;后来把 Java 扩展包和 Spring Boot 扩展包配齐之后发现&#xff0c;日常写接口、调依赖、断点调试、跑测试&#xff0c;这一…

作者头像 李华
网站建设 2026/10/17 7:23:54

OpenCode 终端优先 AI 编程代理:安装配置、使用技巧与避坑指南

1. 为什么终端优先的AI编程代理值得关注第一次看到 OpenCode 这个项目的时候&#xff0c;我正处在对 IDE 插件式 AI 助手审美疲劳的阶段。Cursor、Copilot、Windsurf 这些工具确实好用&#xff0c;但它们都有一个共同点——你必须待在某个特定的编辑器里&#xff0c;接受它给你…

作者头像 李华