經過上節,咱們瞭解了PID namespace,當咱們興致勃勃地在新建的namespace中啓動一個「Apache」進程時,卻出現了「80端口已被佔用」的錯誤,原來主機上已經運行了一個「Apache」進程。怎麼辦?這就須要用到network namespace技術進行網絡隔離啦。docker
Network namespace主要提供了關於網絡資源的隔離,包括網絡設備、IPv4和IPv6協議棧、IP路由表、防火牆、/proc/net目錄、/sys/class/net目錄、端口(socket)等等。一個物理的網絡設備最多存在在一個network namespace中,你能夠經過建立veth pair(虛擬網絡設備對:有兩端,相似管道,若是數據從一端傳入另外一端也能接收到,反之亦然)在不一樣的network namespace間建立通道,以此達到通訊的目的。shell
通常狀況下,物理網絡設備都分配在最初的root namespace(表示系統默認的namespace,在PID namespace中已經說起)中。可是若是你有多塊物理網卡,也能夠把其中一塊或多塊分配給新建立的network namespace。須要注意的是,當新建立的network namespace被釋放時(全部內部的進程都終止而且namespace文件沒有被掛載或打開),在這個namespace中的物理網卡會返回到root namespace而非建立該進程的父進程所在的network namespace。ubuntu
當咱們說到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網絡示意圖app
跟其餘namespace相似,對network namespace的使用其實就是在建立的時候添加CLONE_NEWNET標識位。也能夠經過命令行工具ip建立network namespace。在代碼中創建和測試network namespace較爲複雜,因此下文主要經過ip命令直觀的感覺整個network namespace網絡創建和配置的過程。socket
首先咱們能夠建立一個命名爲test_ns的network namespace。ide
# 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: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網絡部分進行詳細的講解。
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
其次在子進程函數中加入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$="" <="" pre="">
經過驗證咱們能夠獲得如下信息。
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背後運行的原理提供幫助。