技术探索与实践:在Spark on K8s的泥潭里摸爬滚打的一年
北京的春天总是来得猝不及防。上周五晚上十点半,我坐在国贸地铁站的长椅上啃着便利店饭团,脑子里还在反复回放白天那个诡异的OOM问题——不是堆内存溢出,也不是元空间炸了,而是在K8s里跑Spark任务时executor莫名其妙被驱逐。当时真的想砸电脑。
我是老张(真名就不说了),做了三年大数据开发,天天和Spark打交道。坐标帝都,通勤一小时起步,公司做的是电商业务,团队氛围还行(至少比隔壁组强,他们PM半夜三点钉钉发需求)。去年双11前,老板突然拍板要“全面云原生化”,于是我们这群搞大数据的就被推上了前线,开始折腾Spark on Kubernetes。今天这篇技术分享,就是想聊聊这一年踩过的坑、熬过的夜,以及那些在面试题里永远讲不清、只有实战才能懂的细节。
为啥非得把Spark塞进K8s?
说实话,一开始我对这事是抗拒的。Spark on YARN 跑得好好的,资源调度稳如老狗,日志聚合也成熟。但领导说:“我们要拥抱云原生!”、“YARN太重了,不符合微服务架构!”——好吧,我知道真正的原因是运维团队想统一用K8s管理所有工作负载,顺便裁掉YARN那套老旧的监控体系。
但抗拒归抗拒,活还得干。我们面临的实际业务场景是这样的:
- 每天要处理TB级用户行为日知(埋点数据)
- 离线批处理 + 近实时流处理混合
- 团队规模小,没法养两套调度系统(YARN + K8s)
所以,综合来看,迁移到Spark on K8s成了唯一合理的选择——虽然过程相当酸爽。
第一个坑:executor起不来?因为Pod没权限!
刚搭好环境那会儿,提交个最简单的WordCount都报错:
Exception in thread "main" io.fabric8.kubernetes.client.KubernetesClientException:
Failure executing: POST at: https://k8s-api/api/v1/namespaces/spark/pods.
Message: pods "spark-pi-1234567890-exec-1" is forbidden:
User "system:serviceaccount:spark:default" cannot create resource "pods" in API group "" in the namespace "spark".
我当时一脸懵:不是按官方文档配的RBAC吗?翻了半天源码(没错,我又去翻Spark的K8s调度模块了),才发现我们集群启用了PodSecurityPolicy,而默认的ServiceAccount压根没权限创建Pod。
解决方法很简单:给Spark专用的ServiceAccount绑定合适的RoleBinding。
# spark-role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: spark
name: spark-role
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["create", "delete", "get", "list", "watch"]
- apiGroups: [""]
resources: ["services"]
verbs: ["create", "delete", "get"]
---
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
然后提交任务时指定这个SA:
spark-submit \
--master k8s://https://your-k8s-cluster \
--conf spark.kubernetes.authenticate.serviceAccountName=spark \
...
💡 经验教训:别信官方Quick Start,生产环境的安全策略千奇百怪,务必先确认RBAC和PSP配置。
第二个坑:动态资源分配失效?因为K8s不认YARN那套逻辑!
我们之前在YARN上重度依赖Spark的动态资源分配(Dynamic Allocation),能根据任务负载自动扩缩executor,省了不少钱。迁到K8s后,发现这功能“看似可用,实则抽风”。
查了源码才发现,Spark on K8s的动态分配依赖于外部Shuffle Service(ESS),而K8s本身没有类似NodeManager的东西来托管shuffle文件。官方方案是部署一个独立的spark-shuffle-service Pod,但这玩意儿配置复杂、维护成本高,而且我们测试发现稳定性堪忧——双11压测时直接丢shuffle数据。
于是我们团队内部开了个“头脑风暴”(其实就是边喝瑞幸边骂街),最后决定:干脆放弃动态分配,改用更精细的资源配置 + autoscaler。
具体做法:
- 预估任务资源:基于历史任务metrics(CPU、内存、GC时间)建立模型
- 使用Vertical Pod Autoscaler (VPA) 自动调整Pod request/limit
- 关键任务固定资源,非关键任务走弹性
比如一个典型的ETL任务配置:
spark-submit \
--conf spark.executor.instances=20 \
--conf spark.executor.memory=8g \
--conf spark.executor.cores=2 \
--conf spark.kubernetes.executor.limit.cores=3 \
--conf spark.kubernetes.executor.request.cores=2 \
--conf spark.kubernetes.executor.memoryLimitFactor=1.2 \
...
注意 memoryLimitFactor 这个参数,它控制 limits.memory = request.memory * factor,避免因内存超限被OOMKilled。
工具链升级:从Log Aggregation到Debug神器
在YARN时代,看日志靠yarn logs -applicationId xxx,虽然慢但至少能用。到了K8s,每个executor都是独立Pod,日志分散得像我的发际线。
我们试过几种方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
直接 kubectl logs |
简单 | 无法聚合,历史日志难追溯 |
| ELK Stack | 功能强大 | 运维复杂,延迟高 |
| Loki + Promtail | 轻量,集成Grafana | 查询语法反人类 |
最后选了Loki,主要是因为它和Prometheus生态无缝集成,而且我们运维已经有一套Prometheus监控了。配合Grafana做日志面板,效果如下(想象一下):
- 按 applicationId 过滤所有executor日志
- 关联metrics(CPU、内存、GC)一起看
- 异常日志自动告警(比如连续出现
FetchFailed)
另外,我还写了个小工具脚本,一键拉取某个Spark App的所有日志:
#!/bin/bash
# get-spark-logs.sh
APP_ID=$1
NAMESPACE=${2:-spark}
PODS=$(kubectl get pods -n $NAMESPACE -l spark-app-selector=$APP_ID --no-headers | awk '{print $1}')
for pod in $PODS; do
echo "=== Logs for $pod ==="
kubectl logs -n $NAMESPACE $pod
done
虽然土,但救命。
面试题背后的真相:Shuffle到底怎么优化?
每次面试都被问:“Spark Shuffle有几种类型?怎么调优?”
答:“bypass merge sort, tungsten sort... 增大spark.shuffle.file.buffer...”
但现实呢?在K8s环境下,磁盘IO成了最大瓶颈!因为我们用的是云厂商的通用型SSD,随机读写性能远不如物理机上的NVMe。
去年双11,一个核心报表任务从2小时飙到5小时,卡在Shuffle Read阶段。排查发现executor频繁GC,但堆内存明明够用。后来用perf抓火焰图,发现大量时间花在read()系统调用上——磁盘IO阻塞了线程。
解决方案:
- 换用高性能云盘(贵,但值得)
- 调大spark.shuffle.io.maxRetries 和 spark.shuffle.io.retryWait
- 开启spark.shuffle.compress=true(默认开,但确认下)
- 最关键的:把spark.local.dir挂载到本地SSD(如果节点有)
# 在Pod template里挂载本地盘
spec:
containers:
- name: spark
volumeMounts:
- name: local-ssd
mountPath: /tmp/spark-local
volumes:
- name: local-ssd
hostPath:
path: /mnt/disks/ssd0
然后提交时指定:
--conf spark.local.dir=/tmp/spark-local
这招让Shuffle性能提升了40%,老板当场表示“这个月奖金有着落了”(并没有)。
综合调优:从参数到架构
经过一年折腾,我们总结了一套综合调优清单,适用于大多数Spark on K8s场景:
资源配置
spark.executor.memoryOverhead至少设为executor.memory * 0.2(K8s overhead更高)- 避免单Pod过大(建议单executor ≤ 16GB内存),否则调度慢、失败代价高
- 使用
spark.kubernetes.driver.pod.namePrefix方便追踪
容错与重试
--conf spark.kubernetes.driver.retryTimeout=300s \
--conf spark.task.maxFailures=8 \
--conf spark.yarn.maxAppAttempts=1 # 注意:K8s不用yarn,但有些旧代码残留
监控必备
- 暴露Prometheus metrics:
--conf spark.metrics.conf.* - 用
kubectl describe pod看Events,很多调度失败原因藏在这里 - 记录每次任务的
spark-submit命令(我们用Argo Workflows封装)
心得:技术探索不是炫技,而是解决问题
回头看这一年,其实最大的收获不是学会了多少K8s YAML,而是明白了技术选型必须服务于业务。云原生很香,但如果你的团队连基本的K8s运维能力都没有,硬上只会死得更快。
我也曾为了“显得牛逼”在项目里塞进各种新潮工具,结果上线后半夜被PagerDuty叫醒修Bug。现在学乖了:先跑通,再优化;先稳定,再扩展。
顺便说一句,最近面试了几家公司,发现“Spark on K8s”已经成了中高级大数据岗的标配问题。如果你正在准备跳槽,建议:
- 动手搭个Minikube环境跑几个任务
- 读读Spark的K8s scheduler源码(
ResourceStarter.scala和ExecutorPodsAllocator.scala是关键) - 准备好讲清楚你遇到的真实问题,而不是背八股文
写在最后
凌晨一点,地铁末班车快来了。这篇文章写得有点碎,但这就是我们日常的真实状态:在Deadline、线上事故、技术债和学习欲望之间反复横跳。
如果你也在搞Spark on K8s,欢迎留言交流(或者一起吐槽PM)。技术这条路,一个人走太孤独,抱团取暖才能活得久一点。
对了,下周我要去趟上海参加KubeCon,听说有个Spark SIG会议——希望别又遇到那种“理论上可行”的Demo,老子要的是能跑在生产环境的方案!
本文所有配置和代码均来自真实项目脱敏,如有雷同,那说明你也踩过同样的坑。
—— 一个不想再加班的大数据开发,于北京某地铁站

评论 0