而正如我前面所說的,Namespace 的做用是「隔離」,它讓應用進程只能看到該 Namespace 內的「世界」;而 Cgroups 的做用是「限制」,它給這個「世界」圍上了一圈看不見的牆。這麼一折騰,進程就真的被「裝」在了一個與世隔絕的房間裏,而這些房間就是 PaaS 項目賴以生存的應用「沙盒」。docker
但是,還有一個問題不知道你有沒有仔細思考過:這個房間四周雖然有了牆,可是若是容器進程低頭一看地面,又是怎樣一副景象呢?shell
換句話說,容器裏的進程看到的文件系統又是什麼樣子的呢?編程
可能你馬上就能想到,這必定是一個關於 Mount Namespace 的問題:容器裏的應用進程,理應看到一份徹底獨立的文件系統。這樣,它就能夠在本身的容器目錄(好比 /tmp)下進行操做,而徹底不會受宿主機以及其餘容器的影響。json
那麼,真實狀況是這樣嗎?ubuntu
「左耳朵耗子」叔在多年前寫的一篇關於 Docker 基礎知識的博客裏,曾經介紹過一段小程序。這段小程序的做用是,在建立子進程時開啓指定的 Namespace。小程序
下面,咱們不妨使用它來驗證一下剛剛提到的問題。bash
#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 的隔離環境中。ide
咱們來一塊兒編譯一下這個程序:函數
$ 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)才能生效。
但是,做爲一個普通用戶,咱們但願的是一個更友好的狀況:每當建立一個新容器時,我但願容器進程看到的文件系統就是一個獨立的隔離環境,而不是繼承自宿主機的文件系統。怎麼才能作到這一點呢?
不難想到,咱們能夠在容器進程啓動以前從新掛載它的整個根目錄「/」。而因爲 Mount Namespace 的存在,這個掛載對宿主機不可見,因此容器進程就能夠在裏面隨便折騰了。
在 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/Ftest 了。
這種視圖被修改的原理,是否是跟我以前介紹的 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+的存在,容器纔有了一個被反覆宣傳至今的重要特性:一致性。 但有了容器以後,更準確地說,有了容器鏡像(即 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 的呢?
個人環境是 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/Fdocker 路徑下的 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 會把這些增量聯合掛載在一個統一的掛載點上(等價於前面例子裏的「/」目錄)。 這個掛載點就是/var/lib/docker/aufs/Fmnt/,好比:
/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/Faufs/diff 目錄下,而後被聯合掛載在 /Fvar/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 時,把這些信息連同可讀寫層一塊兒提交掉。
因此,Docke 作法是,在修改了這些文件以後,以一個單獨的層掛載了出來。而用戶執行 docker commit 只會提交可讀寫層,因此是不包含這些內容的。
最終,這 7 個層都被聯合掛載到/var/lib/docker/aufs/mnt 目錄下,表現爲一個完整的 Ubuntu 操做系統供容器使用。