寫這個系列文章主要是對之前做項目用到的docker相關技術做一些總結,包括docker基礎技術Linux命名空間,cgroups,網絡等內容。這是第一篇Linux命名空間,主要參考的introduction-to-linux-namespaces和
Namespaces in operation 這兩個系列博客,根據自己的理解進行了翻譯整合。示例代碼也全部來自這兩個參考資料,為了學習方便,我建了個倉庫存放示例代碼以方便測試,代碼地址見這里,如有錯誤,歡迎指正。
1 概述
Linux容器技術(LXC)近幾年十分流行,而其依托的技術并不是很新的東西,而是Linux內核自帶的一套內核級別環境隔離機制。當然,最流行的LXC技術莫過于docker了,現在社區版本更名叫moby了。 Linux容器技術依賴Linux內核的3個主要的隔離機制:chroot,cgroups,namespace。先來看看namespace,在Linux Kernel3.8以后,Linux支持6種namespace。分別是:
namespace | 隔離內容 | flag |
---|---|---|
UTS | 主機名 | CLONE_NEWUTS |
IPC | 進程間通信 | CLONE_NEWIPC |
PID | chroot進程樹 | CLONE_NEWPID |
NS(Mount) | 掛載點(mount points) | CLONE_NEWNS |
NET | 網絡訪問,包括接口 | CLONE_NEWNET |
USER | 將虛擬的本地UID映射到真實的UID | CLONE_NEWUSER |
Linux內核提供了一套API用于操作namespace實現環境隔離,目前namespace操作的API包括clone(), setns()以及unshare()等。通過下面的命令我們可以模擬一個類似容器的環境(隔離了PID namespace,并掛載了proc目錄),你可以發現只能看到bash和shell兩個進程了,而且PID是1和2。而在原PID namespace,我們可以看到bash進程的PID則是15011。個中緣由,且慢慢道來。
root@ubuntu:/home/vagrant/nstest# sudo unshare --fork --pid --mount-proc bash
root@ubuntu:/home/vagrant/nstest# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.0 29164 5876 pts/1 S 22:14 0:00 bash
root 2 0.0 0.0 24388 2592 pts/1 R+ 22:14 0:00 ps aux
root@ubuntu:/home/vagrant/nstest# ps -ef|grep unshare
root 15011 952 0 22:14 pts/1 00:00:00 unshare --fork --pid --mount-proc bash
1.1 使用clone創建新進程同時創建namespace
代碼 ns.c 從子進程運行 /bin/bash,先從這個例子來看看Linux namespace的作用(為了簡單起見,略去了錯誤檢查代碼)。
注意到在代碼中使用了clone來代替更常見的fork系統調用,clone實際上是Unix系統調用fork的一種更通用的實現方式,它的原型是這樣的
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
child_func
參數為傳遞子進程運行的主函數,如ns.c中的child_main
;child_stack
參數為子進程使用的棧空間,參數flags可以指定使用的CLONE_*標志,一次可以指定多個flag;而args則是子進程的參數。編譯運行上面的代碼,結果如下所示,運行正常,但是我們很難區分這是在子進程運行的/bin/bash還是本身的/bin/bash。
root@ubuntu:/home/vagrant/nstest# gcc -Wall ns.c -o ns && ./ns
- Hello ?
- World !
root@ubuntu:/home/vagrant/nstest# #inside container
root@ubuntu:/home/vagrant/nstest# exit
root@ubuntu:/home/vagrant/nstest# #outside container
于是,CLONE_NEWUTS
可以派上用場了。UTS namespace提供了主機名和域名的隔離,這樣每個容器就有獨立的主機名和域名,從而可以在網絡上被當作一個獨立的節點而不是宿主機的一個進程。修改clone函數這行代碼,加入CLONE_NEWUTS的flag,然后在子進程中調用sethostname
函數,修改后代碼 ns_uts.c。
以root身份運行它
root@ubuntu:/home/vagrant/nstest# gcc -Wall ns_uts.c -o ns_uts && ./ns_uts
- Hello ?
- World !
root@In Namespace:/home/vagrant/nstest# #inside container
root@In Namespace:/home/vagrant/nstest# exit
root@ubuntu:/home/vagrant/nstest# #outside container
可以看到,在子進程中hostname變成了In Namespace
,而父進程的hostname為ubuntu不受子進程修改hostname的影響,通過CLONE_NEWUTS實現了主機名的隔離。注意,如果不加CLONE_NEWUTS
標記運行,會發現退出子進程后hostname也還原了,這是因為bash只在登錄的時候讀取一次UTS,等你重新登陸就會發現hostname變了。因此,為了hostname隔離,加上CLONE_NEWUTS
標志。
docker容器的hostname也是通過該機制實現的隔離,每個容器都有自己的hostname(默認是容器ID),并不會對宿主機的hostname產生任何影響。
root@ubuntu:/home/ssj# docker exec -it ssjtestnew /bin/bash
root@c9df3369e321:/# hostname
c9df3369e321
1.2 /proc/PID/ns文件
從/proc/PID/ns目錄中,我們可以看到一個進程的namespace。比如我們運行上面的 ./ns_uts
,并查看父子進程的namespace,結果如下,可以看到ns_uts和子進程bash的ns目錄中,除了UTS namespace是不一樣的,表明這兩個進程在不同的UTS名字空間,其他5個namespace是相同的。/proc/PID/ns目錄中的為符號鏈接,指向的是對應namespace的名字,名字命名規則是namespace類型+inode數字,如ipc:[4026531839]。
root@ubuntu:/home/vagrant# ps -ef
root 3086 2741 0 02:46 pts/0 00:00:00 ./ns_uts
root 3087 3086 0 02:46 pts/0 00:00:00 /bin/bash
root@ubuntu:/home/vagrant# ls -ls /proc/3086/ns/
total 0
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 ipc -> ipc:[4026531839]
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 mnt -> mnt:[4026531840]
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 net -> net:[4026531956]
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 pid -> pid:[4026531836]
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 user -> user:[4026531837]
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 uts -> uts:[4026531838]
root@ubuntu:/home/vagrant# ls -ls /proc/3087/ns/
total 0
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 ipc -> ipc:[4026531839]
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 mnt -> mnt:[4026531840]
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 net -> net:[4026531956]
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 pid -> pid:[4026531836]
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 user -> user:[4026531837]
0 lrwxrwxrwx 1 root root 0 Aug 16 02:47 uts -> uts:[4026532182]
root@ubuntu:/home/vagrant# readlink /proc/3086/ns/uts # show parent UTS namespace
uts:[4026531838]
root@ubuntu:/home/vagrant# readlink /proc/3087/ns/uts # show child UTS namespace
uts:[4026532182]
root@ubuntu:/home/vagrant# touch ~/uts
root@ubuntu:/home/vagrant# mount --bind /proc/3087/ns/uts ~/uts
當然namespace還有其他的用處,只要namespace的文件描述符是打開的,即便該namespace所有進程都終止了,該namespace還是依舊存在。我們如果直接退出程序,可以看到進程退出后/proc/PID目錄會整個刪掉,包括ns目錄。于是,為了保存子進程的UTS namespace,我們用mount命令先掛載該namespace,稍后我們會用setns()將進程加入到該UTS namespace。
mount --bind /proc/3087/ns/uts ~/uts
1.3 加入已經存在的namespace:setns()
通過setns和execve可以讓一個進程加入一個已經存在的namespace并在那個namespace執行命令。測試代碼 ns_setns.c,這里用到上一節中保留的UTS namespace。
運行結果如下:
root@ubuntu:/home/vagrant/nstest# gcc -o ns_setns ns_setns.c
root@ubuntu:/home/vagrant/nstest# ./ns_setns ~/uts /bin/bash
root@In Namespace:/home/vagrant/nstest# echo $$ ## show pid
3375
root@In Namespace:/home/vagrant/nstest# hostname
In Namespace
root@In Namespace:/home/vagrant/nstest# readlink /proc/3375/ns/
ipc mnt net pid user uts
root@In Namespace:/home/vagrant/nstest# readlink /proc/3375/ns/uts
uts:[4026532182]
可以看到該進程的UTS namespace為我們指定的之前保留的child process的UTS namespace。
1.4 隔離一個namespace:unshare()
unshare函數可以讓進程脫離一個namespace,它與clone類似,不同的是,unshare不需要創建新的進程,而是在當前進程直接隔離namespace。
測試代碼 ns_unshare.c ,運行之,在參數中我們傳遞-m
用來隔離NS namespace(即掛載點的namespace),結果可以看到在新的NS namespace的shell進程中umount了一個目錄/run/lock
,并不影響老的shell進程的掛載點。
root@ubuntu:/home/vagrant/nstest# echo $$ #Show pid of shell
4434
root@ubuntu:/home/vagrant/nstest# readlink /proc/4434/ns/mnt # Show shell NS namespace id
mnt:[4026532183]
root@ubuntu:/home/vagrant/nstest# cat /proc/4434/mounts|grep '/run/lock'
none /run/lock tmpfs rw,nosuid,nodev,noexec,relatime,size=5120k 0 0
root@ubuntu:/home/vagrant/nstest# ./ns_unshare -m /bin/bash #Start new shell in separate mount namespace
hello, pid=4927
root@ubuntu:/home/vagrant/nstest# echo $$
4927
root@ubuntu:/home/vagrant/nstest# readlink /proc/4927/ns/mnt #Show mount namespace ID in new shell
mnt:[4026532184]
root@ubuntu:/home/vagrant/nstest# cat /proc/4927/mounts|grep 'run/lock'
none /run/lock tmpfs rw,nosuid,nodev,noexec,relatime,size=5120k 0 0
root@ubuntu:/home/vagrant/nstest# umount /run/lock #Umount dir in separate mount namespace
root@ubuntu:/home/vagrant/nstest# cat /proc/4927/mounts|grep 'run/lock'
root@ubuntu:/home/vagrant/nstest# exit
root@ubuntu:/home/vagrant/nstest# cat /proc/4434/mounts|grep '/run/lock' #Old shell not affected
none /run/lock tmpfs rw,nosuid,nodev,noexec,relatime,size=5120k 0 0
至此,namespace操作的相關API函數已經都說完了,接下來分別看看這6個namespace。
2 UTS Namespace
UTS是實現主機名和域名的隔離,在第一節中已經說過,這里不再贅述。
3 IPC Namespace
IPC指Unix/Linux下進程間通信的方式,可以通過共享內存,信號量,消息隊列,管道等方法實現。這里我們要隔離IPC namespace,實現方式也很簡單,在clone函數的flags參數中加入CLONE_NEWIPC即可,這樣你可以在新的namespace中創建IPC,甚至是命名一個,并不會有與其他應用沖突的風險。
我們在最初的實例代碼ns.c中修改一下,加入CLONE_NEWIPC的flag。修改的代碼只有一行,如下。當然這里的CLONE_NEWUTS不是必須的,保留這個flag只是為了更加方便的顯示效果。
/*
ns_ipc.c: used to test ipc
*/
[...]
int child_pid = clone(child_main, child_stack+STACK_SIZE,
CLONE_NEWUTS | CLONE_NEWIPC| SIGCHLD, NULL);
[...]
先通過ipcmk -Q
創建一個IPC隊列,隊列ID為65536,然后運行./ns_ipc
,可以看到在新的namespace中并沒有該IPC隊列,做到了IPC隔離。
root@ubuntu:/home/vagrant/nstest# ipcmk -Q
Message queue id: 65536
root@ubuntu:/home/vagrant/nstest# ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
0x0a3817cf 65536 root 644 0 0
root@ubuntu:/home/vagrant/nstest# ./ns_ipc
- Hello ?
- World !
root@In Namespace:/home/vagrant/nstest# ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
root@In Namespace:/home/vagrant/nstest# exit
root@ubuntu:/home/vagrant/nstest# ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
0x0a3817cf 65536 root 644 0 0
接下來可能有人要問了,那這種父子進程在不同的IPC namespace了,它們之間怎么通信呢?前面說過,進程間通信有信號量,共享內存,管道,FIFO,sockets等。由于上下文的改變,使用信號量也許不是最佳方案。而使用共享內存則有效率上的問題,如果不隔離網絡棧的話也可以用sockets,但是我們現在要一步步隔離一切,因此sockets也不合適。FIFO則可以用于任意進程間的通信,FIFO是一種特殊的文件類型,在文件系統中是有對應路徑的,它的問題也與sockets類似,因為我們要隔離文件系統的話,它也不合適。管道用于有親屬關系的進程之間通信,比如父子進程或者兄弟進程之間通信,很適合不同namespace的進程通信。
使用管道實現不同namespace之間進程通信的示例代碼 ns_ipc.c,運行之,可以看到位于不同namespace的父子進程確實通信成功了。
root@ubuntu:/home/vagrant/nstest# ./ns_ipc
- Hello ?
- World !
root@In Namespace:/home/vagrant/nstest# exit
root@ubuntu:/home/vagrant/nstest#
4 PID Namespace
實現PID隔離加上CLONE_NEWPID標識即可。示例代碼 ns_pid.c ,運行之:
root@ubuntu:/home/vagrant/nstest# ./ns_pid
- [ 7627] Hello ?
- [ 1] World !
root@In Namespace:/home/vagrant/nstest# echo $$ ##In new PID namespace
1
root@In Namespace:/home/vagrant/nstest# kill -KILL 7627
bash: kill: (7627) - No such process
###host ps view
root@ubuntu:/home/vagrant/nstest# ps -ef|grep 7627
root 7627 2768 0 04:40 pts/1 00:00:00 ./pid
root 7628 7627 0 04:40 pts/1 00:00:00 /bin/bash
可以看到在不同PID namespace中運行的/bin/bash的PID為1。而它的父進程的PID是7627。而在父進程namespace中,可以看到/bin/bash的進程為7268。如果你試圖在新的Namespace中去kill某個不同namespace中的進程,則會報錯提示進程不存在,達到了進程隔離的目的。
要注意的是,這個時候你在新的namespace中用ps,top
等命令去查看,會發現7627這個進程是可見的。這與我們在docker容器中看到的不一致,如在我創建的一個redis容器中,用ps, top
其實是只看得到容器所在namespace的進程的。
root@ubuntu:/home/ssj# docker exec -it redistest /bin/bash
root@0b86fb961783:/data# ps -ef
UID PID PPID C STIME TTY TIME CMD
redis 1 0 0 02:44 ? 00:00:00 redis-server *:6379
root 18 0 0 02:45 ? 00:00:00 /bin/bash
root 25 18 0 02:45 ? 00:00:00 ps -ef
這是因為ps命令讀取的是/proc
文件系統獲取的信息,而文件系統我們還沒有隔離,所以在新的namespace中可以看到所有的進程,接下來我們會用NS namespace來實現這個隔離。
5 NS Namespace
NS namespace也就是掛載點相關的了,在第4節的代碼基礎上加入CLONE_NEWNS
的flag,并在子進程掛載 /proc
目錄。修改后創建進程的代碼 ns_ns.c, 運行之:
root@ubuntu:/home/vagrant/nstest# ./ns_ns
- [27137] Hello ?
- [ 1] World !
root@In Namespace:/home/vagrant/nstest# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 20:37 pts/0 00:00:00 /bin/bash
root 3 1 0 20:39 pts/0 00:00:00 ps -ef
root@In Namespace:/home/vagrant/nstest# ls /proc/
1 bus cpuinfo dma filesystems ioports kcore kpagecount meminfo mpt partitions softirqs sysrq-trigger tty vmstat
5 cgroups crypto driver fs ipmi keys kpageflags misc mtrr sched_debug stat sysvipc uptime zoneinfo
acpi cmdline devices execdomains interrupts irq key-users loadavg modules net self swaps timer_list version
buddyinfo consoles diskstats fb iomem kallsyms kmsg locks mounts pagetypeinfo slabinfo sys timer_stats vmallocinfo
可以看到ps
命令確實只顯示了當前namespace下面的進程了,而且ls /proc/
命令查看發現/proc
目錄下面的內容也清爽多了。docker使用NS namespace實現了一些文件系統的掛載,原理與這個類似,結合chdir和chroot可以實現一個山寨的docker鏡像。
這個時候我們再來看看docker中PID和NS namespace具體的實現(我的docker版本是1.13.1,其他版本可能有所不同),我這里在宿主機起了一個redis容器名為redistest,通過pstree可以看到進程關系如下:
|-dockerd-+-docker-containerd-+-docker-containerd-shim-+-redis-server---3*[{redis-server}]
| | | `-8*[{docker-containe}]
| | `-12*[{docker-containe}]
| `-19*[{dockerd}]
這里對應進程關系就是:
- dockerd進程創建了一個docker-containerd子進程,而docker-contianerd子進程再創建子進程docker-containerd-shim,也就是對應具體容器的進程。
- 容器進程docker-containerd-shim創建容器里面的1號進程redis-server。
- 通過查看
/proc/PID/ns
目錄就可以發現,dockerd,dockerd-containerd以及dockerd-containerd-shim的namespace都是一樣的,而容器里面的1號進程 redis-server的namespace除了User namespace外,其他的namespace都已經不同。也就是說,從容器里面的1號進程開始,進程的namespace開始隔離。 -
另外注意一點的是,當你使用
docker exec -it redistest /bin/bash
命令進入容器的時候,這個/bin/bash
進程的父進程其實是另外一個 docker-containerd-shim進程,只是/bin/bash
進程的namespace和redis-server進程一樣,所以這個時候你在redistest容器中ps -ef
,可以看到除了redis-server進程外,還有/bin/bash
進程。通過exec命令進入容器后,再來看進程關系,是下面這樣的:
|-dockerd-+-docker-containerd-+-docker-containerd-shim-+-redis-server---3*[{redis-server}]
| | | `-8*[{docker-containe}]
| | |-docker-containerd-shim-+-bash
| | | `-8*[{docker-containe}]
| | `-12*[{docker-containe}]
| `-19*[{dockerd}]
而在容器在自己的NS namespace中掛載了很多目錄,如下面這些:
/dev/sda8 on /etc/resolv.conf type ext4 (rw,relatime,data=ordered)
/dev/sda8 on /etc/hostname type ext4 (rw,relatime,data=ordered)
/dev/sda8 on /etc/hosts type ext4 (rw,relatime,data=ordered)
...
proc on /proc/irq type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/sys type proc (ro,nosuid,nodev,noexec,relatime)
6 NET Namespace
NET namespace是指網絡上的隔離,通過加入CLONE_NEWNET
來實現。在討論這個之前,可以先看看通過ip
命令如何手動創建network namespace以及veth設備等。veth主要的目的是為了跨NET namespace之間提供一種類似于Linux進程間通信的技術,所以veth總是成對出現,如下面的veth0和veth1。它們位于不同的NET namespace中,在veth設備任意一端接收到的數據,都會從另一端發送出去。veth工作在L2數據鏈路層,只負責數據傳輸,不會更改數據包。
# Create a "demo" namespace
ip netns add demo
# create a "veth" pair
ip link add veth0 type veth peer name veth1
# and move one to the namespace
ip link set veth1 netns demo
# configure the interfaces (up + IP)
ip netns exec demo ip link set lo up
ip netns exec demo ip link set veth1 up
ip netns exec demo ip addr add 169.254.1.2/30 dev veth1
ip link set veth0 up
ip addr add 169.254.1.1/30 dev veth0
執行完成后,我們可以在宿主機里面看到網絡設備是這樣的:
root@ubuntu:/home/vagrant# ip -d link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 promiscuity 0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 08:00:27:ec:df:9c brd ff:ff:ff:ff:ff:ff promiscuity 0
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 08:00:27:57:25:68 brd ff:ff:ff:ff:ff:ff promiscuity 0
5: veth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 62:14:fd:45:f8:0e brd ff:ff:ff:ff:ff:ff promiscuity 0
veth
root@ubuntu:/home/vagrant# ethtool -S veth0
NIC statistics:
peer_ifindex: 4
而在demo這個NET namespace中,看到的網絡設備是這樣的:
root@ubuntu:/home/vagrant# ip netns exec demo ip -d link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 promiscuity 0
4: veth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 6a:7d:49:3f:bc:8e brd ff:ff:ff:ff:ff:ff promiscuity 0
veth
這個原理就是先創建一個新的NET namespace名為demo,然后創建一對veth設備,veth0和veth1,接著將veth1移動到namespace demo,而veth0仍然保留在原來的namespace,然后啟動對應的veth設備。這樣一對veth設備分屬于不同的namespace,并可以通信。然后給veth0和veth1設置ip并啟動它們。要查看veth的一對設備中另外一個,可以用 ethtool -S
命令。實現上面功能的代碼 ns_net.c ,運行之,如下:
root@ubuntu:/home/vagrant/nstest# ./ns_net
- [ 2760] Hello ?
- [ 1] World !
### 宿主機namespace
root@ubuntu:/home/vagrant/nstest# ip -d link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 promiscuity 0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 08:00:27:ec:df:9c brd ff:ff:ff:ff:ff:ff promiscuity 0
3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 08:00:27:57:25:68 brd ff:ff:ff:ff:ff:ff promiscuity 0
11: veth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether ce:95:ad:9e:ee:6b brd ff:ff:ff:ff:ff:ff promiscuity 0
veth
### 新的namespace
root@In Namespace:/home/vagrant/nstest# ip -d link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 promiscuity 0
10: veth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 9a:e9:95:53:c3:28 brd ff:ff:ff:ff:ff:ff promiscuity 0
veth
root@In Namespace:/home/vagrant/nstest# ethtool -S veth1
NIC statistics:
peer_ifindex: 11
docker網絡分為bridge, host, overlay等幾種類型。host就是與主機共用namespace,這里不單獨分析了。而bridge就與前面例子中類似,不同的是僅僅有veth容器還無法與外部聯通,因此docker借助了網橋技術用于連接不同網段,在L2層進行數據轉發,將veth0加入到宿主機的網橋docker0中,并在iptables加入對應的NAT規則,以保證容器可以與外部連通。注意docker中NET namespace的隔離不是通過ip命令實現的(因為不是所有的內核版本都有ip netns這個高級命令),而是通過netlink基于操作系統調用的方式實現的。而overlay網絡則是通過vxlan協議實現,對應的veth會橋接到overlay的NET namespace一個br0網橋上。bridge和overlay網絡的一個示意圖如下(圖來自 deep-dive-into-docker-overlay-networks),其中192.168.0.X這個是自定義的overlay網絡,而172.18.0.X的則是bridge網絡,docker網絡部分會在下一篇文章再詳細分析。
7 USER Namespace
7.1 創建新的USER Namespace
加上 CLONE_NEWUSER
flag可以實現USER namespace的隔離。示例如下(注意,在debian或者ubuntu中必須設置/proc/sys/kernel/unprivileged_userns_clone
這個文件值為1,否則無法以普通用戶運行帶CLONE_NEWUSER
標記的clone命令
)
示例代碼 ns_user.c,以普通用戶運行之:
vagrant@ubuntu:~/nstest$ id -u
1000
vagrant@ubuntu:~/nstest$ id -g
1000
vagrant@ubuntu:~/nstest$ gcc -o ns_user ns_user.c -lcap
#如果編譯報錯的話,安裝libcap-dev模塊,sudo apt-get install libcap-dev
vagrant@ubuntu:~/nstest$ ./user
eUID = 65534; eGID = 65534; capabilities: = cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend+ep
這里有幾點注意的:
-
其一,從capabilities輸出可以看到子進程在它的namespace里面有全部的capability,雖然我們是用普通用戶權限運行的程序。當一個新的USER namespace創建的時候,這個namespace的第一個進程就被賦予了全部的capability。capability是為了實現更精細化的權限控制而加入的。(我們以前熟知通過設置文件的SUID位,這樣非root用戶的可執行文件運行后的euid會成為文件的擁有者ID,比如passwd命令運行起來后有root權限。一旦SUID的文件存在漏洞,便可能被利用而增加安全風險)。查看文件的capability的命令為
filecap -a
,而查看進程capability的命令為pscap -a
(pscap和filecap工具需要安裝libcap-ng-utils
這個包)。對capability的那串數字解碼命令為capsh --decode=00000000000000c0
。更多capability的內容見參考資料4。對于capability,可以看一個簡單的例子便于理解。如ubuntu14.04系統中自帶的ping工具,它是有設置SUID位的。這里拷貝ping到我的用戶目錄下名為anotherping,可以看到它的SUID位是沒有了的,運行anotherping,會提示權限錯誤。這里,我們只要將其加上
cap_net_raw
權限即可,不需要設置SUID位那么大的權限。vagrant@ubuntu:~$ ls -ls /bin/ping 44 -rwsr-xr-x 1 root root 44168 May 7 2014 /bin/ping vagrant@ubuntu:~$ cp /bin/ping anotherping vagrant@ubuntu:~$ ls -ls anotherping 44 -rwxr-xr-x 1 vagrant vagrant 44168 Aug 27 03:27 anotherping vagrant@ubuntu:~$ ping -c1 www.163.com PING 163.xdwscache.ourglb0.com (112.90.246.87) 56(84) bytes of data. 64 bytes from ns.local (112.90.246.87): icmp_seq=1 ttl=63 time=11.9 ms ... vagrant@ubuntu:~$ ./anotherping -c1 www.163.com ping: icmp open socket: Operation not permitted vagrant@ubuntu:~$ sudo setcap cap_net_raw+ep ./anotherping vagrant@ubuntu:~$ ./anotherping -c1 www.163.com PING 163.xdwscache.ourglb0.com (112.90.246.87) 56(84) bytes of data. 64 bytes from ns.local (112.90.246.87): icmp_seq=1 ttl=63 time=12.4 ms ...
其二,一個進程的uid和gid在不同的USER namespace是可以不一樣的,這需要一個namespace內部映射到namespace外部的映射關系。這樣當一個USER namespace中的進程的操作可能影響到外部系統時,可以對這個進程的權限進行檢查。如果一個用戶ID在USER namespace中沒有映射關系,則
getuid()
系統調用會返回/proc/sys/kernel/overflowuid
值作為用戶ID,這個值默認為65534,就如我們前面程序中輸出一樣(gid對應的文件名為overflowgid)。其三,盡管通過clone系統調用創建的子進程在新的USER namespace中有所有權限,但是它在
parent user namespace
是沒有任何權限的,即便以root身份運行也是一樣。user namespace
的創建可以是嵌套的,一個user namespace
一定有個parent user namespace
,可以有零或者多個child user namespace
。子進程的parent user namespace
就是調用clone()
或者unshare()
通過CLONE_NEWUSER
的flag創建新namespace的那個父進程的user namespace
。
7.2 映射uid和gid
創建新的user namespace
之后第一步就是設置好user和group的映射關系。這個映射通過設置/proc/PID/uid_map(gid_map)
實現,格式如下:
ID-inside-ns ID-outside-ns length
不是所有的進程都能隨便修改映射文件的,必須同時具備如下條件:
- 修改映射文件的進程必須有PID進程所在user namespace的
CAP_SETUID/CAP_SETGID
權限。進程的capability一般是通過其可執行文件的capability獲得。 - 修改映射文件的進程必須是跟PID在同一個user namespace或者PID的parent user namespace。
- 映射文件
uid_map
和gid_map
只能寫入一次,再次寫入會報錯。
下面來測試下7.1中的例子:
#在第一個終端運行 ns_user
vagrant@ubuntu:~/nstest$ ./ns_user x
eUID = 65534; eGID = 65534; capabilities: = ...ep
#在第二個終端寫入該進程對應的uid_map
vagrant@ubuntu:~/nstest$ ps -C ns_user -o 'pid ppid uid comm'
PID PPID UID COMMAND
8775 8577 1000 ns_user
8776 8775 1000 ns_user
vagrant@ubuntu:~/nstest$ echo '0 1000 1' > /proc/8776/uid_map
#第一個終端此時輸出為:
vagrant@ubuntu:~/nstest$ ./ns_user x
eUID = 0; eGID = 65534; capabilities: = ...ep
#在第二個終端繼續寫入gid_map
vagrant@ubuntu:~/nstest$ echo '0 1000 1' > /proc/8776/gid_map
#第一個終端此時輸出為:
vagrant@ubuntu:~/nstest$ ./ns_user x
eUID = 0; eGID = 0; capabilities: = ...ep
可以看到,我們在位于parent user namespace
的bash進程中通過echo命令修改uid_map
和gid_map
都是可以成功的。這是因為我的測試環境的bash
進程具有CAP_SETUID
和CAP_SETGID
權限的,查看/proc/PID/status
可以驗證進程的權限或者getcap
可以驗證一個可執行文件的權限,如下驗證bash的權限,如果bash原來沒有這兩個權限,可以通過命令sudo setcap cap_setgid,cap_setuid+ep /bin/bash
設置:
vagrant@ubuntu:~/nstest$ cat /proc/$$/status | egrep 'Cap(Inh|Prm|Eff)'
CapInh: 0000000000000000
CapPrm: 00000000000000c0
CapEff: 00000000000000c0
vagrant@ubuntu:~/nstest$ getcap /bin/bash
/bin/bash = cap_setgid,cap_setuid+ep
這里有個要注意的地方,ubuntu14.04的/bin/bash文件默認就有修改新的user namespace進程的uid_map
的權限,如果要修改gid_map
要另外加下cap_setgid
權限。而其他的可執行文件,默認也是只有cap_setuid
權限,比如網上很多文章中提到的一個設置user namespace的例子,在ubuntu14.04里面設置gid_map會失敗,因為可執行文件沒有cap_setgid
權限,需要加上gid權限才能成功修改gid_map
。
看這個例子,代碼 ns_child_exec.c,執行后可以發現在新的user namespace里面的bash里面通過echo命令設置uid_map和gid_map都會失敗,這是因為當一個非root用戶的進程執行execve()時,進程的capability會被清空。于是,子進程雖然有新的user namespace所有的權限集合,但是通過它exevce執行的bash進程以及bash進程的子進程是沒有對應的capability的。
vagrant@ubuntu:~/nstest$ ./ns_child_exec -U bash
nobody@ubuntu:~/nstest$ id -u #新的user namespace運行的bash進程
65534
nobody@ubuntu:~/nstest$ id -g
65534
nobody@ubuntu:~/nstest$ echo '0 1000 1' > /proc/$$/uid_map
bash: echo: write error: Operation not permitted
nobody@ubuntu:~/nstest$ echo '0 1000 1' > /proc/$$/gid_map
bash: echo: write error: Operation not permitted
為了設置映射文件,因此需要在父進程中設置,示例代碼 userns_child_exec.c。注意一點的是,要在userns_child_exec進程中成功設置gid_map文件,需要給可執行文件加上 cap_setgid權限
,此外,還要保證 /bin/bash
是有cap_setgid
權限的:
root@ubuntu:~/nstest# setcap cap_setgid+ep ./userns_child_exec
vagrant@ubuntu:~/nstest$ ./userns_child_exec -U -M '0 1000 1' -G '0 1000 1' bash
root@ubuntu:~/nstest# id -u # 新的user namespace
0
root@ubuntu:~/nstest# id -g
0
root@ubuntu:~/nstest# cat /proc/$$/status | egrep 'Cap(Inh|Prm|Eff)'
CapInh: 0000000000000000
CapPrm: 0000003fffffffff
CapEff: 0000003fffffffff
最后一點要注意的是,uid_map文件里面的 ID-outside-ns 這個值是根據當前讀取文件的user namespace生成的,這個是什么意思呢?看下面的例子就明白了。在兩個終端里面分別運行 userns_child_exec
程序,設置不同的ID-inside-ns,運行結果如下所示。也就是說,我們在初始的user namespace創建了2個child user namespace,一個是映射的uid為0,另一個映射的為200,在第一個終端看第二個終端進程對應的映射關系時可以發現uid_map
值為 200 0 1
,也就是說第二個user namespace中的進程用戶ID映射到了當前user namespace的uid 0,而不是初始的user namespace的1000。從第二個終端里面看第一個終端的進程的uid_map正好反轉。當然,你如果在第三個終端從初始的user namespace里面去看uid_map,是跟之前一樣的。
# 第一個終端,映射 0 -> 1000
vagrant@ubuntu:~/nstest$ ./userns_child_exec -U -M '0 1000 1' -G '0 1000 1' bash
root@ubuntu:~/nstest# id -u
0
root@ubuntu:~/nstest# id -g
0
root@ubuntu:~/nstest# echo $$
25730
root@ubuntu:~/nstest# cat /proc/$$/uid_map
0 1000 1
root@ubuntu:~/nstest# cat /proc/26091/uid_map
200 0 1
# 第二個終端,映射 200->1000
vagrant@ubuntu:~/nstest$ ./userns_child_exec -U -M '200 1000 1' -G '200 1000 1' bash
I have no name!@ubuntu:~/nstest$ id -u
200
I have no name!@ubuntu:~/nstest$ echo $$
26091
I have no name!@ubuntu:~/nstest$ cat /proc/$$/uid_map
200 1000 1
I have no name!@ubuntu:~/nstest$ cat /proc/25730/uid_map
0 200 1
# 第三個終端,初始user namespace里面查看映射關系
vagrant@ubuntu:~/nstest$ cat /proc/25730/uid_map
0 1000 1
vagrant@ubuntu:~/nstest$ cat /proc/26091/uid_map
200 1000 1
之前我們提到的docker示例中,沒有對user namespace進行隔離。user namespace功能雖然在很早就出現了,但是直到Linux kernel 3.8之后這個功能才逐步穩定。docker1.10之后的版本可以通過在docker daemon啟動時加上--userns-remap=[USERNAME]
來實現USER Namespace的隔離,在實際使用中我們暫時沒有用到USER namespace的隔離,不過docker對于CAP很早就有使用的,所以可以看到容器啟動的時候如果需要特定功能的需要加--cap-add SYS_ADMIN,NET_ADMIN
這些參數。
8 總結
docker使用的不是新技術,但是著實給開發部署以及應用調度帶來了很大的便利性。特別是docker的overlay網絡可以實現容器之間的跨主機通信,功能很強大。當然docker overlay網絡在大規模使用的時候我們項目中也遇到了一些坑,比如在docker1.13.1版本中容器ip在不同主機復用的時候會導致容器無法連通問題,升級到17.05-ce發現出現了另外一個重啟容器后容器之間網絡連通存在五分鐘延遲的問題,后來升級到17.06.2-ce以后才解決overlay網絡的BUG。
總體來說,docker現在的版本比較穩定,在線上跑過200+容器,除了overlay網絡那個問題外,基本沒有出現過大的BUG,值得一試。
參考資料
- https://blog.yadutaf.fr/2014/01/19/introduction-to-linux-namespaces-part-1,2,3,4,5/
- https://lwn.net/Articles/531114/#series_index
- http://pipul.org/2016/02/create-the-container-virtual-network-by-veth-model/
- http://man7.org/linux/man-pages/man7/capabilities.7.html
- http://rk700.github.io/2016/10/26/linux-capabilities/
- http://blog.siphos.be/2013/05/overview-of-linux-capabilities-part-1/
- https://coolshell.cn/articles/17010.html