【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) # 文件关闭后, 后续删除不会再产生孤儿 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)且仍被进程持有,确认是「删除未关」型泄漏。重启进程即可立即回收,但必须修代码才能长治久安。

九、排查清单

当训练进程报ENOSPCdf -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。