一个图片爬虫把数据盘写到了 100%
事情发生在一个周六早上。手机收到磁盘告警,登录一看,/data 分区 100%,MySQL 直接拒绝写入,站点所有写操作全部报错。第一反应是日志爆了或者备份没删,查了一圈都不是。最后用 du -sh /data/* 排序,发现一个上传子目录占了一百多 GB——是我自己写的一个图片抓取脚本,跑起来就没人管,把外面抓来的图全存在那儿了。
清理完、把脚本改好之后,我做了一件事:给这个分区加上磁盘配额。理由很直接——我不可能永远记得检查每个脚本会不会失控,所以必须让操作系统替我兜底。 脚本写超了,让它自己收到错误码退出,而不是拖垮整台机器的数据库。
这篇记录整个配额方案的落地过程,以及过程中踩到的几个不太常见的坑。先说结论:磁盘配额不是一个开关,它是「文件系统类型 → 挂载选项 → 配额类型 → 工具链」四层全都要配对的一件事,任何一层不对,后面所有命令都会静默失败。
第一步:先确认你的文件系统支持哪种配额
这一步不能跳过,因为 ext4 和 XFS 的配额实现几乎是两套东西,命令完全不通用。
用 findmnt -no FSTYPE /data 看一下类型。如果是 ext4,配额是传统的 usrquota/grpquota,配置写在一堆 aquota.user 文件里。如果是 XFS,情况不一样:现代 XFS 推荐用 project quota(项目配额),而且它是在线开启的,不需要像 ext4 那样先 remount 再 quotacheck。
我这里的服务器是 Debian,数据盘是 XFS,这个组合其实更省事。如果你不确定,还有一个更彻底的办法:直接看 mount 输出里有没有 quota 字样,如果没有,说明现在一个配额都没启用。
有一点要提前说清楚:XFS 在同一台机器上不支持同时开启 usrquota 和 prjquota。 这不是文档里写得很显眼的事,但如果你先按老教程配了 usrquota,再想改用 project quota,会发现挂载直接失败,报 Invalid argument。解决方法是把 fstab 里的选项清干净再重新挂载。我第一次就是踩在这儿,花了二十分钟才反应过来是自己之前加的选项在捣乱。
第二步:给 XFS 开启项目配额
XFS 的项目配额开启很轻量,改 fstab 加一个选项就行:
UUID=3f2a1b7c-... /data xfs defaults,prjquota 0 0改完 fstab 有两个选择:remount 或者重启。mount -o remount /data 在大多数情况下能生效,但如果这个分区上有大量打开的文件描述符,remount 可能返回 EBUSY。我倾向于重启,因为 remount 之后系统和 fstab 是两份状态,下次重启如果顺序有问题会得到不一样的结果。
重启后验证一下:mount | grep /data 应该能看到 prjquota。如果没有,说明 fstab 里的选项没被读到,检查一下有没有拼错,或者有没有被 systemd 的挂载单元覆盖。
接下来要给项目定义一个「容器」,这个概念是 XFS 特有的,很多人会漏:项目配额必须先有项目 ID(project ID),然后把这个 ID 挂到某个目录上,配额才对这个目录生效。 光在 fstab 里开 prjquota 是没用的。
做法是先在 /etc/projects 和 /etc/projid 里登记,这两个文件的格式是这样的:
# /etc/projects 项目ID:路径
42:/data/uploads
# /etc/projid 项目名:项目ID
uploads:42然后执行 xfs_quota -x -c 'project -s uploads' /data。这里的关键是那个 -s 参数,它的意思是「把当前目录下所有文件都归属到这个项目里」。如果漏了 -s,你会发现只有新建的文件才计入配额,已有的历史文件全部不算。 这是最容易出问题的地方:你以为配额生效了,实际上 xfs_quota -x -c 'report -p' 报出来的 used 是 0,而目录里明明躺着几十 GB 的文件。
如果目录巨大,project -s 要跑很久,因为它要遍历每个 inode 打标记。我这次目录有一百多 GB、几十万个小文件,跑了将近四分钟。这段时间里不要中断,中断了会有一部分文件没打上标记,需要重新跑一次。
第三步:设定配额数字,注意顺序
配额设置命令是 setquota 或者 xfs_quota -x -c 'limit',四个数字是有顺序的:块软限、块硬限、inode 软限、inode 硬限。这个顺序极容易记反,我建议直接把命令写进文档,别凭记忆敲。
xfs_quota -x -c 'limit -p bsoft=200g bhard=220g isoft=800k ihard=900k uploads' /data这里加 inode 限制不是多余的。我踩过的另一个坑是:某个缓存目录里生成了上百万个几百字节的小文件,磁盘空间只占了几百 MB,但 inode 用光之后新建文件直接报 No space left on device,而 df -h 显示的可用空间还有一大把。所以容量限制和文件数限制必须一起设,否则你只堵住了一半。
软限和硬限的关系也要理解清楚。软限是可以超的,超了之后进入宽限期(默认 7 天),宽限期过了还没降到软限以下,就变成硬限对待,直接拒绝写入。所以软限的意义是「给一个缓冲期」,不是「可以稍微超一点」。如果你的脚本根本不会自己清理,那软限设得比硬限低意义不大,最后还是要撞硬限。
宽限期可以单独调:xfs_quota -x -c 'timer -p -b 3days uploads' /data。对于我这种「脚本失控」场景,我反而把宽限期设短了,三天。理由是:如果脚本在三天内都没把空间降下来,说明它是真的坏了,早点报错比晚点报错好。
第四步:让脚本能正确识别「配额超了」
这一层最容易被忽略,也最有意思。当你超过硬限时,写操作返回的错误是 EDQUOT,errno 122。而如果你的脚本用 if (!$fp = fopen(...)) 这种写法,它只会得到一个 false,然后根据 error_get_last() 拿到一句通用的话,根本分不清是「磁盘满了」还是「配额超了」。
区分这两者的意义在于应对策略完全不同:磁盘满说明整台机器有问题,需要报警;配额超说明只有这个脚本自己有问题,它应该自己停下来、清理旧文件、然后退出。所以脚本里必须显式判断 errno。
$fp = @fopen($path, 'w');
if ($fp === false) {
$err = error_get_last();
if (isset($err['message']) && stripos($err['message'], 'Disk quota exceeded') !== false) {
fwrite(STDERR, "quota exceeded, aborting crawler\n");
exit(3);
}
fwrite(STDERR, "open failed: " . ($err['message'] ?? 'unknown') . "\n");
exit(4);
}给不同的错误码一个专门的退出值(我这里配额超用 3),配合 cron 的日志和告警,就能一眼看出是哪种故障。这个技巧看起来很小,但在一堆脚本里排查问题时,退出码比日志文本好用得多。
另外提醒一点:EDQUOT 在不同的写入路径上表现不完全一样。用 file_put_contents 可能只会返回写入字节数小于预期,而不会抛异常;用 fwrite 逐块写的时候,可能在写到一半才报错,前面已经写进去的数据还在。所以处理这类失败时,不要假设文件是完整的,要么写到临时文件再 rename,要么在失败时主动 truncate 掉。
第五步:把配额纳入日常巡检
配额设好不等于以后不用管了。最大的问题是它不会主动通知你——没有任何日志会写「某某项目已经用了 80%」。所以必须自己做一个巡检。
查看当前用量的命令是 xfs_quota -x -c 'report -p -h' /data,输出里有 used、soft、hard、warn 几列。写脚本解析的时候有个坑:不同发行版、不同 xfsprogs 版本的表头格式不一样,列的位置会变。 直接按固定列号取会静默拿到错误的数。稳妥做法是解析表头找到 used 和 hard 两列的位置,再取值。
#!/bin/bash
# 输出格式: project used_pct
xfs_quota -x -c 'report -p -N' /data 2>/dev/null | awk '
NR==1 { for (i=1;i<=NF;i++) { if ($i=="Used") u=i; if ($i=="Hard") h=i } ; next }
u && h && $h != "0" { printf "%s %.0f\n", $1, $u*100/$h }
' | while read name pct; do
[ -z "$name" ] && continue
if [ "$pct" -ge 85 ]; then
echo "$(date '+%F %T') WARN project=$name used=${pct}%"
fi
done-N 参数是「不解析数字」,输出原始字节数,这样计算百分比时不会被自动换算成 G、M 之类的单位搞乱。-h 反而会让人算错。
把这个脚本放进每天的定时任务,输出重定向到日志文件,再配一条「日志文件里有 WARN 就发通知」。这样就形成了一个完整的闭环:配额负责在紧急情况下兜底,巡检负责在接近上限时提前预警。
第六步:没有配额能力时的三个替代方案
不是所有环境都能上配额。有些云主机的数据盘是网络块存储,不支持 prjquota;有些容器环境里你根本改不了宿主的挂载选项。这种时候有三个退路,效果按可靠性排序。
第一个是独立分区或者独立逻辑卷。把会失控的目录单独挂一个 LVM 逻辑卷,容量设成它能承受的上限。写满了,影响的也只是这个卷,不会连累 MySQL。这个方案的可靠性最高,因为它是块设备级别的硬隔离,任何程序都绕不过去。代价是规划时要预留空间,扩容需要 lvextend 加文件系统扩展两步。
第二个是 tmpfs 加 size 参数。mount -t tmpfs -o size=2G tmpfs /var/cache/xxx,这个方案的特点是「超了立刻报错」,而且空间是内存不占磁盘。适合真正临时的目录。缺点是重启就没了,而且内存被占掉会影响其他服务,所以 size 必须设得保守。
第三个是给进程加资源限制。ulimit -f 限制单个文件的最大尺寸,配合 timeout 限制运行时长。这个最弱,因为它只能防「单个文件过大」和「跑太久」,防不了「很多个小文件把盘写满」。我通常把它当最后一道,和前两个叠加使用。
我自己的实际方案是:配额作为主力(因为 XFS 支持且改动小),加上每天一次的巡检脚本,再加上抓取脚本本身改成「下载失败就跳过、成功后按日期归档、超过 30 天自动删除」。三层的思路不一样,最后那层才是真正的根治。
几个后续复盘的想法
配额上线之后,我重新看了一遍这次事故,有几点感受。
第一,配额的价值不是「防止磁盘写满」,而是「把故障限制在一个局部」。磁盘写满这件事本身很难完全避免,但让一个爬虫的失控只影响它自己的目录,而不是把整台机器的数据库搞挂,这个收益是巨大的。原来的问题是故障范围失控,不是故障本身。
第二,任何无人值守的脚本,都应该有一个「它会怎么失败」的设计。我原来写这个脚本的时候,只想了「怎么成功抓取」,完全没想「抓取失败或者磁盘满了会怎样」。结果就是它既不失败也不停止,只是慢慢把磁盘吃掉。现在的做法是每个长跑脚本都必须回答三个问题:单次运行的上限是什么、异常了怎么退出、数据留多久。
第三,配额的配置应该写进配置管理,而不是手工命令。我这次的配置散落在 fstab、/etc/projects、/etc/projid 和一个手工执行的 xfs_quota -x -c 命令里。如果哪天重装系统,很可能忘了最后那一步,配额静默失效而没人发现。现在我把这些写成了一个幂等的初始化脚本,重装后跑一次就恢复。
第四,配额的告警阈值我一开始设的是 90%,跑了两周发现太晚——从 90% 到写满可能只有几个小时,尤其在这种突发写入的场景下。后来改到 85%,并且加了一条「增长速度」的判断:如果一小时内的用量增长超过 5%,也单独报一次。这条比单纯看绝对水位有用得多。
最后说一下 XFS 配额的一个限制,我自己是踩过才记住的:项目配额是跟着 inode 走的,不是跟着路径走的。 也就是说,如果你用 mv 把一个已经在项目里的文件移到另一个项目目录,它的项目归属不会自动改变。这会导致两个目录的用量统计看起来都不对。处理方式是移动完之后重新执行一次 project -s,或者更省事的做法——项目之间的移动走 cp 加删除。这个细节在文档里不显眼,但在多目录的场景下很容易造成统计错乱。