Docker 背後的內核知識——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;
}

編譯並執行,結果如下。

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 <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 而沒有對宿主機產生任何影響,用的就是這個原理。

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
exit
已退出

打印 $$ 可以看到 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-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”啓動起來。整個效果類似下圖所示。

圖 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。

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* 等目錄下的資源。關於安全的更多討論和講解,我們會在後文中接着探討。

8. 總結

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

9. 作者簡介

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


感謝郭蕾對本文的策劃和審校。

給 InfoQ 中文站投稿或者參與內容翻譯工作,請郵件至 [email protected] 。也歡迎大家通過新浪微博( @InfoQ )或者騰訊微博( @InfoQ )關注我們,並與我們的編輯和其他讀者朋友交流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章