导致 Spark on Kubernetes 发生 OOM 故障的两个配置错误

2026-06-09 1 阅读 作者:Pranav Bhasker
引言 在将几个 Spark 批处理管道从本地基础设施迁移到 Azure Kubernetes Service (AKS) 后不久,我们发现其中一个比较大的作业反复出现执行器内存不足 (OOM) 故障。这些故障出现在 shuffle 阶段,起初看起来像是典型的 Spark 内存调优问题。我们尝试了增加执行器内存、调整执行器数量,并多次重启该作业,但这些方法都未奏效。令人费解的是,该管道在迁移前已经稳定运行多年。 最终的根本原因根本不是 Spark 配置问题,而是迁移过程中引入的两项基础设施级设置发生了意想不到的交互:基于 RAM 的本地临时目录(spark.kubernetes.local.dirs.tmpfs=true)以及一条严格的强制所有执行器位于同一节点的 podAffinity 规则。这两项设置共同导致了 shuffle 溢写消耗了节点内存而非磁盘空间,从而引发了反复的 OOM 终止。 本文记录了调查过程、根本原因以及用于解决该问题的配置变更。 系统环境与迁移背景 管道环境 我们的数据平台负责管理生产环境的批处理管道,支持美国某大型金融机构对交易数据进行大规模地聚合和转换。相关工作负载每天处理约 3GB 的定宽平面文件。该文件包含多种交错的记录布局,需要进行多次解析和合并操作,这使得该任务的 shuffle 操作强度远高于其 3GB 的输入大小所暗示的水平。在本地环境中,这些管道已经稳定运行了三年多,执行情况稳定,而且未出现这样的内存不足(OOM)模式。 AKS 集群配置 事件发生的环境如下: 迁移背景 迁移至 AKS 是更广泛的云现代化计划的一部分。这些工作负载被视为“平移”的候选对象,只要匹配 CPU 和内存配置,即可在不修改应用程序的情况下保持运行时行为。但这一假设被证明是错误的:迁移过程中引入的两项基础设施设置改变了 Kubernetes 处理执行器部署和本地存储的方式,而这两项设置在审查过程中均未被标记出来。 事件时间线 迁移后第一周 最初似乎运行得很稳定,比较小的任务都顺利完成了。首次内存不足(OOM)故障出现在定宽多布局批处理任务中,具体发生在该任务需要对大量数据进行 shuffle 的合并阶段。在合并结果之前,该任务使用不同的布局解析器多次读取了同一个 3GB 大小的文件。 第二到第三天 OOM 故障最初被视为暂时的。任务被手动重启并暂时恢复。该问题被记录为间歇性故障,并归因于集群层面的潜在资源竞争。 第三到第四天:初步假设与堆配置错误 初步诊断的重点在于执行器堆内存的配置。我们将 spark.executor.memory 从 8 GB 增加到 10 GB。但在高负载下故障依然存在,这排除了将堆内存配置不足作为根本原因的可能性。 第四天:第二种假设与执行器数量 为了进一步分担工作负载,我们增加了执行器的数量,但这并未产生显著的效果;在涉及大量 shuffle 操作的阶段,系统仍会发生故障。 第四至第五天:Kubernetes Pod 放置分析 我们通过 AKS Pod 日志和 Datadog 检查了 Kubernetes Pod 的放置情况。Datadog 的 Kubernetes 节点概览仪表板显示,在重新分配阶段,节点内存使用率飙升至 90% 以上。Kubernetes 事件持续报告: Reason: OOMKilled Exit Code: 137 我们还在 AKS Pod 日志中观察到了明显的执行器更替模式。该作业并未使用最初的四个执行器来完成,而是反复替换因内存不足(OOM)而被终止的执行器,最终在用到了第 50 个执行器时才失败。在每次终止事件发生前的洗牌阶段,Datadog 显示的节点级内存使用量在数秒内从约 42GB 飙升至超过 58GB。这清楚地表明,问题并非出在堆大小配置上,而是节点级内存耗尽导致 Kubernetes 内核 OOM killer 终止了执行器进程。 第五天:根因确认 我们在对 Spark 和 Kubernetes 配置进行审查时发现,spark.kubernetes.local.dirs.tmpfs 在迁移过程中被设置成了 true,这导致所有本地临时目录(包括洗牌溢出路径)都由内存而非磁盘提供支持。此外,临时卷(tmp-volume 和 workdir)的容量大小也仅为 1 GiB,对于该作业生成的洗牌数据而言,这远远不够。在本地环境中,原本是使用本地磁盘空间来存储洗牌溢出数据,而且卷的大小配置合理。然而,在迁移审查过程中,这两处差异均未被记录在案。 第五天:方案部署 通过设置 preferredDuringSchedulingIgnoredDuringExecution,将 podAffinity 托管规则替换为 podAntiAffinity。此外,将 spark.kubernetes.local.dirs.tmpfs 设为 false,并将 tmp-volume 和 workdir 的大小限制从 1 Gibibyte 增加到 10 Gibibyte。OOM 故障随即停止。该修复方案已经稳定运行六个月,未再发生任何故障。 根因分析 在调查过程中,我们发现了导致故障的三个因素。只有在高随机访问负载下,第二和第三个因素相互作用时,这些故障才会出现。 因素1:大型数据任务中由洗牌引发的内存压力 在数据密集型任务中,大规模的洗牌阶段会导致执行器的内存使用量急剧飙升。这是 Spark 中的预期行为;洗牌操作需要在数据溢出到磁盘之前,先将中间数据保存在内存中。单独来看,通过适当的资源配置,这种数据保留是可控的。但当与下文所述的其他因素结合时,情况便变成了灾难性的。 尽管源文件只有大约 3GB,但多布局定宽格式所需的多次解析迭代、针对每种布局生成的中间 Spark DataFrame 实例、将它们合并的并集操作,以及下游的洗牌阶段,会导致内存压力远超原始输入大小所暗示的水平。对于一个拥有 4 个执行器的任务(即一个包含 4 个工作进程的 Spark 集群配置,每个进程被分配固定比例的 CPU 内核和内存,共同负责在分区间并行处理数据)而言,3 GB 的输入文件看似规模不大,但多轮处理将有效工作集的规模放大到了远超原始输入大小的程度。 因素 2:亲和性配置错误导致执行器被迫共置 实际上,执行器放置规则已经成为一项硬性共置约束。配置中存在一条使用 requiredDuringSchedulingIgnoredDuringExecution 的 podAffinity 规则。该规则并未将执行器 Pod 分布到不同的节点上,而是强制将它们放置在同一个节点上。这并非默认的装箱行为;Kubernetes 只是遵循了一条显式的硬性放置规则。 Kubernetes 的调度机制与配置错误的放置规则相结合,导致所有四个执行器 Pod 都被分配到了同一个 64GB 的节点上。这使得洗牌期间的内存和 I/O 压力都集中在了一台机器上。一旦加入基于 tmpfs 的溢出机制,节点内存便告耗尽,内核 OOM killer 开始终止执行器。 在本地环境中,集群调度器曾经配置了明确的放置约束,可以自然地分散执行器。但在迁移过程中,该约束并未被保留。我们通过使用 pr