又一次磁盘使用率告警,还是那台机器,还是那个系统盘。上周刚把/var/lib/docker从一块几十GB的系统盘迁移到数据盘,按当时的计算至少能腾出一半空间,结果没过几天告警平台又弹了“磁盘使用率超过90%”的通知。第一反应是“迁移没生效?”,但df -h一看,数据盘确实挂载正常、空间也确有释放,那剩下的占用在哪?
顺着du一层一层翻下去,最后目标锁定在/var/lib/containerd。这个目录平时几乎没人会注意到,但它悄无声息地吃掉了系统盘几十GB。更关键的是,它根本不归docker命令管,普通迁移/var/lib/docker的操作对它一点影响都没有。这篇就完整记录那次排查思路、根因分析和最终的根治方案,给同样被磁盘空间问题困扰的运维朋友一个可复用的参考。
1. 告警复现:Docker 数据目录迁走之后,空间为什么还在往下掉
1.1 之前的第一次迁移做了什么
先说这台机器的情况。它跑的是一套内部测试环境的容器集群,常年有上百个容器在轮换部署,镜像拉取频率很高。系统盘原本只有 40GB,/var/lib/docker老早就占了 30GB 上下,告警阈值一到就得手工清理。为了根治,上个月我们把 Docker 的数据根目录整体迁到了一块 500GB 的数据盘上,操作流程也很常规:
systemctl stop docker rsync -aH --delete /var/lib/docker/ /data/docker/ mv /var/lib/docker /var/lib/docker.bak mkdir -p /var/lib/docker mount --bind /data/docker /var/lib/docker systemctl start docker重启后docker info显示Docker Root Dir: /var/lib/docker,实际数据落到数据盘,df -h确认系统盘使用率从 85% 降到了 50% 左右。当时以为这事就算翻篇了。
1.2 迁移后一周再次告警的排查起点
结果一周后的早晨,告警群又炸了。系统盘使用率又回到了 88%,并且还在缓慢往上走。第一轮排查先确认了几件最基础的事:
- 系统盘上是否残留了旧目录
/var/lib/docker.bak:查了下,这个目录在确认容器运行正常后已经删除,不存在占用问题。 - 日志文件是否暴涨:
du -sh /var/log只有几百MB,排除了日志打满的可能。 - 是否有进程写了大文件:
lsof +L1查已删除但仍被进程占用的文件,也没有异常。
既然这些常规嫌疑都排除,现象就很奇怪了:系统盘的占用在迁移后依然持续增长,增长量每天几个GB。这不符合“日志写满”的曲线,更像是有某个服务在持续落盘数据。于是开始进入目录级的逐层扫描。
2. 顺藤摸瓜:占用大头全部指向 /var/lib/containerd
2.1 用 du 一层层切割目录
排查大目录最可靠的方式不是df,而是du。很多人习惯直接看根分区总占用,但想精确到“哪个目录涨得最快”,必须逐层切。我常用的命令是:
du -h --max-depth=1 -x /var/lib 2>/dev/null | sort -hr | head -20注意-x参数很关键,它表示不跨文件系统统计。因为我们用mount --bind把数据盘挂到了/var/lib/docker,如果不加-x,du会顺着挂载点把数据盘内容也统计进/var/lib/docker的占用里,结果会误导判断。
扫描结果很意外:/var/lib/docker只占了几GB(这是缓存和临时数据,真正的镜像层已经落到数据盘),而/var/lib/containerd占了 26GB,并且还在缓慢增长。再往下一层:
du -h --max-depth=2 /var/lib/containerd出来的结构非常清晰:
| 路径 | 占用 |
|---|---|
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs | 约 22GB |
/var/lib/containerd/io.containerd.content.v1.content | 约 3GB |
/var/lib/containerd/io.containerd.metadata.v1.bolt | 约 0.5GB |
| 其他目录 | 不足 1GB |
对照之前的迁移记录,这个目录完全不在/var/lib/docker的范围内,也就是说前一次迁移根本与它无关。
2.2 疑问:为什么 Docker 迁移没带上 containerd
当时第一反应是“Docker 和 containerd 难道不是一家的吗”?这确实是最容易踩的认知盲区。
对于 Docker Engine 20.10 之后的版本,镜像的拉取、存储、挂载、容器运行时的生命周期管理,实际都交给了 containerd 来做。你可以理解成:docker命令是面向用户的控制面,containerd是真正干活的执行面。我们在用docker pull、docker run时,底层是 containerd 在拉取镜像、创建快照、挂载文件系统。
但这两个组件的数据目录是分开的:
/var/lib/docker:Docker CLI 和 daemon 的元数据、配置、网络、容器状态等。/var/lib/containerd:containerd 自己管理的镜像内容、快照、元数据。
具体到这篇文章里的场景,绝大部分空间消耗来自 snapshotter 目录。containerd 的 overlayfs snapshotter 会为每个镜像层和每个容器可写层创建快照目录,这些数据默认落在/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots。镜像虽然显示成docker images里的一个名字,但实际文件内容几乎都躺在 snapshotter 里。
所以,“只迁移 Docker 数据目录而不迁移 containerd 数据目录”的方案,从一开始就不完整。镜像拉取得越多、版本迭代越快,/var/lib/containerd就会越肥,系统盘继续爆满只是时间问题。
3. 深入机制:containerd 的存储构成和 “垃圾回收” 的真实情况
3.1 containerd 数据目录的三块核心内容
搞清楚 containerd 为什么能吃掉这么多空间,得先看它在本地扮演的几个角色。以/var/lib/containerd为例,实际起作用的子目录主要有三个,职责完全不同:
第一块:snapshotter 快照数据
路径:/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs这是占用的大头。containerd 以 snapshot 为单位管理镜像层和容器可写层。拉一个几GB的镜像,解压出来的每一层都在这里落一份;每创建一个容器,还会基于镜像层再生成一个可写快照。一个服务连续发布十几次版本,旧版本镜像如果没被清理,对应的快照就一直躺在磁盘上。
第二块:content 内容寻址存储
路径:/var/lib/containerd/io.containerd.content.v1.content/blobs这一块保存的是镜像的 blobs,按 SHA256 寻址存储。它的特点是:某个 blob 可能不会立即被引用删除,即使对应的镜像已经不再被任何容器使用。清理策略依赖 GC 机制,而 GC 的触发时机和删除策略可能比很多人想象的要保守。
第三块:metadata 元数据
路径:/var/lib/containerd/io.containerd.metadata.v1.bolt这是一个 Bolt 数据库文件,记录镜像、容器、快照的索引关系。它本身不大,但会随着镜像和容器的增删而产生碎片,极端情况下也会有可观的膨胀。
3.2 为什么 docker system prune 管不到 all of it
大多数运维遇到磁盘不够时的第一反应是执行:
docker system prune -a -f这个命令确实能清理未被容器使用的镜像,但用它收场这次问题时效果非常有限。原因在机制层面:
docker system prune清理的是 Docker 视角里的“悬空镜像”和“未使用镜像”,但 containerd 的 content store 和部分 snapshot 数据不一定会同步被回收。- containerd 的 GC 是异步运行的,删除容器或镜像后,数据可能只是进入待清理状态,不会立刻释放磁盘。
- 在新版本 Docker 中,
docker rmi删除镜像时会调用 containerd 的 API 删除对应对象,但 overlayfs snapshotter 的父层快照如果还被其他快照引用,就必须等待最后一个引用释放后才删除。
换句话说,镜像版本的堆积是“温水煮青蛙”。每次看起来只是多了一个几十MB的新层,但架不住迭代上百次,快照层的共享链越来越深,/var/lib/containerd就在你眼皮底下一天天胖起来。
3.3 迁移前必须理解的一个细节:Docker 的 containerd 目录 vs 系统 containerd 目录
排查时我还发现一个很多人容易搞混的点。/var/lib/docker/containerd和/var/lib/containerd不是同一个目录,也不一定指向同一个进程。
/var/lib/docker/containerd:Docker daemon 内置的 containerd 实例数据,通常 Docker 自己管理,迁移 Docker 数据目录时会一并迁走。/var/lib/containerd:独立 containerd 服务的数据目录,由containerd.service启动,负责当前 Docker 版本的镜像和容器运行时操作。
在 Docker 20.10 之后,系统普遍运行独立的containerd.service,镜像拉取等工作都走这个外部 containerd,所以它的数据增长完全独立于/var/lib/docker。如果只看/var/lib/docker,会漏掉一整块真实占用。
4. 根治方案:把 containerd 的数据目录整体搬到数据盘
4.1 两种方案选型:改启动参数还是软链接
确认/var/lib/containerd是元凶之后,接下来就是迁移。这里有两种主流做法:改containerd的启动参数,或者建软链接指向数据盘。两者各有适用场景。
方案一:改 systemd unit 的启动参数
containerd 的 root 目录是通过启动参数--root指定的,默认是/var/lib/containerd。修改启动参数意味着从服务层面直接告诉 containerd “你的数据根目录在别处”。这样最干净,不存在目录结构和挂载点混淆。
方案二:保留路径,用软链接拐到数据盘
把/var/lib/containerd整体同步到数据盘后,删除原目录,在原位置建一个指向数据盘的软链接。服务看到的路径没变,实际数据落在数据盘。优点是操作风险低,特别适合那些不方便改 systemd 配置的托管环境。
我最终选择了方案一,因为在我们的环境里 systemd 完全可控,且后续排障时直接看 unit 就能确认数据根位置,不用每次解释“为什么/var/lib/containerd是软链接”。
4.2 完整迁移步骤(含每个步骤的意图说明)
整个迁移过程需要停机操作,顺序不能乱。下面按实际操作顺序写出来:
第一步:确认当前 containerd root 配置
先看当前的启动命令:
systemctl cat containerd.service | grep ExecStart通常输出是/usr/bin/containerd或者带若干参数。确认没有任何自定义--root后,记录默认路径即可。
第二步:停止 Docker 和 containerd
docker ps --quiet | xargs -r docker stop systemctl stop docker systemctl stop containerd必须先停止 Docker 再停 containerd,因为 Docker daemon 还在连接 containerd 操作容器状态。如果只停 Docker 不停 containerd,Docker 重启后可能因为 containerd 数据路径变化而产生状态不一致的告警。
第三步:同步数据到数据盘
mkdir -p /data/containerd rsync -aH --delete /var/lib/containerd/ /data/containerd/这里用了-H保留硬链接,很重要。containerd 的 snapshotter 和 content store 里存在大量硬链接用来共享 blob,如果丢失硬链接关系,不仅复制出的数据体积会翻倍,还可能导致挂载时文件冲突。
第四步:确认同步完整性
rsync 完成后建议再跑一遍(rsync 的天然幂等性),看是否还有增量输出。如果第二次执行没有实际传输,说明源目录已完整同步。
第五步:改 containerd 启动参数
用 systemd override 的方式,不要直接改/lib/systemd/system/containerd.service,这样方便统一管理和回滚:
systemctl edit containerd.service写入:
[Service] ExecStart= ExecStart=/usr/bin/containerd --root /data/containerd第一行ExecStart=是必须的,作用是清空 systemd 继承的默认启动指令,否则新配置会追加成两个ExecStart,服务直接起不来。
第六步:重新加载并启动
systemctl daemon-reload systemctl start containerd systemctl start docker启动后马上验证:
systemctl status containerd docker ps docker images容器列表和镜像列表能正常显示,基本就成功了大半。再执行一个临时容器运行测试:
docker run --rm hello-world确保运行时能正常创建快照,而不仅仅是“能读”。
4.3 常见坑:备份目录不要急着删
迁移完成后,原/var/lib/containerd目录先不要删除。我的做法是把它改名为/var/lib/containerd.bak,保留一个完整副本再观察几天。
原因很简单:如果 containerd 在迁移后因为某些隐藏问题不断报错,比如快照损坏、元数据不匹配,你需要一个能快速回滚的状态。等运行两三天、确认所有服务正常、告警阈值稳定后,再删除备份,释放系统盘空间也不迟。
删除备份时注意,/var/lib/containerd.bak里可能也有大量硬链接文件,直接rm -rf有时候会比较慢,可以用rsync先清空目录结构再删,但一般在测试环境倒也不用那么讲究。
5. 迁移后的验证、监控与日常维护建议
5.1 验证是否真的释放了系统盘
启动服务后,用df -h对比系统盘和数据盘的使用率。正常情况是:系统盘使用率应下降约等于之前/var/lib/containerd的占用,数据盘增加同样大小。
再跑一次目录扫描确认 containerd 的真实落盘位置:
df -h /var/lib/containerd如果输出显示挂载点对应数据盘,说明--root参数已经生效。如果想更直观地从进程层面确认,可以看 containerd 的打开文件路径:
ls -l /proc/$(pgrep containerd | head -1)/cwd cat /proc/$(pgrep containerd | head -1)/cmdline | tr '\0' ' '进程命令行里能看到/data/containerd,就说明运行路径没错。
5.2 把“双目录”检查加入监控
这次问题给的最大教训是:监控不能只看 Docker 一个目录。镜像和容器运行时的数据分散在两个位置,任何一个都可能把系统盘撑爆。
在监控系统里,除了已有的/var/lib/docker使用率,建议把以下路径单独加进去:
| 监控对象 | 检查方式 | 说明 |
|---|---|---|
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots | du定时统计 | 快照层目录,增长最快的区域 |
/var/lib/containerd/io.containerd.content.v1.content/blobs | du定时统计 | 镜像内容存储,拉取镜像时增长 |
/var/lib/containerd/io.containerd.metadata.v1.bolt | 文件大小 | 元数据膨胀异常时重点检查 |
| 系统盘整体使用率 | df标准告警 | 一票制,超过阈值直接告警 |
最好是按目录粒度采集,而不是只看整个/var/lib/containerd,这样才能在磁盘爆满之前预判是哪个环节出了问题。
5.3 建立周期性清理机制
迁移只是解决了“位置不对”的问题,如果平时不控制镜像版本堆积,数据盘迟早也会满。建议在运维侧加一个定时任务,定期清掉不需要的镜像和悬空快照:
docker image prune -af docker builder prune -af docker system prune -af --volumes但注意,这三个命令必须配合容器的实际使用情况执行,尤其是--volumes,会连匿名卷一并删除,生产环境慎用。更细粒度的做法是,按镜像标签和时间维度筛选,把超过 N 天未使用的镜像标记后删除。
另外,containerd 自身也提供 GC 能力,但默认的调度比较保守。如果频繁出现“删除镜像后空间没有立刻释放”的情况,可以通过查看 containerd 日志确认 GC 执行时间,必要时调整 GC 调度配置。不过这属于第二优先级的事——先把数据盘位置改对,再谈回收效率。
写在最后的一点经验
这次问题排查下来,最值钱的收获不是“迁移了一个目录”,而是意识到容器环境下磁盘占用从来不是单一数据目录的问题。Docker 组件拆得越细,数据落盘的位置就越分散,/var/lib/docker只是入口,真正堆镜像层的是 containerd 的 snapshotter。
如果以后再有人问“Docker 迁移后系统盘怎么还是满”,我会先让他执行两条命令:
df -h / du -h --max-depth=1 -x /var/lib | sort -hr | head -10把这两个结果发过来,基本一眼就能定位问题。至于 containerd 目录迁移本身,把--root改到数据盘、验证服务正常、保留备份观察两天,这套流程走一遍,系统的磁盘告警基本就能按下去了。