把 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 本质上是"内存不够了才往磁盘写"的逃生通道。当逃生通道本身也在内存里,就形成了逻辑上的死循环:
- executor 内存压力大,开始 shuffle spill;
- spill 数据写入 tmpfs,占用更多节点内存;
- 节点内存进一步紧张,触发更多 spill;
- 直到内核 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
emptyDir的medium字段默认就是磁盘。只有设为"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 后,资源模型从"独占机器"变成了"共享分时",任何把多维度压力集中到单一维度的配置,都需要重新审视。