☰
Spark on Kubernetes迁移实战:从YARN到云原生的稳定性之路
2026/10/17 4:32:40 网站建设 项目流程

半夜三点收到告警,核心批处理任务的 Spark Application 接连重试四次,全部在 Executor 申请阶段超时。这不是第一次了:YARN 队列里的资源被其他项目占满,我们的任务只能排队到凌晨,运气不好就直接失败。那段时间团队做了一个决定,把整个 Spark 计算层从 YARN 迁移到 Kubernetes,也就是现在常说的 Spark on Kubernetes 方案,把大数据处理完整塞进云原生体系里。这半年踩了不少坑,也整理出不少心得,今天把整个实践过程从头到尾梳理一遍,尤其是那些文档里不会明说、但线上跑起来一定会遇到的地方,给正在评估迁移、或者已经在 K8s 上跑 Spark 却天天被 Pod 折磨的朋友做个参考。

这里先说明一下:本文不会停留在"Spark on K8s 是什么"这种概念层面,而是按照一条真实的技术演进路线来写——迁移动机、部署架构、资源调度、故障排查、性能调优。你可能是刚接触云原生大数据的开发者,也可能是已经在线上维护 Spark 任务、被各种诡异问题折磨得头大的运维同学,这篇文章都会有一些能直接拿到生产环境里用的内容。

1. 为什么要把 Spark 搬到 Kubernetes:从运维视角算一笔账

1.1 YARN 时代的三个真实痛点

我在之前的公司维护过一段时间的 YARN 集群,规模不算大,几十台物理机,但痛点非常典型。最让人头疼的是资源利用率。YARN 的队列划分是静态的,生产队列和开发队列分得清清楚楚,但实际负载根本不会按照你画的线走。开发同学白天跑实验,晚上生产任务高峰期,两边经常互相抢资源。管理员手动调整队列配额,改一次要发公告、等生效,效率很低。

第二个痛点是版本分叉。Spark 从 2.3 开始支持 Kubernetes 作为资源调度器,但很多团队的老作业还跑在 YARN 上,新作业又想上 K8s,导致同一个集群里同时维护两套部署方式。两边的镜像、依赖、启动参数不一样,排查问题的时候经常"同一个任务,换个环境就复现不出来"。这其实不是 Spark 本身的问题,是基础设施割裂带来的运维灾难。

第三个痛点是"机器迷信"。YARN 集群一旦出现资源碎片化,比如某个 NodeManager 上内存剩 2G、CPU 剩 1 核,这种碎片会不断累积,最终导致新任务无法找到满足条件的容器。传统解法是重启 NodeManager,但这是治标不治本。而 Kubernetes 天然有调度器和 Node 资源管理机制,碎片问题虽然也存在,但处理手段灵活得多。

1.2 迁移后的目标架构

我们当时的想法很简单:既然公司已经在大力推 Kubernetes,很多无状态服务都跑在 K8s 上,那 Spark 也搬上去,把计算资源统一收编。目标架构是这样设计的:K8s 集群提供基础资源池,通过自定义 ResourcePool 划分命名空间和资源配额;Spark 任务以 Pod 形式运行,Driver 和 Executor 都由 K8s 调度;镜像统一构建在私有仓库里,版本由 CI 流水线控制;日志统一收集到对象存储,配合 Spark History Server 做任务回溯。

听起来很理想,但真正动工之后才发现,Spark on K8s 的坑并不是"能不能跑"的问题,而是"跑起来稳不稳"的问题。稳定性恰恰是生产环境最要命的部分。

1.3 这笔账真的划算吗

做技术选型不能只看宣传语,我把账拆成了三块。运维成本层面:K8s 集群本身有复杂度,但公司已有平台团队支撑,边际成本低,Spark 任务的部署从"登录跳板机手动提交"变成了"CI 触发 spark-submit",这部分的收益是实打实的。资源成本层面:K8s 可以通过 Node 亲和性和 Pod 资源请求做 bin-packing,比 YARN 的静态队列灵活,相同规模下能把资源利用率提升 20% 到 30%,这是我们压测后得到的数据。隐性成本层面:很多新概念要学,比如 RBAC、PVC、动态资源分配等,团队需要一段适应期,这可能是很多人低估的部分。

一句话总结我的判断:如果你的公司已经把核心业务容器化,K8s 是现成的资源池,那 Spark on K8s 值得迁移;如果你目前还没有 K8s 集群,只是想给 Spark 换一个调度器,那我劝你再想想,因为迁移的工程量远远不止换一个提交命令那么简单。

2. Spark on K8s 的部署架构与资源调度逻辑拆解

2.1 两种接入方式:原生 spark-submit 与 Spark Operator 的取舍

Spark 跑在 K8s 上有两条主流路线。第一条是使用 Spark 自带的 Kubernetes 集群管理器,直接通过 spark-submit 提交任务,K8s 集群负责创建 Driver 和 Executor 的 Pod。第二条是使用 Spark Operator,它通过自定义资源(SparkApplication)声明式地管理作业生命周期,支持自动重启、调度依赖、应用间依赖关系等高级功能。

我们的选择是两条都试了,但最终主推 Spark Operator。为什么?因为 Operator 把"任务"抽象成了 K8s 中的一个资源对象,你可以像操作 Deployment 一样对 Spark 作业做 apply、delete、get,这意味着 GitOps 流水线可以无缝接入。原生 spark-submit 适合快速验证和临时任务,但它的重试策略、状态管理都相对弱,生产环境长期维护还是 Operator 更合适。

这里给出一个 SparkApplication 的简单示例,这是一个我们早期在测试环境用的配置:

apiVersion: sparkoperator.k8s.io/v1beta2 kind: SparkApplication metadata: name: spark-etl-demo namespace:>spec: executor: affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchLabels: spark-role: executor topologyKey: kubernetes.io/hostname

这个配置的意思是:调度器会尽量不让两个 Executor 落在同一台 Node 上。注意,这里用的是"尽量"(preferred),而不是"强制"(required),因为在大规模场景下强制分散反而会导致 Pending。

2.3 认证、RBAC 与 ServiceAccount 配置要点

Spark on K8s 的认证是绕不开的坎。Driver 和 Executor 需要访问 K8s API Server 来创建/删除 Pod,这必须要 Grant 权限。最稳妥的做法是创建一个专用 ServiceAccount,只授予必要的权限,而不是直接用 default。

apiVersion: v1 kind: ServiceAccount metadata: name: spark-operator-spark namespace:>spec: executor: volumes: - name: shuffle-dir persistentVolumeClaim: claimName: spark-shuffle-pvc volumeMounts: - name: shuffle-dir mountPath: /shuffle

同时要在 SparkConf 里指定本地目录:

spark.local.dir=/shuffle

这里有个经验:shuffle 存储的性能直接决定了 Spark 作业的整体耗时。我们压测了一个 1TB 数据的排序作业,用网络存储 PVC 比本地 SSD 慢了约 35%。如果你的作业有大量 Shuffle,尽量让 K8s 调度器把 Executor 调度到现在有本地 SSD 的 Node 上,或者用 local SSD 的 StorageClass,别依赖 NFS 一类的共享存储。

3.3 磁盘爆掉与 Executor 被驱逐的"连锁反应"

磁盘问题不是孤立的事件,它往往引发连锁反应,这个章节我再多说两句。当某个 Node 的磁盘触发 Kubelet 驱逐策略后,K8s 会先尝试把 Pod 调度到其他节点,但 Executor Pod 是有状态的,它内部持有 reducer 正在读取的数据或者 shuffle 索引信息,一旦被强制 Kill,整个 stage 就可能重算。

更麻烦的是,驱逐事件发生时,Spark 应用不会自动感知"Executor 死了还会自动恢复"这个逻辑。除非你配置了spark.executor.lost相关重试策略。我们当时看到的现象就是:任务卡在一个 Stage,Executor 日志显示超时,但 Spark 应用本身没有退出,就一直卡在那里,直到 Driver 内存被积压的任务元数据撑爆。

所以,如果你用了 local 存储做 shuffle,一定要在 nodes 层面预留 15%-20% 的磁盘 buffer,不要觉得"配额给了 100G 就可以用满 100G"。另外要设置正确的spark.shuffle.service.enabled参数——注意,这个参数在 K8s 模式下通常要设为 false,因为 K8s 没有独立的 Shuffle Service 角色,只有 YARN 模式才有。

4. 从提交流程到故障排查:一次完整的 Driver 失联问题定位链路

4.1 故障现场:任务跑到一半 Driver 失联

迁移上线大概第三周,我们遇到了一次典型的故障,非常值得复盘。某天晚上 8 点,核心数仓任务开始跑,一切正常。大约 10 分钟后,任务调度的监控面板显示应用状态变成了 UNKNOWN,Driver Pod 的状态直接变成了 Error。登录 K8s 一看,Driver Pod 显示Error状态,退出日志里并没有发现明显的异常堆栈,只看到 JVM 直接消失了。

这种问题最让人崩溃的地方在于:它不是 API 层面的错误,也不是业务逻辑异常,而是基础设施层面的突发状况。如果你不熟悉整个提交流程,很容易陷入"我代码是不是写错了"的死胡同。

4.2 排查链路:从 Pod 事件到内存分配

我们的排查链路如下:

第一步,看 Pod 事件。

kubectl describe pod spark-etl-demo-driver -n>kubectl logs spark-etl-demo-driver -n>driver: cores: 1 memory: "2g"

这个配置在 Operator 底层会映射为requests.memory=2g和limits.memory=2g,但 Spark 的spark.driver.memory是 2g,加上 256M 的 overhead,总量超出 limit,于是容器被杀。

第四步,计算内存参考值。一般经验公式是:spark.driver.memory + spark.driver.memoryOverhead <= K8s limits。如果你的 Driver 配置了memory: 2g,那么 overhead 默认是0.10 * 2g = 204m,加起来约 2.2g,所以 K8s 的 memory limit 至少要 2.5g 才安全。我们当时的配置是 2g 的 limit,等于把 overhead 空间挤没了,OOM 是必然的。

4.3 修复方案与验证

修复方案如下:

driver: cores: 1 memory: "3g" memoryOverhead: "512m"

把 Driver 内存调大到 3g,overhead 设置为 512m,同时在 sparkConf 里去掉自定义的-Xmx,让 Spark 自己管理堆内存。改完后重新提交任务,观察了一个星期,没有再出现 OOM。

这起事故的关键收获是:在 K8s 模式下,JVM 内存参数与容器资源限制是两套体系,必须协同考虑。很多人只记得调高spark.executor.memory,却忘了memoryOverhead也要同步调整,结果内存被 overhead 挤爆。

4.4 日志收集与 History Server 的作用

解决这次事故之后,我们意识到一个更严重的问题:日志太分散了。Driver 日志在 Pod 里,Executor 日志也在各自的 Pod 里,任务失败之后 Pod 一旦被回收,日志就彻底没了。后来我们把日志统一收集到对象存储,并部署了 Spark History Server 作为 Web UI,这个是 Spark on K8s 里相对弱势的环节,但又是排障必备的。

部署 History Server 时,有几个注意点。第一,必须要能访问所有 Spark 应用的 EventLog,所以 SparkApplication 要配置spark.eventLog.enabled=true和spark.eventLog.dir=oss://...或s3://...。第二,History Server 本身也是一个 K8s Deployment,服务暴露方式建议用 Ingress,方便团队统一访问。第三,Spark 版本必须和作业版本保持一致,否则 EventLog 解析会报错。

5. 性能调优实测:Executor 规格、并行度与 Spark 参数的协同设置

5.1 Executor 规格怎么定:核数与内存的比例

迁到 K8s 上之后,很多原来在 YARN 上习以为常的参数需要重新审视。最典型的就是 Executor 规格。YARN 模式下我们习惯一个 Executor 给 8 核 32G,但 K8s 模式不建议直接照搬,原因在于 K8s 的 Pod 资源分配粒度更细,而且 Executor 和 Node 的亲和性会直接影响任务性能。

我们的建议是:单个 Executor 的核数控制在 2 到 4,内存根据数据量配 4G 到 16G。举个例子,一个任务需要 40 个 vcore,你配 4 核一个 Executor,总共 10 个 Executor;如果配 2 核一个 Executor,就需要 20 个 Executor。后者在弹性伸缩和调度上更灵活,但 Master 端的调度压力也更大。

还有一点,Spark 的spark.executor.cores和spark.task.cpus有一个隐藏关系。如果你给 Executor 分配了 4 核,任务并发度是 4 个,那么每个任务能拿到的 CPU 份额就是整体的四分之一。如果任务里大量使用 CPU 密集型算子,4 核 Executor 反而会因为 CPU 争抢导致执行时间变长,此时降低并发度、增大单任务内存反而更合适。这个没有绝对最优,建议针对你的主要作业做一次简单的压测矩阵。

5.2 并行度参数、动态分配与资源粒度的配合

很多人在 K8s 上遇到的问题是:Executor 数量上去了,任务却不快。这通常是并行度参数不匹配导致的。spark.sql.shuffle.partitions默认是 200,如果你的 Executor 核数总和只有 40,那 200 个分区意味着每个核要处理 5 个分区,任务切分过碎,调度开销大;如果你的核数总和是 200,而分区只有 50,那有 150 个核在空转。

比较实用的做法是:spark.sql.shuffle.partitions设置为集群总核数 * 2到集群总核数 * 3。比如你的 SparkApplication 固定 10 个 Executor、每个 2 核,总核数 20,那 shuffle 分区数可以设为 40 到 60。这个取值不是死的,跟你 shuffle 的数据量也有关系,数据量越大分区数越要往上提,但不要超过单分区 200MB 左右。

动态分配和并行度的配合也需要仔细调。启用了动态分配之后,Executor 数量是波动的,但你给每个 Executor 设置的是固定核数,所以总核数也是波动的。此时如果把shuffle.partitions设成一个固定值,就会出现低峰期分区过多、高峰期分区不够的情况。我们的解法是:低频小作业直接用固定 Executor,不开动态分配;高频核心作业启用动态分配,但把initialExecutors设置为平时负载的 70%,避免任务启动阶段频繁扩缩容。

5.3 不同作业场景下的参数模板

下面这几个是我在实际生产环境中整理出的参数模板,可以直接复制去微调。

第一个是 ETL 清洗类作业,数据量中等,SQL 为主:

spark.executor.cores=2 spark.executor.memory=4g spark.executor.memoryOverhead=1g spark.dynamicAllocation.enabled=true spark.dynamicAllocation.shuffleTracking.enabled=true spark.dynamicAllocation.initialExecutors=4 spark.dynamicAllocation.minExecutors=2 spark.dynamicAllocation.maxExecutors=20 spark.sql.shuffle.partitions=60

第二个是聚合计算类作业,shuffle 量大、中间结果多:

spark.executor.cores=4 spark.executor.memory=8g spark.executor.memoryOverhead=1g spark.dynamicAllocation.enabled=false spark.executor.instances=8 spark.sql.shuffle.partitions=160 spark.shuffle.compress=true spark.shuffle.spill.compress=true

第三个是机器学习训练类作业,涉及迭代计算:

spark.executor.cores=4 spark.executor.memory=16g spark.executor.memoryOverhead=2g spark.dynamicAllocation.enabled=false spark.executor.instances=6 spark.sql.autoBroadcastJoinThreshold=-1 spark.network.timeout=800s spark.rpc.askTimeout=600s

注意最后一个模板里的spark.network.timeout和spark.rpc.askTimeout要调大,因为训练任务里经常出现长时间 GC 导致 Executor 心跳超时,被 Driver 判定为"已死",实际只是老年代 GC 而已。

5.4 我们跑完压测后的几个心得

压测做了三轮,覆盖了 100GB 到 2TB 的数据量,有几个心得比较有价值。

第一,K8s 上最容易性能翻车的不是 Spark 本身的运算,而是网络。如果你的集群网络是用 flannel 或 Calico 的 VXLAN 模式,跨节点 shuffle 流量的开销会非常明显。我们实测,同样规格、同样数据量的聚合作业,在 VXLAN 模式下比在 underlay 网络模式下慢 15% 到 20%。有条件的话,优先给 Spark 作业单独规划一个网络性能要求高的命名空间,或者直接采用性能更好的 CNI 插件。

第二,别迷信spark.executor.instances越大越好。Executor 数量增加会带来两个副作用:一个是 Driver 端维护 BlockManager 的元数据增多,如果 Driver 内存不够,反而先挂;另一个是 K8s API Server 的 Pod 创建压力增大,当一次性创建超过 200 个 Executor Pod 时,API Server 的响应延迟会明显上升。我们的经验是:单批作业 Executor 数不要超过 150 个,如果需求超过这个数字,优先考虑增大单 Executor 的内存和核数,而不是无限增加数量。

第三,关于镜像瘦身。Spark on K8s 是"启动慢"的重灾区。一个包含完整 Python 环境、各种依赖包的 Spark 镜像动辄几个 GB,每次任务启动光拉镜像就要几十秒到几分钟。我们后来做了分层优化:基础镜像只装 Spark 运行环境和 JDK,任务依赖通过spark.jars.packages动态下载,或挂载到 PVC 里做依赖库。效果很明显,任务启动时间从 3 分钟降到了 40 秒左右。

最后分享一个我在实际操作中的体会:Spark on K8s 的上手门槛其实不高,踩坑也大概率是集中在这几个方向——资源参数配错、shuffle 存储选型不当、动态分配与任务特性不匹配。这就像开一台新车,仪表盘上的每个灯都有它存在的意义,你不需要一开始就把所有细节背下来,但至少要知道哪些参数直接影响生产稳定性。把这几个方向理顺了,你的 Spark on K8s 之路会平顺很多。

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

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

立即咨询