Docker 容器把磁盘写满:json 日志无限增长的成因与三层治理

Docker 容器把服务器磁盘写满:日志无限增长的真实原因与治理

个人站长用 Docker 部署站点之后,最经典的一种「半夜炸站」方式是:某个凌晨,Nginx 返回 500,SSH 还能连上,但 df -h 一看,根分区 100%。第一反应是「被攻击了」或者「日志被写了太多」,但真正的元凶往往是一个看起来很无辜的东西:容器的 json-file 日志驱动

这篇文章拆解这个问题的成因,并给出三层治理方案:容器级限制、compose 级默认值、以及宿主机级的轮转兜底。

先定位:到底是什么占满了磁盘

不要凭感觉删文件,先按占用排序。Docker 环境的磁盘分析要分两步走。

# 第一步:看哪个目录大(一层层往下钻,别一次钻到底)
du -x -h -d1 / 2>/dev/null | sort -h | tail -8
# 典型输出:/var 很大

du -x -h -d1 /var 2>/dev/null | sort -h | tail -5
# 典型输出:/var/lib/docker 很大

du -x -h -d1 /var/lib/docker 2>/dev/null | sort -h | tail -5
# 关键:containers 目录异常大

如果 /var/lib/docker/containers 很大,基本可以锁定是容器日志。再进一层确认是哪个容器:

du -sh /var/lib/docker/containers/*/*-json.log 2>/dev/null | sort -h | tail -10

输出会直接告诉你「哪个 container id 的日志有 8 个 G」。到这里,病因就明确了。别急着 rm 那个文件——这么做虽然能立刻腾出空间,但正在运行的容器句柄还指向被删的文件,磁盘空间并不会真正释放,得重启容器才行。而且下一次它还会照样涨回来。

根因:为什么 docker logs 会无限增长

Docker 默认的日志驱动是 json-file,默认没有任何大小和数量限制。它的工作原理是把容器的 stdout / stderr 按行写成 JSON 追加到 {container-id}-json.log 文件里。这里有两个设计上的关键点:

  • 它不做轮转。只要容器还活着,文件就一直追加。这正是很多人「明明配了 logrotate 却没生效」的原因——logrotate 面对的是一个持续被写入且不重新打开的句柄。
  • 它记录的是应用 stdout,而不是应用自己写的日志文件。所以你在 PHP 里写 error_log()、在 Nginx 里配 access_log,如果这些输出指向 stdout/stderr,就都会进入这个 json 文件。

对于个人站,最容易失控的场景有三个:

  • PHP-FPM 的慢日志 / 错误日志打到了 stdout,某个爬虫触发大量警告,一次扫描能刷出几百 MB。
  • 容器内应用在崩溃循环(crash loop)里疯狂输出堆栈,几小时内就能写满磁盘。
  • Nginx access log 被指向 /dev/stdout(这在官方镜像里是默认行为),访问量一大日志跟着涨,而且是永久保留。

这三种情况的共同点是:它们都是「正常工作时也在增长」的日志。所以指望「不报错就不会涨」是不成立的,必须显式设置上限。

方案一:给单个容器设置日志上限(daemon.json 全局默认)

最省事的做法是修改 Docker 的守护进程默认配置,让所有新建容器都带日志上限。编辑或创建 /etc/docker/daemon.json

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

含义是:单个日志文件最大 50MB,最多保留 3 个(含当前文件),也就是单个容器日志最多占 150MB。这个量级对个人站足够用了。改完必须重启 Docker 守护进程:

systemctl restart docker

然后确认配置已生效:

docker info --format '{{.LoggingDriver}}'
docker inspect --format '{{.HostConfig.LogConfig}}' 你的容器名

这里有一个必须记住的坑daemon.json 的日志配置只对新建容器生效,已经在跑的容器不会自动应用。要让存量容器也生效,只能重建容器(docker compose up -d --force-recreate),不能靠 restart。

方案二:在 compose 文件里显式声明(更可控)

如果你用 docker compose 管理站点,把日志配置直接写进 compose 文件更直观,也不依赖宿主机守护进程的改动:

services:
  nginx:
    image: nginx:alpine
    logging:
      driver: "json-file"
      options:
        max-size: "20m"
        max-file: "5"
    volumes:
      - ./logs:/var/log/nginx

  php:
    image: php:8.2-fpm-alpine
    logging:
      driver: "json-file"
      options:
        max-size: "20m"
        max-file: "5"

注意这里给 Nginx 单独挂了 ./logs 卷。这是一个很实用的组合策略:让日志落到宿主机目录,同时限制 json-file 的大小。这样 Nginx 的访问日志由 logrotate 管(宿主机文件),容器的 stdout 只保留启动信息和少量错误,不会暴涨。

如果你的服务不需要 docker logs 看输出,可以直接关掉:

services:
  worker:
    image: my-worker
    logging:
      driver: "none"

这对那些后台跑定时任务的容器特别有用——它们本来就不看日志,只是不断地往磁盘写。

方案三:宿主机层兜底,用 logrotate 管住漏网之鱼

前面两层都做完,理论上不会有无限增长的日志了。但现实中总有例外:忘了重建的旧容器、用 docker run 手起的一次性容器、别的服务往 /var/lib/docker 写东西。所以宿主机层的兜底仍然必要。

/etc/logrotate.d/docker-containers 写一份配置:

/var/lib/docker/containers/*/*.log {
    daily
    rotate 7
    compress
    delaycompress
    missingok
    notifempty
    copytruncate
    maxsize 100M
}

这里最关键的一个参数是 copytruncate。前面说过,Docker 持续持有日志文件句柄,标准的 logrotate「重命名 + 建新文件」方式会让 Docker 继续写进已经改名的那份文件——它感知不到文件被轮转了。copytruncate 的做法是「先复制一份,再把原文件截断为 0 字节」,容器继续往同一个 inode 写,不会丢句柄。代价是复制与截断之间的那几毫秒内可能丢日志,但个人站完全可以接受。

验证 logrotate 配置是否正确(-d 是 dry-run,不会真的轮转):

logrotate -d /etc/logrotate.d/docker-containers
# 手动强制跑一次验证效果
logrotate -vf /etc/logrotate.d/docker-containers

养成习惯:每次写完 logrotate 配置都跑一遍 -d。它会把解析过程和「哪些文件会被处理」全列出来,能立刻发现路径写错、规则没匹配上这类问题。个人站出问题时没人提醒你,上线前自测比事后救火便宜太多。

顺带治理:镜像和构建缓存也在吃磁盘

很多人以为磁盘满就是日志,实际上 Docker 的镜像层、docker build 的缓存、以及带 dangling 标记的中间层同样占地方。先看清楚再清理:

docker system df
# TYPE            TOTAL   ACTIVE   SIZE      RECLAIMABLE
# Images          24      8        6.2GB     3.1GB (50%)
# Containers      11      5        120MB     40MB
# Local Volumes   9       4        800MB     200MB
# Build Cache     60      0        2.4GB     2.4GB

这张表是分诊的核心:RECLAIMABLE 列告诉你「现在就能安全删掉多少」。针对性地清理:

# 清理未使用的构建缓存(最安全,收益通常最大)
docker builder prune

# 清理悬空镜像(没有 tag、没被引用的中间层)
docker image prune

# 清理停止的容器
docker container prune

# 一次清理所有未被使用的资源
docker system prune

一个必须警告的操作docker system prune -a 里的 -a 会删除所有没有被正在运行的容器使用的镜像,包括你本地构建但暂时没跑的自定义镜像。对个人站来说,某个包可能花了 20 分钟才 build 出来,删掉重建很心疼。执行 -a 之前先 docker images 看一眼有没有要保留的自建镜像。

还有一个更隐蔽的坑:docker volume prune 会删除所有未被容器引用的数据卷。如果你的数据库数据放在一个临时停掉容器的卷里,这条命令会直接把它删干净。数据卷清理前必须确认没有停着但需要保留数据的容器,最好先 docker volume ls 对一遍。

一个真实案例:为什么「只改了 daemon.json」没解决问题

把上面的方案串起来看,有一个很典型的过程值得完整走一遍,因为它把三个坑一次性都体现了。

某个站点的服务器磁盘告警被触发,运维(其实就是站长本人)打开 /etc/docker/daemon.json,发现里面是空的,于是加上了 max-size: 50mmax-file: 3,重启 Docker,df -h 一看磁盘还是 100%。三个原因叠在一起:

  • 重启 Docker 不会重建容器。容器在守护进程重启后按原配置恢复,日志配置依然是旧的「无限」。daemon.json 只影响此后新建的容器,存量容器必须 --force-recreate
  • 被写满的那份大日志并没有被清理。新的上限只管未来的增长,8GB 的历史文件还躺在那。而且如果直接 rm 掉它,正在运行的容器句柄还指向已删除的 inode,df 显示的空间不会释放——必须重启容器,或者用 : > 文件路径 的方式截断(这会保留 inode,立刻释放空间,且不打断进程)。
  • 写日志的根本不是被限制的那个容器。排查 du -sh /var/lib/docker/containers/*/*-json.log 才发现,占空间的是一个用 docker run 手工起、早就没人记得的容器,它不在 compose 文件里,所以 compose 里写的 logging 配置对它完全无效。

这个过程给出的教训是有普适性的:Docker 的日志治理必须三层同时做,只做一层就一定会留下缺口。daemon.json 管新建容器,compose 管声明式服务,logrotate 管宿主机上所有漏网的文件——包括那些你早就忘了的手工容器。缺任何一层,都可能出现「配置看起来加过了,磁盘照满不误」的情况。

另外补充一个截断日志的正确姿势,比 rm 安全得多:

# 立刻释放空间,且不打断运行中的容器
: > /var/lib/docker/containers/<container-id>/<container-id>-json.log

# 或者用 truncate 命令
truncate -s 0 /var/lib/docker/containers/<container-id>/<container-id>-json.log

原理是不删除 inode,只把文件长度置零。进程手里那个文件描述符依然有效,继续从偏移 0 开始写,不会报错,也不会出现「删了空间没释放」的怪现象。如果 docker logs 突然不显示历史内容了,用这个方法也会得到同样的效果——这是预期行为,不是故障。

把治理固化成日常检查

上面三层做完之后,建议再加一个每日巡检,把问题在造成故障之前拦住。一条简单但有效的磁盘告警脚本:

#!/bin/bash
THRESHOLD=85
USED=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$USED" -ge "$THRESHOLD" ]; then
    echo "磁盘使用率 ${USED}%,Top 5 容器日志:"
    du -sh /var/lib/docker/containers/*/*-json.log 2>/dev/null | sort -rh | head -5
fi

挂进 crontab,每小时跑一次,输出通过邮件或 webhook 发出来。这样等到磁盘真的满了,你已经提前几小时知道了。

回头看不难发现,Docker 磁盘写满这件事的本质不是「日志太多」,而是「默认配置假设有人会管理日志,而个人站通常没有」。Docker 的哲学是把应用打包成黑盒交付,但它默认不替你考虑长期运行的资源约束——这一点和 Typecho 默认不清理 spam 评论、Nginx 默认不限制连接数是同一类问题:所有「开箱即用」的默认值,都是为「快速跑通」设计的,不是为「跑三年」设计的。理解这一点,你就能主动去检查每一个组件「默认会不会无限增长」,而不是等它半夜把你叫醒。

Last modification:September 22nd, 2026 at 12:24 pm

Leave a Comment