文章摘要
飞宇 GPT

Spark on Kubernetes vs YARN:两种部署模式的选型复盘与深度拆解

一、为什么这篇值得写

去年我把一个日跑 2 万+ Spark 作业的数仓从 YARN 迁到 Kubernetes,过程里踩的坑比写十个 Hadoop 深度系列还多 。当时团队内部争论了三个月:到底是继续堆 YARN,还是全面转向 Spark on K8s。这篇文章就是把那次争论和后续落地一次性复盘清楚,给同样在云原生改造路口的大数据同学一个可参考的决策路径。

先说结论:这两种部署模式不是替代关系,而是适用场景不同。YARN 在 HDFS 本地性密集、批量 ETL 占主导的存算一体集群里依然是首选;K8s 在存算分离、多租户隔离要求高、与 AI/在线业务混部时优势明显。下面我一层层拆。

二、架构本质差异:谁来调度 executor

先上一张我画的调度链路对比图,看懂这张图,后面所有差异都是它的延伸。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
      YARN 模式                          Kubernetes 原生模式

Client / Driver Client / spark-submit
| |
| 1. submit to RM | 1. submit to kube-apiserver
v v
+-----------+ +-----------------+
| ResourceManager | kube-apiserver |
| (全局调度) | | (etcd + API) |
+-----------+ +-----------------+
| ^
| 2. 分配 container | 2. driver pod 内
v | KubernetesClusterScheduler
+-----------+ 3. 启动 +-----------+ 直接 watch/create executor pod
| NodeManager|<--------+ | driver pod|
| (NM, 每节点)| | | (内置 K8s |
+-----------+ | | client) |
| | +-----------+
| 4. 拉起 ^
| ExecutorLauncher | 3. create pods (per executor)
| (ApplicationMaster) | 通过 fabric8 K8s Java client
v +-----------+
+-----------+ | executor |
| Executor |<..................| pods |
| container | 5. NMClient +-----------+
+-----------+ async 启动 ^
| | kubelet 拉镜像、起容器
v v
本地 HDFS 节点本地盘 / PVC / 对象存储

关键差异在于谁拥有”创建 executor 的主动权”:

  • YARN:ResourceManager 是唯一的调度仲裁者。driver 提交后,RM 负责找一个有资源的 NM,先把 ApplicationMaster(在 cluster 模式下就是 driver 本身,client 模式下是 ExecutorLauncher)拉起来,AM 再通过 NMClient 心跳式地向 RM 申请 container,拿到 container 后异步通知对应 NM 启动 executor。整个链路里 driver 必须经过 RM,自己不能直接在某台机器上起 container。

  • K8s 原生模式:从 Spark 2.3 引入、3.1 起进入 stable 的 k8s:// scheme,让 driver pod 内部持有一个 fabric8 Kubernetes Java client,直接对 kube-apiserver 发起 pod 创建/watch 请求。也就是说,driver 把 K8s 当成了”executor 的资源池”,跳过了 YARN RM 这一层集中式仲裁。

这个差异决定了后面三个核心后果:调度延迟来源不同、shuffle 数据本地性策略不同、动态资源分配的实现完全不同。

还有一点容易被忽略:YARN 的 ApplicationMaster 本身也占用资源,在 cluster 模式下它就是 driver,吃的是 yarn.app.mapreduce.am.resource.mb 配置的那块;而 K8s 模式下 driver pod 和 executor pod 平等地存在于同一个 namespace,资源记账口径一致,都是 pod 的 request/limit。这看似细节,但在做容量规划时会影响你对”一个作业到底占多少资源”的估算——YARN 作业的 AM 开销是隐式的,K8s 是显式的。

三、Pod 生命周期:一次作业从提交到清理

K8s 模式下,executor pod 的生命周期比 YARN container 要复杂得多。我在踩坑时画过一张图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
spark-submit (client mode 在外部, cluster mode 起一个 driver pod)
|
| POST /api/v1/namespaces/.../pods (driver pod, owner=job/run)
v
[driver pod: Pending] -- kubelet 调度到节点 --> [Running]
|
| driver main() 启动 SparkContext
| -> KubernetesClusterScheduler.createExecutors()
| -> 按 --num-executors 或动态分配目标数, 逐个 POST executor pod
v
[executor pods: Pending x N] <-- PodGroup / 调度队列
| |
| kubelet pull image (秒级~分钟级, 首次拉远端镜像最慢)
v v
[executor pods: Running] 失败则 backoff 重试, 默认 4 次
|
| executor 向 driver 注册 (反向注册 via RPC)
| driver 分配 task, 计算进行
v
[作业完成 / 失败 / kill]
|
| driver pod 退出码决定清理:
| - --conf spark.kubernetes.executor.deleteOnSuccess=true
| - 失败 pod 默认保留以便看日志 (kubectl logs / describe)
v
[executor pods: Succeeded/Failed]
|
| OwnerReference 链: driver pod <- executor pods
| driver 被删 -> cascading delete executor
v
[资源回收 / PVC 释放 (若用 PVC)]

实测里我得到过这样一组数字:K8s 上一个 executor pod 从提交到 Running 大约 25 秒,而 YARN container 在节点资源充足时只要 5 秒 。这 20 秒的差距,主要消耗在两处:

  1. 镜像拉取:第一次跑某个 Spark 镜像,daemonset 预热不到的话,要等镜像层从 registry 下完。生产环境必须用本地 registry 或预拉镜像(spark.kubernetes.executor.podNamePattern 配合 image preload)。
  2. K8s 调度器决策:默认 scheduler 是逐 pod 调度,没有 gang scheduling 语义。一个 200 executor 的大作业,调到一半发现资源不够,前面已经起的 executor 会空跑等待——这是裸用 K8s scheduler 最大的坑。

YARN 没有镜像问题(jar 在 HDFS 上,NM 本地化即可),而且 RM 的调度是一次性把所有 container 在调度循环里处理完。这就是为什么 YARN 在批量 ETL 场景下体感更快。

还有一个生命周期细节值得提:K8s 的 executor pod 失败重试语义和 YARN 完全不同。YARN 里 executor 挂了,AM 会重新向 RM 申请一个新 container,作业无感继续;K8s 里 executor pod 挂了,driver 通过 watch api 发现 pod 状态变化,然后决定是否重建,重建的 pod 又要走一遍调度和镜像拉取。如果配了 spark.kubernetes.executor.deleteOnSuccess=false,成功的 pod 会保留在 etcd 里,作业多了会撑爆 etcd —— 这个坑我踩过,最后靠定时清理脚本兜底。

四、动态资源分配:YARN ESS vs K8s shuffle tracking

动态资源分配(Dynamic Allocation,下称 DA)是决定作业成本的关键特性。两边的实现差异,是我整个迁移过程中最容易被低估的陷阱。

YARN 上的 DA:依赖 External Shuffle Service(ESS)。ESS 是每个 NM 上常驻的一个 AuxiliaryService,executor 被回收后,它的 shuffle 文件还留在 NM 本地盘,下游 reduce 阶段直接通过 ESS 读到。正因为 ESS 兜底,YARN 才敢激进地回收空闲 executor——shuffle 数据不会丢。

K8s 上的 DA:早期 K8s 没有 ESS(你不能在每个节点上常驻一个 HDFS 的 auxiliary service),所以 DA 长期不能在 K8s 上用。Spark 3.0 引入了 shuffle tracking:driver 自己记录每个 shuffle block 的位置,executor 被回收前,driver 确保所有依赖这个 executor 的 shuffle 数据已经被下游消费完,或者把 shuffle 文件迁移到其他存活的 executor / 外部存储。

配置上是这套:

1
2
3
4
5
6
7
8
# spark-submit 通用部分
--conf spark.dynamicAllocation.enabled=true \
--conf spark.dynamicAllocation.minExecutors=10 \
--conf spark.dynamicAllocation.maxExecutors=300 \
--conf spark.dynamicAllocation.initialExecutors=20 \
--conf spark.shuffle.service.enabled=false \
--conf spark.dynamicAllocation.shuffleTracking.enabled=true \
--conf spark.dynamicAllocation.shuffleTracking.timeout=300s \

注意 spark.shuffle.service.enabled=false —— 这是 K8s 模式的标志位,关掉 ESS,开 shuffle tracking。两边互斥,不能同时开。

实战代价:shuffle tracking 在 K8s 上会让 executor 的回收变得保守。我观察到在 skewed 作业上,K8s 的 executor 空闲后回收延迟比 YARN 高 30%-50%,因为它要等 shuffle 数据真正被消费。对于短作业多的场景,这个保守性会吃掉一部分弹性收益。

更隐蔽的差异在于 DA 的申请节奏。YARN 的 AM 通过心跳向 RM 要 container,RM 在一个调度周期内批量分配,申请和释放是同一个链路里的;K8s 模式下 driver 每次 executor 变更都要走 kube-apiserver,etcd 写入压力随作业规模线性增长。我在一个 500 executor 的作业上观察到,DA 在扩缩容时 API server 的 QPS 会瞬间飙升,必须给 driver pod 配 spark.kubernetes.apiServerRequestTimeout 和合理的重试策略,否则会触发限流。生产环境建议给 kube-apiserver 单独预留 buffer,或者上 Volcano 的批量调度减少 API 压力。

五、Shuffle 落地:本地性丧失是最大的隐性成本

这是整个选型里我认为最硬核、也最少被讲清楚的一点。

YARN + HDFS 的世界:shuffle 文件写在 NM 本地盘,ESS 也在本地,下游 executor 拉 shuffle 数据有大概率走本地短路读。数据本地性是 Hadoop 生态的灵魂,一切调度优化都围绕”把计算送到数据旁边”。

K8s 的世界:pod 是不可迁移的,被调度到哪个节点就钉死在那里。K8s 不知道你的 HDFS 块在哪里,也不知道你的 shuffle 数据在哪里。executor pod 一旦被驱逐,本地盘上的 shuffle 数据全部丢失。这就是 shuffle 数据本地性丧失

落地时有三种主流方案,各有代价:

方案 优点 缺点 适用场景
emptyDir + 本地盘 性能最好,零外部依赖 pod 删了 shuffle 全丢,不能开 DA 短作业、无 DA、固定规模
PVC (ReadWriteOnce) shuffle 持久化,可跨 pod 复用 PVC 绑定节点,限制调度弹性;CSI 性能差异大 中规模、需要 DA、有快 CSI
对象存储 (S3/OSS/COS) 存算分离、无限容量 延迟高,shuffle 写放大严重,成本敏感 云原生、跨 region、AI 训练数据准备

我的实战选择是:热数据用本地盘 emptyDir,冷 shuffle 落 PVC,最终结果落对象存储。代价是作业配置变复杂,要分 stage 指定存储位置。如果你走存算分离路线,shuffle 性能损失 15%-25% 是预期内的

还有一个本地性层面的细节:YARN 的延迟调度(delay scheduling)在 K8s 上完全失效。YARN 里 NM 会把节点位置信息上报给 RM,AM 拿到 container 后能感知数据本地性,优先把 task 分到数据所在节点;哪怕第一轮没调度到,也能等几轮拿到 locality。K8s 调度器只看资源余量,不感知数据位置,spark-submit 里那些 --conf spark.locality.wait=30s 配置在 K8s 模式下基本是摆设。这就是为什么存算一体集群迁到 K8s 后,shuffle 读的跨节点流量会肉眼可见地涨——HDFS 的短路读也没了,所有读都走网络。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# executor pod template, 挂本地盘 + shuffle PVC
apiVersion: v1
kind: Pod
metadata:
labels:
spark-role: executor
app: spark-shuffle
spec:
containers:
- name: executor
image: my-registry/spark:3.5.1
resources:
requests:
cpu: "4"
memory: "16Gi"
limits:
cpu: "4"
memory: "16Gi"
volumeMounts:
- name: shuffle-local
mountPath: /mnt/shuffle
- name: shuffle-pvc
mountPath: /mnt/shuffle-pvc
volumes:
- name: shuffle-local
hostPath:
path: /data/spark/shuffle
type: DirectoryOrCreate
- name: shuffle-pvc
persistentVolumeClaim:
claimName: spark-shuffle-pvc
nodeSelector:
spark-worker: "true"

六、资源隔离与弹性调度

YARN 的隔离:基于 cgroups(CPU)和 Linux container(内存)。NM 配置 yarn.nodemanager.resource.cpu-vcoresmemory-mb,超额会触发 OOM kill。YARN 的隔离粒度是 container 级,多年生产验证,相对稳定。但 YARN 的弹性调度能力很弱——队列层级是静态的,capacity scheduler 调整要 reload,节点 label(NodeLabel)配置繁琐。

K8s 的隔离:原生支持 CPU/memory 的 request/limit,配合 namespace 做租户隔离,配合 NetworkPolicy 做网络隔离,配合 RBAC 做权限隔离。隔离维度远超 YARN。更重要的是 K8s 的弹性调度能力:HPA、VPA、Cluster Autoscaler、Karpenter,配合节点池可以做真正的按需扩缩容。

YARN 的 NodeLabel 也能做节点分区,但配置链路冗长。对比如下:

1
2
3
4
5
6
# YARN NodeLabel: RM 端添加 label, NM 端上报, 队列绑定 label
yarn rmadmin -addToClusterNodeLabels "gpu(exclusive=true),cpu(exclusive=false)"
yarn rmadmin -replaceLabelsOnNode "node-01.example.com,gpu"
# capacity-scheduler.xml 里再配
# <name>yarn.scheduler.capacity.root.etl.accessible-node-labels</name>
# <value>gpu,cpu</value>

K8s 对应的是一行 nodeSelector 或 nodeAffinity:

1
2
3
4
# K8s 节点亲和, 一行搞定
nodeSelector:
node-label: gpu
# 或用 nodeAffinity 做更复杂的表达

同样的”把作业调度到指定节点分区”这件事,YARN 要改三处配置并 reload scheduler,K8s 只要在 pod spec 里写一行。这是声明式 API 相对命令式配置的代差级优势。

我在迁移后做到的一件事:集群平均 CPU 利用率从 60% 提升到 75% 。核心原因是 K8s 允许 Spark 和在线服务混部(通过 priority class + preemption),离线潮汐填满了在线业务的谷段。这在 YARN 上几乎不可能——YARN 节点和在线 K8s 节点是物理隔离的两套集群。

但混部也带来新问题:Spark executor 抢占在线 pod 的情况发生过,最后靠 PriorityClass + spark.kubernetes.executor.podNamePattern + 节点亲和性才压住。

对比 YARN 的资源模型:YARN 的 yarn.nodemanager.resource.cpu-vcores 是静态的,节点上线就钉死,改一次要重启 NM。K8s 的节点资源是动态发现的,kubelet 上报实际 allocatable,新增节点立即纳入调度。这在云上弹性节点池场景下差距巨大——K8s 可以根据作业队列深度触发 Cluster Autoscaler 扩节点,YARN 只能靠人工或脚本预扩容。我的经验是:固定规模集群选 YARN,弹性规模集群选 K8s,这是最朴素的判断标准。

七、调度器增强:Volcano 与 YuniKorn

原生 K8s scheduler 不懂大数据作业的语义,它只看到一堆独立的 pod。这对 Spark 是致命的——一个大作业需要 200 个 executor 同时起,但 K8s 调度器会逐个调度,可能调到 150 个就因为节点碎片化卡住,前面 150 个空跑烧钱。

社区给的两个解法:

Volcano(华为开源,CNCF 孵化):核心是 gang schedulingPodGroup 资源)。所有 executor 属于同一个 PodGroup,要么全调度成功,要么全不调度。配合 scheduling.x-k8s.io/pod-group annotation 使用。Volcano 还提供 bin-packing 算法,把同一作业的 executor 尽量堆到同一批节点,减少跨节点 shuffle。

Apache YuniKorn(来自 Yahoo/Hortonworks):定位是”大数据调度器跑在 K8s 上”,提供 queue 层级、公平调度、抢占语义,把 YARN 的 capacity/fair scheduler 模型搬到 K8s。配置上接近 YARN 的 capacity-scheduler.xml 风格,迁移成本低。

1
2
3
4
5
# 用 Volcano 跑 Spark 的提交参数
--master k8s://https://<api-server> \
--conf spark.kubernetes.scheduler.name=volcano \
--conf spark.kubernetes.scheduler.volcano.podGroupTemplatePath=/path/to/podgroup.yaml \
--conf spark.kubernetes.executor.podNamePrefix=my-spark-job \

我的建议:生产环境跑 Spark on K8s,必须上 Volcano 或 YuniKorn,不要裸用 default scheduler。这点没有妥协空间。

两者的取舍我也说下:Volcano 更通用,是通用批处理调度器,跑 Spark、MPI、TensorFlow 都行,社区活跃度高;YuniKorn 更专注大数据语义,queue 模型直接对齐 YARN 的 capacity scheduler,从 YARN 迁移的团队几乎可以平移配置。我最后选了 Volcano,原因是公司 AI 训练作业也在同一个 K8s 集群,Volcano 能统一调度 Spark 和训练任务。如果你的集群只跑 Spark,YuniKorn 的迁移成本更低。

八、安全与多租户

YARN 的安全模型基于 Kerberos + HDFS ACL + 队列权限。成熟但繁琐,Kerberos 的运维是出了名的痛。

K8s 的安全模型更现代:ServiceAccount + RBAC + Namespace + NetworkPolicy + Secret。每个租户一个 namespace,ServiceAccount 绑定到 executor pod,通过 RBAC 限制它只能在自己的 namespace 里起 pod。镜像仓库用 imagePullSecret 管控。

但 K8s 的安全有个隐性坑:driver pod 拥有在 namespace 里创建 pod 的权限,如果租户之间共享 namespace,一个恶意 driver 可以创建特权 pod 逃逸。所以多租户必须严格 namespace 隔离,而且要配 PodSecurityPolicy(1.25 后是 PodSecurity admission)禁掉 privileged。

Hive Metastore 的接入也是个坑。YARN 模式下 Spark 直接用 kerberos ticket 访问 HMS;K8s 模式下 pod 是临时的,token 怎么传?我们的方案是用 Kubernetes Secret 存 keytab,通过 initContainer 做 kinit,pod 启动时拿到 TGT。

YARN 的 Kerberos 也有它的痛——keytab 分发到 NM 本地、renew 逻辑、delegation token 的刷新,每一环都有坑。但 YARN 的优势是整个 Hadoop 生态围绕 Kerberos 建立了一套约定俗成的运维流程,遇到问题能查到的资料多。K8s 上的 Spark 安全模型还在演进,社区对 HDFS delegation token 在 pod 生命周期内的刷新机制(spark.kerberos.hadoopfs.credentials.path)有过多次 breaking change,升级 Spark 版本时要重点测试。

九、运维成本对比

这是最容易在选型阶段被低估的部分。直接上对比表:

维度 YARN K8s 备注
部署复杂度 中(RM+NM+HDFS) 高(etcd+control plane+CNI+CSI+Ingress) K8s 控制面运维门槛高
监控 成熟(JMX + Prometheus exporter) 成熟(cAdvisor + kube-state-metrics) 都有,但 K8s 指标更细
日志 NM 聚合到 HDFS DaemonSet 采集到 Loki/ES K8s 日志是 pod 级、临时的
升级 滚动重启 NM,影响在途作业 滚动升级节点,驱逐 pod K8s 升级对在途作业更狠
故障排查 看 NM 日志、container exit code kubectl describe/logs/events K8s 的 event 是金矿
镜像管理 镜像仓库 + CVE 扫描 K8s 多一层供应链负担
人员要求 大数据工程师 大数据 + SRE + K8s K8s 模式团队必须有 SRE

YARN 的运维可以由大数据团队独立扛下来;K8s 模式必须有专职 SRE 或平台团队兜底。如果你公司没有 K8s 平台团队,不要硬上 Spark on K8s。

十、选型决策矩阵

把我前面所有维度浓缩成一张决策矩阵。给每个维度打 1-5 分(5 优),最后加权求和。这是我实际在内部评审时用过的模板。

维度 权重 YARN 得分 K8s 得分 说明
HDFS 数据本地性 0.15 5 2 shuffle 短路读是 YARN 的核心优势
弹性扩缩容 0.15 2 5 K8s + Cluster Autoscaler 完胜
资源利用率(混部) 0.15 2 5 K8s 能和在线业务混部
多租户隔离 0.10 3 5 K8s namespace + RBAC 更强
作业启动延迟 0.10 5 3 YARN container 5s vs pod 25s
运维门槛 0.10 4 2 YARN 更简单
AI/流计算融合 0.10 2 5 K8s 是 AI 工作负载的事实标准
生态成熟度 0.10 5 4 YARN 久经考验,K8s 已 stable 但年轻
云原生一致性 0.05 2 5 存算分离、多云一致的赢家是 K8s
加权总分 1.00 3.45 3.85 看场景,差距没有想象中大

注意:权重必须按你的实际业务调整。如果你的集群是纯 ETL + HDFS 本地盘,把 HDFS 本地性权重提到 0.25,YARN 会反超。

十一、小结:Spark 十篇系列的收束

这篇是 Spark 深度系列的一个收束,也是整个大数据技术复盘的一个节点。回头看这十篇 Spark 系列——从 RDD 原理、Shuffle 机制、SQL/Catalyst、内存管理、Adaptive Query Execution、Structured Streaming、动态资源分配,一直到今天这篇部署选型——其实画出来的是一张大数据/AI 工程能力地图

  • 底层引擎层:理解 RDD、Shuffle、内存模型,知道 spark-submit 背后发生了什么;
  • SQL/优化层:Catalyst、AQE、运行时自适应,让作业跑得更快;
  • 流式/AI 层:Structured Streaming、MLlib,让 Spark 延伸到实时和机器学习;
  • 调度/部署层:就是今天这篇,YARN vs K8s,决定引擎跑在什么底座上;
  • 存储/数据层:HDFS、对象存储、Iceberg/Hudi,决定数据落在哪。

这五层合起来,就是一个大数据/AI 工程师需要建立的完整心智。部署选型不是孤立的决策,它是整个能力地图在”底座”那一层的投影——你的存储架构是存算一体还是分离、你的工作负载是纯 ETL 还是混合 AI、你的团队有没有 SRE 能力,这些都会投影到 YARN 和 K8s 的天平上。

下一篇我会开始写 Flink 深度系列,流式引擎的选型和 Spark 是另一个故事。蒲公英会继续把这些复盘一篇篇写下去。