tmpfs写满导致磁盘报错:五层定位与容量核算

为什么不是「磁盘满了」,而是「内存被临时文件吃光」

很多站长第一次遇到 No space left on device 时,第一反应是 df -h 看磁盘——结果发现 / 还有 40% 空闲。第二反应是查 inode,也正常。这时候真正的原因往往藏在 /dev/shm 或者 /run 里:这两个目录压根不在磁盘上,而是 tmpfs(基于内存的临时文件系统)。它们吃的是内存和 swap,写满之后内核照样返回 ENOSPC,报错文本和磁盘写满一模一样。

我负责的一台 4 核 8G 的站点机,去年就栽在这上面。现象是:PHP 上传大图偶发失败、MySQL 日志里冒出 Disk full writing '/tmp/#sql_xx.MAI'、但 df -h / 一直显示只用了 55%。前后折腾了两个小时,最后是 df -h /dev/shm 一行命令破了案:tmpfs 默认上限是物理内存的一半,也就是 4G,而某个报表脚本往 /dev/shm 里写临时排序文件,一次就吃掉 3.8G。

这篇文章把 tmpfs 的容量计算、常见踩坑、监控告警、以及「到底该不该把 /tmp 挂到内存里」这条经常被拿来做性能优化的建议,一次讲清楚。

tmpfs 和磁盘到底差在哪:三个必须搞清的事实

把 tmpfs 当成「一个跑得很快的磁盘」是最容易犯的认知错误。它有三个本质上的不同:

# 1. tmpfs 的内容全在内存里(可能被换出到 swap)
$ df -h /dev/shm /run /tmp
Filesystem      Size  Used Avail Use% Mounted on
tmpfs           3.9G  128M  3.8G   4% /dev/shm
tmpfs           790M  1.2M  789M   1% /run
/dev/vda1        79G   43G   32G  58% /

# 2. 默认 size 上限 = 物理内存的 50%
$ free -h
               total        used        free
Mem:           7.8Gi       5.9Gi       312Mi
Swap:             0B          0B          0B

第一,容量是「上限」不是「预留」。tmpfs 刚挂上时几乎不占内存,只有当文件真正写进去才按页分配。所以 df 显示 3.9G 可用,不代表这 3.9G 已经锁定;反过来,一旦写进去,这部分内存立刻从可用内存里扣掉,其他进程(包括 MySQL 的 buffer pool)就可能开始被 OOM killer 盯上。

第二,tmpfs 的内容可以被换出到 swap。这意味着如果机器有 swap,一个塞满 4G 临时文件的 tmpfs 会把 swap 打满,然后整机进入「疯狂换页」状态——iowait 飙高、SSH 打字都卡。这种情况下看起来像磁盘 I/O 问题,实际上是内存压力。

第三,重启即消失。这既是优点(临时文件不会积压)也是缺点(如果你把 session、队列、上传中的文件放在这里,重启就全丢)。/run 在现代发行版里就是 tmpfs,systemd 的运行时状态、PID 文件、socket 都在里面,所以清空 /run 等于把 systemd 的状态搞乱,千万别手贱去 rm -rf /run/*。

tmpfs 写满的四个典型来源

按我这几年遇到的频率排序,tmpfs 被打满基本就是下面这四种,最后一种最隐蔽。

1. 数据库的临时表和大排序

MySQL 在执行带 GROUP BY、ORDER BY、ALTER TABLE 的语句时,如果内存临时表放不下,会落到 tmpdir。默认 tmpdir=/tmp,而很多镜像或云主机把 /tmp 挂在 tmpfs 上。一条没走索引的大表 ALTER,能轻松在 tmpdir 里写出几十 G 的中间文件——当然,在 4G 的 tmpfs 上会先报 ENOSPC。

处理办法是把数据库的 tmpdir 指到真实磁盘,并且单独划一个目录,避免和系统 /tmp 抢空间:

# /etc/mysql/mysql.conf.d/zz-tmpdir.cnf
[mysqld]
tmpdir = /var/lib/mysql-tmp
tmp_table_size = 64M
max_heap_table_size = 64M

# 目录要由 mysql 用户可写,且不要设 noexec 之外的限制
mkdir -p /var/lib/mysql-tmp
chown mysql:mysql /var/lib/mysql-tmp
chmod 750 /var/lib/mysql-tmp

注意 tmp_table_size 和 max_heap_table_size 必须一起调,MySQL 取两者较小值作为内存临时表上限。只调一个等于没调,这是很多人调完发现没效果的原因。

2. PHP 上传与 session

upload_tmp_dir 未设置时,PHP 用系统 /tmp。上传大文件时,整个文件会先完整落到 tmpdir,再由 move_uploaded_file() 搬到目标目录。所以「上传 500M 视频」这个动作,会在 tmpdir 里先占 500M。并发上传时是相乘的。更糟的是 Nginx 的 client_body_temp_path 也可能在同一分区,请求体先落一次盘,PHP 再落一次,等于双份占用。

除了上传,PHP session 的默认存储路径 session.save_path 在 Debian 系通常是 /var/lib/php/sessions,但也有镜像配到 /tmp。session 文件本身很小,但如果 session.gc_probability 为 0(很多发行版默认如此,改由 cron 清理),而清理 cron 又没跑起来,session 文件会无限堆积——单目录几十万个文件,既吃空间又让 glob 类操作变慢。

3. systemd 服务与 private tmp

PrivateTmp=yes 是 systemd 单元的安全加固选项,它给服务一个独立的 /tmp 和 /var/tmp,实际落在 /tmp/systemd-private-xxx 下。这个目录默认位于 /tmp,如果 /tmp 是 tmpfs,那么所有开了 PrivateTmp 的服务共享同一块内存额度。一个跑长任务的定时脚本在里面写了大文件,会把所有服务的 tmpfs 一起挤爆——但你在服务自己的日志里看不到任何「磁盘满」,因为它是另一个 mount namespace。

4. 用 /dev/shm 做进程间共享(最隐蔽)

PostgreSQL 的 dynamic_shared_memory_type=posix、Chrome/Chromium(比如 Puppeteer 截图服务)、部分 Python 多进程库,都会直接用 /dev/shm。Chrome 尤其典型:容器里跑 headless Chrome,一个标签页崩溃就会在 /dev/shm 留下垃圾,而且默认 64M 的 shm 根本不够,官方镜像文档里直接建议加 --shm-size=1g。如果你的截图脚本跑着跑着报错,先看 ls -la /dev/shm。

定位:从 ENOSPC 到具体文件的五步法

别急着清空任何目录,先定位。下面五步按顺序做,基本三分钟出结论。

# 第 1 步:确认到底是哪个挂载点满了(一定要看 tmpfs 那几行)
$ df -hT | grep -E 'tmpfs|Filesystem'

# 第 2 步:如果 df 显示 / 没满但依然 ENOSPC,检查 inode
$ df -i | awk '$5+0 > 80'

# 第 3 步:找出 tmpfs 里谁在占地方(按目录汇总,-x 不跨文件系统)
$ du -xh --max-depth=2 /tmp /run /dev/shm 2>/dev/null | sort -rh | head -20

# 第 4 步:已删除但仍被进程占用的文件(df 说满,du 却算不出来)
$ lsof +L1 2>/dev/null | awk '$7 > 104857600 {print $1, $2, $7, $9}' | head

# 第 5 步:看 tmpfs 的真实使用量(tmpfs 实际用了多少内存)
$ cat /proc/meminfo | grep -i -E 'Shmem|ShmemPmdMapped'

第 4 步是排查里的关键。文件被 unlink 之后,只要还有进程持有文件描述符,磁盘/内存空间就不会释放。df 显示占用,du 却找不到对应文件,就是这个原因。最常见的场景是:MySQL 或 PHP 进程被 kill -9 之前正在写一个大临时文件,文件从目录树里消失了,但空间被一个僵尸 fd 锁着。解决办法是重启那个进程,或者 lsof 找到 pid 后用 gdb 手动 close(不建议,直接重启更稳)。

另外注意 du 的 -x 参数:不加的话 du /tmp 会跨进 /tmp 下面挂载的其他文件系统,统计出与你以为的不一样的结果。排查空间问题我习惯永远带 -x。

处理:缩容、迁移和「不要盲目删」

定位到之后,处置方式分三种,按推荐度排序。

方案 A:把容易长大的目录迁到真实磁盘。这是最治本的做法。数据库 tmpdir、上传临时目录、截图服务的产物目录,都不该放在内存里。改完配置后重启对应服务,然后观察一周。

方案 B:按需调大或调小 tmpfs 的 size 上限。线上临时扩容可以热做:

# 临时把 /dev/shm 放大到 2G(重启后失效)
mount -o remount,size=2G /dev/shm

# 永久生效写进 fstab
# tmpfs  /dev/shm  tmpfs  defaults,noatime,size=2G,mode=1777  0 0

# 校验
findmnt -no SIZE,USED,AVAIL /dev/shm

注意:调大 size 只是放开了上限,并没有给机器增加内存。4G tmpfs 写满意味着内存已经被吃掉 4G,这时候 OOM 风险已经很高了。所以「调大」只适合临时救火,配合清理才能撑过去。

方案 C:清理,但要清对地方。可以安全清理的:/tmp 下超过 3 天且不属于任何进程的普通文件(find /tmp -xdev -type f -mtime +3 -atime +3 -delete,先 -print 看一遍)、/dev/shm 下明确属于自己的服务留下的垃圾。绝对不能碰的:/run/systemd、/run/php、/run/mysqld/mysqld.sock、任何 .sock 文件。/tmp 里还有 X11 的 .X11-unix 和 systemd-private-*,前者删了桌面会挂,后者删了对应服务会异常。

我一般在清理前先跑一遍「谁在用」的检查:lsof +D /tmp 2>/dev/null | head -30,看清楚再动手。

监控:让告警在写满之前响

tmpfs 最容易出问题的地方在于它不在常规的磁盘告警覆盖范围内。大多数监控模板只盯 / 和 /data,tmpfs 压根没采集。所以我给每台机器都加了一个五分钟跑一次的检查脚本:

#!/bin/bash
# /usr/local/bin/check-tmpfs.sh —— 任意 tmpfs 用量超过 80% 就报警
set -u
THRESHOLD=${THRESHOLD:-80}
ALERT=0
while read -r target size used avail pct; do
  n=${pct%\%}
  [ "$n" -ge "$THRESHOLD" ] || continue
  echo "WARN tmpfs $target at ${n}% (used $used / $size)"
  ALERT=1
done < <(df -P -t tmpfs | tail -n +2)
# 顺带看 shmem 总量:tmpfs 全部占用之和,超过内存 30% 就值得关注
shmem_kb=$(awk '/^Shmem:/ {print $2}' /proc/meminfo)
mem_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)
if [ $((shmem_kb * 100 / mem_kb)) -ge 30 ]; then
  echo "WARN shmem ${shmem_kb}kB is $((shmem_kb * 100 / mem_kb))% of RAM"
  ALERT=1
fi
exit $ALERT

挂到 cron 里,输出被邮件或 webhook 收集。df -P 的 -P 很重要:不加的话超长设备名会让输出折行,awk 取列就错位了,这是很多自写监控脚本报「用量 0%」的根因。

还有一条经验:把 tmpfs 用量和 OOM 事件放在同一张图上。tmpfs 上涨往往紧跟着 dmesg 里的 OOM killer,两者相关性极强。只盯 tmpfs 会让你知道「临时文件多了」,盯 OOM 才知道「业务已经受影响了」。

那到底该不该把 /tmp 挂到内存?

网上很多「VPS 优化」教程会建议把 /tmp 改成 tmpfs,理由是减少磁盘 I/O、加快 session 和临时文件读写。我的观点很明确:对小内存 VPS(4G 以下)不要这么做,对 16G 以上、且 /tmp 里确实只有小文件的场景可以考虑。

判断标准是三条:一是 /tmp 里历史峰值是多少(du -sx /tmp 连续采样一周取最大值),二是机器有没有 swap、swap 有多大,三是有没有跑数据库或上传功能。只要第二条和第三条里有一条是「有」,我都不建议挂 tmpfs。因为一旦内存紧张,tmpfs 会把 swap 拖进死循环,而 swap 上的 tmpfs 读取速度还不如直接读 SSD。

如果确实要挂,务必设置 size= 上限(不要用默认的 50% 内存),加上 nosuid,nodev,noexec 三个安全选项(防止上传目录被当可执行文件运行,这是真实存在的提权路径),并在 /etc/fstab 里写死,不要只靠 mount 命令热挂——重启后失效,运维交接时最容易埋雷。

一次真实故障的完整复盘

回到开头那台 8G 的机器。最终结论是:报表脚本每天凌晨 2 点跑,用 Python 的 tempfile 写中间结果,默认落在 /tmp。脚本被并行跑了 8 个实例(cron 没加 flock,上一轮没跑完下一轮就起来了),每个实例峰值 400M 左右,加起来 3.2G,直接把 4G 的 tmpfs 顶到 98%。为什么 df -h / 是正常的?因为 /tmp 是独立挂载的 tmpfs,根本不在 / 的统计里。

修复做了三件事:给 cron 加 flock -n 保证单实例;把脚本的 tempfile.tempdir 显式指到 /var/tmp/report(真实磁盘);加上前面那个 tmpfs 监控脚本。三个月没再复现。

这件事的教训不是「tmpfs 不好」,而是排障时要先问「这个路径在哪个文件系统上」。df -h 不带挂载点参数时只显示主要文件系统,tmpfs 那几行经常被 head 或者习惯性忽略掉。从那以后我排查空间问题,第一条命令固定是 df -hT | grep -E 'tmpfs|/$'——先确认报错的路径到底在哪块「存储」上,再谈其他。

Last modification:October 21st, 2026 at 07:26 pm

Leave a Comment