k8s 深入篇———— docker 鏡像是什麼[二]

前言

簡單介紹一下docker的鏡像。

正文

前面講到了容器的工作原理了(namespace 限制了時間, cgroup限制了資源),知道docker 歷史的也知道,docker 之所以能夠稱爲容器大佬,是因爲其只做了容器。

也就是做到了一次打包,到處運行的這種思想得到了實現。

那麼容器的鏡像涉及思路是怎麼樣的呢?

tee test.c <<- 'EOF'
> #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;
> }
> EOF

寫入這一段代碼。

然後編譯:

gcc -o test test.h

然後運行一下:

./test

這個時候容器就啓動了。

這個時候就已經進入了,容器裏面了。

但是你這個時候發現一個問題,那就是你所在的容器和外面一模一樣。

即使開啓了 Mount Namespace,容器進程看到的文件系統也跟宿主機完全一樣。

那麼是不是掛載點的問題呢?

還是說代碼根本就沒有成功。

第一,驗證代碼是否開啓了mount namespace:

kill 之後,然後就推出了,這時候就知道了,其實我們一直是在容器裏面。

Mount Namespace 修改的,是容器進程對
文件系統“掛載點”的認知。但是,這也就意味着,只有在“掛載”這個操作發生之後,進
程的視圖纔會被改變。而在此之前,新創建的容器會直接繼承宿主機的各個掛載點。

那麼更換一下掛載點:

[root@iZ8vb42623daibnzbt0j16Z ~]# tee test.c <<- 'EOF'
> #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");
>   // 如果你的機器的根目錄的掛載類型是 shared,那必須先重新掛載根目錄
>   // mount("", "/", NULL, MS_PRIVATE, "");
>   mount("none", "/tmp", "tmpfs", 0, "");
>   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;
> }
> EOF

mount("none", "/tmp", "tmpfs", 0, ""); 這一行。

那麼看下效果,編譯,然後運行。

現在這個進程來說,tmp 就完全看不到了。

然後看一下tmpfs 的掛載情況:

mount -l | grep tmpfs

tmpfs 這個表示內存盤的意思,就是往這個盤上寫東西,其實是寫入內存中。

這個時候看下宿主機的情況。

宿主機也能看到。

也就是說容器影響了宿主機,這很危險。

改下。

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

這就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它對容器進程
視圖的改變,一定是伴隨着掛載操作(mount)才能生效。

可是,作爲一個普通用戶,我們希望的是一個更友好的情況:每當創建一個新容器時,我希
望容器進程看到的文件系統就是一個獨立的隔離環境,而不是繼承自宿主機的文件系統。怎
麼才能做到這一點呢?
不難想到,我們可以在容器進程啓動之前重新掛載它的整個根目錄“/”。而由於 Mount
Namespace 的存在,這個掛載對宿主機不可見,所以容器進程就可以在裏面隨便折騰了。

那麼如果希望看到一個獨立的環境呢? 那麼就得重新掛載整個根目錄了。

在 Linux 操作系統裏,有一個名爲 chroot 的命令可以幫助你在 shell 中方便地完成這個工
作。顧名思義,它的作用就是幫你“change root file system”,即改變進程的根目錄到
你指定的位置。它的用法也非常簡單。

上面創建一些目錄。

然後把文件拷貝進去。

cp -v /bin/{bash,ls} $HOME/testnamespace/bin

拷貝依賴:

T=$HOME/testnamespace
list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
for i in $list; do cp -v "$i" "${T}${i}"; done 

如果是64位,運行這個:

T=$HOME/testnamespace
list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
for i in $list; do cp -v "$i" "${T}${i}"; done 

然後運行:

chroot $HOME/testnamespace /bin/bash

這個時候這個進程,運行的就是在$HOME/testnamepsace空間下。

看一下ls -al,看到的就是$HOME/testnamepsace的內容。

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

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

現在,你應該可以理解,對 Docker 項目來說,它最核心的原理實際上就是爲待創建的用戶
進程:

  1. 啓用 Linux Namespace 配置;
  2. 設置指定的 Cgroups 參數;
  3. 切換進程的根目錄(Change Root)。

這樣,一個完整的容器就誕生了。不過,Docker 項目在最後一步的切換上會優先使用
pivot_root 系統調用,如果系統不支持,纔會使用 chroot。

另外,需要明確的是,rootfs 只是一個操作系統所包含的文件、配置和目錄,並不包括操
作系統內核。在 Linux 操作系統中,這兩部分是分開存放的,操作系統只有在開機啓動時
纔會加載指定版本的內核鏡像

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

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

這就意味着,如果你的應用程序需要配置內核參數、加載額外的內核模塊,以及跟內核進行
直接的交互,你就需要注意了:這些操作和依賴的對象,都是宿主機操作系統的內核,它對
於該機器上的所有容器來說是一個“全局變量”,牽一髮而動全身。

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

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

由於 rootfs 裏打包的不只是應用,而是整個操作系統的文件和目錄,也就意味着,應用以
及它運行所需要的所有依賴,都被封裝在了一起。

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

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

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

最主要的功能是將多個不同位置的目錄聯合掛載(union mount)到同一個目錄下。

做個實驗

有A B C三個目錄

A下有a x兩個文件

B下有b x兩個文件

用聯合掛載的方式,將這兩個目錄掛載到一個公共的目錄 C 上

mount -t aufs -o dirs=./A:./B none ./C

再查看目錄 C 的內容,就能看到目錄 A 和 B 下的文件被合併到了一起:

形成這樣一個覆蓋的功能。

查看docker info:

使用的是overlay2

來查看一下這個東西吧。

我們來隨便查看一個鏡像。

docker image inspect busybox

可以看到這一層的id 是sha256:01fd6df81c8ec7dd24bbbd72342671f41813f992999a3471b9d9cbc44ad88374

然後也可也看到鏡像的位置在:

/var/lib/docker/overlay2/62f55dcb71d6096a2f9a874ae51397fac69c689122aa7fcecb20bfa3ee087d5e

進入/var/lib/docker/overlay2/62f55dcb71d6096a2f9a874ae51397fac69c689122aa7fcecb20bfa3ee087d5e/diff

就可以看到完整的鏡像目錄。

當我們啓動一個容器的時候,我們發現了兩個東西:

這兩層是什麼呢?

這兩層分別是可讀性層 和 init 層。

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

第二部分,可讀寫層。
它是這個容器的 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 只會提交可讀寫層,所以是不包含這些內容的。

通過:

docker inspect 7ebe 可查看.

那麼前面提及到了layer,也就是層sha256:01fd6df81c8ec7dd24bbbd72342671f41813f992999a3471b9d9cbc44ad88374,到底在哪呢?

然後查看cacheid 這個,發現:

這個就是我們前面看到的一層哈。

正是我們經常提到的容器鏡像,也叫作:rootfs。它只是一個操作系統的所有文件和目錄,並不包含
內核,最多也就幾百兆。而相比之下,傳統虛擬機的鏡像大多是一個磁盤的“快照”,磁盤
有多大,鏡像就至少有多大。
通過結合使用 Mount Namespace 和 rootfs,容器就能夠爲進程構建出一個完善的文件系
統隔離環境。當然,這個功能的實現還必須感謝 chroot 和 pivot_root 這兩個系統調用切
換進程根目錄的能力。
而在 rootfs 的基礎上,Docker 公司創新性地提出了使用多個增量 rootfs 聯合掛載一個完
整 rootfs 的方案,這就是容器鏡像中“層”的概念。

下一節容器的基本知識。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章