一次重启之后,站点恢复了速度
去年冬天,我给一台跑了两年的单机服务器加了一块数据盘。加盘的理由很朴素:根分区剩不到 8G,备份文件已经开始挤占空间。挂载、改 fstab、把备份目录迁过去,一共四十分钟,然后我按惯例重启了一次,确认 fstab 没有把系统挂死。
重启之后站点确实快了。首页从 600 毫秒掉到 140 毫秒,管理后台的列表页也不卡了。我当时以为是磁盘空间宽松之后 MySQL 少了一些临时文件落盘的缘故,还挺得意地写了一条笔记。三天后我发现,那批本该凌晨自动执行的任务,一条都没有留下日志。第四天,我发现搜索框里输入中文,偶尔会返回空结果。第六天,我发现某个分类下的文章数从 214 变成了 0。
这些东西有一个共同点:它们都依赖一条「系统启动时自动挂载」的配置。而我那一次改 fstab,多写了一个选项。这篇文章就是讲这一类事故:挂载选项配错之后,系统不会报错,页面不会 502,但它会安静地把你的站点变成另一个东西。
先分清五个概念,排查才有方向
很多「重启之后就不对了」的问题,最后都落在挂载上。但挂载这一层本身有五件不同的事,混在一起就会乱查:
第一,设备与文件系统。设备是 /dev/vdb1,文件系统是 ext4 或 xfs,两者不是同一个东西,格式化错了会得到「wrong fs type」。
第二,挂载点。它就是一个普通目录,没有挂载时这个目录里的内容来自根分区,挂载后这些内容会被「盖住」。这一点是后面所有事故的物理基础。
第三,挂载选项。ro/rw、noexec、nosuid、nodev、noatime、nofail、defaults,写错了才会出现本文这种事故。
第四,挂载时机。开机时由 systemd 的 fstab-generator 处理,运行时由 mount 命令处理,两者读的不是同一份参数来源。
第五,挂载顺序。挂载点必须在父目录挂载完成之后才能挂,否则你会在错误的层级上挂载一个分区,看起来「挂上了」,其实路径完全不是你想的位置。
排查时先问自己:问题是「没挂上」「挂错了位置」还是「挂上了但权限不对」。这三个答案指向完全不同的命令,先分对,能省掉一大半时间。
第一个坑:目录被盖住,数据还在但看不见
这是最常见也最吓人的一种。现象是:某个目录里的文件突然全没了,比如上传目录变成了空的,但磁盘占用没有下降,du 出来的数字还和以前一样大。
原因很直接:这个目录以前是根分区上的一块普通目录,里面已经有几百兆文件。你后来给它在 fstab 里加了一条挂载,于是新分区被挂上来,把原来的目录盖在了下面。原来的文件并没有删除,只是从当前命名空间里看不到它们了。
验证方法只有一条,就是临时把新分区卸下来看:
umount /data/uploads
ls -la /data/uploads | head
df -h /data/uploads如果 umount 之后文件回来了,那就百分之百是这个原因。接下来不要急着把文件搬过去(新盘可能是空的),而是先把旧文件复制到新分区,再挂回去。顺序错了会丢数据。
更稳的做法是在挂载之前就检查目录是否是空的:
mkdir -p /data/uploads
if [ -n "$(ls -A /data/uploads)" ]; then
echo "目标目录非空,先迁移数据再挂载"
exit 1
fi
mount /dev/vdb1 /data/uploads我在那次事故里,uploads 目录就是这样被盖住的。因为文件确实还在,MySQL 里记录的路径也都有效,所以程序没有报错,只是图片全部 404。这个状态最难判断,因为它看起来像「文件被删了」,实际上是「文件被挡住了」。
第二个坑:没写 nofail,一块盘让整机起不来
fstab 默认行为是「必须挂上」,挂不上就卡在 emergency 模式。对于云主机,这等于失联:SSH 进不去,只能去控制台开 VNC。
更隐蔽的情况是盘挂上了、但挂载慢。数据盘做了一次 fsck,或者网络存储超时,systemd 会一直等,等到 default 的 90 秒超时之后才报失败,然后再回退到 emergency。这期间 Web 服务根本没启动。你看到的现象是「重启之后网站挂了五分钟」,日志里没有任何应用错误。
对于非关键的数据盘,正确写法是加上 nofail:
UUID=8f2c1a9e-4d3b-4a77-9c21-6f3e2b1d9a55 /data ext4 defaults,nofail 0 2nofail 的含义是「挂不上也继续启动」,代价是程序可能在一个空目录上跑。所以 nofail 必须配合应用自身的检查,否则只是把「起不来」换成了「起来了但数据全空」。我在上传目录的入口加了一行探针:文件不存在就返回 503,不要返回空列表。返回空列表会让搜索引擎把几万条页面判定为内容消失,这比 503 严重得多。
还有一个配套项是 x-systemd.device-timeout。云盘有时挂载慢,把超时从 90 秒压到 10 秒,可以让失败更快暴露:
UUID=8f2c1a9e-4d3b-4a77-9c21-6f3e2b1d9a55 /data ext4 defaults,nofail,x-systemd.device-timeout=10s 0 2第三个坑:noexec 与 nodev 挂错位置
noexec 是安全加固的标准动作,给 /tmp、/dev/shm 加上它,可以挡住一大类上传即执行。但如果这个选项被顺手写到了数据盘上,而你的 PHP 项目恰好把 composer 的 vendor 或者某些脚本放在这块盘上,那么所有需要执行的命令都会报 Permission denied。
这个报错的迷惑性在于:文件权限明明是 755,属主也对,sudo 也一样失败。因为 noexec 是在内核层拦截 execve 系统调用,和 POSIX 权限位是两套机制。
检查当前挂载到底用了哪些选项很简单:
findmnt -no OPTIONS /data
grep ' /data ' /proc/mounts如果看到 noexec,而这块盘需要跑脚本,那就得改 fstab 并 remount。remount 可以在线生效,不需要重启:
mount -o remount,exec /data但要注意:remount 之后 fstab 还是旧的,下次重启会退回去。fstab 与运行时是两份状态,改完必须两边都确认。这次事故里我就是只看了 mount 的输出,没看 fstab,结果第二次重启问题复现。
nodev 的边界类似,但影响面小一些,主要影响绑定了设备文件的服务。对普通 Web 站点,最容易被 noatime 之外的东西绊到的就是 noexec。
第四个坑:备份任务跑在未挂载的目录上
这是最不容易被发现、后果最严重的一类。场景是这样的:备份脚本把 dump 写到 /data/backup/,而 /data 由于某个原因没有挂上,于是 mysqldump 把整个数据库写进了根分区的 /data/backup 目录。根分区因此被写满,MySQL 因为无法写 binlog 而停止接受写入,站点开始 500。
这一条有一个明确的防御写法:备份脚本开头先验证挂载点,而不是验证目录是否存在:
#!/bin/bash
set -u
set -o pipefail
MOUNT=/data
if ! mountpoint -q "$MOUNT"; then
echo "FATAL: $MOUNT is not a mountpoint" >&2
exit 2
fi
AVAIL=$(df -Pk "$MOUNT" | awk 'NR==2{print $4}')
if [ "$AVAIL" -lt 1048576 ]; then
echo "FATAL: free space under 1G" >&2
exit 3
fi
mysqldump --single-transaction --quick --default-character-set=utf8mb4 \
blog > /data/backup/blog-$(date +%F).sql
grep -q "Dump completed" /data/backup/blog-$(date +%F).sql || exit 4注意 mountpoint -q 这个命令,它判断的是「这个路径是不是一个独立的挂载点」,而不是「这个目录存在」。用 -d 判断目录存在是没用的,因为目录永远存在。这个小工具在几乎所有发行版里都在 util-linux 包里,不需要额外安装。
另外注意脚本里没有写 set -e。原因很实际:后面几个 grep 判断在没匹配时返回 1,set -e 会让脚本在中途静默退出,反而看不清是哪一步失败。
第五个坑:UUID 与设备名混用
fstab 里写 /dev/vdb1 是最省事的,也是最不稳定的。云主机换规格、加盘、调整顺序之后,vdb 可能变成 vdc,于是你挂到了一块完全不相干的盘上,或者挂载失败进 emergency。
正确做法一律使用 UUID 或 LABEL:
blkid /dev/vdb1
# /dev/vdb1: UUID="8f2c1a9e-..." TYPE="ext4"然后写进 fstab。如果文件系统被重新格式化,UUID 会变,fstab 里的旧 UUID 就失效了。这时不写 nofail 就会直接起不来。所以「用 UUID」和「写 nofail」是两条要一起做的事。
还有一种情况是 swap。swap 的 fstab 行也是按 UUID 或路径写的,如果 swap 分区被删掉了而 fstab 没改,开机同样会卡住。排查重启问题时,swap 行经常被忘掉。
这一批事故的完整复盘
回到开头。我那次改 fstab 做的事是:把备份目录从根分区迁到新数据盘。写完之后我重启验证,站点变快,任务开始静默失败,中文搜索偶尔返回空。
三件事的根因分别是:
第一,crontab 里的备份脚本用绝对路径写 /data/backup,而新盘挂载时多写了一个 noexec —— 不,实际情况更简单,是我把挂载点写成了 /data/backup,与原来的目录名重复了一层,于是脚本写入的路径与挂载点不一致,dump 全都落在了根分区的一个同名目录里,后来被我自己清理掉了。
第二,中文搜索返回空结果,是因为搜索用的临时文件目录也在这块盘上,而这块盘的 inode 数量少于根分区,重建索引时提前耗尽 inode,索引文件写了一半。
第三,分类文章数为 0,是因为那个分类页面做了一次静态缓存落盘,缓存目录同样在数据盘上,缓存文件被覆盖之后没有重建。
三个现象看着毫不相关,其实都是同一句话:我把一块盘挂到了一个已有内容、已被程序引用的路径上,并且没有验证挂载是否真的生效、是否与预期一致。
一份可以照抄的上线检查单
现在每次动 fstab,我会按这个顺序走一遍,一共六条:
第一条,确认目标目录是空的,或者在挂载前完成数据迁移。ls -A 输出为空才能挂。
第二条,确认 fstab 用的是 UUID 而不是设备名。blkid 的输出与 fstab 逐字对比。
第三条,非关键盘一律加 nofail,关键盘不加,但要确认它挂了之后应用才启动。
第四条,noexec / nosuid / nodev 只加在临时目录和数据存储目录上,从不上到需要执行脚本的目录。
第五条,mount -a 先做一次演练,再 mountpoint -q 逐个验证,最后 df -h 确认容量对得上。
第六条,重启一次,重启后重新跑一遍第五条的全部检查。没有重启验证过的 fstab 修改,等于没改。
第五条和第六条的区别很重要。mount -a 走的是「按 fstab 补齐缺失挂载」的逻辑,它不会发现你挂错了位置,也不会发现顺序问题。只有一次真实重启,才能验证系统在冷启动时的行为。
几个顺手能用的命令
查某一路径来自哪个设备:
findmnt -T /data/backup -o TARGET,SOURCE,FSTYPE,OPTIONS查当前系统的挂载树:
findmnt --df查一个目录是不是独立挂载点(脚本里最有用):
mountpoint /data && echo yes || echo no查挂载失败的历史原因(重启后进 emergency 时最有价值):
journalctl -b -u data.mount --no-pager | tail -40systemd 会给每个 fstab 条目生成一个 .mount unit,名字就是挂载点路径的转义形式,比如 /data/backup 对应 data-backup.mount。知道这一点,排查时可以直接按 unit 查日志,比翻整个 journal 快得多。
什么时候干脆不要放在 fstab 里
有一种场景,我认为不该写进 fstab:只需要偶尔用一下的数据盘,比如冷备盘、归档盘。这类盘长期不挂,挂上也不该影响开机。
正确写法是用 automount:
UUID=8f2c1a9e-4d3b-4a77-9c21-6f3e2b1d9a55 /data/archive ext4 defaults,nofail,x-systemd.automount,x-systemd.idle-timeout=600 0 0x-systemd.automount 会让系统在第一次访问该目录时才挂载,x-systemd.idle-timeout 让它闲置十分钟后自动卸下。开机完全不依赖这块盘,访问时也不需要人工介入。代价是首次访问会有一次明显的延迟,所以不适合放热数据。
另外,fstab 的最后两个数字也常被忽略。第一个是 dump,ext4 时代基本写 0;第二个是 fsck 顺序,根分区写 1,其他分区写 2,写 0 表示跳过检查。对一块可能掉线的云盘,写 2 是有意义的,因为文件系统真的可能在上次掉线时被弄脏。
最后的判断标准
这篇没有讲任何高深的东西,全部是「写错一个字、少写一个词」级别的细节。但正是这些细节决定了站点在重启之后是活着的,还是看着活着。
我给自己定了一个判断标准:如果我不能在五分钟内说清某块盘为什么这样挂、它盖住了什么、挂不上会怎样,那这块盘就不该出现在生产机的 fstab 里。说不清的配置,早晚会在某个凌晨变成一次四小时的抢救。
顺带说一句,那次事故之后我把所有挂载相关的检查都写进了一个只读脚本,每天跑一次,只输出四行:挂载点是否正常、可用空间、inode 使用率、挂载选项是否符合预期。前三个数字出问题时,人会立刻发现;第四个出问题时,只有脚本会告诉你。