引言
在将几个 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: OOMKilledExit 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 开始终止执行器。
在本地环境中,集群调度器曾经配置了明确的放置约束,可以自然地分散执行器。但在迁移过程中,该约束并未被保留。我们通过使用 preferredDuringSchedulingIgnoredDuringExecution 将共置行为替换为 podAntiAffinity 解决了这个问题。
因素 3:基于 RAM 的本地临时目录(spark.kubernetes.local.dirs.tmpfs=true)
这是影响最大的配置错误。在迁移过程中,Spark 的配置包含:
spark.kubernetes.local.dirs.tmpfs: true当这个属性设置为 true 时,Spark 会指示 Kubernetes 使用基于内存的空目录卷(即临时文件系统 tmpfs)来支持所有本地临时目录,包括洗牌溢出路径。在进行大规模洗牌操作时,Spark 不再将洗牌数据溢出到磁盘,而是将所有数据写入节点内存。Spark 的 Kubernetes 配置文档中有这种行为的记载(参见这里),但其与卷大小限制及执行器共置的交互关系在运维指南中则很少被提及。
基于 tmpfs 的存储与极小的卷大小限制相结合,使这一问题变得更加严重。以下是原始配置:
# 修复前(有问题的配置)volumes: - emptyDir: sizeLimit: 1Gi name: tmp-volume # RAM-backed, capped at 1Gi - emptyDir: sizeLimit: 1Gi name: workdir # RAM-backed, capped at 1Gi当设置 tmpfs=true 时,这些卷消耗的是节点内存而非磁盘空间。每个卷仅有 1 GiB 的容量,对于处理 3 GB 定宽文件的多轮次、多合并作业而言,它们能够为洗牌溢出提供的缓冲空间微乎其微。一旦洗牌溢出超过了基于内存的本地临时卷的有效容量,内存压力便会迅速攀升,内核 OOM killer 便会开始终止执行器进程。在本地部署的配置中, tmpfs=false,并使用基于磁盘的本地存储来处理洗牌溢出。这一差异在迁移审查过程中未被察觉。
复利效应
单独来看,每个因素都是可控的:
仅是洗牌压力:可以通过溢出到磁盘来处理
仅是共置:虽然会增加压力,但在磁盘溢出的情况下并不会造成灾难性后果
仅是临时文件系统(tmpfs):虽然存在问题,但分布式执行器限制了单节点的影响
然而,当所有执行器都被强制分配到同一个节点上,而且其洗牌溢出占用的是节点内存而非磁盘空间时,内存迅速被负载耗尽,进而反复引发 OOM 终止操作。
为何迁移前的测试未能发现这个问题
迁移前的验证测试采用的是规模较小或洗牌强度较低的运行任务。只有在生产级别的负载模式下,即通过多个布局迭代和合并阶段处理全尺寸输入文件时,共置和基于内存的溢出机制所造成的综合影响才会显现。规模较小的测试都能顺利地运行完成,从而给出了修复方案可以稳定运行的假象。迁移前的验证必须在预生产环境中模拟生产级别的负载量;仅凭具有代表性的样本无法揭示那些仅在大规模运行时才会显现的复合型基础设施故障。
修复
下图展示了执行器放置和存储的“前”与“后”状态:
修复前2┌──────────────────────────────────────────────────────┐3│ Node A (64GB RAM) │4│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │5│ │Executor 1│ │Executor 2│ │Executor 3│ │Executor 4│ │6│ │ shuffle │ │ shuffle │ │ shuffle │ │ shuffle │ │7│ │spill→RAM │ │spill→RAM │ │spill→RAM │ │spill→RAM │ │8│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │9│ podAffinity: required (all on same node) │10│ spark.kubernetes.local.dirs.tmpfs: true │11│ tmp-volume: 1Gi (RAM-backed) │12└──────────────────────────────────────────────────────┘13 14执行器内存 + tmpfs 益出 -> 节点内存耗尽15 -> OOMKilled (Exit 137) → 执行器频繁更替 -> 作业失败1617修复后18┌──────────────────┐ ┌──────────────────┐19│ Node A │ │ Node B │20│ ┌──────────┐ │ │ ┌──────────┐ │21│ │Executor 1│ │ │ │Executor 3│ │22│ │spill→disk│ │ │ │spill→disk│ │23│ └──────────┘ │ │ └──────────┘ │24│ ┌──────────┐ │ │ ┌──────────┐ │25│ │Executor 2│ │ │ │Executor 4│ │26│ │spill→disk│ │ │ │spill→disk│ │27│ └──────────┘ │ │ └──────────┘ │28└──────────────────┘ └──────────────────┘29 30podAntiAffinity: preferred (分散到多个节点上)31spark.kubernetes.local.dirs.tmpfs: false32tmp-volume: 10Gi (基于磁盘)33内存压力分散 -> 作业由 4 个执行器在 1 个小时内完成修复 1:针对执行器分布的 Pod 反亲和性设置
我们引入了 preferredDuringSchedulingIgnoredDuringExecution 反亲和性策略,将执行器分散到各个节点上:
affinity: podAntiAffinity: preferredDuringSchedulingIgnoredDuringExecution: - weight: 100 podAffinityTerm: labelSelector: matchExpressions: - key: spark-role operator: In values: - executor topologyKey: kubernetes.io/hostname我们特意选择了 preferred ,而非 required。当集群面临容量压力时,该机制允许共置,从而避免因无法调度 pod 而导致任务失败;而在正常情况下,则强烈倾向于分布式部署。
修复 2:禁用 tmpfs 并增加卷大小限制
同时进行了两项相关的更改:禁用 tmpfs,并提高卷大小限制。
禁用 tmpfs :
# 修复前:基于内存的本地临时目录spark.kubernetes.local.dirs.tmpfs: true# 修复后:基于磁盘的本地临时目录spark.kubernetes.local.dirs.tmpfs: false提高卷大小限制:
# 修复前:1Gi 基于内存的卷volumes: - emptyDir: sizeLimit: 1Gi name: tmp-volume - emptyDir: sizeLimit: 1Gi name: workdir# 修复后: 10Gi 基于磁盘的卷volumes: - emptyDir: sizeLimit: 10Gi name: tmp-volume - emptyDir: sizeLimit: 10Gi name: workdir将 spark.kubernetes.local.dirs.tmpfs 设置为 false,可以指示 Spark 为所有本地临时目录(包括洗牌溢出路径)使用基于磁盘的 emptyDir 卷。Kubernetes 会将数据写入由节点文件系统支持的节点临时存储中,而不是内存。将卷大小限制从 1 GiB 增加到 10 GiB 同样至关重要;原有的 1 GiB 限制远不足以容纳多轮、多合并作业的洗牌溢出数据,即使该卷是基于磁盘的。这两项更改必须同时进行。
我们还验证了将 spark.sql.shuffle.partitions 设置为 200 的效果。这一设置既能为我们的工作负载合理地平衡并行度,又不会生成过多的小型洗牌文件。修复后,我们保留了 10 GB 的执行器内存设置,但存储和调度方面的调整才是决定性的改动。仅增加执行器内存并不能解决故障。
修复 3:节点内存可用空间验证
尽管节点配备了 64GB 内存,但操作系统和 kubelet 开销减少了工作负载可用的内存。经过验证,扣除执行器内存请求以及开销后,余量仍然足够。现在,根据首选的反亲和性规则,四个执行器分布在各节点上,在正常调度下,每个节点最多承载一到两个执行器,即使在多布局合并作业中混排最密集的阶段,峰值内存使用量也始终远低于安全阈值。
在 Standard_D16a_v4 节点上运行 kubectl describe node 命令,可以确认扣除 kubelet 和操作系统开销后的实际可用内存配额:
# kubectl describe node (Standard_D16a_v4)Capacity: memory: 65937008Ki (~62.8 GB) ephemeral-storage: 129886128Ki (~124 GB)Allocatable: memory: 63530608Ki (~60.6 GB) ephemeral-storage: 119703055367 (~111 GB) # kubelet + OS 开销: ~2.3 GB# 4 executors x 10g = 40 GB 执行器内存# 剩余空间: ~20.6 GB将节点升级到更高规格的 SKU 可以提供更大的性能余量,但会增加基础设施成本。本文所述的配置调整方案可以在保持现有节点规模不变且无需额外支出的情况下解决该问题。
结果
修复前
修复后
执行器频繁替换模式是最具说服力的指标。修复之前,集群在每次作业运行时都会启动多达五十个执行器,陷入“ OOM-kill ”和“替换”的无谓循环中——既消耗了集群资源,又未能取得进展。修复后,同一任务仅需四个执行器即可在一小时内完成。无需任何额外的基础设施。
云原生 Spark 的广泛影响
这一事件反映了 Spark 工作负载在向云端迁移的过程中反复出现的三种模式。
云存储的语义并非是基础设施无关的
spark.kubernetes.local.dirs.tmpfs=true 这个配置错误并不是很明显。它的存在肯定有它的道理。对于能够轻松在内存中容纳的小型工作数据集,基于内存的临时目录确实可以提升性能。问题在于,我们将该配置用在了一个包含大量中间数据的作业的洗牌溢出路径上,并将临时卷的大小限制到仅为 1 GiB,这远远不足以承载任何实际的洗牌负载。
云原生存储抽象(如 emptyDir、PVC 和实例存储)与本地物理磁盘在性能和容量方面存在差异。迁移 Spark 工作负载的团队应该在投入生产环境前,明确审核 spark.kubernetes.local.dirs.tmpfs 配置,根据预期的洗牌数据量验证临时卷的大小限制,并确认其在高负载下的运行行为。
Kubernetes 调度器不具备 Spark 感知能力
Kubernetes 针对通用工作负载的调度做了优化,而没有针对 Spark 的洗牌和执行器内存行为做优化。在我们的案例中,问题因一条严格的 podAffinity 规则而加剧(该规则强制要求执行器共置在同一节点上)。这并非调度器的默认行为,而是一个显式配置的错误约束条件。在每个 Spark-on-Kubernetes 部署中,执行器的调度都应被视为一项明确的设计选择,而非默认或沿用自无关的工作负载模式。
配置一致性检查清单未得到充分利用
这次事件中的两处配置错误并非刻意为之,而是由于疏忽所致。原本在本地环境中存在的配置没有完整地迁移过来。为了解决这一问题,我们在文章结尾提供了一份具体的检查清单。
Spark-on-Kubernetes 内存不足(OOM)预防检查清单
本检查清单基于本文中介绍的、导致 OOM 故障的 Spark-on-Kubernetes 配置缺陷。当你在诊断或预防环境中的类似故障时,可以从这里入手。
调度
为执行器 Pod 配置了 Pod 反亲和性
有意识地选择了亲和性模式(首选而非强制)
已根据预期的执行器分布验证了节点池的规模
内存
节点内存计算中已考虑操作系统和 kubelet 开销
执行器的内存请求值和限制值,为每个节点留出安全的余量
已在频繁洗牌的负载下验证了峰值内存
存储
显式将 spark.kubernetes.local.dirs.tmpfs 设置为 false(基于磁盘的临时目录)
为峰值洗牌溢出预留了本地卷空间
通过运行大规模数据集验证了洗牌溢出行为
确认 spark.local.dir 指向基于磁盘的卷
配置一致性
记录了本地调度程序的限制条件,并在云端进行了复现
明确比较了不同环境下的存储卷配置
针对云拓扑验证了网络和 I/O 配置
监控
已配置执行器内存不足(OOM)终止事件的警报
已设置节点内存使用率阈值
已启用作业重试率监控
按作业跟踪读写卷的随机访问情况
小结
这一经历提醒我们,云迁移不仅仅是将工作负载迁移过去,这还会改变这些工作负载所依赖的基础设施协议。本文记录的故障并非由应用程序漏洞、Spark 版本变更或简单的堆内存配置不足所引起。这些故障源于两个基础设施设置,它们在迁移过程中悄然改变了运行时行为,且仅在生产级负载下才会产生破坏性影响。
此后六个月,该集群一直运行稳定。那些希望避免重蹈覆辙的团队,可以从上述检查清单入手。在金融服务领域,日常批处理管道为下游用户提供数据,过时数据会引发用户报告事件,因此这些故障模式带来的后果远不止于工程层面的不便。虽然本文记录的存储和调度配置错误仅针对此 Spark-on-Kubernetes 部署,但其背后动态的隐性配置漂移(这种问题仅在生产级负载下才会显现)是任何云迁移过程中都值得警惕的典型模式。
原文链接:https://www.infoq.com/articles/spark-oom-kubernetes-misconfigurations/





