ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Docker容器存储机制:Storage Driver、可写层与磁盘排查实践

Docker容器存储机制:Storage Driver、可写层与磁盘排查实践 前阵子遇到一次磁盘告警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它看到的就是一个完整统一的文件系统。WorkDiroverlayfs 内核模块做原子性操作时使用的暂存目录一般不用人为干预。打一个比方LowerDir 是几张已经印好的透明胶片一层压一层放在桌上UpperDir 是贴在胶片最上面的一叠便利贴MergedDir 是你透过所有胶片和便利贴看到的那副完整画面WorkDir 是你贴便利贴时用来摆弄剪刀和胶水的操作台。2.2 写入、修改、删除CoW 机制下的三套动作overlayfs 最核心的设计是写时复制Copy-on-WriteCoW。它决定了容器写文件时底层镜像文件不会被动一根汗毛。具体来说分三种情况写入一个新文件直接创建在 UpperDir不需要经过镜像层。修改镜像层里已有的文件overlayfs 会先把整个文件从 LowerDir 复制到 UpperDir然后在 UpperDir 上做修改。这个复制到上层再修改的动作内核里叫 copy-up。删除镜像层里已有的文件不会真的去删除只读层里的文件而是在 UpperDir 创建一个whiteout 文件特殊字符设备设备号 0/0把这个文件盖住让进程在 MergedDir 里看不到它。类似在胶片上贴一张不透光的小纸条把下面的画面遮住。我实际验证过一次。在 busybox 容器里删掉/etc/hostname然后在宿主机上查看 UpperDirdocker 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 typetmpfs,destination/cache,tmpfs-size100m 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最后清理或重建容器大概率能在一轮排查里解决战斗。
返回列表