Docker容器實戰(七) - 容器中進程視野下的文件系統

前兩文中,講了Linux容器最基礎的兩種技術docker

  • Namespace

做用是「隔離」,它讓應用進程只能看到該Namespace內的「世界」shell

  • Cgroups

做用是「限制」,它給這個「世界」圍上了一圈看不見的牆編程

這麼一搞,進程就真的被「裝」在了一個與世隔絕的房間裏,而這些房間就是PaaS項目賴以生存的應用「沙盒」。json

還有一個問題是:牆外的咱們知道他的處境了,牆內的他呢?ubuntu

1 容器裏的進程眼中的文件系統

也許你會認爲這是一個關於Mount Namespace的問題
容器裏的應用進程,理應看到一份徹底獨立的文件系統。這樣,它就能夠在本身的容器目錄(好比/tmp)下進行操做,而徹底不會受宿主機以及其餘容器的影響。小程序

那麼,真實狀況是這樣嗎?segmentfault

「左耳朵耗子」叔在多年前寫的一篇關於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的隔離環境中。

咱們來一塊兒編譯一下這個程序:

這樣,咱們就進入了這個「容器」當中。但是,若是在「容器」裏執行一下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 Namespacerootfs,容器就可以爲進程構建出一個完善的文件系統隔離環境。固然,這個功能的實現還必須感謝chrootpivot_root這兩個系統調用切換進程根目錄的能力。

而在rootfs的基礎上,Docker公司創新性地提出了使用多個增量rootfs聯合掛載一個完整rootfs的方案,這就是容器鏡像中「層」的概念。

經過「分層鏡像」的設計,以Docker鏡像爲核心,來自不一樣公司、不一樣團隊的技術人員被緊密地聯繫在了一塊兒。並且,因爲容器鏡像的操做是增量式的,這樣每次鏡像拉取、推送的內容,比本來多個完整的操做系統的大小要小得多;
而共享層的存在,可使得全部這些容器鏡像須要的總空間,也比每一個鏡像的總和要小。
這樣就使得基於容器鏡像的團隊協做,要比基於動則幾個GB的虛擬機磁盤鏡像的協做要敏捷得多。

更重要的是,一旦這個鏡像被髮布,那麼你在全世界的任何一個地方下載這個鏡像,獲得的內容都徹底一致,能夠徹底復現這個鏡像製做者當初的完整環境。這,就是容器技術「強一致性」的重要體現。

而這種價值正是支撐Docker公司在2014~2016年間迅猛發展的核心動力。容器鏡像的發明,不只打通了「開發-測試-部署」流程的每個環節,更重要的是:

容器鏡像將會成爲將來軟件的主流發佈方式。

參考

深刻剖析Kubernetes

本文由博客一文多發平臺 OpenWrite 發佈!
相關文章
相關標籤/搜索