一、问题背景
公司内部的 CI/CD 构建节点是一台 2C4G 的云主机,上面跑着 GitLab Runner,所有流水线任务都通过 docker run 临时拉起容器执行编译、单元测试和镜像打包。这台机器已经稳定运行了半年多,运维也基本是"放养"状态——除了偶尔手动清理一下磁盘,没人专门维护。某天上午,开发同学突然在群里反馈:多条流水线同时失败,报错信息五花八门,有的说"无法拉取镜像",有的说"容器启动超时"。我登录上去一看,连 docker ps 都开始变得卡顿,这才意识到事情没那么简单。
二、故障现象
最直观的现象是:几乎所有新建容器的操作都会失败。无论是 Runner 自动拉起的构建容器,还是我手动执行的 docker run hello-world,都返回一个类似的错误:
|
|
更诡异的是,报错明明说 “no space left on device(设备上没有剩余空间)",但 df -h / 显示的磁盘使用率只有 63%,还有大把空间。开发同学一度怀疑是 Docker 引擎抽风,甚至有人建议直接重启机器"试试看”。
此外,已有的运行中容器并没有崩溃,业务服务照常对外提供访问,只是任何"新建/重启容器"的动作全部失败;docker images 列表里堆积了上百个 <none> 的中间镜像,磁盘肉眼可见地越来越慢。
三、排查过程
第一步,我本能地按"磁盘满"的思路去查。执行 df -h:
|
|
空间明明还剩 18G,所以 “no space left on device” 在这里是假象。结合经验,Linux 上除了字节数,还有另一个隐藏上限——inode 数量。一个文件系统能创建的文件总数受 inode 限制,即使字节没满,inode 耗尽同样会报这个错。
第二步,用 df -i 验证猜测:
|
|
果然,inode 使用率 100%,只剩 10 个可用。问题定性了:这是 inode 耗尽,不是磁盘空间耗尽。
第三步,定位是哪个目录吃掉了 inode。因为 Docker 的根目录默认在 /var/lib/docker,我写了个循环统计子目录的文件数:
|
|
输出显示 /var/lib/docker/overlay2 一个目录就占了 320 多万个文件,几乎吃光了全部 inode。overlay2 是 Docker 的存储驱动,每一层镜像、每个容器的可写层都被拆成一个个真实的小文件存放在这里。
第四步,进一步分析 overlay2 内部构成。用 docker system df 看到:
|
|
镜像和"僵尸容器"(已退出但未删除)数量惊人。再钻进 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 2 3docker container prune -f docker image prune -a -f docker system prune -a --volumes -f执行后
df -i的 IFree 从 10 回升到 290 多万,inode 使用率降到 12%。 -
重启 Docker 守护进程让状态归一:
1systemctl restart docker -
临时把 Docker 根目录迁到 inode 更充裕的磁盘(可选,针对根盘 inode 本就偏小的云主机):挂载一块数据盘,修改
daemon.json的"data-root": "/data/docker"后重启。 -
验证:手动
docker run --rm hello-world成功,Runner 流水线逐步恢复正常。
五、根因分析
表面看是"磁盘满了",本质是文件系统 inode 资源被海量小文件耗尽。overlay2 存储驱动把每个镜像层和容器可写层都映射成宿主机的真实文件,构建型任务(编译、打包)会在容器内产生大量细小文件,这些文件在容器退出后若不被清理,就以"僵尸容器 + dangling 镜像"的形式永久占用 inode。再加上日志未配置轮转,雪上加霜。云主机默认文件系统的 inode 总数往往偏小(本例仅 327 万),在 CI 场景下极容易被打满。
六、预防措施
-
配置日志轮转:在
daemon.json中加入1 2"log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "3" } -
建立定期清理机制:用 cron 每天凌晨执行
docker system prune -a -f --filter "until=72h",只保留近三天的资源。 -
inode 监控告警:把
df -i接入监控(Node Exporter + Prometheus 或云监控),inode 使用率超 80% 即告警,比磁盘空间告警更早发现隐患。 -
构建阶段减少可写层文件:CI 中尽量用多阶段构建(multi-stage build),把编译产生的中间文件留在构建阶段、不带入最终镜像;构建缓存挂载到宿主机独立大目录而非容器可写层。
-
给 Docker 分配独立数据盘:新建大 inode 文件系统(
mkfs.ext4 -i 8192调高密度)专供/var/lib/docker,从容量上根除风险。
七、总结
这次故障给我最深的教训是:排查 “no space left on device” 不能只看 df -h,一定要补一眼 df -i。在容器、CI、日志这类"产生海量小文件"的场景里,inode 耗尽比磁盘字节耗尽更常见、更隐蔽。把日志轮转、定期 prune、inode 监控三件事做成标准动作,基本可以杜绝这类问题再次发生。