提示

返回博客列表

下到 99% 提示磁盘满了:下载服务的磁盘、inode 与大文件工程问题

那天下午接到报警:一批批量下载任务集体失败。日志里密密麻麻全是:

OSError: [Errno 28] No space left on device

我登上去 df -h,根分区 Used 100%,但另一个数据盘明明还有 20 多 G。查了半小时才明白:任务写的是 /tmp(在根分区上),而我看的是数据盘。

这只是开始。后来我又遇到过:df -h 显示还剩 30% 但就是写不进去(inode 用完了);删了 40G 的大文件之后 df 一点没变(文件还被进程占着);一个 4GB 的文件 ls 显示 4G 但 du 只有 200M(稀疏文件),拷到别的盘上直接把对方撑爆。

这篇文章是这一堆事故的整理:怎么快速定位、大文件写入有哪些必须注意的点、以及我们后来定的磁盘策略。

TL;DR:No space left on device 不等于"空间不够",可能是 inode 耗尽、被删除但仍在使用的文件没释放、分区挂载点搞错、配额(quota) 或者 Docker 的 overlay 占满。排查顺序:df -h → df -i → du -x --max-depth=1 → lsof +L1。大文件写入要做预分配(posix_fallocate),它能提前暴露空间不足而不是让你下到 99% 才失败;要理解稀疏文件(ls 和 du 差很多,拷贝时会填满目标盘)。长期方案是写之前先检查空间 + 失败清理钩子 + 使用率告警。

目录

一、"磁盘满了"至少有四种病因

同样的报错 No space left on device(errno 28),背后可能是:

病因 df -h df -i 怎么确认
块空间真的满了 Use% 100% 正常 du 找大文件
inode 耗尽 可能只用了 60% IUse% 100% df -i
文件被删了但没释放 Used 100%,但 du 加起来对不上 正常 lsof +L1
配额 / 容器限制 正常 正常 quota -s、docker 的 overlay 目录
挂载点搞错 目标目录所在分区满了,你看的是另一个 — df -h /path/to/dir

第一种最常见也最好解决,但值得说的是:很多系统会给 root 预留 5% 的空间(ext4 默认),所以你看到"100%"的时候,root 其实还能写——这是设计上的保护,别急着 tune2fs -m 0。

第二种(inode 耗尽)最容易被忽略。每个文件/目录都要占一个 inode,数量在格式化时就固定了,用完就是用完,删内容不删文件也没用。我们的事故是这样:日志轮转配置错了,每天产生几万个几 KB 的小文件,两个月把 inode 吃光。

第三种(文件被删没释放)最有迷惑性。你 rm 了一个大文件,df 纹丝不动。因为还有进程持有它的文件描述符,在 Unix 语义下文件数据要等所有 fd 关闭才真正释放。解决办法是重启对应进程,或者(如果是日志)用 truncate 清空而不是 rm。

第五种是我开头踩的那个:一定要 df -h <具体目录>,别只看根目录。

二、排查工具箱:五条命令定位

我现在的固定流程:

# 1. 看目标目录所在分区还剩多少(注意:带路径!)
df -h /data/downloads

# 2. 看 inode
df -i /data/downloads

# 3. 从目标目录往下找,谁最大
du -x -h --max-depth=1 /data | sort -h
# -x 很关键:不跨文件系统,避免把挂载的其他盘也算进来

# 4. 交互式查看(强烈推荐装 ncdu)
ncdu -x /data

# 5. 找被删除但仍被占用的文件
lsof +L1
# 或者
lsof 2>/dev/null | grep deleted

lsof +L1 的输出长这样:

COMMAND   PID  USER   FD   TYPE DEVICE  SIZE/OFF NLINK  NODE NAME
ffmpeg   8821  root    3w   REG  253,1  4.2G     0      1234 /data/tmp/abc.mp4 (deleted)

SIZE/OFF 是 4.2G,NLINK 是 0(已删除)——这就是那个"占了空间但你看不到"的文件。处理方式:

# 优雅:重启对应进程
systemctl restart celery

# 或者:把文件内容清空(不删除),空间立刻释放,进程继续写也不影响
: > /proc/8821/fd/3

: > /proc/<pid>/fd/<n> 这一招救过我好几次。它把那个已删除文件的内容截断为 0,空间马上释放,而且不用重启服务。适合"这是个日志文件/临时文件,内容我不在乎"的场景。

另外几条常用的:

# 找 7 天前、大于 100MB 的文件
find /data -type f -size +100M -mtime +7 -print0 | xargs -0 ls -lh

# 按大小排前 20
find /data -type f -printf '%s %p\n' | sort -rn | head -20

三、大文件写入:预分配为什么重要

下载一个 4GB 的文件,最讨厌的情况是:下到 3.9GB 的时候失败。用户等了 20 分钟,然后什么都没拿到。

而且从文件系统角度,这种"边写边涨"的文件还有两个问题:

  1. 写到一半才发现空间不足,白白浪费了 20 分钟带宽;
  2. 文件碎片:文件系统要一边扩展一边分配块,大文件容易碎,后续读取变慢。

解决办法是预分配:先把空间占住,再往里写。

import os

def preallocate(path: str, size: int):
    """预先占用磁盘空间。成功说明后面 size 字节肯定能写完。"""
    with open(path, 'wb') as f:
        os.posix_fallocate(f.fileno(), 0, size)

命令行等价物:

fallocate -l 4G output.mp4      # 瞬间完成,真的分配块(不是稀疏)

注意 fallocate 和 truncate -s 的区别:

truncate -s 4G output.mp4       # 创建的是【稀疏文件】,不占空间!

truncate 只是把文件长度改了,磁盘块一个都没分配——它不保证后面能写满。用 truncate 做预分配是自欺欺人。判断标准:

ls -lh output.mp4      # 显示 4.0G(表观大小)
du -h output.mp4       # truncate: 0 / fallocate: 4.0G

我们的流程是:拿到 Content-Length → 先检查剩余空间 → posix_fallocate 预分配 → 再开始分片下载 → 失败则删除并释放。这样"空间不足"会在任务开始的第一秒就暴露,而不是 20 分钟后。

import shutil

def ensure_space(path: str, need_bytes: int, min_free_ratio: float = 0.05):
    """检查目标分区是否有足够空间(并保留一定余量)。"""
    total, used, free = shutil.disk_usage(path)
    if free < need_bytes * (1 + min_free_ratio):
        raise RuntimeError(
            f'not enough space: need {need_bytes/1e9:.2f}GB, '
            f'free {free/1e9:.2f}GB'
        )

shutil.disk_usage 是 Python 3.3+ 内置的,跨平台,比解析 df 输出靠谱(我见过有人用 subprocess 跑 df 然后按空格切分,遇到设备名里有空格就崩)。

fsync 与持久化

另一个容易忽略的点:写入不等于落盘。数据先到 page cache,由内核在合适的时候刷到磁盘。如果机器断电,最后几秒(甚至几十秒)的写入会丢失。

对下载服务来说,我们不 care——文件下完会做校验,坏了重新下。但数据库非常 care。这就是为什么要区分:

# 普通文件:不用 fsync,让内核自己调度,快
with open(path, 'wb') as f:
    f.write(data)

# 关键数据(比如"这个文件已经完整下好了"的标记):要 fsync
with open(marker, 'w') as f:
    f.write('done')
    f.flush()
    os.fsync(f.fileno())

fsync 的代价很大(一次真实的磁盘 IO),所以只在真正需要持久化的地方用。我的规则是:数据文件不 fsync,状态变更(尤其是"任务完成"这种不可逆标记)要 fsync。

顺带一提:即使 fsync 了,也不是绝对安全——还得看硬盘的写缓存、RAID 卡的电池保护等等。对绝大多数业务来说,fsync 已经够用了,别在这上面过度设计。

四、稀疏文件:ls 和 du 差了 20 倍

前面提到了,单独讲一下,因为它坑过我一次很惨。

稀疏文件(sparse file) 是文件系统对"全是 0 的块"的优化:不实际分配块,只在元数据里记录"这一段是空洞"。读的时候返回 0。

$ truncate -s 4G big.bin
$ ls -lh big.bin
-rw-r--r-- 1 root root 4.0G Mar 11 14:22 big.bin     ← 表观 4G
$ du -h big.bin
0       big.bin                                        ← 实际占 0
$ du -h --apparent-size big.bin
4.0G    big.bin

哪些操作会产生稀疏文件:

  • truncate -s
  • 用 seek 跳着写(比如下载器按分片乱序写入时,中间的空洞)
  • 某些数据库/虚拟机的磁盘镜像

坑在哪?拷贝和打包的时候。

操作 结果
cp 默认 现代 coreutils 会 --sparse=auto,通常保持稀疏
tar 默认不保持稀疏,打包出来是实实在在的 4G
rsync(不带 -S) 变成实文件,目标盘要真有 4G
rsync -S / --sparse 保持稀疏
scp / sftp 变实文件
dd 变实文件

我踩的那次:一个"看起来 4G 实际 200M"的临时文件,用 rsync 同步到备份机,把备份机(只剩 3G)直接撑爆,备份任务失败 + 备份机报警。

现在我的处理:

# 传输大文件时显式处理稀疏
rsync -avS /data/ backup:/data/

# 或者干脆先检查
du -sh --apparent-size file   # 表观
du -sh file                   # 实际

判断一个文件是不是稀疏:

ls -lsh file
# 第一列是实际占用的块数(单位由 -h 决定),第六列是表观大小
# 0 或很小 vs 4.0G → 稀疏

另外,稀疏文件在写入时会"变实":你往空洞里写一字节,那个块就被分配了。所以"稀疏文件的大小"是会变的,做空间预估时要按表观大小算(保守)。

五、inode:小文件也能把盘撑死

df -i 是很多人不知道的命令:

$ df -i /data
Filesystem       Inodes   IUsed   IFree IUse% Mounted on
/dev/sdb1      26214400 26214400       0  100% /data

数据盘只用了 60%,但 inode 用完了。这时候你还能往已有文件里写数据,但不能创建任何新文件。表现非常诡异:程序报"磁盘满",但 df -h 明明有空间。

ext4 的 inode 数量在 mkfs 时确定,之后不能改(除非重新格式化)。这是个大坑:

# 格式化时可以指定 inode 密度
mkfs.ext4 -i 8192 /dev/sdb1      # 每 8192 字节一个 inode(更密)
# 默认通常是每 16384 字节一个 inode

或者用 XFS,它是动态分配 inode 的,不会耗尽(严格说是受限于空间,不是固定数量)。

什么会产生海量小文件:

  • 日志轮转配置错误(每天/每小时切一个文件,从不清理);
  • 缓存目录(yt-dlp 的缓存、缩略图缓存、session 文件);
  • 每个任务一个临时文件且不清理;
  • Python 的 __pycache__、npm 的 node_modules(开发环境常见)。

我们的教训是 cookie 临时文件:tempfile.mkstemp() 每次解析创建一个 cookie 文件,从来没删过。三个月攒了 200 万个几十字节的小文件,inode 直接见底。修法:

import tempfile
import os

cookie_path = None
try:
    fd, cookie_path = tempfile.mkstemp(prefix='cookies_', suffix='.txt')
    with os.fdopen(fd, 'w') as f:
        f.write(content)
    ...使用...
finally:
    if cookie_path and os.path.exists(cookie_path):
        os.remove(cookie_path)      # 必须用 finally 保证清理

try/finally 是必须的,因为异常路径下很容易漏掉清理。更稳妥的是用 tempfile.TemporaryDirectory() 上下文管理器,退出自动清理整个目录。

另外,我加了一个启动时的扫描清理:服务启动时扫一遍临时目录,删除超过 24 小时的 .part 和 cookie 文件。这样即使有漏网的,重启一次就清干净了。

六、文件系统选型:ext4 还是 XFS

不做全面评测(网上有专业的),只说我在"视频下载/转码"这个场景下的体会:

场景 建议 理由
系统盘、小盘(<2TB) ext4 稳、工具全、出问题好救
大容量数据盘(>4TB) XFS 大文件性能好、删除大文件极快、动态 inode
需要快照/校验和 btrfs / ZFS 有额外运维成本,量力而行
移动硬盘 exFAT 跨平台,注意没有权限位
容器 overlay 看发行版默认 注意 overlay 目录也会占满根分区

XFS 在"删除大文件"上的优势太明显了。删除一个 20GB 的视频文件,ext4 可能要几秒(要回收大量块,期间 IO 被打满),XFS 几乎是瞬时的。对"每天删几百 GB 过期文件"的服务来说,这个差距很关键。

反过来,ext4 的好处是出问题的时候好救(fsck 工具成熟、debugfs),而且支持缩小分区(XFS 只能增大不能缩小)。

挂载选项(数据盘):

# /etc/fstab
/dev/sdb1  /data  xfs  defaults,noatime,nodiratime  0  2

noatime 禁掉访问时间更新——大量小文件读取时能省掉一堆元数据写入。对下载/转码这种"写完基本只读"的场景,收益明显。

给 root 留空间(ext4):

tune2fs -l /dev/sdb1 | grep -i reserved
# Reserved block count:     13107200    (默认 5%)

# 数据盘不需要留那么多,改成 1%
tune2fs -m 1 /dev/sdb1

5% 在 10TB 的盘上是 500GB,纯浪费。但系统盘别改,那 5% 是给 root 登录救急用的。

七、下载服务的磁盘策略

踩完所有坑之后,我们现在在服务里做了这几件事:

1. 任务启动前检查空间

def before_task(task):
    need = task.estimated_size or DEFAULT_ESTIMATE
    free = shutil.disk_usage(DOWNLOAD_DIR).free
    if free < need * 1.2:        # 留 20% 余量
        raise RetryableError('not enough disk space, retry later')

注意这里抛的是可重试错误,不是直接失败——空间不足往往是暂时的(旧文件还没清理完),过一会儿再试就好。

2. 预分配 + 失败清理

try:
    preallocate(path, size)
    download_into(path)
except Exception:
    if os.path.exists(path):
        os.remove(path)         # 不留半成品
    raise

3. 全局并发的体积上限

这个是我们后来加的,很有效:所有正在下载的任务的"预估总大小"加起来不能超过剩余空间的 70%。超了就排队,不开始新任务。

def can_start_new_task(need_bytes):
    in_flight = sum(t.estimated_size for t in DownloadTask.objects.filter(
        status__in=['DOWNLOADING', 'PARSING']))
    free = shutil.disk_usage(DOWNLOAD_DIR).free
    return in_flight + need_bytes < free * 0.7

没有这个限制的时候,10 个并发任务同时下 4GB,前 9 个都在 90% 的时候发现空间不够,全部失败——带宽全浪费了。加上之后,系统会在第 7 个任务时就开始排队。

4. 生命周期清理

按修改时间清理过期文件,用 beat 定时任务跑(记得加锁,防止 beat 跑多个实例):

@shared_task
def cleanup_expired(days=7):
    lock = cache.add('lock:cleanup_expired', '1', timeout=600)
    if not lock:
        return
    try:
        cutoff = time.time() - days * 86400
        for root, dirs, files in os.walk(DOWNLOAD_DIR):
            for f in files:
                p = os.path.join(root, f)
                try:
                    st = os.stat(p)
                    if st.st_mtime < cutoff and not is_locked(p):
                        os.remove(p)
                except FileNotFoundError:
                    pass        # 并发下可能已被别人删了
    finally:
        cache.delete('lock:cleanup_expired')

FileNotFoundError 要 catch——清理任务和下载任务并发时,文件可能已经被删了。

5. 用户配额

按用户限制总占用(比如免费用户 5GB)。每次任务完成累加,删除时扣减,定期与实际 du 结果对账(一定要对账,累加逻辑出 bug 后配额会漂移)。

八、磁盘满了之后怎么救

真到了 100% 的时候,按顺序做:

1. 先停止写入(不然越救越糟)

systemctl stop celery          # 停掉会产生文件的服务

2. 快速释放一批空间(按见效速度排序)

# 系统日志
journalctl --vacuum-size=200M

# Docker(如果用了)
docker system prune -a --volumes      # 注意:会删掉所有未使用的镜像和卷

# 包缓存
apt-get clean            # Debian/Ubuntu
yum clean all            # CentOS

# 临时目录
find /tmp -type f -mtime +3 -delete

3. 找真正的大头

ncdu -x /

4. 不要做的事

  • 不要 rm 一个正在被写入的大文件然后期待空间立刻释放(不会释放,要等 fd 关闭);
  • 不要在盘满的时候跑 fsck(可能让情况更糟,而且需要卸载);
  • 不要一次删几十万个文件而不限速——rm -rf 大量小文件会把 IO 打满,服务全卡。要删的话分批:
# 分批删,每批之间歇一下
find /data/cache -type f -print0 | xargs -0 -n 500 rm -f

或者更温和的方式,用 ionice 限流:

ionice -c 3 find /data/cache -type f -delete

-c 3 是 idle 优先级,只在系统空闲时才做 IO。清理任务我都会加这个。

5. 救完之后,立刻加监控(下一节),并且复盘"为什么会满"——空间不够通常是清理策略缺失或者容量规划没做,不是意外。

九、监控:别等它满了才发现

最小可用的监控脚本(crontab 每 5 分钟跑一次):

#!/bin/bash
# /opt/scripts/disk_check.sh
THRESHOLD=85
HOST=$(hostname)
while read -r line; do
    usage=$(echo "$line" | awk '{print $5}' | tr -d '%')
    mount=$(echo "$line" | awk '{print $6}')
    if [ "$usage" -gt "$THRESHOLD" ]; then
        echo "WARN: $HOST $mount usage ${usage}%"
    fi
done < <(df -h | grep -E '^/dev/')

# inode
while read -r line; do
    usage=$(echo "$line" | awk '{print $5}' | tr -d '%')
    mount=$(echo "$line" | awk '{print $6}')
    if [ "$usage" -gt "$THRESHOLD" ]; then
        echo "WARN: $HOST $mount inode usage ${usage}%"
    fi
done < <(df -i | grep -E '^/dev/')

把输出接到告警(邮件、企业微信、钉钉机器人都行)。

我定的阈值:

指标 警告 严重 动作
空间使用率 85% 95% 警告:查清理任务;严重:暂停新任务
inode 使用率 80% 90% 查小文件来源
可用空间绝对值 < 50GB < 10GB 按绝对值告警更实用(10TB 盘的 5% 也有 500G)

用绝对值 + 百分比双阈值,因为大盘的 5% 可能是 500GB,小盘的 5% 只有 5GB。

如果有条件上 Prometheus + node_exporter,指标更全(node_filesystem_avail_bytes、node_filesystem_files_free),还能看增长趋势——趋势比当前值重要得多。我现在的告警里有一条是"预计 7 天内写满",基于最近 3 天的增长率算的,这条最有用,能提前一周去买盘。

十、坑清单

  1. 只看 df -h 不看具体路径 → 数据盘有空间,但写的是 /tmp(在根分区)。
  2. 忘了 df -i → 空间还剩 60% 但 inode 用完,表现跟空间满一模一样。
  3. 删了大文件空间没释放 → 文件还被进程占着。用 lsof +L1,或者 : > /proc/pid/fd/N 截断。
  4. 用 truncate 当预分配 → 创建的是稀疏文件,根本没占空间,照样写到一半失败。用 fallocate / posix_fallocate。
  5. 稀疏文件用 rsync/tar 传 → 变实文件,把目标盘撑爆。用 rsync -S。
  6. du 不带 -x → 把挂载的其他盘算进来,误判谁占用大。
  7. 一次 rm -rf 几十万文件 → IO 打满,服务全卡。分批 + ionice -c 3。
  8. ext4 inode 用完 → 不能动态加,只能删文件或重新格式化。大容量数据盘建议 XFS。
  9. 没给 root 预留空间(tune2fs -m 0)→ 系统盘满了之后连 root 都写不进去,救都没法救。
  10. 并发任务的总大小没限制 → 10 个任务同时下到 90% 才发现空间不够,带宽全浪费。
  11. 临时文件没有 try/finally 清理 → 异常路径下全部泄漏。
  12. 清理任务没加锁 → beat 跑两遍,第二遍删了正在下载的文件。
  13. 清理和下载并发 → FileNotFoundError,要 catch。
  14. 只在盘满了才去看 → 应该看趋势。加"预计 7 天写满"的告警。
  15. 日志轮转配错 → 每天几万个小文件,两个月吃光 inode。配完要看一眼实际产出。

磁盘这件事给我的最大教训是:它不是"容量够不够"的问题,是"有没有明确的生命周期"的问题。

早期我们只管写不管删,觉得"盘大,先跑着",结果三个月后 inode 耗尽、临时文件堆积、清理的时候又不敢删(不知道哪些还在用)。后来定了一套明确的规则——每个目录下的数据都有明确的归属和过期时间,谁产生的谁负责清理,服务启动时做一次兜底扫描——之后就再没出过事故。

还有一点:把磁盘检查放在任务开始之前,而不是写到一半再说。用户等了 20 分钟看到失败,和第一秒就告诉他"空间不足稍后重试",体验天差地别,成本却只多了几行代码。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部