一步步自己做个Docker之Go调用Namespace

docker logo

本文环境: * OS:Ubuntu 18.04.4 LTS * Golang版本:1.12.13

Golang

Go语言是Google开发的一种静态类型、编译型的高级语言,它设计的蛮简单的,学过C的话,其实上手Go很快的,当然相比于C的话,Go有垃圾回收和并发支持,所以写起来心智负担更低一点。
对于Go的安装和配置,我以前写过一篇文章——go语言基本配置,我这里就不在赘述了。Go1.11增加了go modules,使用它的话,就没必要一定要把代码放到GOPATH下面啦(≧▽≦)/go modules详细 使用请参考go mod 使用

Go调用Namespace

其实对于Namespace这种系统调用,使用C语言描述是最好的(上一篇文章就是用C写的示例),但是C比较难,而且Docker也是用Go是实现的,所以我后面的文章都会用Go来写示例代码。
这里我先写了一个UTS Namespace的例子,UTS Namespace主要用来隔离nodenamedomainname这两个系统标识:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
exec.Command("sh")是指定了fork出来的新进程内的初始命令,cmd.SysProcAttr这行就是设置了系统调用函数,Go帮我们封装了clone()函数,syscall.CLONE_NEWUTS这个标识符标明创建一个UTS Namespace
go build .编译代码后,执行程序时我们会遇到错误fork/exec /bin/sh: operation not permitted,这是因为clone()函数需要CAP_SYS_ADMIN权限(这个问题我在v站上问过),解决方法是添加设置 uid 映射:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags:
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getgid(),
Size: 1,
},
},
}

// set identify for this demo
cmd.Env = []string{"PS1=-[namespace-process]-# "}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
我增加了CLONE_NEWUSER标识,让新进程在User Namespace中变成root用户。
1
2
3
4
5
6
$ ./uts-easy 
-[namespace-process]-# id
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
-[namespace-process]-# hostname -b bird
-[namespace-process]-# hostname
bird
启动另一个shell,查看宿主机上hostname:
1
2
$ hostname
salamander-PC
可以看到,外部的hostname并没有被内部的修改所影响,这里我们大致感受了下UTS Namespace的作用。

增加IPC Namespace

IPC Namespace用来隔离System V IPC和POSIX message queues。每一个IPC Namespace都有自己的System V IPCPOSIX message queues。我们稍微改动一下上面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags:
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWUSER |
syscall.CLONE_NEWIPC, // 增加IPC Namespace
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getgid(),
Size: 1,
},
},
}

// set identify for this demo
cmd.Env = []string{"PS1=-[namespace-process]-# "}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
新开一个shell,在宿主机上创建一个message queue:
1
2
3
4
5
6
7
8
9
10
11
12
$ ipcs -q

--------- 消息队列 -----------
键 msqid 拥有者 权限 已用字节数 消息

$ ipcmk -Q
消息队列 id:0
$ ipcs -q

--------- 消息队列 -----------
键 msqid 拥有者 权限 已用字节数 消息
0xc59399dd 0 salamander 644 0 0
运行我们自己的程序:
1
2
3
4
5
$ ./uts-easy 
-[namespace-process]-# ipcs -q

------ Message Queues --------
key msqid owner perms used-bytes messages
可以看到,在新的Namespace中,看不到宿主机上创建的message queue,说明IPC是隔离的。

增加PID Namespace

PID Namespace是用来隔离进程ID的。我们自己进入Docker 容器的时候,就会发现里面的前台进程的PID为1,但是在容器外PID却不是1,这就是通过PID Namespace做到的。修改上述的代码,增加CLONE_NEWPID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags:
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWUSER |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID, // 增加PID Namespace
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getgid(),
Size: 1,
},
},
}

// set identify for this demo
cmd.Env = []string{"PS1=-[namespace-process]-# "}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
运行我们的程序:
1
2
3
 ./uts-easy 
-[namespace-process]-# echo $$
1
可以看到在新的PID Namespace中进程ID为1。
1
2
3
4
5
6
$ pstree -pl | grep easy
| | |-bash(10739)---uts-easy(10768)-+-sh(10773)
| | | |-{uts-easy}(10769)
| | | |-{uts-easy}(10770)
| | | |-{uts-easy}(10771)
| | | `-{uts-easy}(10772)
而我们在宿主机上可以看到它实际的PID(uts-easy这个进程)为10768
如果细心点,我们会发现,在我们的程序中使用pstop这些命令出来的结果跟宿主机上是一样的,这是因为这些命令其实是去使用/proc这个文件夹的内容,这个就需要下面的Mount Namespace了。

增加Mount Namespace

Mount Namespace用来隔离各个进程看到的挂载点视图。在不同Namespace的进程中,看到的文件系统层次是不一样的。在Mount Namespace中调用mount()unmount()只会影响当前Namespace内的文件系统,而对全局的文件系统是没有影响的。
看到这里,也许会想到chroot()。它也能将某一个子目录变为根节点。但是,Mount Namespace不仅能实现这个功能,而且能以更加灵活和安全的方式实现。
现在继续修改上述代码,增加CLONE_NEWNS(Mount Namespace是Linux实现的第一个Namespace类型,因为,它的系统调用参数是NEWNS,NS是New Namespace的缩写。当时人们没有意识到,以后还会有很多类型的Namespace加入Linux大家庭)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags:
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWUSER |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID|
syscall.CLONE_NEWNS, // 增加Mount Namespace
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getgid(),
Size: 1,
},
},
}

// set identify for this demo
cmd.Env = []string{"PS1=-[namespace-process]-# "}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
运行程序,查看/proc的文件内容。
1
2
3
4
-[namespace-process]-# ls /proc
1 1537 198 212 2487 2818 29 3131 329 3363 3422 3557 3793 4250 492 5273 595 62 6519 72 80 87 919 crypto kmsg schedstat vmstat
10 16 1983 213 2501 2841 2902 3139 3297 3367 3425 3571 38 430 493 53 598 6220 654 7221 8042 88 922 devices kpagecgroup scsi zoneinfo
1025 17 2 2133 2506 2846 2913 3156 ....
这里输出的结果很多,因为/proc还是宿主机的,下面将/proc mount到我们自己的Namespace下面来:
1
2
3
4
5
6
-[namespace-process]-# mount -t proc proc /proc
-[namespace-process]-# ls /proc
1 buddyinfo consoles diskstats fb iomem kcore kpagecgroup locks modules pagetypeinfo schedstat softirqs sysrq-trigger tty vmallocinfo
4 bus cpuinfo dma filesystems ioports key-users kpagecount mdstat mounts partitions scsi stat sysvipc uptime vmstat
acpi cgroups crypto driver fs irq keys kpageflags meminfo mtrr pressure self swaps thread-self version zoneinfo
asound cmdline devices execdomains interrupts kallsyms kmsg loadavg misc net sched_debug slabinfo sys timer_list version_signature
结果一下子少了很多,这里我们就可以用ps来查看系统的进程了。
1
2
3
4
-[namespace-process]-# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 17:07 pts/1 00:00:00 sh
root 5 1 0 17:11 pts/1 00:00:00 ps -ef
可以看到,当前的Namespace中,sh进程是PID为1的进程。

增加Network Namespace

Network Namespace是用来隔离网络设备、IP地址端口等网络栈的Namespace。Network Namespace可以让每个容器拥有自己独立(虚拟的)网络设备,而且容器内的应用可以绑定到自己的端口,每个Namespace内的端口都不会互相冲突。在宿主机上搭建网桥后,就能很方便地实现容器之间通讯,而且不同容器上的应用可以使用相同的端口。
继续上述代码,加入CLONE_NEWNET

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package main

import (
"log"
"os"
"os/exec"
"syscall"
)

func main() {
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags:
syscall.CLONE_NEWUTS |
syscall.CLONE_NEWUSER |
syscall.CLONE_NEWIPC |
syscall.CLONE_NEWPID|
syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET, // 增加Network Namespace
UidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getuid(),
Size: 1,
},
},
GidMappings: []syscall.SysProcIDMap{
{
ContainerID: 0,
HostID: os.Getgid(),
Size: 1,
},
},
}

// set identify for this demo
cmd.Env = []string{"PS1=-[namespace-process]-# "}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
log.Fatal(err)
}
}
运行我们的程序,查看网络设备,发现为空
1
2
-[namespace-process]-# ifconfig
-[namespace-process]-#
在宿主机上查看网络设备,发现有lo, enp7s0这些网络设备。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enp7s0: flags=4099<UP,BROADCAST,MULTICAST>  mtu 1500
ether 98:fa:9b:f0:85:c2 txqueuelen 1000 (以太网)
RX packets 0 bytes 0 (0.0 B)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 0 bytes 0 (0.0 B)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
inet6 ::1 prefixlen 128 scopeid 0x10<host>
loop txqueuelen 1000 (本地环回)
RX packets 16381 bytes 23729834 (23.7 MB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 16381 bytes 23729834 (23.7 MB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
....
从上面的结果我们可以看出Network是隔离了。