Docker 磁盘占用排查实战:容器日志清理、悬空镜像与 df/du 不一致的原因

服务器磁盘满了,但文件不知道去哪了

「磁盘爆了」是个人站长投诉率最高的故障之一。典型场景是:监控告警显示根分区 95%,你登上服务器,用 du -sh /* 看了一圈,各项加起来才几个 G,可 df -h 明明说 40G 全满了。多出来的空间凭空蒸发了,找不到凶手。

这种「df 和 du 对不上」的现象在 Docker 环境里尤其常见。原因通常是两类:一是容器日志无限增长把空间吃光,二是被删除但进程仍持有的文件没有真正释放。本文把这两类问题和排查手法讲透,并给出一套自动清理方案。

第一步:先搞清楚是哪一层满了

排查的第一步永远是确认满的是哪个挂载点,而不是上来就删文件。执行:

df -hT

# 典型输出
# Filesystem     Type      Size  Used Avail Use% Mounted on
# /dev/vda1      ext4       40G   38G  0.5G  99% /
# overlay        overlay    40G   38G  0.5G  99% /var/lib/docker/overlay2/xxxx
# tmpfs          tmpfs     1.6G     0  1.6G   0% /dev/shm

注意 Docker 的 overlay 文件系统会把自己的使用量算进根分区,所以在 Docker 主机上,「根分区满」和「Docker 占用大」经常是同一件事,不要被吓到,也不要急着扩容,先把真正的占用者找出来。

第二步:用 du 逐层缩小范围

从根目录开始,一层一层往下钻,找出占用最大的目录:

# 只看根目录下的一级目录,按大小排序
du -xhd1 / 2>/dev/null | sort -rh | head -20

# 输出示例
# 24G  /var
# 8.1G /www
# 3.2G /usr
# 512M /opt

这里的 -x 参数非常重要,它表示「不跨越文件系统」,也就是只统计当前挂载点下的内容。如果不加 -x,du 会跑进 /mnt/data 这些另外挂载的磁盘,甚至钻进网络文件系统里,导致命令卡死。这是 du 在服务器上最经典的踩坑点。

假设 /var 是 24G,接着钻进去:

du -xhd1 /var 2>/dev/null | sort -rh | head -10

# 常见结果
# 22G /var/lib/docker
# 1.1G /var/log

这样三步之内基本就能锁定元凶。绝大多数情况会落在 /var/lib/docker/var/log 上。

第三步:处理容器日志吞噬磁盘

Docker 默认使用 json-file 日志驱动,且默认不限制大小。一个跑得久、输出又啰嗦的应用(比如带 debug 日志的 Node 服务、或者是不断报错的 PHP-FPM),日志文件能轻松涨到几十 GB。查看各容器日志占用:

# 查看每个容器日志文件的实际大小
du -sh /var/lib/docker/containers/*/*-json.log 2>/dev/null \
  | sort -rh | head -10

# 更友好的方式,直接列容器 ID 和名称
docker ps -a --format '{{.ID}}\t{{.Names}}\t{{.Status}}'

# 查看某个容器的日志大小
du -sh /var/lib/docker/containers/$(docker inspect -f '{{.Id}}' myapp)/*-json.log

找到大日志之后,正确的清理方式是「截断」而不是「删除」:

# 正确:清空内容但保留文件,Docker 的文件句柄依然有效
truncate -s 0 /var/lib/docker/containers/CONTAINER_ID/CONTAINER_ID-json.log

# 也可以这样
: > /var/lib/docker/containers/CONTAINER_ID/CONTAINER_ID-json.log

# 危险:直接 rm 掉
# rm /var/lib/docker/containers/.../xxx-json.log
# 这会删掉目录项,但 Docker 进程仍持有句柄,
# 空间不会释放,df 依然显示满 —— 这就是「df 和 du 对不上」的元凶

临时清理治标,治本要限制日志大小。全局配置在 /etc/docker/daemon.json

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "50m",
    "max-file": "3"
  }
}

这表示每个容器日志文件最大 50MB,最多保留 3 个滚动文件,单个容器最多占 150MB。改完执行:

systemctl restart docker

需要注意:这个配置只对新创建的容器生效,已经运行的旧容器不会自动应用。要让现有容器生效,必须重建它们:

docker compose down
docker compose up -d

如果不想中断服务,可以在 docker-compose.yml 里给单个服务单独配置:

services:
  app:
    image: myapp:latest
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "3"

这时用 docker compose up -d 重建该服务即可,日志策略会随之生效。

第四步:处理「已删除但仍占用的文件」

如果 du 加起来远小于 df 的已用量,排名前几的嫌疑就是「deleted 文件」。这些文件被 rm 掉了,但仍有进程打开着句柄,内核不会回收空间,直到进程关闭句柄。查找方法是用 lsof 或 /proc:

# 方法一:用 lsof(需要安装 lsof)
lsof -nP +L1 | head -30

# 输出中 SIZE/OFF 的 NLINK 为 0 且文件标注 (deleted) 的就是

# 方法二:不装任何工具,直接读 /proc
ls -l /proc/*/fd 2>/dev/null \
  | grep '(deleted)' \
  | awk '{print $NF}' | sort | uniq -c | sort -rn | head

找到之后,有两种处理方式。第一种是重启持有该文件的进程:

# 找出是谁持有
lsof -nP +L1 | grep deleted

# 比如是 nginx,就平滑重载(多数情况下 reload 就能释放)
nginx -s reload

# 或者重启服务
systemctl restart nginx

第二种是不重启服务,直接把文件内容清零(适用于日志类文件):

# 用 lsof 拿到的文件描述符号,直接截断
# 注意路径里的 (deleted) 要去掉,写真实路径
: > /www/wwwlogs/access.log

不过要注意,用 : > 截断一个已经 deleted 的文件路径,如果路径对应的是新创建的同名文件,清的就是新文件,而不是那个被删除的旧句柄。所以最稳妥的方式还是重启或重载进程,让内核彻底回收。

第五步:清理 Docker 的闲置资源

Docker 有几类资源会持续累积:停止的容器、无标签的悬空镜像、未被引用的网络、以及构建缓存。这些都可以用 prune 命令批量清理。执行前先看一眼能被清掉多少:

# 查看整体占用
docker system df

# 输出示例
# TYPE            TOTAL   ACTIVE  SIZE     RECLAIMABLE
# Images          42      8       12.4GB   8.1GB (65%)
# Containers      15      6       2.3GB    1.1GB (47%)
# Local Volumes   20      4       4.2GB    2.8GB (66%)
# Build Cache     120     0       3.6GB    3.6GB

然后按需清理:

# 清理停止的容器、悬空镜像、未使用网络、构建缓存(不动卷)
docker system prune -f

# 连未被使用的卷一起清理(会删数据,执行前务必确认)
docker volume prune -f

# 只清构建缓存
docker builder prune -f

# 清理超过 7 天的构建缓存
docker builder prune --filter until=168h -f

务必注意 docker volume prune 的破坏性:没有被任何容器引用的卷会被直接删除。如果你有过「先停容器再重建」的操作习惯,某些数据卷在停容器期间就会变成「未使用」状态,一个 prune 就可能把数据库数据干掉。所以这条命令在数据库服务器上应该禁止自动执行,或者配合白名单使用。

清理悬空镜像也要留意:它删的是 <none>:<none> 的中间层镜像。如果你习惯用 docker build 反复构建同名 tag,旧版本会变成悬空镜像,清理是安全的。但如果某个镜像只是暂时没有 tag,清理后可能无法再找回对应层。

用 ncdu 做交互式排查,比 du 更直观

逐层敲 du 命令在目录层次深的时候相当费力。ncdu 是一个交互式的磁盘占用分析工具,进入界面后可以用方向键在目录树里自由穿梭,一眼就能看出哪个子目录异常膨胀,按 d 还能直接删除选中的文件。安装和用法如下:

# 安装
apt-get install -y ncdu

# 扫描根分区(-x 同样表示不跨文件系统)
ncdu -x /

# 只扫描 Docker 目录,跳过网络挂载
ncdu -x /var/lib/docker

对于容量大、文件多(上百万个 inode)的服务器,ncdu 首次扫描可能耗时几分钟,可以用 -o 把扫描结果导出到文件,之后反复查看而不用重复扫描:

# 第一次扫描并导出
ncdu -x -o /tmp/scan.json /

# 之后从文件打开,秒开
ncdu -f /tmp/scan.json

这招在排查历史快照时特别有用:如果磁盘是在某个时间点突然涨起来的,把两个不同时间的扫描结果对比一下,就能立刻锁定是哪个目录涨的。

建立自动清理与告警

手工清理解决当下问题,自动化解决长期问题。下面这个脚本做两件事:磁盘超阈值时自动清理,清理后仍偏高就发告警。

#!/bin/bash
set -uo pipefail

THRESHOLD=85
LOG="/var/log/docker-cleanup.log"
now() { date '+%F %T'; }

USED=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')
echo "[$(now)] root usage: ${USED}%" >> "$LOG"

if [ "$USED" -lt "$THRESHOLD" ]; then
    echo "[$(now)] below threshold, skip" >> "$LOG"
    exit 0
fi

# 1. 截断超大的容器日志(保留文件句柄)
find /var/lib/docker/containers -name '*-json.log' -size +100M 2>/dev/null \
  | while read -r f; do
      echo "[$(now)] truncating $f ($(du -h "$f" | cut -f1))" >> "$LOG"
      truncate -s 0 "$f"
    done

# 2. 清理 Docker 闲置资源(不动卷)
docker system prune -f --filter "until=168h" >> "$LOG" 2>&1

# 3. 清理过期日志文件
find /www/wwwlogs -name '*.log' -mtime +30 -size +50M -exec truncate -s 0 {} \;

# 4. 复查,仍然偏高则告警
USED_AFTER=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$USED_AFTER" -ge "$THRESHOLD" ]; then
    echo "[$(now)] STILL HIGH: ${USED_AFTER}% after cleanup" >> "$LOG"
    # 这里可以接邮件 / webhook 通知
    # curl -s -X POST https://your-webhook -d "disk ${USED_AFTER}%"
fi

加进 crontab,每天凌晨执行一次:

30 4 * * * /bin/bash /opt/ops/docker-cleanup.sh

如果主机上有多个分区需要监控,可以把 df 循环改成遍历 df -hP | awk 'NR>1 {print $6}' 的输出,逐个判断阈值。

几个容易忽略的占用来源

除了 Docker,还有几个地方经常悄悄吃空间。journal 日志:systemd 的日志默认可能占几百 MB 到几 GB,用 journalctl --disk-usage 查看,用 journalctl --vacuum-size=200M 限制。PHP 会话文件/tmp/var/lib/php/sessions 下的 sess_ 文件,如果 GC 没跑起来会越积越多。宝塔面板的备份目录/www/backup 下会累积大量数据库和网站压缩包,很多人备份成功了却忘了清理。内核版本/boot 分区小的话,多次升级内核后旧版本会在 /usr/lib/modules 里堆积,可以用 du -sh /usr/lib/modules/* 检查。

还有一类容易被忽略的是 Nginx 的日志切割失效。如果 logrotate 配置有问题,access.log 可能几个月没切割,单个文件几十 GB。检查方法:

ls -lh /www/wwwlogs/*.log | sort -k5 -rh | head
# 确认 logrotate 配置
cat /etc/logrotate.d/nginx

结语

磁盘故障的排查逻辑其实很朴素:先用 df 定位挂载点,再用 du 逐层缩小范围,最后用 lsof 查出「df 和 du 对不上」的隐藏占用。真正的长期解决方案是两条:给 Docker 日志设上限,给清理任务设自动化。把这两件事做掉,磁盘告警基本就再也不会半夜打扰你了。

Last modification:September 20th, 2026 at 07:26 pm

Leave a Comment