相信你在很多地方都看到過“Docker基於mamespace、cgroups、chroot等技術來構建容器”的說法,但你有沒有想過為何容器的構建需要這些技術? 為什么不是一個簡單的系統調用就可以搞定?原因在於Linux內核中並不存在“linux container”這個概念,容器是一個用戶態的概念。
Docker軟件工程師Michael Crosby將撰寫一些列博客,深入到Docker運行的背后,探索在 docker run這段代碼的背后發生了什么,這是系列博客的第一篇,深入探討Docker對namespace技術的應用。
Namespaces
在第一部分,我會在文章中討論Docker在使用Linux namespace時,如何創建Linux namespace。
從根本上說,namespace是Linux系統的底層概念有一些不同類型的命名空間被部署在核內。跟蹤docker run -it --privileged --net host crosbymichael/make-containers這段代碼,我們就可以深入到每個不同的namespace。開始會有一些預加載文件和配置。盡管我們也會在用Docker為我們運行的容器中創建namespace,不要讓他影響到你,我選擇提供一個容器預加載所有依賴項的方法。我使用 --net host標志,這樣可以在容器內看到host的網絡接口。也需要提供--privilged標簽,以保證擁有正確的權限去通過容器創建新的namespace。
以下是Dockerfile內的內容:
FROM debian:jessie RUN apt-get update && apt-get install -y \ gcc \ vim \ emacs COPY containers/ /containers/ WORKDIR /containers CMD ["bash"]
我會使用C語言來解釋這個例子,因為它比Go語言更容易去解釋底層的細節。
NET Namespace
network namespaces為你的系統網絡協議棧提供了自己的視圖。這個協議棧包括你的本地主機(localhost)。確認你在目錄crosbymichael/make-containers下,並運行 ip a查看所有運行在你的主機上的網絡接口。
> ip a root@development:/containers# ip a 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000 link/ether 08:00:27:19:ca:f2 brd ff:ff:ff:ff:ff:ff inet 10.0.2.15/24 brd 10.0.2.255 scope global eth0 valid_lft forever preferred_lft forever inet6 fe80::a00:27ff:fe19:caf2/64 scope link valid_lft forever preferred_lft forever 3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000 link/ether 08:00:27:20:84:47 brd ff:ff:ff:ff:ff:ff inet 192.168.56.103/24 brd 192.168.56.255 scope global eth1 valid_lft forever preferred_lft forever inet6 fe80::a00:27ff:fe20:8447/64 scope link valid_lft forever preferred_lft forever 4: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default link/ether 56:84:7a:fe:97:99 brd ff:ff:ff:ff:ff:ff inet 172.17.42.1/16 scope global docker0 valid_lft forever preferred_lft forever inet6 fe80::5484:7aff:fefe:9799/64 scope link valid_lft forever preferred_lft forever
這就是當前在我的主機系統中的所有網絡接口。現在讓我們寫一段代碼創建一個新的network interface。為此,我們將寫一個C語言庫的框架,系統調用了clone。我們將從調用clone開始,文件skeleton.c應該在demo容器的工作目錄中,我們將利用這個文件作為我們例子的基礎。下面是例子的代碼:
#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <sched.h> #include <sys/wait.h> #include <errno.h> #define STACKSIZE (1024*1024) static char child_stack[STACKSIZE]; struct clone_args { char **argv; }; // child_exec is the func that will be executed as the result of clone static int child_exec(void *stuff) { struct clone_args *args = (struct clone_args *)stuff; if (execvp(args->argv[0], args->argv) != 0) { fprintf(stderr, "failed to execvp argments %s\n", strerror(errno)); exit(-1); } // we should never reach here! exit(EXIT_FAILURE); } int main(int argc, char **argv) { struct clone_args args; args.argv = &argv[1]; int clone_flags = SIGCHLD; // the result of this call is that our child_exec will be run in another // process returning it's pid pid_t pid = clone(child_exec, child_stack + STACKSIZE, clone_flags, &args); if (pid < 0) { fprintf(stderr, "clone failed WTF!!!! %s\n", strerror(errno)); exit(EXIT_FAILURE); } // lets wait on our child process here before we, the parent, exits if (waitpid(pid, NULL, 0) == -1) { fprintf(stderr, "failed to wait pid %d\n", pid); exit(EXIT_FAILURE); } exit(EXIT_SUCCESS); }
這是個小的C程序,可以讓你執行./a.out ip a。它把你通過命令行傳入的參數,作為任何你想使用的進程的參數。不用擔心具體實施太多,因為我們將要做的事情將要發生有趣的變化。它將會用任何你想要的參數執行你希望的程序。這意味着如果你想執行下面的demo之一,同時它會產生一個shell會話,這樣你就可以在你的namespace中“閑逛”。你可以自己的方式探索與檢查這些不同的namespace。因此讓我們復制這個文件,並開始使用network namespace。
> cp skeleton.c network.c
在這個文件里有一個很特殊的變量,叫做clone_flags,大部分的變化將在此處發生。namespace主要由clone標志控制。network namespace的clone標記是CLONE_NEWNET。我們需要把int clone_flags = SIGCHLD;這一行改為 int clone_flags = CLONE_NEWNET | SIGCHLD;。這樣調用clone就為我們創建一個新的network namespace。在network.c中保存這一修改,然后編譯運行。
> gcc -o net network.c > ./net ip a 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
這次運行的結果與首次運行ip a相比看起來很不相同。這一次我們只看到了一個 loopback接口。這是因為我們創建的進程,只有一個它自己的network namespace視圖,而不是整個host。這就是如何創建一個新的network namespace的方法。
Docker使用新的network namespace啟動一個veth接口,這樣你的容器將擁有它自己的橋接ip地址,通常是docker0。
例子:
默認情況下,當 docker 實例被創建出來后,使用 ip netns 命令無法看到容器實例對應的 network namespace。這是因為 ip netns 命令是從 /var/run/netns 文件夾中讀取內容的。
- 找到容器的主進程 ID
root@devstack:/home/sammy# docker inspect --format '{{.State.Pid}}' web5 2704
- 創建 /var/run/netns 目錄以及符號連接
root@devstack:/home/sammy# mkdir /var/run/netns root@devstack:/home/sammy# ln -s /proc/2704/ns/net /var/run/netns/web5
- 此時可以使用 ip netns 命令了
root@devstack:/home/sammy# ip netns web5 root@devstack:/home/sammy# ip netns exec web5 ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 15: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default link/ether 02:42:ac:11:00:03 brd ff:ff:ff:ff:ff:ff inet 172.17.0.3/16 scope global eth0 valid_lft forever preferred_lft forever inet6 fe80::42:acff:fe11:3/64 scope link valid_lft forever preferred_lft forever
MNT Namespace
mount namespace可以讓看到系統中所有掛載點在某個范圍下的目錄視圖。人們經常把它和在chroot中禁錮進程混淆在一起,或者是認為他們是相似的,還有人說容器使用mount namespac來把進程禁錮在它的根文件系統中,這都是不對的!
讓我們再來拷貝一份skeleton.c用來做掛載相關的修改。可以快速的構建和運行, 從而看下執行mount命令后我們當前的掛載點的樣子
> cp skeleton.c mount.c > gcc -o mount mount.c > ./mount mount proc on /proc type proc (rw,nosuid,nodev,noexec,relatime) tmpfs on /dev type tmpfs (rw,nosuid,mode=755) shm on /dev/shm type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k) mqueue on /dev/mqueue type mqueue (rw,nosuid,nodev,noexec,relatime) devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666) sysfs on /sys type sysfs (rw,nosuid,nodev,noexec,relatime) /dev/disk/by-uuid/d3aa2880-c290-4586-9da6-2f526e381f41 on /etc/resolv.conf type ext4 (rw,relatime,errors=remount-ro,data=ordered) /dev/disk/by-uuid/d3aa2880-c290-4586-9da6-2f526e381f41 on /etc/hostname type ext4 (rw,relatime,errors=remount-ro,data=ordered) /dev/disk/by-uuid/d3aa2880-c290-4586-9da6-2f526e381f41 on /etc/hosts type ext4 (rw,relatime,errors=remount-ro,data=ordered) devpts on /dev/console type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=000)
上面是在我的demo容器中看到的掛載點。為了創建一個新的mount namespace,我們使用CLONE_NEWNS標志位。大家可以注意到這個標志位名稱有點奇怪,為什么不是CLONE_NEWMOUNT或者CLONE_NEWMNT呢?這是因為mount namespace是Linux中的第一個命名空間,所以這里的這里的標記位參數名字有點不符合常規,經常我們在編碼實現一個新的特性或者應用的時候,我們往往不能預想到最終結果全貌。不論怎樣,我們就把 CLONE_NEWNS添加到clone_flags變量中,結果就是, int clone_flags = CLONE_NEWNS | SIGCHLD;,再來編譯mount.c並運行同樣的命令。
> cp skeleton.c mount.c > gcc -o mount mount.c > ./mount mount
這次沒有任何變化,為何?因為運行在新的mount namespace中的進程,在底層系統下仍然擁有一個/proc視圖。結果就是新的進程繼承了底層掛載點的視圖。我們有一些方法來阻止出現這樣的結果,比如說,使用 pivot_root,將在后續的博客中詳細介紹。
然而,有一種方法,我們可以嘗試在新的mount namespace上掛載點東西。比如在/mytmp下掛載新的 tmpfs。我們在C代碼中執行mount命令,並把需要新掛載的掛載點作為參數寫進去。為了達到這個目標,我們需要在 child_exec函數中調用execvp之前增加代碼,代碼如下:
// child_exec is the func that will be executed as the result of clone static int child_exec(void *stuff)
{
struct clone_args *args = (struct clone_args *)stuff;
if (mount("none", "/mytmp", "tmpfs", 0, "") != 0) { fprintf(stderr, "failed to mount tmpfs %s\n", strerror(errno));
exit(-1); }
if (execvp(args->argv[0], args->argv) != 0) {
fprintf(stderr, "failed to execvp argments %s\n", strerror(errno));
exit(-1);
} // we should never reach here!
exit(EXIT_FAILURE);
}
在編譯和執行之前,我們需要創建一個目錄/mytmp,並運行以上的改變。
> mkdir /mytmp > gcc -o mount mount.c > ./mount mount # cutting out the common output... none on /mytmp type tmpfs (rw,relatime)
這里去掉了一些常見的輸出。
從結果中就能看出,多了一個新的tmpfs掛載點。贊!繼續在當前shell下執行mount來對比下。
注意到了為何tmpfs掛載點為何沒有顯示出來了么?這是因為我們創建的掛載點是在我們自己的mount namespace下,不是在父namespace下。
前面我說過mount namespace和filesystem jail是不同的,繼續執行我們的./mount和 ls命令,就能給出證明了。
UTS Namespace
UTS namespace(UNIX Timesharing System包含了運行內核的名稱、版本、底層體系結構類型等信息)用於系統標識。包含了hostname 和域名domainname 。它使得一個容器擁有屬於自己hostname標識,這個主機名標識獨立於宿主機系統和其上的其他容器。讓我們開始,拷貝 skeleton.c 然后借助他運行hostname 命令。
> cp skeleton.c uts.c > gcc -o uts uts.c > ./uts hostname development
這個會顯示你的系統主機名(在我的案例中應該是 development)。就像先前做的,讓我們添加clone標識給UTS namespace的clone_flags
變量。這個變量值應該是CLONE_NEWUTS
。當你編譯並運行他的時候,你會看到其輸出竟然是一樣的。這些UTS namespace的值是繼承他的母系統。好吧,也就是在這個新的namespace中,我們能修改其主機名而不會對他的宿主系統和其宿主系統的其他容器造成影響,他們有隔離的UTS namespace。
讓我們在 child_exec 函數中修改hostname。為此,我們需要添加#include <unistd.h>
頭文件使其能訪問到sethostname
函數,同時還需要添加#include <string.h>
頭文件使得 setthostname函數能夠調用 strlen 函數。修改完的 child_exec 應該如下:
// child_exec is the func that will be executed as the result of clone static int child_exec(void *stuff) { struct clone_args *args = (struct clone_args *)stuff; const char * new_hostname = "myhostname"; if (sethostname(new_hostname, strlen(new_hostname)) != 0) { fprintf(stderr, "failed to execvp argments %s\n", strerror(errno)); exit(-1); } if (execvp(args->argv[0], args->argv) != 0) { fprintf(stderr, "failed to execvp argments %s\n", strerror(errno)); exit(-1); } // we should never reach here! exit(EXIT_FAILURE); }
請確保在你的main函數中的clone_flags
的變量應該是這樣的nt clone_flags = CLONE_NEWUTS | SIGCHLD;
,然后編譯並用同樣的命令參數運行。這個時候你可以看到用它執行 hostname命令的返回值。同時為了核查這個變動不會影響你當前的shell環境,我們將執行 hostname 並確認返回了先前原始的值。
> gcc -o uts uts.c > ./uts hostname myhostname > hostname development
IPC Namespace
IPC namespace用於隔離進程間通信,像SysV的消息隊列,讓我們為這個命名空間創建一個skeleton.c的副本。
> cp skeleton.c ipc.c
我們測試IPC命名空間的方法,是通過在主機上創建一個消息隊列,當我們在IPC namespace中產生一個新進程時確保我們不能看到它。讓我們首先在當前的shell中創建一個消息隊列,運行skeleton副本代碼來查看隊列。
> ipcmk -Q Message queue id: 65536 > gcc -o ipc ipc.c > ./ipc ipcs -q ------ Message Queues -------- key msqid owner perms used-bytes message 0xfe7f09d1 65536 root 644 0 0
不使用新的IPC namespace,你可以看到同樣的消息隊列被創建。現在讓我們增加CLONE_NEWIPC標簽給我們的 clone_flags變量,去為我們的進程創建一個新的IPC namespace。clone_flags變量可以看作是 int clone_flags = CLONE_NEWIPC | SIGCHLD;,重新編譯和再次執行同樣的命令:
> gcc -o ipc ipc.c > ./ipc ipcs -q ------ Message Queues -------- key msqid owner perms used-bytes message
完成!子進程現在在一個新的IPC namespace中,並擁有完全獨立的視圖,還能訪問消息隊列。
PID Namespace
這部分是非常有趣的。PID (Process Identification,OS里指進程識別號)namespace是划分那些一個進程可以查看並與之交互的PID的方式。當我們創建一個新的 PID namespace時,第一個進程的PID會被賦值為1。進程退出時,內核會殺死這個namespace內的其他進程。讓我們來通過制作skeleton.c副本開始我們的改變。
> cp skeleton.c pid.c
創建一個新的 PID namespace,我們需要設置clone_flags為 CLONE_NEWPID. 該變量應該看起來像int clone_flags = CLONE_NEWPID | SIGCHLD;,我們在shell中運行 ps aux,然后以相同的參數編譯和運行我們的pid.c二進制文件。
> ps aux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.0 0.1 20332 3388 ? Ss 21:50 0:00 bash root 147 0.0 0.1 17492 2088 ? R+ 22:49 0:00 ps aux > gcc -o pid pid.c > ./pid ps aux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.0 0.1 20332 3388 ? Ss 21:50 0:00 bash root 153 0.0 0.0 5092 728 ? S+ 22:50 0:00 ./pid ps aux root 154 0.0 0.1 17492 2064 ? R+ 22:50 0:00 ps aux
在我們預期中ps aux的PID是1,或至少看不到任何從其他父進程進來的 pid 。為什么會這樣?我們孵化的進程仍然會有一個來自父進程的/proc視圖,也就是說 /proc掛載在主機系統上。那么如何我們解決這個問題?我們如何確保我們新的進程只可以查看在它所在的namespace內的pid呢? 我們可以通過重新掛載/proc 開始。
因為我們會用mount來處理,我們可以借此機會使用從MNT namespace所了解到的內容,並結合PID namespace,以確保我們不會將其與自己的主機系統的 /proc搞混。
我們可以通過包含為PID namespace設置的clone flag和為MNT namespace設置的clone flag來啟動。他們看起來像是 int clone_flags = CLONE_NEWPID | CLONE_NEWNS | SIGCHLD;。我們需要編輯 child_exec函數和重新掛載proc。系統調用unmount和 mount即可。因為我們正在創造一個新的MNT namespace,這不會搞亂我們的主機系統。結果應如下所示:
// child_exec is the func that will be executed as the result of clone static int child_exec(void *stuff)
{
struct clone_args *args = (structclone_args *)stuff;
if (umount("/proc", 0) != 0) {
fprintf(stderr, "failedunmount /proc %s\n", strerror(errno)); exit(-1);
}
if (mount("proc", "/proc","proc", 0, "") != 0) {
fprintf(stderr, "failed mount /proc %s\n", strerror(errno)); exit(-1);
}
if (execvp(args->argv[0], args->argv) != 0) {
fprintf(stderr,"failed to execvp argments %s\n", strerror(errno));
exit(-1);
} // we should never reach here!
exit(EXIT_FAILURE);
}
再一次生成並運行看看會發生什么?
> gcc -o pid pid.c > ./pid ps aux USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 1 0.0 0.0 9076 784 ? R+ 23:05 0:00 ps aux
完美 !我們新的 PID namespace已經在MNT namespace的幫助下正常運作!
USER Namespace
User namespace是最新的子用戶空間,它允許你創建獨立於其他namespace之外的用戶。這是通過GID和UID映射實現的。
這里有一個未指定映射的實例應用程序。如果我們添加CLONE_NEWUSER
到clone_flags,然后運行id或ls -la,會得到nobody的輸出,因為當前用戶還未被創建。
> cp skeleton.c user.c # add the clone flag > gcc -o user user.c > ./user ls -la total 84 drwxr-xr-x 1 nobody nogroup 4096 Nov 16 23:10 . drwxr-xr-x 1 nobody nogroup 4096 Nov 16 22:17 .. -rwxr-xr-x 1 nobody nogroup 8336 Nov 16 22:15 mount -rw-r--r-- 1 nobody nogroup 1577 Nov 16 22:15 mount.c -rwxr-xr-x 1 nobody nogroup 8064 Nov 16 21:52 net -rw-r--r-- 1 nobody nogroup 1441 Nov 16 21:52 network.c -rwxr-xr-x 1 nobody nogroup 8544 Nov 16 23:05 pid -rw-r--r-- 1 nobody nogroup 1772 Nov 16 23:02 pid.c -rw-r--r-- 1 nobody nogroup 1426 Nov 16 21:59 skeleton.c -rwxr-xr-x 1 nobody nogroup 8056 Nov 16 23:10 user -rw-r--r-- 1 nobody nogroup 1442 Nov 16 23:10 user.c -rwxr-xr-x 1 nobody nogroup 8408 Nov 16 22:40 uts -rw-r--r-- 1 nobody nogroup 1694 Nov 16 22:36 uts.c
這是個很簡單地例子,但是細想你會發現通過user namespace,可以以root權限在容器中運行(不是主機系統中的root)。不要忘記你可以隨時更改 ls -la到bash中,並通過shell深入了解namespace。
總結
本文中,我們回顧了mount,network,user,PID,UTS和IPC Linux namespace,並沒有修改太多代碼,只是增加了一些flag。復雜的工作集中在管理多個內核子系統的交互。就像我一開始提到的,namespace只是我們用來創建容器的一種工具,我希望PID的例子能使我們理解,是如何使多個namespace協同來創建容器的。