天天看點

Docker背後的核心知識——LXC Namespace資源隔離

Docker這麼火,喜歡技術的朋友可能也會想,如果要自己實作一個資源隔離的容器,應該從哪些方面下手呢?也許你第一反應可能就是chroot指令,這條指令給使用者最直覺的感覺就是使用後根目錄/的挂載點切換了,即檔案系統被隔離了。然後,為了在分布式的環境下進行通信和定位,容器必然需要一個獨立的IP、端口、路由等等,自然就想到了網絡的隔離。同時,你的容器還需要一個獨立的主機名以便在網絡中辨別自己。想到網絡,順其自然就想到通信,也就想到了程序間通信的隔離。可能你也想到了權限的問題,對使用者和使用者組的隔離就實作了使用者權限的隔離。最後,運作在容器中的應用需要有自己的PID,自然也需要與主控端中的PID進行隔離。

由此,我們基本上完成了一個容器所需要做的六項隔離,Linux核心中就提供了這六種namespace隔離的系統調用,如下表所示。

Namespace 系統調用參數 隔離内容
UTS CLONE_NEWUTS 主機名與域名
IPC 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/)。

1. 調用namespace的API

namespace的API包括clone()、setns()以及unshare(),還有/proc下的部分檔案。為了确定隔離的到底是哪種namespace,在使用這些API時,通常需要指定以下六個常數的一個或多個,通過|(位或)操作來實作。你可能已經在上面的表格中注意到,這六個參數分别是CLONE_NEWIPC、CLONE_NEWNS、CLONE_NEWNET、CLONE_NEWPID、CLONE_NEWUSER和CLONE_NEWUTS。

(1)通過clone()建立新程序的同時建立namespace

使用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()的實際程式可供大家參考。

(2)檢視/proc/[pid]/ns檔案

從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,并且以硬連結存在。

(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 <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;
}      

編譯并執行,結果如下。

[email protected]:~# 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 <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;
}      

編譯并運作上述代碼,執行如下指令,效果如下。

[email protected]:~# gcc -Wall uts.c -o uts.o && ./uts.o
程式開始:
在子程序中!
[email protected]:~# exit
exit
已退出
[email protected]:~#      

下面,我們将修改代碼,加入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已經變化。

[email protected]:~# gcc -Wall namespace.c -o main.o && ./main.o
程式開始:
在子程序中!
[email protected]:~# exit
exit
已退出
[email protected]:~#  <- 回到原來的hostname      

也許有讀者試着不加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。

[email protected]:~# ipcmk -Q
Message queue id: 32769      

通過ipcs -q可以檢視到已經開啟的message queue,序号為32769。

[email protected]:~# 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。

[email protected]:~# gcc -Wall ipc.c -o ipc.o && ./ipc.o
程式開始:
在子程序中!
[email protected]:~# ipcs -q
------ Message Queues --------
key   msqid   owner   perms   used-bytes   messages
[email protected]:~# 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);
//[...]      

編譯運作可以看到如下結果。

[email protected]:~# gcc -Wall pid.c -o pid.o && ./pid.o
程式開始:
在子程序中!
[email protected]:~# echo $$
1                      <<--注意此處看到shell的PID變成了1
[email protected]:~# exit
exit
已退出      

列印$$可以看到shell的PID,退出後如果再次執行可以看到效果如下。

[email protected]:~# 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,指令如下。

[email protected]:~# mount -t proc proc /proc
[email protected]:~# 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時這塊檔案對象不可被複制。

Docker背後的核心知識——LXC 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的檔案系統不會被破壞,此處就不再示範了。

6. Network 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”啟動起來。整個效果類似下圖所示。

Docker背後的核心知識——LXC Namespace資源隔離

圖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。
  • 第三、第四條指令分别給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網絡部分進行詳細的講解。

7. User namespaces

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。

[email protected]$ gcc userns.c -Wall -lcap -o userns.o && ./userns.o
程式開始:
在子程序中!
eUID = 65534;  eGID = 65534;  capabilities: = cap_chown,cap_dac_override,[...]37+ep  <<--此處省略部分輸出,已擁有全部權限
[email protected]$       

通過驗證我們可以得到以下資訊。

  • 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
[email protected]:~#      

至此,你就已經完成了綁定的工作,可以看到示範全程都是在普通使用者下執行的。最終實作了在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*等目錄下的資源。關于安全的更多讨論和講解,我們會在後文中接着探讨。

8. 總結

本文從namespace使用的API開始,結合Docker逐漸對六個namespace進行講解。相信把講解過程中所有的代碼整合起來,你也能實作一個屬于自己的“shell”容器了。雖然namespace技術使用起來非常簡單,但是要真正把容器做到安全易用卻并非易事。PID namespace中,我們要實作一個完善的init程序來維護好所有程序;network namespace中,我們還有複雜的路由表和iptables規則沒有配置;user namespace中還有很多權限上的問題需要考慮等等。其中有些方面Docker已經做的很好,有些方面也才剛剛開始。希望通過本文,能為大家更好的了解Docker背後運作的原理提供幫助。

9. 作者簡介

孫健波,浙江大學SEL實驗室碩士研究所學生,目前在雲平台團隊從事科研和開發工作。浙大團隊對PaaS、Docker、大資料和主流開源雲計算技術有深入的研究和二次開發經驗,團隊現将部分技術文章貢獻出來,希望能對讀者有所幫助