原文作者:javaedge
原文連結:
https://developer.aliyun.com/article/720953?spm=a2c6h.13262185.0.0.5edf441ewzXTaI更多雲原生技術資訊可關注
阿裡巴巴雲原生技術圈前兩文中,講了Linux容器最基礎的兩種技術
-
Namespace
作用是“隔離”,它讓應用程序隻能看到該Namespace内的“世界”
-
Cgroups
作用是“限制”,它給這個“世界”圍上了一圈看不見的牆
這麼一搞,程序就真的被“裝”在了一個與世隔絕的房間裡,而這些房間就是PaaS項目賴以生存的應用“沙盒”。
還有一個問題是:牆外的我們知道他的處境了,牆内的他呢?
1 容器裡的程序眼中的檔案系統
也許你會認為這是一個關于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的隔離環境中。
我們來一起編譯一下這個程式:

這樣,我們就進入了這個“容器”當中。可是,如果在“容器”裡執行一下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目錄。
這段修改後的代碼,編譯執行後的結果又如何呢?我們可以試驗一下:
可以看到,這次/tmp變成了一個空目錄,這意味着重新挂載生效了。我們可以用mount -l檢查一下:
可以看到,容器裡的/tmp目錄是以tmpfs方式單獨挂載的。
更重要的是,因為我們建立的新程序啟用了Mount Namespace,是以這次重新挂載的操作,隻在容器程序的Mount Namespace中有效。如果在主控端上用mount -l來檢查一下這個挂載,你會發現它是不存在的:
這就是Mount Namespace跟其他Namespace的使用略有不同的地方:
它對容器程序視圖的改變,一定是伴随着挂載操作(mount)才能生效。
可作為使用者,希望每當建立一個新容器,容器程序看到的檔案系統就是一個獨立的隔離環境,而不是繼承自主控端的檔案系統。怎麼才能做到這一點呢?
可以在容器程序啟動之前重新挂載它的整個根目錄“/”。
而由于Mount Namespace的存在,這個挂載對主控端不可見,是以容器程序就可以在裡面随便折騰了。
在Linux作業系統裡,有一個名為
chroot(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作業系統中,這兩部分是分開存放的,作業系統隻有在開機啟動時才會加載指定版本的核心鏡像。
那麼,對于容器來說,這個
作業系統的“靈魂”在哪
同一台機器上的所有容器,都共享主控端作業系統的核心。
如果你的應用程式需要配置核心參數、加載額外的核心子產品,以及跟核心進行直接的互動
這些操作和依賴的對象,都是主控端作業系統的核心,它對于該機器上的所有容器來說是一個“全局變量”,牽一發動全身。
這也是容器相比于虛拟機的主要缺陷之一
畢竟後者不僅有模拟出來的硬體機器充當沙盒,而且每個沙盒裡還運作着一個完整的Guest OS給應用随便折騰。
不過,正是由于rootfs的存在,容器才有了一個被反複宣傳至今的重要特性:
一緻性
什麼是容器的“一緻性”呢?
由于雲端與本地伺服器環境不同,應用的打包過程,一直是使用PaaS時最“痛苦”的一個步驟。
但有了容器鏡像(即rootfs)之後,這個問題被非常優雅地解決了。
由于rootfs裡打包的不隻是應用,而是整個作業系統的檔案和目錄,也就意味着,應用以及它運作所需要的所有依賴,都被封裝在了一起。
事實上,對于大多數開發者而言,他們對應用依賴的了解,一直局限在程式設計語言層面。比如Golang的Godeps.json。
但實際上,一個一直以來很容易被忽視的事實是,對一個應用來說,作業系統本身才是它運作所需要的最完整的“依賴庫”。
有了容器鏡像“打包作業系統”的能力,這個最基礎的依賴環境也終于變成了應用沙盒的一部分。這就賦予了容器所謂的一緻性:
無論在本地、雲端,還是在一台任何地方的機器上,使用者隻需要解壓打包好的容器鏡像,那麼這個應用運作所需要的完整的執行環境就被重制出來了。
這種深入到作業系統級别的運作環境一緻性,打通了應用在本地開發和遠端執行環境之間難以逾越的鴻溝。
不過,這時你可能已經發現了另一個非常棘手的問題:難道我每開發一個應用,或者更新一下現有的應用,都要重複制作一次rootfs嗎?
比如,我現在用Ubuntu作業系統的ISO做了一個rootfs,然後又在裡面安裝了Java環境,用來部署應用。那麼,我的另一個同僚在釋出他的Java應用時,顯然希望能夠直接使用我安裝過Java環境的rootfs,而不是重複這個流程。
一種比較直覺的解決辦法是,我在制作rootfs的時候,每做一步“有意義”的操作,就儲存一個rootfs出來,這樣其他同僚就可以按需求去用他需要的rootfs了。
但是,這個解決辦法并不具備推廣性。原因在于,一旦你的同僚們修改了這個rootfs,新舊兩個rootfs之間就沒有任何關系了。這樣做的結果就是極度的_碎片化_。
那麼,既然這些修改都基于一個舊的rootfs,我們能不能
以增量的方式去做這些修改
呢?
這樣做的好處是,所有人都隻需要維護相對于base rootfs修改的增量内容,而不是每次修改都制造一個“fork”。
答案當然是肯定的。
這也正是為何,Docker公司在實作Docker鏡像時并沒有沿用以前制作rootfs的标準流程,而是做了一個小小的創新:
Docker在鏡像的設計中,引入了層(layer)的概念。也就是說,使用者制作鏡像的每一步操作,都會生成一個層,也就是一個增量rootfs。
當然,這個想法不是憑空臆造出來的,而是用到
聯合檔案系統(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中生效。
我的環境是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)
這時,我們可以分别檢視一下這些層的内容:
$ 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
在沒有寫入檔案之前,這個目錄是空的。而一旦在容器裡做了寫操作,你修改産生的内容就會以增量的方式出現在這個層中。
如果我現在要做的,是删除隻讀層裡的一個檔案呢?
為了實作這樣的删除操作,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年間迅猛發展的核心動力。容器鏡像的發明,不僅打通了“開發-測試-部署”流程的每一個環節,更重要的是:
容器鏡像将會成為未來軟體的主流釋出方式。
參考
深入剖析Kubernetes
“ 阿裡巴巴雲原生 關注微服務、Serverless、容器、Service Mesh 等技術領域、聚焦雲原生流行技術趨勢、雲原生大規模的落地實踐,做最懂雲原生開發者的技術圈。”