1. 程式人生 > >Docker原理 ---- 深入瞭解容器映象

Docker原理 ---- 深入瞭解容器映象

我講解了 Linux 容器的 最基礎的兩種技術:Namespace 和 Cgroups。希望此時,你已經徹底理解了“容器的本質是一種特殊的程序”這個最重要的概念。

而正如我前面所說的,Namespace 的作用是“隔離”,它讓應用程序只能看到該 Namespace 內的“世界”;而 Cgroups 的作用是“限制”,它給這個“世界”圍上了一圈看不見的牆。這麼一折騰,程序就真的被“裝”在了一個與世隔絕的房間裡,而這些房間就是 PaaS 專案賴以生存的應用“沙盒”。

可是,還有一個問題不知道你有沒有仔細思考過:這個房間四周雖然有了牆,但是如果容器程序低頭一看地面,又是怎樣一副景象呢?

換句話說,容器裡的程序看到的檔案系統又是什麼樣子的呢?

可能你立刻就能想到,這一定是一個關於 Mount Namespace 的問題:容器裡的應用程序,理應看到一份完全獨立的檔案系統。這樣,它就可以在自己的容器目錄(比如 /tmp)下進行操作,而完全不會受宿主機以及其他容器的影響。

那麼,真實情況是這樣嗎?

“左耳朵耗子”叔在多年前寫的一篇關於 Docker 基礎知識的部落格裡,曾經介紹過一段小程式。這段小程式的作用是,在建立子程序時開啟指定的 Namespace。

下面,我們不妨使用它來驗證一下剛剛提到的問題。

#define _GNU_SOURCE #include <sys/mount.h>  #include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = {   "/bin/bash",   NULL };

int container_main(void* arg) {     printf("Container - inside the container!\n");   execv(container_args[0], container_args);   printf("Something's wrong!\n");   return 1; }

int main() {   printf("Parent - start a container!\n");   int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);   waitpid(container_pid, NULL, 0);   printf("Parent - container stopped!\n");   return 0; } 這段程式碼的功能非常簡單:在 main 函式裡,我們通過 clone() 系統呼叫建立了一個新的子程序 container_main,並且宣告要為它啟用 Mount Namespace(即:CLONE_NEWNS 標誌)。

而這個子程序執行的,是一個“/bin/bash”程式,也就是一個 shell。所以這個 shell 就執行在了 Mount Namespace 的隔離環境中。

我們來一起編譯一下這個程式:

$ gcc -o ns ns.c $ ./ns Parent - start a container! Container - inside the container! 這樣,我們就進入了這個“容器”當中。可是,如果在“容器”裡執行一下 ls 指令的話,我們就會發現一個有趣的現象: /tmp 目錄下的內容跟宿主機的內容是一樣的。

$ ls /tmp # 你會看到好多宿主機的檔案 也就是說:

即使開啟了 Mount Namespace,容器程序看到的檔案系統也跟宿主機完全一樣。

這是怎麼回事呢?

仔細思考一下,你會發現這其實並不難理解:Mount Namespace 修改的,是容器程序對檔案系統“掛載點”的認知。但是,這也就意味著,只有在“掛載”這個操作發生之後,程序的檢視才會被改變。而在此之前,新建立的容器會直接繼承宿主機的各個掛載點。

這時,你可能已經想到了一個解決辦法:建立新程序時,除了宣告要啟用 Mount Namespace 之外,我們還可以告訴容器程序,有哪些目錄需要重新掛載,就比如這個 /tmp 目錄。於是,我們在容器程序執行前可以新增一步重新掛載 /tmp 目錄的操作:

int container_main(void* arg) {   printf("Container - inside the container!\n");   // 如果你的機器的根目錄的掛載型別是 shared,那必須先重新掛載根目錄   // mount("", "/", NULL, MS_PRIVATE, "");   mount("none", "/tmp", "tmpfs", 0, "");   execv(container_args[0], container_args);   printf("Something's wrong!\n");   return 1; } 可以看到,在修改後的程式碼裡,我在容器程序啟動之前,加上了一句 mount(“none”, “/tmp”, “tmpfs”, 0, “”) 語句。就這樣,我告訴了容器以 tmpfs(記憶體盤)格式,重新掛載了 /tmp 目錄。

這段修改後的程式碼,編譯執行後的結果又如何呢?我們可以試驗一下:

$ gcc -o ns ns.c $ ./ns Parent - start a container! Container - inside the container! $ ls /tmp 可以看到,這次 /tmp 變成了一個空目錄,這意味著重新掛載生效了。我們可以用 mount -l 檢查一下:

$ mount -l | grep tmpfs none on /tmp type tmpfs (rw,relatime) 可以看到,容器裡的 /tmp 目錄是以 tmpfs 方式單獨掛載的。

更重要的是,因為我們建立的新程序啟用了 Mount Namespace,所以這次重新掛載的操作,只在容器程序的 Mount Namespace 中有效。如果在宿主機上用 mount -l 來檢查一下這個掛載,你會發現它是不存在的:

# 在宿主機上 $ mount -l | grep tmpfs 這就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它對容器程序檢視的改變,一定是伴隨著掛載操作(mount)才能生效。

可是,作為一個普通使用者,我們希望的是一個更友好的情況:每當建立一個新容器時,我希望容器程序看到的檔案系統就是一個獨立的隔離環境,而不是繼承自宿主機的檔案系統。怎麼才能做到這一點呢? 我: 在 Linux 作業系統裡,有一個名為 chroot 的命令可以幫助你在 shell 中方便地完成這個工作。顧名思義,它的作用就是幫你“change root file system”,即改變程序的根目錄到你指定的位置。它的用法也非常簡單。

假設,我們現在有一個 $HOME/test 目錄,想要把它作為一個 /bin/bash 程序的根目錄。

首先,建立一個 test 目錄和幾個 lib 資料夾:

$ mkdir -p $HOME/test $ mkdir -p $HOME/test/{bin,lib64,lib} $ cd $T 然後,把 bash 命令拷貝到 test 目錄對應的 bin 路徑下:

$ cp -v /bin/{bash,ls} $HOME/test/bin 接下來,把 bash 命令需要的所有 so 檔案,也拷貝到 test 目錄對應的 lib 路徑下。找到 so 檔案可以用 ldd 命令:

$ T=$HOME/test $ list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')" $ for i in $list; do cp -v "$i" "${T}${i}"; done 最後,執行 chroot 命令,告訴作業系統,我們將使用 $HOME/test 目錄作為 /bin/bash 程序的根目錄:

$ chroot $HOME/test /bin/bash 這時,你如果執行 "ls /",就會看到,它返回的都是 $HOME/test 目錄下面的內容,而不是宿主機的內容。

更重要的是,對於被 chroot 的程序來說,它並不會感受到自己的根目錄已經被“修改”成 $HOME/test 了。

這種檢視被修改的原理,是不是跟我之前介紹的 Linux Namespace 很類似呢?

沒錯!

實際上,Mount Namespace 正是基於對 chroot 的不斷改良才被髮明出來的,它也是 Linux 作業系統裡的第一個 Namespace。

當然,為了能夠讓容器的這個根目錄看起來更“真實”,我們一般會在這個容器的根目錄下掛載一個完整作業系統的檔案系統,比如 Ubuntu16.04 的 ISO。這樣,在容器啟動之後,我們在容器裡通過執行 "ls /" 檢視根目錄下的內容,就是 Ubuntu 16.04 的所有目錄和檔案。

而這個掛載在容器根目錄上、用來為容器程序提供隔離後執行環境的檔案系統,就是所謂的“容器映象”。它還有一個更為專業的名字,叫作:rootfs(根檔案系統)。

所以,一個最常見的 rootfs,或者說容器映象,會包括如下所示的一些目錄和檔案,比如 /bin,/etc,/proc 等等:

$ ls / bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var 而你進入容器之後執行的 /bin/bash,就是 /bin 目錄下的可執行檔案,與宿主機的 /bin/bash 完全不同。

現在,你應該可以理解,對 Docker 專案來說,它最核心的原理實際上就是為待建立的使用者程序:

啟用 Linux Namespace 配置;

設定指定的 Cgroups 引數;

切換程序的根目錄(Change Root)。

這樣,一個完整的容器就誕生了。不過,Docker 專案在最後一步的切換上會優先使用 pivot_root 系統呼叫,如果系統不支援,才會使用 chroot。這兩個系統呼叫雖然功能類似,但是也有細微的區別,這一部分小知識就交給你課後去探索了。

另外,需要明確的是,rootfs 只是一個作業系統所包含的檔案、配置和目錄,並不包括作業系統核心。在 Linux 作業系統中,這兩部分是分開存放的,作業系統只有在開機啟動時才會載入指定版本的核心映象。

所以說,rootfs 只包括了作業系統的“軀殼”,並沒有包括作業系統的“靈魂”。

那麼,對於容器來說,這個作業系統的“靈魂”又在哪裡呢?

實際上,同一臺機器上的所有容器,都共享宿主機作業系統的核心。

這就意味著,如果你的應用程式需要配置核心引數、載入額外的核心模組,以及跟核心進行直接的互動,你就需要注意了:這些操作和依賴的物件,都是宿主機作業系統的核心,它對於該機器上的所有容器來說是一個“全域性變數”,牽一髮而動全身。

這也是容器相比於虛擬機器的主要缺陷之一:畢竟後者不僅有模擬出來的硬體機器充當沙盒,而且每個沙盒裡還執行著一個完整的 Guest OS 給應用隨便折騰。

不過,正是由於 rootfs 的存在,容器才有了一個被反覆宣傳至今的重要特性:一致性。

什麼是容器的“一致性”呢?

我在專欄的第一篇文章《小鯨魚大事記(一):初出茅廬》中曾經提到過:由於雲端與本地伺服器環境不同,應用的打包過程,一直是使用 PaaS 時最“痛苦”的一個步驟。

但有了容器之後,更準確地說,有了容器映象(即 rootfs)之後,這個問題被非常優雅地解決了。

由於 rootfs 裡打包的不只是應用,而是整個作業系統的檔案和目錄,也就意味著,應用以及它執行所需要的所有依賴,都被封裝在了一起。

事實上,對於大多數開發者而言,他們對應用依賴的理解,一直侷限在程式語言層面。比如 Golang 的 Godeps.json。但實際上,一個一直以來很容易被忽視的事實是,對一個應用來說,作業系統本身才是它執行所需要的最完整的“依賴庫”。

有了容器映象“打包作業系統”的能力,這個最基礎的依賴環境也終於變成了應用沙盒的一部分。這就賦予了容器所謂的一致性:無論在本地、雲端,還是在一臺任何地方的機器上,使用者只需要解壓打包好的容器映象,那麼這個應用執行所需要的完整的執行環境就被重現出來了。

這種深入到作業系統級別的執行環境一致性,打通了應用在本地開發和遠端執行環境之間難以逾越的鴻溝。

不過,這時你可能已經發現了另一個非常棘手的問題:難道我每開發一個應用,或者升級一下現有的應用,都要重複製作一次 rootfs 嗎?

比如,我現在用 Ubuntu 作業系統的 ISO 做了一個 rootfs,然後又在裡面安裝了 Java 環境,用來部署我的 Java 應用。那麼,我的另一個同事在釋出他的 Java 應用時,顯然希望能夠直接使用我安裝過 Java 環境的 rootfs,而不是重複這個流程。

一種比較直觀的解決辦法是,我在製作 rootfs 的時候,每做一步“有意義”的操作,就儲存一個 rootfs 出來,這樣其他同事就可以按需求去用他需要的 rootfs 了。

但是,這個解決辦法並不具備推廣性。原因在於,一旦你的同事們修改了這個 rootfs,新舊兩個 rootfs 之間就沒有任何關係了。這樣做的結果就是極度的碎片化。

那麼,既然這些修改都基於一箇舊的 rootfs,我們能不能以增量的方式去做這些修改呢?這樣做的好處是,所有人都只需要維護相對於 base rootfs 修改的增量內容,而不是每次修改都製造一個“fork”。

答案當然是肯定的。

這也正是為何,Docker 公司在實現 Docker 映象時並沒有沿用以前製作 rootfs 的標準流程,而是做了一個小小的創新:

Docker 在映象的設計中,引入了層(layer)的概念。也就是說,使用者製作映象的每一步操作,都會生成一個層,也就是一個增量 rootfs。

當然,這個想法不是憑空臆造出來的,而是用到了一種叫作聯合檔案系統(Union File System)的能力。

Union File System 也叫 UnionFS,最主要的功能是將多個不同位置的目錄聯合掛載(union mount)到同一個目錄下。比如,我現在有兩個目錄 A 和 B,它們分別有兩個檔案:

$ tree . ├── A │  ├── a │  └── x └── B   ├── b   └── x 然後,我使用聯合掛載的方式,將這兩個目錄掛載到一個公共的目錄 C 上:

$ mkdir C $ mount -t aufs -o dirs=./A:./B none ./C 這時,我再檢視目錄 C 的內容,就能看到目錄 A 和 B 下的檔案被合併到了一起:

$ tree ./C ./C ├── a ├── b └── x 可以看到,在這個合併後的目錄 C 裡,有 a、b、x 三個檔案,並且 x 檔案只有一份。這,就是“合併”的含義。此外,如果你在目錄 C 裡對 a、b、x 檔案做修改,這些修改也會在對應的目錄 A、B 中生效。

那麼,在 Docker 專案中,又是如何使用這種 Union File System 的呢?

我: 可以看到,在這個合併後的目錄 C 裡,有 a、b、x 三個檔案,並且 x 檔案只有一份。這,就是“合併”的含義。此外,如果你在目錄 C 裡對 a、b、x 檔案做修改,這些修改也會在對應的目錄 A、B 中生效。

那麼,在 Docker 專案中,又是如何使用這種 Union File System 的呢?

我的環境是 Ubuntu 16.04 和 Docker CE 18.05,這對組合預設使用的是 AuFS 這個聯合檔案系統的實現。你可以通過 docker info 命令,檢視到這個資訊。

AuFS 的全稱是 Another UnionFS,後改名為 Alternative UnionFS,再後來乾脆改名叫作 Advance UnionFS,從這些名字中你應該能看出這樣兩個事實:

它是對 Linux 原生 UnionFS 的重寫和改進;

它的作者怨氣好像很大。我猜是 Linus Torvalds(Linux 之父)一直不讓 AuFS 進入 Linux 核心主幹的緣故,所以我們只能在 Ubuntu 和 Debian 這些發行版上使用它。

對於 AuFS 來說,它最關鍵的目錄結構在 /var/lib/docker 路徑下的 diff 目錄:

/var/lib/docker/aufs/diff/<layer_id> 而這個目錄的作用,我們不妨通過一個具體例子來看一下。

現在,我們啟動一個容器,比如:

$ docker run -d ubuntu:latest sleep 3600 這時候,Docker 就會從 Docker Hub 上拉取一個 Ubuntu 映象到本地。

這個所謂的“映象”,實際上就是一個 Ubuntu 作業系統的 rootfs,它的內容是 Ubuntu 作業系統的所有檔案和目錄。不過,與之前我們講述的 rootfs 稍微不同的是,Docker 映象使用的 rootfs,往往由多個“層”組成:

$ docker image inspect ubuntu:latest ...      "RootFS": {       "Type": "layers",       "Layers": [         "sha256:f49017d4d5ce9c0f544c...",         "sha256:8f2b771487e9d6354080...",         "sha256:ccd4d61916aaa2159429...",         "sha256:c01d74f99de40e097c73...",         "sha256:268a067217b5fe78e000..."       ]     } 可以看到,這個 Ubuntu 映象,實際上由五個層組成。這五個層就是五個增量 rootfs,每一層都是 Ubuntu 作業系統檔案與目錄的一部分;而在使用映象時,Docker 會把這些增量聯合掛載在一個統一的掛載點上(等價於前面例子裡的“/C”目錄)。

這個掛載點就是 /var/lib/docker/aufs/mnt/,比如:

/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e 不出意外的,這個目錄裡面正是一個完整的 Ubuntu 作業系統:

$ ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var 那麼,前面提到的五個映象層,又是如何被聯合掛載成這樣一個完整的 Ubuntu 檔案系統的呢?

這個資訊記錄在 AuFS 的系統目錄 /sys/fs/aufs 下面。

首先,通過檢視 AuFS 的掛載資訊,我們可以找到這個目錄對應的 AuFS 的內部 ID(也叫:si):

$ cat /proc/mounts| grep aufs none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc... aufs rw,relatime,si=972c6d361e6b32ba,dio,dirperm1 0 0 即,si=972c6d361e6b32ba。

然後使用這個 ID,你就可以在 /sys/fs/aufs 下檢視被聯合掛載在一起的各個層的資訊:

$ cat /sys/fs/aufs/si_972c6d361e6b32ba/br[0-9]* /var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...=rw /var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...-init=ro+wh /var/lib/docker/aufs/diff/32e8e20064858c0f2...=ro+wh /var/lib/docker/aufs/diff/2b8858809bce62e62...=ro+wh /var/lib/docker/aufs/diff/20707dce8efc0d267...=ro+wh /var/lib/docker/aufs/diff/72b0744e06247c7d0...=ro+wh /var/lib/docker/aufs/diff/a524a729adadedb90...=ro+wh 從這些資訊裡,我們可以看到,映象的層都放置在 /var/lib/docker/aufs/diff 目錄下,然後被聯合掛載在 /var/lib/docker/aufs/mnt 裡面。

而且,從這個結構可以看出來,這個容器的 rootfs 由如下圖所示的三部分組成:

第一部分,只讀層。

它是這個容器的 rootfs 最下面的五層,對應的正是 ubuntu:latest 映象的五層。可以看到,它們的掛載方式都是隻讀的(ro+wh,即 readonly+whiteout,至於什麼是 whiteout,我下面馬上會講到)。

這時,我們可以分別檢視一下這些層的內容:

$ ls /var/lib/docker/aufs/diff/72b0744e06247c7d0... etc sbin usr var $ ls /var/lib/docker/aufs/diff/32e8e20064858c0f2... run $ ls /var/lib/docker/aufs/diff/a524a729adadedb900... bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var 可以看到,這些層,都以增量的方式分別包含了 Ubuntu 作業系統的一部分。

第二部分,可讀寫層。

它是這個容器的 rootfs 最上面的一層(6e3be5d2ecccae7cc),它的掛載方式為:rw,即 read write。在沒有寫入檔案之前,這個目錄是空的。而一旦在容器裡做了寫操作,你修改產生的內容就會以增量的方式出現在這個層中。

可是,你有沒有想到這樣一個問題:如果我現在要做的,是刪除只讀層裡的一個檔案呢?

為了實現這樣的刪除操作,AuFS 會在可讀寫層建立一個 whiteout 檔案,把只讀層裡的檔案“遮擋”起來。

比如,你要刪除只讀層裡一個名叫 foo 的檔案,那麼這個刪除操作實際上是在可讀寫層建立了一個名叫.wh.foo 的檔案。這樣,當這兩個層被聯合掛載之後,foo 檔案就會被.wh.foo 檔案“遮擋”起來,“消失”了。這個功能,就是“ro+wh”的掛載方式,即只讀 +whiteout 的含義。我喜歡把 whiteout 形象地翻譯為:“白障”。

所以,最上面這個可讀寫層的作用,就是專門用來存放你修改 rootfs 後產生的增量,無論是增、刪、改,都發生在這裡。而當我們使用完了這個被修改過的容器之後,還可以使用 docker commit 和 push 指令,儲存這個被修改過的可讀寫層,並上傳到 Docker Hub 上,供其他人使用;而與此同時,原先的只讀層裡的內容則不會有任何變化。這,就是增量 rootfs 的好處。

第三部分,Init 層。

它是一個以“-init”結尾的層,夾在只讀層和讀寫層之間。Init 層是 Docker 專案單獨生成的一個內部層,專門用來存放 /etc/hosts、/etc/resolv.conf 等資訊。

需要這樣一層的原因是,這些檔案本來屬於只讀的 Ubuntu 映象的一部分,但是使用者往往需要在啟動容器時寫入一些指定的值比如 hostname,所以就需要在可讀寫層對它們進行修改。

可是,這些修改往往只對當前的容器有效,我們並不希望執行 docker commit 時,把這些資訊連同可讀寫層一起提交掉。

所以,Docker 做法是,在修改了這些檔案之後,以一個單獨的層掛載了出來。而使用者執行 docker commit 只會提交可讀寫層,所以是不包含這些內容的。

最終,這 7 個層都被聯合掛載到 /var/lib/docker/aufs/mnt 目錄下,表現為一個完整的 Ubuntu 作業系統供容器使用。

總結 在今天的分享中,我著重介紹了 Linux 容器檔案系統的實現方式。而這種機制,正是我們經常提到的容器映象,也叫作:rootfs。它只是一個作業系統的所有檔案和目錄,並不包含核心,最多也就幾百兆。而相比之下,傳統虛擬機器的映象大多是一個磁碟的“快照”,磁碟有多大,映象就至少有多大。

通過結合使用 Mount Namespace 和 rootfs,容器就能夠為程序構建出一個完善的檔案系統隔離環境。當然,這個功能的實現還必須感謝 chroot 和 pivot_root 這兩個系統呼叫切換程序根目錄的能力。

而在 rootfs 的基礎上,Docker 公司創新性地提出了使用多個增量 rootfs 聯合掛載一個完整 rootfs 的方案,這就是容器映象中“層”的概念。

通過“分層映象”的設計,以 Docker 映象為核心,來自不同公司、不同團隊的技術人員被緊密地聯絡在了一起。而且,由於容器映象的操作是增量式的,這樣每次映象拉取、推送的內容,比原本多個完整的作業系統的大小要小得多;而共享層的存在,可以使得所有這些容器映象需要的總空間,也比每個映象的總和要小。這樣就使得基於容器映象的團隊協作,要比基於動則幾個 GB 的虛擬機器磁碟映象的協作要敏捷得多。

更重要的是,一旦這個映象被髮布,那麼你在全世界的任何一個地方下載這個映象,得到的內容都完全一致,可以完全復現這個映象製作者當初的完整環境。這,就是容器技術“強一致性”的重要體現。

而這種價值正是支撐 Docker 公司在 2014~2016 年間迅猛發展的核心動力。容器映象的發明,不僅打通了“開發 - 測試 - 部署”流程的每一個環節,更重要的是:

容器映象將會成為未來軟體的主流釋出流程