【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 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) # 文件关闭后, 后续删除不会再产生孤儿 inode5.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有空间时:
df -i看 inode 是否 100%——是则确认 inode 泄漏。lsof +L1 | grep <pid>看是否有大量(deleted)仍被打开——孤儿 inode 证据。ls /proc/<pid>/fd | wc -l看 fd 数是否异常高。- 检查 FastFileWriter / 保存逻辑:是否用裸
open而未close,异常路径是否漏关。 - 改为
with上下文 / RAII writer,确保删除前已 close。 - 删除旧 checkpoint 前先
writer.close(),避免 unlink 后仍持 fd。 - 写 fd 不泄漏单测,CI 拦截。
- 加运行时 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。