操作系统内功 · 第 14 篇:容器的存储驱动——overlay2、devicemapper 与性能对比

容器里写文件,到底写到了哪

容器里 echo hi >> /etc/hosts、往 /var/log/app.log 里追加一行、下载一个 2GB 的文件到 /opt——这三个操作在容器文件系统里的落点完全不同,成本也完全不同。理解这件事的钥匙是容器的存储驱动(storage driver):现代 Docker/containerd 默认的 overlay2 驱动基于内核的 overlayfs 文件系统,把镜像的多层只读内容和容器自己的可写层「叠加」成容器内的根文件系统。绝大多数容器存储类事故——「往镜像自带的日志文件里 append 为什么这么慢」「容器删了镜像层文件为什么宿主机磁盘没释放」「数据库容器不用 volume 跑了一天之后为什么突然卡死」——根源都是没搞清楚写入落点与 copy-up 机制。这篇从 overlayfs 的真实挂载结构讲起,给出可复现的性能数字,再把 devicemapper 这个块设备方案讲清楚,最后落到选型结论。

flowchart TD
    A["lowerdir 第 3 层:应用镜像层(只读)"] --> M
    B["lowerdir 第 2 层:依赖层(只读)"] --> M
    C["lowerdir 第 1 层:基础镜像层(只读)"] --> M
    D["upperdir:容器可写层"] --> M
    E["workdir:overlayfs 内部暂存目录"] --> M
    M["merged:容器内看到的根文件系统 /"]
    M -- "读镜像层文件" --> F["逐层向下查找 lowerdir<br/>(上层命中即返回)"]
    M -- "首次修改镜像层已有文件" --> G["copy-up:把整个文件<br/>从镜像层复制到可写层再改"]
    M -- "新建文件 / 追加可写层文件" --> H["直接写入 upperdir"]

overlay2 的真实结构:从 docker inspect 读出三层目录

先看事实。一台 Docker 主机上,存储驱动信息在 docker info 里:

$ docker info | grep -A4 'Storage Driver'
Storage Driver: overlay2
 Backing Filesystem: xfs
 Supports d_type: true
 Using metacopy: false
 Native Overlay Diff: true

Backing Filesystem: xfs 值得划重点:overlayfs 是「叠加文件系统」,它自己不存数据,底层必须压在真实的文件系统(ext4/xfs)上,且要求底层支持 d_type(xfs 格式化时 ftype=1,默认即开)。起一个容器后,用 inspect 把驱动管理的目录读出来:

$ docker inspect myapp --format '{{.GraphDriver.Data.MergedDir}}'
/var/lib/docker/overlay2/9c2f.../merged
$ sudo mount | grep overlay | head -1
overlay on /var/lib/docker/overlay2/9c2f.../merged type overlay (
  lowerdir=/var/lib/docker/overlay2/l/4QV...:/var/lib/docker/overlay2/l/A3F...:/var/lib/docker/overlay2/l/KJ2...,
  upperdir=/var/lib/docker/overlay2/9c2f.../diff,
  workdir=/var/lib/docker/overlay2/9c2f.../work)

这就是容器根文件系统的完整真相:lowerdir 是按顺序排列的镜像各层(基础镜像层在最左/最底,应用层在最上),全部只读;upperdir 是这个容器私有的可写层(diff 目录),容器运行期的所有修改都落在这里;workdir 是 overlayfs 的内部工作目录,用户不碰;内核把四者「合并投影」成 merged——容器进程看到的 /。容器销毁时 upperdir/workdir/merged 一起删除,镜像层(lowerdir 引用的内容)原样保留,这就是「容器可抛弃、镜像可复用」的存储基础。这个结构还带来一个常被低估的红利:同一镜像的 N 个容器共享同一份 lowerdir 文件与页缓存。10 个基于同一基础镜像的 Java 容器,libc、JRE 这些只读文件在内核页缓存里只有一份,读放大为零;这也是「一个宿主机塞几十个同镜像容器」比「几十个异构镜像」内存效率高的根本原因。

copy-up:容器里第一次改文件的隐藏成本

overlayfs 的读写规则里最有工程杀伤力的是 copy-up:容器要修改镜像层里的一个已有文件时,overlayfs 先把整个文件从镜像层完整复制到 upperdir,然后在新副本上执行修改——哪怕你只是想追加 1 个字节。做一个可以直接复现的实验(镜像里有一个 100MB 的 /var/log/bootstrap.log):

# 容器内
$ ls -lh /var/log/bootstrap.log
-rw-r--r-- 1 root root 100M Nov 4 08:00 /var/log/bootstrap.log
$ time (echo x >> /var/log/bootstrap.log)
real	0m1.4s
$ df -h / | tail -1
overlay   98G   32G   61G  35% /
# 可写层目录在宿主机上的实际增长
$ sudo du -sh /var/lib/docker/overlay2/9c2f.../diff
104M

追加 1 字节,耗时 1.4 秒,可写层涨了 104MB——因为 copy-up 把 100MB 整个搬了一遍。这就是「应用往镜像层路径写日志越写越慢」的机制解释:日志文件越大,每次 inode 变更后的首次写越贵;文件更新频繁的场景(日志、临时缓存、数据库文件)必须避开镜像层路径。删除也有对应的机制:容器里删一个镜像层文件,overlayfs 在 upperdir 创建一个「whiteout」标记(一个 0:0 的字符设备文件),表示「这层之下的同名文件不可见」——镜像层里那个文件当然还在,宿主机磁盘空间不会释放:

# 容器内 rm /var/log/bootstrap.log 后,宿主机 upperdir 里:
$ sudo ls -l /var/lib/docker/overlay2/9c2f.../diff/var/log
总用量 0
c--------- 1 root root 0, 0 Nov  5 11:02 bootstrap.log

c 0, 0 就是 whiteout。同理,「容器里 rm 大文件后磁盘告警没解除」是常态——真正释放要等容器销毁。新文件写入则没有 copy-up 成本,直接进 upperdir,速度接近宿主机本地盘(多一层 VFS 转发开销,通常在几个百分点内)。

性能实测:overlay2 在不同负载下的真实表现

用 fio 分别对「容器可写层」和「同盘 volume 直挂」做基准,宿主机为 NVMe SSD + xfs,容器内核 5.15:

$ fio --name=t --size=1g --bs=4k --rw=randwrite --direct=1 \
      --ioengine=libaio --iodepth=8 --runtime=30 --group_reporting
# overlay2 可写层(新文件,无 copy-up)
   iops        : 41.2k
   throughput  : 161MB/s
   lat (usec)  : avg=191, p99=412
# volume 直挂宿主机 xfs
   iops        : 48.9k
   throughput  : 191MB/s
   lat (usec)  : avg=161, p99=347

结论分三条:顺序写与新文件随机写,overlay2 与本地盘的差距在 10%~20% 以内,日常业务几乎无感;修改镜像层已有文件,copy-up 让首次写成本正比于文件大小,这是数量级的差距,必须规避;元数据密集操作(海量小文件创建、目录树展开,比如 npm install、pip install、构建产物解包)在 overlay2 上比原生慢 5%~30%,因为每一次 inode 操作都要穿过 overlayfs 的层间查找。同一个实验如果换成老内核(4.0 以前)或早期 Docker 的 devicemapper 驱动,随机写的延迟抖动会明显更大——这正是存储驱动演进的动因。

devicemapper:块级快照方案的前世今生

overlayfs 进主线(内核 4.0,2015 年)之前,Docker 在 CentOS/RHEL 7 上依赖的默认驱动是 devicemapper:它站在完全不同的抽象层——用内核 device-mapper 的 thin-provisioning(瘦供给)机制,在块设备级别实现镜像层与容器的写时复制:

$ docker info | grep -A6 'Storage Driver'      # devicemapper 模式
Storage Driver: devicemapper
 Pool Name: docker-253:1-1024-pool
 Pool Blocksize: 65.54 kB
 Base Device Size: 10.74 GB
 Data file: /dev/mapper/docker-data

镜像的每一层是池设备上的一个块级快照,容器 = 镜像快照再套一层可写快照;修改镜像层的某个 64KB 块(Pool Blocksize)时,把该块复制到容器私有设备再改——CoW 的粒度从 overlay2 的「整个文件」缩小到「单个块」。这个差异决定了两者的分野:devicemapper 修改大文件只复制被改的块,没有 copy-up 全量搬运的问题;但它为每个容器维护独立块设备,元数据开销高,小文件随机写的延迟普遍不如 overlay2,且运维上强烈要求 direct-lvm 模式(数据池与元数据池放在独立 LVM 卷上)——历史上一大批用 loopback 文件跑生产的 devicemapper 部署,IO 全部穿过两个回环设备,是当年「容器一上量磁盘 IO 就雪崩」的经典事故源。如今它已是事实上的退役技术(Docker 25 起 devicemapper 支持被移除),值得记住的是它留给行业的两个遗产:块级 CoW 与文件级 CoW 的性能分野(数据库类负载天然亲块级),以及存储驱动必须与宿主机文件系统特性(d_type、xfs pquota)对齐这条部署铁律。

选型结论:哪些写入必须离开容器可写层

把机制转成部署清单。数据库类负载(MySQL/PG/ES/Redis 持久化)一律挂 volume:数据目录放容器可写层等于把数据库 IO 扔进 overlayfs 的层间转发,还附带容器删除即数据蒸发;volume(bind mount 或命名卷)直接落在宿主机文件系统或独立块设备上,绕开 overlayfs。日志二选一:要么 stdout 交给日志收集器(12-Factor 正统),要么 bind mount 到宿主机目录——绝不往镜像层路径里写日志(copy-up)。高频临时文件用 tmpfs:docker run --tmpfs /tmp:rw,size=1g 把临时目录挂成内存盘,构建类容器(CI 编译、测试)收益极大,也天然免除磁盘磨损。/dev/shm 默认只有 64MB:浏览器渲染、IPC 密集型应用在容器里崩在 /dev/shm 写满上,--shm-size=1g 一行解决。容器可写层默认没有大小上限:Java 堆转储、core dump、失控的下载都会无声写满宿主机磁盘——对暴露上传/生成能力的容器,--storage-opt size=20g(需文件系统 pquota 支持)或把可变目录挂 volume 并配配额是必要的防御。CI/CD 高并发构建场景优先 tmpfs + 层缓存:构建容器把工作区放 tmpfs、依赖缓存挂命名卷,既躲开元数据性能税,又利用镜像层页缓存共享降低宿主机内存压力。

为什么你的应用会受这个影响

三个真实故障模式,读者大概率遇到过至少一个。模式一:容器内写日志越来越慢、宿主机磁盘悄悄涨——查 docker inspect 的 UpperDir 再 du -sh 一眼,如果可写层异常膨胀,几乎必然是把可变数据写进了镜像层路径;修复路径是 volume/tmpfs 迁移,而不是给宿主机扩盘。模式二:rm 大文件后磁盘告警不解除——镜像层文件在容器内的删除只是 whiteout,检查方法是 docker system df -v 看可写层占用,释放靠容器重建;这个机制同样解释了「在容器里 apt upgrade 后镜像没变小」。模式三:K8s Pod 的 emptyDir 写爆节点盘——emptyDir 默认落在节点根盘(同一块盘也被镜像层、容器可写层共享),任何一个容量坑都会连坐镜像拉取与全部容器;用 emptyDir.medium: Memory(tmpfs 化)、或单独盘的 local PV 隔离,是节点稳定性的基本功。存储驱动这一层平时完全透明,但它决定了「容器里的每一次写」的真实代价——出问题时的排查入口就是那两条命令:docker info(驱动与底层文件系统)和 mount | grep overlay(层的真实路径)。

两个驱动级开关与一个限容实践:把可写层的代价调到可控

docker info 输出里还有两行不起眼的驱动级开关值得认识:Using metacopy 与 Native Overlay Diff。metacopy 改造的正是 copy-up 的全量搬运:修改镜像层文件时,overlayfs 先在 upperdir 创建一个只带扩展属性的「元数据影子文件」,声明「数据暂用 lower 层的」,真正被写入的块才落到可写层——对 100MB 文件改 4KB,实际复制量从 100MB 降到 KB 级。代价是读取链路多一次间接寻址,且层叠过深时内核会放弃 metacopy 回退全量 copy-up;它要求内核 4.19+ 并在 daemon.json 显式开启("storage-opts": ["overlay2.metacopy=true"])。Native Overlay Diff 优化的是镜像提交方向:容器 commit 不再跑用户态 tar 逐层对比,由 overlayfs 直接报告 upperdir 差异,大镜像的构建收尾明显加快。再看一个部署级限容实践:xfs 以 pquota 挂载后,--storage-opt size=20g 能把单个容器的可写层变成硬顶——写满即 ENOSPC,报错隔离在容器内,而不是失控进程连坐宿主机:

$ docker run -d --storage-opt size=20g myapp
$ docker exec myapp df -h / | tail -1
overlay        20G   2.1G   18G  11% /

三个开关都不改变本篇的核心结论——可变数据仍然应该离开可写层——它们只是把「不得不落在可写层的写」的成本与爆炸半径压进可核算的范围。

小结

overlay2 驱动把容器根文件系统变成「镜像只读层 + 容器可写层」的叠加投影:读是逐层查找,写全部落在可写层,修改镜像层文件触发整文件 copy-up,删除镜像层文件只是写 whiteout 标记。它的日常性能已贴近原生盘,代价集中在「修改大文件」与「元数据密集」两类负载;同一镜像的容器共享只读层与页缓存,是规模化部署的隐性红利。devicemapper 用块级快照换来了细粒度 CoW,但元数据与运维成本(direct-lvm 铁律)让它退出了历史舞台。工程结论浓缩成一句话:可变数据永远离开容器可写层——数据库、日志、临时大文件各回 volume、stdout、tmpfs 的正确位置,把可写层留给真正「可抛弃」的东西,容器的弹性优势才不会变成存储事故的温床。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?