1. 项目概述
1.1 核心需求解析
做大数据的人这两年应该都有同感:某公司的Hadoop集群越撑越吃力,扩容要等采购流程,缩容又怕浪费资源;而上新任务时,YARN队列调来调去,不同团队互相抢资源,运维同学天天救火。我接手某跨平台数据处理系统改造时面临的就是这个局面——批处理任务越来越多,计算峰值波动明显,固定集群的模式已经明显拖后腿。
Spark on Kubernetes这个题目听起来像是个组合技,本质上是把Spark计算引擎和Kubernetes调度能力焊在一起:让Spark任务直接跑在K8s集群上,用容器的思路管理计算资源,用K8s原生的方式做调度、隔离和弹性伸缩。这套方案选型时我综合考虑过三条路,最后确定这条路的原因后面细说。这里先给结论——如果你所在团队的机器学习平台、微服务系统已经在用K8s,或者正在被固定集群的资源利用率问题困扰,这篇实践记录可以帮你少踩不少坑。
1.2 方案选型背后的取舍逻辑
我把当时的几个备选方案拉出来对比过,核心差异集中在一个问题上:计算资源到底由谁来管。
第一种是传统方案,Spark跑在YARN上。优势是Spark和Hadoop生态兼容性好,调度器成熟,社区文档多,工程师普遍熟悉。但短板很明显——需要单独维护一套Hadoop集群,NameNode和ResourceManager都要高可用,机器数量少说也要十几台起步。而且YARN和K8s的混布在运维上非常痛苦,两套调度体系互相不感知,资源池无法灵活互借。大部分公司实际用下来的资源利用率其实都不高,业务低峰期大量计算节点闲置。
第二种是Spark Standalone模式,简单直接,但也简单粗暴。它缺少细粒度的资源隔离,没有Namespace的概念,多人共用时权限基本靠自觉。生产环境跑一段时间你就会发现,这玩意儿适合开发和测试,不适合做严肃的业务支撑。
第三套就是本文要讲的Spark on Kubernetes。它的核心价值是让Spark成为K8s里的一等公民:Driver和Executor都以Pod的形式运行,资源申请调度全部走K8s的调度器。计算集群和微服务可以混布在同一套K8s集群里,峰值时可以快速扩容,低峰时可以把资源释放给其他业务使用。如果你所在的公司已经有一套K8s基础设施,边际成本非常低——不需要再养一套Hadoop运维团队。
实际上,Apache Spark从2.3版本开始官方支持Kubernetes作为调度后端,到2.4版本支持了弹性伸缩,3.x版本进一步成熟。我建议至少用Spark 3.2以上版本跑生产,早期版本的坑比较多,现在已经不值得冒险了。
2. 架构设计与核心组件解析
2.1 整体架构怎么搭
先把架构轮廓画清楚。在Kubernetes上跑Spark,涉及的角色和一个村镇的运作方式很像:村委会是K8s集群的Master节点(API Server),负责记录谁申报了什么资源;村民是各个业务Pod,各自干各自的活;而Spark任务就像一个干活团队,队长是Driver,队员是Executor。
整个流程走下来是这样:
- 用户用spark-submit提交任务,命令里指定master为
k8s://https://<K8s-API-Server地址>。 - Spark自研的KubernetesClient(在3.x版本里叫SparkKubernetesClient)向K8s API Server发起请求。
- K8s创建Driver Pod,Driver启动后会向K8s申请Executor Pod。
- Executor Pod创建成功后回连Driver,建立通信管道开始干活。
- 任务跑完后Executor Pod自动销毁,Driver看情况决定是退出还是进入等待状态。
这里面有个容易误解的点:很多人以为提交任务之后,Spark就把计算逻辑打包扔给K8s就行。实际上Driver Pod和Executor Pod是分开的两种角色,Driver挂掉任务就挂了,所以生产环境要给它整上重启策略和资源限制。Executor则是真正干重活的人,并行度全靠它。
各组件的作用我用一张表来整理,方便你快速对照:
| 组件 | 角色 | 关键配置项 | 作用 |
|---|---|---|---|
| spark-submit | 提交入口 | master、deploy-mode、conf | 任务提交与参数传递 |
| KubernetesClient | 桥接器 | spark.kubernetes.authenticate.* | 与K8s API通信,创建/销毁资源 |
| Driver Pod | 调度核心 | spark.driver.memory、restartPolicy | 解析执行计划、调度任务 |
| Executor Pod | 计算执行 | spark.executor.instances、cores、memory | 执行具体计算逻辑 |
| ConfigMap | 配置载体 | spark.kubernetes.driverEnv.* | 传递环境变量和配置文件 |
| Service | 通信通道 | spark.kubernetes.driver.service.name | 供Executor回连Driver |
2.2 为什么不直接用YARN
这个问题我在做方案调研时反复纠结过。某次在做资源利用率统计时发现,我们在YARN上跑的150多个任务,平均利用率只有37%左右。大量Executor是按峰值申请的,但实际计算过程中大部分时间是I/O等待和Shuffle等待,CPU根本吃不满。而K8s调度能做到Bin Packing,多个Pod按需挤在几台机器上,加上HPA(水平Pod自动扩缩容)机制,理论利用率能做到60%以上。
更关键的是管理维度的差异。YARN集群和K8s集群各自维护,出了问题要排查两套系统的日志、两套监控。而K8s上的Spark任务可以和已有的大数据组件(比如Kafka、Flink任务)统一纳入一套监控体系。我见过一些团队的现状:凌晨一个Spark任务挂了,数据没产出,第二天早上才发现。原因就是日志散落在好几台机器上,监控告警也没有统一收口。K8s化之后至少能把日志、指标这些事情对齐到同一套体系里。
另外还有成本账。某公司之前为了跑一个不算大的报表任务,专门养了6台16核64G的机器做Spark集群,月成本大约是两万多;迁到K8s上之后,这6台机器腾出来跑其他业务,计算高峰期短暂扩容,整体成本大概打了个六折。如果公司的任务量是持续增长的,这笔账只会算得越来越划算。
2.3 组件间协作的关键链路
真正落地时你最先接触的链路是任务提交到Driver创建这一段。spark-submit命令在提交时会把spark.kubernetes.driver.pod.template等配置组装成PodSpec提交给K8s,这个过程有个很容易踩的坑——镜像仓库认证。
先说正常链路:提交命令 → API Server校验身份 → 创建ConfigMap(存放Spark配置)→ 创建Driver Pod → Driver向API Server请求PVC(如果配置了本地存储)→ 根据申请的Executor数量创建对应数量的Pod → 每个Executor Pod创建后主动回连Driver的端口 → 开始执行Task。
这里面有几个容易出错的地方:
- 如果你的镜像存在私有仓库里,K8s调度到Worker节点时需要拉取镜像。这时要用imagePullSecrets指定拉取凭证,否则Executor Pod启动时报ImagePullBackOff,一批批全部失败。
- Driver和Executor之间的通信走的是K8s Service的网络。需要确保spark.kubernetes.driver.service.name配置了一个可解析的Service名称。如果用hostNetwork模式,就要避开节点端口冲突。
- Executor回连Driver时有个超时窗口,如果Driver还没完全起来Executor就尝试连接,会触发连接失败后的重试机制。本地测试时问题不大,生产环境任务量大时,要把spark.kubernetes.allocation.driver.connect.timeout适当调大一些。
我建议在本地先把一套单节点的K8s环境跑通,用minikube或者Kind都行。先把提交链路和Pod的启停过程熟悉了,再上生产环境,不然集群里的问题叠在一起,排查起来会疯。
3. 核心配置与实现细节
3.1 镜像制作:这步决定成败
Spark on Kubernetes里镜像制作是第一个硬门槛。官方镜像可以从Docker Hub直接拉,但生产环境一定要自己做镜像。原因很现实——你需要在镜像里预装公司的加密认证库、Kerberos配置、UDF依赖、Python运行环境等。否则每次任务提交都要在Driver里现场装依赖,时间上耗不起,也容易出错。
我做镜像的思路可以简化为:
- 从官方Spark 3.3.0镜像作为基础镜像。
- 安装Python 3.9和必要的Python包(例如pandas、numpy、requests等)。
- 拷贝HDFS客户端配置和Kerberos认证文件(如果对接HDFS)。
- 将公共的依赖Jar包打进镜像的/jars目录,避免每次提交任务都从OSS或HDFS拉取。
- 设置合适的环境变量(比如HADOOP_CONF_DIR、PYTHONPATH等)。
Dockerfile参考(基于Ubuntu基础镜像):
FROM spark:3.3.0-scala2.12-java11-ubuntu USER root # 安装Python和必要依赖 RUN apt-get update && \ apt-get install -y python3 python3-pip && \ pip3 install --no-cache-dir pandas numpy requests pyarrow # 拷贝Hadoop配置(用于读写HDFS) COPY hadoop-conf/ /etc/hadoop/conf/ # 拷贝Kerberos配置文件 COPY krb5.conf /etc/krb5.conf # 设置环境变量 ENV HADOOP_CONF_DIR=/etc/hadoop/conf ENV PYTHONPATH=/opt/spark/python WORKDIR /opt/spark USER spark制作镜像时有几个细节要注意:
- 基础镜像的时区默认是UTC,如果你的调度系统要按本地时间切割数据,一定在Dockerfile里加上
ENV TZ=Asia/Shanghai,并安装tzdata。否则跑出来的时间分区会差8小时,排查起来特别隐蔽。 - 基础镜像自带JDK版本要和Spark编译时使用的Java版本关联好,不建议安装OpenJDK 17跑Spark 3.2,部分RPC和序列化场景会有兼容问题。
- 如果任务要访问对象存储(如OSS、S3),需要额外添加对应hadoop-aws或aliyun-sdk的Jar包。这类Jar包的版本要和Spark自带的hadoop版本严格对齐,否则会踩所谓“NoClassDefFoundError”的坑。
关于镜像拉取策略,生产环境建议设为
IfNotPresent。开发迭代频繁时可以用Always,但要留意大镜像反复拉取的耗时。如果网络带宽有限,每个Executor都会拉一次镜像,几百个Executor同时启动时对镜像仓库的压力相当大,最好提前把镜像推送到各节点所在区域的仓库或提前预热。
3.2 Driver和Executor的资源调优
资源参数是Spark on K8s落地最核心的参数调优环节。如果你的资源给的太保守,任务会慢得像爬;给得太激进,K8s调度器又很难找到合适节点。
我整理了一套相对稳妥的初始配置参考:
| 参数 | 建议值 | 说明 |
|---|---|---|
| spark.driver.cores | 1~2 | Driver只需要做调度和汇总,给太多浪费 |
| spark.driver.memory | 4g~8g | 看任务复杂度,collect结果集大的任务给大一些 |
| spark.executor.instances | 与数据量匹配 | 先按数据量的100~200MB每个分区粗估 |
| spark.executor.cores | 2~4 | 太高会导致并行task过多,GC压力大 |
| spark.executor.memory | 8g~16g | 按预估单Executor处理的数据量给 |
| spark.kubernetes.executor.limit.cores | 与executor.cores对齐或略高 | 限制Pod对物理CPU的占用 |
| spark.sql.shuffle.partitions | 200~500(看数据量) | 影响Shuffle阶段的并行度 |
| spark.dynamicAllocation.enabled | true | 配合K8s实现动态扩缩容 |
关于Executor内存,有句经验总结是:Spark挂在内存上的故障,多半不是内存不够,而是内存配置不合理。每个Executor的堆内存可以分为执行内存、存储内存和用户内存三块,默认比例分别是0.4、0.4和0.2。如果你的任务集中在Shuffle和Join,执行内存占比可以往0.5调;如果要做Cache或Persist的表比较多,存储内存可以往0.5调。用spark.memory.fraction控制总堆内可用比例,默认0.6,我测试下来0.7~0.8也不会立刻翻车,但建议不要超过0.8,否则GC会特别频繁。
K8s上还有个特殊的问题——直接内存(off-heap)。Executor的spark.executor.memory对应的是Pod里JVM的堆内存,但Netty、RocksDB这些组件还要用堆外内存。如果K8s给Pod限制的内存总量正好等于堆内存大小,Executor跑着跑着就会被OOMKilled。我遇到过的最典型场景:Executor申请了4g,堆内存给了3.5g,还有0.5g留给堆外,结果Pod limit也是4g,Executor启动十几秒就被K8s杀掉,查日志发现是操作系统OOM。所以一定要把堆外内存算在Pod申请里,或者直接用spark.executor.memoryOverhead这参数给堆外预留空间。
spark-submit \ --master k8s://https://<K8S_API_SERVER_ADDR> \ --deploy-mode cluster \ --name spark-k8s-demo \ --class com.example.WordCount \ --conf spark.kubernetes.container.image=<镜像地址> \ --conf spark.kubernetes.namespace=spark \ --conf spark.kubernetes.authenticate.driver.serviceAccountName=spark \ --conf spark.driver.cores=2 \ --conf spark.driver.memory=4g \ --conf spark.executor.instances=10 \ --conf spark.executor.cores=2 \ --conf spark.executor.memory=8g \ --conf spark.executor.memoryOverhead=2g \ --conf spark.kubernetes.executor.limit.cores=2 \ --conf spark.dynamicAllocation.enabled=true \ --conf spark.dynamicAllocation.shuffleTracking.enabled=true \ --conf spark.sql.shuffle.partitions=200 \ --conf spark.eventLog.enabled=true \ --conf spark.eventLog.dir=hdfs://namenode:8020/spark-events \ local:///opt/spark/examples/jars/spark-examples.jar上面的命令里出现的local://前缀是Spark on K8s的特色写法。因为Driver和Executor都是容器,所以Jar包的路径是容器内部的路径,如果用file://会被当作客户端节点上的路径去解析,提交时会直接报错找不到文件。
3.3 动态资源分配与弹性
弹性是K8s相比YARN最吸引人的点之一。Spark从2.4开始支持Kubernetes上的动态资源分配,任务可以先起少量Executor,根据数据量和任务执行情况动态扩容或缩容。这个功能对于白天平台任务多、夜里只有定时任务跑的场景非常实用。
开启动态资源需要满足几个前置条件:
- Shuffle tracking要开启,因为Executor缩容时要保证Shuffle数据可访问,否则缩掉的Executor上的Shuffle文件就丢了。
- 要配置
spark.dynamicAllocation.minExecutors和maxExecutors,防止缩到0或扩到失控。 - K8s侧要给Driver的ServiceAccount配置创建Pod的RBAC权限(见后面的权限说明)。
动态资源分配在K8s上还有一个额外好处——配合集群的Cluster Autoscaler,真正实现按需伸缩。当Executor Pod数量增加导致现有节点资源不足时,K8s的Cluster Autoscaler会向云厂商申请新的节点;任务结束后节点自动回收。用云主机时,可以做到成本跟任务走,而不是任务跟集群走。
注意:动态资源分配依赖Shuffle文件的可访问性。如果任务里用了Kryo序列化或者特殊的Shuffle管理器(非默认SortShuffleManager),建议先做兼容性测试。我当初迁移一个老任务时,开了动态分配后Executor频繁缩容,Shuffle数据直接丢失,任务重跑一次才知道问题出在自定义ShuffleManager上。
3.4 存储选型与配置
跑大数据任务,存储是绕不开的话题。在K8s环境里,存储主要分三类:
第一类是HDFS。如果你的数据源还在HDFS上,那K8s的Executor Pod只要通过HADOOP_CONF_DIR连接NameNode就行。注意要从K8s的Pod内能访问到NameNode的地址和端口,且需要在镜像里放好Hadoop客户端的配置文件。如果你用的是高可用HDFS,还要把dfs.nameservices等配置完整拷贝进去。
第二类是对象存储(S3、OSS等)。这也是K8s上Spark最常见的存储后端。对象存储的好处是与集群解耦,Pod删了数据还在,而且对象存储带宽大,可以支撑很多Executor并发读写。配置时命门是认证——S3用access key,OSS用AccessKeyId/AccessKeySecret,需要设置好对应的Hadoop FileSystem实现和认证配置。
第三类是K8s的PVC。适合临时中间结果或本地测试,跑完任务直接删。生产环境不建议用本地卷存重要数据,因为在Pod被调度到其他节点后数据就丢了。
我给一个S3的配置示例:
--conf spark.hadoop.fs.s3a.endpoint=<S3或OSS的Endpoint> \ --conf spark.hadoop.fs.s3a.access.key=<AK> \ --conf spark.hadoop.fs.s3a.secret.key=<SK> \ --conf spark.hadoop.fs.s3a.path.style.access=true \ --conf spark.hadoop.fs.s3a.impl=org.apache.hadoop.fs.s3a.S3AFileSystem如果你的数据规模上了PB级,建议还是用HDFS或对象存储做数据湖底座,用K8s只做计算层。否则调度弹性带来的好处,会被存储层的运维成本吃掉。
4. 从零到一的实操部署记录
4.1 环境准备与基础规划
实操部分我用一套模拟环境走一遍完整流程,方便你对照。模拟环境是三台机器:一台Master节点(4C8G),两台Worker节点(8C16G)。K8s版本是1.27,Spark版本3.4,Docker作为容器运行时。
环境准备好了之后,先处理权限问题。Spark on K8s默认使用K8s的Service Account认证,需要在目标命名空间先建好ServiceAccount和RBAC规则。这里可以直接用一份最小权限配置:
apiVersion: v1 kind: ServiceAccount metadata: name: spark namespace: spark --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: spark-role namespace: spark rules: - apiGroups: [""] resources: ["pods", "services", "configmaps"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: [""] resources: ["pods/log"] verbs: ["get", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: spark-role-binding namespace: spark subjects: - kind: ServiceAccount name: spark namespace: spark roleRef: kind: Role name: spark-role apiGroup: rbac.authorization.k8s.io这几个权限是最基本的要求。如果日志收集还需要读Pod日志,要加pods/log的get和list权限。如果要用PVC,还要在resources列表里加上persistentvolumeclaims。
另一个容易被忽视的问题是——需要提前想清楚用哪个命名空间。不要把Spark任务直接丢到kube-system或default这种公共命名空间,我建议单独建一个spark命名空间,和其他业务的资源隔离。这样查看日志、配额管理、清理残留Pod都比较方便。
4.2 镜像构建与推送流程
上生产之前,镜像准备工作最好固化成一个自动化流程。我在模拟环境里用一套脚本搞定构建和推送:
#!/bin/bash # 1. 构建镜像 docker build -t registry.example.com/stream-spark/spark-py:3.4.0 . # 2. 登录镜像仓库 docker login registry.example.com # 3. 推送镜像 docker push registry.example.com/stream-spark/spark-py:3.4.0如果你用的是云厂商的镜像仓库,构建完之后可以配置镜像扫描和漏洞修复,主要图个安心。私有化部署的团队可以把Trivy或Clair扫描加进CI流程。
构建时特别注意一点:基础镜像里的Spark是在root用户环境下编译的,但你用非root的spark用户跑任务时,/opt/spark目录的写权限要确认好。Executor起来后要写临时文件到本地目录(比如/tmp、/var/data),如果这些目录没有spark用户的写权限,启动阶段就会报Permission denied。
4.3 任务提交与调度生效验证
环境就绪后,用一个最简单的SparkPi任务验证整条链路通不通:
spark-submit \ --master k8s://https://<MASTER_NODE_IP>:6443 \ --deploy-mode cluster \ --name spark-pi-test \ --class org.apache.spark.examples.SparkPi \ --conf spark.kubernetes.container.image=registry.example.com/stream-spark/spark-py:3.4.0 \ --conf spark.kubernetes.namespace=spark \ --conf spark.kubernetes.authenticate.driver.serviceAccountName=spark \ --conf spark.driver.cores=1 \ --conf spark.driver.memory=2g \ --conf spark.executor.instances=3 \ --conf spark.executor.cores=1 \ --conf spark.executor.memory=2g \ --conf spark.kubernetes.executor.limit.cores=1 \ --conf spark.kubernetes.driver.pod.name=spark-pi-driver \ local:///opt/spark/examples/jars/spark-examples.jar 1000提交后可以通过以下命令观察Pod状态:
kubectl get pods -n spark -w kubectl logs -n spark spark-pi-driver -f第一次跑通时你会看到这样一串事件流转:driver pod先变成ContainerCreating,镜像拉取完成后变成Running;然后executor pod逐个出现,变成Running;最后driver进入Completed状态,executor pod自动清理。
如果卡在ContainerCreating很久不动,先查两个原因:
- 镜像拉不过来:
kubectl describe pod <pod名>看Events里的错误信息,最常见的报错是ImagePullBackOff或ErrImagePull。 - PVC绑定失败:如果配置了存储卷但StorageClass没有默认值,PVC会一直Pending,卡住整个调度。
跑通基础链路之后,我建议按下面这个顺序逐步增加复杂度:先跑一个文件读写的WordCount任务,再跑一个有Shuffle的Join任务,然后跑一个涉及外部数据源的实时读取任务。每一步都验证日志、监控、资源回收是否正常,不要一步到位直接上复杂任务。
4.4 日志收集与监控接入
任务能跑通只是第一步。真要说生产运行稳不稳,还得看监控和日志。K8s上Pod销毁后日志默认会消失,所以需要把日志外置。我用的方案是把日志打到标准输出,由K8s原生的日志收集Agent统一采集。
比较主流的日志方案有两种:
- 采集到ElasticSearch系:用Filebeat或Fluentd采集Pod日志,给Spark任务按
appId或podName打索引,用Kibana查日志。这套适合已经有ELK运维体系的团队。 - 采集到云日志服务:云厂商的基础设施里都有对应的日志采集组件,配置起来更省事。
监控指标方面,建议把Spark的metrics上报到Prometheus。在spark-submit里加上这几行配置:
--conf spark.ui.prometheus.enabled=true \ --conf spark.kubernetes.driver.annotation.prometheus.io/scrape=true \ --conf spark.kubernetes.driver.annotation.prometheus.io/port=8090 \ --conf spark.kubernetes.driver.service.annotation.prometheus.io/scrape=true \开启之后,Grafana里可以定制很多有效的看板:Executor的GC时间、Shuffle读写速率、任务调度延迟、活跃的Stage数量等。某天做性能分析时,我用Prometheus看到某个任务瓶颈在Shuffle写——每个Executor的Shuffle写时间到了运行总时长的63%,后来把Shuffle分区数调大之后,整体耗时降了31%。没有监控,这种性能问题基本靠猜。
4.5 日志排查的小技巧
排查K8s上Spark任务问题时,第一步永远是kubectl get pods -n spark看状态,第二步kubectl describe pod看事件,第三步再kubectl logs看应用日志。这个顺序不要乱,也不要一上来就翻Spark日志,不然会被无效信息淹没。
注意区分三层日志:
- K8s层:镜像拉取、健康检查、资源申请、调度失败等,都在
kubectl describe pod的Events里。 - Spark层:任务阶段提交、Executor心跳、Shuffle过程,在Driver日志和Spark UI里。
- 业务层:你代码里的print和log,也在Pod的标准输出里,但和Spark自身的日志混在一起,建议在代码里给自己的日志打上业务标识前缀,便于过滤。
5. 常见问题与避坑指南
5.1 任务提交常见错误速查
我把这段时间遇到的典型问题整理成一张排查表,方便你直接参照:
| 现象 | 可能原因 | 排查/解决方式 |
|---|---|---|
| 提交后没有创建Driver Pod | ServiceAccount权限不足 | 检查RBAC配置,确认serviceAccountName是否正确 |
| Pod一直ImagePullBackOff | 镜像tag写错或私有仓库认证失败 | kubectl describe pod查看具体错误,检查imagePullSecrets |
| Driver启动后Executor迟迟不创建 | 动态分配配置问题或资源不足 | 查看Driver日志,确认executor.instances或minExecutors配置 |
| Executor报连接Driver超时 | Service没建好或网络策略限制 | 检查spark.kubernetes.driver.service.name,网络策略放行端口 |
| 任务OOM被K8s杀掉 | 堆内存+堆外内存超出Pod限制 | 调大memoryOverhead,或者给Pod换更大规格 |
| Shuffle数据丢失 | 自定义ShuffleManager和动态资源冲突 | 关闭动态分配,或者改造ShuffleManager |
| 重启策略导致重复运行 | configured restartPolicy为Always | 生产环境设置OnFailure或Never |
| Executor启动极慢,超过5分钟 | 镜像太大或仓库网速慢 | 做镜像瘦身,或预热到各节点本地缓存 |
5.2 稳定性相关心得
跑了一段时间Spark on K8s之后,我筛选出三条最重要的稳定性经验。
第一,给Driver设置合适的重启策略。K8s默认的restartPolicy是Never,Driver因为OOM或网络问题退出后任务直接失败。我建议设为OnFailure,这样Driver在部分失败场景下能自动重启,某些抖动造成的偶发失败可以自愈。但要注意,如果业务对“任务失败必须立刻告警”有强诉求,AutoRetry反而会掩盖问题,需要权衡。
第二,注意Pod调度失败的后端退避。Executor Pod创建失败后,Spark默认会重试,但重试次数和间隔如果不控制,短时间内会狂发创建请求压垮API Server。建议把spark.kubernetes.allocation.maxRetries限制在5~10次,间隔用默认值即可。
第三,一定要做资源配额(ResourceQuota)。如果多人共用一个K8s集群,没配额意味着有人能把集群资源打满,导致其他业务不可用。在spark命名空间设置ResourceQuota能有效防止单个任务疯狂扩张:
apiVersion: v1 kind: ResourceQuota metadata: name: spark-quota namespace: spark spec: hard: requests.cpu: "100" requests.memory: 400Gi limits.cpu: "120" limits.memory: 480Gi count/pods: "500"5.3 成本优化与资源效率
最后聊下成本。我见过太多团队迁到K8s之后,成本反而涨了,核心原因是没有利用好弹性和资源复用。
首先看你的任务节奏。如果每天固定时间有批处理任务,白天其他时间没有,就可以把Spark的Executor时间窗和在线业务的波峰错开。K8s的优先级别调度(PriorityClass)在这里能派上大用场——在线业务的Pod优先级高,Spark这种批处理任务优先级低,资源紧时先把批处理挤掉,在线业务优先保障。
其次,利用Spot实例或抢占式实例跑Spark。Executor本身就设计为随时可能被替换,对底层实例的突然回收容忍度比特长时间运行的在线服务高得多。用Spot实例跑非核心的批处理任务,成本能再降四成左右。前提是任务要做好重试和检查点机制,被抢占后能无缝恢复。
还有一类容易被忽略的资源浪费——Executor内存申请比JVM实际用到的多很多。通过监控面板统计Executor的峰值内存使用率,把冗余的内存逐步压下来,10个Executor省2个G,几百个Executor省下的资源就很可观了。我见过有些团队给Executor申请16g,实际每个只用6g,纯浪费。定期用Spark UI的Event Log分析Executor内存峰值,是个很实用的小习惯。
6. 后续扩展与个人体会
部署完成并稳定运行一段时间之后,可以继续往这几个方向做扩展:一是把Airflow或DolphinScheduler接进来做任务编排,让Spark任务变成工作流的一个节点;二是引入Iceberg或Hudi做数据湖,让Spark读写对象存储的同时保证事务性;三是用Spark Operator(比如Kudobuilder的Spark Operator)管理SparkApplication资源对象,实现声明式任务部署。
个人体会放在最后说吧。Spark on Kubernetes这套方案,不是简单的“换个提交方式”,而是整个资源管理理念的变化——从固定集群的方式,变成了以任务为中心的动态资源交付。它让Spark任务不再依赖大型常驻集群,而是跟着云端能力走。这套模式在成本和弹性方面有明显优势,但需要团队对K8s的基础设施能力有一定掌控力。如果你所在团队的K8s运维能力本身还在起步阶段,建议先把镜像构建、RBAC、日志监控这些基础打好再加任务量,不然一次集群故障带来的问题,会比传统Hadoop环境放大好几倍。
做这套系统的过程中我自己也踩过不少坑,笔记记了好几页,但实际跑通之后回头看,这套方案是值得投入的。尤其是当你的业务规模涨到一定程度,发现自己再也不想回到手动扩缩容机器的时代时,你会明白云原生大数据这条路的价值。