剖析 Docker 文件系统:Aufs 与 Devicemapper

阅读数:23873 2015 年 4 月 21 日

Docker 在启动容器的时候,需要创建文件系统,为 rootfs 提供挂载点。最初 Docker 仅能在支持 Aufs 文件系统的 Linux 发行版上运行,但是由于 Aufs 未能加入 Linux 内核,为了寻求兼容性、扩展性,Docker 在内部通过 graphdriver 机制这种可扩展的方式来实现对不同文件系统的支持。目前,Docker 支持 Aufs,Devicemapper,Btrfs 和 Vfs 四种文件系统。若想了解更多内容可以点击这里

本文以 Docker 1.4 为基础,首先分析 Docker 镜像的结构,接着介绍 Aufs 和 Devicemapper 的应用,最后从代码的角度分析 Docker 文件系统的实现。

Docker 镜像

典型的 Linux 文件系统由 bootfs 和 rootfs 两部分组成,bootfs(boot file system) 主要包含 bootloader 和 kernel,bootloader 主要是引导加载 kernel,当 kernel 被加载到内存中后 bootfs 就被 umount 了。 rootfs (root file system) 包含的就是典型 Linux 系统中的 /dev,/proc,/bin,/etc 等标准目录和文件。

Docker 容器是建立在 Aufs 基础上的,Aufs 是一种 Union FS, 简单来说就是支持将不同的目录挂载到同一个虚拟文件系统下,并实现一种 layer 的概念。Aufs 将挂载到同一虚拟文件系统下的多个目录分别设置成 read-only,read-write 以及 whiteout-able 权限,对 read-only 目录只能读,而写操作只能实施在 read-write 目录中。重点在于,写操作是在 read-only 上的一种增量操作,不影响 read-only 目录。当挂载目录的时候要严格按照各目录之间的这种增量关系,将被增量操作的目录优先于在它基础上增量操作的目录挂载,待所有目录挂载结束了,继续挂载一个 read-write 目录,如此便形成了一种层次结构。

Docker 镜像的典型结构如下图。传统的 Linux 加载 bootfs 时会先将 rootfs 设为 read-only,然后在系统自检之后将 rootfs 从 read-only 改为 read-write,然后我们就可以在 rootfs 上进行写和读的操作了。但 Docker 的镜像却不是这样,它在 bootfs 自检完毕之后并不会把 rootfs 的 read-only 改为 read-write。而是利用 union mount(UnionFS 的一种挂载机制)将一个或多个 read-only 的 rootfs 加载到之前的 read-only 的 rootfs 层之上。在加载了这么多层的 rootfs 之后,仍然让它看起来只像是一个文件系统,在 Docker 的体系里把 union mount 的这些 read-only 的 rootfs 叫做 Docker 的镜像。但是,此时的每一层 rootfs 都是 read-only 的,我们此时还不能对其进行操作。当我们创建一个容器,也就是将 Docker 镜像进行实例化,系统会在一层或是多层 read-only 的 rootfs 之上分配一层空的 read-write 的 rootfs。

为了形象化 Docker 的镜像结构,我们docker pull一个 ubuntu:14.04 的镜像,使用docker images -tree查看结果如下:

[root@qingze qingze]# docker images -tree
Warning: '-tree' is deprecated, it will be removed soon. See usage.
└─511136ea3c5a Virtual Size: 0 B
  └─3b363fd9d7da Virtual Size: 192.5 MB
   └─607c5d1cca71 Virtual Size: 192.7 MB
     └─f62feddc05dc Virtual Size: 192.7 MB
       └─8eaa4ff06b53 Virtual Size: 192.7 MB Tags: ubuntu:14.04,

可以看到 Ubuntu 的镜像中有多个长 ID 的 layer,且以一种树状结构继承下来,如下图。其中,第 n+1 层继承了第 n 层,并在此基础上有了自己的内容,直观上的表现就是第 n+1 层占用磁盘空间增大。并且,不同的镜像可能会有相同的父镜像。例如,图中 Tomcat 和 Nginx 继承于同一个 Vim 镜像,这种组织方式起到共享的作用,节约了镜像在物理机上占用的空间。

接下来,使用docker save命令将镜像具体化并查看其结构:

[root@qingze qingze]# docker save -o ubuntu.tar ubuntu:14.04
[root@qingze qingze]#  tar -tf ubuntu.tar 
3b363fd9d7dab4db9591058a3f43e806f6fa6f7e2744b63b2df4b84eadb0685a/
3b363fd9d7dab4db9591058a3f43e806f6fa6f7e2744b63b2df4b84eadb0685a/VERSION
3b363fd9d7dab4db9591058a3f43e806f6fa6f7e2744b63b2df4b84eadb0685a/json
3b363fd9d7dab4db9591058a3f43e806f6fa6f7e2744b63b2df4b84eadb0685a/layer.tar
511136ea3c5a64f264b78b5433614aec563103b4d4702f3ba7d4d2698e22c158/
511136ea3c5a64f264b78b5433614aec563103b4d4702f3ba7d4d2698e22c158/VERSION
511136ea3c5a64f264b78b5433614aec563103b4d4702f3ba7d4d2698e22c158/json
511136ea3c5a64f264b78b5433614aec563103b4d4702f3ba7d4d2698e22c158/layer.tar
607c5d1cca71dd3b6c04327c3903363079b72ab3e5e4289d74fb00a9ac7ec2aa/
607c5d1cca71dd3b6c04327c3903363079b72ab3e5e4289d74fb00a9ac7ec2aa/VERSION
607c5d1cca71dd3b6c04327c3903363079b72ab3e5e4289d74fb00a9ac7ec2aa/json
607c5d1cca71dd3b6c04327c3903363079b72ab3e5e4289d74fb00a9ac7ec2aa/layer.tar
8eaa4ff06b53ff7730c4d7a7e21b4426a4b46dee064ca2d5d90d757dc7ea040a/
8eaa4ff06b53ff7730c4d7a7e21b4426a4b46dee064ca2d5d90d757dc7ea040a/VERSION
8eaa4ff06b53ff7730c4d7a7e21b4426a4b46dee064ca2d5d90d757dc7ea040a/json
8eaa4ff06b53ff7730c4d7a7e21b4426a4b46dee064ca2d5d90d757dc7ea040a/layer.tar
f62feddc05dc67da9b725361f97d7ae72a32e355ce1585f9a60d090289120f73/
f62feddc05dc67da9b725361f97d7ae72a32e355ce1585f9a60d090289120f73/VERSION
f62feddc05dc67da9b725361f97d7ae72a32e355ce1585f9a60d090289120f73/json
f62feddc05dc67da9b725361f97d7ae72a32e355ce1585f9a60d090289120f73/layer.tar
repositories

我们可以发现在具体化的镜像中对应树型结构中的每一个 layer 都有一个文件夹存在,且每个文件夹中包含 VERSION,json,layer.tar 三个文件, 另外还有一个 repositories 文件。长 ID 为 511136ea3c5a 的 layer 没有继承任何 layer,因此被称为 base 镜像,一般来说镜像都是以这个 layer 开始的, 其内容也为空。

[root@qingze 3b363fd9d7dab4db9591058a3f43e806f6fa6f7e2744b63b2df4b84eadb0685a]# ls
json  layer.tar  VERSION
[root@qingze 3b363fd9d7dab4db9591058a3f43e806f6fa6f7e2744b63b2df4b84eadb0685a]# cat json | python -m json.tool
{
    "Size": 192480945,
    "architecture": "amd64",
    "checksum": "tarsum.dev+sha256:3679c533275a3074e9ab22c1a98a5c63515df754dacecb36cf03115a6ce7cc44",
    "config": {
        "AttachStderr": false,
        "AttachStdin": false,
        "AttachStdout": false,
        "Cmd": null,
        "CpuShares": 0,
        "Cpuset": "",
        "Domainname": "",
        ...
    },
    "container": "8c41fcbc2d072f0158d254ca92f33e10d7cb57af3eec3cd3c7fb308a6f24a3e0",
    "container_config": {
        ...
    },
    "created": "2015-01-01T01:25:18.368698296Z",
    "docker_version": "1.4.1",
    "id": "3b363fd9d7dab4db9591058a3f43e806f6fa6f7e2744b63b2df4b84eadb0685a",
    "os": "linux",
    "parent": "511136ea3c5a64f264b78b5433614aec563103b4d4702f3ba7d4d2698e22c158"
}

由于篇幅关系,这里只截取了 json 文件的一部分内容,可以看出 json 文件包含了镜像以及其对应的容器的配置信息,其中有docker run时指定的,如CpuSharesCpuset,也有系统生成的,如parent,正是这个属性记录了继承结构,从而实现了 layer 的层次关系。

另外,每个文件夹下都有一个 layer.tar 的文件,这个压缩文件存放的正是 rootfs 的内容,不过是增量存放的而已。为了证明这一点我们找到 Ubuntu:14.04 对应的 layer 是8eaa4ff06b53,进入该目录,查看 layer 的内容,未发现任何东西。

[root@qingze 8eaa4ff06b53ff7730c4d7a7e21b4426a4b46dee064ca2d5d90d757dc7ea040a]# tar -tf layer.tar 
[root@qingze 8eaa4ff06b53ff7730c4d7a7e21b4426a4b46dee064ca2d5d90d757dc7ea040a]# 

然后,我们使用该镜像启动一个容器,创建一个 100M 的文件并docker commit该容器,具体化为 ubuntu:test 后重新查看。结果显示,ubuntu:test 相比 ubuntu:14.04 多了一个7e64c2624ce0文件夹,且该文件夹下的 layer.tar 中有了 test 文件。读者亦可对比各个长 ID 中的内容,以加深理解。

[root@qingze qingze]# docker images -tree
Warning: '-tree' is deprecated, it will be removed soon. See usage.
└─511136ea3c5a Virtual Size: 0 B
  └─3b363fd9d7da Virtual Size: 192.5 MB
   └─607c5d1cca71 Virtual Size: 192.7 MB
     └─f62feddc05dc Virtual Size: 192.7 MB
       └─8eaa4ff06b53 Virtual Size: 192.7 MB Tags: ubuntu:14.04
         └─7e64c2624ce0 Virtual Size: 297.5 MB Tags: ubuntu:test
[root@qingze qingze]# docker save -o ubuntu-test.tar ubuntu:test 
[root@qingze qingze]# mkdir ubuntu-test
[root@qingze qingze]# tar -xf ubuntu-test.tar -C ubuntu-test/
[root@qingze qingze]# cd ubuntu-test/
[root@qingze ubuntu-test]# cd 7e64c2624ce0fe1b893ecfbec4e47eb3bd80dd30e615e376ac37405a7baa4e4c/
[root@qingze 7e64c2624ce0fe1b893ecfbec4e47eb3bd80dd30e615e376ac37405a7baa4e4c]# tar -tf layer.tar 
test
root/
root/.bash_history

Aufs 与 Devicemapper 的应用

Aufs 是 Docker 最初采用的文件系统,由于 Aufs 未能加入到 Linux 内核,考虑到兼容性问题,加入了 Devicemapper 的支持。目前,除少数版本如 Ubuntu,Docker 基本运行在 Devicemapper 基础上。这一节中,我们将着重介绍 Aufs 和 Devicemapper 的应用。

Aufs

Aufs 是 Another Union File System 的缩写,是一种 Union FS,支持将多个目录挂载到同一个虚拟目录下。由于上文已有介绍,不再赘述。这里主要介绍一下 Aufs 的使用。默认采用的是 Ubuntu:14.04。

Aufs 的使用非常简单,只需简单的 mount 命令即可:

root@Standard-PC:/tmp# tree
.
├── aufs
├── dir1
│   └── file1
└── dir2
    └── file2
root@Standard-PC:/tmp# sudo mount -t aufs -o br=/tmp/dir1=ro:/tmp/dir2=rw none /tmp/aufs
mount: warning: /tmp/aufs seems to be mounted read-only.
root@Standard-PC:/tmp#

假设在 /tmp 目录下存在如上文件结构,file1 的内容为:this is dir1,file2 的内容为:this is dir2。我们使用mount命令将 dir1 和 dir2 挂载到 /tmp/aufs 目录下,其中:

  • -o 指定 mount 传递给文件系统的参数
  • br 指定需要挂载的文件夹,这里包括 dir1 和 dir2
  • ro/rw 指定文件的权限只读和可读写
  • none 这里没有设备,用 none 表示

经过以上命令,文件夹 dir1 和 dir2 被挂载到了 /tmp/aufs 目录下,且 file1 只有只读属性:

root@Standard-PC:/tmp/aufs# echo hello > file1
bash: file1: Read-only file system
root@Standard-PC:/tmp/aufs# echo hello > file2

root@Standard-PC:/tmp/dir2# cat file2 
hello

写到这里有的读者可能会问,如果 dir1 和 dir2 两个文件夹含有相同的文件会发生什么情况,我们来试一下,将 dir1 中的 file1 更名为 file2, 重新挂载。我们发现 /tmp/aufs 目录中仅有 file2,且内容为 dir1 中 file2 的内容。由此可见,在挂载的过程中,mount命令按照命令行中给出的文件夹顺序挂载,若出现有同名文件的情况,则以先挂载的为主,其他的不再挂载。这也说明了的 Docker 镜像为什么采用增量的方式:完全是利用 Aufs 的特性达到节约空间的目的。

root@wangqingze-Standard-PC:/tmp/aufs# ls
file2
root@wangqingze-Standard-PC:/tmp/aufs# cat file2 
this is dir1
root@wangqingze-Standard-PC:/tmp/aufs# 

Devicemapper

在介绍 Devicemapper 之前,我们首先介绍几个 Linux 下有关 Logical Volume 的概念以及他们的特性,以帮助后文的理解,主要包括:Snapshot,Thinly-Provisioned Snapshot。

Snapshot 是 Lvm 提供的一种特性,它可以在不中断服务运行的情况下为 the origin(original device)创建一个虚拟快照 (Snapshot),它具有以下几个特点:

  1. 当 the origin 内容发生变化时,snapshot 对变化的部分做一个拷贝以用来对 the origin 进行重构。
  2. 因为只对变化的部分做拷贝,所以 Lvm 的 Snapshot 在读操作频繁而写操作不频繁的情况下占用很少的一部分空间便能完成特定任务。
  3. 当 Snapshot 大小耗尽或者远大于实际需求时,我们可以对其大小进行调节。
  4. 当对 Snapshot 的数据进行写操作的时候,Snapshot 实施相应操作,并丢弃从 the origin 的拷贝,以后的操作以写操作之后 Snapshot 中的数据为准。
  5. 在某些发行版的 Linux 系统下,可以使用 lvconvert 的 --merge 选项将 Snapshot 合并回 the origin。

对 Snapshot 的应用诸如:对数据进行实时备份,利用其可读写的特性创建 Snapshot 供测试等等。

Thin-Provisioning 是一项利用虚拟化方法减少物理存储部署的技术,可最大限度提升存储空间利用率。下图中展示了某位用户向服务器管理员请求分配 10TB 的资源的情形。实际情况中这个数值往往是峰值,根据使用情况,分配 2TB 就已足够。因此,系统管理员准备 2TB 的物理存储,并给服务器分配 10TB 的虚拟卷。服务器即可基于仅占虚拟卷容量 1/5 的现有物理磁盘池开始运行。这样的“始于小”方案能够实现更高效地利用存储容量。

Thin-provisioning Snapshot 结合 Thin-Provisioning 和 Snapshot 两种技术,允许多个虚拟设备同时挂载到一个数据卷以达到数据共享的目的。Thin-Provisioning Snapshot 的特点如下:

  1. 可以将不同的 snaptshot 挂载到同一个 the origin 上,节省了磁盘空间。
  2. 当多个 Snapshot 挂载到了同一个 the origin 上,并在 the origin 上发生写操作时,将会触发 COW 操作。这样不会降低效率。
  3. Thin-Provisioning Snapshot 支持递归操作,即一个 Snapshot 可以作为另一个 Snapshot 的 the origin,且没有深度限制。
  4. 在 Snapshot 上可以创建一个逻辑卷,这个逻辑卷在实际写操作(COW,Snapshot 写操作)发生之前是不占用磁盘空间的。

Thin-Provisioning Snapshot 虽然有诸多优点,但是也有很多不足之处,例如大小固定等问题,如若有兴趣可以阅读参考文献,这里不再赘述。

Thin-Provisioning Snapshot 是作为 device mapper 的一个 target 在内核中实现的。Device mapper 是 Linux 2.6 内核中提供的一种从逻辑设备到物理设备的映射框架机制。在该机制下,用户可以很方便的根据自己的需要制定实现存储资源的管理策略,如条带化,镜像,快照等。

Device Mapper 主要包含内核空间的映射和用户空间的 device mapper 库及 dmsetup 工具。Device Mapper 库是对 ioctl、用户空间创建删除 Device Mapper 逻辑设备所需必要操作的封装,dmsetup 是一个提供给用户直接可用的创建删除 device mapper 设备的命令行工具。

我们以 dmsetup 命令来介绍一下 Thin-Provisioning Snapshot 时如何实现的。Thin-Provisioning Snapshot 需要一个 data 设备和一个 metadata 设备分别用来存放实际数据和元数据。有两种方式可以更改 metadta:一种时通过函数调用,另一种则是通过dmsetup message命令。这里,我们创建两个稀疏文件作为 data 和 metadata 设备:

[root@qingze dev]# dd if=/dev/zero of=/tmp/metadata bs=1K count=1 seek=2G
1+0 records in
1+0 records out
1024 bytes (1.0 kB) copied, 0.000153611 s, 6.7 MB/s
[root@qingze dev]# dd if=/dev/zero of=/tmp/data bs=1K count=1 seek=100G
1+0 records in
1+0 records out
1024 bytes (1.0 kB) copied, 8.7567e-05 s, 11.7 MB/s

以上命令分别创建了 2G 的 metadata 和 100G 的 data 两个稀疏文件。注意命令中seek选项,其表示为略过of选项指定的输出文件的前 2G 空间再写入内容。这 2G 在硬盘上是没有占有空间的,占有空间只有 1k 的内容。当向其写入内容时,才会在硬盘上为其分配空间。

文件创建完成后,我们将其挂载到 loopback 设备上,以供使用:

[root@qingze dev]# losetup /dev/loop0 /tmp/metadata
[root@qingze dev]# losetup /dev/loop1 /tmp/data
[root@qingze dev]# losetup -a
/dev/loop0: [0035]:183033 (/tmp/metadata)
/dev/loop1: [0035]:185651 (/tmp/data)

准备工作就绪后,我们开始正式创建 Snapshot。首先创建一个 thin-pool,指定要用哪些设备来创建 Snapshot,此命令的作用为:将逻辑设备的 0~20971520 之间的 sector 映射到 /dev/loop1 上,元数据信息存储到 /dev/loop1:

[root@qingze dev]# dmsetup create pool --table "0 20971520 thin-pool 
/dev/loop0 /dev/loop1 128 32768 1 skip_block_zeroing"
  • pool 指定 thin-pool 的名字
  • --table 指定 dmsetup 的一个 target,其中:

    0 表示起始 sector

    20971520 表示 sector 的数量

    thin-pool 表示 target 的名字

    /dev/loop0 /dev/loop1 为设备名

    128 表示一次可分配的最小 sector 数

    32768 表示空闲空间的阈值

    1 特征参数的个数

    skip_block_zeroing 特征参数,表示略过用 0 填充的块

在继续创建 Snapshot 前,需要创建一个 Thinly-Provisioned volume 并格式化:

[root@qingze dev]# dmsetup message /dev/mapper/pool 0 "create_thin 0"
[root@qingze dev]# dmsetup create thin --table "0 2097152 thin /dev/mapper/pool 0"
[root@qingze dev]# mkfs.ext4 -E discard,lazy_itable_init=0 /dev/mapper/thin

命令的格式为:

message device_name sector message

Send message to target. If sector not needed use 0

'0' is an identifier for the volume

官方文档中对 Snapshot 有 internal 和 external 之分,说明如下:

internal snapshot:

Once created, the user doesn't have to worry about any connection

between the origin and the snapshot. Indeed the snapshot is no

different from any other thinly-provisioned device and can be

snapshotted itself via the same method. It's perfectly legal to

have only one of them active, and there's no ordering requirement on

activating or removing them both. (This differs from conventional

device-mapper snapshots.)

external snapshot:

You can use an external read only device as an origin for a

thinly-provisioned volume. Any read to an unprovisioned area of the

thin device will be passed through to the origin. Writes trigger

the allocation of new blocks as usual.

读者可以根据自己的需求选择,这里我们只建立一个 internal snapshot:

[root@qingze dev]# dmsetup suspend /dev/mapper/thin
[root@qingze dev]# dmsetup message /dev/mapper/pool 0 "create_snap 1 0"
[root@qingze dev]# dmsetup resume /dev/mapper/thin
[root@qingze dev]# dmsetup create snap --table "0 2097152 thin /dev/mapper/pool 1"
[root@qingze dev]# dmsetup status
thin: 0 2097152 thin 99840 2097151
fedora-swap: 0 8126464 linear 
fedora-root: 0 104857600 linear 
snap: 0 2097152 thin 99840 2097151
pool: 0 20971520 thin-pool 0 280/4161600 780/163840 - rw discard_passdown queue_if_no_space 
fedora-home: 0 97509376 linear

至此,我们已经成功建立了一个 Snapshot,接下来可以将其挂载到任何一个文件夹下,并对其进行创建文件等操作:

[root@qingze dev] mount /dev/mapper/snap dv

我们在 dv 文件夹下创建一个内容为this is snap的文件 file,然后,在 snap 的基础上再创建一个 snap1,观察发生了什么:

[root@qingze tmp]# dmsetup message /dev/mapper/pool 0 "create_snap 2 1"
[root@qingze tmp]# dmsetup create snap1 --table "0 2097152 thin /dev/mapper/pool 2"
[root@qingze tmp]# mkdir dv1
[root@qingze tmp]# mount /dev/mapper/snap1 dv1

[root@qingze dv]# cd ..
[root@qingze tmp]# cd dv1
[root@qingze dv1]# ls
file  lost+found
[root@qingze dv1]# cat file
this is snap

可以发现包含同样的文件和内容。事实上,Docker 正是以这种方式来创建容器的,读者可以执行以下命令来验证:

# 在`metadata`文件夹下查看某个某个容器的`device_id`
[root@qingze metadata]# cat 3b363fd9d7dab4db9591058a3f43e806f6fa6f7e2744b63b2df4b84eadb0685a 
{"device_id":910,"size":10737418240,"transaction_id":1817,"initialized":false}
# 创建并挂载 snapshot
[root@qingze tmp]# dmsetup message /dev/mapper/docker-253:1-3020991-pool 0 "create_snap 6001 910"
[root@qingze tmp]# dmsetup create snap6 --table "0 20971520 thin /dev/mapper/docker-253:1-3020991-pool 6001"
[root@qingze tmp]# mkdir dv6
[root@qingze tmp]# mount /dev/mapper/snap6 dv6
[root@qingze tmp]# cd dv6/
[root@qingze dv6]# ls
id  lost+found  rootfs
[root@qingze dv6]# cd rootfs/
[root@qingze rootfs]# ls
bin  boot  dev  etc  home  lib  lib64  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

源码分析

在继续介绍之前,我们首先介绍一下 Docker 的系统架构。Docker 采用一种 c/s 的结构,如下图所示,由 Client 和 Daemon 两部分组成。Docker Daemon 主要由三部分:Server,Engine 和 Job。Docker Daemon 启动时首先进行一些初始化操作,如初始化 network,创建 grapdriver,创建 execdriver 等等。在初始化结束后,Docker Daemon 启动 Server 负责监听 Docker Client 发来的请求,Server 的运行以及其对 Docker Client 端请求的响应都是以 Job 的形式在 Engine 中完成的。Engine 是 Docker 架构中的运行引擎,同时也是 Docker 的核心模块。一个 Job 可以认为是 Docker 架构中 Engine 内部最基本的工作执行单元。Docker 可以做的每一项工作,都可以抽象为一个 Job。Docker Client 负责对命令行的解析,其运用反射机制将命令行转换成函数的形式通过某种协议与 Docker Daemon 进行通信,通过Router以 RPC 的方式调用 Server 相应功能。目前,Docker 支持三种调用方式:fd,unix socket,http。值得注意的是,Docker Daemon 向 Docker Client 提供的所有功能都是以 Handler 的方式注册到 Engine 中的。举例说明,Engine 的 Handler 对象中有一项为:{"create":daemon.ContainerCreate},则说明当名为"create"的 Job 在运行时,执行的是 daemon.ContainerCreate 的 Handler。这样做的好处是增加了系统的可扩展性,添加新功能时只需简单的将其注册到 Engine 中即可。

在 Docker 中为了实现对多种不同文件系统的支持,增加系统的可扩展性,Docker 采用多态的方式将文件系统的操作抽象为 ProtoDriver 接口:

type ProtoDriver interface {
    // String returns a string representation of this driver.
    String() string
    // Create creates a new, empty, filesystem layer with the
    // specified id and parent. Parent may be "".
    Create(id, parent string) error
    // Remove attempts to remove the filesystem layer with this id.
    Remove(id string) error
    // Get returns the mountpoint for the layered filesystem referred
    // to by this id. You can optionally specify a mountLabel or "".
    // Returns the absolute path to the mounted layered filesystem.
    Get(id, mountLabel string) (dir string, err error)
    // Put releases the system resources for the specified id,
    // e.g, unmounting layered filesystem.
    Put(id string)
    // Exists returns whether a filesystem layer with the specified
    // ID exists on this driver.
    Exists(id string) bool
    // Status returns a set of key-value pairs which give low
    // level diagnostic status about this driver.
    Status() [][2]string
    // Cleanup performs necessary tasks to release resources
    // held by the driver, e.g., unmounting all layered filesystems
    // known to this driver.
    Cleanup() error
}

ProtoDriver 包含了各文件系统所需的基本操作,如 Crate,Remove 等,不同的文件系统只需简单的实现各接口即可。Go 语言在语法上与其他语言有些不同,如果不理解具体实现,请参阅相关文档。Docker 中文件系统的继承结构如下图(仅 Aufs 和 Devivcemapper 画出):

Docker 文件系统功能分为两部分:文件系统初始化和创建容器。我们首先介绍初始化部分 Aufs 和 Devicemapper 实现的功能,然后再介绍如何创建容器。

文件系统初始化过程是在 Docker Daemon 启动时进行的。 在 Docker Daemon 启动时会调用Newdaemon函数,继而调用NewdaemonFromDirectory完成相关环境的设置与初始化。其中,以下为初始化文件系统的操作:

// Load storage driver
    driver, err := graphdriver.New(config.Root, config.GraphOptions)
    if err != nil {
        return nil, err
    }
    log.Debugf("Using graph driver %s", driver)

graphdriver.New 函数会调用 daemon/graphdriver/driver.go 中的 GetDriver 函数:

func GetDriver(name, home string, options []string) (Driver, error) {
    if initFunc, exists := drivers[name]; exists {
        return initFunc(path.Join(home, name), options)
    }
    return nil, ErrNotSupported
}

GetDriver 函数中通过 drivers[name] 返回物理机所支持的文件系统的初始化函数,进行初始化操作。整个流程如下图:

Aufs 在初始化Init函数中主要完成了以下几个操作:

  1. 调用surportsAufs函数加载 Aufs 模块。
  2. 调用MakePrivate在系统中为 /var/lib/docker/aufs 创建一个挂载点。这里的实现原理与mount --bind命令一样,只不过 mount 命令的源文件夹和目的文件夹一样,在系统中只创建了挂载点而已。并且这个挂载点的内容即不受源文件夹的影响也不影响源文件夹。
  3. 最后,在 /var/lib/docker/aufs 创建 mnt, diff, layers 文件夹。mnt 文件夹为容器的挂载点目录,每一个容器在 mnt 下都有一个长 ID 目录,对应为该容器的 rootfs 的挂载点。diff 有着与 mnt 中对应的长 ID 目录,这里的每个目录对应 Docker 镜像的一个 layer 层,里面存放的是该 layer 相比较于父 layer 变化的内容。注意: 这里才是存放我们在容器中看到的内容的地方,比如 /usr, /bin 等等

相比之下,Devicemapper 的初始化操作Init相对麻烦一些。Devicemapper 在使用的过程中需要需要一个 24-bit 的标识来唯一标记一个 Snapsot,同时为了保证向 metadata 中数据的一致性,Dokcer 定义了DeviceSetDevInfo结构,通过对属性DeviceIdTransactionId加锁来实现一致性。DeviceSet 和 DevInfo 是一对多的关系,每一个 Snapshot 都会对应一个 DevInfo 在 DeviceSet 中。

在初始化的过程中,Init 首先调用NewDeviceset函数构建一个 DeviceSet 对象,配置相关选项,接着调用initDevmapper函数完成 Devicemapper 的初始化操作,大致分为四个步骤:

  1. 创建一个 thin pool。为了创建 thin pool,需要两个块设备。默认情况下,Docker 启动时为我们在 /var/lib/docker/devicemapper/devicemapper 下创建了 100G 的 metadata 和 2G 的 data 两个文件,并挂载到 loopback 设备 /dev/loop0 和 /dev/loop1,用 loop0,loop1 创建 thin pool。由于它们是稀疏文件,所以直到被使用,否则它们是不占用空间的。

    <

  2. setupBaseImage中调用createRegisterDevice创建一个 external snapshot 用作 Snapshot 的 the origin。
  3. setupBaseImage中调用activateDeviceIfNeeded激活步骤 2 创建的的设备。
  4. setupBaseImage中调用createFilesystem对步骤 2 创建的的设备格式化。

在我们使用 docker run 创建一个容器时,如下图,这个过程分为两个步骤:1. 创建容器,2. 启动容器。以下两个小节中,我们将以创建 ID 为4ff06b5335f4为例说明创建容器的过程。

Aufs 在创建容器的过程中调用createRootfs函数创建容器运行所需的 rootfs,具体步骤如下:

  1. createRootfs调用Create函数 mnt 和 diff 文件夹下创建 4ff06b5335f4-init 文件夹。
  2. createRootfs调用Get函数将容器4ff06b5335f4所依赖的 layer 按照继承顺序挂载到 mnt/4ff06b5335f4-init 文件夹下。如下图所示, 假设继承顺序为 511136ea3c5a<-3b363fd9d7da<-607c5d1cca7<-f62feddc05dc<-8eaa4ff06b53<-4ff06b5335f4,则按照这个顺序连同 diff/4ff06b5335f4-init 文件夹一起挂载。强调一点是,这里除 diff/4ff06b5335f4-init 文件夹外,所有权限都是 read-only,这样我们向容器进行写操作时, 所有的内容都会出现在 diff/4ff06b5335f4-init 文件夹。

  3. createRootfs再次调用Create函数在 mnt 和 diff 文件夹下创建 4ff06b5335f4 文件夹。

当容器创建完成后, 进入容器启动阶段:

Mount调用Get函数将容器4ff06b5335f4所依赖的 layer 按照继承顺序挂载到 mnt/4ff06b5335f4 文件夹下,操作如同第一次挂载。

Devmapper 在创建容器的过程中同样调用createRootfs函数创建容器运行所需的 rootfs,具体步骤如下:

  1. createRootfs调用Create函数,其调用AddDevice->createRegisterSnapDevice->CreateSnapDevice创建一个 Snapshot 4ff06b5335f4-init,下图中以 Snap-init-3 表示。其继承了 Snap-1,Snap-2 的所有内容,并且只保存相对于 Snap-2,Snap-3 变化的内容。这里为了简单未画出所有的 snapshot,读者可以认为已经存在了容器4ff06b5335f4所需的所有 layer,而 Snap-2 是容器4ff06b5335f4的父 layer。

  2. createRootfs调用Get函数, 其调用MountDevice->activateDeviceIfNeeded完成设备的激活,最后调用系统调用将 Snapshot 4ff06b5335f4-init挂载到 mnt/4ff06b5335f4-init 文件夹下。
  3. createRootfs调用Create函数创建 Snapshot 4ff06b5335f4,即图中的 Snap-3。

当容器创建完成后, 进入启动阶段:

Mount调用Get函数,其调用MountDevice->activateDeviceIfNeeded完成设备的激活,最后调用系统调用将 Snapshot 4ff06b5335f4 挂载到 mnt/4ff06b5335f4 文件夹下。

总结

Aufs 实现起来比较简单,但是由于其迟迟不能加入 linux 内核,导致兼容性差。目前,仅有 Ubuntu 支持。Devicemapper 虽然实现起来复杂,但兼容性好。其存在的一点不足是,当 metadata 和 data 空间被耗尽时,需要重启 Docker 来扩充空间。

作者简介

王青泽,软件开发工程师。东北大学硕士, 研究方向为基于 Hadoop 的多样性标签推荐。2014 年毕业后就职于京东,参与并完成了基于 Docker 的 Falcon 自动化运维项目,因而对 Docker 产生了浓厚兴趣。邮箱:qingzew@126.com

参考文献

  1. http://www.infoq.com/cn/articles/docker-core-technology-preview
  2. http://tech.365rili.com/?p=41
  3. http://www.thegeekstuff.com/2013/05/linux-aufs/
  4. https://access.redhat.com/documentation/en-US/Red_Hat_Enterprise_Linux/6/html/Logical_Volume_Manager_Administration/snapshot_volumes.html
  5. http://www.turbolinux.com.cn/turbo/wiki/doku.php?id=%E7%B3%BB%E7%BB%9F%E7%AE%A1%E7%90%86:linux_device_mapper
  6. http://www.projectatomic.io/docs/filesystems/
  7. http://blog.csdn.net/cymm_liu/article/details/8760033
  8. https://www.kernel.org/doc/Documentation/device-mapper/thin-provisioning.txt

感谢郭蕾对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们,并与我们的编辑和其他读者朋友交流。

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论