Docker背后的內核知識
當談論Docker時,常常會聊到Docker的實現方式。很多開發者都知道,Docker容器本質上是宿主機上的進程。Docker通過namespace實現了資源隔離。通過cgroups實現了資源限制,通過寫時復制機制實現了高效的文件操作。但更進一步深入namespace和cgroups等技術細節時,大部分開發者都會感到茫然無措。所以在這里,先帶領大家走進Linux內核,了解namespace和cgroups的技術細節。
namespace資源隔離
想要要實現一個資源隔離的容器,應該從哪些方面入手呢?可能有人第一反應可能就是chroot命令,這條命令給用戶最直觀的感覺就是使用后根目錄/的掛載點切換了,即文件系統被隔離了。接着,為了在分布式的環境下進行通信和定位,容器必然需要一個獨立的IP、端口、路由等,自然就想到了網絡的隔離。同時,容器還需要一個獨立的主機名以便在網絡中標識自己。想到網絡,順其自然就想到通信,也就想到了進程間通信需要隔離。開發者可能也想到了權限的問題,對用戶和用戶組的隔離就實現了用戶權限的隔離。最后,運行在容器中的應用需要有自己的(PID),自然也需要與宿主機中的PID進行隔離。
由此,基本上完成了一個容器所需要做的六項隔離,Linux內核中就提供了這六種namespace隔離的系統調用,如表1-1所示。
Namespace | 系統調用參數 | 隔離內容 |
UTS | CLONE_NEWUTS | 主機名與域名 |
IPC | CLONE_NEWIPC | 信號量、消息隊列和共享內存 |
PID | CLONE_NEWPID | 進程編號 |
Network | CLONE_NEWNET | 網絡設備、網絡棧、端口等等 |
Mount | CLONE_NEWNS | 掛載點(文件系統) |
User | CLONE_NEWUSER | 用戶和用戶組 |
實際上,Linux內核實現namespace的一個主要目的,就是為了實現輕量級虛擬化(容器)服務。在同一個namespace下的進程可以感知彼此的變化,而對外界的進程一無所知。這樣就可以讓容器中的進程產生錯覺,仿佛自己置身於一個獨立的系統環境中,以此達到獨立和隔離的目的。
1.進行namespace API操作的四種方式
namespace的API包括clone()、setns()以及unshare(),還有/proc下的部分文件。為了確定隔離的到底是哪6項namespace,在使用這些API時,通常需要指定以下六個常數的一個或多個,通過|(位或)操作來實現。從表1-1可知,這六個參數分別是CLONE_NEWIPC、CLONE_NEWNS、CLONE_NEWNET、CLONE_NEWPID、CLONE_NEWUSER和CLONE_NEWUTS。
通過clone()創建新進程的同時創建namespace
使用clone()來創建一個獨立namespace的進程是最常見做法,也是Docker使用namespace最基本的方法,它的調用方式如下:
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
clone()實際上是Linux系統調用fork()的一種更通用的實現方式,它可以通過flags來控制使用多少功能。一共有二十多種CLONE_*的flag(標志位)參數用來控制clone進程的方方面面(如是否與父進程共享虛擬內存等等),下面挑選與namespace相關的四個參數進行說明。
- child_func傳入子進程運行的程序主函數。
- child_stack傳入子進程使用的棧空間。
- flags表示使用哪些CLONE_*標志位,與namespace相關的主要包括CLONE_NEWIPC、CLONE_NEWNS、CLONE_NEWNET、CLONE_NEWPID、CLONE_NEWUSER和、CLONE_NEWUTS。
- args則可用於傳入用戶參數。
查看/proc/[pid]/ns文件
從3.8版本的內核開始,用戶就可以在/proc/[pid]/ns文件下看到指向不同namespace號的文件,效果如下所示,形如[4026531839]者即為namespace號。
# ls -l /proc/$$/ns #<<-- $$是shell中表示當前運行的進程ID號 total 0 lrwxrwxrwx 1 root root 0 Nov 24 10:18 ipc -> ipc:[4026531839] lrwxrwxrwx 1 root root 0 Nov 24 10:18 mnt -> mnt:[4026531840] lrwxrwxrwx 1 root root 0 Nov 24 10:18 net -> net:[4026531956] lrwxrwxrwx 1 root root 0 Nov 24 10:18 pid -> pid:[4026531836] lrwxrwxrwx 1 root root 0 Nov 24 10:18 user -> user:[4026531837] lrwxrwxrwx 1 root root 0 Nov 24 10:18 uts -> uts:[4026531838]
如果兩個進程指向的namespace編號相同,就說明他們在同一個namespace下,否則則在不同namespace里面。/proc/[pid]/ns里設置這些link的另一個作用是,一旦上述link文件被打開,只要打開的文件描述符(fd)存在,那么就算該namespace下的所有進程都已經結束,創建的namespace就會一直存在,后續進程也可以再加入進來。在Docker中,通過文件描述符定位和加入一個存在的namespace是最基本的方式。
另外,把/proc/[pid]/ns目錄文件使用--bind掛載起來就可以達到打開文件描述符的效果,命令如下:
# touch ~/uts # mount --bind /proc/27514/ns/uts ~/uts
通過setns()加入一個已經存在的namespace
上面提到,在進程都結束的情況下,也可以通過掛載的形式把namespace保留下來,保留namespace的目的自然是為以后有進程加入做准備。在Docker中,使用docker exec命令在已經運行着的容器中執行一個新的命令,就需要用到該方法。通過setns()系統調用,進程的pid namespace加入某個已經存在的namespace,使用方法如下。
int setns(int fd, int nstype);
- 參數fd表示要加入的namespace的文件描述符。上面提到,它是一個指向/proc/[pid]/ns目錄的文件描述符,可以通過直接打開該目錄下的鏈接或者打開一個掛載了該目錄下鏈接的文件得到。
- 參數nstype讓調用者可以去檢查fd指向的namespace類型是否符合我們實際的要求。該參數為0表示不檢查。
通常為了不影響進程的調用者,也為了使新加入的pid namespace生效,會正在setns()函數執行后使用clone()創建子進程繼續執行命令,讓原先的進程結束運行。
fd = open(argv[1], O_RDONLY); /* 獲取namespace文件描述符 */ setns(fd, 0); /* 加入新的namespace */ execvp(argv[2], &argv[2]); /* 執行程序 */
假設編譯后的程序名稱為setns-test:
# ./setns-test ~/uts /bin/bash # ~/uts 是綁定的/proc/27514/ns/uts
至此,就可以在新的namespace(名字空間)中執行shell命令了,下面會多次使用這種方式來演示隔離的效果。
通過unshare()在原先進程上進行namespace隔離
最后要說明的系統調用是unshare(),它跟clone()很像,不同的是,unshare()運行在原先的進程上,不需要啟動一個新進程。
int unshare(int flags);
調用unshare()的主要作用就是,不啟動新進程就可以起到隔離的效果,相當於跳出原先的namespace進行操作。這樣,就可以在原進程進行一些需要隔離的操作。Linux中自帶的unshare命令,就是通過unshare()系統調用實現的。
fork()系統調用
系統調用函數fork()並不屬於namespace的API,當程序調用fork()函數時,系統會創建新的進程,為其分配資源,例如存儲數據和代碼的空間。然后把原來的進程的所有值都復制到新的進程中,只有少量數值與原來的進程值不同,相當於復制了本身。那么程序的后續代碼邏輯要如何區分自己是新進程還是父進程呢?
fork()的神奇之處在於它僅僅被調用一次,卻能夠返回兩次(父進程與子進程各返回一次),通過返回值的不同就可以進行區分父進程與子進程。它可能有三種不同的返回值:
- 在父進程中,fork返回新創建子進程的進程ID;
- 在子進程中,fork返回0;
- 如果出現錯誤,fork返回一個負值;
下面給出一段實例代碼,命名為fork_example.c:
#include <unistd.h> #include <stdio.h> int main() { pid_t fpid; //fpid表示fork函數返回的值 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
代碼執行過程中,在語句fpid=fork()之前,只有一個進程在執行這段代碼,在這條語句之后,就變成父進程和子進程同時執行了。這兩個進程幾乎完全相同,將要執行的下一條語句都是if (fpid < 0),同時fpid = fork()的返回值會依據所屬進程返回不同的值。
使用fork()后,父進程有義務監控子進程的運行狀態,並在子進程退出后自己才能正常退出,否則子進程就會成為“孤兒”進程。
下面根據Docker內部對namespace資源隔離使用的方式分別對六種namespace進行介紹。
2.UTS namespace
UTS(UNIX Time-sharing System)namespace提供了主機名和域名的隔離,這樣每個Docker容器就可以擁有獨立的主機名和域名,在網絡上可以被視作一個獨立的節點,而非宿主機上的一個進程。在Docker中,每個鏡像基本都以自身所提供的服務名稱來命名鏡像的hostname,且不會對宿主機產生任何影響,其原理就是利用了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, CLONE_NEWUTS | 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 *args) { printf("在子進程中!\n"); sethostname("NewNamespace", 12); execv(child_args[0], child_args); return 1; }
再次編譯運行,可以看到hostname發生了變化:
root@local:~# gcc -Wall namespace.c -o main.o && ./main.o 程序開始: 在子進程中! root@NewNamespace:~# exit exit 已退出 root@local:~#
值得一提的是,也許有讀者會嘗試不加CLONE_NEWUTS參數運行上述代碼,發現主機名也變了,輸入exit后主機名也恢復了,似乎沒什么區別。實際上不加CLONE_NEWUTS參數進行隔離時,由於使用sethostname函數,已經把宿主機的主機名改掉了。而看到exit退出后還原,是因為bash只在剛登錄的時候讀取一次UTS,不會實時讀取最新的主機名,當你重新登陸或者使用uname命令進行查看時,就會發現產生了變化。
3.IPC namespace
進程間通信(Inter-Process Communication,IPC)涉及到的IPC資源包括常見的信號量、消息隊列和共享內存。然而與虛擬機不同的是,容器內部進程間通信對宿主機來說,實際上是具有相同PID namespace中的進程間通信,因此需要一個唯一的標識符來進行區別。申請IPC資源就是申請了一個全局唯一的32位ID,所以IPC namespace中實際上包含了系統IPC標識符以及實現POSIX消息隊列的文件系統。在同一個IPC namespace下的進程彼此可見,不同IPC namespace下的進程則互相不可見。
IPC namespace在代碼上的變化與UTS namespace相似,只是標識位有所變化,需要加上CLONE_NEWIPC參數。主要改動如下,其他部位不變,程序名稱改為ipc.c:
//[...] 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當前也使用IPC namespace實現了容器與宿主機、容器與容器之間的IPC隔離。
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中的任何內容,由此產生如下結論:
- 每個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中的init進程
在傳統的UNIX系統中,PID為1的進程是init,地位非常特殊。它作為所有進程的父進程,維護一張進程表,不斷檢查進程的狀態,一旦有某個子進程因為父進程錯誤成為了“孤兒”進程,init就會負責回收這個子進程占用的資源並結束子進程。所以在要實現的容器中,啟動的第一個進程也需要實現類似init的功能,維護所有后續啟動進程的運行狀態。
當系統中存在樹狀嵌套結構的PID namespace時,若某個子進程成為孤兒進程,收養該子進程的責任就交給了該子進程所屬的PID namespace中的init進程。
至此,可能有人已經明白了內核設計的良苦用心。PID namespace維護這樣一個樹狀結構,有利於系統的資源監控與回收。因此,如果確實需要在一個Docker容器中運行多個進程,最先啟動的命令進程應該是具有資源監控與回收等管理能力的,如bash。
信號與init進程
內核還為PID namespace中的init進程賦予了其他特權——信號屏蔽。如果init中沒有編寫處理某個信號的代碼邏輯,那么與init在同一個PID namespace下的進程(即使有超級權限)發送給它的該信號都會被屏蔽。這個功能的主要作用是防止init進程被誤殺。
那么,父節點PID namespace中的進程發送同樣的信號給子節點的init進程會被忽略嗎?父節點中的進程發送的信號,如果不是SIGKILL(銷毀進程)或SIGSTOP(暫停進程)也會被忽略。但如果發送SIGKILL或SIGSTOP,子節點的init會強制執行(無法通過代碼捕捉進行特殊處理),也就是說父節點中的進程有權終止子節點中的進程。
一旦init進程被銷毀,同一PID namespace中的其他進程也會隨之接收到SIGKILL信號而被銷毀。理論上,該PID namespace自然也就不復存在了。但是如果/proc/[pid]/ns/pid處於被掛載或者打開狀態,namespace就會被保留下來。然而,保留下來的namespace無法通過setns()或者fork()創建進程,所以實際上並沒有什么作用。
當一個容器內存在多個進程時,容器內的init進程可以對信號進行捕獲,當SIGTERM或SIGINT等信號到來時,對其子進程做信息保存、資源回收等處理工作。在Docker daemon的源碼中也可以看到類似的處理方式,當結束信號來臨時,結束容器進程並回收相應資源。
掛載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可以修復錯誤。后面還會介紹通過mount namespace來隔離文件系統,當我們基於mount namespace實現了容器proc文件系統隔離后,我們就能在Docker容器中使用ps等命令看到與PID namespace對應的進程列表。
unshare()和setns()
之前我們談到unshare()和setns()這兩個API,在PID namespace中使用時,也有一些特別之處需要注意。
unshare()允許用戶在原有進程中建立命名空間進行隔離。但是創建了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。在Docker中,docker exec會使用setns()函數加入已經存在的命名空間,但是最終還是會調用clone()函數,原因就在於此。
mount 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)之間的關系,這樣的關系包括共享關系和從屬關系,系統用這些關系決定任何掛載對象中的掛載事件如何傳播到其他掛載對象。所謂傳播事件,是指由一個掛載對象的狀態變化導致的其它掛載對象的掛載與解除掛載動作的事件。
- 共享關系(share relationship)。如果兩個掛載對象具有共享關系,那么一個掛載對象中的掛載事件會傳播到另一個掛載對象,反之亦然。
- 從屬關系(slave relationship)。如果兩個掛載對象形成從屬關系,那么一個掛載對象中的掛載事件會傳播到另一個掛載對象,但是反過來不行;在這種關系中,從屬對象是事件的接收者。
一個掛載狀態可能為如下的其中一種:
- 共享掛載(shared)
- 從屬掛載(slave)
- 共享/從屬掛載(shared and slave)
- 私有掛載(private)
- 不可綁定掛載(unbindable)
傳播事件的掛載對象稱為共享掛載(shared mount);接收傳播事件的掛載對象稱為從屬掛載(slave mount);同時兼有前述兩者特征的掛載對象稱為共享/從屬掛載;既不傳播也不接收傳播事件的掛載對象稱為私有掛載(private mount);另一種特殊的掛載對象稱為不可綁定的掛載(unbindable mount),它們與私有掛載相似,但是不允許執行綁定掛載,即創建mount namespace時這塊文件對象不可被復制。通過圖1-1可以更好地了解它們的狀態變化:
圖1-1 mount各類掛載狀態示意圖
下面我們以圖1-1為例說明常用的掛載傳播方式。最上層的mount namespace下的/bin目錄與child namespace通過master slave方式進行掛載傳播,當mount namespace中的/bin目錄發生變化時,發生的掛載事件能夠自動傳播到child namespace中;/lib目錄使用完全的共享掛載傳播,各namespace之間發生的變化都會互相影響;/proc目錄使用私有掛載傳播方式,各mount namespace之間互相隔離;最后的/root目錄一般都是管理員所有,不能讓其他mount namespace掛載綁定。
默認情況下,所有掛載都是私有的。設置為共享掛載的命令如下。
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); //[...]
CLONE_NEWS生效之后,子進程進行的掛載與卸載操作都將只作用於這個mount namespace,因為在上面提到的處於單獨PID namespace隔離中的進程在加上mount namespace的隔離之后,即使該進程重新掛載了/proc文件系統,當進程退出后,root mount namespace(主機)的/proc文件系統是不會被破壞的。
network 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)中。如果有多塊物理網卡,也可以把其中一塊或多塊分配給新創建的network namespace。需要注意的是,當新創建的network namespace被釋放時(所有內部的進程都終止並且namespace文件沒有被掛載或打開),在這個namespace中的物理網卡會返回到root namespace,而非創建該進程的父進程所在的network namespace。
當說到network namespace時,指的未必是真正的網絡隔離,而是把網絡獨立出來,給外部用戶一種透明的感覺,仿佛跟另外一個網絡實體在進行通信。為了達到這個目的,容器的經典做法就是創建一個veth pair,一端放置在新的namespace中,通常命名為eth0,一端放在原先的namespace中連接物理網絡設備,再通過把多個設備接入網橋或者進行路由轉發,來實現通信的目的。
也許有人會好奇,在建立起veth pair之前,新舊namespace該如何通信呢?答案是pipe(管道)。以Docker Daemon在啟動容器的過程為例。假設容器內初始化的進程稱為init。Docker Daemon在宿主機上負責創建這個veth pair,,把一端綁定到docker0網橋上,另一端接入新建的network namespace進程中。這個過程執行期間,Docker Daemon和init就通過pipe進行通信。具體來說,就是在Docker Daemon完成veth pair的創建之前,init在管道的另一端循環等待,直到管道另一端傳來Docker Daemon關於veth設備的信息,並關閉管道。init才結束等待的過程,並把它的“eth0”啟動起來。整個結構如圖1-2所示。
圖1-2 Docker網絡示意圖
跟其他namespace類似,對network namespace的使用其實就是在創建的時候添加CLONE_NEWNET標識位。
user namespace
user namespace主要隔離了安全相關的標識符(identifiers)和屬性(attributes),包括用戶ID、用戶組ID、root目錄、key(指密鑰)以及特殊權限。通俗地講,一個普通用戶的進程通過clone()創建的新進程在新user namespace中可以擁有不同的用戶和用戶組。這意味着一個進程在容器外屬於一個沒有特權的普通用戶,但是他創建的容器進程卻屬於擁有所有權限的超級用戶,這個技術為容器提供了極大的自由。
user namespace是目前的六個namespace中最后一個支持的,並且直到Linux內核3.8版本的時候還未完全實現(還有部分文件系統不支持)。因為user namespace實際上並不算完全成熟,很多發行版擔心安全問題,在編譯內核的時候並未開啟USER_NS。Docker在1.10版本中對user namespace進行了支持。只要用戶在啟動Docker daemon的時候指定了--userns-remap,那么當用戶運行容器時,容器內部的root用戶並不等於宿主機內的root用戶,而是映射到宿主上的普通用戶。
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); //[...]
這里要先開啟user namespace限制:
# echo 15000 > /proc/sys/user/max_user_namespaces
至此,第一部分的代碼修改就結束了,將其另存為userns.c。然后開始編譯運行,並進行新建的user namespace,會發現shell提示符前的用戶名會變為“nobody”或者“I have no name!”,具體因操作系統而異。首先,我們先打印當前用戶的uid和guid,請注意,此時顯示的是普通用戶:
# id -u 0 # id -g 0 # gcc userns.c -Wall -lcap -o userns.o && ./userns.o 程序開始: 在子進程中! eUID = 65534; eGID = 65534; capabilities: = cap_chown,cap_dac_override,[……],35,36+ep <<--此處省略部分輸出,已擁有全部權限 /usr/bin/id: cannot find name for group ID 65534 /usr/bin/id: cannot find name for user ID 65534 [I have no name!@docker]$ id -u 65534 [I have no name!@docker]$ id -g 65534 [I have no name!@docker]$ exit exit 已退出 # id -u 0 # id -g 0
通過驗證可以得到以下信息:
- 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非常相似。
從圖1-3中可以看到,namespace實際上就是按層次關聯起來,每個namespace都發源於最初的root namespace並與之建立映射。
圖1-3 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, 1500, 1); set_gid_map(getpid(), 0, 1500, 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; }
這里,我們首先要創建一個ID為1500的用戶和用戶組,如果上述程序所指定ID所對應的用戶和用戶組不存在,程序將無法執行成功。另外,這里需要將用戶bar將入到root用戶組,否則上述程序也無法執行成功
# groupadd -g 1500 bar # useradd -u 1500 bar -g bar
# usermod -a -G root bar
然后,我們登陸bar用戶,首先打印關於當前用戶的id信息,再編譯並運行上述的程序:
[bar@docker ~]$ id uid=1500(bar) gid=1500(bar) groups=1500(bar),0(root) # gcc userns.c -Wall -lcap -o main && ./main 程序開始: 在子進程中! eUID = 0; eGID = 65534; capabilities: = cap_chown,cap_dac_override……<<--此處省略部分輸出 [root@docker ~]# id uid=0(root) gid=65534 groups=65534 [root@docker ~]# exit exit 已退出 [bar@docker ~]$
注:這里的gid並沒有改動到,具體的原因筆者也在查找,如果有知道原因的大佬,希望能不吝賜教
至此,就已經完成了綁定的工作,可以看到演示全程都是在普通用戶下執行的。最終實現了在user namespace中成為了root而對應到外面的是一個uid為1500的普通用戶。
如果要把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*等目錄下的資源。關於安全的更多討論和講解,會在后面的章節中接着探討。