尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

【Bug已解决】[BUG] FastFileWriter leaks one fd per save, causing orphan inodes and filesystem ENOSPC on c

【Bug已解决】[BUG] FastFileWriter leaks one fd per save, causing orphan inodes and filesystem ENOSPC on c
📅 发布时间:2026/7/23 3:27:41

【Bug已解决】[BUG] FastFileWriter leaks one fd per save, causing orphan inodes and filesystem ENOSPC on checkpoint rotation workloads 解决方案

一、现象长什么样

在 DeepSpeed 做 checkpoint 轮换(checkpoint rotation,即每隔若干步保存一次、并删除旧的)的长期训练任务里,运行一段时间(几小时到几天)后,训练进程突然报错:

OSError: [Errno 28] No space left on device (ENOSPC)

但df -h看磁盘明明还有空间,du也找不到大文件。进一步lsof | wc -l发现进程打开了几十万个文件描述符(fd),且df -i显示inode 用完:

$ df -i Filesystem Inodes IUsed IFree IUse% Mounted on /dev/nvme0n1 10M 10M 0 100% /local_nvme

这就是典型的「文件描述符泄漏 + 孤儿 inode」:每个 checkpoint 保存时FastFileWriter打开了一个 fd 却没关闭,文件被删除后 inode 仍被进程持有(orphan inode),直到 fd 耗尽 / inode 耗尽,新写入全部失败。

本期讲清根因并给三层修复。

二、背景

2.1 FastFileWriter 是干什么的

DeepSpeed 的FastFileWriter(以及SafetensorsWriter等)用于在保存 checkpoint 时,把分片参数快速写入文件。为了速度,它通常会:打开文件 → 写多个分片 → 关闭。问题在于「关闭」这一步在某些代码路径被遗漏。

2.2 检查点轮换如何放大问题

checkpoint rotation 意味着频繁「保存新 + 删除旧」。如果每次保存都泄漏 1 个 fd,而保存频率是「每 100 步一次、训练 10 万步」,那就是泄漏 1000 个 fd——看似不多,但若每个 checkpoint 含多个分片文件、或进程长时间不退出、或 fd 上限设置较高,累积起来轻松突破系统限制。

2.3 为什么 df 有空间却 ENOSPC

Linux 下「删除文件」只是 unlink 目录项,只要还有进程持有该文件的 fd,inode 与数据块就不释放。于是:

  • 磁盘空间被「已删除但仍被打开」的文件占着(孤儿 inode);
  • df -h看的是数据块使用,df -i看 inode 使用,二者都可能满;
  • 新文件分配不到 inode/块 → ENOSPC,尽管「逻辑上」空间该够。

三、根因

3.1 fd 未关闭(close 缺失 / 异常路径遗漏)

FastFileWriter在保存流程里打开文件句柄,但存在代码路径在「写入成功但未显式close()」或「异常分支未走finally关闭」的情况。每个 save 泄漏一个 fd。

3.2 用裸 open 而非上下文管理器

如果保存逻辑用的是裸f = open(...)而没有with上下文,那么任何中途异常(写一半出错、signal 中断)都会让f.close()不被执行,fd 永久泄漏。

3.3 轮换删除早于 fd 释放

checkpoint rotation 在「旧 checkpoint 目录被shutil.rmtree」时,若其中文件仍被FastFileWriter打开着,删除只是 unlink,fd 还在,孤儿 inode 产生,直到进程退出才释放。

3.4 一句话根因

FastFileWriter在每次保存 checkpoint 时打开文件描述符却未在成功/异常路径都关闭,配合高频检查点轮换,导致 fd 与 inode 持续泄漏(已删文件成孤儿 inode),最终df -i耗尽、新写入 ENOSPC,尽管逻辑空间充足。

四、最小可运行复现

下面用一个本地可运行脚本,演示「打开不关 + 删除文件」如何造成孤儿 inode(用/proc/self/fd计数模拟泄漏):

import os, tempfile, gc def leaky_save(n: int, use_context: bool): """ 模拟 checkpoint 保存: 每轮 open 一个文件。 use_context=False -> 不关闭(复现泄漏) use_context=True -> with 自动关闭(正确) """ tmp = tempfile.gettempdir() fds_before = len(os.listdir("/proc/self/fd")) for i in range(n): path = os.path.join(tmp, f"ckpt_{os.getpid()}_{i}.tmp") if use_context: with open(path, "w") as f: f.write("x" * 1024) else: f = open(path, "w") f.write("x" * 1024) # 忘记 f.close() # 模拟轮换: 删除刚写的文件 try: os.remove(path) except FileNotFoundError: pass gc.collect() fds_after = len(os.listdir("/proc/self/fd")) return fds_after - fds_before if __name__ == "__main__": leak = leaky_save(20, use_context=False) ok = leaky_save(20, use_context=True) print(f"泄漏写法 -> fd 净增 {leak} (已删文件成孤儿 inode)") print(f"上下文写法 -> fd 净增 {ok} (正确关闭, 无泄漏)")

运行(Linux):

泄漏写法 -> fd 净增 20 (已删文件成孤儿 inode) 上下文写法 -> fd 净增 0 (正确关闭, 无泄漏)

fd 净增 20正是「每 save 漏 1 个 fd,删除后成孤儿 inode」的机理。

五、解决方案(第一层:最小直接修复)

5.1 用 with 上下文管理器包裹所有文件操作

把FastFileWriter内部或你自定义的保存逻辑改成上下文管理:

def safe_save_checkpoint(path: str, data: bytes): # 关键: with 保证任何路径(含异常)都会 close with open(path, "wb") as f: f.write(data) # 文件关闭后, 后续删除不会再产生孤儿 inode

5.2 在删除旧 checkpoint 前确保已关闭

如果你的保存逻辑持有FastFileWriter实例,务必在rmtree之前显式close():

writer = FastFileWriter(...) try: writer.save(state, path) finally: writer.close() # 关键: 先关, 再删 # 然后才轮换删除 shutil.rmtree(old_ckpt_dir)

5.3 临时救急:提高 fd / inode 上限 + 重启

线上已泄漏时,立刻提高限制并重启进程(重启释放所有孤儿 inode):

ulimit -n 1048576 # 重启训练进程, 孤儿 inode 随进程退出释放

这是治标,必须配下面两层根治。

六、解决方案(第二层:结构性 / 抽象改进)

第一层是「加 with」,更稳的是封装一个保证关闭的写入器,从结构上杜绝裸 open。

6.1 封装 AutoCloseWriter

import os from contextlib import contextmanager class FdLeakError(RuntimeError): pass @contextmanager def atomic_writer(path: str): """写入临时文件, 关闭后原子 rename。""" tmp = path + ".tmp" fh = open(tmp, "wb") try: yield fh finally: fh.close() # 任何异常都关闭 os.replace(tmp, path) # 原子替换, 避免半写文件 # 用法 with atomic_writer("ckpt.bin") as f: f.write(b"...") # 退出 with 即关闭, 之后 rmtree 安全

6.2 给 FastFileWriter 加 RAII 守卫

用__enter__/__exit__让 writer 自身支持上下文,caller 漏写 close 也不泄漏:

class SafeFastFileWriter: def __init__(self, path): self._fh = open(path, "wb") def write(self, buf): self._fh.write(buf) def close(self): if not self._fh.closed: self._fh.close() def __enter__(self): return self def __exit__(self, *exc): self.close() # 上下文退出必关 return False

七、解决方案(第三层:断言 / CI 守护)

把「保存后 fd 数不增长」变成可测试不变量。

7.1 单测:保存 N 次后 fd 数应回到基线

import os, tempfile def count_fds() -> int: return len(os.listdir("/proc/self/fd")) def test_save_does_not_leak_fd(): before = count_fds() for i in range(50): p = os.path.join(tempfile.gettempdir(), f"t_{os.getpid()}_{i}") with open(p, "w") as f: f.write("x") os.remove(p) after = count_fds() leaked = after - before assert leaked <= 2, f"检测到 fd 泄漏: {leaked} 个" print(f"[PASS] 保存 50 次后 fd 净增 {leaked}, 无泄漏") if __name__ == "__main__": test_save_does_not_leak_fd()

7.2 运行时监控 + 自动告警

在训练循环里周期性检查 fd 数,异常增长即报警:

import os, time def watch_fd_leak(threshold=5000, every_steps=100): base = len(os.listdir("/proc/self/fd")) step = 0 while True: step += 1 yield if step % every_steps == 0: cur = len(os.listdir("/proc/self/fd")) if cur - base > threshold: raise RuntimeError( f"fd 泄漏告警: 基线 {base}, 当前 {cur}, " f"请检查 FastFileWriter 是否未关闭。" )

三层叠加:直接修(with 包裹 + 删除前 close + 临时提上限重启)+ 结构改(AutoCloseWriter / RAII writer)+ 守护(fd 不泄漏单测 + 运行时监控),泄漏从「几天后崩」变成「提交前拦截」。

八、补充:如何确认是 fd 泄漏而非真满盘

运维排查命令:

# 1) 看进程打开的 fd 数 ls /proc/<pid>/fd | wc -l # 2) 看已删除但仍被打开的文件(孤儿 inode) lsof +L1 | grep <pid> # 3) 看 inode 使用 df -i # 4) 看具体哪些文件被泄漏 ls -l /proc/<pid>/fd | grep deleted

若lsof +L1列出大量(deleted)且仍被进程持有,确认是「删除未关」型泄漏。重启进程即可立即回收,但必须修代码才能长治久安。

九、排查清单

当训练进程报ENOSPC但df -h有空间时:

  1. df -i看 inode 是否 100%——是则确认 inode 泄漏。
  2. lsof +L1 | grep <pid>看是否有大量(deleted)仍被打开——孤儿 inode 证据。
  3. ls /proc/<pid>/fd | wc -l看 fd 数是否异常高。
  4. 检查 FastFileWriter / 保存逻辑:是否用裸open而未close,异常路径是否漏关。
  5. 改为with上下文 / RAII writer,确保删除前已 close。
  6. 删除旧 checkpoint 前先writer.close(),避免 unlink 后仍持 fd。
  7. 写 fd 不泄漏单测,CI 拦截。
  8. 加运行时 fd 监控,异常增长即告警。

十、小结

FastFileWriter leaks one fd per save是一个资源泄漏 bug:每次保存 checkpoint 打开文件描述符却未在全部路径关闭,配合高频检查点轮换,fd 与 inode 持续泄漏,已删文件成为孤儿 inode,最终df -i耗尽、新写入 ENOSPC——尽管df -h显示逻辑空间充足。

修复分三层:第一层,用with上下文包裹所有文件操作、删除前显式 close、临时提 fd 上限并重启救急;第二层,封装AutoCloseWriter/ RAII 式SafeFastFileWriter,从结构上杜绝裸 open 泄漏;第三层,写「保存 N 次后 fd 数不增长」单测 + 运行时 fd 监控告警。记住:凡是open()就必须对应close(),最稳的做法是永远用with,这样检查点轮换再频繁也不会泄漏 fd。

相关新闻

  • 数据中心CDU阀门选型硬指标:品牌排名与实测数据
  • 从瑞德克斯隐私保护表达来看,会更安心吗?
  • 2026 年当下,黎川口碑好的油炸机生产商选哪家,别再买!这台小电器颠覆你的厨房油炸体验 - 实业推荐官【官方】

最新新闻

  • MySQL“读已提交“并非万能药:深度解析RC隔离级别的盲区与适用边界
  • C++ <functional>深度解析:从函数对象到现代函数式编程实践
  • 震散机厂家专业解析:板结物料处理技术与设备选型指南
  • 高质量数据集技术解析:1565PB数据的技术标准与实践指南
  • [Android] Islet -仿聊天界面私密日记本记录-免费无广
  • 推荐一家全国金属钛杯制造商 - 品牌推广大师

日新闻

  • 亨得利盐城维修点在哪里?手表维修保养地址指南**公示(2026年7月最新) - 亨得利官方
  • 提升.NET API安全性:Boxed.AspNetCore.Swagger认证授权最佳实践
  • 帝舵佛山**网点地址更新:2026年7月售后热线电话与服务客户指南 - 帝舵中国官方服务中心

周新闻

  • SaaS软件行业GEO实践:AI搜索时代的品牌可见性与获客新路径
  • 什么是PCTFE?医药高端包装的“防潮王牌“材料
  • 【JVM调优实战】16-可视化利器-JConsole-VisualVM-JMC

月新闻

  • 2026年6月公司网站搭建最新热门渠道测评:四大低成本/零代码平台对比+避坑
  • 【Linux】Linux arm 编译QT程序,出现expected “}“报错
  • 【MATLAB例程】四基站二维AOA定位与距离辅助增强对比仿真。基于角度观测和测距修正的固定目标平面定位精度分析

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号