儲存驅動程式

要有效地使用儲存驅動程式,務必瞭解 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。您使用第一個建立一個名為 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

第二個映像檔包含第一個映像檔的所有層,加上由 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 指令的輸出。

    以上範例使用帶有 --format 選項的 docker image inspect 指令,以 JSON 陣列格式檢視層 ID。Docker 指令中的 --format 選項是一個強大的功能,允許您從輸出中提取和格式化特定資訊,而無需額外的工具,例如 awksed。要瞭解更多關於使用 --format 旗標格式化 docker 指令輸出的資訊,請參閱格式化指令和日誌輸出章節。我們還使用 jq 工具 來美化 JSON 輸出,以提高可讀性。

複製可提高容器效率

當您啟動一個容器時,會在其他層之上添加一個薄的可寫容器層。容器對檔案系統所做的任何更改都儲存在這裡。容器未更改的任何檔案都不會被複製到這個可寫層。這表示可寫層盡可能地小。

當修改容器中現有的檔案時,儲存驅動程式會執行寫入時複製操作。涉及的具體步驟取決於特定的儲存驅動程式。對於 overlay2 驅動程式,寫入時複製操作遵循以下大致順序

  • 搜尋映像層以尋找要更新的檔案。此過程從最新的層開始,一次向下處理到基礎層。找到結果後,會將它們添加到快取中以加快未來的操作。
  • 對找到的檔案的第一個副本執行 copy_up 操作,將檔案複製到容器的可寫層。
  • 對此檔案副本進行任何修改,並且容器無法看到下層中存在的檔案的唯讀副本。

Btrfs、ZFS 和其他驅動程式以不同的方式處理寫入時複製。您可以在稍後的詳細說明中閱讀更多關於這些驅動程式的方法。

寫入大量資料的容器比不寫入大量資料的容器消耗更多空間。這是因為大多數寫入操作會在容器的薄可寫頂層中消耗新的空間。請注意,更改檔案的元數據,例如,更改檔案的權限或所有權,也可能導致 copy_up 操作,因此會將檔案複製到可寫層。

小技巧

對於寫入密集型應用程式,請使用磁碟區。

不要將資料儲存在寫入密集型應用程式的容器中。此類應用程式,例如寫入密集型資料庫,已知會產生問題,尤其是在唯讀層中存在預先存在的資料時。

相反,請使用 Docker 磁碟區,它們獨立於正在執行的容器,並且設計為 I/O 高效。此外,磁碟區可以在容器之間共享,並且不會增加容器可寫層的大小。請參閱使用磁碟區章節以瞭解磁碟區。

copy_up 操作可能會導致明顯的效能開銷。此開銷因使用的儲存驅動程式而異。大型檔案、大量層和深目錄樹可能會使影響更加明顯。由於每個 copy_up 操作僅在第一次修改給定檔案時發生,因此可以減輕這種影響。

為了驗證寫入時複製的工作方式,以下程序將基於我們先前構建的 acme/my-final-image:1.0 映像啟動 5 個容器,並檢查它們佔用了多少空間。

  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.75MB),但沒有資料寫入到容器的檔案系統,因此容器沒有使用額外的儲存空間。

    注意事項

    此步驟需要 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 檔案系統或其他優化。使用此儲存驅動程式時,會為每個容器建立映像資料的完整副本。