当 tmpfs 遇上硬亲和力:Spark 在 Kubernetes 上的 OOM 连环杀

2026-06-03 16 预计阅读时间:1 分钟
来源:infoq.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:10 分钟

把 Spark 作业从传统集群搬上 Kubernetes,看起来只是换了个运行环境——实际上底层资源模型的差异远比想象中大。Pranav Bhasker 在将 Spark 管道迁移到 Azure Kubernetes Service(AKS)后,遇到了一种标准诊断手段几乎无法捕捉的 OOM 故障:作业反复被杀,日志里没有明显的内存溢出线索,Spark UI 也看不出异常。最终排查发现,两个看似各自合理的配置项在组合后产生了毁灭性交互——spark.kubernetes.local.dirs.tmpfs=true 把 shuffle 落盘变成了落内存,而一条硬 podAffinity 规则把所有 executor 塞进了同一个节点。两者叠加,节点内存被榨干,进程被内核 OOM killer 静默处决。

这篇文章拆解这两个配置的交互机制,并给出可操作的排查与修复方案。

tmpfs:把"临时磁盘"变成"临时内存"

Spark 在 shuffle 阶段需要把溢出数据写到本地磁盘。默认情况下,Kubernetes 上 Spark executor 的本地目录就是容器内的普通文件系统,背后是节点的物理磁盘。

问题出在 spark.kubernetes.local.dirs.tmpfs=true 这个参数。启用后,Spark 会把本地临时目录挂载为 tmpfs——一种驻留在内存中的文件系统。写入 tmpfs 的每一字节都占用容器所在节点的 RAM,而不是磁盘。

设计意图并不坏:tmpfs 读写极快,对小规模、低 shuffle 量的作业确实能提升性能。但 Spark 的 shuffle spill 本质上是"内存不够了才往磁盘写"的逃生通道。当逃生通道本身也在内存里,就形成了逻辑上的死循环:

  1. executor 内存压力大,开始 shuffle spill;
  2. spill 数据写入 tmpfs,占用更多节点内存;
  3. 节点内存进一步紧张,触发更多 spill;
  4. 直到内核 OOM killer 介入,直接杀掉进程。

更隐蔽的是,从容器内部看,df -h 显示的是一个"大磁盘",因为 tmpfs 的报告大小是虚拟内存上限,而非实际可用物理内存。常规监控很难察觉这个"磁盘"正在吃掉 RAM。

硬亲和力:所有 executor 挤在一台机器上

第二个配置是一条 Kubernetes podAffinity 规则,要求所有 executor pod 必须调度到同一个节点。这类规则常见于追求网络局部性或共享本地缓存的场景,在数据量小、executor 数少时确实有用。

但当 executor 数量增多、shuffle 数据量变大时,硬亲和力就变成了资源炸弹:

  • 所有 executor 的 JVM 堆内存加在一起,已经逼近节点容量;
  • 再加上 tmpfs 把 shuffle 数据也塞进同一个节点的 RAM;
  • 节点内存瞬间耗尽,没有其他节点可以分担。

如果亲和力是软约束(preferredDuringSchedulingIgnoredDuringExecution),调度器在节点满载时至少能把部分 executor 分散到其他节点。硬约束(requiredDuringSchedulingIgnoredDuringExecution)则不留退路——哪怕节点已经濒临 OOM,新 executor 还是会被强制塞进来。

为什么标准诊断看不到问题

这两个配置叠加后产生的 OOM kill 有几个特征,让它几乎隐形:

  • 内核 OOM kill 不留 JVM 堆栈:进程被 SIGKILL 立即终止,没有 heap dump、没有线程栈、没有 Spark 的标准错误日志。Spark 只看到 executor "lost",原因不明。
  • tmpfs 占用不在 JVM 监控范围内:Spark UI 和 Prometheus JMX exporter 监控的是 JVM 堆内存,tmpfs 占用的 RAM 属于进程的"外部"内存,完全不在指标里。
  • 节点级内存压力难以关联到单个作业:Kubernetes 的 node-level OOM 事件在节点事件日志里,但集群运维和 Spark 作业开发通常是两拨人,信息断层很大。

修复与防护:可操作的配置清单

下面给出具体的配置修改和排查命令,可以直接用在 AKS 或任何 K8s 集群上。

1. 关掉 tmpfs 挂载,让 shuffle 回到磁盘

在 Spark 提交参数中显式禁用:

spark-submit \
  --conf spark.kubernetes.local.dirs.tmpfs=false \
  --conf spark.local.dir=/mnt/spark-tmp \
  ...

同时在 executor 的 pod template 中挂载一块节点磁盘到 /mnt/spark-tmp

apiVersion: v1
kind: Pod
spec:
  containers:
    - name: spark-kubernetes-executor
      volumeMounts:
        - name: spark-tmp
          mountPath: /mnt/spark-tmp
  volumes:
    - name: spark-tmp
      emptyDir:
        medium: ""  # 空字符串 = 使用节点磁盘,不是 tmpfs

emptyDirmedium 字段默认就是磁盘。只有设为 "Memory" 才会变成 tmpfs。这里显式写空字符串,是为了在 pod template 里留下明确意图,防止后来者误改。

2. 把硬亲和力改成软亲和力

apiVersion: v1
kind: Pod
spec:
  affinity:
    podAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
          podAffinityTerm:
            labelSelector:
              matchExpressions:
                - key: spark-app-name
                  operator: In
                  values:
                    - my-spark-job
            topologyKey: kubernetes.io/hostname

weight: 100 是最高偏好权重,调度器会尽量把 executor 放到同一节点,但当节点资源不足时会自动分散。这比硬亲和力多了一条逃生通道。

3. 排查命令:确认节点内存和 tmpfs 占用

当作业出现 executor 反复 lost 时,先查节点级 OOM 事件:

# 查看节点上的 OOM kill 记录
kubectl describe node <node-name> | grep -A5 "OOMKilled"

# 查看节点内存压力事件
kubectl get events --field-selector reason=MemoryPressure --all-namespaces

再进入 executor 容器确认 tmpfs 挂载情况:

# 进入任意 executor pod
kubectl exec -it <executor-pod> -- sh

# 检查本地目录是否为 tmpfs
mount | grep tmpfs
df -h /tmp  # 或 spark.local.dir 对应的路径

# 查看 cgroup 内存占用(包含 tmpfs)
cat /sys/fs/cgroup/memory/memory.usage_in_bytes
cat /sys/fs/cgroup/memory/memory.limit_in_bytes

如果 mount 输出显示 Spark 本地目录挂载为 tmpfs,而 cgroup 的 memory.usage_in_bytes 接近 memory.limit_in_bytes,基本可以确认 tmpfs 是 OOM 的直接推手。

4. 资源预算:防止亲和力叠加超载

即使不用 tmpfs,硬亲和力也需要做内存预算。一个简单的估算脚本:

#!/usr/bin/env python3
"""估算单节点上所有 executor 的总内存需求(不含 tmpfs)"""

node_memory_gb = 64          # 节点总物理内存
system_reserve_gb = 8        # 给 OS、kubelet、daemonset 留的内存
executor_count = 12          # 同节点上的 executor 数量
executor_heap_gb = 4         # 每个 executor 的 JVM 堆
executor_overhead_gb = 0.4   # spark.memory.offHeapSize + 堆外开销

total_executor_gb = executor_count * (executor_heap_gb + executor_overhead_gb)
available_gb = node_memory_gb - system_reserve_gb

print(f"节点可用内存: {available_gb} GB")
print(f"executor 总需求: {total_executor_gb} GB")
print(f"剩余: {available_gb - total_executor_gb} GB")

if available_gb - total_executor_gb < 2:
    print("⚠️  剩余不足 2 GB,有 OOM 风险,减少 executor 数或降低堆大小")
else:
    print("✅ 内存预算安全")

运行示例:

$ python3 estimate_memory.py
节点可用内存: 56 GB
executor 总需求: 52.8 GB
剩余: 3.2 GB
✅ 内存预算安全

executor_overhead_gb 调大一点(比如 0.8),模拟 tmpfs 开销,就能看到预算瞬间变红——这正是故障发生时的真实情况。

迁移检查清单

把 Spark 搬上 Kubernetes 时,以下几项值得逐条确认:

检查项 风险 建议
spark.kubernetes.local.dirs.tmpfs shuffle spill 占 RAM 默认 false,除非 executor 数少且 shuffle 量极小
podAffinity 硬约束 executor 无法分散 改为软亲和力,或根据节点容量限制 executor 数
executor 堆 + overhead 总量 单节点超载 堆外开销至少留 10%,tmpfs 场景下留 30%+
节点 cgroup 内存监控 tmpfs 占用不可见 监控 memory.usage_in_bytes,不只是 JVM 指标
K8s OOM 事件告警 故障无 Spark 日志 配置 node-level OOM kill 事件告警,关联到 Spark 作业

两个配置单独看都有合理的使用场景,但组合在一起时,它们把 Spark 的内存逃生通道(shuffle spill 到磁盘)和 Kubernetes 的调度逃生通道(分散到多节点)同时堵死。迁移到 K8s 后,资源模型从"独占机器"变成了"共享分时",任何把多维度压力集中到单一维度的配置,都需要重新审视。


相关推荐