一、现象与影响面:盘满不是事故,是欠账
症状:df -h 里某个分区 Use% 一路爬到 90 以上甚至 100%,业务开始报 No space left on device——数据库写不进、日志写不进、连临时文件都建不了。与”磁盘占满”的通用排查不同,本文聚焦其中最可预防的一类:日志持续增长而没有人给它设上限。这类故障有个恼人的特点:它不是突然发生的,是每天涨一点,直到某天早上把你叫醒。
动手之前先立一条纪律:别急着 rm 大文件。文件还在被进程写着的时候,rm 掉它,空间不会释放——进程握着的文件描述符(fd)没关,内核不回收数据块,df 纹丝不动,你还顺手丢掉了现场。反模式一:见到大日志直接 rm。要清空用 truncate -s 0 文件名,或者先让进程关掉文件再删。本文第一节就记住这一条,后面至少有三处会用到它。
排查主线:df 定位分区 → du 定位目录与文件 → 按文件类型分三条支线治理(业务日志的轮转配置、journald 的体积、容器日志的限额)。每条支线互相独立,但修法都建立在同一套语义上。
二、第一步:df 找分区,du 找真凶
以下命令都在出问题的那台服务器上跑。先用 df 确认是哪个分区满了,顺带排除一种假象:
df -h
1. Filesystem Size Used Avail Use% Mounted on
1. /dev/vda1 40G 38G 0 100% /
1. ← 哪个分区满、挂载在哪,一目了然
df -i
1. Inodes IUsed IFree IUse%
1. 2560000 2549800 200 100%
1. ← Use% 还有空间但照样报 no space?多半是 inode 耗尽:
1. 海量小文件把文件数额度用光了,df -h 和 df -i 要一起看
分区定位后,用 du 逐层下钻找具体目录。-x 是关键参数:只统计本文件系统,不跨进其他挂载点,数字才对得上 df:
du -xh --max-depth=1 / 2>/dev/null | sort -rh | head
1. 38G /
1. 22G /var
1. 9.8G /home
1. ...
du -xh --max-depth=1 /var 2>/dev/null | sort -rh | head
1. 14G /var/lib/docker ← 支线三:容器日志
1. 12G /var/log ← 支线一/二:业务日志与 journald
如果发现某个”已删除”的文件还占着几十 G,那是第一节说的幽灵占用。用这条命令把它揪出来:
lsof +L1 2>/dev/null | awk '$7 > 1000000000' # 列出 link count 异常且大于 1G 的
1. COMMAND PID USER FD SIZE
1. nginx 1234 root 4w 8.2G (deleted)
1. ← 进程名和 fd 都在,修法:reload 让它重开日志,或临时 truncate
到此你知道了是谁占的盘。日志类文件按归属走三条支线,先从最大头的业务日志开始。
三、第二步:业务日志为什么没轮转
logrotate 是绝大多数发行版自带的日志轮转工具,但它只管有配置的日志。自研服务自己打开一个文件写日志、没人在 /etc/logrotate.d/ 里给它登记,是盘满案例里频率最高的根因。逐项核对:
# 服务器上;先看登记簿里有没有这家伙
ls /etc/logrotate.d/
1. nginx rsyslog mysql-server ... ← 没有你的服务名就是没配
1. 看上次轮转时间(状态文件路径各发行版不同,Debian 系如下,RHEL 系以文档为准)
grep -A1 "/var/log/myapp" /var/lib/logrotate/status
1. "/var/log/myapp/app.log" 2024-8-4-3:10:0 ← 上次动它的时刻
1. 干跑一遍配置,只打印将做什么,不动真格(-d = debug/dry-run)
logrotate -d /etc/logrotate.d/myapp
1. 输出里 reading config / considering log / log does not need rotating
1. ← 如果报 parent directory has insecure permissions,
1. 是日志目录属主与运行 logrotate 的身份不符,配置里加一行 su root adm 解决
状态文件里的时间戳能直接回答”轮转到底跑没跑”:每天该轮转但时间戳停在一周前,是 cron/timer 层没执行 logrotate 本身;时间戳在更新而文件不缩,是模式配错了,下一节讲。反模式二:新服务上线只配了写日志,不配轮转也不验证——把 logrotate -d 放进发布清单,上线当天就能发现。
四、第三层:复制模式与转移模式,选错丢日志
logrotate 有两种轮转动作,语义完全不同,选错是这个领域最隐蔽的坑:
| 维度 | 转移模式(默认,rename + create) | 复制模式(copytruncate) |
|---|---|---|
| 动作 | 把旧文件改名,再新建一个同名空文件 | 复制一份出来,然后原地清空 |
| 对应用的依赖 | 应用必须重新打开日志文件(收到信号后 reopen,如 nginx 收 USR1、多数守护进程收 HUP) | 不需要通知应用,fd 全程不换 |
| 代价 | 应用不 reopen 就继续写进改过名的旧文件——新文件永远是空的,旧文件无限涨 | copy 与 truncate 之间写入的那几行会丢 |
| 适用 | 支持信号重开日志的成熟服务 | 不支持重开的自研脚本、第三方闭源进程 |
一份带注释的标准配置长这样:
/var/log/myapp/*.log {
daily # 每天轮转
rotate 14 # 保留 14 份,到数删除——不配它日志迟早还是满盘
compress # 旧日志 gzip,文本日志压缩比通常很可观
delaycompress # 最新一份延一天再压,方便 tail 排查
missingok # 日志不存在不报错
notifempty # 空文件不轮转
dateext # 轮转文件按日期命名,而不是 .1 .2
copytruncate # 自研服务收不了信号,选复制模式;成熟服务删掉这行改用转移
su root adm # 目录属主特殊时用它过权限检查
}
怎么判断自己是不是选错了模式:转移模式配置下,轮转发生后看旧文件(改名的那份)是不是还在涨。在涨,说明应用没 reopen,你的 rotate 等于没做。修法要么给应用配上 reopen 信号,要么改用 copytruncate。确认模式无误、状态文件在走,业务日志这条线就通了,往下看 journald。
五、第四层:journald 的体积治理
systemd 发行版上,很多服务的输出进了 journald 而不是文件。journald 有自带的体积上限机制,但默认值在小盘机器上依然可能成为大头:
# 服务器上;先看它现在吃多大
journalctl --disk-usage
1. Archived and active journals take up 3.9G in the filesystem.
1. ← 不设上限时,默认约为所在文件系统的 10%,且封顶 4 GiB(长期稳定默认,
1. 具体数值语义以 man journald.conf 为准)——40G 的盘它理直气壮吃 4G
治理分两步:立即回收一次,再改配置让它永久受限:
# 立即瘦身:只保留 200M(立即生效,可先用它救急)
journalctl --vacuum-size=200M
1. 永久上限:编辑 /etc/systemd/journald.conf
1. [Journal]
1. SystemMaxUse=500M
systemctl restart systemd-journald # 改配置后重启 journald 生效
设了 SystemMaxUse 之后,journald 自己会滚动删除最旧的归档,不需要 logrotate 介入。journald 这条线收缩完,看看最后一条支线——容器日志,它默认完全没有任何上限。
六、第五层:容器日志的限额
Docker 默认的 json-file 日志驱动把每个容器的 stdout/stderr 原样写进一个文件,默认不限量、不轮转。一个疯狂打日志的容器就能独自吃满一台机器:
# 服务器上;按大小排一下所有容器的日志
du -sh /var/lib/docker/containers/*/*.log 2>/dev/null | sort -rh | head
1. 8.4G /var/lib/docker/containers/a1b2.../a1b2...-json.log
1. ← 第二步里 /var/lib/docker 的 14G,大头在这
docker inspect --format '{{.Name}} {{.HostConfig.LogConfig.Config}}' a1b2
1. /myapp map[] ← Config 为空就是没设任何限额
限额是全局一处配置,但生效条件有个高频坑:log-opts 只对之后新建的容器生效。docker update 不支持改日志限额,restart 也不会让已有容器换上新参数——必须重建容器:
# /etc/docker/daemon.json(服务器上;改完 systemctl reload docker)
{
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "3" }
}
1. 单容器上限 = max-size × max-file = 30M;按业务日志量定,别照抄
1. 已堆积的存量文件救急(容器没法立刻重建时):
truncate -s 0 /var/lib/docker/containers/a1b2.../a1b2...-json.log
1. 不是 rm!第一节的老规矩。诚实边界:truncate 后 docker 沿旧偏移继续写,
1. 文件会出现空洞、du 的数字要等重建容器才彻底干净——这是临时手段,根治靠限额加重建
反模式三:改了 daemon.json 就 restart 容器,然后宣布限额生效。restart 不重建容器,LogConfig 还是启动时的旧值,必须 down/up 或重新 run。三条支线都过了一遍,把根因收进一张清单。
七、根因清单:按出现频率排序
| 根因 | 典型特征 | 一行验证 |
|---|---|---|
| 自研服务写日志没配 logrotate | 某个自己的日志文件单调增长数月 | ls /etc/logrotate.d/ 里没有它 |
| 转移模式配了不会 reopen 的进程 | 改过名的旧文件还在涨,新文件恒为空 | 轮转后 ls -l 看旧文件字节数 |
| docker json-file 无限额 | *-json.log 以 G 计 |
du -sh /var/lib/docker/containers |
| journald 未设 SystemMaxUse | /var/log/journal 占数 G |
journalctl --disk-usage |
| rm 了仍在写的大日志 | df 不降,lsof 可见 (deleted) | lsof +L1 |
| logrotate 权限报错整批跳过 | 状态文件时间戳停在很久以前 | logrotate -d 看 insecure permissions |
| inode 耗尽的假盘满 | df -h 有空间仍报 no space | df -i 看 IUse% |
八、预防:把欠账变成按期小额还款
| 措施 | 做什么 | 边界 |
|---|---|---|
| 水位告警 | df 的 Use% 达 80 预警、90 告警,inode 一并采集 | 告警要发给能动手的人,否则等于没配 |
| 限额进装机清单 | 新机器初始化时写好 journald SystemMaxUse 与 daemon.json log-opts | daemon.json 改动对存量容器不生效,重建时才兑现 |
| 轮转验证进发布清单 | 新服务上线跑一次 logrotate -d,一周后核对状态文件时间戳 |
daily 轮转最快也要一天后才能验证 |
| 巡检幽灵占用 | 巡检脚本带上 lsof +L1 的汇总行 |
大文件被误删的现场要保住 fd 信息再 reload |
这套走法收拢成一句:先用 df 与 du 把真凶找出来,rm 之前先查 fd;业务日志选对轮转模式并验证状态文件在走,journald 与容器日志各设上限——盘满从不突然发生,它只是终于发生了。
延展阅读:df、du、lsof 这些观测命令的日常用法,见《Linux 常用命令》;当机器多起来之后,水位监控与限额基线要统一规划,见《多节点集群架构规划》。
版本核实(以官方文档为准):logrotate 的 copytruncate 复制窗口丢行、rename 后应用未 reopen 则旧文件继续增长,为长期稳定行为;状态文件路径 Debian 系与 RHEL 系不同,以发行版文档为准;journald 的 SystemMaxUse 默认约为文件系统 10% 并封顶 4 GiB、vacuum 系列为长期稳定语义,以 man journald.conf 为准;Docker json-file 驱动默认不限量不轮转、log-opts 仅对新容器生效且 docker update 不支持修改,为长期稳定行为,具体以所用版本的 Docker 文档为准;swapfile 相关内容见本系列内存篇。