Docker 宿主机 overlay2 inode 耗尽致容器批量创建失败的定位

CI 构建节点新建容器全部报 no space left on device,但 df -h 显示空间充足,最终定位为 overlay2 海量小文件耗尽 inode 的排查实录。

一、问题背景

公司内部的 CI/CD 构建节点是一台 2C4G 的云主机,上面跑着 GitLab Runner,所有流水线任务都通过 docker run 临时拉起容器执行编译、单元测试和镜像打包。这台机器已经稳定运行了半年多,运维也基本是"放养"状态——除了偶尔手动清理一下磁盘,没人专门维护。某天上午,开发同学突然在群里反馈:多条流水线同时失败,报错信息五花八门,有的说"无法拉取镜像",有的说"容器启动超时"。我登录上去一看,连 docker ps 都开始变得卡顿,这才意识到事情没那么简单。

二、故障现象

最直观的现象是:几乎所有新建容器的操作都会失败。无论是 Runner 自动拉起的构建容器,还是我手动执行的 docker run hello-world,都返回一个类似的错误:

1
docker: Error response from daemon: mkdir /var/lib/docker/overlay2/xxxxxxx: no space left on device.

更诡异的是,报错明明说 “no space left on device(设备上没有剩余空间)",但 df -h / 显示的磁盘使用率只有 63%,还有大把空间。开发同学一度怀疑是 Docker 引擎抽风,甚至有人建议直接重启机器"试试看”。

此外,已有的运行中容器并没有崩溃,业务服务照常对外提供访问,只是任何"新建/重启容器"的动作全部失败;docker images 列表里堆积了上百个 <none> 的中间镜像,磁盘肉眼可见地越来越慢。

三、排查过程

第一步,我本能地按"磁盘满"的思路去查。执行 df -h

1
2
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda1        50G   32G   18G  63% /

空间明明还剩 18G,所以 “no space left on device” 在这里是假象。结合经验,Linux 上除了字节数,还有另一个隐藏上限——inode 数量。一个文件系统能创建的文件总数受 inode 限制,即使字节没满,inode 耗尽同样会报这个错。

第二步,用 df -i 验证猜测:

1
2
Filesystem       Inodes   IUsed    IFree IUse% Mounted on
/dev/vda1       3276800 3276790       10  100% /

果然,inode 使用率 100%,只剩 10 个可用。问题定性了:这是 inode 耗尽,不是磁盘空间耗尽。

第三步,定位是哪个目录吃掉了 inode。因为 Docker 的根目录默认在 /var/lib/docker,我写了个循环统计子目录的文件数:

1
2
3
for d in /var/lib/docker/*; do
  echo "$d: $(find "$d" -xdev -type f 2>/dev/null | wc -l)"
done

输出显示 /var/lib/docker/overlay2 一个目录就占了 320 多万个文件,几乎吃光了全部 inode。overlay2 是 Docker 的存储驱动,每一层镜像、每个容器的可写层都被拆成一个个真实的小文件存放在这里。

第四步,进一步分析 overlay2 内部构成。用 docker system df 看到:

1
2
3
4
5
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          214       12        28.4GB    26.1GB (91%)
Containers      187       3         1.2GB     1.1GB (95%)
Local Volumes   9         2         0         0
Build Cache     0         0         0         0

镜像和"僵尸容器"(已退出但未删除)数量惊人。再钻进 overlay2 看单目录文件数分布,发现大量 <hash>/diff 目录每个都装着几万甚至十几万个小文件——这正是那些 Java/Maven 项目在构建时往容器可写层写入海量 .class、依赖 jar、源码文件留下的痕迹。

第五步,确认加剧因素——日志。检查 /etc/docker/daemon.json,发现根本没有配置 json-file 的日志轮转(max-size / max-file)。这意味着每个容器的标准输出日志都在 /var/lib/docker/containers/<id>/<id>-json.log 里无限增长,虽然单个日志是"大文件"对 inode 贡献不大,但叠加海量中间层小文件,最终把 inode 彻底打满。

至此根因链条清晰:长期不清理 + 构建任务频繁创建容器/镜像 + 日志不轮转,导致 overlay2 累积了数百万个小文件,耗尽 inode,新容器再也无法创建目录。

四、解决方案

当务之急是腾出 inode,让 Docker 能继续工作:

  1. 清理已退出容器与悬空镜像(释放海量小文件):

    1
    2
    3
    
    docker container prune -f
    docker image prune -a -f
    docker system prune -a --volumes -f
    

    执行后 df -i 的 IFree 从 10 回升到 290 多万,inode 使用率降到 12%。

  2. 重启 Docker 守护进程让状态归一:

    1
    
    systemctl restart docker
    
  3. 临时把 Docker 根目录迁到 inode 更充裕的磁盘(可选,针对根盘 inode 本就偏小的云主机):挂载一块数据盘,修改 daemon.json"data-root": "/data/docker" 后重启。

  4. 验证:手动 docker run --rm hello-world 成功,Runner 流水线逐步恢复正常。

五、根因分析

表面看是"磁盘满了",本质是文件系统 inode 资源被海量小文件耗尽。overlay2 存储驱动把每个镜像层和容器可写层都映射成宿主机的真实文件,构建型任务(编译、打包)会在容器内产生大量细小文件,这些文件在容器退出后若不被清理,就以"僵尸容器 + dangling 镜像"的形式永久占用 inode。再加上日志未配置轮转,雪上加霜。云主机默认文件系统的 inode 总数往往偏小(本例仅 327 万),在 CI 场景下极容易被打满。

六、预防措施

  1. 配置日志轮转:在 daemon.json 中加入

    1
    2
    
    "log-driver": "json-file",
    "log-opts": { "max-size": "100m", "max-file": "3" }
    
  2. 建立定期清理机制:用 cron 每天凌晨执行 docker system prune -a -f --filter "until=72h",只保留近三天的资源。

  3. inode 监控告警:把 df -i 接入监控(Node Exporter + Prometheus 或云监控),inode 使用率超 80% 即告警,比磁盘空间告警更早发现隐患。

  4. 构建阶段减少可写层文件:CI 中尽量用多阶段构建(multi-stage build),把编译产生的中间文件留在构建阶段、不带入最终镜像;构建缓存挂载到宿主机独立大目录而非容器可写层。

  5. 给 Docker 分配独立数据盘:新建大 inode 文件系统(mkfs.ext4 -i 8192 调高密度)专供 /var/lib/docker,从容量上根除风险。

七、总结

这次故障给我最深的教训是:排查 “no space left on device” 不能只看 df -h,一定要补一眼 df -i。在容器、CI、日志这类"产生海量小文件"的场景里,inode 耗尽比磁盘字节耗尽更常见、更隐蔽。把日志轮转、定期 prune、inode 监控三件事做成标准动作,基本可以杜绝这类问题再次发生。

使用 Hugo 构建
主题 StackJimmy 设计