关于存储驱动程序

为了有效地使用存储驱动程序,了解 Docker 如何构建和存储镜像以及这些镜像如何被容器使用非常重要。您可以利用这些信息来做出明智的决策,选择最佳方式来持久保存您的应用程序数据,并避免性能问题。

存储驱动程序与 Docker 卷

Docker 使用存储驱动程序来存储镜像层,以及存储容器可写层中的数据。容器的可写层在容器被删除后不会持久保存,但适合存储在运行时生成的临时数据。存储驱动程序针对空间效率进行了优化,但(取决于存储驱动程序)写入速度比原生文件系统性能更低,特别是对于使用写时复制文件系统的存储驱动程序而言。写密集型应用程序(例如数据库存储)会受到性能开销的影响,特别是如果读写层中存在预先存在的数据。

对于写密集型数据、必须在容器生命周期之外持久保存的数据以及必须在容器之间共享的数据,请使用 Docker 卷。请参阅 卷部分,了解如何使用卷来持久保存数据并提高性能。

镜像和层

Docker 镜像由一系列层构建而成。每个层代表镜像的 Dockerfile 中的一条指令。除最后一层外,所有层都是只读的。请考虑以下 Dockerfile

# syntax=docker/dockerfile:1

FROM ubuntu:22.04
LABEL org.opencontainers.image.authors="org@example.com"
COPY . /app
RUN make /app
RUN rm -r $HOME/.cache
CMD python /app/app.py

此 Dockerfile 包含四个命令。修改文件系统的命令会创建一个层。FROM 语句首先从 ubuntu:22.04 镜像创建一个层。LABEL 命令只修改镜像的元数据,不会生成新的层。COPY 命令从 Docker 客户端的当前目录添加一些文件。第一个 RUN 命令使用 make 命令构建您的应用程序,并将结果写入新的层。第二个 RUN 命令删除缓存目录,并将结果写入新的层。最后,CMD 指令指定在容器中运行的命令,这只会修改镜像的元数据,不会生成镜像层。

每个层只是一组与之前层相比的差异。请注意,添加删除 文件都会导致生成新的层。在上面的示例中,$HOME/.cache 目录被删除,但仍将在之前层中可用,并计入镜像的总大小。请参阅 编写 Dockerfile 的最佳实践使用多阶段构建 部分,了解如何优化 Dockerfile 以生成高效的镜像。

这些层叠加在一起。当您创建一个新的容器时,您会在底层层之上添加一个新的可写层。此层通常被称为“容器层”。对正在运行的容器进行的所有更改,例如写入新文件、修改现有文件和删除文件,都将写入此精简的可写容器层。下图显示了一个基于 ubuntu:15.04 镜像的容器。

Layers of a container based on the Ubuntu image

存储驱动程序处理这些层之间交互方式的详细信息。有多种可用的存储驱动程序,它们在不同情况下具有优点和缺点。

容器和层

容器和镜像之间的主要区别在于顶层的可写层。对容器的所有写入,包括添加新数据或修改现有数据,都将存储在此可写层中。当容器被删除时,可写层也会被删除。底层镜像保持不变。

由于每个容器都有自己的可写容器层,并且所有更改都存储在此容器层中,因此多个容器可以共享对同一个底层镜像的访问,但仍然拥有自己的数据状态。下图显示了多个容器共享同一个 Ubuntu 15.04 镜像。

Containers sharing the same image

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

注意

如果您需要多个容器共享访问完全相同的数据,请使用 Docker 卷。请参阅 卷部分,了解有关卷的信息。

容器磁盘大小

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

  • size:每个容器的可写层使用的(磁盘上的)数据量。
  • virtual size:容器使用的只读镜像数据量,加上容器的可写层 size。多个容器可能会共享一些或全部只读镜像数据。从同一个镜像启动的两个容器共享 100% 的只读数据,而具有不同镜像(但有共同层)的两个容器将共享这些共同层。因此,您不能简单地将虚拟大小加总。这会高估磁盘总使用量,高估量可能很大。

所有正在运行的容器在磁盘上使用的总磁盘空间是每个容器 sizevirtual size 值的某种组合。如果多个容器从完全相同的镜像启动,这些容器在磁盘上的总大小将为 SUM(容器的 size)加上一个镜像大小(virtual size - size)。

这也忽略了容器占用磁盘空间的其他几种方式:

  • 日志记录驱动程序 存储的日志文件使用的磁盘空间。如果您的容器生成大量日志记录数据且未配置日志轮换,这可能会相当大。
  • 容器使用的卷和绑定挂载。
  • 容器配置文件使用的磁盘空间,这些文件通常很小。
  • 写入磁盘的内存(如果启用了交换)。
  • 检查点(如果您使用的是实验性的检查点/恢复功能)。

写时复制 (CoW) 策略

写时复制是一种共享和复制文件以实现最大效率的策略。如果文件或目录存在于镜像中的较低层,并且另一个层(包括可写层)需要对其进行读取访问,它将直接使用现有文件。当另一个层需要修改文件(在构建镜像或运行容器时)时,该文件将被复制到该层并进行修改。这将最大限度地减少 I/O 和后续每个层的大小。这些优点在下面更详细地解释。

共享促进更小的镜像

当您使用 docker pull 从存储库拉取镜像,或者当您从本地尚不存在的镜像创建容器时,每个层都会被单独拉取,并存储在 Docker 的本地存储区域,通常是在 Linux 主机上的 /var/lib/docker/。您可以看到此示例中拉取的这些层:

$ docker pull ubuntu:22.04
22.04: Pulling from library/ubuntu
f476d66f5408: Pull complete
8882c27f669e: Pull complete
d9af21273955: Pull complete
f5029279ec12: Pull complete
Digest: sha256:6120be6a2b7ce665d0cbddc3ce6eae60fe94637c6a66985312d1f02f63cc0bcd
Status: Downloaded newer image for ubuntu:22.04
docker.io/library/ubuntu:22.04

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

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

目录名称不对应于层 ID。

现在假设您有两个不同的 Dockerfile。您使用第一个 Dockerfile 创建一个名为 acme/my-base-image:1.0 的镜像。

# syntax=docker/dockerfile:1
FROM alpine
RUN apk add --no-cache bash

第二个 Dockerfile 基于 acme/my-base-image:1.0,但有一些额外的层

# syntax=docker/dockerfile:1
FROM acme/my-base-image:1.0
COPY . /app
RUN chmod +x /app/hello.sh
CMD /app/hello.sh

第二个镜像包含第一个镜像中的所有层,以及 COPYRUN 指令创建的新层,以及一个读写容器层。Docker 已经拥有第一个镜像中的所有层,因此不需要再次拉取它们。这两个镜像共享它们拥有的任何共同层。

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

  1. 创建一个新目录 cow-test/ 并进入该目录。

  2. cow-test/ 中,创建一个名为 hello.sh 的新文件,内容如下。

    #!/usr/bin/env bash
    echo "Hello world"
  3. 将上面第一个 Dockerfile 的内容复制到一个名为 Dockerfile.base 的新文件中。

  4. 将上面第二个 Dockerfile 的内容复制到一个名为 Dockerfile 的新文件中。

  5. cow-test/ 目录中,构建第一个镜像。不要忘记在命令中包含最后的 .。这会设置 PATH,告诉 Docker 在哪里查找需要添加到镜像的任何文件。

    $ docker build -t acme/my-base-image:1.0 -f Dockerfile.base .
    [+] Building 6.0s (11/11) FINISHED
    => [internal] load build definition from Dockerfile.base                                      0.4s
    => => transferring dockerfile: 116B                                                           0.0s
    => [internal] load .dockerignore                                                              0.3s
    => => transferring context: 2B                                                                0.0s
    => resolve image config for docker.io/docker/dockerfile:1                                     1.5s
    => [auth] docker/dockerfile:pull token for registry-1.docker.io                               0.0s
    => CACHED docker-image://docker.io/docker/dockerfile:1@sha256:9e2c9eca7367393aecc68795c671... 0.0s
    => [internal] load .dockerignore                                                              0.0s
    => [internal] load build definition from Dockerfile.base                                      0.0s
    => [internal] load metadata for docker.io/library/alpine:latest                               0.0s
    => CACHED [1/2] FROM docker.io/library/alpine                                                 0.0s
    => [2/2] RUN apk add --no-cache bash                                                          3.1s
    => exporting to image                                                                         0.2s
    => => exporting layers                                                                        0.2s
    => => writing image sha256:da3cf8df55ee9777ddcd5afc40fffc3ead816bda99430bad2257de4459625eaa   0.0s
    => => naming to docker.io/acme/my-base-image:1.0                                              0.0s
    
  6. 构建第二个镜像。

    $ docker build -t acme/my-final-image:1.0 -f Dockerfile .
    
    [+] Building 3.6s (12/12) FINISHED
    => [internal] load build definition from Dockerfile                                            0.1s
    => => transferring dockerfile: 156B                                                            0.0s
    => [internal] load .dockerignore                                                               0.1s
    => => transferring context: 2B                                                                 0.0s
    => resolve image config for docker.io/docker/dockerfile:1                                      0.5s
    => CACHED docker-image://docker.io/docker/dockerfile:1@sha256:9e2c9eca7367393aecc68795c671...  0.0s
    => [internal] load .dockerignore                                                               0.0s
    => [internal] load build definition from Dockerfile                                            0.0s
    => [internal] load metadata for docker.io/acme/my-base-image:1.0                               0.0s
    => [internal] load build context                                                               0.2s
    => => transferring context: 340B                                                               0.0s
    => [1/3] FROM docker.io/acme/my-base-image:1.0                                                 0.2s
    => [2/3] COPY . /app                                                                           0.1s
    => [3/3] RUN chmod +x /app/hello.sh                                                            0.4s
    => exporting to image                                                                          0.1s
    => => exporting layers                                                                         0.1s
    => => writing image sha256:8bd85c42fa7ff6b33902ada7dcefaaae112bf5673873a089d73583b0074313dd    0.0s
    => => naming to docker.io/acme/my-final-image:1.0                                              0.0s
    
  7. 查看镜像的大小。

    $ docker image ls
    
    REPOSITORY             TAG     IMAGE ID         CREATED               SIZE
    acme/my-final-image    1.0     8bd85c42fa7f     About a minute ago    7.75MB
    acme/my-base-image     1.0     da3cf8df55ee     2 minutes ago         7.75MB
    
  8. 查看每个镜像的历史记录。

    $ docker image history acme/my-base-image:1.0
    
    IMAGE          CREATED         CREATED BY                                      SIZE      COMMENT
    da3cf8df55ee   5 minutes ago   RUN /bin/sh -c apk add --no-cache bash # bui…   2.15MB    buildkit.dockerfile.v0
    <missing>      7 weeks ago     /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
    <missing>      7 weeks ago     /bin/sh -c #(nop) ADD file:f278386b0cef68136…   5.6MB
    

    有些步骤没有大小 (0B),并且只是元数据更改,不会生成镜像层,也不会占用任何大小,除了元数据本身。上面的输出显示该镜像包含 2 个镜像层。

    $ docker image history  acme/my-final-image:1.0
    
    IMAGE          CREATED         CREATED BY                                      SIZE      COMMENT
    8bd85c42fa7f   3 minutes ago   CMD ["/bin/sh" "-c" "/app/hello.sh"]            0B        buildkit.dockerfile.v0
    <missing>      3 minutes ago   RUN /bin/sh -c chmod +x /app/hello.sh # buil…   39B       buildkit.dockerfile.v0
    <missing>      3 minutes ago   COPY . /app # buildkit                          222B      buildkit.dockerfile.v0
    <missing>      4 minutes ago   RUN /bin/sh -c apk add --no-cache bash # bui…   2.15MB    buildkit.dockerfile.v0
    <missing>      7 weeks ago     /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
    <missing>      7 weeks ago     /bin/sh -c #(nop) ADD file:f278386b0cef68136…   5.6MB
    

    请注意,第一个图像的所有步骤都包含在最终图像中。最终图像包括第一个图像的两个图层,以及在第二个图像中添加的两个图层。

    docker history 输出中的 <missing> 行表示这些步骤要么是在另一个系统上构建的,并且是来自 Docker Hub 的 alpine 镜像的一部分,要么是使用 BuildKit 作为构建器构建的。在 BuildKit 出现之前,"经典"构建器会为每个步骤生成一个新的"中间"镜像以用于缓存,而 IMAGE 列会显示该镜像的 ID。

    BuildKit 使用它自己的缓存机制,不再需要中间镜像来进行缓存。请参阅 BuildKit 以了解有关 BuildKit 中进行的其他改进的更多信息。

  9. 查看每个图像的图层

    使用 docker image inspect 命令查看每个图像中图层的加密 ID

    $ docker image inspect --format "{{json .RootFS.Layers}}" acme/my-base-image:1.0
    [
      "sha256:72e830a4dff5f0d5225cdc0a320e85ab1ce06ea5673acfe8d83a7645cbd0e9cf",
      "sha256:07b4a9068b6af337e8b8f1f1dae3dd14185b2c0003a9a1f0a6fd2587495b204a"
    ]
    
    $ docker image inspect --format "{{json .RootFS.Layers}}" acme/my-final-image:1.0
    [
      "sha256:72e830a4dff5f0d5225cdc0a320e85ab1ce06ea5673acfe8d83a7645cbd0e9cf",
      "sha256:07b4a9068b6af337e8b8f1f1dae3dd14185b2c0003a9a1f0a6fd2587495b204a",
      "sha256:cc644054967e516db4689b5282ee98e4bc4b11ea2255c9630309f559ab96562e",
      "sha256:e84fb818852626e89a09f5143dbc31fe7f0e0a6a24cd8d2eb68062b904337af4"
    ]
    

    请注意,前两个图层在两个图像中都是相同的。第二个图像添加了两个额外的图层。共享的图像图层仅在 /var/lib/docker/ 中存储一次,并且在将图像推送到或从图像注册表拉取时也会共享。因此,共享的图像图层可以减少网络带宽和存储。

    提示

    使用 --format 选项格式化 Docker 命令的输出。

    上面的示例使用 docker image inspect 命令以及 --format 选项来查看图层 ID,这些 ID 以 JSON 数组的形式格式化。Docker 命令上的 --format 选项是一个强大的功能,它允许您从输出中提取和格式化特定信息,而无需使用 awksed 等其他工具。要了解有关使用 --format 标志格式化 docker 命令输出的更多信息,请参阅 格式化命令和日志输出部分。我们还使用 jq 实用程序 对其进行美化,以提高可读性。

复制使容器更高效

启动容器时,会在其他图层之上添加一个薄的、可写的容器图层。容器对文件系统所做的任何更改都将存储在此处。容器未更改的任何文件都不会复制到此可写图层。这意味着可写图层尽可能小。

当容器中修改了现有文件时,存储驱动程序会执行写时复制操作。涉及的具体步骤取决于特定的存储驱动程序。对于 overlay2 驱动程序,写时复制操作遵循以下粗略顺序

  • 在图像图层中搜索要更新的文件。该过程从最新的图层开始,然后逐层向下工作到基本图层。找到结果后,将它们添加到缓存中以加快以后的操作。
  • 对找到的第一个文件副本执行 copy_up 操作,将文件复制到容器的可写图层。
  • 对该文件副本进行任何修改,并且容器无法看到存在于较低图层中的只读文件副本。

Btrfs、ZFS 和其他驱动程序以不同的方式处理写时复制。您可以在后面详细描述这些驱动程序的方法时阅读更多相关信息。

写入大量数据的容器比不写入数据的容器占用更多的空间。这是因为大多数写入操作都会在容器的薄可写顶层中消耗新的空间。请注意,更改文件的元数据(例如,更改文件的权限或所有权)也会导致 copy_up 操作,因此会将文件复制到可写图层。

提示

对写入量大的应用程序使用卷。

不要将写入量大的应用程序中的数据存储在容器中。例如,写入密集型数据库等此类应用程序在只读图层中存在预先存在的数据时会出现问题。

相反,请使用 Docker 卷,它们独立于正在运行的容器,并且设计为对 I/O 高效。此外,卷可以在容器之间共享,并且不会增加容器可写图层的大小。请参阅 使用卷 部分以了解有关卷的更多信息。

copy_up 操作可能会产生明显的性能开销。此开销因使用的存储驱动程序而异。大型文件、大量图层和深层目录树会使影响更加明显。这通过以下事实得到缓解:每个 copy_up 操作仅在修改给定文件时首次发生。

为了验证写时复制的工作方式,以下过程启动了 5 个基于我们之前构建的 acme/my-final-image:1.0 镜像的容器,并检查它们占用了多少空间。

  1. 在 Docker 主机上的终端中,运行以下 docker run 命令。末尾的字符串是每个容器的 ID。

    $ 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
    
    40ebdd7634162eb42bdb1ba76a395095527e9c0aa40348e6c325bd0aa289423c
    a5ff32e2b551168b9498870faf16c9cd0af820edf8a5c157f7b80da59d01a107
    3ed3c1a10430e09f253704116965b01ca920202d52f3bf381fbb833b8ae356bc
    939b3bf9e7ece24bcffec57d974c939da2bdcc6a5077b5459c897c1e2fa37a39
    cddae31c314fbab3f7eabeb9b26733838187abc9a2ed53f97bd5b04cd7984a5a
    
  2. 使用 --size 选项运行 docker ps 命令以验证 5 个容器是否正在运行,并查看每个容器的大小。

    $ docker ps --size --format "table {{.ID}}\t{{.Image}}\t{{.Names}}\t{{.Size}}"
    
    CONTAINER ID   IMAGE                     NAMES            SIZE
    cddae31c314f   acme/my-final-image:1.0   my_container_5   0B (virtual 7.75MB)
    939b3bf9e7ec   acme/my-final-image:1.0   my_container_4   0B (virtual 7.75MB)
    3ed3c1a10430   acme/my-final-image:1.0   my_container_3   0B (virtual 7.75MB)
    a5ff32e2b551   acme/my-final-image:1.0   my_container_2   0B (virtual 7.75MB)
    40ebdd763416   acme/my-final-image:1.0   my_container_1   0B (virtual 7.75MB)
    

    上面的输出显示所有容器共享镜像的只读图层 (7.75 MB),但没有数据写入容器的文件系统,因此容器没有使用额外的存储空间。

    注意

    此步骤需要 Linux 机器,并且在 Docker Desktop 上不起作用,因为它需要访问 Docker Daemon 的文件存储。

    虽然 docker ps 的输出提供了有关容器可写图层所消耗的磁盘空间的信息,但它不包括有关为每个容器存储的元数据和日志文件的信息。

    可以通过探索 Docker Daemon 的存储位置 (默认情况下为 /var/lib/docker) 来获得更多详细信息。

    $ sudo du -sh /var/lib/docker/containers/*
    
    36K  /var/lib/docker/containers/3ed3c1a10430e09f253704116965b01ca920202d52f3bf381fbb833b8ae356bc
    36K  /var/lib/docker/containers/40ebdd7634162eb42bdb1ba76a395095527e9c0aa40348e6c325bd0aa289423c
    36K  /var/lib/docker/containers/939b3bf9e7ece24bcffec57d974c939da2bdcc6a5077b5459c897c1e2fa37a39
    36K  /var/lib/docker/containers/a5ff32e2b551168b9498870faf16c9cd0af820edf8a5c157f7b80da59d01a107
    36K  /var/lib/docker/containers/cddae31c314fbab3f7eabeb9b26733838187abc9a2ed53f97bd5b04cd7984a5a
    

    这些容器中的每一个在文件系统上只占用了 36k 的空间。

  3. 每个容器的存储

    为了演示这一点,请运行以下命令,将单词 "hello" 写入容器 my_container_1my_container_2my_container_3 中的可写图层上的文件

    $ for i in {1..3}; do docker exec my_container_$i sh -c 'printf hello > /out.txt'; done
    

    之后再次运行 docker ps 命令,显示这些容器现在每个消耗了 5 个字节。这些数据对每个容器都是唯一的,并且不共享。容器的只读图层不受影响,并且仍然由所有容器共享。

    $ docker ps --size --format "table {{.ID}}\t{{.Image}}\t{{.Names}}\t{{.Size}}"
    
    CONTAINER ID   IMAGE                     NAMES            SIZE
    cddae31c314f   acme/my-final-image:1.0   my_container_5   0B (virtual 7.75MB)
    939b3bf9e7ec   acme/my-final-image:1.0   my_container_4   0B (virtual 7.75MB)
    3ed3c1a10430   acme/my-final-image:1.0   my_container_3   5B (virtual 7.75MB)
    a5ff32e2b551   acme/my-final-image:1.0   my_container_2   5B (virtual 7.75MB)
    40ebdd763416   acme/my-final-image:1.0   my_container_1   5B (virtual 7.75MB)
    

前面的示例说明了写时复制文件系统如何帮助提高容器的效率。写时复制不仅节省空间,而且还缩短了容器启动时间。当您创建容器(或从同一镜像创建多个容器)时,Docker 只需创建薄的可写容器图层。

如果 Docker 每次创建新容器时都必须完全复制底层镜像堆栈,容器创建时间和使用的磁盘空间将大大增加。这将类似于虚拟机的工作方式,每个虚拟机有一个或多个虚拟磁盘。 vfs 存储 不提供 CoW 文件系统或其他优化。使用此存储驱动程序时,会为每个容器创建图像数据的完整副本。