在Linux系統中,Namespace是在內核級別以一種抽象的形式來封裝系統資源,通過將系統資源放在不同的Namespace中,來實現資源隔離的目的。不同的Namespace程序,可以享有一份獨立的系統資源。Namespace的一個作用就是來實現容器。
Linux提供了系統資源的隔離機制,如下:
Namespace | Flag | Page | Isolates |
---|---|---|---|
Cgroup | CLONE_NEWCGROUP | cgroup_namespaces | Cgroup root directory |
IPC | CLONE_NEWIPC | ipc_namespaces | System V IPC,POSIX message queues 隔離進程間通信 |
Network | CLONE_NEWNET | network_namespaces | Network devices,stacks, ports, etc. 隔離網絡資源 |
Mount | CLONE_NEWNS | mount_namespaces | Mount points 隔離文件系統掛載點 |
PID | CLONE_NEWPID | pid_namespaces | Process IDs 隔離進程的ID |
Time | CLONE_NEWTIME | time_namespaces | Boot and monotonic clocks |
User | CLONE_NEWUSER | user_namespaces | User and group IDs 隔離用戶和用戶組的ID |
UTS | CLONE_NEWUTS | uts_namespaces | Hostname and NIS domain name 隔離主機名和域名信息 |
Namespace View
從3.8內核開始,用戶可以用過/proc/$pid/ns/
查看Namespace的文件信息,例如PID為12583的進程情況如下:
其中 4026532869
為Namespace ID, 如果兩個進程的Namespace相同意味着它們處於同一個命名空間中。
Namespace Lifetime
如果沒有任何其他因素, Namespace
將在其中的最后一個進程終止或者離開該 Namespace
時自動刪除。當然也存在特殊的情況,例如下面這段操作將會使 Namespace
一直駐留。
通過掛載的方式打開文件描述符:
touch ~/mnt
mount --bind /proc/12583/mnt ~/mnt
這樣就可以保留PID為12583的 Mount Namespace
,即使 12583
進程銷毀或者退出,ID為 4026532869
的 Mount Namespace
依然存在。
此外, Namespace
不會被自動刪除的情況還有:
- 是一個擁有等級體系的
Namespace
,且有一個child Namespace
- 是一個
user Namespace
,且擁有一個或多個nonuser Namespace
- 是一個
PID Namespace
,且有一個進程通過/proc/[pid]/ns/pid_for_children
軟鏈接引用這個Namespace
- 是一個
IPC Namespace
, 且進程通信的mqueue
對應掛載文件系統引用這個Namespace
- 是一個
PID Namespace
,且proc(5)
對應掛載文件系統引用這個Namespace
Namespace API
涉及到 Namespace
操作接口的API有 clone(2)
、 setnx(2)
、 unshare(2)
、 ioctl(2)
。
**
clone(2)
這個系統調用創建一個新的獨立的 Namespace
進程,函數描述如下:
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
通過flags參數控制創建進程的特性,例如新創建進程是否與父進程共享虛擬內存等。例如傳入 CLONE_NEWNS
標志使得新創建的進程擁有獨立的 Mount Namespace
,同樣也可以傳入多個flag使得新創建的進程擁有多種特性,
例如傳入這個flags創建的新進程將同時擁有獨立的 Mount Namespace
、 UTS Namespace
和 IPC Namespace
。
flags = CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC ;
**
setns(2)
這個系統調用允許進程加入指定的 Namespace
中,它的函數描述如下:
int setns(int fd, int nstype);
- fd參數:表示文件描述符。可以通過打開
/proc/$pid/ns/
的方式將指定的Namespace
保留下來,也就是說可以通過文件描述符的方式索引到某個Namespace
。 nstype
參數:用來檢查fd
關聯Namespace
是否與nstype
表明的Namespace
一致,如果填0則不檢查該項。
unshare(2)
使該進程脫離 Namespace
,並加入到一個新的 Namespace
中。與 setnx()
不同的是, unshare()
不用關聯之前存在的 Namespace
,只需要指定需要分離的 Namespace
即可,該調用會自動創建一個新 Namespace
。函數描述如下:
int unshare(int flags);
其中 flags
用於指明要分離的資源類別,它支持 flags
與 clone
系統調用支持的 flags
類似。
ioctl(2)
可以使用 ioctl(2)
操作發現關於 namespace
的信息。函數描述如下:
int ioctl(int fd, unsigned long request, ...);
fd
參數:必須是一個打開的文件描述符- 第二個參數:是依賴於設備的請求代碼
- 第三個參數:指向內存的無類型指針
other
unshare()
與 setns()
系統調用對 PID Namespace
處理不盡相同,當 unshare PID Namespace
時,調用進程會為它的子進程分配一個新的 PID Namespace
,但是調用進程本身不會被遷移到新的 Namespace
中,而且調用進程第一個創建的子進程在新 Namespace
中的 PID
為1,並成為新 Namespace
中的 init
進程。
setnx()
系統調用也類似,調用者進程不會進入新的 PID Namespace
,而是隨后創建的子進程會進入。
那為什么創建其它的 Namespace
時 unshare()
和 setns()
會直接進入新的 Namespace
,而唯獨 PID Namespace
不是如此呢?
因為調用 getpid()
函數得到的 PID
是根據調用者所在的 PID Namespace
而決定返回哪個 PID
,進入新的 PID Namespace
會導致PID產生變化。而對用戶態的程序和庫函數來說,它們都會認為進程的 PID
是一個常量, PID
的變化會引起這些進程崩潰。一旦程序進程創建以后,那么它的 PID Namespace
的關系就確定下來了,進程不會變更它們對應的 PID Namespace
。
IPC Namespace
IPC(Interprocess Communication) Namespace 是對進程通信的隔離,進程間通信常用的方法包括信號量、消息隊列和共享內存。然而與虛擬機不同的是,容器內部進程通信對宿主機來說,實際上是具有相同 PID Namespace 的進程間的通信,因此需要一個唯一的標識符進行區別。申請IPC資源就申請這樣一個全局唯一的32位ID,所以IPC Namespace中實際上包含了系統IPC標識符以及實現POSIX消息隊列的文件系統。在同一個 IPC Namespace 下的進程彼此可見,而與其它的IPC Namespace下的進程則互相不可見。
目前使用 IPC Namespace 機制的系統不多,其中比較有名的有 Postgre SQL。Docker 本身是通過 socket 或 tcp 進行通信。
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,在 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”啟動起來。整個效果類似下圖所示。
與其它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 后續部分進行詳解。
Mount Namespace
Mount Namespace
用來隔離文件系統的掛載點,不同 Mount Namesace
的進程擁有不同的掛載點,同時也擁有了不同的文件系統視圖。 Mount Namespace
是歷史上第一個支持的 Namespace
。
Mount
掛載的過程是通過 mount
系統調用完成的,它有兩個參數:一個是已存在的普通文件名,一個是可以直接訪問的特殊文件,這個特殊文件一般用來關聯一些存儲卷,這個存儲卷可以包含自己的目錄層級和文件系統結構。
mount
的效果就像訪問一個普通的文件一樣訪問位於其它設備上的文件系統的根目錄,也就是將該設備上該目錄的根節點掛到了另外一個文件系統的頁節點上,達到了這個文件系統擴充容量的目的。
可以通過 /proc
文件系統查看一個進程的掛載信息:
cat /proc/$pid/mountinfo
如下圖,我用該命令查看一個在Docker容器里的Java進程的掛載信息:
輸出的格式如下:
Mount Propagation
進程在創建 mount namespace
時,會把當前的文件結構復制給新的 namespace
。新的 namespace
中所有 mount
操作都只影響自身的文件系統,而對外界不會產生任何影響。這樣非常嚴格實現了隔離,但是某些情況下可能並不適用。例如父節點 namespace
中的進程掛載了一張 CD-ROM
,這時子節點的 namespace
拷貝的目錄結構就無法自動掛載這張 CD-ROM
,因為這種操作會影響父節點的文件系統。
2006年引入了掛載傳播(mount propagation)解決了這個問題,掛載傳播定義了掛載對象(mount object)之間的關系,系統利用這些關系決定任何掛載對象中的掛載事件傳播到其它掛載對象。所謂傳播事件,就是一個掛載對象狀態變化導致的其它掛載對象的掛載與解除掛載動作的事件。
- 如果兩個掛載對象具有共享關系(share relationship),那么一個掛載對象的掛載事件會傳播到另一個掛載對象,反之亦然。
- 如果兩個掛載對象形成從屬關系(master slave),那么一個掛載對象的掛載事件會傳播到另一個掛載對象,但反之不行。在這種關系中,從屬對象是事件的接受者。
一個掛載狀態可以為如下的其中一種:
- 共享狀態(shared)
- 從屬狀態(slave)
- 共享/從屬狀態(shared and slave)
- 私有掛載(private)
- 不可綁定掛載(unbindable)
傳播事件的掛載對象稱為共享掛載(shared mount);接收傳播事件的掛載對象稱為從屬掛載(slave mount)。既不傳播也不接收傳播事件的掛載對象稱為私有掛載(private mount)。另一種特殊的掛載對象稱為不可綁定的掛載(unbindable mount),它們與私有掛載相似,但是不允許執行綁定掛載,即創建 mount namespace 時這塊文件對象不可被復制。
共享掛載的應用場景非常明顯,就是為了文件數據的共享所必須的一種掛載方式;從屬掛載更大的意義在於一些“只讀”場景;私有掛載則是純粹的隔離,作為獨立個體存在;不可綁定掛載則有助於防止沒必要的文件拷貝。
默認情況下,所有掛載都是私有的。從共享掛載克隆的掛載對象也是共享的掛載,它們互相傳播掛載事件。
從屬掛載克隆的掛載對象也是從屬的掛載,它也從屬於原來的從屬掛載的主掛載對象。
mount --make-shared /mntS # 將掛載點設置為共享關系屬性
mount --make-private /mntP # 將掛載點設置為私有關系屬性
mount --make-slave /mntY # 將掛載點設置為從屬關系屬性
mount --make-unbindable /mntU # 將掛載點設置為不可綁定屬性
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 進程
當我們新建一個 PID namespace 時,默認啟動的進程 PID 為 1。我們知道,在傳統的 UNIX 系統中,PID 為 1 的進程是 init,地位非常特殊。他作為所有進程的父進程,維護一張進程表,不斷檢查進程的狀態,一旦有某個子進程因為程序錯誤成為了“孤兒”進程,init 就會負責回收資源並結束這個子進程。所以在你要實現的容器中,啟動的第一個進程也需要實現類似 init 的功能,維護所有后續啟動進程的運行狀態。
看到這里,可能讀者已經明白了內核設計的良苦用心。PID namespace 維護這樣一個樹狀結構,非常有利於系統的資源監控與回收。Docker 啟動時,第一個進程也是這樣,實現了進程監控和資源回收,它就是 dockerinit。
信號與 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,也就是這個道理。
掛載 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 可以修復錯誤。
unshare() 和 setns()
unshare() 和 setns() 這兩個 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。
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* 等目錄下的資源。關於安全的更多討論和講解,會在后文中接着探討。
UTS Namespace
UTS(UNIX Time-sharing System) 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);
//[...]
}