Docker容器實戰(七) - Docker存儲隔離原理?
容器為什么需要進行文件系統隔離呢?

被其他容器篡改文件,導致安全問題
文件的并發寫入造成的不一致問題
Linux容器通過Namespace、Cgroups,進程就真的被“裝”在了一個與世隔絕的房間里,而這些房間就是PaaS項目賴以生存的應用“沙盒”。墻內的它們是怎樣的生活呢?
1 容器里的進程眼中的文件系統
也許你會認為這是一個Mount Namespace的問題
容器里的應用進程,理應看到一份完全獨立的文件系統。這樣,它就可以在自己的容器目錄(比如/tmp)下操作,而完全不會受宿主機以及其他容器的影響。
那么,真實情況是這樣嗎?
“左耳朵耗子”叔在多年前寫的一篇關于docker基礎知識的博客里,曾經介紹過一段小程序。
這段小程序的作用是,在創建子進程時開啟指定的Namespace。
下面,我們不妨使用它來驗證一下剛剛提到的問題。
#define _GNU_SOURCE #include
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
在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; }
1
2
3
4
5
6
7
8
9
10
可以看到,在修改后的代碼里,我在容器進程啟動之前,加上了一句mount(“none”, “/tmp”, “tmpfs”, 0, “”)語句。就這樣,我告訴了容器以tmpfs(內存盤)格式,重新掛載了/tmp目錄。
這段修改后的代碼,編譯執行后的結果又如何呢?我們可以試驗一下:
可以看到,這次/tmp變成了一個空目錄,這意味著重新掛載生效了。我們可以用mount -l檢查一下:
可以看到,容器里的/tmp目錄是以tmpfs方式單獨掛載的。
更重要的是,因為我們創建的新進程啟用了Mount Namespace,所以這次重新掛載的操作,只在容器進程的Mount Namespace中有效。如果在宿主機上用mount -l來檢查一下這個掛載,你會發現它是不存在的:
這就是Mount Namespace跟其他Namespace的使用略有不同的地方:
它對容器進程視圖的改變,一定是伴隨著掛載操作(mount)才能生效。
1
可作為用戶,希望每當創建一個新容器,容器進程看到的文件系統就是一個獨立的隔離環境,而不是繼承自宿主機的文件系統。怎么才能做到這一點呢?
可以在容器進程啟動之前重新掛載它的整個根目錄“/”。
而由于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
1
2
3
然后,把bash命令拷貝到test目錄對應的bin路徑下:
$ cp -v /bin/{bash,ls} $HOME/test/bin
1
接下來,把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
1
2
3
最后,執行chroot命令,告訴操作系統,我們將使用$HOME/test目錄作為/bin/bash進程的根目錄:
$ chroot $HOME/test /bin/bash
1
這時,你如果執行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
1
2
而你進入容器之后執行的/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)到同一目錄。
容器有了進程隔離(視野隔離),CGroup資源隔離,還缺少隔離的文件系統,可認為Unionfs是容器房間里的地板,將多個文件目錄掛載給某個容器進程,供其獨享。
為了解決該問題,Docker在Ubuntu發行版上默認使用AuFS(Advanced Union FS)支持Docker鏡像的Layer,也支持其他UnionFS的版本。
比如,現在有兩個目錄A、B,分別有倆文件:
$ tree . ├── A │ ├── a │ └── x └── B ├── b └── x
1
2
3
4
5
6
7
8
然后使用聯合掛載,將這倆目錄掛載到一個公共目錄C上:
$ mkdir C $ mount -t aufs -o dirs=./A:./B none ./C
1
2
再查看目錄C的內容,就能看到目錄A和B下的文件被合并到了一起:
$ tree ./C ./C ├── a ├── b └── x
1
2
3
4
5
可見,在這個合并后的目錄C里,有a、b、x三個文件,并且x文件只有一份。這就是“合并”。如果在目錄C里對a、b、x文件做修改,這些修改也會在對應的目錄A、B中生效。
AuFS全稱Another UnionFS,后改名為Alternative UnionFS,再后來干脆改名叫作Advance UnionFS,從這些名字中你應該能看出這樣兩個事實:
對Linux原生UnionFS的重寫和改進
Linus一直不讓AuFS進入Linux內核主干,只能在Ubuntu和Debian這些發行版上使用
對于AuFS來說,它最關鍵的目錄結構在/var/lib/docker路徑下的diff目錄:
/var/lib/docker/aufs/diff/
1
現在啟動一個容器
$ docker run -d ubuntu:latest sleep 3600
1
Docker就會從Docker Hub上拉取一個Ubuntu鏡像到本地。
這“鏡像”實際上是一個Ubuntu操作系統的rootfs,內容是Ubuntu操作系統的所有文件和目錄。
不同的是,Docker鏡像使用的rootfs,往往由多個“層”組成:
docker image inspect ubuntu:latest ... "RootFS": { "Type": "layers", "Layers": [ "sha256:f49017d4d5ce9c0f544c...", "sha256:8f2b771487e9d6354080...", "sha256:ccd4d61916aaa2159429...", "sha256:c01d74f99de40e097c73...", "sha256:268a067217b5fe78e000..." ] }
1
2
3
4
5
6
7
8
9
10
11
12
可以看到,這個Ubuntu鏡像,實際上由五個層組成。
這五個層就是五個增量rootfs,每一層都是Ubuntu操作系統文件與目錄的一部分;而在使用鏡像時,Docker會把這些增量聯合掛載在一個統一的掛載點上(等價于前面例子里的“/C”目錄)。
這個掛載點就是/var/lib/docker/aufs/mnt/,比如:
/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
1
不出意外的,這個目錄里面正是一個完整的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
1
2
那么,前面提到的五個鏡像層,又是如何被聯合掛載成這樣一個完整的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
1
2
即,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
1
2
3
4
5
6
7
8
從這些信息里,我們可以看到,鏡像的層都放置在/var/lib/docker/aufs/diff目錄下,然后被聯合掛載在/var/lib/docker/aufs/mnt里面。
Docker 任務調度 容器
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。