Kubernetes — 深入理解容器鏡像


而正如我前面所說的,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)才能生效。

可是,作為一個普通用戶,我們希望的是一個更友好的情況:每當創建一個新容器時,我希望容器進程看到的文件系統就是一個獨立的隔離環境,而不是繼承自宿主機的文件系統。怎么才能做到這一點呢?

不難想到,我們可以在容器進程啟動之前重新掛載它的整個根目錄“/”。而由於 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 操作系統供容器使用。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM