容器技术原理(二):使用 Namespace 实现进程隔离

Namespace 是由 Linux 内核提供的一种特性,它能够将一些系统资源包装到一个抽象的空间中,并使得该空间中的进程以为这些资源是系统中仅有的资源。Namespace 是构建容器技术的基石,它使得容器内的进程只能看到容器内的进程和资源,实现与宿主系统以及其他容器的进程和资源隔离。

Namespace 按操作的系统资源不同有很多种类,比如 cgroup namespace,mount namespace 等等,接下来我们仅以 pid namespace 为例,以 runC 作为容器运行时实现,来演示当我们执行对容器的操作时,namespace 是如何工作的。

在上一篇文章中我们已经介绍过,绝大部分容器系统都使用 runC 作为底层的运行时实现,如果你是在 Linux 发行版系统中使用 docker ,甚至不需要专门安装就能使用 runc 命令。

准备工作

filesystem bundle

runC 只能从 filesystem bundle 中执行容器(filesystem bundle 顾名思义就是一个满足特定结构的文件夹),但是我们可以使用 docker 来准备一个可用的 bundle :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 创建 bundle 的顶层目录
$ mkdir /mycontainer
$ cd /mycontainer

# 创建用于存放 root filesystem 的 rootfs 目录
$ mkdir rootfs

# 利用 Docker 导出 busybox 容器的 root filesystem 
$ docker export $(docker create busybox) | tar -C rootfs -xvf -

# 创建一个 config.json 作为整个 bundle 的 spec
$ runc spec

此时整个 bundle 的目录结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ tree -L 2 /mycontainer

/mycontainer
├── config.json
└── rootfs
    ├── bin
    ├── dev
    ├── etc
    ├── home
    ├── proc
    ├── root
    ├── sys
    ├── tmp
    ├── usr
    └── var

系统监测工具

为了完成演示,我们需要一些第三方的系统监测工具作为辅助:

  1. 监测进程的启动以获得容器中运行进程的 PID,如 ubuntu 中的 forkstat ,它可以实时地监测 fork(), exec()exit() 等系统调用,安装方式如下:

    1
    
    $ apt install forkstat
    
  2. 查看 namespace 信息,如 [cinf](https://github.com/mhausenblas/cinf) ,它是一个能够方便地列出系统中所有 namespace 或查看某个 namespce 详细信息的命令行工具,安装方式如下:

    1
    2
    3
    4
    5
    
    $ curl -s -L https://github.com/mhausenblas/cinf/releases/latest/download/cinf_linux_amd64.tar.gz \
         -o cinf.tar.gz && \
         tar xvzf cinf.tar.gz cinf && \
         mv cinf /usr/local/bin && \
         rm cinf*
    

使用 runc 运行容器

首先我们需要在一个窗口中运行 forkstat

1
$ forkstat -e exec

接着另外新建一个终端窗口,切换到 /mycontainer 目录,使用 runC 运行容器:

1
$ runc run mybox

执行后会直接进入到新创建的容器中,运行 ps 命令:

1
2
3
PID   USER     TIME  COMMAND
    1 root      0:00 sh
    7 root      0:00 ps

forkstat 窗口将会有以下输出:

1
2
3
4
5
6
7
8
Time     Event     PID Info   Duration Process
12:35:22 exec    33040                 runc run mybox
12:35:22 exec    33047                 runc init
12:35:22 exec    33049                 dumpe2fs -h /dev/sdb3
12:35:22 exec    33050                 dumpe2fs -h /dev/sdb3
12:35:22 exec    33047                 runc init
12:35:22 exec    33052                 sh
12:35:37 exec    33062                 ps

从同步打印的结果可以判断, psforkstat 所分别输出的 shps 实际上是同一个进程,但由于容器中的进程位于一个单独的 pid namespace 中,它们在容器中拥有另外的 PID,而且它们以为自己是容器中唯一存在的进程,因此 PID 会从 1 开始。

找到进程所属的 namespace

现在来找出容器所使用的 pid namespace,为此需要调整一下 ps 命令的输出格式:

1
2
3
$ ps -p 33052 -o pid,pidns
PID      PIDNS  
33052 4026532395

PIDNS 即 pid namespace,以上命令可得到 PID 为 33052 的 sh 进程属于 4026532395 这个 pid namespace。既然已经有了容器中进程的 PID,实际上我们可以通过宿主机的 /proc 文件系统获得该进程所属的所有 namespace:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ ll /proc/33052/ns
lrwxrwxrwx 1 root root 0  7月 21 12:37 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 ipc -> 'ipc:[4026532394]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 mnt -> 'mnt:[4026532383]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 net -> 'net:[4026532397]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 pid -> 'pid:[4026532395]'
lrwxrwxrwx 1 root root 0  7月 21 12:37 pid_for_children -> 'pid:[4026532395]'
lrwxrwxrwx 1 root root 0  7月 21 12:37 time -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0  7月 21 12:37 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0  7月 21 12:36 uts -> 'uts:[4026532393]'

打印结果展示了一个进程所属的 namespace:

  1. 每个 namespace 都是一个软链接,软链接的名称指示了 namespace 的类型,如 cgroup 表示 cgroup namespace, pid 表示 pid namespace。
  2. 每个软链接指向该进程所属的真正 namespace 对象,该对象用 inode 号码表示,每个 inode 号码在宿主系统中都是唯一的。
  3. 如果有两个进程的同一类型 namespace 软链接都指向同一个 inode ,说明他们属于同一个 namespace。

实际上所有的进程都会属于至少一个 namespace,Linux 系统在启动时就会为所有类型创建一个默认的 namespace 供进程使用。

我们也可以尝试在容器内获得 sh 所属的 namespace,此时需要在容器内使用 1 这个 PID:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ ls -l /proc/1/ns
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 cgroup -> cgroup:[4026531835]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 ipc -> ipc:[4026532394]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 mnt -> mnt:[4026532383]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 net -> net:[4026532397]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 pid -> pid:[4026532395]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 pid_for_children -> pid:[4026532395]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 time -> time:[4026531834]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 time_for_children -> time:[4026531834]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 user -> user:[4026531837]
lrwxrwxrwx    1 root     root             0 Jul 21 04:37 uts -> uts:[4026532393]

观察 namespace 中的进程

下面我们将从 namespace 的角度,来观测 pid namespace 中的所有进程。Linux 系统并未提供类似的功能,因此需要借助上文安装的 cinf 工具来实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ cinf -namespace 4026532395

 PID    PPID   NAME  CMD  NTHREADS  CGROUPS                                                          STATE

 33052  33052  sh    sh   1         12:devices:/user.slice/mybox                                     S (sleeping)
                                    11:blkio:/user.slice/mybox 10:rdma:/
                                    9:memory:/user.slice/user-0.slice/session-590.scope/mybox
                                    8:net_cls,net_prio:/mybox 7:freezer:/mybox
                                    6:pids:/user.slice/user-0.slice/session-590.scope/mybox
                                    5:cpu,cpuacct:/user.slice/mybox 4:cpuset:/mybox
                                    3:perf_event:/mybox 2:hugetlb:/mybox
                                    1:name=systemd:/user.slice/user-0.slice/session-590.scope/mybox
                                    0::/user.slice/user-0.slice/session-590.scope

目前这个 namespace 中只有一个进程,这个进程也是我们所创建容器的 init 进程。当一个新的容器被创建时,系统将创建一些新的 namespace,容器的 init 进程将被加入到这些 namespace。

对于 pid namespace 来说,容器中运行的所有进程只能看到位于同一 pid namespace 即 pid:[4026532395] 中的其他进程。sh 进程在容器中被认为是系统运行的第一个进程,PID 为 1,但在宿主机中只是一个 PID 为 33052 的普通进程,同一个进程在不同 namespace 中拥有不同的 PID,这就是 pid namespace 的作用。某种程度上,容器就意味着一个新的 namespace 集合。

在容器中创建新的进程

创建一个新的终端窗口,在已运行的容器中运行一个新的进程:

1
$ runc exec mybox /bin/top -b

forkstat 窗口中,我们可以看到新创建进程的 PID:

1
2
3
4
5
Time     Event     PID Info   Duration Process
12:40:23 exec    33132                 runc exec mybox /bin/top -b
12:40:23 exec    33140                 runc init
12:40:23 exec    33140                 runc init
12:40:23 exec    33142                 /bin/top -b

实际上还有更直接的方式从宿主机中查看容器中运行的进程,我们可以使用 runC 提供的 ps 子命令:

1
2
3
4
$ runc ps mybox
UID          PID    PPID  C STIME TTY          TIME CMD
root       33052   33040  0 12:35 pts/0    00:00:00 sh
root       33142   33132  0 12:40 pts/1    00:00:00 /bin/top -b

接下来依然使用 cinf 来找出新创建进程所属的 namespace:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ cinf --pid 33142

 NAMESPACE   TYPE

 4026532383  mnt
 4026532393  uts
 4026532394  ipc
 4026532395  pid
 4026532397  net
 4026531837  user

从结果来看,并没有新的命名空间被创建,32608 进程的 namespace 和 mybox 容器的 init 进程- sh 所属的 namespace 是完全相同的。也就是说,在容器中创建一个新的进程,只是将这个进程加入到了容器 init 进程所属的 namespace。

下面来列出 4026532395 namespace 所拥有的所有进程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ cinf --namespace 4026532395

 PID    PPID   NAME  CMD     NTHREADS  CGROUPS                                                          STATE

 33052  33040  sh    sh      1         12:devices:/user.slice/mybox                                     S (sleeping)
                                       11:blkio:/user.slice/mybox 10:rdma:/
                                       9:memory:/user.slice/user-0.slice/session-590.scope/mybox
                                       8:net_cls,net_prio:/mybox 7:freezer:/mybox
                                       6:pids:/user.slice/user-0.slice/session-590.scope/mybox
                                       5:cpu,cpuacct:/user.slice/mybox 4:cpuset:/mybox
                                       3:perf_event:/mybox 2:hugetlb:/mybox
                                       1:name=systemd:/user.slice/user-0.slice/session-590.scope/mybox
                                       0::/user.slice/user-0.slice/session-590.scope
 33142  33132  top   top -b  1         12:devices:/user.slice/mybox                                     S (sleeping)
                                       11:blkio:/user.slice/mybox 10:rdma:/
                                       9:memory:/user.slice/user-0.slice/session-590.scope/mybox
                                       8:net_cls,net_prio:/mybox 7:freezer:/mybox
                                       6:pids:/user.slice/user-0.slice/session-590.scope/mybox
                                       5:cpu,cpuacct:/user.slice/mybox 4:cpuset:/mybox
                                       3:perf_event:/mybox 2:hugetlb:/mybox
                                       1:name=systemd:/user.slice/user-0.slice/session-590.scope/mybox
                                       0::/user.slice/user-0.slice/session-590.scope

如果在容器内运行 ps -ef ,我们也能看到这些进程,由于 pid namespace 的原因它们的 PID 将会有所不同:

1
2
3
4
PID   USER     TIME  COMMAND
    1 root      0:00 sh
   19 root      0:00 top -b
   20 root      0:00 ps -ef

现在我们知道,docker/runc exec 实际上就是在已创建容器的 namespace 中运行一个新的进程。

总结

运行一个容器时,将创建一些新的 namespace, init 进程将被加入到这些 namespace;在一个容器中运行一个新进程时,新进程将加入创建容器时所创建的 namespace。

实际上创建容器时新建 namespace 这种行为是可以改变的,我们可以指定新建的容器使用已有的 namespace。

参考链接

updatedupdated2023-06-062023-06-06