Docker背後的內核知識——Namespace資源隔離(上)

Docker這麼火,喜歡技術的朋友可能也會想,若是要本身實現一個資源隔離的容器,應該從哪些方面下手呢?也許你第一反應可能就是chroot命令,這條命令給用戶最直觀的感受就是使用後根目錄/的掛載點切換了,即文件系統被隔離了。而後,爲了在分佈式的環境下進行通訊和定位,容器必然須要一個獨立的IP、端口、路由等等,天然就想到了網絡的隔離。同時,你的容器還須要一個獨立的主機名以便在網絡中標識本身。想到網絡,順其天然就想到通訊,也就想到了進程間通訊的隔離。可能你也想到了權限的問題,對用戶和用戶組的隔離就實現了用戶權限的隔離。最後,運行在容器中的應用須要有本身的PID,天然也須要與宿主機中的PID進行隔離。html

由此,咱們基本上完成了一個容器所須要作的六項隔離,Linux內核中就提供了這六種namespace隔離的系統調用,以下表所示。linux

Namespace 系統調用參數 隔離內容
UTS CLONE_NEWUTS 主機名與域名
IPC CLONE_NEWIPC 信號量、消息隊列和共享內存
PID CLONE_NEWPID 進程編號
Network CLONE_NEWNET 網絡設備、網絡棧、端口等等
Mount CLONE_NEWNS 掛載點(文件系統)
User CLONE_NEWUSER 用戶和用戶組

表 namespace六項隔離docker

實際上,Linux內核實現namespace的主要目的就是爲了實現輕量級虛擬化(容器)服務。在同一個namespace下的進程能夠感知彼此的變化,而對外界的進程一無所知。這樣就可讓容器中的進程產生錯覺,彷彿本身置身於一個獨立的系統環境中,以此達到獨立和隔離的目的。shell

須要說明的是,本文所討論的namespace實現針對的均是Linux內核3.8及其之後的版本。接下來,咱們將首先介紹使用namespace的API,而後針對這六種namespace進行逐一講解,並經過程序讓你親身感覺一下這些隔離效果(參考自http://lwn.net/Articles/531114/)。安全

1. 調用namespace的APIbash

namespace的API包括clone()、setns()以及unshare(),還有/proc下的部分文件。爲了肯定隔離的究竟是哪一種namespace,在使用這些API時,一般須要指定如下六個常數的一個或多個,經過|(位或)操做來實現。你可能已經在上面的表格中注意到,這六個參數分別是CLONE_NEWIPC、CLONE_NEWNS、CLONE_NEWNET、CLONE_NEWPID、CLONE_NEWUSER和CLONE_NEWUTS。網絡

(1)經過clone()建立新進程的同時建立namespaceapp

使用clone()來建立一個獨立namespace的進程是最多見作法,它的調用方式以下。socket

int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);

clone()其實是傳統UNIX系統調用fork()的一種更通用的實現方式,它能夠經過flags來控制使用多少功能。一共有二十多種CLONE_*的flag(標誌位)參數用來控制clone進程的方方面面(如是否與父進程共享虛擬內存等等),下面外面逐一講解clone函數傳入的參數。tcp

  • 參數child_func傳入子進程運行的程序主函數

  • 參數child_stack傳入子進程使用的棧空間

  • 參數flags表示使用哪些CLONE_*標誌位

  • 參數args則可用於傳入用戶參數

在後續的內容中將會有使用clone()的實際程序可供你們參考。

(2)查看/proc/[pid]/ns文件

從3.8版本的內核開始,用戶就能夠在/proc/[pid]/ns文件下看到指向不一樣namespace號的文件,效果以下所示,形如[4026531839]者即爲namespace號。

$ ls -l /proc/$$/ns         <<-- 0="" 1="" 8="" $$="" 表示應用的pid="" total="" lrwxrwxrwx.="" mtk="" jan="" 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,而且以硬連接存在。

(3)經過setns()加入一個已經存在的namespace

上文剛提到,在進程都結束的狀況下,也能夠經過掛載的形式把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命令了,在下文中會屢次使用這種方式來演示隔離的效果。

(4)經過unshare()在原先進程上進行namespace隔離

最後要提的系統調用是unshare(),它跟clone()很像,不一樣的是,unshare()運行在原先的進程上,不須要啓動一個新進程,使用方法以下。

int unshare(int flags);

調用unshare()的主要做用就是不啓動一個新進程就能夠起到隔離的效果,至關於跳出原先的namespace進行操做。這樣,你就能夠在原進程進行一些須要隔離的操做。Linux中自帶的unshare命令,就是經過unshare()系統調用實現的,有興趣的讀者能夠在網上搜索一下這個命令的做用。

(5)延伸閱讀:fork()系統調用

系統調用函數fork()並不屬於namespace的API,因此這部份內容屬於延伸閱讀,若是讀者已經對fork()有足夠的瞭解,那大可跳過。

當程序調用fork()函數時,系統會建立新的進程,爲其分配資源,例如存儲數據和代碼的空間。而後把原來的進程的全部值都複製到新的進程中,只有少許數值與原來的進程值不一樣,至關於克隆了一個本身。那麼程序的後續代碼邏輯要如何區分本身是新進程仍是父進程呢?

fork()的神奇之處在於它僅僅被調用一次,卻可以返回兩次(父進程與子進程各返回一次),經過返回值的不一樣就能夠進行區分父進程與子進程。它可能有三種不一樣的返回值:

  • 在父進程中,fork返回新建立子進程的進程ID

  • 在子進程中,fork返回0

  • 若是出現錯誤,fork返回一個負值

下面給出一段實例代碼,命名爲fork_example.c。

#include#includeint 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進行詳細解析。

2. UTS(UNIX Time-sharing System)namespace

UTS namespace提供了主機名和域名的隔離,這樣每一個容器就能夠擁有了獨立的主機名和域名,在網絡上能夠被視做一個獨立的節點而非宿主機上的一個進程。

下面咱們經過代碼來感覺一下UTS隔離的效果,首先須要一個程序的骨架,以下所示。打開編輯器建立uts.c文件,輸入以下代碼。

#define _GNU_SOURCE
#include#include#include#include#include#include#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="" <="" pre="">

也許有讀者試着不加CLONE_NEWUTS參數運行上述代碼,發現主機名也變了,輸入exit之後主機名也會變回來,彷佛沒什麼區別。實際上不加CLONE_NEWUTS參數進行隔離而使用sethostname已經把宿主機的主機名改掉了。你看到exit退出後還原只是由於bash只在剛登陸的時候讀取一次UTS,當你從新登錄或者使用uname命令進行查看時,就會發現產生了變化。

Docker中,每一個鏡像基本都以本身所提供的服務命名了本身的hostname而沒有對宿主機產生任何影響,用的就是這個原理。

3. IPC(Interprocess Communication)namespace

容器中進程間通訊採用的方法包括常見的信號量、消息隊列和共享內存。然而與虛擬機不一樣的是,容器內部進程間通訊對宿主機來講,其實是具備相同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進行通訊。

4. PID namespace

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="" 已退出="" <="" pre="">

打印$$能夠看到shell的PID,退出後若是再次執行能夠看到效果以下。

root@local:~# echo $$
17542

已經回到了正常狀態。可能有的讀者在子進程的shell中執行了ps aux/top之類的命令,發現仍是能夠看到全部父進程的PID,那是由於咱們尚未對文件系統進行隔離,ps/top之類的命令調用的是真實系統下的/proc文件內容,看到的天然是全部的進程。

此外,與其餘的namespace不一樣的是,爲了實現一個穩定安全的容器,PID namespace還須要進行一些額外的工做才能確保其中的進程運行順利。

(1)PID namespace中的init進程

當咱們新建一個PID namespace時,默認啓動的進程PID爲1。咱們知道,在傳統的UNIX系統中,PID爲1的進程是init,地位很是特殊。他做爲全部進程的父進程,維護一張進程表,不斷檢查進程的狀態,一旦有某個子進程由於程序錯誤成爲了「孤兒」進程,init就會負責回收資源並結束這個子進程。因此在你要實現的容器中,啓動的第一個進程也須要實現相似init的功能,維護全部後續啓動進程的運行狀態。

看到這裏,可能讀者已經明白了內核設計的良苦用心。PID namespace維護這樣一個樹狀結構,很是有利於系統的資源監控與回收。Docker啓動時,第一個進程也是這樣,實現了進程監控和資源回收,它就是dockerinit。

(2)信號與init進程

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,也就是這個道理。

(3)掛載proc文件系統

前文中已經提到,若是你在新的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能夠修復錯誤。

(4)unshare()和setns()

在開篇咱們就講到了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。

5. Mount namespaces

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 --make-slave

從從屬掛載克隆的掛載對象也是從屬的掛載,它也從屬於原來的從屬掛載的主掛載對象。

將一個從屬掛載對象設置爲共享/從屬掛載,能夠執行以下命令或者將其移動到一個共享掛載對象下。

mount --make-shared

若是你想把修改過的掛載對象從新標記爲私有的,能夠執行以下命令。

mount --make-private

經過執行如下命令,能夠將掛載對象標記爲不可綁定的。

mount --make-unbindable

這些設置均可以遞歸式地應用到全部子目錄中,若是讀者感興趣能夠搜索到相關的命令。

在代碼中實現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的文件系統不會被破壞,此處就再也不演示了。

相關文章
相關標籤/搜索