打工仔折腾 AI头像
关注
Docker镜像分层与卷挂载到底怎么工作:一次文件系统层面的实测分析封面图

Docker镜像分层与卷挂载到底怎么工作:一次文件系统层面的实测分析

用 Docker 部署服务的时候,我遇到过一个当时没想明白的现象:同一个镜像起了三个容器,往其中一个容器的 /app/data 目录写文件,另外两个容器完全看不到。但把镜像换成新版本重新构建,三个容器又都拿到了新代码。这两件事背后其实是两套不同的机制——镜像分层和卷挂载。搞不清它们的边界,排查问题时很容易往错误的方向找。

这篇文章不打算复述 Docker 文档,而是从文件系统层面把这两件事拆开看。下面所有命令都可以直接跑,存储驱动以 overlay2 为例(这是目前主流 Linux 发行版上 Docker 的默认驱动,可以用 docker info | grep "Storage Driver" 确认自己的环境)。

先看镜像到底存在哪

镜像不是一个 tar 包躺在磁盘上,而是被拆成了若干层,每层对应 Dockerfile 里的一条指令。可以先拉一个小镜像看看实际情况:

docker pull alpine:3.19
docker image inspect alpine:3.19 --format '{{json .RootFS.Layers}}' | python3 -m json.tool

输出是一个数组,每个元素是一个层的 digest。层数取决于镜像怎么构建的。alpine 这种官方精简镜像层数不多,但一个典型的 Python 应用镜像,从基础镜像到 pip install 再到 COPY . .,层数会明显增加。

这些层在宿主机上的落地位置,取决于存储驱动。overlay2 的情况下通常在 /var/lib/docker/overlay2/:

sudo ls /var/lib/docker/overlay2/ | head

每个目录下面一般有 diff、link、lower、merged、work 这几个子目录。diff 是这一层实际的文件内容,lower 记录了它下面压着哪些层,merged 是把多层叠起来之后的合并视图。这里能看到一个关键点:镜像层本身是只读的,多个容器共享同一份底层数据。

分层叠加:读时合并,写时复制

分层叠加:读时合并,写时复制

容器启动后,Docker 会在所有镜像层之上再加一个可写的容器层。读文件的时候,overlay2 从上往下找,找到第一个匹配的就返回;写文件的时候,如果文件在只读层里,就先把它复制到可写层再改。这就是常说的 Copy-on-Write(CoW)。

这个行为可以直接验证。先起一个容器:

docker run -d --name demo1 alpine:3.19 sleep 3600
docker exec demo1 sh -c 'echo hello > /tmp/test.txt'

然后在宿主机上找这个文件。容器层对应的是 overlay2 里某个目录的 diff,可以这样定位:

docker inspect demo1 --format '{{.GraphDriver.Data.UpperDir}}'

执行后会打印出容器可写层的实际路径。进去看一眼:

sudo ls -l <上面输出的路径>/tmp/

应该能看到 test.txt。这说明容器里新写的文件,物理上就落在宿主机这个目录里。

再验证 CoW:往一个镜像里已存在的文件写内容,看看会发生什么。alpine 里 /etc/hostname 是存在的,改它:

docker exec demo1 sh -c 'echo changed > /etc/hostname'
sudo ls -l <UpperDir>/etc/

这时候 /etc/hostname 会出现在可写层的 diff/etc/ 下——因为它原本在只读层,写入触发了复制。而如果只是读 /etc/passwd 不改,可写层里就不会有它。

【关键结论】镜像层共享且只读,容器层独立且可写。同一镜像起 N 个容器,镜像数据只有一份,容器层各有一份。这就是为什么改一个容器的文件不影响其他容器。

这个机制带来一个实际影响:容器里删文件并不会真的释放磁盘。用 rm 删掉镜像层里的文件,overlay2 采用的是 whiteout 机制,在可写层放一个特殊标记,底层数据还在。所以频繁在容器里增删大文件,容器层会越来越胖,镜像本身不受影响。

卷挂载:绕过叠加层

卷挂载:绕过叠加层

容器层可写,但生命周期跟着容器走,容器删了数据就没了。要在容器之间共享数据,或者让数据持久化,就得用卷。Docker 里有两类容易混淆的方式:volume 和 bind mount。

先看 volume:

docker volume create mydata
docker run -d --name demo2 -v mydata:/data alpine:3.19 sleep 3600
docker exec demo2 sh -c 'echo persisted > /data/file.txt'

volume 的实际路径可以通过 inspect 拿到:

docker volume inspect mydata --format '{{.Mountpoint}}'

通常在 /var/lib/docker/volumes/mydata/_data。进去能看到 file.txt。

再看 bind mount,直接把宿主机某个目录挂进去:

mkdir -p /tmp/hostdata
docker run -d --name demo3 -v /tmp/hostdata:/data alpine:3.19 sleep 3600
docker exec demo3 sh -c 'echo fromcontainer > /data/file.txt'
cat /tmp/hostdata/file.txt

宿主机上能直接看到容器写的文件。

这里有个容易忽略的细节:挂载点会覆盖容器内该路径原有的内容。比如镜像里 /data 本来有文件,一旦把卷挂到 /data,容器里看到的就是卷的内容,镜像里的那份被遮住了。这不是删除,只是被挂载点挡住了。把挂载去掉,原内容又回来了。这一点在排查"文件怎么不见了"的时候特别有用。

两种方式的区别整理一下:

方案优点缺点适用场景
volume由 Docker 管理,跨平台一致,权限问题少,适合生产路径不直观,需 inspect 才能定位数据库数据、应用持久化目录
bind mount路径明确,宿主机可直接编辑依赖宿主机目录结构,权限/路径差异大,可移植性差本地开发挂代码、挂配置文件

三种写入路径的对比实验

三种写入路径的对比实验

把上面几种情况放一起,能更清楚数据到底落在哪。设计一个对照:分别往容器可写层、volume、bind mount 写文件,然后看宿主机上的位置和容器删除后的表现。

# 1. 只写容器层
docker run --name t1 alpine:3.19 sh -c 'echo a > /root/a.txt'
# 2. 写 volume
docker run --name t2 -v voltest:/root alpine:3.19 sh -c 'echo b > /root/b.txt'
# 3. 写 bind mount
mkdir -p /tmp/bindtest
docker run --name t3 -v /tmp/bindtest:/root alpine:3.19 sh -c 'echo c > /root/c.txt'

三个容器跑完就退出了(没有 -d,命令执行完容器停止)。现在把它们删掉,再看数据:

docker rm t1 t2 t3
sudo cat /var/lib/docker/volumes/voltest/_data/b.txt   # 还在
cat /tmp/bindtest/c.txt                                 # 还在
# t1 的 a.txt 随着容器删除一起没了

【踩坑提醒】容器层的数据在 docker rm 之后会一起被清理。如果只是 docker stop,容器层还在,数据还在,docker start 起来能看到。真正丢数据是发生在 rm 的时候。所以把数据库这种需要持久化的东西放在容器层,是迟早要出问题的。

volume 的好处在这里体现出来:它的生命周期独立于容器,容器删了卷还在,新容器挂上同一个卷就能接着用。这也是为什么官方推荐生产环境用 volume 存数据。

一个容易搞混的点:volume 和 bind mount 的写法

一个容易搞混的点:volume 和 bind mount 的

-v 的两种写法长得像但含义不同:

-v mydata:/data          # mydata 不是绝对路径 → 当成 volume,Docker 管理
-v /tmp/hostdata:/data   # 以 / 开头 → 当成 bind mount,用宿主机路径

以 / 开头就是 bind mount,否则是 volume。这个规则在 --mount 语法里更明确:

--mount type=volume,source=mydata,target=/data
--mount type=bind,source=/tmp/hostdata,target=/data

--mount 写法更啰嗦,但意图清楚,不会因为少写一个 / 就把 bind mount 变成 volume。我个人在脚本里更倾向用 --mount,出问题的概率低一些。

关于性能的一点说明

overlay2 的 CoW 在写大文件时会有额外开销,因为要先复制。这一点在容器里跑数据库、频繁写大文件时能感觉到。把数据目录挂成 volume 能绕开这个复制过程,因为 volume 直接落在宿主机文件系统上,不经过叠加层。

不过具体差多少,取决于文件大小、写入模式和底层文件系统,我没有做过系统的基准测试,这里不下定量结论。只能说方向上,持久化数据走 volume 比留在容器层更合适。

收尾

把这两件事理清楚之后,很多现象就不奇怪了:

  • 容器间数据不共享,是因为各自有独立的可写层。
  • 重建镜像后所有容器都更新,是因为它们共享同一批只读镜像层,镜像换了层也就换了。
  • 挂载卷后文件"消失",是被挂载点遮住了,不是真删了。
  • 容器删了数据没了,是因为数据写在了容器层而不是卷里。

排查这类问题时,先问一句"这个文件在容器层、volume 还是 bind mount 里",基本能定位大半。剩下的就是去对应的宿主机路径看实际内容。docker inspect 拿 UpperDir 和 Mountpoint 这两个字段,是我用得最多的两个入口。

有一块我还没深入:overlay2 之外,btrfs、zfs 这些驱动的层管理方式不太一样,CoW 的实现和性能特征也不同。如果后面有环境,可以单独测一轮再做对比。

=备用标题=

  1. Docker 镜像分层与卷挂载实测:容器里的文件到底写到了哪
  2. 从 overlay2 看 Docker 存储:镜像层、容器层与卷的关系
  3. Docker 数据持久化怎么选:容器层、volume 与 bind mount 对比
  4. 一次搞懂 Docker 镜像分层和 CoW:附可复现验证步骤
  5. Docker 容器里的文件为什么删不掉也丢不了:存储机制拆解

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2402_83344867/article/details/167223161

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--