copy-on-write(CoW: 写时复制)

镜像和层

在了解什么是写时复制时,我们需要先了解镜像的本质是什么。一个Docker镜像是由一系列的层构建起来。每一个层代表Dockerfile中的一个指令。每一个层除了最后一层都是只读的。镜像并非独立的二进制块,而是由多个镜像层构成的。 不同的镜像可以共享若干个镜像层,这样确保镜像的存储和传递更加高效。所有构成基础镜像的层只会被保存一次。当拉取镜像的时候,docker会独立的下载每一层,对于已经存储在本地的层,docker不会重复下载它们。

Dockerfile中的每条指定构成一个镜像层。拉取基础镜像层以后,docker会在这个层上面构建新的层。最后一层会为镜像打上tag(标签)。参考之前打包构建的node.js应用:

思考下面这个Dockerfile:

FROM ubuntu:18.04
COPY . /app
RUN make /app
CMD python /app/app.py

上面的Dockerfile包含四条指令,每一条指令都会创建一个镜像层。FROM指令通过ubuntu:18.04创建一个起始层。COPY指令负责添加一些来自当前路径下面的文件。RUN指令使用make命令来构建应用。最终,最后的CMD指令指定了在容器中运行的命令。

每一层只是与上一层不同的一组集合。这些层彼此堆叠。创建新容器时,可以在基础层之上添加一个新的可写层。该层通常称为“容器层”。对运行中的容器所做的所有更改(例如写入新文件,修改现有文件和删除文件)都将写入此可写容器层。下图显示了基于Ubuntu 15.04镜像的容器。

存储驱动程序处理有关这些层相互交互的方式的详细信息。可以使用不同的存储驱动程序,它们在不同情况下各有利弊。

容器和层

容器和镜像之间的主要区别是顶层的可写层。在容器中添加新数据或修改现有数据的所有写操作都存储在此可写层中。删除容器后,可写层也会被删除。基础镜像保持不变。

因为每个容器都有其自己的可写容器层,并且所有更改都存储在该容器层中,所以多个容器可以共享对同一基础镜像的访问,但具有自己的数据状态。下图显示了共享同一Ubuntu 15.04镜像的多个容器。

Docker使用存储驱动程序来管理镜像层和可写容器层的内容。每个存储驱动程序处理方式的实现各不相同,但是所有驱动程序都使用可堆叠的镜像层和写时复制(CoW)策略。

要查看正在运行的容器的大致大小,可以使用docker ps -s命令。有两个不同的列与大小有关。

  • size:用于每个容器的可写层的数据量(在磁盘上)。

  • virtual size:容器使用的只读镜像数据的数据量加上容器的可写层大小。多个容器可以共享部分部或全部只读镜像数据。从同一镜像开始的两个容器共享100%的只读数据,而具有不同镜像的两个容器(具有相同的层)共享这些公共层。因此,我们不能只对虚拟大小进行总计。这高估总的磁盘使用量。

磁盘上所有正在运行的容器使用的磁盘总空间是每个容器的大小(size)和虚拟大小(virtual size)值的某种组合。如果多个容器从相同的镜像开始,则这些容器在磁盘上的总大小将为容器的大小(size)加上一个镜像大小(virtual size减去size)之和。

上面计算容器占用的磁盘空间时,不包括容器通过以下其他方式占用的磁盘空间大小:

  • 如果使用json-file日志记录驱动程序,则用于日志文件的磁盘空间。如果容器生成大量的日志数据并且未配置日志轮询,那么这可能并非易事。

  • 容器使用的卷(volume)和绑定挂载(bind mount)。 容器的配置文件所用的磁盘空间,通常较小。

  • 写入磁盘的内存(如果启用了swap交换)。

  • 检查点(如果正在使用实验特性:检查点/恢复功能)。

The copy-on-write (CoW) 策略

写时复制是一种共享和复制文件的策略,可最大程度地提高效率。如果文件或目录位于镜像的较低层中,而另一层(包括可写层)需要对其进行读取访问,则它仅使用现有的已经存在的文件。另一层第一次需要修改文件时(在构建镜像或运行容器时),将文件复制到该层并进行修改。这样可以将I/O和每个后续层的大小最小化。这些优点将在下面更深入地说明。

当使用docker pull拉取镜像仓库中的镜像时,或者当我们从本地尚不存在的镜像中创建一个容器时,每个层将分别向下拉,并存储在Docker的本地存储区域,Linux 主机上该存储区通常是/var/lib/docker。可以在此示例中看到这些镜像层被拉取:

$ docker pull ubuntu:18.04
18.04: Pulling from library/ubuntu
f476d66f5408: Pull complete
8882c27f669e: Pull complete
d9af21273955: Pull complete
f5029279ec12: Pull complete
Digest: sha256:ab6cb8de3ad7bb33e2534677f865008535427390b117d7939193f8d1a6613e34
Status: Downloaded newer image for ubuntu:18.04

这些层中的每一个都存储在Docker主机的本地存储区域内的各自的目录中。要检查文件系统上的镜像层,列出/var/lib/docker/<storage-driver>的内容。此示例使用overlay2存储驱动程序:

$ ls /var/lib/docker/overlay2
16802227a96c24dcbeab5b37821e2b67a9f921749cd9a2e386d5a6d5bc6fc6d3
377d73dbb466e0bc7c9ee23166771b35ebdbe02ef17753d79fd3571d4ce659d7
3f02d96212b03e3383160d31d7c6aeca750d2d8a1879965b89fe8146594c453d
ec1ec45792908e90484f7e629330666e7eee599f08729c93890a7205a6ba35f5
l

假设我们有两个Dockerfile,使用第一个Dockerfile创建一个叫做acme/my-base-image:1.0的镜像

FROM ubuntu:18.04
COPY . /app

使用第二个Dockerfile基于第一个Dockerfile构建的镜像进行构建,但是有一些额外的层:

FROM acme/my-base-image:1.0
CMD /app/hello.sh

第二个镜像包含第一个镜像的所有镜像层,以及带有CMD 指令的新层和读写容器层。Docker已经拥有了第一个镜像的所有镜像层,因此不需要再次拉取它们。这两个镜像共享它们的通用层。

如果我们从两个Dockerfile构建镜像,则可以使用docker image ls和docker history命令来验证共享层的加密ID是否相同。

  1. 创建一个新的目录cow-test/, 然后该目录下面进行更改。

  2. 在cow-test/目录中,创建一个新的包含下面内容的hello.sh文件:

#!/bin/sh
echo "Hello world"

保存该文件,并为它添加可执行权限:

chmod +x hello.sh

3. 将第一个Dockerfile的内容拷贝到Dockerfile.base这个文件中。

4. 将第二个Dockerfile的内容拷贝到新的Dockerfile文件中。

5. 在cow-test/目录下面构建第一个镜像。

$ docker build -t acme/my-base-image:1.0 -f Dockerfile.base .
Sending build context to Docker daemon  812.4MB
Step 1/2 : FROM ubuntu:18.04
 ---> d131e0fa2585
Step 2/2 : COPY . /app
 ---> Using cache
 ---> bd09118bcef6
Successfully built bd09118bcef6
Successfully tagged acme/my-base-image:1.0

6. 构建第二个镜像。

$ docker build -t acme/my-final-image:1.0 -f Dockerfile .

Sending build context to Docker daemon  4.096kB
Step 1/2 : FROM acme/my-base-image:1.0
 ---> bd09118bcef6
Step 2/2 : CMD /app/hello.sh
 ---> Running in a07b694759ba
 ---> dbf995fc07ff
Removing intermediate container a07b694759ba
Successfully built dbf995fc07ff
Successfully tagged acme/my-final-image:1.0

7. 检查镜像的大小:

$ docker image ls

REPOSITORY                         TAG                     IMAGE ID            CREATED             SIZE
acme/my-final-image                1.0                     dbf995fc07ff        58 seconds ago      103MB
acme/my-base-image                 1.0                     bd09118bcef6        3 minutes ago       103MB

8. 检查构成镜像的每一层:

$ docker history bd09118bcef6
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
bd09118bcef6        4 minutes ago       /bin/sh -c #(nop) COPY dir:35a7eb158c1504e...   100B                
d131e0fa2585        3 months ago        /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B                  
<missing>           3 months ago        /bin/sh -c mkdir -p /run/systemd && echo '...   7B                  
<missing>           3 months ago        /bin/sh -c sed -i 's/^#\s*\(deb.*universe\...   2.78kB              
<missing>           3 months ago        /bin/sh -c rm -rf /var/lib/apt/lists/*          0B                  
<missing>           3 months ago        /bin/sh -c set -xe   && echo '#!/bin/sh' >...   745B                
<missing>           3 months ago        /bin/sh -c #(nop) ADD file:eef57983bd66e3a...   103MB      
$ docker history dbf995fc07ff

IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
dbf995fc07ff        3 minutes ago       /bin/sh -c #(nop)  CMD ["/bin/sh" "-c" "/a...   0B                  
bd09118bcef6        5 minutes ago       /bin/sh -c #(nop) COPY dir:35a7eb158c1504e...   100B                
d131e0fa2585        3 months ago        /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B                  
<missing>           3 months ago        /bin/sh -c mkdir -p /run/systemd && echo '...   7B                  
<missing>           3 months ago        /bin/sh -c sed -i 's/^#\s*\(deb.*universe\...   2.78kB              
<missing>           3 months ago        /bin/sh -c rm -rf /var/lib/apt/lists/*          0B                  
<missing>           3 months ago        /bin/sh -c set -xe   && echo '#!/bin/sh' >...   745B                
<missing>           3 months ago        /bin/sh -c #(nop) ADD file:eef57983bd66e3a...   103MB  

请注意,除第二个镜像的顶层外,所有镜像层都是相同的。所有其他镜像层在两个镜像之间共享,并且仅存储在/var/lib/docker/中一次。新层实际上根本不占用任何空间,因为它不会更改任何文件,而只会运行命令。

Note:

docker history输出中的<missing>行表示这些镜像层构建在另一个系统上,并且不在本地提供。这些镜像层可以忽略。

拷贝使容器更高效

当我们启动一个容器时,一个可写的容器层被添加到其他层的顶部。容器对文件系统的任何更改都会存储在这里。没有更改的文件不会被拷贝到可写层。这意味着可写层尽可能的小。

当容器中的一个文件被更改时,存储驱动会执行一个cow操作。操作的步骤取决于存储驱动的类型。例如aufs,overlay和overlay2驱动,cow操作大致遵循下面的步骤:

  • 在镜像层中搜索需要更新的文件。搜索的过程从最近的镜像层开始,从上往下查找。当找到文件以后,它们会被添加到一个缓存中来加快后面重复的操作。

  • 对找到的文件执行一个copy_up操作,将文件拷贝到容器的可写层。

  • 所有的修改都会作用于拷贝的文件上,容器无法看到下层镜像层的只读副本。

Btrfs, ZFS和其他驱动处理cow是不同的。写入大量数据的容器比不写入数据的容器消耗更多的空间。这是因为大多数写入操作在容器的可写层中消耗了新的空间。

Note:

对于写入比较频繁的应用程序,我们不应将数据存储在容器中。相反,使用Docker卷,它独立于运行中的容器,旨在高效使用I/O。此外,卷可以在容器之间共享,并且不会增加容器可写层的大小。

copy_up操作可能会产生明显的性能开销。此性能开销因使用的存储驱动程序而异。大量的文件、大量的镜像层和深目录树会使影响更加明显。每个copy_up操作仅在对给定文件修改时才发生,从而缓解了这一问题。

为了验证cow的工作方式,以下程序根据我们之前构建的acme/my-final-image:1.0镜像创建5个容器,并检查它们占用了多少空间。

在终端输入下面的命令,创建5个容器:

$ docker run -dit --name my_container_1 acme/my-final-image:1.0 bash \
  && docker run -dit --name my_container_2 acme/my-final-image:1.0 bash \
  && docker run -dit --name my_container_3 acme/my-final-image:1.0 bash \
  && docker run -dit --name my_container_4 acme/my-final-image:1.0 bash \
  && docker run -dit --name my_container_5 acme/my-final-image:1.0 bash

  c36785c423ec7e0422b2af7364a7ba4da6146cbba7981a0951fcc3fa0430c409
  dcad7101795e4206e637d9358a818e5c32e13b349e62b00bf05cd5a4343ea513
  1e7264576d78a3134fbaf7829bc24b1d96017cf2bc046b7cd8b08b5775c33d0c
  38fa94212a419a082e6a6b87a8e2ec4a44dd327d7069b85892a707e3fc818544
  1a174fc216cccf18ec7d4fe14e008e30130b11ede0f0f94a87982e310cf2e765

运行docker ps命令来检查正在运行的5个容器。

CONTAINER ID      IMAGE                     COMMAND     CREATED              STATUS              PORTS      NAMES
1a174fc216cc      acme/my-final-image:1.0   "bash"      About a minute ago   Up About a minute              my_container_5
38fa94212a41      acme/my-final-image:1.0   "bash"      About a minute ago   Up About a minute              my_container_4
1e7264576d78      acme/my-final-image:1.0   "bash"      About a minute ago   Up About a minute              my_container_3
dcad7101795e      acme/my-final-image:1.0   "bash"      About a minute ago   Up About a minute              my_container_2
c36785c423ec      acme/my-final-image:1.0   "bash"      About a minute ago   Up About a minute              my_container_1

列出容器本地存储区域的内容:

$ sudo ls /var/lib/docker/containers

1a174fc216cccf18ec7d4fe14e008e30130b11ede0f0f94a87982e310cf2e765
1e7264576d78a3134fbaf7829bc24b1d96017cf2bc046b7cd8b08b5775c33d0c
38fa94212a419a082e6a6b87a8e2ec4a44dd327d7069b85892a707e3fc818544
c36785c423ec7e0422b2af7364a7ba4da6146cbba7981a0951fcc3fa0430c409
dcad7101795e4206e637d9358a818e5c32e13b349e62b00bf05cd5a4343ea513

检查这些文件的大小:

$ sudo du -sh /var/lib/docker/containers/*

32K  /var/lib/docker/containers/1a174fc216cccf18ec7d4fe14e008e30130b11ede0f0f94a87982e310cf2e765
32K  /var/lib/docker/containers/1e7264576d78a3134fbaf7829bc24b1d96017cf2bc046b7cd8b08b5775c33d0c
32K  /var/lib/docker/containers/38fa94212a419a082e6a6b87a8e2ec4a44dd327d7069b85892a707e3fc818544
32K  /var/lib/docker/containers/c36785c423ec7e0422b2af7364a7ba4da6146cbba7981a0951fcc3fa0430c409
32K  /var/lib/docker/containers/dcad7101795e4206e637d9358a818e5c32e13b349e62b00bf05cd5a4343ea513

每个容器在文件系统上仅占用32kb的空间。

cow不仅节省了空间,还缩短了启动时间。当启动一个容器(或来自同一镜像的多个容器)时,Docker只需要创建薄薄的可写容器层 。

如果Docker每次启动新容器时必须获取一个底层镜像堆栈的完整副本,则容器启动时间和磁盘空间将显著增加。这将类似于虚拟机的工作方式,每个虚拟机有一个或多个虚拟磁盘。

Last updated