儲存驅動程式

為了有效地使用儲存驅動,瞭解 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)策略

寫時複製(Copy-on-write)是一種共享和複製檔案以實現最高效率的策略。如果一個檔案或目錄存在於映象的下層,而另一層(包括可寫層)需要對其進行讀訪問,它就直接使用現有的檔案。當另一層第一次需要修改該檔案時(在構建映象或執行容器時),該檔案會被複制到那一層並進行修改。這最大限度地減少了 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。您使用第一個來建立一個名為 acme/my-base-image:1.0 的映象。

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

第二個是基於 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

第二個映象包含了第一個映象的所有層,加上由 `COPY` 和 `RUN` 指令建立的新層,以及一個讀寫容器層。Docker 已經擁有第一個映象的所有層,所以它不需要再次拉取它們。這兩個映象共享它們共有的任何層。

如果您從這兩個 Dockerfile 構建映象,您可以使用 `docker image ls` 和 `docker 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` 輸出中的 `` 行表示這些步驟要麼是在另一個系統上構建的,並且是作為從 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,並格式化為 JSON 陣列。Docker 命令的 `--format` 選項是一個強大的功能,它允許您從輸出中提取和格式化特定資訊,而不需要像 `awk` 或 `sed` 這樣的額外工具。要了解更多關於使用 `--format` 標誌格式化 docker 命令輸出的資訊,請參閱格式化命令和日誌輸出部分。我們還使用 `jq` 工具來美化 JSON 輸出以便於閱讀。

複製使容器高效

當您啟動一個容器時,一個薄薄的可寫容器層被新增到其他層的頂部。容器對檔案系統所做的任何更改都儲存在這裡。容器未更改的任何檔案都不會被複制到這個可寫層。這意味著可寫層儘可能小。

當容器中一個現有檔案被修改時,儲存驅動會執行一次寫時複製操作。具體步驟取決於所使用的儲存驅動。對於 `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. 使用 `docker ps` 命令並帶上 `--size` 選項,以驗證 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.75MB),但沒有資料寫入容器的檔案系統,因此沒有為容器使用額外的儲存空間。

    注意

    此步驟需要一臺 Linux 機器,並且在 Docker Desktop 上無法工作,因為它需要訪問 Docker 守護程序的檔案儲存。

    雖然 `docker ps` 的輸出為您提供了有關容器可寫層消耗的磁碟空間資訊,但它不包括為每個容器儲存的元資料和日誌檔案的資訊。

    透過探索 Docker 守護程序的儲存位置(預設為 `/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_1`、`my_container_2` 和 `my_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)檔案系統或其他最佳化。使用此儲存驅動時,會為每個容器建立映象資料的完整副本。