容器日志体积失控:json-file 驱动的 max-size 与 max-file 配置脚本

Docker 默认使用 json-file 日志驱动,而且默认不做任何轮转。一个打印量稍大的服务,跑上两周就能把宿主机的 /var/lib/docker 撑满,然后你会在 docker ps 里看到容器状态变成 Up 但业务全挂,或者在 dmesg 里看到 No space left on device。更麻烦的是,日志文件被删掉之后磁盘空间并不一定释放——

Docker 默认使用 json-file 日志驱动,而且默认不做任何轮转。一个打印量稍大的服务,跑上两周就能把宿主机的 /var/lib/docker 撑满,然后你会在 docker ps 里看到容器状态变成 Up 但业务全挂,或者在 dmesg 里看到 No space left on device。更麻烦的是,日志文件被删掉之后磁盘空间并不一定释放——因为还有进程持有文件句柄。

这篇内容解决三件事:第一,把 max-size 与 max-file 的真实语义和容易踩的坑讲清楚;第二,给出一份可以直接在生产机上跑的巡检与整改脚本,覆盖「已经跑起来的容器」和「未来新建的容器」两种情况;第三,把整改过程中最常见的几种报错和处理办法列出来。

先把结论放前面:max-size 控制的是单个日志文件的上限,max-file 控制的是保留多少个轮转文件。所以一个容器的日志理论上限约等于 max-size × max-file,再加上驱动自身的少量元数据开销。这两个参数只能在容器创建时生效,对已存在容器 docker update 是不支持的——这是绝大多数人第一次整改失败的原因。

一、json-file 驱动的日志布局与参数真实语义

先看清楚文件长什么样,后面所有脚本都是围绕这个布局做判断的。在宿主机上执行:

docker inspect -f '{{.LogPath}}' nginx-demo
# 输出示例
# /var/lib/docker/containers/3f2a9c.../3f2a9c...-json.log

ls -lh /var/lib/docker/containers/3f2a9c.../
# -rw-r----- 1 root root  10M Oct 11 09:12 3f2a9c...-json.log
# -rw-r----- 1 root root  10M Oct 11 08:55 3f2a9c...-json.log.1
# -rw-r----- 1 root root 512K Oct 11 08:40 3f2a9c...-json.log.2

轮转文件的命名是 <容器ID>-json.log.N,其中 .1 是上一次写满的文件,数字越大越旧。max-file 设置为 3,你最多看到 .log、.log.1、.log.2 三个文件;再轮转一次,.log.2 会被删除,.log.1 变成 .log.2。

几个容易误解的点:

  • max-size 的取值是文件大小,不是行数。支持单位后缀 k、m、g,也支持 kb、mb、gb(Docker 20.10 之后对单位更宽容,但小写单字母最稳)。写成 10MB 在部分老版本上会启动失败。
  • 轮转是写满即切,不是按时切。所以一个容器如果每秒只写几行,可能几天都不会轮转一次,此时磁盘占用由写入速率决定,而不是由参数绝对值决定。
  • max-size 不能小于 8k。设成 1k 这种值,容器能启动但日志会异常,且新版 Docker 会在守护进程日志里刷警告。
  • 参数生效在容器创建时写入 HostConfig.LogConfig,之后 docker inspect 能查到但改不了。

下面这段命令可以直接看出某个容器是否配置了限制:

docker inspect -f '{{.Name}} => {{.HostConfig.LogConfig.Type}} {{.HostConfig.LogConfig.Config}}' $(docker ps -aq)

# 典型输出:
# /nginx-demo => json-file map[max-file:3 max-size:10m]
# /legacy-app => json-file map[]

map[] 就是没有配置,即默认无限制。这就是巡检脚本要抓的目标。

二、全局默认配置:daemon.json 的正确改法

逐个容器加参数不现实,标准做法是在 /etc/docker/daemon.json 里设置默认值,让新建容器自动继承。

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

注意事项:

  • 改完必须重启 docker 服务,执行 systemctl reload docker 无效,必须 systemctl restart docker。重启会停止所有容器,除非容器设置了 --restart always 或 unless-stopped,否则不会自动拉起。
  • 如果原文件已有内容,不要整个覆盖,用 jq 合并更安全:jq '. + {"log-opts":{"max-size":"50m","max-file":"5"}}' /etc/docker/daemon.json。
  • daemon.json 语法错误会导致 Docker 直接起不来,报错形如 unable to configure the Docker daemon with file /etc/docker/daemon.json: invalid character '}' looking for beginning of object key string。改完先跑 jq . /etc/docker/daemon.json 验证。
  • 这套默认值只对新创建的容器生效,老容器依然是无限制的,这是最大的坑。

另外,如果你用 systemd 的 docker.service 并且曾经在 unit 文件里用 ExecStart 追加过 --log-opt,注意两者不要冲突。daemon.json 与命令行参数同时存在时,命令行优先,容易造成「我明明改了 json 但没生效」的错觉。用 dockerd --validate --config-file=/etc/docker/daemon.json 可以校验配置。

三、巡检脚本:找出没有日志上限的容器

这是日常最需要的一段。目标:列出所有未配置 max-size 的容器,并给出它们当前的日志实际占用。

#!/usr/bin/env bash
# log-audit.sh —— 巡检未配置日志上限的容器
set -uo pipefail

DOCKER_BIN="${DOCKER_BIN:-docker}"

printf '%-28s %-12s %-10s %s\n' "CONTAINER" "LOGSIZE" "MAXSIZE" "LOGPATH"

for cid in $($DOCKER_BIN ps -aq); do
    name=$($DOCKER_BIN inspect -f '{{.Name}}' "$cid" | sed 's#^/##')
    driver=$($DOCKER_BIN inspect -f '{{.HostConfig.LogConfig.Type}}' "$cid")
    maxsize=$($DOCKER_BIN inspect -f '{{index .HostConfig.LogConfig.Config "max-size"}}' "$cid" 2>/dev/null || true)
    logpath=$($DOCKER_BIN inspect -f '{{.LogPath}}' "$cid")

    # 非 json-file 驱动没有 LogPath,直接跳过
    if [ -z "$logpath" ] || [ "$logpath" = "<no value>" ]; then
        printf '%-28s %-12s %-10s %s\n' "$name" "-" "-" "driver=$driver"
        continue
    fi

    logsizemb=$(du -sm "$logpath" 2>/dev/null | awk '{print $1}')
    [ -z "$maxsize" ] && maxsize="UNLIMITED"

    printf '%-28s %-12s %-10s %s\n' "$name" "${logsizemb:-0}M" "$maxsize" "$logpath"
done

几个实现细节值得说明:

  • -f '{{index .HostConfig.LogConfig.Config "max-size"}} 是取 map 里不存在的键,Docker 会输出 <no value> 而不是空串,所以后面用 -z 判断要小心。更稳妥的写法是在 shell 里再做一次过滤:[ "$maxsize" = "<no value>" ] && maxsize=""。上面的脚本里留了 || true 兜底,实际投产时建议补齐这一行。
  • du -sm 统计的是整个 LogPath 所在文件的大小。如果要统计包含轮转文件在内的总占用,用 du -cm ${logpath}*,但它会跨文件累加,输出格式需要 tail -1 处理。
  • docker inspect -f 对已停止容器同样有效,脚本里用 ps -aq 而不是 ps -q,就是为了把停止的容器也扫出来——它们重启后照样写日志。

把这段包成 Cron,每天输出到报表,就能持续发现「新增的无限制容器」。

四、存量整改:三种可选路径与脚本实现

已经跑着的容器改不了 HostConfig,只能重建。根据环境不同,有三条路径:

路径 A:docker run 启动的容器,直接重建

先记录下来容器的完整启动参数,再删掉重跑。手工抄参数容易漏,用 runlike 或 docker inspect 还原更靠谱。下面给一个不依赖第三方工具的还原脚本:

#!/usr/bin/env bash
# rebuild-with-loglimit.sh  [max-size] [max-file]
set -euo pipefail

C="$1"
MAXSIZE="${2:-50m}"
MAXFILE="${3:-5}"

docker inspect -f '{{json .Config}}' "$C" > "/tmp/${C}.config.json"
docker inspect -f '{{json .HostConfig}}' "$C" > "/tmp/${C}.hostconfig.json"
docker inspect -f '{{json .NetworkSettings.Networks}}' "$C" > "/tmp/${C}.networks.json"

# 停掉旧容器但先不删,确认新容器起来后再手动 rm
docker stop "$C"
docker rename "$C" "${C}-old"

# 这里建议用人工或编排工具重建,脚本只负责把参数固化下来
echo "旧容器已改名为 ${C}-old,参数快照在 /tmp/${C}.*.json"
echo "重建时务必追加: --log-opt max-size=${MAXSIZE} --log-opt max-file=${MAXFILE}"

不建议脚本自动删容器,因为 --network、--mount、--device 这些参数从 inspect 还原成命令行很容易出错(特别是匿名卷和 host 网络)。把快照落盘、用新参数重建、验证日志正常写入之后,再执行 docker rm ${C}-old,是更稳的流程。

路径 B:docker compose 管理的容器,改 yml 后 up 重建

在 compose.yaml 的服务定义里加:

services:
  api:
    image: registry.example.com/api:1.4.2
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "5"

然后执行 docker compose up -d。注意不能只 restart,restart 不会重建容器,配置不会变。Compose 场景下可以用 docker compose config 先校验合并后的配置是否正确。

路径 C:只清理当前日志,不重建

生产环境窗口期不允许重建时,可以先腾空间,但要记住这只是止血。清理的正确做法是截断而不是删除:

# 错误做法:rm 之后 df 不掉,句柄未释放
# rm -f /var/lib/docker/containers//-json.log

# 正确做法:截断,句柄保留,瞬时释放空间
: > /var/lib/docker/containers//-json.log

# 或者用 truncate 指定清零
truncate -s 0 /var/lib/docker/containers//-json.log

如果已经误删了,用 lsof +L1 | grep json.log 找到持有句柄的进程,确认之后重启对应容器,空间才会真正释放。注意 lsof 默认看不到 deleted 文件,必须加 +L1。

批量截断脚本,带保护:

#!/usr/bin/env bash
# truncate-big-logs.sh —— 截断超过阈值的容器日志
set -uo pipefail
THRESHOLD_MB="${1:-1024}"   # 默认 1G

root="/var/lib/docker/containers"
[ -d "$root" ] || { echo "not found: $root"; exit 1; }

find "$root" -maxdepth 2 -name '*-json.log' -type f -size "+${THRESHOLD_MB}M" -print0 |
while IFS= read -r -d '' f; do
    sz=$(du -m "$f" | awk '{print $1}')
    truncate -s 0 "$f" && echo "truncated ${sz}M -> $f"
done

find -size +1024M 的单位后缀里 M 表示 1024×1024 字节,+ 表示大于。这个脚本不要放进高频 Cron,否则会把排查问题的现场抹掉,建议只在大促前或磁盘水位告警时人工触发。

五、验证与常见报错处理

重建之后怎么确认真的生效?两条命令:

docker inspect -f '{{.HostConfig.LogConfig}}' api
# {json-file map[max-file:5 max-size:50m]}

# 持续写入一段时间后看文件数量是否受控
ls -1 /var/lib/docker/containers// | grep -c 'json.log'
# 期望 <= max-file

下面是实操中真的会遇到的报错:

报错信息 原因 处理
Error response from daemon: InvalidConfig: Unknown log opt 'max-size' for json-file log driver 老版本 Docker(1.8 之前)或 log-driver 被写成了别的驱动 确认 docker info | grep "Logging Driver" 输出为 json-file,升级 Docker
json: cannot unmarshal string into Go struct field LogConfig.Config of type map[string]string daemon.json 里 log-opts 写成了数组或其他类型 log-opts 必须是 JSON 对象,键和值都用双引号
Error response from daemon: conflicting options: max-file and --log-opt max-file daemon.json 与命令行同时指定,值不一致 删掉命令行上的 --log-opt,统一走 daemon.json
docker update --log-opt max-size=50m 返回 UpdateConfig: log options cannot be changed docker update 不支持改日志配置,这是设计如此 只能重建容器
容器内看不到日志、docker logs 输出为空,且报 configured logging driver does not support reading 容器被配置成了 journald、syslog、fluentd 等不支持读取的驱动 改用 json-file 或 local 驱动,后两者都支持 docker logs
磁盘告警但 du -sh /var/lib/docker 与 df -h 差距很大 存在被 rm 但句柄未释放的文件 lsof +L1 | grep -i deleted 定位,重启对应进程

另外值得关注的是 local 驱动。Docker 18.09 起提供 local 日志驱动,默认就是 max-size: 20m、max-file: 5,并且写入格式更紧凑(不是逐行 JSON 文本)。它同样支持 docker logs,在不需要外部采集的场景下,比 json-file 更省空间。切换方式:

{
  "log-driver": "local",
  "log-opts": {
    "max-size": "50m",
    "max-file": "5"
  }
}

但要注意:local 驱动写的是二进制块文件(*-json.log 变成 local-logs/container.log 目录结构),如果你们有直接用 tail -f 读 /var/lib/docker/containers/*/*-json.log 的采集脚本,切换后会失效,需要改成走 docker logs --follow 或 journald。这个改动的影响面比想象中大,切换前务必排查一遍采集链路。

小结

把这件事做完,落地的其实就三件:

  1. daemon.json 里设置 log-opts 默认值(max-size 与 max-file),重启 dockerd,覆盖未来所有新容器。
  2. 用巡检脚本定期扫出 LogConfig.Config 为空的容器,纳入整改清单,通过 Compose 改 yml 或重建 run 容器来落地,覆盖存量。
  3. 加一条磁盘水位告警,定向监控 /var/lib/docker,配合截断脚本做应急止血;出现空间不释放先查 lsof +L1。

参数选型上,大多数 HTTP 服务的经验值是 max-size 取 50m 到 100m、max-file 取 3 到 5,单容器控制在 300MB 到 500MB 之间;日志量特别大的采集型 sidecar 建议直接换成 local 或交给 fluentd 等外部驱动,不要让宿主机磁盘承担缓冲。真正要防的从来不是单个容器写多大,而是「没人知道它没配上限」。

FAQ

Q:能不能不重建容器就改 max-size?
A:不能。日志配置在容器创建时固化进 HostConfig.LogConfig,docker update 明确不支持。变通方案只有截断现有日志止血,最终仍需重建。

Q:max-size × max-file 就是磁盘占用上限吗?
A:近似是,但略高一点。轮转文件之外还有少量驱动元数据,且 max-file 为 1 时只有一个文件、轮转时直接截断重写,实际占用就是 max-size 附近。docker system df -v 里的 LOGS 列可以核对。

Q:截断日志是否会影响 docker logs?
A:会丢失截断之前的历史内容,截断之后的新日志仍能正常通过 docker logs 读取,不会破坏容器运行。docker logs --tail 只依赖文件末尾的读取位置。

Q:docker compose restart 会应用新的 logging 配置吗?
A:不会。restart 只重启容器进程,配置不变。必须 docker compose up -d 触发重建(检测到配置差异时会重建)。

Q:Docker Desktop(macOS/Windows)上这套配置有效吗?
A:有效。日志写在 Docker 虚拟机的文件系统里,路径不同(需要 docker run --rm -it --privileged --pid=host justincormack/nsenter1 进 VM 查看),但 daemon.json 的位置在 Docker Desktop 的 Settings → Docker Engine 里编辑,参数语义一致。

Q:max-size 设太小会有什么后果?
A:轮转过于频繁,短时间内大量产生 .1、.2 的重命名操作,增加 inode 与 IO 压力;同时单次轮转窗口内可回溯的日志变少,排障困难。低于 8k 属于未定义行为,不要尝试。