筆者在《Docker 鏡像之進階篇》中介紹了鏡像分層、寫時複製以及內容尋址存儲(content-addressable storage)等技術特性,爲了支持這些特性,docker 設計了一套鏡像元數據管理機制來管理鏡像元數據。另外,爲了可以讓 docker 容器適應不一樣平臺不一樣應用場景對存儲的要求,docker 提供了各類基於不一樣文件系統實現的存儲驅動來管理實際鏡像文件。html
本文咱們就來介紹 docker 如何管理鏡像元數據,以及如何經過存儲驅動來管理實際的容器鏡像文件。python
Docker 鏡像在設計上將鏡像元數據和鏡像文件的存儲徹底隔離開了。Docker 在管理鏡像層元數據時採用的是從上至下 repository、image 和 layer 三個層次。因爲 docker 以分層的形式存儲鏡像,因此 repository 和 image 這兩類元數據並無物理上的鏡像文件與之對應,而 layer 這種元數據則存在物理上的鏡像層文件與之對應。接下來咱們就介紹這些元數據的管理與存儲。linux
repository 元數據
repository 是由具備某個功能的 docker 鏡像的全部迭代版本構成的鏡像庫。Repository 在本地的持久化文件存放於 /var/lib/docker/image/<graph_driver>/repositories.json 中,下圖顯示了 docker 使用 aufs 存儲驅動時 repositories.json 文件的路徑:算法
咱們能夠經過 vim 查看 repositories.json 的內容,並經過命令 :%!python -m json.tool 進行格式化:docker
文件中存儲了全部本地鏡像的 repository 的名字,好比 ubuntu ,還有每一個 repository 下的鏡像的名字、標籤及其對應的鏡像 ID。當前 docker 默認採用 SHA256 算法根據鏡像元數據配置文件計算出鏡像 ID。上圖中的兩條記錄本質上是同樣的,第二條記錄和第一條記錄指向同一個鏡像 ID。其中 sha256:c8c275751219dadad8fa56b3ac41ca6cb22219ff117ca98fe82b42f24e1ba64e 被稱爲鏡像的摘要,在拉取鏡像時能夠看到它:json
鏡像的摘要(Digest)是對鏡像的 manifest 內容計算 sha256sum 獲得的。咱們也能夠直接指定一個鏡像的摘要進行 pull 操做:
$ docker pull ubuntu@sha256:c8c275751219dadad8fa56b3ac41ca6cb22219ff117ca98fe82b42f24e1ba64e
這和 docker pull ubuntu:latest 是同樣的(固然,若是鏡像被更新了,就會有新的摘要來對應 ubuntu:latest)。ubuntu
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> 中。vim
452a96d81c30a1e426bc250428263ac9ca3f47c9bf086f876d11cb39cf57aeec 就是鏡像的ID。其內容以下(簡潔起見,省略中間大部分的內容):bash
它包含全部鏡像層信息的 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 計算出來的,具體算以下:
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>/ 目錄下的內容:
存儲驅動根據操做系統底層的支持提供了針對某種文件系統的初始化操做以及對鏡像層的增、刪、改、查和差別比較等操做。目前存儲系統的接口已經有 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 容器與容器雲》