资讯详情

资讯详情

Spark on Kubernetes Operator 生产落地常见问题与排查实战

Spark on Kubernetes 用起来最磨人的其实不是 Spark 本身而是 operator 生产化落地过程中的各种“暗坑”。凡是跑过 SparkApplication 的人都知道CRD 提交成功只是第一步真正让人熬夜的全是后续的资源、日志、调度和镜像问题。这期是常见问题系列的第二篇接着上一篇没聊完的内容继续整理我实际运维中反复踩过的几个问题点每一个都是能直接影响任务成功率的硬货希望能帮你少走几趟弯路。1. Spark Operator 安装环节的经典翻车现场1.1 CRD 与 Helm Chart 版本不匹配导致的“幽灵错误”安装 operator 本身看着很简单一条 helm install 就完事但问题往往藏在 CRD 和 chart 版本的隐性依赖上。我见过太多人用最新 chart 配老 CRD或者反过来结果提交 SparkApplication 时报错信息模棱两可——一会儿说 metadata 里缺少字段一会儿说 schema validation 失败排查半天最后发现是 CRD 没跟着 chart 升级。这里要说明一个原理spark-on-kubernetes-operator 的 CRD 定义了 SparkApplication 这套资源对象的“数据结构模板”而 operator 控制器运行时代码是根据特定版本的 CRD 结构来做 JSON 反序列化的。一旦两边版本错位你提交的 CRD 资源里某些字段比如 driver.sidecars、monitoring.exposeDriverMetrics在旧 CRD 中根本不存在API Server 直接拒绝写入而某些新 CRD 字段如果在老 chart 的 webhook 校验逻辑里没处理也会出现 500 错误。实操建议安装前先查一下你用的 chart 版本对应的 CRD 内容确认kubectl get crd sparkapplications.sparkoperator.k8s.io -o yaml里的字段和你提交的 YAML 一致。升级之后记得先执行一次kubectl apply -f crd/覆盖安装再升级 controller。我在测试环境直接吃过这个亏一个顺手helm upgrade忘了 CRD导致整个集群所有 SparkApplication 提交全部失败控制台日志没有任何 Exception只有反复的 “reconcile error”排查基线完全错乱。1.2 命名空间权限不足导致 SparkApplication 无法创建 Driver Pod很多人在单命名空间部署 operator 后发现任务能提交但 driver Pod 一直处于 Pending 状态查看事件才发现 operator 根本没权限在你提交任务的 namespace 里创建 Pod。这个问题的根源在于 Spark-on-K8S-operator 的 controller 默认通过 Kubernetes API 创建 driver/executor Pod如果你没有把相应 namespace 的 role 绑定给 operator 的 serviceAccount你会发现 SparkApplication 显示 “Submitting” 状态很久然后卡住不动。如果你用--namespaceoperator-namespace模式部署operator 默认只 watch 自己所在 namespace。若你想跨 namespace 提交要么给 operator 的 ServiceAccount 绑定整个集群范围的权限要么逐个 namespace 创建 RoleBinding。这里容易踩的坑是绑定对了 Role却忘了 SparkApplication 的.spec.serviceAccount字段——driver Pod 起来之后如果它内部需要访问 K8S API比如动态资源分配、或者你自定义了 driver 里需要调用 API 的逻辑这个字段如果指向一个不存在的 SAPod 会创建成功但马上以 CrashLoopBackOff 退出K8S Events 里只看到 “Error creating: pods is forbidden”非常误导人。一个稳妥的排查顺序先看kubectl get sparkapplication -A的状态再看对应 driver Pod 的kubectl describe pod最后看 operator 日志。把这三层串起来看大部分权限问题都能定位到。2. 资源申请与调度类问题占比最高的“重灾区”2.1 未显式设置资源请求导致 Executor 被“挤死”SparkApplication 里如果不写.spec.executor.resources或者只写limits不写requests部分集群尤其开了 ResourceQuota 的 namespace会出现 Pod 能创建但无法调度的现象。这是因为 K8S 的 QOS 类判定、节点压力驱逐、以及 HPA 的计算逻辑很大程度上依赖 requests 的稳定值。极限情况下节点内存压力上来后系统会优先驱逐那些 requests 设置不规范或者 QOS 为 BestEffort 的 Pod而 Spark Executor 一旦被驱逐shuffle 数据丢失任务直接挂在 fetch 阶段。我的做法是 always 显式同时配置 requests 和 limits且尽量让两者一致保证 QOS 为 Guaranteed。这看起来浪费了一点弹性但对于跑批任务来说稳定性远比弹性重要。尤其要注意 executor 的 memory overheadSpark 3.x 默认 overhead 比例是 10%如果给得很小比如只有 512MiB但实际执行中 executor 堆外内存使用量大就会出诡异问题——进程不退出但 Runtime.getRuntime.maxMemory 与容器 cgroup 限制之间存在 gap导致频繁 Full GC。2.2 动态资源分配与 K8S 调度器之间“谁也说不服谁”Spark 原生的动态资源分配spark.dynamicAllocation.enabledtrue在 YARN 模式是标配但在 K8S 模式很多人在 executor 数量波动上遇到过怪事executor 按需减少后Pod 并没有被回收而是变成 Completed 或一直 Terminating。本质原因是 Executor 退出时Spark Context 需要跟 Driver 同步更新心跳而 Driver 如果此时正好在做 shuffle 归并或者 BlockManager 注册更新会造成 executor 的 BlockManager 通知丢失Driver 侧迟迟不感知 executor 退出。在 operator 模式下我建议结合 Executor 数量上限来配置即显式设置spark.dynamicAllocation.maxExecutors否则 Spark 会按需疯狂申请导致 Pod 数量被 K8S 集群的 nodePort 或 Pod 数量上限卡住。还有一个容易忽略的参数是spark.dynamicAllocation.executorIdleTimeout默认 60 秒但在 K8S 集群里如果容器镜像拉取时间较长空闲 executor 可能被多次反复重建放大集群资源碎片化。如果你对任务耗时敏感建议这种场景直接关闭动态分配改用固定instances。operator 自带的 SparkApplication 控制器也支持配置spec.executor.instances并且在 submit 时会将这个值覆盖到 SparkConf 里比在 SparkConf 里写死更直观也方便后期维护。2.3 节点亲和性与并行度的匹配失误K8S 集群里多套业务共用节点时经常给 Spark 打污点并配置节点亲和性。问题往往出现在.spec.executor.affinity只写了preferredDuringSchedulingIgnoredDuringExecution而节点的标签在任务高峰时段被弹性伸缩组件改动导致 executor 调度到非目标节点shuffle 读写的跨节点流量暴增。还有一种容易忽略的情况Spark 的每个 executor 在执行spark.task.maxFailures内的任务重试时会尝试拉动同一个 host 上的 shuffle index 文件。如果节点亲和性配置让 executor 分布太散比如每节点只放 1 个 executor反而会让 shuffle 中间结果落地分散每次 task 拉取都需要跨机走网络性能下降明显。建议同一任务要么使用spark.executor.cores与节点容量对齐保证每个节点尽量放满减少跨节点 fetch 的频率。3. 日志、监控与 UI 访问的三连坑3.1 日志采集器拿不到 Executor 日志的路径问题在 YARN 时代我们习惯去 NodeManager 的日志目录里找 container 日志但 K8S 下日志采集器比如 Fluentd、Filebeat普遍通过收集/var/log/containers/*.log软链到容器标准输出。Spark 的 executor 日志如果没被重定向到 stdout默认情况下是 Spark executor 进程自身以文件形式写日志采集器根本收集不到 Driver 里的 Driver 日志更别说 executor 日志了。最直接的解决方式是设置spark.eventLog.dir和spark.history.fs.logDirectory同时将 executor 端的日志直接输出到 stdout再通过容器运行时打到宿主机文件。在 Spark 3.2 及以上版本可以显式把spark.kubernetes.driver.container.log.file与 stdout 做 soft-link或者干脆把 log4j2.properties 里的 console 级别开到 INFO避免采集器抓空。另外要注意operator 默认会为 SparkApplication 创建spark-app-driver的 ConfigMap 用于配置脚本如果你在.spec.driver.configMap里做了日志相关挂载务必确认日志目录不存在权限冲突。我遇到过 Fluentd 以 root 身份读取日志而 Spark 容器以 UID 2000 运行导致文件读取被拒采集端直接弃读。这种情况在宿主机上给目录加 acl 就好不需要改容器镜像。3.2 Spark UI 访问路径在 Operator 下的变更很多人第一次接触 operator 时都会在 “如何访问 Spark UI” 这个问题上花不少时间。因为 SparkApplication 创建的 K8S Service 命名不是你提交的任务名而是spark-app-name-driver-svc这种自动拼接的格式。如果你在 YAML 里自定义了.spec.driver.service的metadata.name那个自定义名字只用于额外的 Service真正给 UI 用的还是默认生成的带前缀的 Service。拿到 Service 名之后你还需要确认端口名。默认情况下 driver 的 Spark UI 端口是 4040但如果你在 SparkConf 里设置了spark.ui.port为其他端口Service 的目标端口不会自动跟着变需要手动在.spec.driver.service.ports里补上。这一点在文档里写得很隐晦坑了很多刚上手的人。建议直接在本地开发阶段用kubectl port-forward svc/spark-app-name-driver-svc 4040:4040来调试不要在测试环境纠结于 LoadBalancer 或 Ingress 暴露方案。生产环境要稳定访问 Spark UI更靠谱的方式是配置 Spark History Server而不是直接开 driver 的端口暴露。因为 driver Pod 生命周期短任务跑完 Service 还在但后端 Pod 已销毁打开就是连接重置体验非常差。3.3 History Server 与 S3 存储的集成小坑如果你把 event log 写到 S3比如 MinIOHistory Server 读取时经常遇到路径前缀不一致的问题。Spark 写日志时会写入spark.eventLog.dir指定的桶路径但 History Server 加载时对裸路径和带 s3a:// 前缀的路径解析逻辑不同。一个典型报错是java.io.FileNotFoundException: s3a://bucket/spark-events/application_xxx.inprogress查看后发现 API 请求的路径被重复拼接了s3a://或桶名因为前端配置的spark.history.fs.logDirectory与后端写路径不统一。解决思路是统一两边配置不要一个写s3a://一个写s3://且在 History Server 所在容器里必须配置 core-site.xml 的 Hadoop 凭据。另一个坑是.inprogress文件在应用未正常结束时会残留History Server 默认会跳过.inprogress文件但如果你设置了spark.history.fs.inprogressSuffix一定要确认后缀拼写是否与 Spark 默认值一致否则页面会直接显示 “Application Not Found”。4. 任务提交与运行中那些“说不清”的故障4.1 提交后一直处于 “SUBMITTED” 状态的背后原因SparkApplication 提交后一直卡在 SUBMITTED 状态是运营排障里最容易被误判的场景。很多人以为是 CRD 没问题去看 operator 日志却发现根本没有新 reconciler 触发的日志这时问题已经不在 K8S 侧而在 SparkApplication 对象的.spec.restartPolicy或.spec.driver.serviceAccount字段。当.spec.driver.serviceAccount指向的 ServiceAccount 没有权限获取或修改资源时driver Pod 可能根本无法启动而 operator 端的 reconcile 循环会反复尝试重试但状态不更新看起来像 “卡住”。如果设置了OnFailure重启策略driver 失败后还会循环重启更加剧了表象上的“一直提交中”。遇到这个状态最有效的办法是看一眼 operator 侧的--v级别日志把日志级别调到 4 或 5能直接看到 API 调用的失败响应体。另外注意检查.spec.submission里的spark-submit参数比如--conf spark.kubernetes.container.image的镜像名是否被替换成了:latesttag而imagePullPolicy没有设为Always导致每次重新提交时 K8S 缓存的旧镜像没更新跑的还是改动前的代码这种隐蔽问题日志里根本查不到。4.2 Shuffle Service 与 Cleaner 的相爱相杀在 K8S 动态环境下Executor 被驱逐或异常退出后BlockManager 会向 Driver 汇报 block loss这时如果开启外置 Shuffle Service比如在 YARN 场景下是常见配置K8S 模式下却没部署 external shuffle service那么大量 shuffle data 会直接丢失导致任务卡在FetchFailedException。K8S 模式里不存在类似 YARN 的节点级 Shuffle Service所以通常直接把spark.kubernetes.memoryOverheadFactor调大同时增加 reducer 的重试次数。但要注意设置spark.shuffle.io.retryWait和spark.shuffle.io.maxRetries只能缓解临时性驱逐无法解决节点彻底挂掉带来的 shuffle 文件不可访问问题。更靠谱的做法是任务加 checkpointspark.checkpoint.dir配合 broadcast 合适的 hash 分桶能显著降低 shuffle 数据总量。这里想提一个容易被忽略的点operator 模式里SparkApplication 同时允许你在.spec.driver.env或.spec.executor.env里自定义环境变量。我曾在 executor 里设置了SPARK_SHUFFLE_OPTS相关的旧变量结果干扰了 Spark 3.x 默认的 Netty memory 分配导致大 shuffle 任务不断 OOM。现在我的准则是除非明确知道用途否则不要在 executor 环境变量里塞与 shuffle 内存相关的参数。4.3 PodTemplate 与 Spark 内置配置的优先级混淆spark-on-kubernetes-operator 允许通过.spec.driver.podTemplate和.spec.executor.podTemplate传入自定义 Pod 模板这在很多定制的调度场景比如注入 sidecar、自定义 label里特别有用。但这里有一个隐藏规则K8S Pod 模板中的字段优先级高于 SparkApplication 自身生成的 Pod 配置。如果你在 PodTemplate 里写了labels而 SparkApplication 的.spec.driver.label也设置了同样的 key那么最终生效的是 PodTemplate 里的 value而不是 SparkApplication 里的。这意味着什么如果你依赖 operator 的spark-app-name标签做日志采集或 Pod 筛选而 PodTemplate 里定义了一个覆盖用的自定义 label采集规则很容易漏掉部分 executor日志缺失半天排查不出来。我的建议是PodTemplate 尽量只做“附加”类的配置比如加注解、加额外 volumeMounts不要动 Spark 自己管理的 metadata。还需要注意podTemplate的容器名必须与 Spark 的命名规范保持一致driver 容器名为spark-kubernetes-driverexecutor 容器名为spark-kubernetes-executor否则 Pod 创建时会直接报 “container named spark-kubernetes-driver not found” 的错。这个错很容易被理解为镜像拉取失败实际上是模板合并后丢容器了。4.4 镜像构建里藏着的时间炸弹自建 Spark 镜像的人几乎都遇到过这样一个场景代码改完重新打镜像提交任务后却还是旧逻辑。排查到最后多数是镜像 tag 与集群节点上的缓存策略问题。如果你一直用latest或者其他固定 tag且imagePullPolicy是IfNotPresent那所有节点只要拉过一次后续都默认用本地缓存。你可以在 SparkApplication 里直接通过.spec.imagePullPolicy强制每次拉取但这会显著影响大规模 executor 扩容的速度。更好的方案是 CI 里用 commit SHA 或时间戳打 tag从根上避免旧镜像复用。另外要检查镜像里的SPARK_HOME是否被更改过。一些基础镜像把 Spark 装在/opt/spark但也有人用usr/lib/spark如果 SparkApplication 的spark.kubernetes.container.image没问题但 executor 启动时找不到spark-submit或java大概率是镜像内 PATH 没包含你的 Spark 安装目录。5. 版本选择与升级路上的几个提醒5.1 Operator 版本与 Spark 3.x 子版本的兼容性spark-operator 对 Spark 版本的兼容说起来有点模糊。很多文章说“支持 Spark 2.4 和 3.x”但实际用下来Spark 3.4 与 3.5 之间或者 3.1 与 3.2 之间RPC 协议和 Pod 标签逻辑差异会导致一些 small issues。我比较保守的习惯是新项目选型时Spark 版本尽量和官方快速启动指南示例保持一致这样一旦出问题社区案例也最多反而最容易搜到答案。升级时最常踩的坑是 mutation webhook 的配置。老版本 operator 的 webhook 校验逻辑对某些字段的默认值处理不一样比如spec.sparkVersion老版本可能自动补全3.1.1新版本则要求显式填写。升级后大量存量 SparkApplication 没有该字段会一直处于 invalid 状态。解决办法是升级前先批量 patch 存量 CR 的sparkVersion再做 webhook 变更。5.2 删掉未完成的 SparkApplication 留下的孤儿 Pod这是一个操作性极强的问题K8S 集群长时间运行后检查发现有一些 driver Pod 处于 Running 但对应的 SparkApplication 已经被删除。原因是删除 CR 时operator 的 finalizer 做了清理动作但如果你手动kubectl delete sparkapplication时刚好 operator 进程重启或卡住finalizer 没有正常执行滚动删除Pod 就残留在 namespace 里。遇到这种情况最稳妥的方式是不要直接delete namespace而是一层层清理先把所有 SparkApplication 的 yaml 备份然后手动 delete 相应 Pod再用 kubectl patch 去掉 finalizerkubectl patch sparkapplication name -p {metadata:{finalizers:[]}} --typemerge最后删 CR。这个顺序能避免 controller 侧在删除过程中反复创建新 Pod 的“拉锯战”。我还习惯在关键项目的 SparkApplication YAML 里加.spec.cleanup相关配置根据 operator 版本使用对应的资源清理字段让 controller 在应用结束后自动回收已经销毁的 Pod。这能减少一种常见问题废弃 driver 和 executor Pod 积压导致 K8S Etcd 里对象数量膨胀最终影响 API Server 响应速度。大集群里这种小问题积累起来调度延迟会明显增加。5.3 提到spark.executor.instances时operator 与 submit 的覆盖关系最后提一个特别容易误导的参数关系。SparkApplication 的.spec.executor.instances字段会在 operator 提交任务时自动变成一个 SparkConf 项但如果你同时在.spec.sparkConf里手动写了spark.executor.instances二者的优先级不是固定不变的而是取决于 operator 内部的 merge 策略版本。有的版本 YAML 里的显式字段覆盖 conf有的版本反过来。我的习惯是只维护一种来源。建议统一使用 YAML 中的.spec.executor.instances并在.spec.sparkConf里只放与实例数无关的配置。这样从 SparkApplication 的 YAML 就能一眼看出任务规模不用去解析 SparkConf 里额外覆盖的配置项。还有一个相关的点如果你使用 pod template 且里面指定了spec.containers[].resources它与.spec.executor.instances的关联性主要体现在调度层面不会影响 executor 数量但有些同学会误以为给 pod template 加了个大的 resources 配置就可以不改 instances 字段结果执行器数量不变但每个执行器内存暴增最终因节点容量不足排队。写在最后的一点个人体会Spark-on-K8S-operator 这个项目本质上解决的是“把 Spark 以应用级别的抽象跑在 K8S 上”这件事但生产环境的坑往往不在 Spark 本身而在 K8S 的调度细节和资源语义差异上。我维护这套环境以来最大的感受是排障思路要切换到 K8S 的“事件驱动”视角不要一上来就看 Spark 日志。先看 Pod 状态、K8S Events、网络策略再看错误日志很多时候 Spark 代码根本没问题是上层容器环境在搞鬼。另一个小技巧是定期做集群层面的审计列出一周内提交过的 SparkApplication检查失败任务的失败原因分类。你会发现大量问题来自镜像标签不一致、资源请求设置不规范、以及清理策略缺失。把这些基础问题修复后任务成功率显著提升并不需要每次都深入源码级调试。希望这篇内容能帮正在踩坑的你把问题定位时间从“小时级”压缩到“分钟级”。如果你在环境部署中遇到同样的问题也欢迎按自己的经历补充排查路径——踩过的坑分享出来才最值钱。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →