1. 程式人生 > 實用技巧 >Docker容器實現原理

Docker容器實現原理

容器中的程序隔離

容器技術的核心功能,就是通過約束和修改程序的動態表現,從而為其創造出一個“邊界”。在Docker中使用了Namespace 技術來修改程序檢視從而達到程序隔離的目的。

首先建立一個容器作為例子:

$ docker run -it busybox /bin/sh
/ #

-it 引數告訴了 Docker 專案在啟動容器後,需要給我們分配一個文字輸入 / 輸出環境,也就是 TTY,跟容器的標準輸入相關聯,這樣我們就可以和這個 Docker 容器進行互動了。而 /bin/sh 就是我們要在 Docker 容器裡執行的程式。

如果我們執行如下命令:

/ # ps
PID  USER   TIME COMMAND
  1 root   0:00 /bin/sh
  10 root   0:00 ps

可以看到,我們在 Docker 裡最開始執行的 /bin/sh,就是這個容器內部的第 1 號程序(PID=1)。這就意味著,前面執行的 /bin/sh,以及我們剛剛執行的 ps,已經被 Docker 隔離在了一個跟宿主機完全不同的世界當中。

本來,每當我們在宿主機上運行了一個 /bin/sh 程式,作業系統都會給它分配一個程序編號,比如 PID=100。而現在,我們要通過 Docker 把這個 /bin/sh 程式執行在一個容器當中。

Docker會將宿主機的作業系統裡,還是原來的第 100 號程序通過Linux 裡面的 Namespace 機制重新進行程序編號。如下:

int pid = clone(main_function, stack_size, SIGCHLD, NULL);

當我們用 clone() 系統呼叫建立一個新程序時,就可以在引數中指定 CLONE_NEWPID 引數,比如:

int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);

新建立的這個程序將會“看到”一個全新的程序空間,在這個程序空間裡,它的 PID 是 1。

每個 Namespace 裡的應用程序,都會認為自己是當前容器裡的第 1 號程序,它們既看不到宿主機裡真正的程序空間,也看不到其他 PID Namespace 裡的具體情況。

除了我們剛剛用到的 PID Namespace,Linux 作業系統還提供了 Mount、UTS、IPC、Network 和 User 這些 Namespace,用來對各種不同的程序上下文進行“障眼法”操作。

但是,基於 Linux Namespace 的隔離機制相比於虛擬化技術也有很多不足之處,其中最主要的問題就是:隔離得不徹底。

首先,既然容器只是執行在宿主機上的一種特殊的程序,那麼多個容器之間使用的就還是同一個宿主機的作業系統核心。

其次,在 Linux 核心中,有很多資源和物件是不能被 Namespace 化的,最典型的例子就是:時間。

這就意味著,如果你的容器中的程式使用 settimeofday(2) 系統呼叫修改了時間,整個宿主機的時間都會被隨之修改,這顯然不符合使用者的預期。

容器中隔離中的資源限制

Linux Cgroups 就是 Linux 核心中用來為程序設定資源限制的一個重要功能。

Linux Cgroups 的全稱是 Linux Control Group。它最主要的作用,就是限制一個程序組能夠使用的資源上限,包括 CPU、記憶體、磁碟、網路頻寬等等。

此外,Cgroups 還能夠對程序進行優先順序設定、審計,以及將程序掛起和恢復等操作。

在 Linux 中,Cgroups 給使用者暴露出來的操作介面是檔案系統,即它以檔案和目錄的方式組織在作業系統的 /sys/fs/cgroup 路徑下。用 mount 指令把它們展示出來,這條命令是:

$ mount -t cgroup 
cpuset on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cpu on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu)
cpuacct on /sys/fs/cgroup/cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct)
blkio on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
memory on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
...

可以看到,在 /sys/fs/cgroup 下面有很多諸如 cpuset、cpu、 memory 這樣的子目錄,也叫子系統。這些都是我這臺機器當前可以被 Cgroups 進行限制的資源種類。

比如,對 CPU 子系統來說,我們就可以看到如下幾個配置檔案,這個指令是:

$ ls /sys/fs/cgroup/cpu
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us  cpu.shares notify_on_release
cgroup.procs      cpu.cfs_quota_us  cpu.rt_runtime_us cpu.stat  tasks

下面我們來使用一下cgroup,看看它是如何限制CPU的使用率的。

現在進入 /sys/fs/cgroup/cpu 目錄下:

root@ubuntu:/sys/fs/cgroup/cpu$ mkdir container
root@ubuntu:/sys/fs/cgroup/cpu$ ls container/
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us  cpu.shares notify_on_release
cgroup.procs      cpu.cfs_quota_us  cpu.rt_runtime_us cpu.stat  tasks

這個目錄就稱為一個“控制組”。你會發現,作業系統會在你新建立的 container 目錄下,自動生成該子系統對應的資源限制檔案。

現在,我們在後臺執行這樣一條指令碼:

$ while : ; do : ; done &
[1] 226

顯然,它執行了一個死迴圈,可以把計算機的 CPU 吃到 100%,根據它的輸出,我們可以看到這個指令碼在後臺執行的程序號(PID)是 226。

這樣,我們可以用 top 指令來確認一下 CPU 有沒有被打滿:

$ top
%Cpu0 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

下面我們進入到container,看到 container 控制組裡的 CPU quota 還沒有任何限制(即:-1),CPU period 則是預設的 100 ms(100000 us):

$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us 
-1
$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us 
100000

接下來,我們可以通過修改這些檔案的內容來設定限制。比如,向 container 組裡的 cfs_quota 檔案寫入 20 ms(20000 us):

$ echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us

它意味著在每 100 ms 的時間裡,被該控制組限制的程序只能使用 20 ms 的 CPU 時間,也就是說這個程序只能使用到 20% 的 CPU 頻寬。

接下來,我們把被限制的程序的 PID 寫入 container 組裡的 tasks 檔案,上面的設定就會對該程序生效了:

$ echo 226 > /sys/fs/cgroup/cpu/container/tasks 

我們可以用 top 指令檢視一下:

$ top
%Cpu0 : 20.3 us, 0.0 sy, 0.0 ni, 79.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

可以看到,計算機的 CPU 使用率立刻降到了 20%。

對於 Docker 等 Linux 容器專案來說,它們只需要在每個子系統下面,為每個容器建立一個控制組(即建立一個新目錄),然後在啟動容器程序之後,把這個程序的 PID 填寫到對應控制組的 tasks 檔案中就可以了。

想要了解更多的資訊,可以看這篇:Linux 資源管理指南

容器中隔離中的檔案系統

如果一個容器需要啟動,那麼它一定需要提供一個根檔案系統(rootfs),容器需要使用這個檔案系統來建立一個新的程序,所有二進位制的執行都必須在這個根檔案系統中。

一個最常見的 rootfs,會包括如下所示的一些目錄和檔案,比如 /bin,/etc,/proc 等等:

$ ls /
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var

為了保證當前的容器程序沒有辦法訪問宿主機器上其他目錄,我們在這裡還需要通過 libcontainer 提供的 pivot_root 或者 chroot 函式改變程序能夠訪問個檔案目錄的根節點。不過,Docker 專案在最後一步的切換上會優先使用 pivot_root 系統呼叫,如果系統不支援,才會使用 chroot。

通過pivot_root或chroot將容器需要的目錄掛載到了容器中,同時也禁止當前的容器程序訪問宿主機器上的其他目錄,保證了不同檔案系統的隔離。

但是rootfs 只是一個作業系統所包含的檔案、配置和目錄,並不包括作業系統核心。在 Linux 作業系統中,這兩部分是分開存放的,作業系統只有在開機啟動時才會載入指定版本的核心映象。

這就意味著,如果你的應用程式需要配置核心引數、載入額外的核心模組,以及跟核心進行直接的互動,你就需要注意了:這些操作和依賴的物件,都是宿主機作業系統的核心,它對於該機器上的所有容器來說是一個“全域性變數”,牽一髮而動全身。

我們首先來解釋一下,什麼是Mount Namespace:

Mount Namespace用來隔離檔案系統的掛載點,這樣程序就只能看到自己的 mount namespace 中的檔案系統掛載點。

程序的Mount Namespace中的掛載點資訊可以在 /proc/[pid]/mounts、/proc/[pid]/mountinfo 和 /proc/[pid]/mountstats 這三個檔案中找到。

然後我們再來看看什麼是根檔案系統rootfs:

根檔案系統首先是一種檔案系統,該檔案系統不僅具有普通檔案系統的儲存資料檔案的功能,但是相對於普通的檔案系統,它的特殊之處在於,它是核心啟動時所掛載(mount)的第一個檔案系統,核心程式碼的映像檔案儲存在根檔案系統中,系統引導啟動程式會在根檔案系統掛載之後從中把一些初始化指令碼(如rcS,inittab)和服務載入到記憶體中去執行。

Linux啟動時,第一個必須掛載的是根檔案系統;若系統不能從指定裝置上掛載根檔案系統,則系統會出錯而退出啟動。成功之後可以自動或手動掛載其他的檔案系統。

基於上面兩個基礎知識,我們知道一個Linux容器,首先應該要有一個檔案隔離環境,並且還要實現rootfs。

而在 Linux 作業系統裡,有一個名為 chroot 的命令可以實現改變程序的根目錄到指定的位置的目的從而實現rootfs。

所以我們的容器程序啟動之前重新掛載它的整個根目錄“/”。而由於 Mount Namespace 的存在,這個掛載對宿主機不可見,所以就建立了一個獨立的隔離環境。

而掛載在容器根目錄上、用來為容器程序提供隔離後執行環境的檔案系統就是叫做rootfs。

所以,一個最常見的 rootfs,或者說容器映象,會包括如下所示的一些目錄和檔案,比如 /bin,/etc,/proc 等等:

$ ls /
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var

由於有了rootfs之後,所以rootfs 裡打包的不只是應用,而是整個作業系統的檔案和目錄,也就意味著,應用以及它執行所需要的所有依賴,都被封裝在了一起。這也就為容器映象提供了“打包作業系統”的能力。

層(layer)

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

layer是使用了一種叫作聯合檔案系統(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


$ tree ./C
./C
├── a
├── b
└── x

比如我們拉取一個映象:

$ docker run -d ubuntu:latest sleep 3600

在Docker中,這個所謂的“映象”,實際上就是一個 Ubuntu 作業系統的 rootfs,它的內容是 Ubuntu 作業系統的所有檔案和目錄。但是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/mnt/,比如,這個目錄裡面正是一個完整的 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

我們可以在/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

從這個結構可以看出來,這個容器的 rootfs 由如下圖所示的三部分組成:

第一部分,只讀層。

對應的正是 ubuntu:latest 映象的五層。它們的掛載方式都是隻讀(ro+wh)的。

$ 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

第二部分,可讀寫層。

它是這個容器的 rootfs 最上面的一層(6e3be5d2ecccae7cc),它的掛載方式為:rw,即 read write。在沒有寫入檔案之前,這個目錄是空的。而一旦在容器裡做了寫操作,你修改產生的內容就會以增量的方式出現在這個層中。

如果刪除一個只讀的檔案,AuFS 會在可讀寫層建立一個 whiteout 檔案,把只讀層裡的檔案“遮擋”起來。

比如,你要刪除只讀層裡一個名叫 foo 的檔案,那麼這個刪除操作實際上是在可讀寫層建立了一個名叫.wh.foo 的檔案。

最上面這個可讀寫層的作用,就是專門用來存放你修改 rootfs 後產生的增量,無論是增、刪、改,都發生在這裡。

第三部分,Init 層。

它是一個以“-init”結尾的層,夾在只讀層和讀寫層之間。Init 層是 Docker 專案單獨生成的一個內部層,專門用來存放 /etc/hosts、/etc/resolv.conf 等資訊。

需要這樣一層的原因是,這些檔案本來屬於只讀的 Ubuntu 映象的一部分,但是使用者往往需要在啟動容器時寫入一些指定的值比如 hostname,所以就需要在可讀寫層對它們進行修改。

但是這些修改往往只對當前的容器有效,並不會執行 docker commit 時,把這些資訊連同可讀寫層一起提交掉。

上面的這張圖片非常好的展示了組裝的過程,每一個映象層都是建立在另一個映象層之上的,同時所有的映象層都是隻讀的,只有每個容器最頂層的容器層才可以被使用者直接讀寫,所有的容器都建立在一些底層服務(Kernel)上,包括名稱空間、控制組、rootfs 等等,這種容器的組裝方式提供了非常大的靈活性,只讀的映象層通過共享也能夠減少磁碟的佔用。

在最新的 Docker 中,overlay2 取代了 aufs 成為了推薦的儲存驅動,但是在沒有 overlay2 驅動的機器上仍然會使用 aufs作為 Docker 的預設驅動。

這篇官方文章裡詳細的介紹了儲存驅動:Docker storage drivers

Docker exec的實現原理

比如說我們運行了一個Docker容器,我們如果想進入到容器內部進行操作,一般會使用如下命令:

docker exec -it {container id} /bin/sh

通過使用docker exec 命令進入到了容器當中,那麼docker exec 是怎麼做到進入容器裡的呢?

實際上,Linux Namespace 建立的隔離空間雖然看不見摸不著,但一個程序的 Namespace 資訊在宿主機上是確確實實存在的,並且是以一個檔案的方式存在。

比如,通過如下指令,你可以看到當前正在執行的 Docker 容器的程序號(PID):

# docker inspect --format '{{ .State.Pid }}' 6e27dcd23489
29659

這時,你可以通過檢視宿主機的 proc 檔案,看到這個 29659 程序的所有 Namespace 對應的檔案:

(base) [root@VM_243_186_centos ~]# ls -l /proc/29659/ns
總用量 0
lrwxrwxrwx 1 root root 0 7月  14 15:18 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 7月  14 15:18 ipc -> ipc:[4026532327]
lrwxrwxrwx 1 root root 0 7月  14 15:09 mnt -> mnt:[4026532325]
lrwxrwxrwx 1 root root 0 7月  14 15:09 net -> net:[4026532330]
lrwxrwxrwx 1 root root 0 7月  14 15:18 pid -> pid:[4026532328]
lrwxrwxrwx 1 root root 0 7月  14 15:18 uts -> uts:[4026532326]

這也就意味著:一個程序,可以選擇加入到某個程序已有的 Namespace 當中,從而達到“進入”這個程序所在容器的目的,這正是 docker exec 的實現原理。

setns() 函式

通過 setns() 函式可以將當前程序加入到已有的 namespace 中。setns() 在 C 語言庫中的宣告如下:

#define _GNU_SOURCE
#include <sched.h>
int setns(int fd, int nstype);
  • fd:表示要加入 namespace 的檔案描述符。
  • nstype:引數 nstype 讓呼叫者可以檢查 fd 指向的 namespace 型別是否符合實際要求。若把該引數設定為 0 表示不檢查。

所以說docker exec 這個操作背後,其實是利用了setns呼叫進入到 namespace從而進行相關的操作。

Volume 機制

Volume 機制,允許你將宿主機上指定的目錄或者檔案,掛載到容器裡面進行讀取和修改操作。

在 Docker 專案裡,它支援兩種 Volume 宣告方式,可以把宿主機目錄掛載進容器的 /test 目錄當中:

$ docker run -v /test ...
$ docker run -v /home:/test ...

在第一種情況下,由於你並沒有顯示宣告宿主機目錄,那麼 Docker 就會預設在宿主機上建立一個臨時目錄 /var/lib/docker/volumes/[VOLUME_ID]/_data,然後把它掛載到容器的 /test 目錄上。而在第二種情況下,Docker 就直接把宿主機的 /home 目錄掛載到容器的 /test 目錄上。

映象的各個層,儲存在 /var/lib/docker/aufs/diff 目錄下,在容器程序啟動後,它們會被聯合掛載在 /var/lib/docker/aufs/mnt/ 目錄中,這樣容器所需的 rootfs 就準備好了。

容器會在 rootfs 準備好之後,在執行 chroot 之前,把 Volume 指定的宿主機目錄(比如 /home 目錄),掛載到指定的容器目錄(比如 /test 目錄)在宿主機上對應的目錄(即 /var/lib/docker/aufs/mnt/[可讀寫層 ID]/test)上,這個 Volume 的掛載工作就完成了。

由於執行這個掛載操作時,“容器程序”已經建立了,也就意味著此時 Mount Namespace 已經開啟了。所以,這個掛載事件只在這個容器裡可見。你在宿主機上,是看不見容器內部的這個掛載點的。從而保證了容器的隔離性不會被 Volume 打破。

這裡的掛載技術就是Linux 的繫結掛載(bind mount)機制。它的主要作用就是,允許你將一個目錄或者檔案,而不是整個裝置,掛載到一個指定的目錄上。並且,這時你在該掛載點上進行的任何操作,只是發生在被掛載的目錄或者檔案上,而原掛載點的內容則會被隱藏起來且不受影響。

mount --bind /home /test,會將 /home 掛載到 /test 上。其實相當於將 /test 的 dentry,重定向到了 /home 的 inode。這樣當我們修改 /test 目錄時,實際修改的是 /home 目錄的 inode。

Reference