容器技术的基础

容器技术的基础

1. 从进程开始说起

假如现在要写一个计算加法的小程序,这个程序需要的输入来自一个文件,计算完成后的结果则输出到另一个文件中。

由于计算机只认识 0 和 1,因此无论这段代码是用哪种语言编写的,最后都需要通过某种方式翻译二进制文件,才能在计算机操作系统中运行。

为了能够让这些代码正常运行,我们往往要给它提供数据,比如在这个加法程序中所需要的输入文件。这些数据加上代码本身的二进制文件放在磁盘上,就是我们平常所说的一个“程序“。

首先,操作系统从“程序”中发现输入数据保存在一个文件中,然后这些数据会被加载到内存中待命。同时,操作系统又读取到了计算加法的指令,这时,它就需要知识 CPU 完成加法操作。而 CPU 与内存协作进行加法计算,又会使用寄存器存放数值,内存堆栈保存执行的命令和变量。同时,计算机里还有被打开的文件,以及各种各样的 I/O 设备在不断的调用中修改自己的状态。

就这样,“程序”一旦被执行,它就从磁盘上的二进制文件变成了由计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息组成的一个集合。像这样一个程序运行起来之后的计算机执行环境的总和,就是“进程

容器技术的核心:就是通过约束和修改进程的动态表现,为其创造一个“边界”

对于 Linux 容器来说:

  • Cgroups 技术是用来制造约束的主要手段。
  • Namespace 技术是用来修改进程视图的主要方法。

现在让我们创建一个容器:

1
2
$ docker run -it busybox /bin/sh
/ #

接下来,执行 ps 命令

1
2
3
4
/# ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh
10 root 0:00 ps

可以看到,在 Docker 里最开始执行的 /bin/sh 就是这个容器内部的第 1 号进程(PID=1),而容器里共有两个进程在运行。

这种对被隔离应用的进程空间动了手脚,使得这些进程只能“看到”重新计算过的 PID,比如 PID=1,可实际上,在宿主机的操作系统里,还是原来的 100 号进程。这种技术就是 Linux 的 Namespace 机制。

Linux 系统中创建线程的系统调用是 clone(),比如:

1
int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);

这时,创建的进程就会”看到“一个全新的进程空间。在这个进程空间里,它的 PID 是 1.

Linux 命名空间种类:

  • PID
  • Mount
  • UTS
  • IPC
  • Network
  • User

可见,容器其实是一种特殊的进程而已。

虚拟机和容器的对比图

总结:Docker 项目帮助用户启动的还是原来的应用进程,只不过在创建这些进程时,Docker 为它们加上了各种各样的 Namespace 参数。

2. 隔离与限制

Linux Namespace 实际上修改了应用进程看待整个计算机“视图”的视野。

虚拟机优势和劣势:

  • 隔离得彻底。
  • 自身占用资源。
  • 系统调用等导致的性能损耗。

虚拟机优势和劣势

  • 高性能、低损耗。
  • 隔离得不彻底,共享内核。
  • 很多资源和对象不能被 Namespace 化,最典型的例子就是:时间。

Linux Cgroups 是 Linux 内核中用来为进程设置资源限制的一个重要功能。

  • 限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络宽带等。
  • 对进程进行优先级设置、审计,以及进程挂起和恢复等操作。

Cgroups 无法限制 /proc 文件系统,导致容器内 top 看到的数据和宿主机的数据相同。

3. 深入理解容器镜像

挂载在容器根目录上用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像“(rootfs,根文件系统)

Docker 项目最核心的原理实际上就是为待创建的用户进程:

    1. 启用 Linux Namespace 配置;
    1. 设置指定的 Cgroups 参数;
    1. 切换进程的根目录(change root)。

Docker 在镜像的设计中引入了层(layer)的概念,用户制作镜像的每一步操作都会生成一个层,也就是一个增量 rootfs。使用了一种叫做 UnionFS(union file system,联合文件系统)的能力。它最主要的功能是将不同位置的目录联合挂载(union mount)到同一个目录下。

1
2
3
4
5
6
7
8
9
10
11
12
$ tree
.
|-- A
| |-- a
| `-- x
|-- B
| |-- a
| `-- x
|-- C
`-- tmp

$ mount -t overlay -o lowerdir=./A,upperdir=./B,workdir=./tmp overlay ./C

镜像的层都放置在 /var/lib/docker/overlay2/diff/ 目录下。在使用镜像时,Docker 会把这这些增量联合挂载在一个统一的挂载点上 /var/lib/docker/overlay2/mnt/ 下。

rootfs 构成示意图

  • 只读层

只读层是容器的 rootfs 最下面的层,它们的挂载方式都是只读的(ro+wh,即 readonly + whiteout)

  • 可读写层

可读写层是容器的 rootfs 最上面的一层,它的挂载方式为 rw,即 read write。在写入文件之前,这个目录是空的。而一旦在容器里进行了写操作,修改产生的内容就会以增量的方式出现在该层中。如果我们要删除只读层里的文件,会在可读写层创建一个 whiteout 文件,把只读写层里的文件“遮挡”。

  • Init 层

Init 层是一个以 -init 结尾的层,夹在只读层和可读写层之间。为了能够存放 /etc/hosts、/etc/resolv.conf 等信息,防止 docker commit 连同可读写层一起提交。独立出了一个只改变容器的 init 层。

4. 重新认识容器

Linux Namespace 创建的隔离空间虽然看不见,摸不着,但一个进程的 Namespace 信息再宿主机上是确实存在的,并且以文件的形式存在。

1
$ docker inspect --format '{{ .State.Pid }}' {container_id}

Volume 机制允许你讲宿主机上指定的目录或者文件挂载到容器中进行读取和修改。

Docker 项目支持两种 Volume 声明格式,可以把宿主机目录挂载进容器的 /test 目录

1
2
$ docker run -v /test ...
$ docker run -v /home:/test ...

第一种情况下,由于你没有显示声明宿主机目录,因此 Docker 默认再宿主机上创建一个临时目录 /var/lib/docker/volumes/[ID]/_data 目录。
第二种情况下,Docker 直接把宿主机的 /home 目录挂载到了容器的 /test 目录上。

Volume 实现机制:

当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以“看到”宿主机上的整个文件系统。

宿主机上的文件系统自然也包括我们要使用的容器镜像。这个镜像的各个层保存在 /var/lib/docker/aufs/diff 目录下,在容器进程启动后,它们会被联合挂载在 /var/lib/docker/aufs/mnt/ 目录中,这样容器所需的 rootfs 就准备好了。

所以,我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录)挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。


参考:

容器技术的基础
http://www.liarsa.me/2023/08/09/容器技术的基础/
Author
Liarsa Shepard
Posted on
August 9, 2023
Licensed under