前阵子遇到一次磁盘告警,df -h查下来/var/lib/docker占了将近 240G,可仓库里的镜像加起来才几十个 G。翻来覆去查了半天才定位到问题:几个在跑的容器,把运行日志和临时缓存全写进了容器自己的可写容器层,最终那部分由Storage Driver管理的数据把磁盘吃满了。这也是我后来下决心把容器存储这套机制彻底捋清楚的原因。这篇文章就从"Storage Driver 管理的存储"这个角度出发,聊聊容器运行时产生的临时数据到底落在哪里、怎么被管理,顺手把排查过程中用到的命令和踩过的坑分享给正在搞容器运维和开发的读者。
1. 只读镜像层和可写容器层:容器分层的两个基本动作
1.1 镜像层为什么必须只读
很多人第一次接触 Docker 时都听过"镜像由多层组成"这句话,但很少有人深究"层"到底是什么。用最直白的话说,镜像层就是一串叠加起来的只读文件系统快照,每一层记录了某一时刻文件系统的变更集合。
我拿一个最普通的 Dockerfile 举例:
FROM ubuntu:22.04 RUN apt-get update && apt-get install -y curl COPY app /app ENTRYPOINT ["/app/start"]这个镜像构建出来之后,基础镜像ubuntu:22.04本身可能是好几层(每一层都对应一个 RUN 或 COPY 指令),紧接着是我们这条RUN产生的新层,然后是COPY产生的上层。每一层都只保存"相对上一层来说新增、修改或删除了哪些文件",而不是完整复制一份 Ubuntu。
层为什么必须设计成只读?两个原因,都很实在:
- 可校验、可复用。层的内容一旦构建就不再变化,校验值(digest)也就稳定。多个镜像可以放心地共享底层,仓库拉取时命中公共层还能省流量。一个系统里跑 50 个
ubuntu容器,底层那几层物理上只会存一份。 - 隔离变更。构建过程每次产生的操作都固定在某一层里,后续层的修改不会倒灌到前面层,保证了镜像从构建到发布的一致性。
如果哪天有人直接在容器里"改"了镜像层的文件,Docker 并不会真的动底下那层,而是把文件复制到上层再改。这就引出了可写容器层存在的意义。
1.2 可写容器层:临时数据的默认归宿
当你执行docker run时,Docker 会在那些只读镜像层之上,为这个容器专门加一层可写层。所有进程运行时的写操作——日志、PID 文件、临时缓存、程序运行期间产生的中间状态——全都落在这里。
这里有个绕不开的生活常识:可写层随着容器生死而生死。容器被docker rm掉,这层数据就没了;容器 stop 再 start,数据还在(只要不删容器)。所以官方文档里反复强调,可写层是"临时"的存储。这不是文档废话,是很多人丢数据的根源:以为容器停了数据就安全了,结果哪天清理环境时一个docker container prune,日志、缓存、甚至没来得及备份的导出文件全没了。
镜像层和容器层的差异,可以整理成一张表:
| 对比项 | 镜像层 | 容器可写层 |
|---|---|---|
| 文件权限 | 只读 | 可读可写 |
| 生命周期 | 跟镜像长期共存 | 跟容器绑定,容器删除即消失 |
| 多个容器能否共享 | 可以共享底层 | 每个容器独立一份 |
| 数据性质 | 程序文件、依赖、系统环境 | 运行日志、临时文件、进程写出的数据 |
| 管理方式 | Storage Driver 统一管理 | Storage Driver 统一管理 |
标题里说的"由 Storage Driver 管理的存储,用于管理容器运行时产生的临时数据",翻译过来就是这个意思:Storage Driver 负责把一堆只读层和一个可写层拼成一个完整的、进程可见的根文件系统,同时处理容器写入临时数据时需要的空间分配和回收。
1.3 把分层想象成 Git 提交就通了
跟 Git 打交道多的人,理解分层会特别快。
- 镜像层约等于 Git 里的 commit 快照,每个 commit 只记录和父提交的差异;
- 镜像标签(tag)约等于 commit 上的 tag;
- 容器可写层约等于你正在改动的工作区;
- 多个容器基于同一个镜像启动,就像多个分支从同一个 commit 拉出来,大家共享底层的 commit 区,各自的工作区互相独立。
你在工作区改了文件并不会修改历史 commit,只有 commit 才会产生新快照。对应到容器里,你在可写层改了文件并不会修改镜像,只有把改动docker commit成一个新镜像,这些差异才会固化成新的镜像层。
这么一类比,为什么"在容器里改完东西直接 commit 当新镜像用"是个坏习惯就很好理解了:每一次零散的 commit 都会制造极大的冗余层,后续维护、升级、排查都会变得痛苦。
2. Storage Driver 的工作内幕:overlay2 如何把层叠成根文件系统
2.1 一个容器在磁盘上的真实姿态
光知道分层还不够,得实际看看 Storage Driver 到底是怎么把多个层"粘"成一个根文件系统的。现在绝大多数 Linux 发行版默认用overlay2,它底层依赖内核的 overlayfs 模块。
用一个运行中的 busybox 容器做实验,先看它挂载信息:
docker run -d --name test-storage busybox sleep 3600 docker inspect test-storage -f '{{json .GraphDriver}}'输出大概是这样的结构:
{ "Data": { "LowerDir": "/var/lib/docker/overlay2/aaaaaaaa/diff:/var/lib/docker/overlay2/bbbbbbbb/diff", "MergedDir": "/var/lib/docker/overlay2/cccccccc/merged", "UpperDir": "/var/lib/docker/overlay2/cccccccc/diff", "WorkDir": "/var/lib/docker/overlay2/cccccccc/work" }, "Name": "overlay2" }四个目录各管各的事:
- LowerDir:冒号分隔的一串目录,对应这些只读镜像层。overlay2 支持多个 lower 目录,底层在前、顶层在后;容器内看到的文件系统里,如果多个 lower 有同名文件,排后面的层会覆盖排前面的层。
- UpperDir:当前容器专属的可写层。进程写入的内容、日志、修改后的文件,全部放在这里。这也是磁盘占用里最容易膨胀的那部分。
- MergedDir:把 lower 和 upper 叠加之后呈现给容器进程的最终"合成视图"。容器里的进程感知不到什么 lower/upper,它看到的就是一个完整统一的文件系统。
- WorkDir:overlayfs 内核模块做原子性操作时使用的暂存目录,一般不用人为干预。
打一个比方:LowerDir 是几张已经印好的透明胶片,一层压一层放在桌上;UpperDir 是贴在胶片最上面的一叠便利贴;MergedDir 是你透过所有胶片和便利贴看到的那副完整画面;WorkDir 是你贴便利贴时用来摆弄剪刀和胶水的操作台。
2.2 写入、修改、删除:CoW 机制下的三套动作
overlayfs 最核心的设计是写时复制(Copy-on-Write,CoW)。它决定了容器写文件时,底层镜像文件不会被动一根汗毛。
具体来说,分三种情况:
- 写入一个新文件:直接创建在 UpperDir,不需要经过镜像层。
- 修改镜像层里已有的文件:overlayfs 会先把整个文件从 LowerDir 复制到 UpperDir,然后在 UpperDir 上做修改。这个"复制到上层再修改"的动作,内核里叫 copy-up。
- 删除镜像层里已有的文件:不会真的去删除只读层里的文件,而是在 UpperDir 创建一个whiteout 文件(特殊字符设备,设备号 0/0),把这个文件"盖住",让进程在 MergedDir 里看不到它。类似在胶片上贴一张不透光的小纸条,把下面的画面遮住。
我实际验证过一次。在 busybox 容器里删掉/etc/hostname,然后在宿主机上查看 UpperDir:
docker exec test-storage rm /etc/hostname ls -l /var/lib/docker/overlay2/cccccccc/diff/etc/输出里会出现:
c--------- 1 root root 0, 0 Jul 1 10:00 hostname这个c开头的文件就是 whiteout 标记。它几乎不占空间,但含义很明确:来自底层镜像的/etc/hostname在合并视图里被遮蔽了。如果你把容器删了,这个 whiteout 也随之消失,底层镜像的/etc/hostname依然完好。
同样的逻辑也能解释一个常见疑问:为什么容器里删除大文件,宿主机对应层目录的占用不一定立即下降。因为只要还有别的容器共享同一镜像层,那些层的内容就不能删;而容器自己的可写层里曾经被标记删除的文件,如果已经被 whiteout 盖住,你从 MergedDir 看到的是"没了",但 UpperDir 里那个被标记删除的文件本体可能还在,这取决于是否有容器引用。所以"删了文件但磁盘没释放"这种事,在镜像层和可写层边界处特别容易发生。
2.3 copy-up 的隐藏成本:第一次写大文件很慢
CoW 听起来很省空间,但它不是免费的。修改一个镜像层里的大文件,代价是把整个文件先复制到上一层,再做修改。我见过一个排查案例:某个容器每次启动都会把几百 MB 的配置缓存从镜像层文件复制出来再改写,结果每次冷启动都要多扛十几秒 IO。
如果你只是运行一个轻量服务,这个开销基本可以忽略。但如果你的容器里跑着数据库、做着大量文件改写,或者把日志直接写到一个存在于镜像层中的文件路径上,那 copy-up 的性能损耗就会很扎眼。
这也是为什么数据库、消息队列这类重度写入的服务,官方镜像几乎都要求你把数据目录挂载到 volume 里,而不要留在容器可写层。Container Layer 适合"小、碎、临时"的写入,不适合"大、持续、关键"的写入。这一点在后面第 5 章展开。
3. 存储驱动选型:overlay2 为什么是默认,以及什么时候要犹豫
3.1 先看当前环境用的什么驱动
判断当前宿主机的存储驱动很简单:
docker info | grep "Storage Driver"输出一般就是overlay2。想更具体一点,可以看这个目录里的实际结构:
ls -l /var/lib/docker/overlay2/能看到很多L开头的短链接目录、l目录,以及每个层对应的diff目录。L开头的是短名称链接,目的是规避 Linux 单页路径长度限制,deep 路径访问时会走到这里。
如果某台老机器上输出的是overlay而不是overlay2,意味着内核里只有旧版 overlayfs 模块,它通常只支持单个 lower 目录,多镜像层需要链式串联,性能差得多。遇到这种环境我建议升级内核或者迁移到新机器,没必要迁就老驱动。
3.2 驱动之间的横向对比
写这篇内容前,我把常见的几个驱动拉出来对比了一遍,方便你按场景挑:
| 驱动 | 是否用 CoW | 主要特点 | 适用场景 |
|---|---|---|---|
| overlay2 | 是 | 内核原生叠加,支持多个 lower 目录,性能好、空间利用率高 | 绝大多数 Linux 生产环境的默认选择 |
| overlay | 是 | 老版本内核驱动,通常只支持单 lower 目录 | 内核过老无法升级的旧系统 |
| vfs | 否 | 每个层都完整拷贝一份,不用叠加,最占空间 | 测试环境验证兼容性,生产基本不用 |
| fuse-overlayfs | 是 | 用户态文件系统实现,不需要 root 权限 | rootless Docker / Podman 容器 |
| devicemapper | 是 | 基于设备映射的 block 级方案,配置复杂 | 历史遗留系统,社区已不推荐 |
从运维角度说,选驱动的第一原则是:能上新内核就上新内核,能默认就默认。overlay2 之所以是几乎所有人的默认选项,就是因为它把"内核原生、多 lower 支持、CoW 省空间"这几个优点凑齐了。vfs这类驱动只有在交叉验证环境、模拟别的镜像格式时才值得碰。
3.3 切换驱动的真实代价
有些文章会说"把 storage-driver 改成 overlay2 就完事了",实际操作没这么轻松。
修改/etc/docker/daemon.json:
{ "storage-driver": "overlay2" }重启 docker 之后你会发现,之前用其他驱动拉取的本地镜像在新驱动下全部不可见,因为/var/lib/docker/overlay2里没有这些镜像的层结构,需要重新docker pull。如果原来用的是 vfs,层数据还在/var/lib/docker/vfs对应的路径里,想回归需要把驱动程序改回去,镜像才会重新出现。
所以切换驱动前务必确认:
- 本地有没有必须保留、且无法从镜像仓库重新拉取的镜像;
- 容器数据卷是否做了备份;
- 是否提前通知了使用该宿主机的团队。
我自己的习惯是:涉及存储驱动的变更一律安排在维护窗口,先docker system df看一眼现有占用,再docker image save备份关键镜像,最后才动配置。
4. 容器临时数据膨胀:一次完整的磁盘排查链路
讲完机制回来说开头那个磁盘告警。那次排查过程比较有代表性,我把完整链路记下来,下次你遇到类似问题可以照方抓药。
4.1 先搞清楚"数据都死在哪里了"
容器相关的磁盘占用,通常来自三个方向:
- 容器可写层里的业务数据:程序运行时往工作目录、
/tmp、日志目录写的文件,没挂载任何 volume,全堆在/var/lib/docker/overlay2/<容器id>/diff下。 - json-file 日志文件:Docker 默认把容器标准输出和标准错误重定向到宿主机上的 json 日志文件,路径在
/var/lib/docker/containers/<容器ID>/<容器ID>-json.log。日志量大、没做轮转的话,这个文件能吃掉几十个 G。 - 悬空镜像和构建缓存:构建镜像产生的中间层、被新镜像替换掉的旧镜像层,暂时没人引用但还没被清理。
4.2 排查命令按什么顺序执行
我习惯的排查顺序是这样的:
# 第一步:看整体盘子 docker system df # 第二步:看 docker 目录下谁最大 du -sh /var/lib/docker/* | sort -hr # 第三步:看容器占用的近似值(注意看 SIZE 和 VIRTUAL SIZE 两列) docker ps -a -s | sort -k 3 -h # 第四步:定位日志文件的体积 du -sh /var/lib/docker/containers/*/*-json.log | sort -hrdocker system df会先给出一个概览,能看到 Images、Containers、Local Volumes、Build Cache 各自占了多少空间,以及哪些可以回收(RECLAIMABLE)。这相当于一个仪表盘,先看它基本能判断问题出在哪一类。
接下来du -sh /var/lib/docker/*直接看物理分布。如果overlay2目录最大,说明镜像层和容器可写层是主要矛盾;如果containers目录最大,大概率是日志文件炸了。
docker ps -a -s的SIZE列不是精确值,但它能反映容器可写层和日志文件的大致占用,排序之后能找到最可疑的容器。再配合du -sh /var/lib/docker/containers/*/*-json.log,能精确锁定是哪几个容器的日志没做轮转。
4.3 进入容器内部确认"膨胀点"
锁定嫌疑容器之后,进容器里做更细的定位:
docker exec -it <容器ID> sh du -sh / 2>/dev/null | sort -hr | head -20这时往往能看到几个典型大块头:
/var/log下堆了大量应用日志;/tmp或工作目录下有没清理的缓存、打包文件;- 包管理器缓存(
/var/cache/apt、pip 缓存、npm 缓存)没清。
用 busybox 容器测试写 100MB 临时文件,你会立刻看到/var/lib/docker/overlay2/<容器id>/diff对应目录体积增长。这清楚地说明一个事实:容器里的临时文件,本质上是在消耗宿主机磁盘。不挂 volume、不做清理,它就一直占着。
4.4 处理方案和教训
我的处理方案分两段:
- 如果容器允许重启,直接把关键容器重建,让可写层里的临时数据一次性丢弃,空间立刻释放;
- 如果容器不能随意重启,就进容器用
rm清理临时目录和缓存,再确认日志轮转是否已配置。
日志轮转是每次排查之后我都会顺手加上的配置,写在/etc/docker/daemon.json:
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }重启 Docker 后,所有容器日志单文件超过 10MB 就会自动轮转,最多保留 3 个旧文件。不加这个配置的后果就是日志无限增长——尤其像 Nginx、Java 应用这类爱往 stdout 写日志的程序,json.log 能轻松撑爆磁盘。
5. 持久化数据别放可写层:Volume、bind mount 和 tmpfs 的正确打开方式
5.1 三种挂载类型解决三类问题
了解了存储驱动的工作机制,你就能理解容器持久化方案设计的底层逻辑了。可写层只适合临时数据,想让数据活得比容器久、或想避开 copy-up 的性能损耗,就得用挂载卷。
| 类型 | 生命周期 | 典型场景 | 注意事项 |
|---|---|---|---|
| Volume | 由 Docker 管理,独立于容器 | 数据库数据目录、多容器共享数据 | 备份迁移要主动做 |
| bind mount | 与宿主机指定路径绑定 | 配置文件、开发代码、宿主机日志目录 | 路径不存在时 Docker 会帮你建目录,但要注意权限 |
| tmpfs | 内存中,容器停止即消失 | 敏感 token、进程缓存、临时目录 | 数据不可持久化,重启即清空 |
5.2 三个挂载的经典命令
数据库用 volume,这是最常见的需求:
docker volume create pgdata docker run -d -v pgdata:/var/lib/postgresql/data postgres:16配置文件用 bind mount,希望直接改宿主机文件就能影响容器:
docker run -d -v /etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro nginx敏感信息或临时性能敏感的数据用 tmpfs,写入落在内存而不是磁盘,也完全绕开了 Storage Driver 的写时复制机制:
docker run --rm --mount type=tmpfs,destination=/cache,tmpfs-size=100m alpine第三条值得多说一句:对需要反复读写的临时数据(比如 session 标识、内存缓存),tmpfs 比可写层快得多,因为根本不落盘。缺点是容器重启数据就没了——但临时数据本来就该用完就丢。
5.3 Volume 的备份和迁移
我见过不少团队在生产环境乱用 bind mount 存数据库数据,然后迁移主机时只能cp -r整个目录。如果换成 Volume,迁移会优雅很多。
给名为pgdata的卷做备份:
docker run --rm -v pgdata:/data -v $(pwd):/backup alpine \ tar czf /backup/pgdata.tar.gz -C /data .恢复到新环境:
docker run --rm -v pgdata:/data -v $(pwd):/backup alpine \ tar xzf /backup/pgdata.tar.gz -C /data用一条docker run临时起个容器去打包/解包 volume,比在宿主机上到处找卷目录、再担心权限问题要省心得多。/var/lib/docker/volumes/下那些目录直接拿去做备份虽然可行,但涉及 Docker 本身的元数据一致性,不如上述方式稳。
5.4 选择挂载方式的三条判断准则
一句话概括我的选型逻辑:
- 数据需要跨容器、跨镜像版本保留,且不希望宿主机目录结构暴露给应用 → 用 Volume;
- 数据需要和宿主机文件系统直接交互(改配置、看日志、开发调试) → 用 bind mount;
- 数据只用一次、越快越好、消失也无所谓 → 用 tmpfs。
6. 从这些坑里总结出的几个默认习惯
最后分享几个我现在做容器运维时的默认动作,未必是最优解,但能避开七八成的磁盘和存储问题。
第一,容器的可写层一律当"临时垃圾场"看待。能挂卷的挂卷,不能挂卷的就做好定期清理,绝不把日志和缓存默认留在可写层里。容器本来就是一次性的,运行状态应该尽可能通过重建来恢复,而不是在原容器上修修补补。
第二,日志轮转要一劳永逸地配好。给/etc/docker/daemon.json加上max-size和max-file,新容器自动继承。这个动作一次配置、长期生效,性价比极高。老容器如果没自动继承,用docker inspect看下 LogConfig 再决定是否重建。
第三,控制镜像层数。每次RUN都会产生新层,建议用&&合并多条命令,或者尽量用多阶段构建。层太多不只是构建慢的问题,镜像也会更臃肿,overlay2 的 lower 数量限制早晚会让你头疼。
第四,定期执行docker system df看盘面。把它写进监控脚本或巡检清单,发现 RECLAIMABLE 数值异常大时再决定docker image prune或docker system prune。docker system prune -a --volumes这种激进清理命令慎用,它会删掉无容器的卷,数据丢了找不回来。
第五,只要条件允许,尽量别让存储驱动选型变成"历史包袱"。新的生产主机直接默认 overlay2,老主机升级内核后择机切换。别等真的出现兼容性问题了才去研究驱动差异,那时候你已经没有余量做计划内迁移了。
容器存储这个话题看着抽象,其实拆开就三件事:搞懂层和可写层的默认行为,弄清楚 Storage Driver 如何处理写入和删除,然后明确哪些数据该留在可写层、哪些该进挂载卷。把这三点想透了,再遇到磁盘告警时你就不太会慌了——先看docker system df,再去翻 json.log,最后清理或重建容器,大概率能在一轮排查里解决战斗。