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 由如下圖所示的三部分組成:

img

第一部分,只讀層。

對應的正是 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 時,把這些信息連同可讀寫層一起提交掉。

docker-filesystems

上面的這張圖片非常好的展示了組裝的過程,每一個鏡像層都是建立在另一個鏡像層之上的,同時所有的鏡像層都是只讀的,只有每個容器最頂層的容器層才可以被用戶直接讀寫,所有的容器都建立在一些底層服務(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)機制。它的主要作用就是,允許你將一個目錄或者文件,而不是整個設備,掛載到一個指定的目錄上。並且,這時你在該掛載點上進行的任何操作,只是發生在被掛載的目錄或者文件上,而原掛載點的內容則會被隱藏起來且不受影響。

img

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

Reference


免責聲明!

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



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