2026/10/14 2:38:50

Spark on Kubernetes迁移实战:从YARN到云原生的完整指南

Spark on Kubernetes迁移实战:从YARN到云原生的完整指南 搞大数据的人最近几年应该都感受到了一股明显的迁移潮Spark任务从传统的YARN集群往Kubernetes上搬。这个趋势在社区里讨论热度一直很高我们团队从最早的小范围试点到后来线上批处理任务绝大部分都跑在K8s上中间积累了不少实操经验也踩过不少坑。这篇内容就是把 Spark on Kubernetes 这套云原生大数据处理方案从选型到落地再到问题排查完整地拆开讲一遍。如果你是刚准备从YARN迁到K8s或者已经在K8s上跑了Spark但时不时被各种疑难杂症折腾那这篇应该能帮你少走一些弯路。1. 为什么选择Spark on Kubernetes很多团队的第一反应是YARN跑得好好的为什么要折腾我当时的想法也一样。但真正深入对比之后会发现Kubernetes带来的不是简单的换了一个调度器而是整个资源管理和运维模式的改变。1.1 与传统YARN模式的核心差异YARN模式下Spark的Driver和Executor运行在YARN容器里资源由ResourceManager统一调度。这个体系很成熟尤其是Hadoop生态紧密耦合的场景下YARN的队列机制、优先级、ACL控制都做得很完善。但问题也很明显YARN集群是一个独立的基础设施需要专门维护而且和底层云资源的联动非常有限。Kubernetes模式下Spark的Driver和Executor都变成了Pod资源申请直接走K8s的调度器。这意味着Spark作业和公司里其他微服务共用一套基础设施。对运维来说只要管好K8s集群就同时管好了业务应用和大数据任务不需要再搞两套集群、两套监控、两套告警。对资源来说K8s的节点弹性能力比YARN强很多业务低峰期可以把节点缩掉高峰期再自动扩容这是YARN时代很难做到的。我把两者的关键差异整理成一个表格看起来更直观对比维度YARN模式Kubernetes模式调度单元容器ContainerPod资源隔离基于cgroup但层级比较浅Namespace cgroup Pod资源限制基础设施共享依赖独立Hadoop生态与业务应用共享一套K8s集群弹性扩缩容需要手动扩容YARN节点节点自动伸缩 Pod级弹性运维成本需要维护YARN组件复用K8s生态标准化程度高GPU调度支持但有限原生支持成熟度较高当然这不是说YARN一无是处。如果你的公司深度绑定HDFS而且YARN集群已经运行了很多年非常稳定那盲目改造反而是给自己找麻烦。技术选型从来不是追新而是看投入产出比。1.2 什么场景适合迁移到K8s我见过很多团队一上来就想把全部Spark任务迁到K8s结果发现存储对接、网络策略、权限模型全都要重新梳理搞到一半就搁浅了。根据实际经验适合迁移的场景大概有这几类公司基础设施已经全面K8s化不希望再单独维护一套YARN集群。任务以批处理为主对实时性要求不算极端允许通过K8s重新调度。需要强GPU调度能力比如跑一些机器学习预处理任务K8s的GPU调度比YARN灵活很多。希望统一日志、监控、告警体系不想在大数据和业务两条线上重复建设。不适合迁移的场景也很清晰比如核心交易链路依赖HDFS强本地性数据全部放在HDFS上且没有做远程存储改造又比如团队对YARN的调优已经非常成熟但对K8s网络和存储的运维能力几乎为零——这种情况下强行迁移大概率会踩到比YARN更多的问题。2. 云原生环境准备与镜像构建要点确定要往K8s上走之后第一个实际工作就是准备环境。这一节讲的都是能直接照做的内容特别是镜像构建和RBAC配置很多坑就埋在这里。2.1 基础镜像与Dockerfile实操Spark官方提供了构建镜像的脚本bin/docker-image-tool.sh可以直接用它打一个基础镜像。但生产环境一般不会直接用默认镜像因为不同公司的作业依赖、Python版本、Hadoop配置都不一样。以Spark 3.5.0为例我一般在官方脚本生成的基础上再套一层定制核心的Dockerfile大概是这样的FROM openjdk:11-jre-slim ARG SPARK_VERSION3.5.0 ARG HADOOP_VERSION3 ARG SPARK_HADOOP_PATHspark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION} # 下载并解压Spark二进制 RUN set -ex \ apt-get update \ apt-get install -y wget \ wget -q https://archive.apache.org/dist/spark/spark-${SPARK_VERSION}/${SPARK_HADOOP_PATH}.tgz \ tar -xzf ${SPARK_HADOOP_PATH}.tgz -C /opt \ mv /opt/${SPARK_HADOOP_PATH} /opt/spark \ rm ${SPARK_HADOOP_PATH}.tgz \ apt-get purge -y wget \ apt-get autoremove -y ENV SPARK_HOME/opt/spark ENV PATH$SPARK_HOME/bin:$SPARK_HOME/sbin:$PATH # 如果是PySpark环境必须要装Python RUN apt-get update apt-get install -y python3 python3-pip这里有几个细节我想特别强调。第一个是基础镜像选择JVM任务用openjdk:11-jre-slim就够了没必要上完整JDK镜像镜像体积能小不少。第二个是Python环境很多团队跑PySpark任务时直接用官方Spark镜像结果发现容器里压根没有python3这属于很低级但是非常常见的坑。第三个是时区线上任务经常要输出时间字段镜像里最好把tzdata装好否则默认UTC时间会让下游数据对不上。构建镜像时建议加上版本号和构建时间标签docker build -t spark/worker:3.5.0-20240501 . docker push spark/worker:3.5.0-20240501标签里带上日期能避免团队里几个人明明改的是不同的代码却共用同一个latest标签最后排查问题时对着镜像干瞪眼。2.2 Spark Operator与RBAC准备查看和提交任务可以直接用spark-submit但生产上更推荐部署开源社区维护的Spark Operator。Operator的核心价值是把Spark应用抽象成一种自定义资源你可以通过声明式YAML来描述一个Spark作业K8s会自动帮你创建Driver和Executor Pod失败还能自动重试。对于平台团队来说用Operator统一管理作业时可以很方便地对接CI/CD系统把作业定义、版本、参数都纳入代码仓库管理。安装Operator本身不复杂官方Helm Chart一条命令就能装好。但真正重要的其实是权限模型也就是RBAC。Spark应用在K8s上运行时Driver Pod需要通过Kubernetes API创建、查询和删除Executor Pod所以必须给Spark应用使用的ServiceAccount授予相应权限。我通常会在专门的命名空间下创建最小权限的RBACapiVersion: v1 kind: ServiceAccount metadata: name: spark namespace: spark-jobs --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: spark-role namespace: spark-jobs rules: - apiGroups: [] resources: [pods, services, configmaps] verbs: [*] - apiGroups: [] resources: [persistentvolumeclaims] verbs: [get, create, delete]然后在提交任务时指定这个ServiceAccount。很多权限类报错比如forbidden: User cannot get resource pods基本都是因为RBAC没配全或提交时忘了指定ServiceAccount。2.3 常用存储选型Spark往K8s上迁移时存储方案是需要最先确定的。如果数据还放在HDFS上那K8s集群必须能访问到HDFS的端口同时Driver和Executor Pod的网络策略要放通这一块身份认证又得单独配一下。如果你们已经用了对象存储事情就简单很多S3协议或者兼容接口的存储都能直接对接。在K8s环境内存储大致分三类存储类型适用场景注意事项对象存储数据湖、离线数仓冷热数据分层带宽要够否则shuffle和中间结果写入会变慢PVC持久卷checkpoint、History Server、中间结果注意回收策略避免作业结束后PV不释放emptyDirshuffle文件、临时文件随Pod生命周期Pod重建即丢失有一个细节大家容易踩坑如果Executor Pod挂了K8s重新调度一个新Pod那新Pod里的emptyDir是空的之前Executor产生的shuffle数据全没了。这种场景下重算的成本可能是灾难级的。所以如果任务对稳定性要求高要么给Executor挂PVC要么成体系地引入Remote Shuffle Service具体在下面动态分配的部分会展开讲。3. Driver与Executor资源调度关键配置这一节是Spark on K8s和传统Spark部署差异最大的地方。你不能再按YARN时代的老思路只调spark.executor.memory因为K8s的Pod资源和JVM堆内存是两套表述中间有一层很容易被忽略的映射关系。3.1 内存与CPU的映射关系在YARN模式下spark.executor.memory设置的是Executor JVM堆大小YARN会自动在容器层面叠加内存开销。但在K8s模式下Pod的requests和limits不会自动帮你计算这些你必须显式地为每个Executor Pod声明完整的资源量。这里有一个非常关键的经验公式Executor Pod内存上限 spark.executor.memory spark.executor.memoryOverhead其中spark.executor.memory是堆内内存spark.executor.memoryOverhead是堆外内存包括直接内存、线程栈、NIO缓存、K8s本身预留的少量开销。默认情况下memoryOverhead只有堆内存的10%如果你用了大量堆外内存比如Parquet读写、网络传输这个比例大概率不够。我遇到过最典型的情况是这样的配置了spark.executor.memory4gK8s里对应的Pod内存limit也按经验设了6g结果跑一个数据量稍大的ETL任务Executor频繁被OOMKilled。查看Pod事件发现是内存超过limit而实际上JVM堆才用了不到3g。问题就出在堆外内存涨得太猛最后的解法是把memoryOverhead从默认的10%调到40%Pod内存limit相应到8g左右。CPU方面相对简单spark.executor.cores对应Pod的CPU request。建议把limit和request设为一致避免Executor因为CPU被限流而拖慢整个Stage。3.2 动态资源分配的坑K8s上Spark最吸引人的能力之一就是动态资源分配任务高峰期自动拉起大量Executor低峰期自动回收。但很多人开了spark.dynamicAllocation.enabledtrue之后发现任务要么不弹Executor要么弹了Executor后频繁失败。原因在于Spark的动态分配依赖于Shuffle数据的安全性。在YARN模式下有External Shuffle Service来保存Executor产生的shuffle文件哪怕Executor被回收了shuffle数据还在。K8s模式下默认没有这个保障Executor Pod一旦被销毁本地磁盘上的shuffle文件也随之消失后续Stage要读取这些数据时根本找不到文件只能重算。我自己在实践中的建议分两种场景如果你们的作业Shuffle量不大或者允许极端情况下有少量重算可以开动态分配但要显式开启spark.shuffle.service.enabledfalse并配合spark.dynamicAllocation.shuffleTracking.enabledtrue让Spark通过追踪shuffle状态来尽量避免数据丢失。如果作业Shuffle数据量大且要求稳定最稳妥的方案是先不要动态分配直接设置固定的spark.executor.instances。或者引入Remote Shuffle Service我对这种方案的定位是“K8s上跑重Shuffle任务的成熟解法”但部署和维护成本确实不低。动态分配的另一个坑和K8s的调度器有关。Executor Pod数量变化很快如果集群用的是默认调度器可能会因为节点资源碎片化而出现大量Pending。这时候可以通过spark.kubernetes.allocation.driver.scheduler.name指定一个更细粒度的调度器比如支持Bin Packing策略的调度器能明显提升Pod分配成功率。3.3 podTemplateFile的实战用法除了直接在spark-submit参数里配资源Spark还提供了Pod模板Pod Template能力。这个东西的核心价值在于你可以像写普通K8s Pod一样为Driver或Executor定义额外的元数据、存储挂载、节点选择规则甚至注入Sidecar容器。我使用频率比较高的一个场景是给Executor挂载本地SSD盘apiVersion: v1 kind: Pod spec: containers: - name: spark-kubernetes-executor resources: requests: memory: 6Gi cpu: 2 volumeMounts: - name: ssd-data mountPath: /data volumes: - name: ssd-data hostPath: path: /mnt/ssd nodeSelector: disk-type: ssd使用方式是在提交时加上--conf spark.kubernetes.executor.podTemplateFile/path/to/executor-template.yaml这里要注意一个很容易错的地方Pod模板里容器的name必须是spark-kubernetes-executorDriver模板对应的容器名是spark-kubernetes-driver。如果名字写错Spark会在启动时直接报错而且报错信息不太直观我当初排查这个问题花了不少时间。另外Pod模板里如果显式写了resources会和Spark的spark.executor.memory、spark.executor.cores设置冲突。实际运行时Spark还是会根据配置参数计算最终资源模板里的值不一定生效所以不要在模板里重复定义CPU和内存模板只负责挂载、标签、容忍这种辅助配置就好。4. 实操演练一次完整的Spark作业提交前面讲了那么多原理和坑现在走一遍实际提交流程。我以最简单的Spark Pi为例但参数解析部分完全适用于生产任务。4.1 提交命令逐项拆解假设K8s集群的API Server地址是https://k8s-apiserver:6443Spark基础镜像已经推送到了镜像仓库命名空间spark-jobs和ServiceAccountspark都已经创建好。提交命令如下spark-submit \ --master k8s://https://k8s-apiserver:6443 \ --deploy-mode cluster \ --name spark-pi-k8s \ --class org.apache.spark.examples.SparkPi \ --conf spark.kubernetes.namespacespark-jobs \ --conf spark.kubernetes.authenticate.driver.serviceAccountNamespark \ --conf spark.kubernetes.container.imagespark/worker:3.5.0-20240501 \ --conf spark.kubernetes.driver.request.cores1 \ --conf spark.executor.instances2 \ --conf spark.executor.cores1 \ --conf spark.executor.memory2g \ --conf spark.executor.memoryOverhead1g \ local:///opt/spark/examples/jars/spark-examples_2.12-3.5.0.jar 100逐个拆开看--master k8s://https://...这是Spark on K8s特有的连接方式格式必须是k8s://开头Spark会通过这个地址连接K8s API Server。很多新手直接把Master地址写成https://...导致报错这里有个很容易忽略的细节。--deploy-mode cluster生产环境强烈推荐cluster模式Driver本身也以Pod方式运行在集群里。client模式在K8s上支持有限而且Driver如果部署在提交机器上一旦提交机器网络不稳定或者重启整个应用的元数据就丢了。--conf spark.kubernetes.driver.request.cores1给Driver Pod申请1个CPU核。注意Driver的正常工作不依赖太多CPU但内存建议设置充足一点因为Spark UI和任务元数据都在Driver上。--conf spark.executor.memoryOverhead1g这个参数前面专门讲过Heap之外的内存总和。根据任务类型动态调整不要一直用默认值。local:///opt/spark/...这个路径指的是镜像内部的路径而不是提交机器上的路径。如果你用file://Spark会尝试从提交机器上传Jar包在K8s模式下经常因为网络不通而失败。4.2 日志查看与任务可视化提交成功后可以先看Pod状态kubectl get pods -n spark-jobs -l spark-app-namespark-pi-k8s正常情况下你会看到Driver和两个Executor的Pod状态都变成Running或Completed。查看Driver日志kubectl logs spark-pi-k8s-driver -n spark-jobs如果任务执行时间较长可以用--follow持续跟踪日志。判断任务是否真正完成看Driver日志里是否输出了Successfully stopped SparkContext或者通过kubectl get pods看到Driver的STATUS是Completed。还有一个小技巧K8s上Spark Web UI可以直接通过端口转发到本地查看kubectl port-forward spark-pi-k8s-driver 4040:4040 -n spark-jobs浏览器打开http://localhost:4040就能看到Spark UI的Stage执行情况、Executor列表和事件时间线。排查性能瓶颈时这个视图比原始日志直观太多。4.3 集群模式与客户端模式的取舍刚提到生产环境推荐cluster模式这里不再重复。补充一个点如果你因为调试需要必须用client模式要确认Driver所在机器到K8s内Executor的Pod网络是通的。K8s的Service模式会根据Driver的Pod或Service自动生成一个稳定的地址但client模式下Driver的地址是提交机器的IP如果提交机器处于NAT后面Executor连不上Driver的概率极高。我自己在本地调试时会先干脆起一个KIND或者单节点K8s然后在集群内部跑client模式调试调试完成后再用cluster模式提交到生产。这个流程比较舒服也不会因为网络问题浪费大量时间。5. 性能调优与成本控制实战Spark on K8s跑起来之后更大的挑战是性能和成本。K8s的调度与YARN不同同样的参数在不同的基础设施下跑出来的效果可能差很多。5.1 Executor规格的合理设定很多团队习惯把一个Executor的CPU和内存配得非常大比如spark.executor.cores8、spark.executor.memory32g认为这样单任务执行最快。但在K8s环境下大规格Executor往往带来两个副作用。第一是调度碎片化。假设集群里每台节点是16核64G你申请8核32G的Executor每个节点最多只能放两个一旦两个节点各剩余一点点碎片资源Executor就只能Pending。第二是失败恢复代价高。一个Executor包含的任务越多它挂掉时重算的代价就越大。我习惯的推荐范围是spark.executor.cores2~4spark.executor.memory4g~8gmemoryOverhead根据序列化框架决定Kryo序列化通常是1g~2g。具体任务类型不同会有波动但大方向是宁可多起几个Executor也不要把单个Executor塞得过大。5.2 节点亲和与多租户隔离因为K8s是共享基础设施Spark作业很容易和其他在线业务抢资源。为了减少互相干扰一般会给大数据节点打上专门的标签然后在提交时通过spark.kubernetes.driver.node.selector和spark.kubernetes.executor.node.selector指定Executor只能调度到指定标签的节点上。--conf spark.kubernetes.executor.node.selector.disk-typessd --conf spark.kubernetes.executor.node.selector.node-poolbigdata如果节点数量紧张也可以使用污点来保证大数据Pod优先调度。给大数据节点打上污点然后在Pod模板里加上对应的容忍配置这样普通业务Pod不会被调度到大节点池而Spark Pod可以。隔离效果很好但要注意别把所有大数据Pod都塞到少数几个节点上否则单节点故障时影响面很大。5.3 弹性扩缩容与成本节省K8s做资源弹性比YARN强太多了。如果你们用托管的K8s服务可以直接开启节点自动扩缩容。配合Spark的动态资源分配后可以实现白天任务高峰期自动拉起一批节点跑Executor夜间任务跑完节点自动缩容到最小规模。这里面有一个比较实际的成本优化手段把Spark任务的Schedule和预测性扩缩容结合起来。比如每天凌晨定点跑数据仓库的批处理任务提前半小时把节点池从2台扩展到10台任务执行完毕后延迟半小时再缩容。这比让K8s根据实时负载扩容要省钱得多因为节点从启动到Ready往往需要几分钟实时扩容时Spark任务已经在那空等了。6. 踩坑复盘常见问题与排查手册最后这部分是我最想写的——实际运维里遇到的坑基本都集中在下面这几个问题上我按照现象、原因、解决手段的方式整理了一份速查表。问题现象可能原因排查手段与解决思路Pod一直Pending节点资源不足、PVC绑定失败、节点亲和规则不满足kubectl describe pod pod查看SchedulingFailed事件调整Executor规格或放宽节点选择镜像拉取一直失败镜像不存在、镜像仓库未认证、节点无法访问仓库kubectl describe pod看Events确认镜像tag是不是最新推上去了检查是否使用imagePullSecret可以临时换用docker.io/公共镜像做联调Executor被OOMKilled堆外内存超过Pod内存limit消除歧义先看JVM日志如果有OutOfMemoryError调高spark.executor.memory如果是内核OOMKilled且JVM堆没满调高memoryOverheadDriver和Executor连不上网络策略拦截、RBAC没权限创建Service、Driver服务名被占用kubectl get svc -n spark-jobs看是否有Driver Service存在检查RBAC是否允许创建Service确认网络组件Calico/Cilium没有拦截Pod间流量动态分配不生效spark.shuffle.service.enabled没配置、没有部署External Shuffle Service固定Executor数量先跑稳定或者先部署Remote Shuffle Service再开动态分配提交时报RBAC权限错误提交时指定的ServiceAccount权限不足检查Role和RoleBinding确保ServiceAccount所在命名空间有Pod/Service的读写权限自定义镜像里跑PySpark报ModuleNotFoundError镜像里没装Python或没装第三方依赖换Python环境或重新构建镜像除了表格里的问题还有两个经验想单独分享一下。第一个是保存kubectl describe pod的输出。每次遇到Pod异常第一件事就是执行这个命令把Events部分截下来。Events能直接告诉你Pod为什么没起来比如镜像找不到、资源不够、Volume挂载失败。很多团队一遇到问题就去看Spark日志其实Spark日志反映的是JVM内部情况Pod层面的问题必须先通过Kubectl事件来定位。第二个是关于镜像权限的。私有镜像仓库如果没配置好imagePullSecretExecutors在节点上拉不出镜像任务就会卡在ContainerCreating。这个配置看起来不起眼但每次新环境部署必踩。我习惯在提交时通过spark.kubernetes.container.image.pullSecret指定镜像拉取密钥这样不同命名空间的任务不需要共享Secret也能正常拉镜像。还有一个关于时间的坑K8s集群里如果节点时间不同步Spark任务的Event时间线会出现严重错乱甚至导致某些心跳超时。Kubernetes本身对时间同步有要求但很多自建集群没有配置好NTP大数据任务跑起来后就容易莫名失败。这个和Spark本身没关系但排查效率影响极大建议集群搭建初期就统一配置好时间同步服务。写在最后的一个实操体会Spark on Kubernetes这个方向我已经跟进了很久最大的感受是不要把它当成一个纯技术迁移问题它本质上是一次资源供给方式的变更。技术层面Spark和K8s的对接其实已经相当成熟真正决定项目能不能落地成功的往往是你有没有把资源、存储、权限、监控这四个周边问题一并考虑清楚。从个人经验来看刚开始先接一两个非核心的离线分析任务跑通全链路不要一上来就把所有作业都迁过去等积累了一些稳定性数据之后再考虑全面铺开。线上环境遇到问题也别慌多利用kubectl describe、K8s事件和Spark UI三件套来定位很多疑难杂症其实都有规律可循。希望这篇实践总结能给你在Spark on Kuberenetes这条路上提供一些参考。