本博文轉載自http://www.infoq.com/cn/articles/docker-kernel-knowledge-namespace-resource-isolationhtml
Docker這麼火,喜歡技術的朋友可能也會想,若是要本身實現一個資源隔離的容器,應該從哪些方面下手呢?也許你第一反應可能就是chroot命令,這條命令給用戶最直觀的感受就是使用後根目錄/的掛載點切換了,即文件系統被隔離了。而後,爲了在分佈式的環境下進行通訊和定位,容器必然須要一個獨立的IP、端口、路由等等,天然就想到了網絡的隔離。同時,你的容器還須要一個獨立的主機名以便在網絡中標識本身。想到網絡,順其天然就想到通訊,也就想到了進程間通訊的隔離。可能你也想到了權限的問題,對用戶和用戶組的隔離就實現了用戶權限的隔離。最後,運行在容器中的應用須要有本身的PID,天然也須要與宿主機中的PID進行隔離。linux
由此,咱們基本上完成了一個容器所須要作的六項隔離,Linux內核中就提供了這六種namespace隔離的系統調用,以下表所示。docker
Namespaceshell |
系統調用參數ubuntu |
隔離內容安全 |
UTSbash |
CLONE_NEWUTS網絡 |
主機名與域名socket |
IPCtcp |
CLONE_NEWIPC |
信號量、消息隊列和共享內存 |
PID |
CLONE_NEWPID |
進程編號 |
Network |
CLONE_NEWNET |
網絡設備、網絡棧、端口等等 |
Mount |
CLONE_NEWNS |
掛載點(文件系統) |
User |
CLONE_NEWUSER |
用戶和用戶組 |
表 namespace六項隔離
實際上,Linux內核實現namespace的主要目的就是爲了實現輕量級虛擬化(容器)服務。在同一個namespace下的進程能夠感知彼此的變化,而對外界的進程一無所知。這樣就可讓容器中的進程產生錯覺,彷彿本身置身於一個獨立的系統環境中,以此達到獨立和隔離的目的。
須要說明的是,本文所討論的namespace實現針對的均是Linux內核3.8及其之後的版本。接下來,咱們將首先介紹使用namespace的API,而後針對這六種namespace進行逐一講解,並經過程序讓你親身感覺一下這些隔離效果(參考自http://lwn.net/Articles/531114/)。
namespace的API包括clone()、setns()以及unshare(),還有/proc下的部分文件。爲了肯定隔離的究竟是哪一種namespace,在使用這些API時,一般須要指定如下六個常數的一個或多個,經過|(位或)操做來實現。你可能已經在上面的表格中注意到,這六個參數分別是CLONE_NEWIPC、CLONE_NEWNS、CLONE_NEWNET、CLONE_NEWPID、CLONE_NEWUSER和CLONE_NEWUTS。
使用clone()來建立一個獨立namespace的進程是最多見作法,它的調用方式以下。
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
clone()其實是傳統UNIX系統調用fork()的一種更通用的實現方式,它能夠經過flags來控制使用多少功能。一共有二十多種CLONE_*的flag(標誌位)參數用來控制clone進程的方方面面(如是否與父進程共享虛擬內存等等),下面外面逐一講解clone函數傳入的參數。
參數child_func傳入子進程運行的程序主函數。
參數child_stack傳入子進程使用的棧空間
參數flags表示使用哪些CLONE_*標誌位
參數args則可用於傳入用戶參數
在後續的內容中將會有使用clone()的實際程序可供你們參考。
從3.8版本的內核開始,用戶就能夠在/proc/[pid]/ns文件下看到指向不一樣namespace號的文件,效果以下所示,形如[4026531839]者即爲namespace號。
$ ls -l /proc/$$/ns <<-- $$ 表示應用的PID total 0 lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 ipc -> ipc:[4026531839] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 mnt -> mnt:[4026531840] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 net -> net:[4026531956] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 pid -> pid:[4026531836] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 user->user:[4026531837] lrwxrwxrwx. 1 mtk mtk 0 Jan 8 04:12 uts -> uts:[4026531838]
若是兩個進程指向的namespace編號相同,就說明他們在同一個namespace下,不然則在不一樣namespace裏面。/proc/[pid]/ns的另一個做用是,一旦文件被打開,只要打開的文件描述符(fd)存在,那麼就算PID所屬的全部進程都已經結束,建立的namespace就會一直存在。那如何打開文件描述符呢?把/proc/[pid]/ns目錄掛載起來就能夠達到這個效果,命令以下。
# touch ~/uts # mount --bind /proc/27514/ns/uts ~/uts
若是你看到的內容與本文所描述的不符,那麼說明你使用的內核在3.8版本之前。該目錄下存在的只有ipc、net和uts,而且以硬連接存在。
上文剛提到,在進程都結束的狀況下,也能夠經過掛載的形式把namespace保留下來,保留namespace的目的天然是爲之後有進程加入作準備。經過setns()系統調用,你的進程從原先的namespace加入咱們準備好的新namespace,使用方法以下。
int setns(int fd, int nstype);
參數fd表示咱們要加入的namespace的文件描述符。上文已經提到,它是一個指向/proc/[pid]/ns目錄的文件描述符,能夠經過直接打開該目錄下的連接或者打開一個掛載了該目錄下連接的文件獲得。
參數nstype讓調用者能夠去檢查fd指向的namespace類型是否符合咱們實際的要求。若是填0表示不檢查。
爲了把咱們建立的namespace利用起來,咱們須要引入execve()系列函數,這個函數能夠執行用戶命令,最經常使用的就是調用/bin/bash並接受參數,運行起一個shell,用法以下。
fd = open(argv[1], O_RDONLY); /* 獲取namespace文件描述符 */ setns(fd, 0); /* 加入新的namespace */ execvp(argv[2], &argv[2]); /* 執行程序 */
假設編譯後的程序名稱爲setns。
# ./setns ~/uts /bin/bash # ~/uts 是綁定的/proc/27514/ns/uts
至此,你就能夠在新的命名空間中執行shell命令了,在下文中會屢次使用這種方式來演示隔離的效果。
最後要提的系統調用是unshare(),它跟clone()很像,不一樣的是,unshare()運行在原先的進程上,不須要啓動一個新進程,使用方法以下。
int unshare(int flags);
調用unshare()的主要做用就是不啓動一個新進程就能夠起到隔離的效果,至關於跳出原先的namespace進行操做。這樣,你就能夠在原進程進行一些須要隔離的操做。Linux中自帶的unshare命令,就是經過unshare()系統調用實現的,有興趣的讀者能夠在網上搜索一下這個命令的做用。
系統調用函數fork()並不屬於namespace的API,因此這部份內容屬於延伸閱讀,若是讀者已經對fork()有足夠的瞭解,那大可跳過。
當程序調用fork()函數時,系統會建立新的進程,爲其分配資源,例如存儲數據和代碼的空間。而後把原來的進程的全部值都複製到新的進程中,只有少許數值與原來的進程值不一樣,至關於克隆了一個本身。那麼程序的後續代碼邏輯要如何區分本身是新進程仍是父進程呢?
fork()的神奇之處在於它僅僅被調用一次,卻可以返回兩次(父進程與子進程各返回一次),經過返回值的不一樣就能夠進行區分父進程與子進程。它可能有三種不一樣的返回值:
在父進程中,fork返回新建立子進程的進程ID
在子進程中,fork返回0
若是出現錯誤,fork返回一個負值
下面給出一段實例代碼,命名爲fork_example.c。
#include <unistd.h> #include <stdio.h> int main (){ pid_t fpid; //fpid表示fork函數返回的值 int count=0; fpid=fork(); if (fpid < 0)printf("error in fork!"); else if (fpid == 0) { printf("I am child. Process id is %d/n",getpid()); } else { printf("i am parent. Process id is %d/n",getpid()); } return 0; }
編譯並執行,結果以下。
root@local:~# gcc -Wall fork_example.c && ./a.out I am parent. Process id is 28365 I am child. Process id is 28366
使用fork()後,父進程有義務監控子進程的運行狀態,並在子進程退出後本身才能正常退出,不然子進程就會成爲「孤兒」進程。
下面咱們將分別對六種namespace進行詳細解析。
UTS namespace提供了主機名和域名的隔離,這樣每一個容器就能夠擁有了獨立的主機名和域名,在網絡上能夠被視做一個獨立的節點而非宿主機上的一個進程。
下面咱們經過代碼來感覺一下UTS隔離的效果,首先須要一個程序的骨架,以下所示。打開編輯器建立uts.c文件,輸入以下代碼。
#define _GNU_SOURCE #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 child_stack[STACK_SIZE]; char* const child_args[] = { "/bin/bash", NULL }; int child_main(void* args) { printf("在子進程中!\n"); execv(child_args[0], child_args); return 1; } int main() { printf("程序開始: \n"); int child_pid = clone(child_main, child_stack + STACK_SIZE, SIGCHLD, NULL); waitpid(child_pid, NULL, 0); printf("已退出\n"); return 0; }
編譯並運行上述代碼,執行以下命令,效果以下。
root@local:~# gcc -Wall uts.c -o uts.o && ./uts.o 程序開始: 在子進程中! root@local:~# exit exit 已退出 root@local:~#
下面,咱們將修改代碼,加入UTS隔離。運行代碼須要root權限,爲了防止普通用戶任意修改系統主機名致使set-user-ID相關的應用運行出錯。
//[...] int child_main(void* arg) { printf("在子進程中!\n"); sethostname("Changed Namespace", 12); execv(child_args[0], child_args); return 1; } int main() { //[...] int child_pid = clone(child_main, child_stack+STACK_SIZE, CLONE_NEWUTS | SIGCHLD, NULL); //[...] }
再次運行能夠看到hostname已經變化。
root@local:~# gcc -Wall namespace.c -o main.o && ./main.o 程序開始: 在子進程中! root@NewNamespace:~# exit exit 已退出 root@local:~# <- 回到原來的hostname
也許有讀者試着不加CLONE_NEWUTS參數運行上述代碼,發現主機名也變了,輸入exit之後主機名也會變回來,彷佛沒什麼區別。實際上不加CLONE_NEWUTS參數進行隔離而使用sethostname已經把宿主機的主機名改掉了。你看到exit退出後還原只是由於bash只在剛登陸的時候讀取一次UTS,當你從新登錄或者使用uname命令進行查看時,就會發現產生了變化。
Docker中,每一個鏡像基本都以本身所提供的服務命名了本身的hostname而沒有對宿主機產生任何影響,用的就是這個原理。
容器中進程間通訊採用的方法包括常見的信號量、消息隊列和共享內存。然而與虛擬機不一樣的是,容器內部進程間通訊對宿主機來講,其實是具備相同PID namespace中的進程間通訊,所以須要一個惟一的標識符來進行區別。申請IPC資源就申請了這樣一個全局惟一的32位ID,因此IPC namespace中實際上包含了系統IPC標識符以及實現POSIX消息隊列的文件系統。在同一個IPC namespace下的進程彼此可見,而與其餘的IPC namespace下的進程則互相不可見。
IPC namespace在代碼上的變化與UTS namespace類似,只是標識位有所變化,須要加上CLONE_NEWIPC參數。主要改動以下,其餘部位不變,程序名稱改成ipc.c。(測試方法參考自:http://crosbymichael.com/creating-containers-part-1.html)
//[...] int child_pid = clone(child_main, child_stack+STACK_SIZE, CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL); //[...]
咱們首先在shell中使用ipcmk -Q命令建立一個message queue。
root@local:~# ipcmk -Q Message queue id: 32769
經過ipcs -q能夠查看到已經開啓的message queue,序號爲32769。
root@local:~# ipcs -q ------ Message Queues -------- key msqid owner perms used-bytes messages 0x4cf5e29f 32769 root 644 0 0
而後咱們能夠編譯運行加入了IPC namespace隔離的ipc.c,在新建的子進程中調用的shell中執行ipcs -q查看message queue。
root@local:~# gcc -Wall ipc.c -o ipc.o && ./ipc.o 程序開始: 在子進程中! root@NewNamespace:~# ipcs -q ------ Message Queues -------- key msqid owner perms used-bytes messages root@NewNamespace:~# exit exit 已退出
上面的結果顯示中能夠發現,已經找不到原先聲明的message queue,實現了IPC的隔離。
目前使用IPC namespace機制的系統很少,其中比較有名的有PostgreSQL。Docker自己經過socket或tcp進行通訊。
PID namespace隔離很是實用,它對進程PID從新標號,即兩個不一樣namespace下的進程能夠有同一個PID。每一個PID namespace都有本身的計數程序。內核爲全部的PID namespace維護了一個樹狀結構,最頂層的是系統初始時建立的,咱們稱之爲root namespace。他建立的新PID namespace就稱之爲child namespace(樹的子節點),而原先的PID namespace就是新建立的PID namespace的parent namespace(樹的父節點)。經過這種方式,不一樣的PID namespaces會造成一個等級體系。所屬的父節點能夠看到子節點中的進程,並能夠經過信號等方式對子節點中的進程產生影響。反過來,子節點不能看到父節點PID namespace中的任何內容。由此產生以下結論(部份內容引自:http://blog.dotcloud.com/under-the-hood-linux-kernels-on-dotcloud-part)。
每一個PID namespace中的第一個進程「PID 1「,都會像傳統Linux中的init進程同樣擁有特權,起特殊做用。
一個namespace中的進程,不可能經過kill或ptrace影響父節點或者兄弟節點中的進程,由於其餘節點的PID在這個namespace中沒有任何意義。
若是你在新的PID namespace中從新掛載/proc文件系統,會發現其下只顯示同屬一個PID namespace中的其餘進程。
在root namespace中能夠看到全部的進程,而且遞歸包含全部子節點中的進程。
到這裏,可能你已經聯想到一種在外部監控Docker中運行程序的方法了,就是監控Docker Daemon所在的PID namespace下的全部進程即其子進程,再進行刪選便可。
下面咱們經過運行代碼來感覺一下PID namespace的隔離效果。修改上文的代碼,加入PID namespace的標識位,並把程序命名爲pid.c。
//[...] int child_pid = clone(child_main, child_stack+STACK_SIZE, CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL); //[...]
編譯運行能夠看到以下結果。
root@local:~# gcc -Wall pid.c -o pid.o && ./pid.o 程序開始: 在子進程中! root@NewNamespace:~# echo $$ 1 <<--注意此處看到shell的PID變成了1 root@NewNamespace:~# exit exit 已退出
打印$$能夠看到shell的PID,退出後若是再次執行能夠看到效果以下。
root@local:~# echo $$ 17542
已經回到了正常狀態。可能有的讀者在子進程的shell中執行了ps aux/top之類的命令,發現仍是能夠看到全部父進程的PID,那是由於咱們尚未對文件系統進行隔離,ps/top之類的命令調用的是真實系統下的/proc文件內容,看到的天然是全部的進程。
此外,與其餘的namespace不一樣的是,爲了實現一個穩定安全的容器,PID namespace還須要進行一些額外的工做才能確保其中的進程運行順利。
當咱們新建一個PID namespace時,默認啓動的進程PID爲1。咱們知道,在傳統的UNIX系統中,PID爲1的進程是init,地位很是特殊。他做爲全部進程的父進程,維護一張進程表,不斷檢查進程的狀態,一旦有某個子進程由於程序錯誤成爲了「孤兒」進程,init就會負責回收資源並結束這個子進程。因此在你要實現的容器中,啓動的第一個進程也須要實現相似init的功能,維護全部後續啓動進程的運行狀態。
看到這裏,可能讀者已經明白了內核設計的良苦用心。PID namespace維護這樣一個樹狀結構,很是有利於系統的資源監控與回收。Docker啓動時,第一個進程也是這樣,實現了進程監控和資源回收,它就是dockerinit。
PID namespace中的init進程如此特殊,天然內核也爲他賦予了特權——信號屏蔽。若是init中沒有寫處理某個信號的代碼邏輯,那麼與init在同一個PID namespace下的進程(即便有超級權限)發送給它的該信號都會被屏蔽。這個功能的主要做用是防止init進程被誤殺。
那麼其父節點PID namespace中的進程發送一樣的信號會被忽略嗎?父節點中的進程發送的信號,若是不是SIGKILL(銷燬進程)或SIGSTOP(暫停進程)也會被忽略。但若是發送SIGKILL或SIGSTOP,子節點的init會強制執行(沒法經過代碼捕捉進行特殊處理),也就是說父節點中的進程有權終止子節點中的進程。
一旦init進程被銷燬,同一PID namespace中的其餘進程也會隨之接收到SIGKILL信號而被銷燬。理論上,該PID namespace天然也就不復存在了。可是若是/proc/[pid]/ns/pid處於被掛載或者打開狀態,namespace就會被保留下來。然而,保留下來的namespace沒法經過setns()或者fork()建立進程,因此實際上並無什麼做用。
咱們常說,Docker一旦啓動就有進程在運行,不存在不包含任何進程的Docker,也就是這個道理。
前文中已經提到,若是你在新的PID namespace中使用ps命令查看,看到的仍是全部的進程,由於與PID直接相關的/proc文件系統(procfs)沒有掛載到與原/proc不一樣的位置。因此若是你只想看到PID namespace自己應該看到的進程,須要從新掛載/proc,命令以下。
root@NewNamespace:~# mount -t proc proc /proc root@NewNamespace:~# ps a PID TTY STAT TIME COMMAND 1 pts/1 S 0:00 /bin/bash 12 pts/1 R+ 0:00 ps a
能夠看到實際的PID namespace就只有兩個進程在運行。
注意:由於此時咱們沒有進行mount namespace的隔離,因此這一步操做實際上已經影響了 root namespace的文件系統,當你退出新建的PID namespace之後再執行ps a就會發現出錯,再次執行mount -t proc proc /proc能夠修復錯誤。
在開篇咱們就講到了unshare()和setns()這兩個API,而這兩個API在PID namespace中使用時,也有一些特別之處須要注意。
unshare()容許用戶在原有進程中創建namespace進行隔離。可是建立了PID namespace後,原先unshare()調用者進程並不進入新的PID namespace,接下來建立的子進程纔會進入新的namespace,這個子進程也就隨之成爲新namespace中的init進程。
相似的,調用setns()建立新PID namespace時,調用者進程也不進入新的PID namespace,而是隨後建立的子進程進入。
爲何建立其餘namespace時unshare()和setns()會直接進入新的namespace而惟獨PID namespace不是如此呢?由於調用getpid()函數獲得的PID是根據調用者所在的PID namespace而決定返回哪一個PID,進入新的PID namespace會致使PID產生變化。而對用戶態的程序和庫函數來講,他們都認爲進程的PID是一個常量,PID的變化會引發這些進程奔潰。
換句話說,一旦程序進程建立之後,那麼它的PID namespace的關係就肯定下來了,進程不會變動他們對應的PID namespace。
Mount namespace經過隔離文件系統掛載點對隔離文件系統提供支持,它是歷史上第一個Linux namespace,因此它的標識位比較特殊,就是CLONE_NEWNS。隔離後,不一樣mount namespace中的文件結構發生變化也互不影響。你能夠經過/proc/[pid]/mounts查看到全部掛載在當前namespace中的文件系統,還能夠經過/proc/[pid]/mountstats看到mount namespace中文件設備的統計信息,包括掛載文件的名字、文件系統類型、掛載位置等等。
進程在建立mount namespace時,會把當前的文件結構複製給新的namespace。新namespace中的全部mount操做都隻影響自身的文件系統,而對外界不會產生任何影響。這樣作很是嚴格地實現了隔離,可是某些狀況可能並不適用。好比父節點namespace中的進程掛載了一張CD-ROM,這時子節點namespace拷貝的目錄結構就沒法自動掛載上這張CD-ROM,由於這種操做會影響到父節點的文件系統。
2006 年引入的掛載傳播(mount propagation)解決了這個問題,掛載傳播定義了掛載對象(mount object)之間的關係,系統用這些關係決定任何掛載對象中的掛載事件如何傳播到其餘掛載對象(參考自:http://www.ibm.com/developerworks/library/l-mount-namespaces/)。所謂傳播事件,是指由一個掛載對象的狀態變化致使的其它掛載對象的掛載與解除掛載動做的事件。
共享關係(share relationship)。若是兩個掛載對象具備共享關係,那麼一個掛載對象中的掛載事件會傳播到另外一個掛載對象,反之亦然。
從屬關係(slave relationship)。若是兩個掛載對象造成從屬關係,那麼一個掛載對象中的掛載事件會傳播到另外一個掛載對象,可是反過來不行;在這種關係中,從屬對象是事件的接收者。
一個掛載狀態可能爲以下的其中一種:
共享掛載(shared)
從屬掛載(slave)
共享/從屬掛載(shared and slave)
私有掛載(private)
不可綁定掛載(unbindable)
傳播事件的掛載對象稱爲共享掛載(shared mount);接收傳播事件的掛載對象稱爲從屬掛載(slave mount)。既不傳播也不接收傳播事件的掛載對象稱爲私有掛載(private mount)。另外一種特殊的掛載對象稱爲不可綁定的掛載(unbindable mount),它們與私有掛載類似,可是不容許執行綁定掛載,即建立mount namespace時這塊文件對象不可被複制。
圖1 mount各種掛載狀態示意圖
共享掛載的應用場景很是明顯,就是爲了文件數據的共享所必須存在的一種掛載方式;從屬掛載更大的意義在於某些「只讀」場景;私有掛載其實就是純粹的隔離,做爲一個獨立的個體而存在;不可綁定掛載則有助於防止沒有必要的文件拷貝,如某個用戶數據目錄,當根目錄被遞歸式的複製時,用戶目錄不管從隱私仍是實際用途考慮都須要有一個不可被複制的選項。
默認狀況下,全部掛載都是私有的。設置爲共享掛載的命令以下。
mount --make-shared <mount-object>
從共享掛載克隆的掛載對象也是共享的掛載;它們相互傳播掛載事件。
設置爲從屬掛載的命令以下。
mount --make-slave <shared-mount-object>
從從屬掛載克隆的掛載對象也是從屬的掛載,它也從屬於原來的從屬掛載的主掛載對象。
將一個從屬掛載對象設置爲共享/從屬掛載,能夠執行以下命令或者將其移動到一個共享掛載對象下。
mount --make-shared <slave-mount-object>
若是你想把修改過的掛載對象從新標記爲私有的,能夠執行以下命令。
mount --make-private <mount-object>
經過執行如下命令,能夠將掛載對象標記爲不可綁定的。
mount --make-unbindable <mount-object>
這些設置均可以遞歸式地應用到全部子目錄中,若是讀者感興趣能夠搜索到相關的命令。
在代碼中實現mount namespace隔離與其餘namespace相似,加上CLONE_NEWNS標識位便可。讓咱們再次修改代碼,而且另存爲mount.c進行編譯運行。
//[...] int child_pid = clone(child_main, child_stack+STACK_SIZE, CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL); //[...]
執行的效果就如同PID namespace一節中「掛載proc文件系統」的執行結果,區別就是退出mount namespace之後,root namespace的文件系統不會被破壞,此處就再也不演示了。
經過上節,咱們瞭解了PID namespace,當咱們興致勃勃地在新建的namespace中啓動一個「Apache」進程時,卻出現了「80端口已被佔用」的錯誤,原來主機上已經運行了一個「Apache」進程。怎麼辦?這就須要用到network namespace技術進行網絡隔離啦。
Network namespace主要提供了關於網絡資源的隔離,包括網絡設備、IPv4和IPv6協議棧、IP路由表、防火牆、/proc/net目錄、/sys/class/net目錄、端口(socket)等等。一個物理的網絡設備最多存在在一個network namespace中,你能夠經過建立veth pair(虛擬網絡設備對:有兩端,相似管道,若是數據從一端傳入另外一端也能接收到,反之亦然)在不一樣的network namespace間建立通道,以此達到通訊的目的。
通常狀況下,物理網絡設備都分配在最初的root namespace(表示系統默認的namespace,在PID namespace中已經說起)中。可是若是你有多塊物理網卡,也能夠把其中一塊或多塊分配給新建立的network namespace。須要注意的是,當新建立的network namespace被釋放時(全部內部的進程都終止而且namespace文件沒有被掛載或打開),在這個namespace中的物理網卡會返回到root namespace而非建立該進程的父進程所在的network namespace。
當咱們說到network namespace時,其實咱們指的未必是真正的網絡隔離,而是把網絡獨立出來,給外部用戶一種透明的感受,彷彿跟另一個網絡實體在進行通訊。爲了達到這個目的,容器的經典作法就是建立一個veth pair,一端放置在新的namespace中,一般命名爲eth0,一端放在原先的namespace中鏈接物理網絡設備,再經過網橋把別的設備鏈接進來或者進行路由轉發,以此網絡實現通訊的目的。
也許有讀者會好奇,在創建起veth pair以前,新舊namespace該如何通訊呢?答案是pipe(管道)。咱們以Docker Daemon在啓動容器dockerinit的過程爲例。Docker Daemon在宿主機上負責建立這個veth pair,經過netlink調用,把一端綁定到docker0網橋上,一端連進新建的network namespace進程中。創建的過程當中,Docker Daemon和dockerinit就經過pipe進行通訊,當Docker Daemon完成veth-pair的建立以前,dockerinit在管道的另外一端循環等待,直到管道另外一端傳來Docker Daemon關於veth設備的信息,並關閉管道。dockerinit才結束等待的過程,並把它的「eth0」啓動起來。整個效果相似下圖所示。
圖2 Docker網絡示意圖
跟其餘namespace相似,對network namespace的使用其實就是在建立的時候添加CLONE_NEWNET標識位。也能夠經過命令行工具ip建立network namespace。在代碼中創建和測試network namespace較爲複雜,因此下文主要經過ip命令直觀的感覺整個network namespace網絡創建和配置的過程。
首先咱們能夠建立一個命名爲test_ns的network namespace。
# ip netns add test_ns
當ip命令工具建立一個network namespace時,會默認建立一個迴環設備(loopback interface:lo),並在/var/run/netns目錄下綁定一個掛載點,這就保證了就算network namespace中沒有進程在運行也不會被釋放,也給系統管理員對新建立的network namespace進行配置提供了充足的時間。
經過ip netns exec命令能夠在新建立的network namespace下運行網絡管理命令。
# ip netns exec test_ns ip link list 3: lo: <LOOPBACK> mtu 16436 qdisc noop state DOWN link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
上面的命令爲咱們展現了新建的namespace下可見的網絡連接,能夠看到狀態是DOWN,須要再經過命令去啓動。能夠看到,此時執行ping命令是無效的。
# ip netns exec test_ns ping 127.0.0.1 connect: Network is unreachable
啓動命令以下,能夠看到啓動後再測試就能夠ping通。
# ip netns exec test_ns ip link set dev lo up # ip netns exec test_ns ping 127.0.0.1 PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data. 64 bytes from 127.0.0.1: icmp_req=1 ttl=64 time=0.050 ms ...
這樣只是啓動了本地的迴環,要實現與外部namespace進行通訊還須要再建一個網絡設備對,命令以下。
# ip link add veth0 type veth peer name veth1 # ip link set veth1 netns test_ns # ip netns exec test_ns ifconfig veth1 10.1.1.1/24 up # ifconfig veth0 10.1.1.2/24 up
第一條命令建立了一個網絡設備對,全部發送到veth0的包veth1也能接收到,反之亦然。
第二條命令則是把veth1這一端分配到test_ns這個network namespace。
第3、第四條命令分別給test_ns內部和外部的網絡設備配置IP,veth1的IP爲10.1.1.1,veth0的IP爲10.1.1.2。
此時兩邊就能夠互相連通了,效果以下。
# ping 10.1.1.1 PING 10.1.1.1 (10.1.1.1) 56(84) bytes of data. 64 bytes from 10.1.1.1: icmp_req=1 ttl=64 time=0.095 ms ... # ip netns exec test_ns ping 10.1.1.2 PING 10.1.1.2 (10.1.1.2) 56(84) bytes of data. 64 bytes from 10.1.1.2: icmp_req=1 ttl=64 time=0.049 ms ...
讀者有興趣能夠經過下面的命令查看,新的test_ns有着本身獨立的路由和iptables。
ip netns exec test_ns route ip netns exec test_ns iptables -L
路由表中只有一條通向10.1.1.2的規則,此時若是要鏈接外網確定是不可能的,你能夠經過創建網橋或者NAT映射來決定這個問題。若是你對此很是感興趣,能夠閱讀Docker網絡相關文章進行更深刻的講解。
作完這些實驗,你還能夠經過下面的命令刪除這個network namespace。
# ip netns delete netns1
這條命令會移除以前的掛載,可是若是namespace自己還有進程運行,namespace還會存在下去,直到進程運行結束。
經過network namespace咱們能夠了解到,實際上內核建立了network namespace之後,真的是獲得了一個被隔離的網絡。可是咱們實際上須要的不是這種徹底的隔離,而是一個對用戶來講透明獨立的網絡實體,咱們須要與這個實體通訊。因此Docker的網絡在起步階段給人一種很是難用的感受,由於一切都要本身去實現、去配置。你須要一個網橋或者NAT鏈接廣域網,你須要配置路由規則與宿主機中其餘容器進行必要的隔離,你甚至還須要配置防火牆以保證安全等等。所幸這一切已經有了較爲成熟的方案,咱們會在Docker網絡部分進行詳細的講解。
User namespace主要隔離了安全相關的標識符(identifiers)和屬性(attributes),包括用戶ID、用戶組ID、root目錄、key(指密鑰)以及特殊權限。說得通俗一點,一個普通用戶的進程經過clone()建立的新進程在新user namespace中能夠擁有不一樣的用戶和用戶組。這意味着一個進程在容器外屬於一個沒有特權的普通用戶,可是他建立的容器進程卻屬於擁有全部權限的超級用戶,這個技術爲容器提供了極大的自由。
User namespace是目前的六個namespace中最後一個支持的,而且直到Linux內核3.8版本的時候還未徹底實現(還有部分文件系統不支持)。由於user namespace實際上並不算徹底成熟,不少發行版擔憂安全問題,在編譯內核的時候並未開啓USER_NS。實際上目前Docker也還不支持user namespace,可是預留了相應接口,相信在不久後就會支持這一特性。因此在進行接下來的代碼實驗時,請確保你係統的Linux內核版本高於3.8而且內核編譯時開啓了USER_NS(若是你不會選擇,可使用Ubuntu14.04)。
Linux中,特權用戶的user ID就是0,演示的最終咱們將看到user ID非0的進程啓動user namespace後user ID能夠變爲0。使用user namespace的方法跟別的namespace相同,即調用clone()或unshare()時加入CLONE_NEWUSER標識位。老樣子,修改代碼並另存爲userns.c,爲了看到用戶權限(Capabilities),可能你還須要安裝一下libcap-dev包。
首先包含如下頭文件以調用Capabilities包。
#include <sys/capability.h>
其次在子進程函數中加入geteuid()和getegid()獲得namespace內部的user ID,其次經過cap_get_proc()獲得當前進程的用戶擁有的權限,並經過cap_to_text()輸出。
int child_main(void* args) { printf("在子進程中!\n"); cap_t caps; printf("eUID = %ld; eGID = %ld; ", (long) geteuid(), (long) getegid()); caps = cap_get_proc(); printf("capabilities: %s\n", cap_to_text(caps, NULL)); execv(child_args[0], child_args); return 1; }
在主函數的clone()調用中加入咱們熟悉的標識符。
//[...] int child_pid = clone(child_main, child_stack+STACK_SIZE, CLONE_NEWUSER | SIGCHLD, NULL); //[...]
至此,第一部分的代碼修改就結束了。在編譯以前咱們先查看一下當前用戶的uid和guid,請注意此時咱們是普通用戶。
$ id -u 1000 $ id -g 1000
而後咱們開始編譯運行,並進行新建的user namespace,你會發現shell提示符前的用戶名已經變爲nobody。
sun@ubuntu$ gcc userns.c -Wall -lcap -o userns.o && ./userns.o 程序開始: 在子進程中! eUID = 65534; eGID = 65534; capabilities: = cap_chown,cap_dac_override,[...]37+ep <<--此處省略部分輸出,已擁有所有權限 nobody@ubuntu$
經過驗證咱們能夠獲得如下信息。
user namespace被建立後,第一個進程被賦予了該namespace中的所有權限,這樣這個init進程就能夠完成全部必要的初始化工做,而不會因權限不足而出現錯誤。
咱們看到namespace內部看到的UID和GID已經與外部不一樣了,默認顯示爲65534,表示還沒有與外部namespace用戶映射。咱們須要對user namespace內部的這個初始user和其外部namespace某個用戶創建映射,這樣能夠保證當涉及到一些對外部namespace的操做時,系統能夠檢驗其權限(好比發送一個信號或操做某個文件)。一樣用戶組也要創建映射。
還有一點雖然不能從輸出中看出來,可是值得注意。用戶在新namespace中有所有權限,可是他在建立他的父namespace中不含任何權限。就算調用和建立他的進程有所有權限也是如此。因此哪怕是root用戶調用了clone()在user namespace中建立出的新用戶在外部也沒有任何權限。
最後,user namespace的建立實際上是一個層層嵌套的樹狀結構。最上層的根節點就是root namespace,新建立的每一個user namespace都有一個父節點user namespace以及零個或多個子節點user namespace,這一點與PID namespace很是類似。
接下來咱們就要進行用戶綁定操做,經過在/proc/[pid]/uid_map和/proc/[pid]/gid_map兩個文件中寫入對應的綁定信息能夠實現這一點,格式以下。
ID-inside-ns ID-outside-ns length
寫這兩個文件須要注意如下幾點。
這兩個文件只容許由擁有該user namespace中CAP_SETUID權限的進程寫入一次,不容許修改。
寫入的進程必須是該user namespace的父namespace或者子namespace。
第一個字段ID-inside-ns表示新建的user namespace中對應的user/group ID,第二個字段ID-outside-ns表示namespace外部映射的user/group ID。最後一個字段表示映射範圍,一般填1,表示只映射一個,若是填大於1的值,則按順序創建一一映射。
明白了上述原理,咱們再次修改代碼,添加設置uid和guid的函數。
//[...] void set_uid_map(pid_t pid, int inside_id, int outside_id, int length) { char path[256]; sprintf(path, "/proc/%d/uid_map", getpid()); FILE* uid_map = fopen(path, "w"); fprintf(uid_map, "%d %d %d", inside_id, outside_id, length); fclose(uid_map); } void set_gid_map(pid_t pid, int inside_id, int outside_id, int length) { char path[256]; sprintf(path, "/proc/%d/gid_map", getpid()); FILE* gid_map = fopen(path, "w"); fprintf(gid_map, "%d %d %d", inside_id, outside_id, length); fclose(gid_map); } int child_main(void* args) { cap_t caps; printf("在子進程中!\n"); set_uid_map(getpid(), 0, 1000, 1); set_gid_map(getpid(), 0, 1000, 1); printf("eUID = %ld; eGID = %ld; ", (long) geteuid(), (long) getegid()); caps = cap_get_proc(); printf("capabilities: %s\n", cap_to_text(caps, NULL)); execv(child_args[0], child_args); return 1; } //[...]
編譯後便可看到user已經變成了root。
$ gcc userns.c -Wall -lcap -o usernc.o && ./usernc.o 程序開始: 在子進程中! eUID = 0; eGID = 0; capabilities: = [...],37+ep root@ubuntu:~#
至此,你就已經完成了綁定的工做,能夠看到演示全程都是在普通用戶下執行的。最終實現了在user namespace中成爲了root而對應到外面的是一個uid爲1000的普通用戶。
若是你要把user namespace與其餘namespace混合使用,那麼依舊須要root權限。解決方案能夠是先以普通用戶身份建立user namespace,而後在新建的namespace中做爲root再clone()進程加入其餘類型的namespace隔離。
講完了user namespace,咱們再來談談Docker。雖然Docker目前還沒有使用user namespace,可是他用到了咱們在user namespace中說起的Capabilities機制。從內核2.2版本開始,Linux把原來和超級用戶相關的高級權限劃分紅爲不一樣的單元,稱爲Capability。這樣管理員就能夠獨立對特定的Capability進行使能或禁止。Docker雖然沒有使用user namespace,可是他能夠禁用容器中不須要的Capability,一次在必定程度上增強容器安全性。
固然,說到安全,namespace的六項隔離看似全面,實際上依舊沒有徹底隔離Linux的資源,好比SELinux、 Cgroups以及/sys、/proc/sys、/dev/sd*等目錄下的資源。關於安全的更多討論和講解,咱們會在後文中接着探討。
本文從namespace使用的API開始,結合Docker逐步對六個namespace進行講解。相信把講解過程當中全部的代碼整合起來,你也能實現一個屬於本身的「shell」容器了。雖然namespace技術使用起來很是簡單,可是要真正把容器作到安全易用卻並不是易事。PID namespace中,咱們要實現一個完善的init進程來維護好全部進程;network namespace中,咱們還有複雜的路由表和iptables規則沒有配置;user namespace中還有不少權限上的問題須要考慮等等。其中有些方面Docker已經作的很好,有些方面也纔剛剛開始。但願經過本文,能爲你們更好的理解Docker背後運行的原理提供幫助。