Docker容器日志占满磁盘怎么解决?日志轮转与容量限制配置

凌晨三点告警响了:/var/lib/docker 分区使用率 100%,业务写入失败报 no space left on device。登上机器一查,某个 Java 容器因为一个打印堆栈的死循环,三天写了 200GB 日志。这类事故在 Docker 环境里极其常见,根源在于一个容易被忽略的默认行为:Docker 默认的日志驱动 json-file 不做任何轮转,也没有大小上限,日志会一直写到磁盘写满为止。本文讲清楚如何快速止损、如何给单个容器和全局配置日志轮转、以及怎么建立长效的容量管控机制。

一、确认磁盘到底被谁吃了

不要一上来就删文件。先确认是日志、镜像还是容器可写层占了空间,三者的处理方式完全不同。

# 1. 总体镜像、容器、卷、构建缓存占用概览
docker system df

# 详细到每个对象
docker system df -v

# 2. 确认 Docker 数据根目录(非默认路径时很重要)
docker info --format '{{.DockerRootDir}}'
# 通常为 /var/lib/docker;rootless 模式为 ~/.local/share/docker

# 3. 逐层定位大目录
du -xh --max-depth=1 /var/lib/docker 2>/dev/null | sort -h
du -xh --max-depth=1 /var/lib/docker/containers 2>/dev/null | sort -h

# 4. 找出超过 100MB 的日志文件(-json.log 为默认 json-file 驱动产物)
find /var/lib/docker/containers -name '*-json.log' -size +100M -exec ls -lh {} \; | sort -k5 -h

# 5. 汇总所有容器日志总大小
find /var/lib/docker/containers -name '*-json.log' -printf '%s\n' | \
  awk '{s+=$1} END {printf "总日志大小: %.2f GB\n", s/1024/1024/1024}'

# 6. 查看某个容器当前的日志驱动与日志路径
docker inspect <容器> --format '{{.HostConfig.LogConfig.Type}}'
docker inspect <容器> --format '{{.LogPath}}'
# 典型路径:/var/lib/docker/containers/<容器完整ID>/<容器完整ID>-json.log

注意区分 du 和 df 的差异:如果容器日志文件被删除但进程仍持有句柄,du 看不到而 df 仍显示占满。用下面命令找出这种"已删除但未释放"的文件:

# 查找被删除但仍被进程占用的文件(第 7 列为 (deleted))
lsof +L1 2>/dev/null | grep deleted
lsof 2>/dev/null | grep 'containers.*json.log'

# 这种情况下只能重启容器释放句柄,或者用 truncate 清空而非 rm
truncate -s 0 /proc//fd/   # 极端情况下的救命手段

二、紧急止损:在线清空日志而不停容器

磁盘已满、业务还在跑,这时候最怕的就是 rm -f。不要直接删除 json.log 文件:Docker 守护进程持有该文件的写句柄,删除后空间不会立即释放,而且容器在重启前会一直往已删除的 inode 里写,导致 df 显示占用 100% 但 du 找不到文件,排查难度陡增。

正确做法是用 truncate 把文件截断为 0,文件 inode 不变,Docker 继续正常写入:

# 清空单个容器日志(推荐方式)
truncate -s 0 $(docker inspect --format='{{.LogPath}}' <容器名>)

# 批量清空所有运行中容器的日志
docker ps -q | while read cid; do
  lp=$(docker inspect --format='{{.LogPath}}' "$cid")
  [ -n "$lp" ] && [ -f "$lp" ] && truncate -s 0 "$lp"
done

# 清理完先确认空间已释放
df -h /var/lib/docker

# 如果不想全部清空,只截断某容器最后 100MB(保留近期日志用于排查)
# 思路:把文件按偏移量拷贝后截断,这里推荐先备份再清空
cp $(docker inspect --format='{{.LogPath}}' <容器>) /tmp/app.log.bak
truncate -s 0 $(docker inspect --format='{{.LogPath}}' <容器>)
tail -1000 /tmp/app.log.bak

清空之前最好先看一眼日志内容,确认是什么在刷屏,否则日志清理只是把问题往后推:

# 看最近的错误高频模式
docker logs --tail 200 --timestamps <容器> 2>&1 | sort | uniq -c | sort -rn | head -20

# 按秒统计日志速率,量化日志量(例如每秒输出多少行)
docker logs --tail 10000 --timestamps <容器> 2>&1 | awk '{print $2}' | cut -d'.' -f1 | uniq -c | tail -20

三、根本解决:配置日志轮转

止损之后必须配置轮转,否则几天后会原样复发。有两种作用范围:全局(对新创建容器生效)和单容器(优先级最高)。

3.1 全局配置(推荐,一次配好所有新容器)

编辑宿主机上的 /etc/docker/daemon.json(不存在则新建):

cat > /etc/docker/daemon.json <<'EOF'
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "50m",
    "max-file": "3",
    "compress": "true"
  }
}
EOF

# 校验 JSON 合法性(这一步能挡掉 90% 的 Docker 启动失败)
python -m json.tool /etc/docker/daemon.json

# 重载配置并重启守护进程
systemctl daemon-reload
systemctl restart docker

# 确认新容器已应用配置
docker run -d --name test-log nginx:1.25
docker inspect test-log --format '{{json .HostConfig.LogConfig}}'

参数说明:

  • max-size:单个日志文件的触发轮转阈值,单位支持 k / m / g,不带单位会被当作字节,这是个常见踩坑点。
  • max-file:保留的历史文件数量,必须大于等于 1,默认值就是 1(即不保留历史,只留当前文件)。
  • compress:轮转后的历史文件用 gzip 压缩,对文本日志通常能省 80%-95% 空间。注意当前正在写的文件不会被压缩。

容量计算公式很简单:单容器日志占用上限 ≈ max-size × max-file。上面配置即每个容器最多约 50m × 3 = 150MB。据此可以反推:如果宿主机给 Docker 预留 10GB 空间,跑 20 个容器,那么每个容器的上限应控制在 500MB 以内比较安全。

3.2 单容器配置(覆盖全局,优先级更高)

# docker run 时指定
docker run -d --name app \
  --log-driver=json-file \
  --log-opt max-size=10m \
  --log-opt max-file=5 \
  --log-opt compress=true \
  nginx:1.25

# docker-compose.yml 中的写法
# services:
#   app:
#     image: nginx:1.25
#     logging:
#       driver: "json-file"
#       options:
#         max-size: "10m"
#         max-file: "5"
#         compress: "true"

# Docker Swarm service 写法
docker service update --log-driver json-file \
  --log-opt max-size=10m --log-opt max-file=5 web_app

3.3 日志驱动选型

驱动是否支持轮转存储位置适用场景
json-file(默认)支持,需配 max-size/max-file/var/lib/docker/containers//-json.log通用,单机自建
local默认自动轮转,无需配 size/var/lib/docker/containers//local-logs/只想落盘、不走集中采集
journald由 systemd-journald 管理/var/log/journal/已用 journalctl 的体系
syslog不支持 Docker 侧轮转远端 syslog 服务器已有 syslog 基础设施
fluentd / gelf不支持远端日志服务ELK / EFK 集中式日志
none不适用不落盘日志通过 stdout 之外途径输出

如果日志已经接入 ELK/EFK 这类集中式系统,本地只是临时缓冲,强烈建议把 max-size 调到很小(如 10m、max-file 2),因为本地日志的留存价值很低,纯粹是磁盘负担。

# 使用 journald 驱动时的查看方式完全不同
docker run -d --log-driver=journald --name app nginx
journalctl CONTAINER_NAME=app -f
journalctl -u docker --since "1 hour ago"

# journald 自身的容量限制也要配(/etc/systemd/journald.conf)
# SystemMaxUse=2G  SystemKeepFree=15%  MaxRetentionSec=7day
mkdir -p /etc/systemd/journald.conf.d
cat > /etc/systemd/journald.conf.d/00-limit.conf <<'EOF'
[Journal]
SystemMaxUse=2G
SystemKeepFree=15%
MaxRetentionSec=7day
EOF
systemctl restart systemd-journald

3.5 日志暴涨的根因治理

轮转只是限定了上限,真正让磁盘不再告警的是解决"为什么会产生这么多日志"。实践中最常见的四类根因,按出现频率排序如下:

  • 应用层的异常堆栈循环。代码里捕获异常后在同一处反复重试并打印完整堆栈,一次失败可能输出几十 KB。典型特征:日志里同一个 Exception 堆栈重复出现,且时间戳密集。这类问题需要与开发协同修复,运维侧的临时手段是把该服务的日志级别调到 WARN 或 ERROR。
  • 健康检查与访问日志打满。Nginx/Envoy 的 access log、K8s 的存活探测日志,在高 QPS 下每小时可达数 GB。建议把访问类日志单独输出到挂载卷或直接旁路到采集端,不要让它们进入 stdout。
  • 日志级别配置过低。生产环境跑着 DEBUG 级别,框架会把每一条 SQL、每一次 RPC 全部打印。上线前应统一检查配置中心的日志级别。
  • 容器反复重启导致日志叠加。容器正常重启时日志会被保留(旧文件 container-stopped 后仍占用空间),频繁重启的容器即使单条日志不多,累积起来也很可观。

一个实用的容量规划口径:按"每秒日志行数 × 平均单行字节数 × 86400 秒"估算单容器日增量,再乘以保留天数,就能得到合理的 max-size × max-file 组合。例如某项服务日志速率为 20 行/秒、单行平均 500 字节,日增量约 864MB,若只保留 1 天,则 max-size 设 100m、max-file 设 10 比较合适;若希望保留 3 天,则应改用 journald 或远端日志服务,而不是继续放大本地文件。

3.6 Kubernetes 环境的对应配置

K8s 节点上容器日志同样会写满磁盘,配置位置在 kubelet 而非 Docker:

# kubelet 配置(新版用 config 文件:/var/lib/kubelet/config.yaml)
cat /var/lib/kubelet/config.yaml | grep -A5 'containerLog'
# containerLogMaxSize: 50Mi
# containerLogMaxFiles: 5

# 旧版 kubelet 启动参数
ps aux | grep kubelet | grep -oE '\-\-container-log-max-[a-z]+=[^ ]+'
# --container-log-max-size=50Mi --container-log-max-files=5

# K8s 节点日志路径同样在 containers 目录下
ls /var/lib/docker/containers/          # Docker 运行时
ls /var/lib/containerd/                  # containerd 运行时

# 查看某 Pod 的日志文件大小
du -sh /var/log/pods/*/*/*.log 2>/dev/null | sort -h | tail -10

# 修改后重启 kubelet
systemctl daemon-reload
systemctl restart kubelet
systemctl status kubelet

K8s 还有一个重要机制:kubelet 会按 imageGCHighThresholdPercent(默认 85%)和 imageGCLowThresholdPercent(默认 80%)自动清理镜像,以及 evictionHard 中的 nodefs.available(默认 10%)触发 Pod 驱逐。但这只处理镜像和驱逐 Pod,不会帮你轮转已有容器的日志文件,所以日志上限仍必须配。

四、长效治理:脚本 + 监控 + 定期巡检

  1. 为所有新容器设置全局默认值:/etc/docker/daemon.json 配置 max-size 与 max-file。
  2. 对日志量大的容器(Java 堆栈、Nginx 访问日志)单独调小 max-size 或改用 volume 挂载宿主机日志目录统一管理。
  3. 存量容器必须重建才能应用新的 log-opts;重建前先确认 docker-compose.yml 里也写好了 logging 配置。
  4. 编写定时巡检脚本,每天输出日志占用 Top 10 的容器。
  5. 给 /var/lib/docker 所在分区配置监控告警,建议阈值:使用率 > 80% 告警,> 90% 严重告警。
  6. 考虑把 /var/lib/docker 挂到独立分区或独立磁盘(通过 daemon.json 的 data-root 指定),避免日志写满影响根分区。
  7. 定期执行 docker system prune 清理停止的容器、无用网络和悬空镜像。
# 巡检脚本:列出日志占用 Top 10 容器
cat > /usr/local/bin/docker-log-report.sh <<'EOF'
#!/bin/bash
docker ps -a --format '{{.Names}}\t{{.ID}}' | while IFS=$'\t' read name cid; do
  lp=$(docker inspect --format='{{.LogPath}}' "$cid" 2>/dev/null)
  [ -z "$lp" ] || [ ! -f "$lp" ] && continue
  sz=$(stat -c %s "$lp" 2>/dev/null || echo 0)
  echo "$((sz/1024/1024))MB\t$name"
done | sort -rn | head -10
EOF
chmod +x /usr/local/bin/docker-log-report.sh
/usr/local/bin/docker-log-report.sh

# 加入 crontab,每天 8 点巡检
# 0 8 * * * /usr/local/bin/docker-log-report.sh | mail -s "Docker日志巡检" ops@example.com

# 定期清理无用对象(-f 不提示,生产环境建议先不用 -a)
docker system prune -f
# 谨慎使用:下面的命令会删除所有未被使用的镜像
docker system prune -a -f
# 如需把 Docker 数据目录迁到独立磁盘,修改 daemon.json
# { "data-root": "/data/docker" }

五、建立日志容量规范

单点修好一台机器价值有限,真正见效的是把规范写进交付标准和镜像构建流程。建议按下面三档制定基线:

容器类型max-sizemax-file单容器上限说明
核心业务服务50m3约 150MB需保留少量现场用于排障
访问类日志(Nginx/Envoy)20m2约 40MB已旁路到采集端,本地仅兜底
批处理/定时任务100m2约 200MB单次输出量大,执行完应归档

告警阈值建议设置为两个层级:宿主机 /var/lib/docker 分区使用率超过 80% 触发提醒,超过 90% 触发严重告警并自动执行 docker system prune。同时把"单容器日志文件 > 1GB"也做一项巡检规则——正常情况下配置了轮转就不该出现这么大的文件,一旦出现说明要么该容器没生效配置,要么它在无视轮转机制往挂载卷里写,两者都需要人工介入确认。

小结

Docker 容器日志占满磁盘这件事,技术手段非常简单——配好 max-size 和 max-file 就解决了九成问题——难的是把它作为规范固化下来,让每一台新交付的机器都默认带上。记住核心公式:单容器日志上限 ≈ max-size × max-file,据此按宿主机磁盘容量和容器数量反推参数。紧急情况用 truncate 清空而不是 rm,避免句柄未释放导致空间不回收。

避坑建议:

  • 永远不要 rm -f 容器 json.log,用 truncate -s 0;删除后如遇句柄未释放,仍需重启容器。
  • max-size 必须带单位(k/m/g),只写数字会被当成字节数。
  • max-file 要 ≥ 1,默认值为 1,很多人误以为配了 max-size 就会自动多留几个文件。
  • 改 daemon.json 只影响新建容器,存量容器必须 docker rm 后重建,且 compose 文件要同步改。
  • daemon.json 改完必须校验 JSON 合法性,语法错误会让 Docker 守护进程直接起不来。
  • 已经用 ELK/EFK 的集群,本地日志要尽量小,否则一份日志存两处,纯属浪费。
  • 把 /var/lib/docker 单独分区,是最省心的兜底方案,日志写满也不会拖垮根分区和系统进程。
  • K8s 环境配 kubelet 的 containerLogMaxSize,配 Docker 的 daemon.json 对 Pod 无效。

延伸阅读:日志问题解决后,容器网络的连通性与 DNS 配置是另一类高频故障,两者常常同时爆发,建议一并纳入巡检。