1. 为什么要在 Kubernetes 上跑 Spark,而不是继续用 YARN
很多团队第一次接触“在 Kubernetes 上跑 Spark”这个命题时,第一反应往往是:我们已经有 YARN 了,为什么还要折腾一层容器编排?这个问题如果不先想清楚,后面所有的配置都会变成盲目的复制粘贴。我最初也是从 YARN 集群迁移过来的,踩过的坑足够写满一个笔记本,所以先把动机讲透。
Spark 从 2.3 版本开始原生支持 Kubernetes 作为资源调度后端,到了 3.x 版本这套机制已经相当成熟。它的核心思路是:Driver 和 Executor 不再由 YARN 的 NodeManager 管理,而是直接以 Pod 的形式提交到 Kubernetes 集群,由 Kubernetes 负责调度、资源隔离和生命周期管理。这个转变带来的最大好处是资源池的统一——你的在线业务、离线任务、机器学习训练可以共享同一个物理集群,不用再为 Spark 单独维护一套 YARN 集群。
但这里有个关键点容易被忽略:Spark on Kubernetes 有两种运行模式,一种是Client 模式,一种是Cluster 模式。Client 模式下 Driver 跑在你提交任务的本地机器上,Executor 跑在 K8s 里;Cluster 模式下 Driver 本身也是一个 Pod,跑在集群内部。生产环境几乎都用 Cluster 模式,因为 Driver 在本地意味着你的笔记本一断网,整个任务就挂了。而标题里提到的RC 模式,指的是用 ReplicationController 这种早期的控制器来管理 Driver Pod,这是 Spark 早期版本在 K8s 上运行时的一种典型做法,虽然现在更推荐用 Deployment,但理解 RC 模式对于读懂老版本配置和排查历史问题非常有帮助。
那么到底什么场景适合迁移?我的经验是:如果你的团队已经在用 Kubernetes 管理大部分服务,且 Spark 任务有明显的波峰波谷特征,那迁移收益很大;如果你只有零星几个 Spark 任务,且 YARN 集群运行稳定,那迁移的投入产出比并不高。这个判断很重要,因为它决定了你后面愿意投入多少精力去调优。
2. RC 模式与 Deployment 模式的本质区别
2.1 ReplicationController 在 Spark 场景下的角色
ReplicationController 是 Kubernetes 最早期的控制器对象,它的职责非常简单:确保指定数量的 Pod 副本始终运行。在 Spark on K8s 的早期实现里,Driver Pod 就是通过 RC 来创建的。当你用spark-submit提交任务时,Spark 会先创建一个 RC,由这个 RC 负责拉起 Driver Pod,Driver 启动后再向 Kubernetes API Server 申请 Executor Pod。
为什么早期用 RC 而不是 Deployment?这跟历史演进有关。Deployment 是在 Kubernetes 1.2 之后才引入的,而 Spark on K8s 的早期实验性支持正好处于这个过渡期。RC 的语义更简单——它只管副本数,不管滚动更新、回滚这些高级功能。对于 Driver 这种“要么活着要么死掉”的场景,RC 其实够用了。但 RC 有个硬伤:它不支持滚动更新策略,也不支持版本回滚,一旦 Driver 配置需要变更,只能删掉重建。
2.2 两种模式的能力对比
| 对比维度 | RC 模式 | Deployment 模式 |
|---|---|---|
| 副本管理 | 支持 | 支持 |
| 滚动更新 | 不支持 | 支持 |
| 版本回滚 | 不支持 | 支持 |
| 选择器语法 | 仅支持等值匹配 | 支持集合匹配 |
| 推荐使用版本 | Spark 2.3 及更早 | Spark 2.4+ |
| 生产环境适用性 | 低 | 高 |
从表格能看出来,如果你现在从零开始搭建,没有理由选 RC。但如果你接手的是一个历史项目,或者需要维护老版本的 Spark 任务,那理解 RC 模式就是必修课。我在实际排查中遇到过好几次问题,都是因为 RC 的标签选择器写得太宽泛,导致它误管理了其他业务的 Pod,这种坑在 Deployment 上出现的概率要低得多。
2.3 为什么理解 RC 模式仍然有价值
有人可能会问,既然 RC 已经过时,为什么还要花时间学?我的看法是:RC 是理解 Kubernetes 控制器模型的入口。Deployment 本质上是在 ReplicaSet 之上包了一层,而 ReplicaSet 又是 RC 的升级版。你把 RC 的标签选择器、Pod 模板、副本数这三个核心概念搞清楚了,再看 Deployment 就是水到渠成的事。而且很多 Spark 的官方文档和社区教程仍然以 RC 为例,读不懂 RC 就看不懂那些资料。
3. 搭建前的环境准备与镜像选型
3.1 Kubernetes 集群的最低要求
在动手之前,先确认你的集群满足以下条件。这不是随便列的,每一条都对应着实际会卡住你的地方:
- Kubernetes 版本不低于 1.16:低于这个版本,一些 RBAC 和 ServiceAccount 的行为不一致,会导致 Driver Pod 无法创建 Executor。
- 集群有可用的 ServiceAccount 且具备创建 Pod 的权限:Spark 的 Driver 需要调用 K8s API 来创建 Executor Pod,如果 ServiceAccount 权限不足,任务会卡在“等待 Executor 分配”的状态。
- 节点有足够的 CPU 和内存,且没有设置过于严格的 ResourceQuota:我见过一个集群因为命名空间级别的 ResourceQuota 限制,导致 Executor 一直 Pending。
- 网络插件支持 Pod 之间的 DNS 解析:Driver 和 Executor 之间需要通信,如果 DNS 有问题,任务会报连接超时。
提示:如果你的集群开启了 PodSecurityPolicy,需要额外确认 Spark 的 Pod 是否满足策略要求,否则 Driver Pod 会被直接拒绝创建。
3.2 Spark 镜像的构建思路
Spark 官方提供了spark:3.x.x的基础镜像,但直接用官方镜像往往会遇到两个问题:一是缺少业务需要的依赖包,二是镜像体积太大导致拉取慢。我的做法是基于官方镜像做一层定制,把常用的依赖和配置文件打进去。
构建镜像时有个细节值得注意:Spark 的 Kubernetes 模式需要镜像里包含kubernetes-client相关的 jar 包。官方镜像已经内置了,但如果你自己从零构建,很容易漏掉。另外,镜像的入口点不要覆盖,否则 Driver 启动会失败。
FROM spark:3.5.0-scala2.12-java11-python3-ubuntu USER root # 拷贝业务依赖 COPY jars/*.jar /opt/spark/jars/ # 拷贝自定义配置 COPY conf/spark-defaults.conf /opt/spark/conf/ # 保持官方入口点不变 USER spark构建完成后推送到你的私有镜像仓库,后面提交任务时通过--conf spark.kubernetes.container.image指定。
3.3 ServiceAccount 与 RBAC 配置
这是最容易出问题的一环。Spark 的 Driver 需要权限去创建和删除 Executor Pod,所以必须给它绑定一个合适的 Role。最小权限原则在这里很重要,不要图省事直接绑 cluster-admin。
apiVersion: v1 kind: ServiceAccount metadata: name: spark-driver-sa namespace: spark-jobs --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: spark-driver-role namespace: spark-jobs rules: - apiGroups: [""] resources: ["pods", "services", "configmaps"] verbs: ["create", "get", "list", "watch", "delete"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: spark-driver-binding namespace: spark-jobs subjects: - kind: ServiceAccount name: spark-driver-sa namespace: spark-jobs roleRef: kind: Role name: spark-driver-role apiGroup: rbac.authorization.k8s.io这套配置我用了很久,覆盖了 Driver 创建 Executor、注册 Service、读取 ConfigMap 的全部需求。如果你发现任务提交后 Executor 一直起不来,第一件事就是检查 ServiceAccount 的权限。
4. 用 spark-submit 提交任务到 K8s 的完整流程
4.1 提交命令的逐参数拆解
下面这条命令是我在生产环境反复验证过的模板,每个参数都有它存在的理由:
bin/spark-submit \ --master k8s://https://<k8s-apiserver>:6443 \ --deploy-mode cluster \ --name spark-pi \ --class org.apache.spark.examples.SparkPi \ --conf spark.kubernetes.namespace=spark-jobs \ --conf spark.kubernetes.authenticate.driver.serviceAccountName=spark-driver-sa \ --conf spark.kubernetes.container.image=my-registry/spark:3.5.0-custom \ --conf spark.kubernetes.driver.replicas=1 \ --conf spark.kubernetes.executor.instances=3 \ --conf spark.executor.cores=2 \ --conf spark.executor.memory=4g \ --conf spark.driver.cores=1 \ --conf spark.driver.memory=2g \ local:///opt/spark/examples/jars/spark-examples.jar 1000逐个说关键参数。--master必须写成k8s://加 API Server 地址,注意是 https。--deploy-mode cluster决定了 Driver 跑在集群里。spark.kubernetes.authenticate.driver.serviceAccountName指定前面创建的 ServiceAccount。spark.kubernetes.container.image是你自己构建的镜像地址。
这里有个容易踩的坑:local://前缀。当你的 jar 包已经打进镜像里时,必须用local://开头,否则 Spark 会尝试从网络下载。我第一次提交时忘了加,结果 Driver 一直报找不到文件。
4.2 RC 模式下 Driver Pod 的创建过程
当你执行上面的命令后,背后发生了一系列动作。Spark 的提交客户端会先向 Kubernetes API Server 发送一个创建 ReplicationController 的请求,这个 RC 的 Pod 模板里定义了 Driver 容器的镜像、命令、资源限制等。RC 创建成功后,Kubernetes 的控制器管理器会拉起 Driver Pod。
Driver Pod 启动后,内部的 SparkContext 会通过 Kubernetes API 创建 Executor Pod。注意,Executor 不是由 RC 管理的,而是由 Driver 直接创建和删除的。这也是为什么 Driver 的 ServiceAccount 必须有创建 Pod 的权限。
整个链路可以用下面的顺序来理解:
- spark-submit 客户端创建 RC
- RC 控制器创建 Driver Pod
- Driver Pod 启动,初始化 SparkContext
- Driver 向 API Server 请求创建 Executor Pod
- Executor Pod 启动并注册到 Driver
- 任务开始执行
4.3 验证任务是否正常运行
提交之后不要干等着,用几条命令确认状态:
# 查看 Driver Pod 状态 kubectl get pods -n spark-jobs -l spark-role=driver # 查看 Driver 日志 kubectl logs -n spark-jobs <driver-pod-name> # 查看 Executor Pod kubectl get pods -n spark-jobs -l spark-role=executor如果 Driver Pod 处于 Running 但 Executor 一直没出现,八成是权限问题。如果 Executor 出现了但任务不推进,检查网络连通性和资源是否充足。
5. 资源调度与动态分配的实际调优
5.1 Executor 数量的静态配置与动态伸缩
静态配置就是上面命令里的spark.kubernetes.executor.instances=3,任务一启动就固定三个 Executor。这种方式简单可控,但资源利用率低——任务前期和后期可能根本用不到这么多 Executor。
动态分配是更优的方案,需要开启spark.dynamicAllocation.enabled=true,并配合spark.dynamicAllocation.minExecutors和spark.dynamicAllocation.maxExecutors。但这里有个坑:在 Kubernetes 模式下,动态分配需要额外的 shuffle 服务支持,否则 Executor 被回收后 shuffle 数据就丢了。你需要部署一个 External Shuffle Service,或者开启spark.dynamicAllocation.shuffleTracking.enabled=true让 Spark 自己跟踪。
我实测下来,shuffle tracking 的方式更简单,不需要额外部署服务,但在大规模 shuffle 场景下稳定性不如 External Shuffle Service。
5.2 资源请求与限制的写法
Kubernetes 里每个容器都可以设置 requests 和 limits。Spark 通过以下参数控制:
spark.kubernetes.driver.request.cores和spark.kubernetes.driver.limit.coresspark.kubernetes.executor.request.cores和spark.kubernetes.executor.limit.cores
我的建议是requests 和 limits 设成一样的值,也就是 Guaranteed QoS。这样做的好处是 Pod 不会被驱逐,调度也更稳定。如果设成 Burstable,在集群资源紧张时 Executor 可能被优先杀掉,导致任务失败。
内存方面,spark.executor.memory控制的是 JVM 堆内存,而 Pod 的总内存还要加上 overhead。Spark 会自动计算 overhead,但你可以通过spark.executor.memoryOverhead手动调整。如果任务经常 OOMKilled,先看看是不是 overhead 给少了。
5.3 节点选择与亲和性配置
生产集群里通常有不同类型的节点,比如计算密集型、内存密集型。你可以通过 nodeSelector 或 affinity 把 Executor 调度到合适的节点上:
--conf spark.kubernetes.executor.node.selector.disktype=ssd --conf spark.kubernetes.driver.node.selector.disktype=ssd如果集群有多租户隔离需求,还可以用 taint 和 toleration 来确保 Spark 任务只跑在专用节点池上。这些配置在 YARN 时代对应的是队列和标签,概念是相通的。
6. 那些让我熬夜排查的典型故障
6.1 Driver Pod 一直 Pending
这是最常见的问题,原因通常有三类:资源不足、镜像拉取失败、调度约束不满足。排查顺序应该是先看kubectl describe pod的 Events 部分,那里会直接告诉你原因。
如果是资源不足,Events 里会显示Insufficient cpu或Insufficient memory。这时候要么减少请求量,要么扩容节点。如果是镜像拉取失败,检查镜像地址是否正确、私有仓库的 secret 是否配置。如果是调度约束,检查 nodeSelector 是否写错、taint 是否没有对应的 toleration。
6.2 Executor 启动后立即退出
这种情况日志里通常能看到Exit code 137,意思是容器被 OOMKilled 或者被外部信号杀掉。如果是 OOMKilled,增加spark.executor.memoryOverhead。如果是外部信号,检查是不是有节点级别的驱逐策略。
还有一种可能是 Executor 启动后连不上 Driver。这通常是网络问题,检查 Driver 的 Service 是否创建成功、DNS 是否能解析。我在一个集群里遇到过 CoreDNS 配置错误导致 Executor 找不到 Driver 的情况,排查了很久才发现是 DNS 的问题。
6.3 任务卡在某个 Stage 不动
如果 Driver 和 Executor 都正常运行,但任务卡住不动,大概率是数据倾斜或者 shuffle 问题。先看 Spark UI 里各个 Stage 的任务分布,如果某个 Task 处理的数据量远大于其他 Task,那就是数据倾斜。解决办法是加盐、重分区或者调整并行度。
另一个可能是 Executor 的磁盘空间不足,shuffle 数据写不下。Kubernetes 里 Pod 的临时存储是有限的,可以通过spark.kubernetes.executor.volumeMounts挂载外部存储来解决。
6.4 RC 残留导致的重复提交问题
这是 RC 模式特有的坑。如果任务异常退出,RC 可能没有被正确清理,下次提交同名任务时会出现冲突。我的做法是在提交脚本里加一段清理逻辑:
kubectl delete rc -n spark-jobs -l spark-app-name=spark-pi --ignore-not-found加上--ignore-not-found避免资源不存在时报错。这个清理步骤在 Deployment 模式下同样适用,只是把 rc 换成 deployment。
7. 从 RC 迁移到 Deployment 的实操建议
如果你现在维护的是 RC 模式的老项目,想迁移到 Deployment,我的建议是分步走,不要一次性全改。先把新任务用 Deployment 模式提交,验证稳定后再逐步迁移老任务。
迁移时需要注意几个差异点。第一,Deployment 的 selector 语法更严格,必须用matchLabels,不能像 RC 那样直接写键值对。第二,Deployment 默认会保留历史版本,可以通过revisionHistoryLimit控制数量,避免 etcd 膨胀。第三,Deployment 的滚动更新策略在 Spark 场景下其实用不到,因为 Driver 通常只有一个副本,但保留这个能力对后续升级有好处。
apiVersion: apps/v1 kind: Deployment metadata: name: spark-driver namespace: spark-jobs spec: replicas: 1 selector: matchLabels: app: spark-driver template: metadata: labels: app: spark-driver spec: serviceAccountName: spark-driver-sa containers: - name: driver image: my-registry/spark:3.5.0-custom这个模板可以直接用,把镜像和命令替换成你的实际值即可。迁移完成后,记得把旧的 RC 删掉,避免资源泄漏。
8. 监控与日志收集的落地做法
任务跑起来只是第一步,能观测才是长久之计。Spark 自带 Web UI,但在 Cluster 模式下 UI 跑在 Driver Pod 里,需要做端口转发或者配 Ingress 才能访问。我的做法是配一个 Service 加上 Ingress,通过域名访问,省去每次转发端口的麻烦。
日志方面,Kubernetes 默认把容器日志写到节点磁盘,但节点磁盘空间有限,日志多了会被轮转掉。生产环境建议接入统一的日志收集方案,比如用 Fluentd 或 Filebeat 采集到集中存储。Spark 的日志量很大,尤其是开启 DEBUG 级别后,所以日志级别要控制好,生产环境用 WARN 或 INFO 就够了。
指标监控可以用 Prometheus 抓取 Spark 的 metrics endpoint。Spark 支持通过spark.metrics.conf配置上报,配合 Prometheus 的 JMX exporter 或者直接暴露 HTTP endpoint 都可以。我倾向于用后者,配置更简单。
注意:Driver Pod 重启后 IP 会变,如果监控配置里写死了 IP,重启后就抓不到指标了。用 Service 名称做服务发现更可靠。
9. 一些让我少走弯路的经验总结
折腾了这么多集群,有几个心得是花钱买来的。第一,镜像一定要打标签,不要用 latest。我有一次因为用了 latest,重新构建镜像后所有任务行为都变了,排查了半天才发现是镜像被覆盖了。第二,资源请求宁可保守一点。一开始为了跑得快把 Executor 内存设得很大,结果集群资源碎片化严重,小任务反而排不上队。第三,提交脚本要幂等。加上清理逻辑和重试机制,避免因为一次网络抖动就导致整个调度流程失败。
还有一个容易被忽视的点:Spark 版本和 Kubernetes 版本的兼容性。不是所有 Spark 版本都支持所有 K8s 版本,升级前一定要查兼容性矩阵。我在一个项目里因为 K8s 升级到 1.24,导致老版本 Spark 的 ServiceAccount 机制失效,所有任务都起不来,最后只能紧急升级 Spark 版本。
最后说一个关于 RC 模式的体会。虽然现在新项目基本不会用 RC 了,但理解它的工作原理对排查问题帮助很大。因为很多报错信息里仍然会出现 RC 相关的字样,如果你不知道 RC 是什么,看到报错就会一头雾水。技术演进是层层叠加的,老知识不一定过时,只是换了个地方发挥作用。