合规国际互联网加速 OSASE为企业客户提供高速稳定SD-WAN国际加速解决方案。 广告
# 第11章 Docker存储引擎 使用Docker的最大的好处之一在于它可以从一个现有的镜像快速的实例化一个新的容器。作为Docker的前身,历史上的[LXC](https://linuxcontainers.org)容器,它的做法是将会为每个新创建的容器分配宿主机上一个单独的目录,然后将镜像的根文件系统复制到该目录下。这显然是相当低效的。在这种情况下,磁盘空间的消耗会随着每一个新创建的容器而不断的增长,并且容器的启动时间也取决于从一个目录复制到另一个目录下的数据量的大小。与之不同的是,Docker利用**镜像分层技术**来解决这些难题。 从整体上来讲,镜像层就是一个简单的文件树结构,它可以按需挂载和更改。新的镜像层可以是全新的也可以基于现有的镜像即所谓的**父镜像层**之上创建。基于一个现有镜像层创建出来的新的镜像层实际上算是它的一个副本——他们两个都可以被Docker根据一个相同且唯一的名字所定位。而一旦这个新的镜像层发生更改,那么Docker将会为它立马生成和分配一个新的唯一的名字。从这一刻起,父镜像层将会保持不变,而未来对该镜像做出的任何更改都只会应用到新的这一层。如果这让你想到了著名的[写时复制](https://en.wikipedia.org/wiki/Copy-on-write)(CoW)机制,那么也许你会立刻恍然大悟! Docker这一镜像分层技术的实现有赖于众多的写时复制文件系统的支持,其中有一些已经内置到了原生的Linux内核里。与之前直接复制父镜像的做法不同的是,Docker只关注父镜像及基于它所创建的新的镜像层之间的变更内容(又称为**增量**)。这样一来便节省了大量的磁盘空间。整个镜像文件系统主要的增长点只在于各镜像层之间的增量的大小。 Docker在容器的存储管理方面采取了类似的概念。每一个容器都分为以下两层。 - **初始层**——基于父镜像的基础镜像层。它包含了每个Docker容器都会出现的一些基本文件:`/etc/hosts`、`/etc/resolv.conf`等。 - **容器文件系统层**——初始层之上的镜像层。它包含容器本身存储的一些数据。 Docker通过它所提供的远程API对外公开了它的镜像分层,这里面也提供了一些比较贴心的功能,例如,用户可以实现容器的版本控制以及可以为镜像打上标签等。如果用户想从一个现有的容器保存出一个新的镜像层,只需要简单的调用`docker commit container_id`命令,随后Docker便会自动去定位自**初始层**起一路到该容器层所应用的所有变更,然后在父镜像层之上创建一个新的分层。用户也可以为刚提交的这一分层打上一个标签,要么据此构建新镜像,又或者是自此实例化新的容器。由于在容器文件系统上应用了相同的写时复制概念,Docker容器的启动时间也因此大大缩短。 一图胜千言——如果读者有兴趣想挖掘一下存储在Docker Hub里的任一Docker镜像对应具体的镜像分层,可以试试由[Centurylink实验室](https://labs.ctl.io)研发的一个非常棒的[Image Layers](https://imagelayers.io)工具,它使用户可以手动检查该镜像具体可用的分层,而它实际提供的功能还远不止这些。 到目前为止,上述所讲的内容已经覆盖了关于Docker如何处理镜像和容器文件系统必备的基础知识,接下来,我们将探讨所有Docker原生支持的存储引擎。我们将深入剖析里面的一些核心概念,从而让读者能够更好的理解其中的原理,并且我们还会提供一些实际的例子,这里面的每条命令读者都可以在自己的Docker宿主机上一个个地直接执行。 Docker原生提供了不少开箱即用的存储引擎。用户需要做的只是选用其中之一罢了。一旦决定了要使用哪款引擎,用户就需要在环境变量`DOCKER_OPTS`里追加一个`--storage-driver`命令行参数来告知Docker守护进程。跟其他的服务一样,用户必须重启一次Docker守护进程来使得新的配置参数生效。下面,我们的探索之旅将首先从Docker默认的存储引擎`aufs`开始。 就像之前所提到的那样,`aufs`是Docker提供的默认的存储引擎。选择它的**部分原因**在于Docker团队最开始在[dotCloud](https://www.dotcloud.com)内部便是使用它来运行的容器,因此他们对于如何在生产环境下应用它已经有了一个比较坚实的理论基础和运维经验。 顾名思义(该引擎的正式名称尚且待定),`aufs`使用[AUFS](http://aufs.sourceforge.net)文件系统来存储镜像和容器。AUFS的工作原理是通过层层“堆叠”多个称为**分支**的文件系统层,然后每一层都对外公开一个单独的挂载点以使用户可以独立访问它们。每个分支都是一个简单的目录,里面包含一些普通的文件和元数据。最上层的分支则是**唯一**的一个可读写的文件层。AUFS正是靠元数据在所有堆叠的镜像层之间查找和定位文件的具体位置。它每一次的查找操作总是先从最顶层开始,而当某个文件需要做读写相关的操作时该文件也将会被复制到最顶层。一旦这个文件本身很大的时候,这类操作所耗费的时间可能就会比较长。 理论就先讲到这里。接下来,我们来看一个具体的实战例子,它将为我们展示Docker是如何运用AUFS存储引擎的。首先,先确认一下Docker是否的确配置了`aufs`为存储引擎: ``` # sudo docker info Containers: 10 Images: 60 Storage Driver: aufs Root Dir: /var/lib/docker/aufs Backing Filesystem: extfs Dirs: 80 Execution Driver: native-0.2 Kernel Version: 3.13.0-40-generic Operating System: Ubuntu 14.04.1 LTS CPUs: 1 Total Memory: 490 MiB Name: docker-hacks ID:DK4P:GBM6:NWWP:VOWT:PNDF:A66E:B4FZ:XMXA:LSNB:JLGB:TUOL:J3IH ``` 正如我们所看到的,AUFS引擎默认的基础镜像存储目录便是`/var/lib/docker/aufs`。让我们一起来看看这个目录里包含了哪些内容: ``` # ls -l /var/lib/docker/aufs/ total 36 drwxr-xr-x 82 root root 12288 Apr 6 15:29 diff drwxr-xr-x 2 root root 12288 Apr 6 15:29 layers drwxr-xr-x 82 root root 12288 Apr 6 15:29 mnt ``` 如果用户还没有创建任何容器,那么所有相关的目录都将是空的。顾名思义,`mnt`子目录里面包含的内容便是所有容器文件系统的挂载点。他们只会在容器运行的时候才会被挂载上。既然如此,让我们先试试创建一个新容器,然后实际来看看它里面生成的内容吧。我们将在一个新的Docker容器里运行一个`top`命令,这样一来它便会一直保持运行的状态,直到我们主动停止它: ``` # docker run -d busybox top Unable to find image 'busybox:latest' locally 511136ea3c5a: Pull complete df7546f9f060: Pull complete ea13149945cb: Pull complete 4986bf8c1536: Pull complete busybox:latest: The image you are pulling has been verified. Im- portant: image verification is a tech preview feature and should not be relied on to provide security. Status: Downloaded newer image for busybox:latest f534838e081ea8c3fc6c76aa55a719629dccbf7d628535a88be0b3996574fa47 ``` 从上面的输出可以看到,`busybox`镜像由5个镜像层组成,它们分别对应了5个AUFS分支。AUFS分支的数据则存放在`diff`目录,用户可以很轻松的通过如下命令来验证该`diff`目录下的每个子目录对应的镜像层: ``` # ls -1 /var/lib/docker/aufs/diff/ 511136ea3c5a64f264b78b5433614aec563103b4d4702f3ba7d4d2698e22c158 df7546f9f060a2268024c8a230d8639878585defcc1bc6f79d2728a13957871b ea13149945cb6b1e746bf28032f02e9b5a793523481a0a18645fc77ad53c4ea2 4986bf8c15363d1c5d15512d5266f8777bfba4974ac56e3270e7760f6f0a8125 f534838e081ea8c3fc6c76aa55a719629dccbf7d628535a88be0b3996574fa47 f534838e081ea8c3fc6c76aa55a719629dccbf7d628535a88be0b3996574fa47-init ``` 读者也可以看到当我们启动该容器时,像本章开头讲述的那样,**初始镜像层**在最上层最先被创建出来。如今,该容器已经处于运行状态,它的文件系统也应该被挂载上,让我们一起来确认一下: ``` # grep f534838e081e /proc/mounts /var/lib/docker/aufs/mnt/ f534838e081ea8c3fc6c76aa55a719629dccbf7d628535a88be0b3996574fa47 aufs rw,relatime,si=fa8a65c73692f82b 0 0 ``` 该运行中的容器文件系统的挂载点会被映射到`/var/lib/docker/aufs/mnt/container_id`并且它会被挂载成读-写模式。我们不妨试试修改该容器的文件系统,在它里面创建一个简单的文件(`/etc/test`)然后将这个修改提交: ``` # docker exec -it f534838e081e touch /etc/test # docker commit f534838e081e 4ff22ae4060997f14703b49edd8dc1938438f1ce73070349a4d4413d16a284e2 ``` 上述操作应该会创建一个新的镜像层,它将包含刚刚我们创建的新文件并且会把该增量存放到一个特定的diff目录。这一点可以很方便的通过列出`diff`目录下的子目录里的内容来确认: ``` # find /var/lib/docker/aufs/diff/ 4ff22ae4060997f14703b49edd8dc1938438f1ce73070349a4d4413d16a284e2/ -type f /var/lib/docker/aufs/diff/ 4ff22ae4060997f14703b49edd8dc1938438f1ce73070349a4d4413d16a284e2/ etc/test ``` 现在,可以基于刚创建的包含`/etc/test`这个文件的镜像层启动一个新容器: ``` # docker run -d 4ff22ae4060997f14703b49edd8dc1938438f1ce73070349a4d4413d16a284e2 top 9ce0bef93b3ac8c3d37118c0cff08ea698c66c153d78e0d8ab040edd34bc0ed9 # docker ps -q 9ce0bef93b3a f534838e081e ``` 可以通过如下命令来确认该文件是否的确存在于新创建的这个容器内: ``` # docker exec -it 9ce0bef93b3a ls -l /etc/test -rw-r--r-- 1 root root 0 Apr 7 00:27 /etc/ test ``` 那么,让我们再来看看如果删除容器里的一个文件并且提交这一更改会发生什么: ``` # docker exec -it 9ce0bef93b3a rm /etc/test # docker commit 9ce0bef93b3a e3b7c789792da957c4785190a5044a773c972717f6c2ba555a579ee68f4a4472 ``` 当删除一个文件时,AUFS会创建一个所谓的“**写出**”文件,基本上就是一个重命名的加上“`.wh.`”前缀的文件。这便是AUFS将文件**标记**为已删除的方式。这一点同样也非常容易验证,只需要检索对应镜像层目录里的内容: ``` ls -a /var/lib/docker/aufs/diff/ e3b7c789792da957c4785190a5044a773c972717f6c2ba555a579ee68f4a4472/ etc/ . .. .wh.test ``` 该隐藏文件实际上仍然存放在宿主机的文件系统上,但是当用户基于这一创建的镜像层启动一个新容器时,AUFS会非常智能地将其剔除,而这个文件将不会再出现在新运行的容器的文件系统里: ``` # docker run --rm -it e3b7c789792da957c4785190a5044a773c972717f6c2ba555a579ee68f4a4472 test -f /etc/.wh.test || echo "File does not exist" File does not exist ``` 以上便是我们介绍的`aufs`存储引擎的全部内容。在这里,我们一起探讨了Docker是如何利用AUFS文件系统所提供的一些功能特性来创建的容器并且展示了一些实战案例。下面我们对这一节做一个简单的总结,然后转到下一个存储引擎的介绍。 AUFS的挂载速度是相当快的,因此它们能够非常快速的创建出新容器。它们的读/写速度也几乎跟原生的差不了多少。这使得它成为众多运行容器的Docker存储引擎里一个比较合适和成熟的方案。AUFS的性能瓶颈主要在于需要写入大文件的场景,因此使用aufs存储引擎来存放数据库文件可能不是一个好主意。同样地,太多的镜像层可能会导致文件查找时间过长,因此最好不要让自己的容器有太多的分层。 虽然用户可以通过一些变通手段来解决先前所提到的一些不足之处(如用卷来挂载数据目录和减少镜像层数),但是aufs存储引擎的最大的问题还是在于AUFS文件系统本身还没有被容纳到主流的Linux内核版本里,而且以后也不太可能。这就是说它不大可能会出现在主流的Linux发行版中,而它在使用上就可能需要用到一些黑科技,这对于用户而言实在是不太方便,并且由于它没有被内置到内核里,如何将其更新补丁应用到Linux内核也是一件令人头疼的事情。即便是曾经在他们的内核里加入了对AUFS的默认支持的Ubuntu,如今也决定了在12.04版本里[禁用](https://lists.ubuntu.com/archives/ubuntu-devel/2012-February/034850.html)这一特性,并且明确鼓励用户迁移到[OverlayFS](https://www.kernel.org/doc/Documentation/filesystems/overlayfs.txt),该文件系统自11.10版本起被内嵌到了Ubuntu的内核里,而且仍然在积极地迭代更新。关于OverlayFS,我们将在这一章的稍后部分详细讨论。现在,让我们来一起看看另外一个建立在相对成熟的Linux存储技术基础上的存储引擎`devicemapper`。 DeviceMapper是一个由Linux内核提供的先进的存储框架,它能够将物理块设备映射为虚拟块设备。它也是[LVM2](http://sourceware.org/lvm2/),块级别存储加密,[多路径](https://en.wikipedia.org/wiki/Linux_DM_Multipath)以及许多其他的Linux存储工具等诸多技术实现的基础。用户可以在Linux内核的[官方文档](https://www.kernel.org/doc/Documentation/device-mapper/)里获取更多有关DeviceMapper的信息。在这一小节里,我们将把重点放在Docker是如何使用DeviceMapper来管理容器以及镜像的存储。 `devicemapper`存储引擎使用了DeviceMapper的预分配(thin provisioning[\[1\]](part0017.xhtml#anchor111))模块来实现镜像的分层。从整体上来说,预分配机制(也被称之为thinp)会对外提供一组原始的物理存储(块),用户可以据此创建任意大小的虚拟块设备或是虚拟磁盘。thinp技术比较神奇的一点在于直到用户实际开始将数据写到它们里面之前,这些设备不会占用任何实际的磁盘空间,也不会有任何的原始存储块会被标记为正在使用。 另外,thinp技术支持创建数据卷的快照功能。用户也可以据此创建一个现有卷的副本,而新的快照卷将不会占用任何额外的存储空间。值得再次强调的是,在用户开始写入数据之前,它将不会从存储池里申请任何额外的存储空间。 预分配技术本身使用两种块设备: - **数据设备**——用作存储池的设备,一般都很大; - **元数据设备**——用来存放已创建的卷(包括快照点)的存储块和存储池之间的映射关系等信息。 `devicemapper`存储引擎的写时复制技术是基于单个块设备级别实现的,这和`aufs引擎`基于文件系统层面的实现略有不同。当Docker守护进程启动时,它会为之自动创建预分配机制正常工作所必需的两个块设备: - 用作存储池的数据设备; - 维护元数据的设备。 默认情况下,这些设备都只是一些绑定到回环设备上的[稀疏文件](https://en.wikipedia.org/wiki/Sparse_file)。这些文件大小上一般看上去是100 GB和2 GB,但是因为它们是稀疏文件,因此实际上并不会用去宿主机上太多的磁盘空间。 一个实际的例子可能会更好地帮助理解这些概念。首先,我们需要设置一下环境变量`DOCKER_OPTS`从而告知Docker守护进程采用`devicemapper`存储引擎作为默认的存储选项,然后重启服务。在服务重启完成之后,我们可以通过如下方式来验证它现在是否真的使用`devicemapper`作为存储引擎: ``` # docker info Containers: 0 Images: 0 Storage Driver: devicemapper Pool Name: docker-253:1-143980-pool Pool Blocksize: 65.54 kB Backing Filesystem: extfs Data file: /dev/loop0 Metadata file: /dev/loop1 Data Space Used: 305.7 MB Data Space Total: 107.4 GB Metadata Space Used: 729.1 kB Metadata Space Total: 2.147 GB Udev Sync Supported: false Data loop file: /var/lib/docker/devicemapper/devicemapper/data Metadata loop file: /var/lib/docker/devicemapper/devicemapper/ metadata Library Version: 1.02.82-git (2013-10-04) Execution Driver: native-0.2 Kernel Version: 3.13.0-40-generic Operating System: Ubuntu 14.04.1 LTS CPUs: 1 Total Memory: 490 MiB Name: docker-book ID: IZT7:TU36:TNKP:RELL:2Q2J:CA24:OK6Z:A5KZ:HP5Q:WBPG:X4UJ:WB6A ``` 让我们一起来看看`devicemapper`在`/var/lib/docker/devicemapper/`这个目录下都做了哪些动作: ``` # ls -alhs /var/lib/docker/devicemapper/devicemapper/ total 292M 4.0K drwx------ 2 root root 4.0K Apr 7 20:58 . 4.0K drwx------ 4 root root 4.0K Apr 7 20:58 .. 291M -rw------- 1 root root 100G Apr 7 20:58 data 752K -rw------- 1 root root 2.0G Apr 7 21:07 metadata ``` 如上所示,Docker创建的`data`和`metadata`文件只占用了很少的磁盘空间。我们可以通过执行如下命令来确认这两个文件实际上是否真的被用作了回环设备的后端存储: ``` # lsblk NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT loop0 7:0 0 100G 0 loop docker-253:1-143980-pool (dm-0) 252:0 0 100G 0 dm docker-253:1-143980-base (dm-1) 252:1 0 10G 0 dm loop1 7:1 0 2G 0 loop docker-253:1-143980-pool (dm-0) 252:0 0 100G 0 dm docker-253:1-143980-base (dm-1) 252:1 0 10G 0 dm ``` 除了为预分配创建必要的稀疏文件之外,Docker守护进程还会在预分配的存储池上自动创建一个包含一个空白的`ext4`文件系统的**基础设备**。所有新的镜像层都是基础设备的一个快照,这意味着每个容器和镜像都拥有一个属于它自己的块设备。这样一来,用户在任何时间点都可以为任意现有镜像或者容器创建一个新的快照点。基础设备的默认大小设置是10 GB,这也是一个容器或镜像的最大空间大小,但是由于使用了预分配机制,它们实际占用空间会小很多。用户可以很轻松地通过执行如下命令来验证基础设备的存在: ``` # dmsetup ls docker-253:1-143980-base (252:1) docker-253:1-143980-pool (252:0) ``` 让我们来试试创建一个简单的容器,然后在里面执行我们在11.1节里做过的类似测试: ``` # docker run -d busybox top Unable to find image 'busybox:latest' locally 511136ea3c5a: Pull complete df7546f9f060: Pull complete ea13149945cb: Pull complete 4986bf8c1536: Pull complete busybox:latest: The image you are pulling has been verified. Im- portant: image verification is a tech preview feature and should not be relied on to provide security. Status: Downloaded newer image for busybox:latest f5a805967279e0e07c597c0607afe9adb82514d6184f4fe4c24f064e1fda8c01 ``` 如果列出`/var/lib/docker/devicemapper/mnt/`目录下的具体内容,读者会发现这里面有一列对应每个镜像层的文件目录: ``` # ls -1 /var/lib/docker/devicemapper/mnt/ 511136ea3c5a64f264b78b5433614aec563103b4d4702f3ba7d4d2698e22c158 df7546f9f060a2268024c8a230d8639878585defcc1bc6f79d2728a13957871b ea13149945cb6b1e746bf28032f02e9b5a793523481a0a18645fc77ad53c4ea2 4986bf8c15363d1c5d15512d5266f8777bfba4974ac56e3270e7760f6f0a8125 f5a805967279e0e07c597c0607afe9adb82514d6184f4fe4c24f064e1fda8c01 f5a805967279e0e07c597c0607afe9adb82514d6184f4fe4c24f064e1fda8c01-init ``` 这些目录即是对应特定`devicemapper`镜像层的挂载点。除非哪个特定的镜像层被实际挂载,例如,当某个容器正在运行时,用户会发现它目录下面的内容全部是空的。同样地,用户也可以非常简单地通过检查正在运行的容器对应的挂载目录里的内容来验证这一点: ``` # docker ps -q f5a805967279 # grep f5a805967279 /proc/mounts /dev/mapper/docker-253:1-143980- f5a805967279e0e07c597c0607afe9adb82514d6184f4fe4c24f064e1fda8c01/ var/lib/docker/devicemapper/mnt/ f5a805967279e0e07c597c0607afe9adb82514d6184f4fe4c24f064e1fda8c01 ext4 rw,relatime,discard,stripe=16,data=ordered 0 0 ``` 现在,如果用户列出该目录下的内容,将能够看到它里面包含的容器文件系统的具体内容。而一旦用户停止了该容器,那么它的附加块设备都将会被卸载而对应挂载点所在的目录里的内容都会被置为空: ``` # docker stop f5a805967279 f5a805967279 # ls -l /var/lib/docker/devicemapper/mnt/ f5a805967279e0e07c597c0607afe9adb82514d6184f4fe4c24f064e1fda8c01 total 0 ``` 这就意味着当用户使用`devicemapper`作为存储引擎的时候,将不太容易直观的得到镜像层之间的差异。 此外,当采用`devicemapper`作为Docker的默认存储引擎时,它将会使用稀疏文件作为容器和镜像的后端存储。这会对性能方面产生显著的影响。每当一个容器修改一次它的文件系统时,Docker就必须从存储池里申请一个新的存储块,这在采用稀疏文件的情况下可能需要耗费一定的时间。试想下如果同时运行数百个容器并且它们正在修改各自的文件系统时会是怎样的一个场景吧。 幸运的是,Docker允许用户通过传入特定的命令行参数`--storage-opt`来告知Docker守护进程使用真实的块设备来存储`devicemapper`的数据和元数据设备。它们是: - 针对数据块设备的`dm.datadev`; - 针对元数据设备的`dm.metadatadev`。 在生产环境下应用`devicemapper`引擎时请记得**一定要**设置成使用真实的块设备来存储数据和元数据设备。当运行了很多的容器时这一点尤其重要! 读者可以转到https://github.com/docker/docker/tree/master/daemon/graphdriver/devmapper,了解`devicemapper`引擎提供的所有可用的选项。 **提示**:*如果想从*`aufs`*转到*`devicemapper`*引擎,用户必须首先通过执行*`docker save`*命令将所有镜像都对应保存到一个个单独的tar包里,一旦*`devicemapper`*存储引擎被Docker守护进程启用,用户便可以轻松地使用*`docker load`*命令加载这些已经保存的镜像。* 正如我们所看到的,`devicemapper`存储引擎为Docker提供了一个非常有意思的容器存储的备选方案。如果用户对[LVM](https://en.wikipedia.org/wiki/Logical_Volume_Manager_%28Linux%29)以及它周边丰富的工具集非常熟悉,可以很轻松地使用类似的方式来管理Docker的存储。然而,当用户决定要使用这个存储引擎的时候,往往在使用上有一些需要特别注意的地方: - 使用`devicemapper`引擎至少需要了解`devicemapper`各个子系统的一些基础运维知识; - 更改`devicemapper`的任何选项都需要先停止Docker守护进程并且擦除`/var/lib/docker`目录下的所有内容; - 正如我们之前所提到的那样,容器文件系统的大小默认设置为10 GB(可以在守护进程启动时通过`dm.basesize`参数来修改这一配置); - 通常很难去扩展一个已经超出其基础设备大小的运行中的容器的空间; - 不能轻易的扩展镜像的空间大小——提交一个大过它基础设备大小的容器真的不是那么容易。 现在,我们对于如何使用`devicemapper`存储引擎应该有了一些不错的想法,那么是时候继续前行,接着讨论下一个可选方案了,下一个存储引擎的背后是一个在Linux社区长久以来拥有着广泛影响力(积极的和负面的兼而有之)的文件系统`btrfs`。 即便是一个普通人都能猜到,`btrfs`存储引擎使用的是[BTRFS文件系统](https://btrfs.wiki.kernel.org/index.php/Main_Page)来实现Docker镜像层写时复制的功能。为了进一步理解`btrfs`存储引擎,让我们先来了解下BTRFS文件系统所具备的一些功能特性,然后通过一些实战案例来讲解如何在Docker中应用它。 `btrfs`是一个已经内置到Linux内核主干中很长一段时间的叠加文件系统,但是它依然没有达到一个生产环境文件系统所需的质量或者说成熟度。它设计的初衷是为了用来和Sun公司的[ZFS文件系统](https://en.wikipedia.org/wiki/ZFS)提供的一些功能特性相抗衡。BTRFS具备的一些显著特性包括: - 快照; - 子卷; - 无间断地添加或删除块设备; - 透明压缩。 `btrfs`以**块**为单位存储数据。一个块就是简单的一段原始存储,一般大小在1 GB左右,BTRFS即是使用它来存放实际的数据。数据块一般都是均匀的分布到所有底层的块设备里。所以,即使物理磁盘上有存储空间,数据块仍然可能提前耗尽。当这样的情况发生时,用户唯一能做的便是重新调整自己的文件系统,这样一来它将会从空白或者接近空白的存储块里挪走数据从而释放一些磁盘空间。这一操作过程中间没有任何的宕机成本。 以上便是我们需要介绍的关于BTRFS的一些理论基础。现在,让我们一起来看看`btrfs`存储引擎的一些实际应用案例。为了能够使用这一引擎,Docker要求将`/var/lib/docker`目录挂载到一个BTRFS文件系统上。我们就不讲解如何去做的详细步骤了——这里我们假定用户已经事先准备好了一个BTRFS分区并且在其上面创建好了`btrfs`引擎所需的特定目录: ``` # grep btrfs /proc/mounts /dev/sdb1 /var/lib/docker btrfs rw,relatime,space_cache 0 0 ``` 现在,需要通过修改环境变量`DOCKER_OPTS`来告知Docker守护进程使用`btrfs`作为其存储引擎。在守护进程重启后,用户可以通过如下命令来查询当前Docker正在使用的存储引擎的信息: ``` # docker info Containers: 0 Images: 0 Storage Driver: btrfs Execution Driver: native-0.2 Kernel Version: 3.13.0-24-generic Operating System: Ubuntu 14.04 LTS CPUs: 1 Total Memory: 490.1 MiB Name: docker ID: NQJM:HHFZ:5636:VGNJ:ICQA:FK4U:6A7F:EUDC:VFQL:PJFF:MI7N:TX7L WARNING: No swap limit support ``` 用户可以通过执行如下命令来检索BTRFS文件系统对应的一些信息,它可以展示该文件系统使用情况的概要: ``` # btrfs filesystem show /var/lib/docker Label: none uuid: 1d65647c-b920-4dc5-b2f4-de96f14fe5af Total devices 1 FS bytes used 14.63MiB devid 1 size 5.00GiB used 1.03GiB path /dev/sdb1 Btrfs v3.12 # btrfs filesystem df /var/lib/docker Data, single: total=520.00MiB, used=14.51MiB System, DUP: total=8.00MiB, used=16.00KiB System, single: total=4.00MiB, used=0.00 Metadata, DUP: total=255.94MiB, used=112.00KiB Metadata, single: total=8.00MiB, used=0.00 ``` Docker还利用了BTRFS的**子卷**特性。用户可以在https://lwn.net/Articles/579009/了解更多有关子卷的知识。整体上说,子卷是一个相当复杂的概念,可以简单地认为它是一个可以通过文件系统顶层子卷访问的POSIX文件命名空间,或者它也能够以自己的方式挂载。 每一个新创建的Docker容器都会被分配一个新的BTRFS子卷,并且如果存在父镜像层,那么它会以父镜像层子卷的一个快照的形式创建。Docker镜像同样如此。我们不妨先创建一个新的容器然后再通过具体的案例深入理解这相关的概念: ``` # docker run -d busybox top Unable to find image 'busybox:latest' locally 511136ea3c5a: Pull complete df7546f9f060: Pull complete ea13149945cb: Pull complete 4986bf8c1536: Pull complete busybox:latest: The image you are pulling has been verified. Im- portant: image verification is a tech preview feature and should not be relied on to provide security. Status: Downloaded newer image for busybox:latest 86ab6d8602036cadb842d3a030adf2b05598ac0e178ada876da84489c7ebc612 ``` 用户可以很方便地通过如下命令来验证每一层是否真的对应分配了一个新的BTRFS子卷: ``` # btrfs subvolume list /var/lib/docker/ ID 258 gen 9 top level 5 path btrfs/subvolumes/ 511136ea3c5a64f264b78b5433614aec563103b4d4702f3ba7d4d2698e22c158 ID 259 gen 10 top level 5 path btrfs/subvolumes/ df7546f9f060a2268024c8a230d8639878585defcc1bc6f79d2728a13957871b ID 260 gen 11 top level 5 path btrfs/subvolumes/ ea13149945cb6b1e746bf28032f02e9b5a793523481a0a18645fc77ad53c4ea2 ID 261 gen 12 top level 5 path btrfs/subvolumes/ 4986bf8c15363d1c5d15512d5266f8777bfba4974ac56e3270e7760f6f0a8125 ID 262 gen 13 top level 5 path btrfs/subvolumes/ 86ab6d8602036cadb842d3a030adf2b05598ac0e178ada876da84489c7ebc612-init ID 263 gen 14 top level 5 path btrfs/subvolumes/ 86ab6d8602036cadb842d3a030adf2b05598ac0e178ada876da84489c7ebc612 ``` 用户可以在任何时间为任意一层镜像设置快照点。一个快照点等同于一个新的子卷,它和其他的子卷共享它的数据(和元数据)。由于一个快照点便是一个子卷,因此还可以创建快照的快照。一个实际的案例也许可以帮助我们理解的更加透彻。我们将对一个正在运行中的容器做变更然后提交它: ``` # docker exec -it 86ab6d860203 touch /etc/testfile # docker commit 86ab6d860203 32cb186de0d0890c807873a3126e797964c0117ce814204bcbf7dc143c812a33 ``` 如预期那样,被提交的容器在`/var/lib/docker/btrfs/subvolumes/32cb186de0d0890c807873a3126e797964c0117ce814204bcbf7dc143c812a33`目录里创建了一个新的BTRFS子卷。如果进一步查看这个子卷里的具体内容,会发现它实际上包含了一个完整的镜像文件系统,而不只是一个增量镜像。这是由于BTRFS没有读写分层的概念并且也很难去列出不同的快照点之间的差异。关于这一点也许以后会得到改善。 现在,让我们探讨一下当运用`btrfs`存储引擎作为Docker存储时还有哪些值得关注的地方。正如之前所提到的,`btrfs`引擎要求将`/var/lib/docker`目录挂载到BTRFS文件系统上。这样做的优势在于可以让用户的整个宿主操作系统的其他部分免受潜在的文件系统中断的影响。在这里,我们也推荐把`/var/lib/docker/vfs/`目录挂载到像`ext4`或者`vfs`这样久经考验的文件系统。 BTRFS文件系统对于磁盘空间不足的问题非常敏感。用户必须确保能随时监控存储块的使用情况并且在需要的时候不断地重新调整文件系统。这可能会对运维人员造成一些负担,尤其是当用户运行了大量的容器然后必须在宿主机上保留大量容器镜像的时候。从另一方面来看,用户也因此得获一个可以轻松扩展而无需中断服务的存储服务。 通过运用它的快照特性,BTRFS的写时复制功能使得备份容器和镜像变得超级简单。但是,话说回来,BTRFS的写时复制特性并不适用于创建和修改大量小文件的容器,例如数据库。这会导致经常出现文件系统碎片,因而需要频繁地进行文件系统的调整。针对那些绑定挂载到高IO类应用的容器的目录或者卷,用户可能需要通过禁用它们的写时复制来避免上述的这些问题。 如果下定决心要删除Docker的BTRFS子卷,在通过`rm -rf`命令实际删除底下的目录内容之前,别忘了先确保已经使用`btrfs subvolume delete subvolume_directory`删除了对应的子卷,否则这可能会导致文件系统的损坏。这种情况有时候也会在尝试删除镜像或者销毁容器的时候发生,所以请一定要留意这一点。 综上所述,`btrfs`存储引擎最大的优势和弊端正是BTRFS文件系统本身。它要求使用者在使用方面有充足的操作经验,而且它在多样的生产环境下性能不是很好。BTRFS也不允许共享页面缓存,因为这可能会导致过高的内存使用率。以上这些也正是为什么[CoreOS](https://coreos.com)[团队最近](https://lwn.net/Articles/627232/)决定在他们的操作系统发行版里放弃内置对btrfs的支持的主要原因。 `overlay`是Docker最新引入的存储引擎。它使用[OverlayFS](https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/tree/Documentation/filesystems/overlayfs.txt)文件系统来为Docker镜像分层提供写时复制的支持。和之前一样,需要将环境变量`DOCKER_OPTS`修改为指向该引擎,然后重启Docker守护进程。在深入讲解一些实际案例之前,让我们先来介绍一下OverlayFS文件系统。 OverlayFS是一个联合文件系统,Linux内核在3.18版本起就已经内置了对此文件系统的支持。它实际上结合了两种文件系统: - **上层**——子文件系统; - **下层**——父文件系统。 OverlayFS引入了**工作目录**的概念,所谓的工作目录指的便是一个驻留在上层文件系统的目录,而它通常用于完成上、下层之间文件的原子复制。 下层文件系统可以是任意一个Linux内核(包括overlayFS本身)支持的文件系统并且通常都是**只读**的。事实上,我们可以通过层层堆叠或者说互相在彼此的顶部“叠加”来划分出多个下层文件系统层。上层文件系统一般是可写的。作为一个联合文件系统,overlayFS也实现了通过经典的“写出”文件来标记将要删除的文件,这与我们在第一节里讲述的AUFS文件系统的做法有些类似。 叠加的主要操作便是目录的**合并**。每个目录树下相同路径的两个文件将会被合并到叠加后的文件系统里的相同目录下。与之前容器的镜像层数越多,AUFS需要耗费的查找时间则越长的情况相比,OverlayFS在查找效率方面做出了不小的改进。每当有针对合并目录的查找请求时,它会在被合并的目录中同时进行查找,然后将最终的结果**缓存**到叠加文件系统的固定条目里。如果它们均找到对应的文件,那么这些查找记录会被保存下来,并且会为此创建一个新的合并目录将这些相同的文件合并,否则便**只有其中一个会被保存**:如果上层查找得到便是上层,否则便是下层。 那么,Docker是如何使用OverlayFS的呢?它将下层的文件系统作为基础镜像层,当创建一个新的Docker容器时,它会自动为之创建一个新的只包含两个镜像层之间增量的上层文件系统。这和我们之前介绍的AUFS的做法简直一模一样。正如我们预期的那样,提交一个容器所创建的新的镜像层同样也只是包含基础镜像层和新镜像层之间的差异而已。 好了,理论就先讲到这里。接下来,让我们来看一个具体案例。在此之前,为了满足Overlayfs的基本使用条件,用户的Linux内核必须是3.18或以上版本。如果用户的内核版本比这个低,Docker将会选择下一个可用的存储引擎,而不使用`overlay`。 与之前一样,用户需要告知Docker守护进程采用`overlay`存储引擎。记住,存储引擎的名字**是`overlay`**而不是**`overlayfs`**: ``` # docker info Containers: 0 Images: 0 Storage Driver: overlay Backing Filesystem: extfs Execution Driver: native-0.2 Kernel Version: 3.18.0-031800-generic Operating System: Ubuntu 14.04 LTS CPUs: 1 Total Memory: 489.2 MiB Name: docker ID: NQJM:HHFZ:5636:VGNJ:ICQA:FK4U:6A7F:EUDC:VFQL:PJFF:MI7N:TX7L WARNING: No swap limit support ``` 现在,让我们创建一个新容器,随后看下`/var/lib/docker`目录里的内容: ``` # docker run -d busybox top Unable to find image 'busybox:latest' locally 511136ea3c5a: Pull complete df7546f9f060: Pull complete ea13149945cb: Pull complete 4986bf8c1536: Pull complete busybox:latest: The image you are pulling has been verified. Important: image verification is a tech preview feature and should not be relied on to provide security. Status: Downloaded newer image for busybox:latest eb9e1a68c70532ecd31e20d8ca4b7f598d2259d1ac8accaa02090f32ee0b95c1 ``` 所有的镜像层都如预期那样出现在了`/var/lib/docker/overlay`目录里。查找基础镜像和容器镜像层实际上和检索挂载好的该容器文件系统一样简单,而且用户可以在这里看到**下层**和**上层**目录里的内容: ``` # grep eb9e1a68c705 /proc/mounts overlay /var/lib/docker/overlay/ eb9e1a68c70532ecd31e20d8ca4b7f598d2259d1ac8ac- caa02090f32ee0b95c1/merged overlay rw,relatime,lowerdir=/var/lib/docker/overlay/ 4986bf8c15363d1c5d15512d5266f8777bfba4974ac56e3270e7760f6f0a8125/ root,upperdir=/var/lib/docker/overlay/ eb9e1a68c70532ecd31e20d8ca4b7f598d2259d1ac8ac- caa02090f32ee0b95c1/upper,workdir=/var/lib/docker/overlay/ eb9e1a68c70532ecd31e20d8ca4b7f598d2259d1ac8ac- caa02090f32ee0b95c1/work 0 0 ``` 和我们测试之前的存储引擎一样,我们将会在一个正在运行的容器里创建一个空文件然后提交,而这会立刻触发创建一个新的镜像层: ``` # docker exec -it eb9e1a68c705 touch /etc/testfile # docker ps CONTAINER ID IMAGE COMMAN CREATED STATUS PORTS NAMES eb9e1a68c705 busybox:latest "top" 9 minutes ago Up 9 minutes cocky_bohr # docker commit eb9e1a68c705 eae6654d10c2c6467e975a80559dcc2dd57178baaea57dd8d347c950a46c404b ``` 用户可以简单地通过如下命令来确认它是否已经创建好新的镜像层以及里面是否包含我们刚刚创建的那个新的空白文件: ``` # ls -l /var/lib/docker/overlay/ eae6654d10c2c6467e975a80559dcc2dd57178baaea57dd8d347c950a46c404b/ root/etc/testfile -rw-r--r-- 1 root root 0 Apr 8 21:44 /var/lib/docker/overlay/ eae6654d10c2c6467e975a80559dcc2dd57178baaea57dd8d347c950a46c404b/ root/etc/testfile ``` 上述所有结果完全符合我们的预期,而它最终的表现和`aufs`存储引擎所做的几乎完全一样。但是,这里面也有些许的不同。`overly`这里没有`diff`子目录。然而这并不意味着我们就没有办法检索文件系统的增量——它们只是被“隐藏”在了某个地方罢了。`overlay`存储引擎会为每个容器创建3个子目录: - `upper`——这个便是可读写的上层文件系统层; - `work`——原子复制所需的临时目录; - `merged`——正在运行中的容器的挂载点。 此外,该引擎会创建一个叫做**lower-id**的文件,这里面会包含父镜像层的`id`而它的"根"目录将会被`overlay`当做下层目录——该文件一般用于存放父镜像层的查找`id`。 让我们从之前提交的一个镜像层启动一个新容器: ``` # docker run -d eae6654d10c2c6467e975a80559dcc2dd57178baaea57dd8d347c950a46c404b 007c9ca6bb483474f1677349a25c769ee7435f7b22473305f18cccb2fca21333 ``` 我们可以很方便地通过查看“lower-id”文件来确认容器的父镜像层的`id`: ``` # cat /var/lib/docker/overlay/ 007c9ca6bb483474f1677349a25c769ee7435f7b22473305f18cccb2fca21333/lower-id eae6654d10c2c6467e975a80559dcc2dd57178baaea57dd8d347c950a46c404b ``` 由于`overlay`存储引擎和之前评测过的`aufs`引擎有非常多的相似之处,因此这里不再赘述。话说回来,为什么`overlay`引擎能提供如此多令人兴奋的特性呢?这里面有几个重要的原因: - Linux内核主干内置了对Overlay文件系统的支持——我们不需要再安装任何额外的内核补丁; - 因为采用了页面缓存共享的机制,所以它能够占用更少的内存资源; - 虽然它在下层和上层文件系统之间的复制速度方面有一定的问题,但是综合来说,它依旧比aufs引擎要快上不少; - 对于镜像之间相同的文件是以硬链接的方式关联在一起,如此就可以避免重复的覆盖并且容许更快的删除/销毁。 尽管有上述这些优点,但OverlayFS仍旧是一个非常年轻的文件系统。虽然从初步的测试结果来看它是十分理想的,但是我们仍然没有看到它实际投入生产环境下应用的具体案例。有鉴于此,越来越多像[CoreOS](https://coreos.com)这样的企业和OverlayFS站到了同一[阵营](http://lwn.net/Articles/627232/),因此我们可以预见到的是未来它会有更多积极的开发和改进。 现在,是时候放下对写时复制这一特性的迷恋了,接下来我们来看一看最后一个Docker存储引擎`vfs`,这是一个不提供写时复制机制的存储引擎。 就像前面提到的,`vfs`存储引擎是唯一的一个不采用任何写时复制机制的存储引擎。每个镜像层就是一个单一的目录。当Docker创建一个新的镜像层时它所做的便是将基础镜像层所在的目录**全量地物理复制**到新建镜像的目录里。这将导致这个引擎运转非常缓慢并且磁盘空间的利用率也会很低。 让我们来看一个使用`vfs`的实际案例。和之前一样,我们首先需要修改一下环境变量`DOCKER_OPTS`,告知Docker守护进程采用`vfs`作为存储引擎,然后重启服务: ``` # ps -ef|grep docker root 2680 1 0 21:51 ? 00:00:02 /usr/bin/docker -d --storage-driver=vfs ``` 我们将通过一个很小的只运行`top`命令的`busybox`容器来讲解`vfs`引擎的一些特性。 ``` # docker run -d busybox top ... [FILTERED OUTPUT] ... de8c6e2684acefa1e84791b163212d92003886ba8cb73eccef4b2c4d167a59a4 ``` VFS镜像层存放在`/var/lib/docker/vfs/dir`目录里。现在,我们将测试一下使用这一引擎时的一些具体速度和磁盘空间方面的性能表现。我们会通过如下命令启动一个新容器然后在里面生成一个合适大小的大文件: ``` # docker run -ti busybox /bin/sh / # dd if=/dev/zero of=sample.txt bs=200M count=1 1+0 records in 1+0 records out / # du -sh sample.txt 200.0M sample.txt /# ``` 紧接着,提交这个容器来触发Docker创建一个新的镜像层,并且观察一下对应的执行速度: ``` # time docker commit 24247ae7c1c0 7f3a2989b52e0430e6372e34399757a47180080b409b94cb46d6cd0a7c46396e real 0m1.029s user 0m0.008s sys 0m0.008s ``` 结果表明,它耗费了1秒左右的时间来创建一个新的镜像层——这要归咎于基础镜像层里我们创建的那个200 MB大小的文件。最后,让我们从新提交的这一层镜像中再创建一个新容器,然后再看看它的速度是怎样的: ``` # time docker run --rm -it 7f3a2989b52e0430e6372e34399757a47180080b409b94cb46d6cd0a7c46396e echo VFS is slow VFS is slow real 0m3.124s user 0m0.021s sys 0m0.007s ``` 从先前那个提交的镜像创建一个新容器竟然花了将近3秒!此外,我们拥有的两个容器在宿主机文件系统上每个均占用了200 MB的磁盘空间! 上述的这个例子已经证明VFS是不太适用于生产环境的。它更像是当宿主机上没有任何支持写时复制特性的文件系统的时候的一个备选方案。尽管如此,VFS依旧算是挂载Docker卷的一个不错的解决方案,原因在于它具备良好的平台兼容性并且当用户打算在像FreeBSD这样的非Linux平台上运行Docker时它会是一个不错的选择。 Docker在存储引擎方面提供了一个相当全面的选择。这也许是一个喜忧参半的情况,因为通常初学者一旦知道了有这么多的备选后,可能会困惑到底应该选哪个。Donald Knuth关于“[过早优化](https://en.wikipedia.org/wiki/Program_optimization#When_to_optimize)”的名言常常会在我们讨论技术选型的时候出现在脑海里。 实际上,人们往往会选择自己在生产环境里最有把握运维的那个工具。最后,我们以一张简短的表(表11-1)来结束本章,表中列出了一些在选择使用哪个存储引擎之前需要评估的要点。 表11-1 AUFS OverlayFS BTRFS DeviceMapper 上线 非常快 非常快 快 快 小文件I/O 非常快 非常快 快 快 大文件I/O 慢 慢 快 快 内存使用率 高效 高效 高效 不是很高效 缺陷 没有在内核主干里,有层数限制,随机并发度的问题 不成熟 接近成熟,但是实际上还不是,存储扩容会很费劲,需要一些扎实的运维知识 高密度的容器及很高的磁盘占用,周边生态不好,大部分需要的是运维经验 Docker在网络领域同样带来了革命性的挑战,我们将在第12章讲解这方面的内容。 - - - - - - [\[1\]](part0017.xhtml#ac111) 是DeviceMapper提供的一项特性,它允许在实际使用时实报实销的资源利用,即类似虚拟内存的一项技术。——译者注