Docker 鏡像之存儲管理


筆者在《Docker 鏡像之進階篇》中介紹了鏡像分層、寫時復制以及內容尋址存儲(content-addressable storage)等技術特性,為了支持這些特性,docker 設計了一套鏡像元數據管理機制來管理鏡像元數據。另外,為了能夠讓 docker 容器適應不同平台不同應用場景對存儲的要求,docker 提供了各種基於不同文件系統實現的存儲驅動來管理實際鏡像文件。

本文我們就來介紹 docker 如何管理鏡像元數據,以及如何通過存儲驅動來管理實際的容器鏡像文件。

Docker 鏡像元數據管理

Docker 鏡像在設計上將鏡像元數據和鏡像文件的存儲完全隔離開了。Docker 在管理鏡像層元數據時采用的是從上至下 repository、image 和 layer 三個層次。由於 docker 以分層的形式存儲鏡像,所以 repository 和 image 這兩類元數據並沒有物理上的鏡像文件與之對應,而 layer 這種元數據則存在物理上的鏡像層文件與之對應。接下來我們就介紹這些元數據的管理與存儲。

repository 元數據
repository 是由具有某個功能的 docker 鏡像的所有迭代版本構成的鏡像庫。Repository 在本地的持久化文件存放於 /var/lib/docker/image/<graph_driver>/repositories.json 中,下圖顯示了 docker 使用 aufs 存儲驅動時 repositories.json 文件的路徑:

我們可以通過 vim 查看 repositories.json 的內容,並通過命令 :%!python -m json.tool 進行格式化:

文件中存儲了所有本地鏡像的 repository 的名字,比如 ubuntu ,還有每個 repository 下的鏡像的名字、標簽及其對應的鏡像 ID。當前 docker 默認采用 SHA256 算法根據鏡像元數據配置文件計算出鏡像 ID。上圖中的兩條記錄本質上是一樣的,第二條記錄和第一條記錄指向同一個鏡像 ID。其中 sha256:c8c275751219dadad8fa56b3ac41ca6cb22219ff117ca98fe82b42f24e1ba64e 被稱為鏡像的摘要,在拉取鏡像時可以看到它:

鏡像的摘要(Digest)是對鏡像的 manifest 內容計算 sha256sum 得到的。我們也可以直接指定一個鏡像的摘要進行 pull 操作:
$ docker pull ubuntu@sha256:c8c275751219dadad8fa56b3ac41ca6cb22219ff117ca98fe82b42f24e1ba64e
這和 docker pull ubuntu:latest 是一樣的(當然,如果鏡像被更新了,就會有新的摘要來對應 ubuntu:latest)。

image 元數據
image 元數據包括了鏡像架構(如 amd64)、操作系統(如 linux)、鏡像默認配置、構建該鏡像的容器 ID 和配置、創建時間、創建該鏡像的 docker 版本、構建鏡像的歷史信息以及 rootfs 組成。其中構建鏡像的歷史信息和 rootfs 組成部分除了具有描述鏡像的作用外,還將鏡像和構成該鏡像的鏡像層關聯了起來。Docker 會根據歷史信息和 rootfs 中的 diff_ids 計算出構成該鏡像的鏡像層的存儲索引 chainID,這也是 docker 1.10 鏡像存儲中基於內容尋址的核心技術。
鏡像 ID 與鏡像元數據之間的映射關系以及元數據被保存在文件 /var/lib/docker/image/<graph_driver>/imagedb/content/sha256/<image_id> 中。

452a96d81c30a1e426bc250428263ac9ca3f47c9bf086f876d11cb39cf57aeec 就是鏡像的ID。其內容如下(簡潔起見,省略中間大部分的內容):

它包含所有鏡像層信息的 rootfs(見上圖的 rootfs 部分),docker 利用 rootfs 中的 diff_id 計算出內容尋址的索引(chainID) 來獲取 layer 相關信息,進而獲取每一個鏡像層的文件內容。注意,每個 diff_id 對應一個鏡像層。上面的 diff_id 的排列也是有順序的,從上到下依次表示鏡像層的最低層到最頂層:

layer 元數據
layer 對應鏡像層的概念,在 docker 1.10 版本以前,鏡像通過一個 graph 結構管理,每一個鏡像層都擁有元數據,記錄了該層的構建信息以及父鏡像層 ID,而最上面的鏡像層會多記錄一些信息作為整個鏡像的元數據。graph 則根據鏡像 ID(即最上層的鏡像層 ID) 和每個鏡像層記錄的父鏡像層 ID 維護了一個樹狀的鏡像層結構。

在 docker 1.10 版本后,鏡像元數據管理巨大的改變之一就是簡化了鏡像層的元數據,鏡像層只包含一個具體的鏡像層文件包。用戶在 docker 宿主機上下載了某個鏡像層之后,docker 會在宿主機上基於鏡像層文件包和 image 元數據構建本地的 layer 元數據,包括 diff、parent、size 等。而當 docker 將在宿主機上產生的新的鏡像層上傳到 registry 時,與新鏡像層相關的宿主機上的元數據也不會與鏡像層一塊打包上傳。
Docker 中定義了 Layer 和 RWLayer 兩種接口,分別用來定義只讀層和可讀寫層的一些操作,又定義了 roLayer 和 mountedLayer,分別實現了上述兩種接口。其中,roLayer 用於描述不可改變的鏡像層,mountedLayer 用於描述可讀寫的容器層。
具體來說,roLayer 存儲的內容主要有索引該鏡像層的 chainID、該鏡像層的校驗碼 diffID、父鏡像層 parent、graphdriver 存儲當前鏡像層文件的 cacheID、該鏡像層的 size 等內容。這些元數據被保存在 /var/lib/docker/image/<graph_driver>/layerdb/sha256/<chainID>/ 文件夾下。
/var/lib/docker/image/<graph_driver>/layerdb/sha256/ 目錄下的目錄名稱都是鏡像層的存儲索引 chainID:

鏡像層的存儲索引 chainID 目錄下的內容為:

其中 diffID 和 size 可以通過鏡像層包計算出來(diff 文件的內容即 diffID,其內容就是 image 元數據中對應層的 diff_id)。chainID 和父鏡像層 parent 需要從所屬 image 元數據中計算得到。而 cacheID 是在當前 docker 宿主機上隨機生成的一個 uuid,在當前的宿主機上,cacheID 與該鏡像層一一對應,用於標識並索引 graphdriver 中的鏡像層文件:

在 layer 的所有屬性中,diffID 采用 SHA256 算法,基於鏡像層文件包的內容計算得到。而 chainID 是基於內容存儲的索引,它是根據當前層與所有祖先鏡像層 diffID 計算出來的,具體算如下:

  • 如果該鏡像層是最底層(沒有父鏡像層),該層的 diffID 便是 chainID。
  • 該鏡像層的 chainID 計算公式為 chainID(n)=SHA256(chain(n-1) diffID(n)),也就是根據父鏡像層的 chainID 加上一個空格和當前層的 diffID,再計算 SHA256 校驗碼。

mountedLayer 存儲的內容主要為索引某個容器的可讀寫層(也叫容器層)的 ID(也對應容器層的 ID)、容器 init 層在 graphdriver 中的ID(initID)、讀寫層在 graphdriver 中的 ID(mountID) 以及容器層的父層鏡像的 chainID(parent)。相關文件位於 /var/lib/docker/image/<graph_driver>/layerdb/mounts/<container_id>/ 目錄下。
啟動一個容器,查看 /var/lib/docker/image/<graph_driver>/layerdb/mounts/<container_id>/ 目錄下的內容:

Docker aufs 存儲驅動

存儲驅動根據操作系統底層的支持提供了針對某種文件系統的初始化操作以及對鏡像層的增、刪、改、查和差異比較等操作。目前存儲系統的接口已經有 aufs、btrfs、devicemapper、voerlay2 等多種。在啟動 docker deamon 時可以指定使用的存儲驅動,當然指定的驅動必須被底層操作系統支持。下面我們以 aufs 存儲驅動為例介紹其工作方式。

先來簡單認識一下 aufs,aufs(advanced multi layered unification filesystem)是一種支持聯合掛載的文件系統。簡單來說就是支持將不同目錄掛載到同一個目錄下,這些掛載操作對用戶來說是透明的,用戶在操作該目錄時並不會覺得與其他目錄有什么不同。這些目錄的掛載是分層次的,通常來說最上層是可讀寫層,下面的層是只讀層。所以,aufs 的每一層都是一個普通的文件系統。
當需要讀取一個文件 A 時,會從最頂層的讀寫層開始向下尋找,本層沒有,則根據層之間的關系到下一層開始找,直到找到第一個文件 A 並打開它。
當需要寫入一個文件 A 時,如果這個文件不存在,則在讀寫層新建一個,否則像上面的過程一樣從頂層開始查找,直到找到最近的文件 A,aufs 會把這個文件復制到讀寫層進行修改。
由此可以看出,在第一次修改某個已有文件時,如果這個文件很大,即使只要修改幾個字節,也會產生巨大的磁盤開銷。
當需要刪除一個文件時,如果這個文件僅僅存在於讀寫層中,則可以直接刪除這個文件,否則就需要先刪除它在讀寫層中的備份,再在讀寫層中創建一個 whiteout 文件來標志這個文件不存在,而不是真正刪除底層的文件。
當新建一個文件時,如果這個文件在讀寫層存在對應的 whiteout 文件,則先將 whiteout 文件刪除再新建。否則直接在讀寫層新建即可。

那么鏡像文件在本地存放在哪里呢?
以 aufs 驅動為例,我們先查看 /var/lib/docker/aufs 目錄下的內容:

$ sudo su
$ cd /var/lib/docker/aufs
$ ls

其中 mnt 為 aufs 的掛載目錄,diff 為實際的數據來源,包括只讀層和可讀寫層,所有這些層最終一起被掛載在 mnt 下面的目錄上,layers 下為與每層依賴有關的層描述文件。

最初,mnt 和 layers 都是空目錄,文件數據都在 diff 目錄下。一個 docker 容器創建與啟動的過程中,會在 /var/lib/docker/aufs 下面新建出對應的文件和目錄。由於 docker 鏡像管理部分與存儲驅動在設計上完全分離了,鏡像層或者容器層在存儲驅動中擁有一個新的標識 ID,在鏡像層(roLayer)中稱為 cacheID,容器層(mountedLayer)中為 mountID。在 Linux 環境下,mountID 是隨機生成的並保存在 mountedLayer 的元數據 mountID 中,持久化在 image/aufs/layserdb/mounts/<container_id>/mount-id 中。下面以 mountID 為例,介紹創建一個新讀寫層的步驟:
第一步,分別在 mnt 和 diff 目錄下創建與該層的 mountID 同名的子文件夾。
第二步,在 layers 目錄下創建與該層的 mountID 同名的文件,用來記錄該層所依賴的所有的其它層。
第三步,如果參數中的 parent 項不為空(這里介紹的是創建容器的情景,parent 就是鏡像的最上層),說明該層依賴於其它的層。GraphDriver 就需要將 parent 的 mountID 寫入到該層在 layers 下對應 mountID 的文件里。然后 GraphDriver 還需要在 layers 目錄下讀取與上述 parent 同 mountID 的文件,將 parent 層的所有依賴層也復制到這個新創建層對應的層描述文件中,這樣這個文件才記錄了該層的所有依賴。創建成功后,這個新創建的層描述文件如下:

上圖中 6a2ef0693c2879347cc1a575c1db60765afb0cff47dcf3ab396f35d070fb240b 為 mountID。隨后 GraphDriver 會將 diff 中屬於容器鏡像的所有層目錄以只讀方式掛載到 mnt 下,然后在 diff 中生成一個以當前容器對應的 <mountID>-init 命名的文件夾作為最后一層只讀層,這個文件夾用於掛載並重新生成如下代碼段所列的文件:
    "/dev/pts":"dir",
    "/dev/shm":"dir",
    "/proc":"dir",
    "/sys":"dir",
    "/.dockerinit":"file",
    "/.dockerenv":"file",
    "/etc/resolv.conf":"file",
    "/etc/hosts":"file",
    "/etc/hastname":"file",
    "/dev/console":"file",
    "/etc/mtab":"/proc/mounts",
可以看到這些文件與這個容器內的環境息息相關,但並不適合被打包作為鏡像的文件內容(畢竟文件里的內容是屬於這個容器特有的),同時這些內容又不應該直接修改在宿主機文件上,所以 docker 容器文件存儲中設計了 mountID-init 這么一層單獨處理這些文件。這一層只在容器啟動時添加,並會根據系統環境和用戶配置自動生成具體的內容(如 DNS配置等),只有當這些文件在運行過程中被改動后並且 docker commit 了才會持久化這些變化,否則保存鏡像時不會包含這一層的內容。
所以嚴格地說,docker 容器的文件系統有 3 層:可讀寫層、init 層和只讀層。但是這並不影響我們傳統認識上可讀寫層 + 只讀層組成的容器文件系統:因為 init 層對於用戶來說是完全透明的。
接下來會在 diff 中生成一個以容器對應 mountID 為名的可讀寫目錄,也掛載到 mnt 目錄下。所以,將來用戶在容器中新建文件就會出現在 mnt 下一 mountID 為名的目錄下,而該層對應的實際內容則保存在 diff 目錄下。
至此我們需要明確,所有文件的實際內容均保存在 diff 目錄下,包括可讀寫層也會以 mountID 為名出現在 diff 目錄下,最終會整合到一起聯合掛載到 mnt 目錄下以 mountID 為名的文件夾下。接下來我們統一觀察 mnt 對應的 mountID 下的變化。

第一步,先創建一個容器

$ docker container create -it --name mycon ubuntu bash

比如我們得到的容器 ID 為:059a01071ab7f51abdfbe9f78b95be06ad631d0e0d4be3153e4a1bc32ffa453a,此時容器的狀態為 "Created"。
然后在 /var/lib/docker/image/aufs/layerdb/mounts 目錄中,查看 059a01071ab7f51abdfbe9f78b95be06ad631d0e0d4be3153e4a1bc32ffa453a 目錄下 mount-id 文件的內容如下:

819e3e9a67f4440cecf29086c559a57a1024a078eeee42f48d5d3472e59a6c94

這就是容器層對應的 mountID。接下來查看容器運行前對應的 mnt 目錄:

$ du -h . --max-depth=1 |grep 819e

此時 mountID 對應的文件夾下是空的。

第二步,啟動容器

$ docker container start -i mycon

現在再來查看 mnt 下對應目錄的大小:

容器層變大了,進入到文件夾中可以看到掛載好的文件系統:

第三步,在容器中創建文件
下面我們進入到容器中,創建一個 1G 大小的文件:

此時再來查看 mnt 下對應目錄的大小:

容器層目錄的大小反映了我們對文件執行的操作。

第四步,停止容器

$ docker container stop mycon

停止容器后,/var/lib/docker/aufs/mnt 目錄下對應的 mountID 目錄被卸載(umount),此時該目錄為空。但是 /var/lib/docker/aufs/diff 目錄下對應的目錄和文件都還存在。

綜上所述,我們可以通過下圖來理解 docker aufs 驅動的主要存儲目錄和作用:

最后,當我們用 docker container commit 命令把容器提交成鏡像后,就會在 diff 目錄下生成一個新的 cacheID 命名的文件夾,它存放了最新的差異變化文件,這時一個新的鏡像層就誕生了。而原來的以 mountID 為名的文件夾會繼續存在,直至對應容器被刪除。

總結

本文結合實例介紹了 docker 鏡像元數據的存儲和 aufs 存儲驅動下 docker 鏡像層的文件存儲。由於 docker 鏡像管理部分與存儲驅動在設計上的完全分離,使得這部分內容初看起來並不是那么直觀。希望本文能對大家理解 docker 鏡像及其存儲有所幫助。

參考:
《docker 容器與容器雲》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM