自制Docker系列实战——Linux下利用namespace进行进程隔离
2022-07-22 20:07:37 # Docker # 容器 # 读书笔记

说明

docker是当今十分流行的容器快速建构工具,容器技术快速的代替了硬件虚拟化技术成为隔离和计算资源限制的主流技术。容器的技术基础是建立在Linux下的namespace和cgroups的基础上(windows下利用了wsl技术),并且依靠AUFS技术将各类文件系统统一为一个接口,为用户提供了便捷的接口。

众所周知,Docker是用go语言开发的,我将模仿docker,通过go语言实现一个具有最基本功能的容器工具。它的技术栈并复杂,就像某位行业大牛说的,一个人们常用的程序,往往核心代码只有几十行。(我忘了具体谁说的,以及说的原话,找到以后回来补充。)

不要畏难,docker,你也可以实现!

书籍及其资料链接:

《自己动手实现Docker》

《Docker容器与容器云》

Linux编程手册 自查 namespace部分

docker是如何实现资源的隔离和限制的?

docker是通过namespace实现进程资源的隔离,再通过cgroups实现资源的限制,通过写时复制(copy-on-write)实现高效的文件操作。

首先,我们来讲解namespace的基础知识,然后用go写出最简单的进程隔离的小程序。

namespace 技术基础及go代码实战

基础知识

Linux内核实现namespace的主要目的就是实现轻量级的虚拟化(容器)服务。Linux用户可以创建指定的namespace并且将要隔离的进程放入该namespace中,这表示从当前的系统运行环境中隔离一个进程的运行环境,在此namespace中的进程将认为自己拥有独立的资源。

其实linux在开机的时候,就会创建一个root namespace,该namespace直接基于内核,而用户创建的namespace运行环境是基于root namespace上的。新的namespace的默认值会自动继承原本的namespace。

比如用户当前在namespace1,新建的namespace2中的各类资源的值都与namespace1中相同。

avatar

avatar

首先要知道,容器必有独立于物理机的IP、端口、路由,这就想到了网络隔离(network)。容器也要有独立的主机名以便在网路中被识别到,这就是UTS隔离。有网络,就不能不说进程之间的通信,就要有进程之间通信需要隔离,这就是IPC隔离。开发者又要想到权限问题,对用户及其用户组的隔离,这就是User隔离。同时,容器还要挂载其他设备,这就设计到了mount隔离,还要有PID的隔离

总之,主要的隔离在最新的Linux有八种(2022/07/22),这些都叫做namespace。在linux中,有专门的系统调用(system call)去实现以下八种隔离。

八种 namespace:

  • Cgroup ,系统调用标识符为:CLONE_NEWCGROUP (since Linux 4.6) 控制进程使用的资源隔离
  • UTS ,系统调用标识符为: CLONE_NEWUTS (since Linux 2.6.19) 隔离主机名和域名
  • IPC ,系统调用标识符为:CLONE_NEWIPC (since Linux 2.6.19) 隔离进程间通信
  • PID ,系统调用标识符为: CLONE_NEWPID (since Linux 2.6.24) 隔离PID
  • Network ,系统调用标识符为:CLONE_NEWNET (since Linux 2.4.19) 隔离网络空间
  • Mount ,系统调用标识符为:CLONE_NEWNS (since Linux 2.4.19) 隔离挂载点,文件系统
  • User ,系统调用标识符为:CLONE_NEWUSER 隔离用户和用户组
  • Time , 系统调用标识符为:CLONE_NEWTIME (since Linux 5.8) 隔离时间

通过对这八种namespace隔离,就可以让被隔离的进程产生”错觉“,让它们认为自己在一个独立的系统环境中,以此达到独立和隔离的目的。

注意:我们会实现其中七种隔离,除去time namespace。

实现Namespace的系统调用

namespace的API有三种clone()\setns()\unshare()以及/proc下的部分文件。

通过 clone() 在创建新进程的同时创建namespace

在linux系统下,可以通过命令man clone查看该系统调用的详细信息。找到CLONE_NEW开头的系统调用标识符。

avatar

avatar

1
2
3
4
5
int clone(int *(child_func)(void *),void *child_stack,int flags,void *args);
// child_func 是传入子程序运行的程序主函数
// child_stack 传入子程序使用的栈空间
// flags表示要使用哪些CLONE_*标识符,若使用多个标识符,则标识符中间使用 | 来分割。
// args表示传入的用户参数

后续我们使用golang在使用这一系统调用。

通过 setns()加入一个已存在的namespace

通过使用setns()系统调用,可以将进程移入另一个namespace中。为了不影响进程的调用者,也为了使新加入的pid namespace生效,会在setns()函数执行后,使用clone()创建子进程执行命令,让原先的进程结束运行。

1
2
3
4
5
6
int setns(int fd,int nstype);
//int fd 表示要加入的namespace的文件描述符,它是一个指向/proc/[pid]/ns目录的文件描述符.
//可以通过直接打开目录下的链接或者打开一个挂载了该目录下的链接的文件得到,
//或者是pid文件描述符 see pidfd_open(2)

//int nstype 它让调用者检查fd指向的namespace类型是否符合实际要求。该参数为0表示不检查。
通过 unshare() 在原先的进程上进行namespace隔离

它与clone()很像,不同的是,unshare()运行在原先的进程上,不需要再启动一个新进程。

1
int unshare(int flags);

该系统调用是不启动一个新进程就可以起到隔离的效果。相当于跳出了原来的namespace进行操作,再原进程中进行一些需要隔离的操作。Linux中自带的unshare的命令,就是通过unshare系统调用实现的。

查看/proc/[pid]/ns文件

首先要说明的是,/proc文件系统是一种内核和内核模块用来向进程发送消息的机制,所以它是proc,即process的缩写。这个文件系统是伪文件系统,它是将内存上正在运行的进程挂载到根目录,并且/proc不像其他文件,它是存在内存上的。通过对/proc内的文件进行操作,就相当于对进程进行操作,这是一个非常好的接口。后面我们会发现,cgroups也是这样的接口,通过对文件的修改,就是对接口的操作,Linux牛逼!

我们进入/proc目录。

avatar

可以看到,该目录下有很多以数字命名的文件,其中的数字是系统正在运行的进程的PID。

进入这些PID文件,

avatar

运行sudo ls -l ns,就可以看到该PID进程所属的namespace了

avatar

当namespace编号相同时,就能说明它们是在同一个namespace下,否则便在不同的namespace中。需要注意的是,当该进程结束运行后,但namespace的fd依然处于被打开的状态,该namespace依然存在,后续进程也可继续加入到该namespace中。

UTS Namespace 及 go语言实现UTS隔离

UTS Namespace 主要用来隔离nodename(主机名)和domainname(域名)的两个系统标识。这可以让容器拥有独立的主机名和域名,在网络上被当作一个独立的节点,而不是物理机的进程。

go实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// filename: utsns.go
package main

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

func main(){
cmd := exec.Command("bash")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
} // 标识符为CLONE_NEWUTS的系统调用clone

cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil{
log.Fatal(err)
}
}

执行 sudo go run utsns.go,再输入pstree -pl 得到进程之间的关系,找到utsns进程及其后续的bash进程,得到他们的PID。

分别输入readlink '/proc/PID/ns/uts' 得到他们各自的uts,会发现他们的uts并不相同。

avatar

继续修改hostname,观察被隔离后的进程和物理机的hostname是否相互影响。

avatar

可以看到,我们成功实现了UTS Namespace的隔离。

IPC Namespace 及 go语言实现IPC隔离

UTS Namespace 主要用来隔离System V IPC和POSIX message queues。

说明:

  • System V IPC: 进程间通信机制
  • POSIX message queues:消息队列(类似与go语言的通道)

go实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// filename: ipcns.go
package main

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

func main(){
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS|syscall.CLONE_NEWIPC,
} // 标识符为CLONE_NEWUTS以及CLONE_NEWIPC的系统调用clone

cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil{
log.Fatal(err)
}
}

首先,为了验证我们的代码是否能完成对IPC的隔离,我们需要在物理机创建一个IPC,然后打印出物理机当前所有的IPC。之后,运行代码,在新的sh中再次打印出隔离情况下的所有IPC,看是否存在物理机的IPC,若不存在,则说明IPC隔离完成。

我们在物理机创建一个IPC,输入命令ipcmk -Q,然后会回显新IPC的ID号。

我们查看当前物理机中的所有IPC,输入ipcs -q,会打印当前物理机中的所有IPC

avatar

我们运行代码,再次在隔离中查看所有的IPC,看是否有物理机中建立的IPC。

sudo go run ipcns.go紧接着,运行ipcs -q.

avatar

可以看到,我们成功隔离了IPC。

PID Namespace 及 go语言实现PID隔离

准备

PID Namespace 主要用来隔离PID,例如实用的PID重新标号的功能。它可以让不同namespace中的程序拥有相同的PID。每个PID namespace都有自己的PID计数器,内核为PID namespace维护了一个树状结构。 每个namespace中PID为1的进程类似与Linux系统初始化的第一个程序init。通过树状结构,PID namespace形成了一个层级结构,父进程可以看到子进程的内容并且有权限kill掉子进程,而反过来却不行。具体表现为/proc文件系统。那么,拥有最高等级的root namespace中可以看到所有的进程,以及它们递归形成的所有子节点中的进程。

到这里,我们在后面就可以通过该方法,实现物理机监视容器的内部情况,通过PID namespace的机制就可以做到。

go实现代码如下:

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
// filename: pidns.go
package main

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

func main(){
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS|syscall.CLONE_NEWIPC|syscall.CLONE_NEWPID,
} // 标识符为CLONE_NEWUTS、CLONE_NEWIPC以及CLONE_NEWPID的系统调用clone

cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil{
log.Fatal(err)
}
}

同样,为了检验我们是否隔离了PID,我们需要通过一些命令来做些实验。

我们需要查看当前进程的PID。

我们使用echo $$来查看当前进程的PID。

avatar

可以看到,我们暂时成功隔离了PID。

但是我们执行命令ps a

avatar

由于ps调用了/proc目录的内容,下面,我们来解决这个麻烦。

挂载proc文件系统

在新的PID namespace中使用ps命令,看到了还是所有的进程的信息,而不是在我们隔离区域的进程,这是因为与PID直接相关的/proc文件系统(procfs)没有挂载到一个与原来/proc不同的位置。如果想真正的做到PID的隔离,那么我们需要挂载/proc文件系统。

在隔离的环境下执行命令mount -t proc proc /proc,然后执行ps a。即可得到我们想要的结果。

avatar

没有隔离Mount namespace进行mount带来的问题

注意,我们还没有进行mount namespace的隔离,此时在被隔离区域执行mount是会产生一些错误的。由于我们通过未被隔离的mount挂载了隔离区的/proc文件系统,这就导致我们返回物理机时的报错和在物理机执行ps a命令的报错。

avatar

这个时候,我们重新挂载我们物理机上对应的/proc文件系统即可。

sudo mount -t proc proc /proc,然后执行ps a,即成功回显。

avatar

Mount Namespace 及 go语言实现Mount隔离

Mount Namespace 主要用来隔离各个进程看到的挂载点情况。它是Linux历史上第一个namespace,所以标识符为CLONE_NEWNS (New Namespace)。在不的namespace中,看到的文件系统的层次是不同的。在mount namespace中,mount()umount()只是影响当前的Mount namespace,而不会影响全局的文件系统。

go实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// filename: mountns.go
package main

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

func main(){
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS|syscall.CLONE_NEWIPC|syscall.CLONE_NEWPID|syscall.CLONE_NEWNS,
} // 标识符为CLONE_NEWUTS、CLONE_NEWIPC、CLONE_NEWPID以及CLONE_NEWNS的系统调用clone

cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil{
log.Fatal(err)
}
}

通过工具来看一下。

avatar

先在物理机中查看/proc,运行代码后,进行mount隔离,然后挂载新的namespace的/proc文件,再次查看/proc,便发现物理机和被隔离的情况的proc文件系统的不同。

User Namespace 及 go语言实现User隔离

User Namespace 主要用来隔离用户ID和用户组ID,安全相关的标识符和属性,以及root目录和key及其特殊权限。

白话一些,就是一个用户的进程通过clone() 创建的新进程在新的user namespace中可以拥有不同的用户和用户组。

go实现代码如下:

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
46
47
// filename: userns.go
package main

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

func main(){
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS|syscall.CLONE_NEWIPC|
syscall.CLONE_NEWPID|syscall.CLONE_NEWNS|
syscall.CLONE_NEWUSER,

UidMappings: []syscall.SysProcIDMap{
{
//
ContainerID: 88,
HostID: 88,
Size: 1,
}
}

GidMappings: []syscall.SysProcIDMap{
{
//
ContainerID: 66,
HostID: 66,
Size: 1,
}
}
}



cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil{
log.Fatal(err)
}
os.Exit(-1)
}

avatar

可以看到,我们发现uid及其gid在隔离区与物理区是不同的。

注意的是

  • 当user namespace被创建时,第一个进程被赋予了该namespace中全部的权限,这样第一个进程就能完成该namespace中所有进程的初始化,避免产生权限错误。
  • 从namespace内部观察到的UID和GID已经与外部不同,默认显示65534,表示尚未与外部namespace进行映射。

Network Namespace 及 go语言实现network隔离

Network Namespace 主要是用来隔离网络资源,包括网络设备、IPv4和IPv6协议栈、IP路由表、防火墙、/proc/net目录、/sys/class/net目录,套接字等等。一个现实中的网络设备只能存在于一个network namespace中。我们在后续会介绍如何实现物理机与容器间的通信,以及容器与容器之间的通信。

想象你满怀期待的运行你的HTTP Server服务,但是系统报错,返回”80端口被占用“的情况,这时就需要使用network namespace的技术去创建一个新的隔离区域。

go实现代码如下:

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
46
47
// filename: netns.go
package main

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

func main(){
cmd := exec.Command("sh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS|syscall.CLONE_NEWIPC|
syscall.CLONE_NEWPID|syscall.CLONE_NEWNS|
syscall.CLONE_NEWUSER|syscall.CLONE_NEWNET,

UidMappings: []syscall.SysProcIDMap{
{
//
ContainerID: 88,
HostID: 88,
Size: 1,
}
}

GidMappings: []syscall.SysProcIDMap{
{
//
ContainerID: 66,
HostID: 66,
Size: 1,
}
}
}



cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil{
log.Fatal(err)
}
os.Exit(-1)
}

为了显示物理机与隔离区域的network的区别,我们查看各自区域内的网络设备。

avatar

运行隔离代码,查看新的network namespace的网络设备

avatar

成功隔离了network。

Cgroup Namespace 及 go语言实现Cgroup隔离

在写cgroups资源限制的部分补充。