写点什么

使用 Shell 脚本实现 Docker

用户头像
KernelNewbies
关注
发布于: 2021 年 05 月 17 日
使用 Shell 脚本实现 Docker

《使用 Shell 脚本实现 Docker》旨在通过一系列的实验使用户对 docker 的底层技术,如 Namespace、CGroups、rootfs、联合加载等有一个感性的认识。在此过程中,我们还将通过 Shell 脚本一步一步地实现一个简易的 docker,以期使读者在使用 docker 的过程中知其然知其所以然。


我们的实验环境为 Ubuntu 18.04 64bit,简易 docker 工程的名字为 docker.sh,该工程仓库地址如下:

https://github.com/pandengyang/docker.sh.git
复制代码

《使用 Shell 脚本实现 Docker》目录如下:

1. Namespace1.1. Namespace简介1.2. uts namespace1.2.1. uts namespace简介1.2.2. docker.sh1.3. mount namespace1.3.1. /etc/mtab、/proc/self/mounts	1.3.2. /proc/self/mountinfo	1.3.3. bind mount1.3.4. mount namespace简介1.3.5. docker.sh1.4. pid namespace1.4.1. unshare的--fork选项1.4.2. pid namespace简介1.4.3. pid嵌套1.4.4. docker.sh2. CGroups2.1. CGroups简介2.2. 限制内存	2.2.1. 用CGroups限制内存	2.2.2. docker.sh3. 切换根文件系统3.1. 根文件系统3.2. pivot_root3.3. docker.sh4. 联合加载4.1. 联合加载简介4.2. AUFS4.3. docker.sh5. 卷5.1. 卷简介5.2. docker.sh6. 后记
复制代码

1.Namespace

1.1.Namespace 简介

传统上,在 Linux 中,许多资源是全局管理的。例如,系统中的所有进程按照惯例是通过 PID 标识的,这意味着内核必须管理一个全局的 PID 列表。而且,所有调用者通过 uname 系统调用返回的系统相关信息都是相同的。用户 id 的管理方式类似,即各个用户是通过一个全局唯一的 UID 标识。

Namespace 是 Linux 用来隔离上述全局资源的一种方式。把一个或多个进程加入到同一个 namespace 中后,这些进程只会看到该 namespace 中的资源。namespace 是后来加入到 Linux 中的,为了兼容之前的全局资源管理方式,Linux 为每一种资源准备了一个全局的 namespace。Linux 中的每一个进程都默认加入了这些全局 namespace。

Linux 中的每个进程都有一个/proc/[pid]/ns/目录,里面包含了该进程所属的 namespace 信息。我们查看一下当前 Shell 的/proc/[pid]/ns 目录,命令及结果如下:

phl@kernelnewbies:~$ sudo ls -l /proc/$$/nstotal 0lrwxrwxrwx 1 phl phl 0 Jan 22 08:43 cgroup -> cgroup:[4026531835]lrwxrwxrwx 1 phl phl 0 Jan 22 08:43 ipc -> ipc:[4026531839]lrwxrwxrwx 1 phl phl 0 Jan 22 08:43 mnt -> mnt:[4026531840]lrwxrwxrwx 1 phl phl 0 Jan 22 08:43 net -> net:[4026531993]lrwxrwxrwx 1 phl phl 0 Jan 22 08:43 pid -> pid:[4026531836]lrwxrwxrwx 1 phl phl 0 Jan 22 08:43 pid_for_children -> pid:[4026531836]lrwxrwxrwx 1 phl phl 0 Jan 22 08:43 user -> user:[4026531837]lrwxrwxrwx 1 phl phl 0 Jan 22 08:43 uts -> uts:[4026531838]
复制代码

该目录下有很多符号链接,每个符号链接代表一个该进程所属的 namespace。用 readlink 读取这些符号链接可以查看进程所属的 namespace id。我们读一下当前 Shell 所属的 uts namespace id,命令及结果如下:

phl@kernelnewbies:~$ sudo readlink /proc/$$/ns/utsuts:[4026531838]
复制代码

后文中我们将介绍 uts namespace、mount namespace、pid namespace 的用法。

1.2.uts namespace

1.2.1.uts namespace 简介

uts namespace 用于隔离系统的主机名等信息,我们将通过实验学习其用法。在实验过程中,我们采用如下的步骤:

  1. 查看全局 uts namespace 信息

  2. 新建一个 uts namespace,查看其信息并作出修改

  3. 查看全局 uts namespace,查看其是否被新建的 uts namespace 影响到

对于其他 namespace,我们也采取类似的步骤进行实验学习。

首先,我们查看一下全局的 hostname 及 uts namespace id。命令及结果如下:

phl@kernelnewbies:~$ hostnamekernelnewbies
phl@kernelnewbies:~$ sudo readlink /proc/$$/ns/utsuts:[4026531838]
复制代码

然后,我们创建一个新的 uts namespace,并查看其 namespce id。

在继续之前,需要介绍一个 namespace 工具 unshare。利用 unshare 我们可以新建一个的 namespace,并在新 namespace 中执行一条命令。unshare 执行时需要 root 权限。unshare 的使用方法如下:

unshare [options] [program [arguments]]
复制代码

执行 unshare 时,我们可以指定要新建的 namespace 的类型以及要执行的命令。unshare 提供了一系列选项,当指定某个选项时可新建指定的 namespace。namespace 类型选项如下:

  • --uts 创建新的 uts namespace

  • --mount 创建新的 mount namespace

  • --pid 创建新的 pid namespace

  • --user 创建新的 user namespace

介绍完 unshare 之后,我们继续之前的实验。我们用 unshare 创建一个新的 uts namespace,并在新的 uts namespace 中执行/bin/bash 命令,命令及结果如下:

phl@kernelnewbies:~$ sudo unshare --uts /bin/bashroot@kernelnewbies:~#
复制代码

我们用 unshare 创建了一个新的 uts namespace。在新的 uts namespace 中查看其 hostname 和 namespace id,命令及结果如下:

root@kernelnewbies:~$ hostnamekernelnewbies
root@kernelnewbies:~# readlink /proc/$$/ns/utsuts:[4026532177]
复制代码

从结果我们可以看到,新 uts namespace 的 id 与全局 uts namespace 的 id 不一致。这说明/bin/bash 已运行在一个新的 uts namespace 中了。

我们将新 uts namespace 的 hostname 改为 dreamland,并强制更新 Shell 提示符。命令及结果如下:

root@kernelnewbies:~# hostname dreamlandroot@kernelnewbies:~# hostnamedreamland
root@kernelnewbies:~# exec /bin/bashroot@dreamland:~#
复制代码

从结果我们可以看到,新 uts namespace 的 hostname 的确是被修改了,exec /bin/bash 用于强制更新 Shell 的提示符。

我们重新打开一个 Shell 窗口,该 Shell 位于全局 uts namespace 中。在新的 Shell 窗口中查看全局 uts namespace id 及 hostname,命令及结果如下:

phl@kernelnewbies:~$ hostnamekernelnewbies
phl@kernelnewbies:~$ sudo readlink /proc/$$/ns/utsuts:[4026531838]
复制代码

从结果我们可以看到,我们在新 uts namespace 中所作的修改并未影响到全局的 uts namespace。

父进程创建子进程时只有提供创建新 namespace 的标志,才可创建新的 namespace,并使子进程处于新的 namespace 中。默认情况下,子进程与父进程处于相同的 namespace 中。我们在新的 uts namespace 中创建一个子进程,然后查看该子进程的 uts namespace id,命令及结果如下:

phl@kernelnewbies:~$ sudo unshare --uts /bin/bashroot@kernelnewbies:~# readlink /proc/$$/ns/utsuts:[4026532305]
root@kernelnewbies:~# bashroot@kernelnewbies:~# readlink /proc/$$/ns/utsuts:[4026532305]
复制代码

从结果我们可以看到,子进程所属 uts namespace 的 id 与其父进程相同。其他 namespae 与 uts namespace 类似,子进程与父进程同属一个 namespace。

1.2.2.docker.sh

有了以上关于 uts namespace 的介绍,我们就可以将 uts namespace 加入到 docker.sh 中了。docker.sh 工程分为两个脚本:docker.sh 和 container.sh。

docker.sh 用于收集用户输入、调用 unshare 创建 namespace 并执行 container.sh 脚本,docker.sh 脚本如下:

#!/bin/bash
usage () { echo -e "\033[31mIMPORTANT: Run As Root\033[0m" echo "" echo "Usage: docker.sh [OPTIONS]" echo "" echo "A docker written by shell" echo "" echo "Options:" echo " -c string docker command" echo " (\"run\")" echo " -m memory" echo " (\"100M, 200M, 300M...\")" echo " -C string container name" echo " -I string image name" echo " -V string volume" echo " -P string program to run in container"
return 0}
if test "$(whoami)" != rootthen usage exit -1fi
while getopts c:m:C:I:V:P: optiondo case "$option" in c) cmd=$OPTARG;; m) memory=$OPTARG;; C) container=$OPTARG;; I) image=$OPTARG;; V) volume=$OPTARG;; P) program=$OPTARG;; \?) usage exit -2;; esacdone
export cmd=$cmdexport memory=$memoryexport container=$containerexport image=$imageexport volume=$volumeexport program=$program
unshare --uts ./container.sh
复制代码

脚本最开始为 usage 函数,该函数为 docker.sh 的使用说明。当用户以非预期的方式使用 docker.sh 时,该函数会被调用。该函数输出如下信息:

IMPORTANT: Run As Root
Usage: docker.sh [OPTIONS]
A docker written by shell
Options: -c string docker command ("run") -m memory ("100M, 200M, 300M...") -C string container name -I string image name -V string volume -P string program to run in container
复制代码

从 usage 函数的输出我们可以看到,执行 docker.sh 时需要 root 权限且需要正确地传递参数。

docker.sh 首先对当前用户进行检测,如果用户不为 root,则打印使用说明并退出脚本;如果用户为 root,则继续执行。检测用户的脚本如下:

if test "$(whoami)" != rootthen        usage        exit -1fi
复制代码

然后,docker.sh 使用 getopts 从命令行提取参数,然后赋值给合适的变量。从命令行提取参数的脚本如下:

while getopts c:m:C:I:V:P: optiondo        case "$option"        in                c) cmd=$OPTARG;;                m) memory=$OPTARG;;                C) container=$OPTARG;;                I) image=$OPTARG;;                V) volume=$OPTARG;;                P) program=$OPTARG;;                \?) usage                    exit -2;;        esacdone
复制代码

如果用户的输入不正确,则打印使用说明并退出脚本;如果用户输入正确,则解析命令行参数并赋值给合适的变量。

为了简化,用户在运行 docker.sh 时需提供完整的参数列表,示例如下:

sudo ./docker.sh -c run -m 100M -C dreamland -I ubuntu1604 -V data1 -P /bin/bash
复制代码

当然,如果当前用户就是 root,就不需要 sudo 了。下表列出了各个参数的含义及示例:

docker.sh 将命令行参数赋值给变量后,需要将这些变量导出,以传递给 container.sh。导出变量的脚本如下:

export cmd=$cmdexport memory=$memoryexport container=$containerexport image=$imageexport volume=$volumeexport program=$program
复制代码

这里说明一下为什么要将 docker.sh 工程拆分为 docker.sh 和 container.sh 两个脚本。因为调用 unshare 创建新的 namespace 时,会执行一个命令,该命令在新的 namespace 中运行。该命令一旦结束,unshare 也就结束了,unshare 创建的新 namespace 也就不存在了。

docker.sh 不会并发地执行 unshare 命令与 unshare 之后的脚本,因此,只有 unshare 结束了,后续脚本才可继续运行。但是当 unshare 结束了,准备执行后续脚本时,新的 namespae 已经不存在了。因此一些加入 cgroups、切换根文件系统等工作必须在 unshare 执行的命令中进行,所以我们采用在 unshare 中执行 container.sh 脚本的方式完成后续的工作。

最后,docker.sh 调用 unshare 创建新的 uts namespace,并执行 container.sh 脚本。调用 unshare 的脚本如下:

unshare --uts ./container.sh
复制代码

container.sh 将容器的 hostname 修改为通过-C 传递的容器的名字,然后执行通过-P 传递的程序。container.sh 脚本如下:

#!/bin/bash
hostname $containerexec $program
复制代码

现在,我们运行 docker.sh,并查看其 hostname。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo ./docker.sh -c run -m 100M -C dreamland -I ubuntu1604 -V data1 -P /bin/bashroot@dreamland:~/docker.sh# hostnamedreamland
复制代码

从结果我们可以看到,容器的 hostname 已经改变为我们传递的容器名字 dreamland 了。

1.3.mount namespace

1.3.1./etc/mtab、/proc/self/mounts

早期的 Linux 使用/etc/mtab 文件来记录当前的挂载点信息。每次 mount/umount 文件系统时会更新/etc/mtab 文件中的信息。

后来,linux 引入了 mount namespace,每个进程都有一份自己的挂载点信息。当然,处于同一个 mount namespace 里面的进程,其挂载点信息是相同的。进程的挂载点信息通过/proc/[pid]/mounts 文件导出给用户。

为了兼容以前的/etc/mtab,/etc/mtab 变成了指向/proc/self/mounts 的符号链接。通过 readlink 查看/etc/mtab 指向的文件,命令及结果如下:

phl@kernelnewbies:~$ readlink /etc/mtab../proc/self/mounts
复制代码

通过读取/proc/self/mounts 文件,可以查看当前的挂载点信息,命令及结果如下:

phl@kernelnewbies:~$ cat /proc/self/mountssysfs /sys sysfs rw,nosuid,nodev,noexec,relatime 0 0proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0/dev/sda1 / ext4 rw,relatime,errors=remount-ro 0 0securityfs /sys/kernel/security securityfs rw,nosuid,nodev,noexec,relatime 0 0...
复制代码

由于该文件中内容太多,我们省略了一部分,只保留了一些比较重要的挂载点信息。每行的信息分为六个字段,各字段的含义及示例如下:

由于该文件有点过时,被后文介绍的/proc/self/mountinfo 替换掉,所以不做过多介绍。

1.3.2./proc/self/mountinfo

/proc/self/mountinfo 包含了进程 mount namespace 中的挂载点信息。 它提供了旧的/proc/[pid]/mounts 文件中缺少的各种信息(传播状态,挂载点 id,父挂载点 id 等),并解决了/proc/[pid]/mounts 文件的一些其他缺陷。我们查看进程挂载点信息时应优先使用该文件。

该文件中每一行代表一个挂载点信息,每个挂载点信息分为 11 个字段。挂载点信息的示例如下:

各字段的含义及示例如下:

我们主要关注可选字段中的传播状态选项。首先,我们看一下关于 mount namespace 的问题。问题如下:

当创建 mount namespace 时,新 mount namespace 会拷贝一份老 mount namespace 里面的挂载点信息。例如,全局 mount namespace 中有一个/a 挂载点,新建的 mount namespace 中也会有一个/a 挂载点。那么我们在新 mount namespace 中的/a 下创建或删除一个挂载点,全局 mount namespace 中的/a 会同步创建或删除该挂载点吗?或者在全局 mount namespace 中的/a 下创建或删除一个挂载点,新 mount namespace 中的/a 会同步创建或删除该挂载点吗?

mountinfo 文件中可选字段的传播状态就是控制在一个挂载点下进行创建/删除挂载点操作时是否会传播到其他挂载点的选项。传播状态有四种可取值,常见的有如下两种:

  • shared 表示创建/删除挂载点的操作会传播到其他挂载点

  • private 表示创建/删除挂载点的操作不会传播到其他挂载点

由于在容器技术中要保证主机与容器的挂载点信息互不影响,因此要求容器中的挂载点的传播状态为 private。

1.3.3.bind mount

bind mount 可以将一个目录(源目录)挂载到另一个目录(目的目录),在目的目录里面的读写操作将直接作用于源目录。

下面我们通过实验了解一下 bind mount 的功能,首先,我们准备一下实验所需要的的目录及文件。命令及结果如下:

phl@kernelnewbies:~$ mkdir bindphl@kernelnewbies:~$ cd bind/phl@kernelnewbies:~/bind$ mkdir aphl@kernelnewbies:~/bind$ mkdir bphl@kernelnewbies:~/bind$ echo hello, a > a/a.txtphl@kernelnewbies:~/bind$ echo hello, b > b/b.txt
复制代码

然后,我们将 a 目录 bind mount 到 b 目录并查看 b 目录下的内容。命令及结果如下:

phl@kernelnewbies:~/bind$ sudo mount --bind a bphl@kernelnewbies:~/bind$ tree bb└── a.txt0 directories, 1 file
复制代码

从结果我们可以看到,b 目录下原先的内容被隐藏,取而代之的是 a 目录下的内容。

然后,我们修改 b 目录下的内容,修改完毕后,从 b 目录上卸载掉 a 目录。命令及结果如下:

phl@kernelnewbies:~/bind$ echo hello, a from b > b/a.txtphl@kernelnewbies:~/bind$ sudo umount b
复制代码

我们读取一下 a 目录中 a.txt,看看其内容是否被改变。命令及结果如下:

phl@kernelnewbies:~/bind$ cat a/a.txthello, a from b
复制代码

从结果我们可以看到,a 目录中的内容确实被当 a 被 bind mount 到 b 时对 b 目录的操作所修改了。

bind mount 在容器技术中有很重要的用途,后文会有涉及。

1.3.4.mount namespace 简介

mount namespace 用来隔离文件系统的挂载点信息, 使得不同的 mount namespace 拥有自己独立的挂载点信息。不同的 namespace 之间不会相互影响,其在 unshare 中的选项为--mount。

当用 unshare 创建新的 mount namespace 时,新创建的 namespace 将拷贝一份老 namespace 里的挂载点信息,但从这之后,他们就没有关系了。这是 unshare 将新 namespace 里面的所有挂载点的传播状态设置为 private 实现的。通过 mount 和 umount 增加和删除各自 mount namespace 里面的挂载点都不会相互影响。

下面我们将演示 mount namespace 的用法。首先,我们准备需要的目录和文件,命令及结果如下:

phl@kernelnewbies:~$ mkdir -p hds/hd1 hds/hd2 && cd hds
phl@kernelnewbies:~/hds$ dd if=/dev/zero bs=1M count=1 of=hd1.img && mkfs.ext2 hd1.imgphl@kernelnewbies:~/hds$ dd if=/dev/zero bs=1M count=1 of=hd2.img && mkfs.ext2 hd2.img
phl@kernelnewbies:~$ tree ..├── hd1├── hd1.img├── hd2└── hd2.img2 directories, 2 files
复制代码

然后,我们在全局的 mount namespace 中挂载 hd1.img 到 hd1 目录,然后查看该 mount namespace 中的挂载点信息与 mount namespace id。命令及结果如下:

phl@kernelnewbies:~/hds$ sudo mount hd1.img hd1phl@kernelnewbies:~/hds$ cat /proc/self/mountinfo | grep hd556 27 7:18 / /home/phl/hds/hd1 rw,relatime shared:372 - ext2 /dev/loop18 rw
phl@kernelnewbies:~/hds$ sudo readlink /proc/$$/ns/mntmnt:[4026531840]
复制代码

然后,执行 unshare 命令创建一个新的 mount namespace 并查看该 mount namespace id 和挂载点信息。命令及结果如下:

phl@kernelnewbies:~/hds$ sudo unshare --uts --mount /bin/bashroot@kernelnewbies:~/hds# cat /proc/self/mountinfo | grep hd739 570 7:18 / /home/phl/hds/hd1 rw,relatime - ext2 /dev/loop18 rw
root@kernelnewbies:~/hds# readlink /proc/$$/ns/mntmnt:[4026532180]
复制代码

从结果我们可以看到,新 mount namespace 中的挂载点信息与全局 mountnamespace 中的挂载点信息基本一致,一些挂载选项(如传播状态)变化了。新的 mount namespace id 与全局 mount namespace id 是不一样的。

然后,我们在新的 mount namespace 中挂载 hd2.img 到 hd2 目录,并查看挂载点信息。命令及结果如下:

root@kernelnewbies:~/hds# mount hd2.img hd2root@kernelnewbies:~/hds# cat /proc/self/mountinfo | grep hd739 570 7:18 / /home/phl/hds/hd1 rw,relatime - ext2 /dev/loop18 rw740 570 7:19 / /home/phl/hds/hd2 rw,relatime - ext2 /dev/loop19 rw
复制代码

从结果我们可以看到,新 mount namespace 中有 hd1 和 hd2 这两个挂载点。现在启动一个新的 Shell 窗口,查看全局 mount namespace 中的挂载点信息。命令及结果如下:

phl@kernelnewbies:~/hds$ cat /proc/self/mountinfo | grep hd556 27 7:18 / /home/phl/hds/hd1 rw,relatime shared:372 - ext2 /dev/loop18 rw
复制代码

从结果我们可以看到,全局 mount namespace 中的挂载点信息只有 hd1,而没有 hd2。这说明在新 mount namespace 中进行挂载/卸载操作不会影响其他 mount namespace 中的挂载点信息。

mount namespace 只隔离挂载点信息,并不隔离挂载点下面的文件信息。对于多个 mount namespace 都能看到的挂载点,如果在一个 namespace 中修改了挂载点下面的文件,其他 namespace 也能感知到。下面,我们在新建的 mount namespace 中创建一个文件,命令如下:

root@kernelnewbies:~/hds# echo hello from new mount namespace > hd1/hello.txt
复制代码

在新启动的 Shell 中,查看 hd1 目录并读取 hd1/hello.txt 文件。命令及结果如下:

phl@kernelnewbies:~/hds$ tree hd1hd1├── hello.txt└── lost+found [error opening dir]1 directory, 1 file
phl@kernelnewbies:~/hds$ cat hd1/hello.txthello from new mount namespace
复制代码

从结果我们可以看到,在全局 mount namespace 中,我们可以读取到在新建的 mount namespace 中创建的文件。

1.3.5.docker.sh

有了以上关于 mount namespace 的知识,我们就可以将 mount namespace 加入到 docker.sh 中了。mount namespace 将放在 docker.sh 中,带下划线的行是我们为实现 mount namespace 而修改的代码。修改后的 docker.sh 脚本如下:

...unshare --uts --mount ./container.sh
复制代码

从上述代码我们可以看到,我们仅仅是在调用 unshare 时加入--mount 选项,就可为 docker.sh 引入了 mount namespace 功能。

1.4.pid namespace

1.4.1.unshare 的--fork 选项

unshare 有一个选项--fork,当执行 unshare 时,如果没有这个选项,unshare 会直接 exec 新命令,也就是说 unshare 变成了新命令。如果带有--fork 选项,unshare 会 fork 一个子进程,该子进程 exec 新命令,unshare 是该子进程的父进程。我们分别不带--fork 和带--fork 来执行 unshare,然后查看进程之间的关系。

首先,我们不带--fork 选项执行 unshare,并查看当前 Shell 的进程 id。命令及结果如下:

phl@kernelnewbies:~$ sudo unshare --uts /bin/bashroot@kernelnewbies:~/hds# echo $$11699
复制代码

此时 unshare 会创建一个新的 uts namespace,然后 exec /bin/bash。我们启动一个新 Shell,然后使用 pstree 查看进程间关系,命令及结果如下:

phl@kernelnewbies:~/hds$ pstree -p | grep 11699sudo(11698)---bash(11699)
复制代码

从结果我们可以看到,sudo fork 出一个子进程,该子进程执行 unshare。unshare 创建了新 uts namespace 后,exec 了/bin/bash,也就是说 unshare 变成了/bin/bash。

然后,我们带--fork 选项执行 unshare,并查看当前 Shell 的进程 id。命令及结果如下:

phl@kernelnewbies:~/hds$ sudo unshare --uts --fork /bin/bashroot@kernelnewbies:~/hds# echo $$11866
复制代码

此时 unshare 会创建一个新的 uts namespace,然后 fork 出一个子进程,该子进程 exec /bin/bash。我们启动一个新 Shell,然后使用 pstree 查看进程间关系,命令及结果如下:

phl@kernelnewbies:~/hds$ pstree -p | grep 11866sudo(11864)---unshare(11865)---bash(11866)
复制代码

从结果我们可以看到,sudo fork 出一个子进程,该子进程执行命令 unshare。unshare 创建了新 uts namespace 后,fork 出一个子进程,该子进程 exec /bin/bash,也就是说 unshare 变成了新的/bin/bash 进程的父进程。

1.4.2.pid namespace 简介

pid namespace 用来隔离进程 pid 空间,使得不同 pid namespace 里的进程 pid 可以重复且相互之间不影响。进程所属的 pid namespace 在创建的时候就确定了,无法更改,因此需要--fork 选项来创建一个新进程,然后将该新进程加入新建的 pid namespace 中。pid namespace 在 unshare 中的选项为--pid。

unshare 在创建 pid namespace 时需同时提供--pid 与--fork 选项。unshare 本身会加入全局的 pid namespace,其 fork 出的子进程会加入新建的 pid namespace。

首先,我们查看全局 pid namespace id,命令及结果如下:

phl@kernelnewbies:~$ sudo readlink /proc/$$/ns/pidpid:[4026531836]
复制代码

然后,执行 unshare 命令创建一个新的 pid namespace 并查看该 pid namespace id。命令及结果如下:

phl@kernelnewbies:~$ sudo unshare --mount --pid --fork /bin/bashroot@kernelnewbies:~# readlink /proc/$$/ns/pidpid:[4026531836]
复制代码

从结果我们可以看到,新创建的进程也处于全局 pid namespace 中,而不是新的 pid namespace。

出现这种情形是因为当前的/proc 文件系统是老的。我们查看一下 $$的值,命令及结果如下:

root@kernelnewbies:~# echo $$1
复制代码

从结果我们可以看到,$$的值为 1,但是/proc 文件系统却是老的,因此我们查看的实际是 init 进程所属的 pid namespace,当然是全局 pid namespace 了。

重新挂载/proc 文件系统,这也是 unshare 执行时带--mount 选项的原因,只有这样,重新挂载/proc 文件系统时,不会搞乱整个系统。再次查看新进程所属的 pid namespace,命令及结果如下:

root@kernelnewbies:~# mount -t proc proc /procroot@kernelnewbies:~# readlink /proc/$$/ns/pidpid:[4026532182]
复制代码

从结果我们可以看到,新进程的 pid namespace 与全局 pid namespace 的 id 不同。

接下来,我们再来查看一下新 pid namespace 中的进程信息。命令及结果如下:

root@kernelnewbies:~# ps -efUID        PID  PPID  C STIME TTY          TIME CMDroot         1     0  0 19:03 pts/1    00:00:00 /bin/bashroot        10     1  0 19:03 pts/1    00:00:00 ps -e
复制代码

从结果我们可以看到,当前 pid namespace 中只有 2 个进程,看不到全局 pid namespace 里面的其他进程。我们通过 unshare 执行的进程 pid 为 1,也就是说该进程成了新 pid namespace 中的 init 进程。

1.4.3.pid 嵌套

pid namespace 可以嵌套,也就是说有父子关系,在当前 pid namespace 里面创建的所有新的 pid namespace 都是当前 pid namespace 的子 pid namespace。

首先,我们创建 3 个嵌套的 pid namespace,并查看每个 pid namespace id。--mount-proc 选项用于自动挂载/proc 文件系统,省去了手动挂载/proc 文件系统的操作。命令及结果如下:

phl@kernelnewbies:~$ sudo readlink /proc/$$/ns/pidpid:[4026531836]
phl@kernelnewbies:~$ sudo unshare --uts --mount --pid --mount-proc --fork /bin/bashroot@kernelnewbies:~# readlink /proc/$$/ns/pidpid:[4026532182]
root@kernelnewbies:~# unshare --uts --mount --pid --mount-proc --fork /bin/bashroot@kernelnewbies:~# readlink /proc/$$/ns/pidpid:[4026532185]
root@kernelnewbies:~# unshare --uts --mount --pid --mount-proc --fork /bin/bashroot@kernelnewbies:~# readlink /proc/$$/ns/pidpid:[4026532188]
复制代码

然后,我们启动一个新 Shell,然后使用 pstree 查看进程间关系。命令及结果如下:

phl@kernelnewbies:~$ pstree -lp | grep unsharesudo(12547)---unshare(12548)---bash(12549)---unshare(12579)---bash(12580)---unshare(12593)---bash(12594)
复制代码

使用 cat /proc/[pid]/status | grep NSpid 可查看某进程在当前 pid namespace 及子孙 pid namespace 中的 pid。我们在全局 pid namespace 中查看上述各进程在各 pid namespace 中的 pid,命令及结果如下:

phl@kernelnewbies:~$ cat /proc/12594/status | grep NSpidNSpid: 12594 21 11 1
phl@kernelnewbies:~$ cat /proc/12593/status | grep NSpidNSpid: 12593 20 10
phl@kernelnewbies:~$ cat /proc/12580/status | grep NSpidNSpid: 12580 11 1
phl@kernelnewbies:~$ cat /proc/12579/status | grep NSpidNSpid: 12579 10
phl@kernelnewbies:~$ cat /proc/12549/status | grep NSpidNSpid: 12549 1
复制代码

下面我们将以上进程在各 pid namespace 中的 pid,整理成表格。表格信息如下:

我们以最后一行为例进行介绍,最后一行有 4 个 pid,这 4 个 pid 其实是同一个进程。这个进程在 4 个 pid namespace 中都可以被看到,且其在 4 个 pid namespace 中的 pid 各不相同。

1.4.4.docker.sh

有了以上关于 pid namespace 的知识,我们就可以将 pid namespae 加入到 docker.sh 中了。pid namespace 将放在 docker.sh 中,带下划线的行是我们为实现 pid namespace 而修改的代码。修改后的 docker.sh 脚本如下:

...unshare --uts --mount --pid --fork ./container.sh
复制代码

从上述代码我们可以看到,我们仅仅是在调用 unshare 时加入--pid 和--fork 选项,就可为 docker.sh 引入了 pid namespace 功能。

然后,我们需要重新挂载/proc 文件系统。重新挂载/proc 文件系统的功能将放在 container.sh 中,带下划线的行是我们为重新挂载/proc 文件系统而新添的代码。修改后的 container.sh 脚本如下如下所示:

hostname $containermount -t proc proc /procexec $program
复制代码

现在,我们运行 docker.sh,并查看当前的进程信息。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo ./docker.sh -c run -m 100M -C dreamland -I ubuntu1604 -V data1 -P /bin/bashroot@dreamland:~/docker.sh# ps -efUID        PID  PPID  C STIME TTY          TIME CMDroot         1     0  0 17:31 pts/1    00:00:00 /bin/bashroot        16     1  0 17:31 pts/1    00:00:00 ps -ef
复制代码

从结果我们可看出,当前进程只有两个,不再有主机上的其他进程。

2.CGroups

2.1.CGroups 简介

CGroups 是一种将进程分组,并以组为单位对进程实施资源限制的技术。每个组都包含以下几类信息:

  • 进程列表

  • 资源 A 限制

  • 资源 B 限制

  • 资源 C 限制

  • ...

我们将以常见的 CPU 资源及内存资源为例进行介绍。以下的信息将使进程号为 1001、1002、2008、3306 的四个进程总共只能使用一个 CPU 核心;总共最多使用 25%的 CPU 资源;总共最多使用 100M 内存,这样的一个分组被称为 cgroup。

上面的介绍只是说明了要将何种资源限制施加于哪些进程,并未说明资源限制是如何施加到进程上。具体施加资源限制的过程需要 subsystem 来帮忙。subsystem 读取 cgroup 中的资源限制和进程列表,然后将这些资源限制施加到这些进程上。常见的 subsystem 包括如下几种:

  • cpu

  • memory

  • pids

  • devices

  • blkio

  • net_cls

每个 subsystem 只读取与其相关的资源限制,然后施加到进程上。例如:memory 子系统只读取内存限制,而 cpu 子系统只读取 cpu 限制。

cgroup 被组织成树,如下图所示:

采用树状结构可以方便地实现资源限制继承,一个 cgroup 中的资源限制将作用于该 cgroup 及其子孙 cgroup 中的进程。例如:图中 13001、10339、2999 受到 A、B、C、D 四个 cgroup 中的资源限制。这样的一个树状结构被称为 hierarchy。

hierarchy 中包含了系统中所有的进程,它们分布于各个 cgroup 中。在 hierarchy 中,一个进程必须属于且只属于一个 cgroup,这样才能保证对进程施加的资源限制不会遗漏也不会冲突。

要想让一个 subsystem 读取 hierarchy 中各 cgroup 的资源限制,并施加于其中的进程需要将 subsystem 和 hierarchy 关联起来。subsystem 与 hierarchy 的关系如下:

  • 系统中可以有多个 hierarchy

  • 一个 hierarchy 可以关联 0 个或多个 subsystem,当关联 0 个 subsystem 时,该 hierarchy 只是对进程进行分类

  • 一个 subsystem 最多关联到一个 hierarchy,因为每个 hierarchy 都包含系统中所有的进程,若一个 subsystem 关联到了多个 hierarchy,对同一进程将有多种资源限制,这是不对的

系统使用 CGroups 通常有两种形式:一种是创建一个 hierarchy,将所有的 subsystem 关联到其上,在这个 hierarchy 上配置各种资源限制;另一种是为每一个 subsystem 创建一个 hierarchy,并将该 subsystem 关联到其上,每个 hierarchy 只对一种资源进行限制。后一种比较清晰,得到了更普遍的采用。

CGroups 不像大多数的技术那样提供 API 或命令之类的用户接口,而是提供给用户一个虚拟文件系统,该虚拟文件系统类型为 cgroup。一个挂载后的 cgroup 文件系统就是一个 hierarchy,文件系统中的一个目录就是一个 cgroup,目录中的文件代表了进程列表或者资源限制信息。文件系统是树状结构,其各个目录之间的父子关系就代表了 cgroup 之间的继承关系。挂载 cgroup 虚拟文件系统后,通过在该文件系统上创建目录、写进程列表文件、写资源限制文件就可以操作 CGroups。

下面,我们通过实验学习一下 CGroups 的用法。首先,我们挂载一个 cgroup 虚拟文件系统,该文件系统不与任何 subsystem 关联,仅仅是将进程进行分类。命令及结果如下:

phl@kernelnewbies:~$ mkdir -p cg/test# -o none,name=test 表示该cgroup文件系统不与任何子系统关联# 该文件系统用name=test来标识phl@kernelnewbies:~$ sudo mount -t cgroup -o none,name=test test cg/testphl@kernelnewbies:~$ tree cg/testcg/test├── cgroup.clone_children├── cgroup.procs├── cgroup.sane_behavior├── notify_on_release├── release_agent└── tasks0 directories, 6 files
复制代码

挂载 cgroup 文件系统后,该 cgroup 文件系统的根目录下会生成许多文件,该根目录被称为 root cgroup。cgroup.procs 里面存放的是当前 cgroup 中的所有进程 id,由于该 hierarchy 中只有一个 cgroup,所以这个文件包含了系统中所有的进程 id。其他的文件与 cgroups 基本功能关系不大,暂时可以忽略。

在 cgroup 文件系统中,创建一个目录就会创建一个 cgroup。下面我们将会演示如何创建下面这样的 hierarchy:

命令及结果如下:

phl@kernelnewbies:~$ sudo mkdir -p cg/test/test1/test11phl@kernelnewbies:~$ sudo mkdir -p cg/test/test2/test22phl@kernelnewbies:~$ tree cg/testcg/test├── cgroup.clone_children├── cgroup.procs├── cgroup.sane_behavior├── notify_on_release├── release_agent├── tasks├── test1│   ├── cgroup.clone_children│   ├── cgroup.procs│   ├── notify_on_release│   ├── tasks│   └── test11│       ├── cgroup.clone_children│       ├── cgroup.procs│       ├── notify_on_release│       └── tasks└── test2    ├── cgroup.clone_children    ├── cgroup.procs    ├── notify_on_release    ├── tasks    └── test22        ├── cgroup.clone_children        ├── cgroup.procs        ├── notify_on_release        └── tasks
4 directories, 22 files
复制代码

从结果我们可以看到,我们创建了相应的目录后,这些目录下自动出现了包含 cgroup 信息的目录及文件。

删除 cgroup 时只需删除该 cgroup 所在的目录即可。下面我们将删除 test11 cgroup,命令及结果如下:

phl@kernelnewbies:~$ sudo rmdir cg/test/test1/test11phl@kernelnewbies:~$ tree cg/testcg/test├── cgroup.clone_children├── cgroup.procs├── cgroup.sane_behavior├── notify_on_release├── release_agent├── tasks├── test1│   ├── cgroup.clone_children│   ├── cgroup.procs│   ├── notify_on_release│   └── tasks└── test2    ├── cgroup.clone_children    ├── cgroup.procs    ├── notify_on_release    ├── tasks    └── test22        ├── cgroup.clone_children        ├── cgroup.procs        ├── notify_on_release        └── tasks
3 directories, 18 files
复制代码

每个 cgroup 下面都有一个 cgroup.procs 文件,该文件里面包含当前 cgroup 里面的所有进程 id。只要将某个进程的 id 写入该文件,即可将该进程加入到该 cgroup 中。下面,我们将当前的 bash 加入到 test22 cgroup 中,命令及结果如下:

phl@kernelnewbies:~$ echo $$3894phl@kernelnewbies:~$ sudo sh -c "echo 3894 > cg/test/test2/test22/cgroup.procs"
复制代码

/proc/[pid]/cgroup 包含了某个进程所在的 cgroup 信息。下面,我们查看一下当前 bash 进程所在的 cgroup 信息,命令及结果如下:

phl@kernelnewbies:~$ cat /proc/3894/cgroup13:name=test:/test2/test2212:freezer:/11:perf_event:/10:blkio:/user.slice9:devices:/user.slice8:hugetlb:/7:cpu,cpuacct:/user.slice6:net_cls,net_prio:/5:memory:/user.slice4:rdma:/3:pids:/user.slice/user-1001.slice/session-4.scope2:cpuset:/1:name=systemd:/user.slice/user-1001.slice/session-4.scope0::/user.slice/user-1001.slice/session-4.scope
复制代码

从结果我们可以看到,当前 bash 进程加入了多个 cgroup,其中带下划线的行为我们刚刚加入的 cgroup。

要想将 hierarchy 与子系统关联起来,需要在-o 选项中指定子系统名称。下面演示了如何将 memory 子系统与新挂载的 cgroup 文件系统关联起来。代码如下:

phl@kernelnewbies:~$ sudo mkdir cg/memoryphl@kernelnewbies:~$ sudo mount -t cgroup -o memory memcg cg/memory
复制代码

由于很多发行版的操作系统已经为我们配置好了这些 cgroup 文件系统,我们应当直接使用这些已经挂在好的文件系统,不需要自己去挂载。

另外,当创建子进程时,子进程会自动加入父进程所在的 cgroup。

2.2.限制内存

2.2.1.用 CGroups 限制内存

下面我们将介绍演示 CGroups 如何限制进程使用的内存资源,我们以内存为例进行讲解。

Ubuntu18.04 已经为我们挂载了一个关联 memory 子系统的 cgroup 虚拟文件系统。我们用 mount 命令查看一下该系统挂载到了何处,命令及结果如下:

phl@kernelnewbies:~$ mount | grep cgrouptmpfs on /sys/fs/cgroup type tmpfs (ro,nosuid,nodev,noexec,mode=755)cgroup on /sys/fs/cgroup/unified type cgroup2 (rw,nosuid,nodev,noexec,relatime,nsdelegate)cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,name=systemd)cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma)cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio)cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct)cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
复制代码

该系统挂载到了/sys/fs/cgroup/memory 目录下。我们在该 hierarchy 中创建一个 test cgroup 并查看该 cgroup 的目录结构,命令及结果如下:

phl@kernelnewbies:~$ sudo mkdir /sys/fs/cgroup/memory/testphl@kernelnewbies:~$ tree /sys/fs/cgroup/memory/test/sys/fs/cgroup/memory/test├── cgroup.clone_children├── cgroup.event_control├── cgroup.procs├── memory.failcnt├── memory.force_empty├── memory.kmem.failcnt├── memory.kmem.limit_in_bytes├── memory.kmem.max_usage_in_bytes├── memory.kmem.slabinfo├── memory.kmem.tcp.failcnt├── memory.kmem.tcp.limit_in_bytes├── memory.kmem.tcp.max_usage_in_bytes├── memory.kmem.tcp.usage_in_bytes├── memory.kmem.usage_in_bytes├── memory.limit_in_bytes├── memory.max_usage_in_bytes├── memory.move_charge_at_immigrate├── memory.numa_stat├── memory.oom_control├── memory.pressure_level├── memory.soft_limit_in_bytes├── memory.stat├── memory.swappiness├── memory.usage_in_bytes├── memory.use_hierarchy├── notify_on_release└── tasks0 directories, 27 files
复制代码

从结果我们可以看到,新建的 test cgroup 中有许多文件,这些文件中存放着资源限制信息。其中 memory.limit_in_bytes 里面存放的是该 cgroup 中的进程能够使用的内存额度。

下面,我们将当前 bash 加入到 test cgroup 中并查看当前 bash 所属的 cgroup 信息。命令及结果如下:

phl@kernelnewbies:~$ echo $$2984phl@kernelnewbies:~$ sudo sh -c "echo 2984 > /sys/fs/cgroup/memory/test/cgroup.procs"phl@kernelnewbies:~$ cat /proc/2984/cgroup12:devices:/user.slice11:hugetlb:/10:memory:/test9:rdma:/8:perf_event:/7:blkio:/user.slice6:cpu,cpuacct:/user.slice5:pids:/user.slice/user-1001.slice/session-4.scope4:freezer:/3:cpuset:/2:net_cls,net_prio:/1:name=systemd:/user.slice/user-1001.slice/session-4.scope0::/user.slice/user-1001.slice/session-4.scope
复制代码

从结果我们可以看到,当前 bash 所属的 memory cgroup 变为了/test,该目录为一个相对于 root cgroup 的相对路径。

然后,将 100M 写入 test cgroup 中的 memory.limit_in_bytes 文件中,命令如下:

phl@kernelnewbies:~$ sudo sh -c "echo 100M > /sys/fs/cgroup/memory/test/memory.limit_in_bytes"
复制代码

我们在当前 bash 中启动一个占用 300M 进程的 stress 进程,该 stress 进程是 bash 的子进程,其与 bash 进程都在 test cgroup 中。命令如下:

phl@kernelnewbies:~$ stress --vm 1 --vm-bytes 300M --vm-keep
复制代码

启动一个新的 Shell 窗口,执行 top 命令查看 stress 进程占用的内存。命令及结果如下:

PID USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND14216 root      20   0  315440 101224    264 D 27.7  2.5   0:02.66 stress
复制代码

从结果我们可以看到,stress 进程占用了 2.5%的内存。我的电脑的内存为 4G,4G * 2.5% = 100M,stress 进程确实受到了 cgroup 中设置的内存额度的限制。

2.2.2.docker.sh

下有了以上关于 CGroups 的知识,我们就可以将限制内存的功能加入到 docker.sh 中了。限制内存的功能将放在 container.sh 中,带下划线的行是我们为实现限制内存而新添的代码。修改后的 container.sh 脚本如下:

hostname $containermkdir -p /sys/fs/cgroup/memory/$containerecho $$ > /sys/fs/cgroup/memory/$container/cgroup.procsecho $memory > /sys/fs/cgroup/memory/$container/memory.limit_in_bytesmount -t proc proc /procexec $program
复制代码

首先,我们根据容器的名字创建 cgroup,命令如下:

mkdir -p /sys/fs/cgroup/memory/$container
复制代码

然后,我们将当前 bash 加入到我们创建的 cgroup 中,命令如下:

echo $$ > /sys/fs/cgroup/memory/$container/cgroup.procs
复制代码

最后,我们将内存限制写入新 cgroup 的 memory.limit_in_bytes 文件中,命令如下:

echo $memory > /sys/fs/cgroup/memory/$container/memory.limit_in_bytes
复制代码

现在,我们运行 docker.sh,并启动一个占用 300M 进程的 stress 进程。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo ./docker.sh -c run -m 100M -C dreamland -I ubuntu1604 -V data1 -P /bin/bashroot@dreamland:~/docker.sh# stress --vm 1 --vm-bytes 300M --vm-keepstress: info: [12] dispatching hogs: 0 cpu, 0 io, 1 vm, 0 hdd
复制代码

启动一个新的 Shell 窗口,执行 top 命令查看 stress 进程占用的内存。命令及结果如下:

PID USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND14216 root      20   0  315440 101224    264 D 27.7  2.5   0:02.66 stress
复制代码

从结果我们可以看到,容器内的 stress 进程只使用了 100M 的内存。

3.切换根文件系统

3.1.根文件系统

在容器技术中,根文件系统可为容器进程提供一个与主机不一致的文件系统环境。举个例子,主机为 Ubuntu 18.04,创建的容器采用 Ubuntu 16.04 的根文件系统,那么容器运行时所用的软件及其依赖库、配置文件等都是 Ubuntu 16.04 的。尽管该容器使用的内核是仍旧是 Ubuntu 18.04 的,但应用软件的表现却与 Ubuntu 16.04 一致,从虚拟化的角度来说该容器就是一个 Ubuntu 16.04 系统。

debootstrap 是 Ubuntu 下的一个工具,用来构建根文件系统。生成的目录符合 Linux 文件系统标准,即包含了/boot、/etc、/bin、/usr 等目录。debootstrap 的安装命令如下:

sudo apt install debootstrap
复制代码

下面我们通过 debootstrap 构建 Ubuntu 16.04 的根文件系统。为了清晰,我们在 images 目录下生成根文件系统。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ mkdir imagesphl@kernelnewbies:~/docker.sh$ cd imagesphl@kernelnewbies:~/docker.sh/images$ sudo debootstrap --arch amd64 xenial ./ubuntu1604
复制代码

制作根文件系统需要从服务器下载很多文件,很耗时,请耐心等待。当文件系统制作好后,可以使用 tree 命令查看生成的根文件系统。命令及结果如下:

phl@kernelnewbies:~/docker.sh/images$ tree -L 1 ubuntu1604/ubuntu1604/├── bin├── boot├── dev├── etc├── home├── lib├── lib64├── media├── mnt├── old_root├── opt├── proc├── root├── run├── sbin├── srv├── sys├── tmp├── usr└── var20 directories, 0 files
复制代码

这个根文件系统与 Linux 系统目录很相近,我们后续的实验将使用该根文件系统。

3.2.pivot_root

pivot_root 命令用于切换根文件系统,其使用方式如下:

pivot_root new_root put_old
复制代码

pivot_root 将当前进程的根文件系统移至 put_old 目录并使 new_root 目录成为新的根文件系统。

下面我们将通过实验学习 pivot_root 的使用方法。为了简单,我们在一个新的 mount namespace 下进行实验。首先,我们创建一个新的 mount namespace,命令及结果如下:

phl@kernelnewbies:~/docker.sh/images$ sudo unshare --mount /bin/bashroot@kernelnewbies:~/docker.sh/images#
复制代码

在我们的实验中,我们的根文件系统将挂载在 ubuntu1604 目录,而老的根文件系统将被移动到 ubuntu1604/old_root 目录下。我们先创建 old_root 目录,命令如下:

root@kernelnewbies:~/docker.sh/images# mkdir -p ubuntu1604/old_root/
复制代码

由于 pivot_root 命令要求老的根目录和新的根目录不能在同一个挂载点下,因此我们通过 bind mount 将 ubuntu1604 目录变成一个挂载点。命令及结果如下:

root@kernelnewbies:~/docker.sh/images# mount --bind ubuntu1604 ubuntu1604root@kernelnewbies:~/docker.sh/images# cat /proc/self/mountinfo | grep ubuntu1604624 382 8:1 /home/phl/docker.sh/images/ubuntu1604 /home/phl/docker.sh/images/ubuntu1604 rw,relatime - ext4 /dev/sda1 rw,errors=remount-ro
复制代码

准备好切换根文件系统所需要的条件后,我们调用 pivot_root 切换根文件系统。命令及结果如下:

root@kernelnewbies:~/docker.sh/images# cd ubuntu1604/root@kernelnewbies:~/docker.sh/images/ubuntu1604# pivot_root . old_root/
复制代码

此时,已完成根文件系统的切换,/proc 文件系统也被挪到了/home/phl/docker.sh/images/ubuntu1604/old_root/proc,也就是说当前没有/proc 文件系统,因此,我们无法查看挂载点信息,自然也无法执行一些依赖于/proc 文件系统的操作。我们需要重新挂载/proc 文件系统。命令如下:

root@kernelnewbies:~/docker.sh/images/ubuntu1604# mount -t proc proc /proc
复制代码

重新挂载/proc 文件系统后,我们就可以查看当前的挂载点信息了。通过读取/proc/self/mountinfo 文件来查看系统的挂载点信息。命令及结果如下:

root@kernelnewbies:~/docker.sh/images/ubuntu1604# cat /proc/self/mountinfo382 624 8:1 / /old_root rw,relatime - ext4 /dev/sda1 rw,errors=remount-ro...624 381 8:1 /home/phl/docker.sh/images/ubuntu1604 / rw,relatime - ext4 /dev/sda1 rw,errors=remount-ro625 624 0:5 / /proc rw,relatime - proc proc rw
复制代码

此时的挂载点很多,为了方便查看,此处只保留了一些主要的挂载点信息。这些挂载点信息包括/、/proc、/old_root。/old_root 为老的根文件系统,我们需要将其卸载。命令及结果如下:

root@kernelnewbies:~/docker.sh/images/ubuntu1604# umount -l /old_root/
复制代码

卸载掉老的根文件系统后,我们再查看系统的挂载点信息。命令及结果如下:

root@kernelnewbies:~/docker.sh/images/ubuntu1604# cat /proc/self/mountinfo624 381 8:1 /home/phl/docker.sh/images/ubuntu1604 / rw,relatime - ext4 /dev/sda1 rw,errors=remount-ro625 624 0:5 / /proc rw,relatime - proc proc rw
复制代码

此时,挂载点信息中只有/、/proc,不再有主机的挂载点信息。

3.3.docker.sh

有了以上关于切换根文件系统的知识,我们就可以将切换根文件系统的功能加入到 docker.sh 中了。切换根文件系统的功能将放在 container.sh 中,带下划线的行是我们为实现切换根文件系统而新添的代码。修改后的 container.sh 脚本如下:

#!/bin/bash
hostname $container
mkdir -p /sys/fs/cgroup/memory/$containerecho $$ > /sys/fs/cgroup/memory/$container/cgroup.procsecho $memory > /sys/fs/cgroup/memory/$container/memory.limit_in_bytes
mkdir -p images/$image/old_rootmount --bind images/$image images/$image
cd images/$imagepivot_root . ./old_root
mount -t proc proc /procumount -l /old_root
exec $program
复制代码

首先,我们在新的根文件系统目录中创建挂载老的根文件系统的目录。命令如下:

mkdir -p images/$image/old_root
复制代码

然后,我们将新根文件系统目录 bind mount 成一个挂载点。命令如下:

mount --bind images/$image images/$image
复制代码

然后,我们切换根文件系统。命令如下:

cd images/$imagepivot_root . ./old_root
复制代码

最后,我们重新挂载/proc 文件系统,然后卸载掉老的根文件系统。命令如下:

mount -t proc proc /procumount -l /old_root
复制代码

现在,我们运行 docker.sh,并查看当前的发行版信息。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo ./docker.sh -c run -m 100M -C dreamland -I ubuntu1604 -V data1 -P /bin/bashroot@dreamland:/# cat /etc/issueUbuntu 16.04 LTS \n \l
复制代码

从结果我们可以看出,读出的发行版信息是 Ubuntu 16.04 LTS \n \l,而非主机的 Ubuntu 18.04.3 LTS \n \l。这说明当前使用的根文件系统确实是 ubuntu16.04 目录下的根文件系统,而非主机的根文件系统。

我们再查看一下当前的挂载点信息,看看是否只有/与/proc。命令及结果如下:

root@dreamland:/# cat /proc/self/mountinfo625 381 8:1 /home/phl/docker.sh/images/ubuntu1604 / rw,relatime - ext4 /dev/sda1 rw,errors=remount-ro626 625 0:52 / /proc rw,relatime - proc proc rw
复制代码

从结果我们可看出,当前挂载点信息中只有/、/proc,不再有主机的挂载点信息。

通过根文件系统,我们实现了在容器中虚拟出与主机不一样的操作系统的功能。

4.联合加载

4.1.联合加载简介

联合加载指的是一次同时加载多个文件系统,但是在外面看起来只能看到 一个文件系统。联合加载会将各层文件系统叠加到一起,这样最终的文件系统会 包含所有底层的文件和目录。

联合加载的多个文件系统中有一个是可读写文件系统,称为读写层,其他文件系统是只读的,称为只读层。当联合加载的文件系统发生变化时,这些变化都应用到这个读写层。比如,如果想修改一个文件,这个文件首先会从只读层复制到读写层。原只读层中的文件依然存在,但是被读写层中的该文件副本所隐藏。我们以后读写该文件时,都是读写的该文件在读写层中的副本。这种机制被称为 写时复制。

我们之前实现的 docker.sh,有一个很大的缺陷。那就是,如果使用相同的根文件系统同时启动多个容器的实例,那么,这些容器实例使用的根文件系统位于同一个目录。我们在不同的容器实例对根文件系统所作的修改,这些容器彼此之间都可以看到,甚至一个容器可以覆覆盖另一个容器所作的修改。同时,容器实例退出时,对根文件系统所作的修改也直接作用于其所使用的根文件系统。当我们使用该根文件系统再次启动容器实例时,新启动的容器实例也可以看到以前的这些修改。例如,我们用 ubuntu1604 根文件系统启动两个容器实例,命令如下:

phl@kernelnewbies:~/docker.sh$ sudo ./docker.sh -c run -m 100M -C dreamland -I ubuntu1604 -V data1 -P /bin/bashphl@kernelnewbies:~/docker.sh$ sudo ./docker.sh -c run -m 100M -C dreamland2 -I ubuntu1604 -V data1 -P /bin/bash
复制代码

这两个容器实例对根文件系统做的修改彼此都可以看到。容器实例退出时,这些修改也被保存了下来,当用 ubuntu1604 根文件系统启动新的容器实例时,新实例也可看到以前实例所做的修改。

如果容器使用的根文件系统是一个联合加载的文件系统,原先的根文件系统作为一个只读层,再添加一个读写层,那么,在容器内所作的修改都将只作用于读写层。为了区分,我们以后称 ubuntu1604 目录下的根文件系统为镜像。而我们可以为每一个容器实例指定一个唯一的读写层目录,这样的话,多个容器实例就可以使用同一个镜像,容器内所作的修改不会影响彼此,也不会影响到以后启动的容器实例。例如:

phl@kernelnewbies:~/docker.sh$ sudo ./docker.sh -c run -m 100M -C dreamland -I ubuntu1604 -V data1 -P /bin/bashphl@kernelnewbies:~/docker.sh$ sudo ./docker.sh -c run -m 100M -C dreamland2 -I ubuntu1604 -V data1 -P /bin/bash
复制代码

我们使用 ubuntu1604 镜像启动了两个容器示例,并在容器实例里进行读写操作。这两个容器实例的读写层目录是不一样的,在容器实例中所作的修改只作用于各自的读写层,彼此之间不会影响,当然更不会影响到后续启动的容器实例。

4.2. AUFS

AUFS 是一个实现了联合加载功能的文件系统。我们将采用 AUFS 实现 docker.sh 中的联合加载功能。

下面,我们将通过实验演示一下 AUFS 文件系统的用法。首先,我们准备需要用到的目录及文件。命令及结果如下:

phl@kernelnewbies:~$ mkdir aufsphl@kernelnewbies:~$ cd aufs/phl@kernelnewbies:~/aufs$ mkdir rw r1 r2 unionphl@kernelnewbies:~/aufs$ echo hello r1 > r1/hellor1.txtphl@kernelnewbies:~/aufs$ echo hello r2 > r2/hellor2.txtphl@kernelnewbies:~/aufs$ echo hello rw > rw/hellorw.txt
复制代码

下表列出了各个目录的作用。列表如下:

  • rw 为 aufs 文件系统的读写层目录

  • r1 为 aufs 文件系统的只读层目录

  • r2 为 aufs 文件系统的只读层目录

  • union 为挂载点,联合加载的 aufs 文件系统挂载于此目录

下面我们将 rw、r1、r2 联合加载到 union 目录。命令如下:

phl@kernelnewbies:~/aufs$ sudo mount -t aufs -o dirs=rw:r1:r2 none union
复制代码
  • -t aufs 表示要挂载的文件系统类型为 AUFS

  • -o dirs=rw:r1:r2 表示要将哪些目录加载到 afus 文件系统中,多个目录之间以:分隔。目录列表中的第一个目录表示读写层目录

  • union 表示 aufs 文件系统要挂载的目录

挂载好 AUFS 文件系统后,我们进入该文件系统,查看其内容。命令及结果如下:

phl@kernelnewbies:~/aufs$ cd union/phl@kernelnewbies:~/aufs/union$ lshellor1.txt hellor2.txt hellorw.txt
复制代码

从输出结果来看,rw、r1、r2 目录下的内容全部出现在了 AUFS 文件系统中,该文件系统由 rw、r1、r2 目录叠加而成。

然后,我们修改这些文件,看看原始的 rw、r1、r2 目录下的文件是否更改。命令及结果如下:

phl@kernelnewbies:~/aufs/union$ echo hello to r1 from union > hellor1.txtphl@kernelnewbies:~/aufs/union$ echo hello to r2 from union > hellor2.txtphl@kernelnewbies:~/aufs/union$ echo hello to rw from union > hellorw.txt
复制代码

我们返回到 aufs 目录,直接查看 aufs 目录下的内容。命令及结果如下:

phl@kernelnewbies:~/aufs$ tree ..├── r1│   └── hellor1.txt├── r2│   └── hellor2.txt├── rw│   ├── hellor1.txt│   ├── hellor2.txt│   └── hellorw.txt└── union    ├── hellor1.txt    ├── hellor2.txt    └── hellorw.txt
4 directories, 8 files
复制代码

从输出结果我们可以看到,我们修改的 hellor1.txt 和 hellor2.txt 文件分别被拷贝了一份放在读写层目录 rw 中。我们查看一下这些文件的内容,命令及结果如下:

phl@kernelnewbies:~/aufs$ cat r1/hellor1.txthello r1phl@kernelnewbies:~/aufs$ cat r2/hellor2.txthello r2phl@kernelnewbies:~/aufs$ cat rw/hellor1.txthello to r1 from unionphl@kernelnewbies:~/aufs$ cat rw/hellor2.txthello to r2 from unionphl@kernelnewbies:~/aufs$ cat rw/hellorw.txthello to rw from union
复制代码

从输出结果我们看到,用户修改只读层 r1、r2 中的文件时,这些文件被复制到了读写层,我们修改的是读写层的副本,原只读层中的文件没有变化。用户修改读写层 rw 中的文件时,修改直接作用于这些文件本身。

4.3.docker.sh

在继续之前,我们需要将上一章在 ubuntu1604 根文件系统中创建的 old_root 目录删除掉,以保证该根文件系统跟刚制作好时一样。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo rm -rf images/ubuntu1604/old_root
复制代码

有了以上关于联合加载的介绍,我们就可以将联合加载功能加入到 docker.sh 中了。联合加载功能将放在 container.sh 脚本中,带下划线的行是我们为实现联合加载功能而新添的代码。修改后的 container.sh 如下:

#!/bin/bash
hostname $container
mkdir -p /sys/fs/cgroup/memory/$containerecho $$ > /sys/fs/cgroup/memory/$container/cgroup.procsecho $memory > /sys/fs/cgroup/memory/$container/memory.limit_in_bytes
mkdir -p $container/rwlayermount -t aufs -o dirs=$container/rwlayer:./images/$image none $container
mkdir -p $container/old_rootcd $containerpivot_root . ./old_root
mount -t proc proc /procumount -l /old_root
exec $program
复制代码

首先,我们根据容器的名字创建联合加载需要的读写层目录及文件系统挂载目录。命令如下:

mkdir -p $container/rwlayer
复制代码

假如我们传递的容器的名字为 dreamland,将创建以下目录:

phl@kernelnewbies:~/docker.sh$ tree dreamland/dreamland/└── rwlayer
复制代码

其中 dreamland/rwlayer 目录为创建的 AUFS 文件系统的读写层,dreamland 目录为 AUFS 文件系统的挂载点。

然后我们将镜像目录、读写层目录联合加载到挂载点目录。命令如下:

mount -t aufs -o dirs=$container/rwlayer:./images/$image none $container
复制代码

假如容器名字为 dreamland,使用的镜像为 ubuntu1604 根文件系统,dreamland/rwlayer、images/ubuntu1604 将被联合加载的 dreamland 目录。其中,dreamland/rwlayer 为 AUFS 文件系统的读写层,images/ubuntu1604 为 AUFS 文件系统的只读层。

之前我们将老的根文件系统挪到了 rootfs/old_root,rootfs 代表一个具体的镜像目录。创建 old_root 目录时直接修改了该镜像。下面我们将老的根文件系统的挂载点目录放在 AUFS 文件系统中,并将老的根文件系统挪到此处。命令如下:

mkdir -p $container/old_rootcd $containerpivot_root . ./old_root
复制代码

此时,$container 目录本身就是一个挂载点,挂载了 AUFS 文件系统。因此下面的代码就被移除了:

mount --bind images/$image images/$image
复制代码

现在,我们运行 docker.sh,并在/root 下创建一个文件。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo ./docker.sh -c run -m 100M -C dreamland -I ubuntu1604 -V data1 -P /bin/bashroot@dreamland:/# cd /rootroot@dreamland:/root# lsroot@dreamland:/root# cat /etc/issue > hello.txtroot@dreamland:/root# cat hello.txtUbuntu 16.04 LTS \n \l
复制代码

启动一个新的 Shell 窗口,查看一下该容器使用的 AUFS 文件系统。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo tree dreamland/dreamland/└── rwlayer    ├── old_root    └── root        └── hello.txt
2 directories, 1 file
复制代码

从结果我们可以看到,我们新建的文件及创建的老根文件系统的挂载点目录都出现在了读写层。我们再查看一下新创建的文件。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo cat dreamland/rwlayer/root/hello.txtUbuntu 16.04 LTS \n \l
复制代码

文件内容是 Ubuntu 16.04 的发行版信息。

通过联合加载,我们实现了在容器中的读写不会影响使用的镜像。这样使用 ubuntu1604 镜像创建多个容器时,彼此之间就不会相互影响了。

5.卷

5.1.卷简介

卷是容器内的一个目录,这个目录可以绕过联合文件系统,提供数据共享(容器所使用的的联合文件系统不应该被主机或其他容器访问)与数据持久化的功能。

举个例子,假如容器有个目录为/data 的卷,我们向这个卷写入的内容不会出现在联合文件系统的读写层,而是直接出现在这个目录里。主机与其他容器也可以访问该目录,从而达到数据共享与数据持久化的目的。

卷位于联合文件系统中,通常来说写入该目录的内容会被写入容器的读写层中,那么怎样才能是写入卷的目录直接出现在该目录中,而不是容器读写层呢?其实方法很简单,只要我们将该目录变成一个挂载点就行,变成挂载点后,这个目录中的内容就不属于联合文件系统了,写入该目录的内容自然会保存在挂载到该挂载点的设备中。

5.2.docker.sh

有了以上关于卷的介绍,我们就可以将卷功能加入到 docker.sh 中了。卷功能将放在 container.sh 脚本中,带下划线的行是我们为实现卷功能而新添的代码。修改后的 container.sh 脚本如下:

#!/bin/bash
hostname $container
mkdir -p /sys/fs/cgroup/memory/$containerecho $$ > /sys/fs/cgroup/memory/$container/cgroup.procsecho $memory > /sys/fs/cgroup/memory/$container/memory.limit_in_bytes
mkdir -p $container/rwlayermount -t aufs -o dirs=$container/rwlayer:./images/$image none $container
mkdir -p $volumemkdir -p $container/$volumemount --bind $volume $container/$volume
mkdir -p $container/old_rootcd $containerpivot_root . ./old_root
mount -t proc proc /procumount -l /old_root
exec $program
复制代码

首先,我们根据卷的名字创建主机卷目录,我们在容器内部对卷的修改,都将作用于此目录。命令如下:

mkdir -p $volume
复制代码

然后,我们在容器内部创建同名卷目录,该目录本身会出现在容器的读写层中,因为该目录是在 AUFS 文件系统中创建的。因为 $container 目录为容器的根目录,所以容器内部卷目录的路径为/$volume。命令如下:

mkdir -p $container/$volume
复制代码

将主机上的卷目录 bind mount 到容器内部的卷目录上,这样容器内部对卷目录的修改,都将作用于主机卷目录。命令如下:

mount --bind $volume $container/$volume
复制代码

现在,我们运行 docker.sh,并在卷目录(/data1)中创建一个文件。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo ./docker.sh -c run -m 100M -C dreamland -I ubuntu1604 -V data1 -P /bin/bashroot@dreamland:/# cd /data1root@dreamland:/data1# echo "hello to data1 volume from ubuntu16.04" >> hello.txt
复制代码

启动一个新的 Shell 窗口,查看一下该容器使用的 AUFS 文件系统中的内容。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo tree dreamland/dreamland/└── rwlayer    ├── data1    ├── old_root    └── root        └── hello.txt
4 directories, 1 file
复制代码

从结果我们可以看到,我们使用的卷目录被创建在了容器的读写层,但是我们在卷目录中新建的文件却没有出现在读写层中。

我们再来查看一下主机卷目录的内容。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo tree data1/data1/└── hello.txt
0 directories, 1 file
复制代码

从结果我们可以看到,在容器内部对卷目录的修改直接作用在了主机上的卷目录。我们再来查看一下主机卷目录下 hello.txt 中的内容。命令及结果如下:

phl@kernelnewbies:~/docker.sh$ sudo cat data1/hello.txthello to data1 volume from ubuntu16.04
复制代码

从结果我们可以看到,该文件的内容与我们在容器内部写入 hello.txt 的内容一致。

通过卷目录,我们实现了容器之间数据共享与数据持久化的功能。

6.后记

至此,我们通过一系列的实验对 docker 的底层技术有了一个感性的认识。我们在使用 docker 时,也能够对其是如何运作的有了一个大致的了解。当然,这对于掌握 docker 技术来说还远远不够,有很多知识我们没有涉及,例如 user namespace、容器安全、其他的 CGroups、虚拟网络等。

发布于: 2021 年 05 月 17 日阅读数: 50
用户头像

KernelNewbies

关注

苔花如米小,也学牡丹开。 2019.03.04 加入

专注创作有助于理解CPU体系结构、虚拟化的文章。

评论

发布
暂无评论
使用 Shell 脚本实现 Docker