Docker overlay2 分析

前言

Docker的官方定义:

​ Docker是以Docker容器为资源分割和调度的基本单位,封装整个软件运行时环境,为开发者和系统管理员设计的,用于构建、发布和运行分布式应用的平台。它是跨平台、可移植并且简单易用的容器解决方案。Docker的源代码托管在GitHub上,基于GO语言开发并遵循从apache2.0协议。Docker可在容器内部快速自动化地部署应用,并通过操作系统内核技术为容器提供资源隔离与安全保障。

以上加粗了两个词:简单易用、快速自动化部署。

那么这两个功能具体是通过什么实现的?又是如何实现的?

这就是本文讨论的Docker overlay2所实现的内容。

PS:本文中所用到的附件可在以下链接下载:

https://pan.baidu.com/s/19xlAm4F_X16Ibb5LUnjEaw?pwd=6666 提取码: 6666

在学习Docker overlay2之前,我们还需要一点前置知识。

rootfs

rootfs 也叫根文件系统,是 Linux 使用的最基本的文件系统,是内核启动时挂载的第一个文件系统,提供了根目录/,根文件系统包含了系统启动时所必须的目录和关键性文件,以及使其他文件系统得以挂载所必要的文件。在根目录下有根文件系统的各个目录,例如 /bin、/etc、/mnt 等,再将其他分区挂载到 /mnt,/mnt 目录下就有了这个分区的各个目录和文件。

举个例子,找到通过上文链接下载的附件中ubuntu-base-20.04.3-base-arm64.tar.gz,将其拷贝到安装docker系统的任意目录,创建一个Dockerfile文件,将如下内容写入

1
2
3
FROM scratch
ADD ubuntu-base-20.04.3-base-arm64.tar.gz /
CMD ["/bin/bash"]

build并运行镜像

docker build .

docker run -it XXXXXXXXXX

image-20230214140241957

从上图中可以看出我们利用ubuntu的rootfs创建了一个最基础的系统,可以完成基本命令等操作,但是需要注意的是我们通过build建立的系统是在rootfs的基础上挂载了一个读写层,那么docker的文件系统为什么是一层一层的?这就不得不提到UnionFS(联合文件系统)了。

UnionFS

UnionFS(联合文件系统):UnionFS是一种分层、轻量级并且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下

结合上一个例子我们来对UnionFS进行具体介绍:

292888-20200628130907346-881829309

图中的bootfs是内核相关内容
图中的Base Image(rootfs)就是我们build操作后到ubuntu镜像(不是run后得到的)
rootfs和bootfs都是只读的

如果我们在基础镜像的基础上增加一些如Apache镜像,那么镜像会变成什么呢?请看图:

image-20230214143624155

请注意:图中的所有内容都是只读的,且都是镜像

当我们启动容器时,一个新的可写层加载到镜像的顶部!这一层就是我们通常说的容器层,容器之下的都叫镜像层!如图:

image-20230214144318076

通过以上介绍我们知道UnionFS联合并挂载了只读层、和读写层,但实际上还有一个工作基础目录work,挂载后内容被清空,且在使用过程中其内容不可见,所以总结如下:

  • lower目录:只读层,可以有多个,处于最底层目录
  • upper目录:读写层,只有一个
  • work目录:工作基础目录,挂载后内容被清空,且在使用过程中其内容不可见
  • merged目录:联合挂载后得到的视图,其中本身并没有实体文件,实际文件都在upper目录和lower目录中,在merged目录中对文件进行编辑,实际会修改upper目录中文件,而在upper目录与lower目录中修改文件,都会影响我们在merged目录中看到的结果

overlayFS

overlayFS是一种类似aufs的一种堆叠文件系统,于2014年正式合入Linux-3.18主线内核,目前其功能已经基本稳定(虽然还存在一些特性尚未实现)且被逐渐推广,特别在容器技术中更是势头难挡。

overlayFS目前有两个版本的存储驱动,分别是overlay和overlay2,如果你的docker不是那么老旧,那么一定使用的是overlay2,因为它更新、更稳定

我们通过一些简单的操作演示来深入理解不同层不同目录之间的关系

在mnt目录。 创建几个文件夹和文件:

mkdir A
mkdir B
mkdir C

echo “from A” > A/a.txt
echo “from A” > A/b.txt
echo “from A” > A/c.txt
echo “from B” > B/a.txt
echo “from B” > B/d.txt
echo “from C” > C/b.txt
echo “from C” > C/e.txt

tree .

image-20230214153631230

使用 mount命令挂载成 overlayFS 文件系统,用 A 和 B 两个文件夹作为 lower 目录,用 C 作为 upper 目录,worker 作为 work 目录,并对挂载内容进行查看

mkdir merged

mkdir worker

mount -t overlay overlay -o lowerdir=A:B,upperdir=C,workdir=worker /mnt/merged

tree merged

image-20230214153602733

这里不难看出原本的文件结构已经发生了变化,在分析文件结构前我先给出其规律:

  1. 同层中排序越前离merged层越近的文件优先(如A比B排序前)
  2. 不同层中离merged层越近的文件优先(如A和C中的b.txt)

根据给出的规律,可以发现a.txt遵守第一条,b.txt遵守第二条,我们可以验证一下

image-20230214161855753

接下来我们查看upper 层读写和lower 层只读的性质是否还存在,尝试修改merged层的a.txt文件,并在修改后对A和C中的a.txt进行查看

echo “TEST” > a.txt

cat ../A/a.txt

cat ../C/a.txt

image-20230214162638154

可以看到A中的a.txt文件内容没有改变,但是C中的a.txt文件内容被修改了,说明merged层在修改文件的时候只会对读写层进行修改,并不会修改只读层文件的内容

PS:别忘了卸载

umount /mnt/merged

overlay2

overlay2存储文件的方式:将镜像层和容器层都放在单独的目录,并且有唯一 ID,每一层仅存储发生变化的文件,最终使用联合挂载技术将容器层和镜像层的所有文件统一挂载到容器中,使得容器中看到完整的系统文件。

overlay2与overlay的不同点:

  1. overlay的lowdir只有一层,每层只读层都通过硬链接共享文件,因此每层只读层都有一套完整的增量,增加了磁盘的负担。
  2. overlay2的只读层是独立的个体,并且将其联合到一起,容器启动时统一挂载到merged。

overlay2相比于overlay对资源的占用更小、更快速

若是你想知道自己的docker使用的是overlay还是overlay2,可以执行此命令查看

docker info | grep -i “storage driver”

image-20230215172247614

我们知道了lower是一层一层叠加的,那么其最大数量有没有限制呢?答案是有的,查看overlay2底层代码:

1
2
3
4
5
6
7
// daemon/graphdriver/overlay2/overlay.go#L442
func (d *Driver) getLower(parent string) (string, error) {
// 省略部分内容
if len(lowers) > maxDepth {
return "", errors.New("max depth exceeded")
}
}

可以看出lower有硬编码限制,而硬编码的限制为128,即overlay2的lower最大可叠加层数为128

在日常使用Docker时,我们能够接触到最多的就是镜像层(docker pull)和容器层(docker exec),并且一个镜像可以生成很多容器(docker run),那么我就针对镜像层和容器层以及其元数据做一个详细的探讨。

镜像层(image)

首先我们先准备一个全新的,空的docker,将之前的ubuntu镜像导入

image-20230216152543556

使用inspect命令查看镜像配置中的GraphDriver字段,可以发现其中有overlay2文件结构的目录信息

docker image inspect XXXXXXXX

image-20230216152825984

我们将目录切换到/var/lib/docker/overlay2中,由于最基础的系统只有一个镜像层,我们直接直接进入

image-20230216153049880

细心的读者可能发现在我们使用inspect命令查看有overlay2文件结构的目录信息时,有merged、diff、work,但是我们实际进入文件夹查看时,却没有merged和work,其中work在挂载后会自动删除,但是为什么没有merged呢?

因为在overlay2中merged是镜像层文件的视图,在我们实际使用中是对容器层进行操作的,不需要merged,但是overlay2为了显示其完成的文件结构,所以在镜像配置信息中我们可以看到merged

现在我们删除ubuntu镜像,拉取一个比较复杂的镜像

docker pull xaor/lamp-php5.6

image-20230216155635567

进入到/var/lib/docker/overlay2下,打开一个镜像层查看一下有什么文件

image-20230216155839659

image-20230216160026143

可以发现相比于之前的ubuntu镜像多了link和lower,为什么会多这两个文件?这几个文件具体是什么?接下来我们详细探讨一下。

diff

diff目录中存放的是当前镜像层的所有文件,随便进入两个镜像层文件的diff目录查看我们就可以发现diff目录中都是所构成镜像的基础文件。

image-20230216164101113

overlay2是将所有镜像层的文件联合起来挂载得到完整的rootfs,那么它们是怎么联合起来的?请看link和lower。

link文件中存放的内容是当前层的软链接名称,cat一下link文件就可以发现其存放的内容是一串iD

image-20230216165257399

那么其存放的ID和当前镜像的关系是什么呢?我们可以在l目录下找到答案。

ls -al l

image-20230216165344543

不难发现link和镜像的关系是一一对应的软链接,但是镜像的名称也可以作为其ID,为什么overlay2没有这么做?

因为镜像名称的长度超过了mount命令,为了其能成功挂载而使用了更短的ID来区分每一层镜像。

lower

lower文件中存放的内容是此层之下所有层的软链接名称

通过之前的overly文件结构实操我们总结出了:同层中排序越前离merged层越近的文件优先(如A比B排序前),lower实现的功能就是将镜像进行排序

lower文件还有一个性质:最底层的镜像层不存在lower文件,因为此层之下没有镜像层了,我们验证一下:

通过docker image inspect查看LowerDir,找到第一个镜像层的名称和最后一个镜像层的名称,先查看第一个

image-20230216170643320

image-20230216170829496

可以发现在lower中存放的link是从高到低的,在查看最后一个镜像层

image-20230216170936552

发现没有lower文件,这也验证了lower文件的性质

元数据(meta data)

元数据 (meta data)——“data about data” 描述数据的数据,一般是结构化数据 ( 如存储在数据库里的数据,规定了字段的长度、类型等 )

在docker中就是指创建容器时,容器自带的信息,其目录在/var/lib/docker/image/overlay2/(和镜像层不同)

image-20230216172751926

我们主要探讨其中的imagedb和layerdb

imagedb

imagesdb中存储的是镜像相关的元数据,具体位置在/imagedb/content/sha256目录,

其存储的信息和我们平常使用docker息息相关,如docker imges查看镜像中的image id列表和其sha256目录下的文件名称对应

image-20230216173340545

再如docker image inspect 查看到的相关信息和其sha256目录下的文件内容对应,图中的Layers就是diff_id,diff_id对应每一个镜像层,但是实际上还有另外两个 id:cache_id 和 chain_id对应每个镜像层

image-20230216173740922

cache_id就是docker/overlay2所有文件夹的名称,其本质是宿主机生成uuid,chain_id我们在layerdb中讨论

layerdb

layerdb/sha256下的目录名称是以layer的chainID来命名的,它的计算方式为:

  • 如果layer是最底层,没有任何父layer,那么diffID = chainID;
  • 否则,chainID(n)=sha256sum(chainID(n-1)) diffID(n))

image-20230216174601155

所以三个id之间一一对应,我们可以用chainID通过计算推出diffID,也可以将其反推用diffID得到chainID

容器层(container)

既然讲到了容器层,我们就要先启动一个容器

docker run -itd -p5000:80 748 /start.sh

回到/var/lib/docker/overlay2目录,可以发现多了一个init结尾文件夹,这是init层,init层处在容器层之下,它的作用是用于修改容器中一些文件如/etc/hostname,/etc/hosts,/etc/resolv.conf等。和它同名不带-init的是容器层,它是和镜像层在同一目录的

image-20230216182236113

我们进入其容器层目录,发现容器层中有了merged目录

image-20230216182646008

进入merged目录,发现其展示了完整的rootfs文件系统,验证了overlay2的工作方式:overlay2的只读层是独立的个体,并且将其联合到一起,容器启动时统一挂载到merged。也就是我们通过docker exec进入容器所看到的视图。

image-20230217092341583

link和lower文件的内容和作用同镜像层是一样的,这里说说不一样的diff目录。diff目录在容器层中充当upper层的角色,它的用途是用于存储用户在对容器文件的编辑。

image-20230217092840588

但是对文件的编辑后存储的方式有所不同,我们先实操一下,进入一个容器,我们在run目录中随便创建一个文件

image-20230217093311980

不难发现创建的文件在容器层的diff目录中的run目录中出现了我们创建的txt文件,接下来我们删除一个镜像中自带的文件如run中的apache2文件夹

image-20230217094250261

image-20230217094234629

可以看出我们在查看镜像层文件时apache2文件夹并没有被删除,而是被隐藏了,这是为什么?让我们先学习一下whiteout文件

without文件的概念

whiteout 概念存在于联合文件系统(UnionFS)中,代表某一类占位符形态的特殊文件,当用户文件夹与系统文件夹的共通部分联合到一个目录时(例如 bin 目录),用户可删除归属于自己的某些系统文件副本,但归属于系统级的原件仍存留于同一个联合目录中,此时系统将产生一份 whiteout 文件,表示该文件在当前用户目录中已删除,但系统目录中仍然保留。

理解一下就是当我们删除镜像层和容器层联合文件时,容器层会在镜像层创建一个without文件,在容器层中文件已经被删除,但是在镜像层中文件被隐藏,所以就看到了我们实操后的结果。

本文对Docker overlay2的分析到这里就结束了

总结

我写这篇文章的原因是我在ctf比赛中遇到了一道docker镜像层文件取证的题目,在之后复现时发现自己对docker的文件结构并不是很了解,在学习的过程中总结出了这篇文章,如果您发现文章中有错误还请您一定要指出。文章篇幅比较长,非常感谢您能看到这里,希望这篇文章对您的学习有所帮助。

参考文章:

搭建最小ubuntu20.04系统

联合文件系统(UnionFS)和 镜像分层

Docker镜像内部结构

深入理解 overlayfs

Docker:overlay2浅析

聊聊 Docker 的存储 Overlay2

Docker底层:Overlay2 文件系统原理

手撕docker文件结构 —— overlayFS,image,container文件结构详解

Docker inspect 容器元数据

Linux whiteout文件