乐趣区

Docker技术三大要点:cgroup, namespace和unionFS的理解

www.docker.com 的网页有这样一张有意思的动画:

从这张 gif 图片,我们不难看出 Docker 网站想传达这样一条信息, 使用 Docker 加速了 build,ship 和 run 的过程。
Docker 最早问世是 2013 年,以一个开源项目的方式被大家熟知。

Docker 的奠基者是 dotcloud,一家开发 PaaS 平台的技术公司。
不过可惜的是,这家公司把 Docker 开源之后,于 2016 年倒闭了,因为其主业务 PaaS 无法和微软,亚马逊等 PaaS 业界巨头竞争,不禁让人唏嘘。

Docker 其实是容器化技术的具体技术实现之一,采用 go 语言开发。很多朋友刚接触 Docker 时,认为它就是一种更轻量级的虚拟机,这种认识其实是错误的,Docker 和虚拟机有本质的区别。容器本质上讲就是运行在操作系统上的一个进程,只不过加入了对资源的隔离和限制。而 Docker 是基于容器的这个设计思想,基于 Linux Container 技术实现的核心管理引擎。
为什么资源的隔离和限制在云时代更加重要?在默认情况下,一个操作系统里所有运行的进程共享 CPU 和内存资源,如果程序设计不当,最极端的情况,某进程出现死循环可能会耗尽 CPU 资源,或者由于内存泄漏消耗掉大部分系统资源,这在企业级产品场景下是不可接受的,所以进程的资源隔离技术是非常必要的。
我当初刚接触 Docker 时,以为这是一项新的技术发明,后来才知道,Linux 操作系统本身从操作系统层面就支持虚拟化技术,叫做 Linux container,也就是大家到处能看到的 LXC 的全称。
LXC 的三大特色:cgroup,namespace 和 unionFS。
cgroup:
CGroups 全称 control group,用来限定一个进程的资源使用,由 Linux 内核支持,可以限制和隔离 Linux 进程组 (process groups) 所使用的物理资源,比如 cpu,内存,磁盘和网络 IO,是 Linux container 技术的物理基础。
namespace:
另一个维度的资源隔离技术,大家可以把这个概念和我们熟悉的 C ++ 和 Java 里的 namespace 相对照。
如果 CGroup 设计出来的目的是为了隔离上面描述的物理资源,那么 namespace 则用来隔离 PID(进程 ID),IPC,Network 等系统资源。
我们现在可以将它们分配给特定的 Namespace,每个 Namespace 里面的资源对其他 Namespace 都是透明的。
不同 container 内的进程属于不同的 Namespace,彼此透明,互不干扰。
我们用一个例子来理解 namespace 的必要。
假设多个用户购买了一台 Linux 服务器的 Nginx 服务,每个用户在该服务器上被分配了一个 Linux 系统的账号。我们希望每个用户只能访问分配给其的文件夹,这当然可以通过 Linux 文件系统本身的权限控制来实现,即一个用户只能访问属于他本身的那些文件夹。
但是有些操作仍然需要系统级别的权限,比如 root,但我们肯定不可能给每个用户都分配 root 权限。因此我们就可以使用 namespace 技术:
我们能够为 UID = n 的用户,虚拟化一个 namespace 出来,在这个 namespace 里面,该用户具备 root 权限,但是在宿主机上,该 UID = n 的用户还是一个普通用户,也感知不到自己其实不是一个真的 root 用户这件事。
同样的方式可以通过 namespace 虚拟化进程树。
在每一个 namespace 内部,每一个用户都拥有一个属于自己的 init 进程,pid = 1,对于该用户来说,仿佛他独占一台物理的 Linux 服务器。
对于每一个命名空间,从用户看起来,应该像一台单独的 Linux 计算机一样,有自己的 init 进程(PID 为 1),其他进程的 PID 依次递增,A 和 B 空间都有 PID 为 1 的 init 进程,子容器的进程映射到父容器的进程上,父容器可以知道每一个子容器的运行状态,而子容器与子容器之间是隔离的。从图中我们可以看到,进程 3 在父命名空间里面 PID 为 3,但是在子命名空间内,他就是 1. 也就是说用户从子命名空间 A 内看进程 3 就像 init 进程一样,以为这个进程是自己的初始化进程,但是从整个 host 来看,他其实只是 3 号进程虚拟化出来的一个空间而已。
看下面的图加深理解。
父容器有两个子容器,父容器的命名空间里有两个进程,id 分别为 3 和 4, 映射到两个子命名空间后,分别成为其 init 进程,这样命名空间 A 和 B 的用户都认为自己独占整台服务器。

Linux 操作系统到目前为止支持的六种 namespace:

unionFS:
顾名思义,unionFS 可以把文件系统上多个目录 (也叫分支) 内容联合挂载到同一个目录下,而目录的物理位置是分开的。
要理解 unionFS,我们首先要认识 bootfs 和 rootfs。
1. boot file system(bootfs):包含操作系统 boot loader 和 kernel。用户不会修改这个文件系统。
一旦启动完成后,整个 Linux 内核加载进内存,之后 bootfs 会被卸载掉,从而释放出内存。
同样内核版本的不同的 Linux 发行版,其 bootfs 都是一致的。
2. root file system(rootfs):包含典型的目录结构,包括 /dev, /proc, /bin, /etc, /lib, /usr, and /tmp
就是我下面这张图里的这些文件夹:

等再加上要运行用户应用所需要的所有配置文件,二进制文件和库文件。这个文件系统在不同的 Linux 发行版中是不同的。而且用户可以对这个文件进行修改。

Linux 系统在启动时,roofs 首先会被挂载为只读模式,然后在启动完成后被修改为读写模式,随后它们就可以被修改了。
不同的 Linux 版本,实现 unionFS 的技术可能不一样,使用命令 docker info 查看,比如我的机器上实现技术是 overlay2:

看个实际的例子。
新建两个文件夹 abap 和 java,在里面用 touch 命名分别创建两个空文件:

新建一个 mnt 文件夹,用 mount 命令把 abap 和 java 文件夹 merge 到 mnt 文件夹下,- t 执行文件系统类型为 aufs:
sudo mount -t aufs -o dirs=./abap:./java none ./mnt

mount 完成后,到 mnt 文件夹下查看,发现了来自 abap 和 java 文件夹里总共 4 个文件:

现在我到 java 文件夹里修改 spring,比如加上一行 spring is awesome, 然后到 mnt 文件夹下查看,发现 mnt 下面的文件内容也自动被更新了。

那么反过来会如何呢?比如我修改 mnt 文件夹下的 aop 文件:

而 java 文件夹下的原始文件没有受到影响:

实际上这就是 Docker 容器镜像分层实现的技术基础。如果我们浏览 Docker hub,能发现大多数镜像都不是从头开始制作,而是从一些 base 镜像基础上创建,比如 debian 基础镜像。
而新镜像就是从基础镜像上一层层叠加新的逻辑构成的。这种分层设计,一个优点就是资源共享。
想象这样一个场景,一台宿主机上运行了 100 个基于 debian base 镜像的容器,难道每个容器里都有一份重复的 debian 拷贝呢?这显然不合理;借助 Linux 的 unionFS,宿主机只需要在磁盘上保存一份 base 镜像,内存中也只需要加载一份,就能被所有基于这个镜像的容器共享。
当某个容器修改了基础镜像的内容,比如 /bin 文件夹下的文件,这时其他容器的 /bin 文件夹是否会发生变化呢?
根据容器镜像的写时拷贝技术,某个容器对基础镜像的修改会被限制在单个容器内。
这就是我们接下来要学习的容器 Copy-on-Write 特性。
容器镜像由多个镜像层组成,所有镜像层会联合在一起组成一个统一的文件系统。如果不同层中有一个相同路径的文件,比如 /text,上层的 /text 会覆盖下层的 /text,也就是说用户只能访问到上层中的文件 /text。
假设我有如下这个 dockerfile:
FROM debian
RUN apt-get install emacs
RUN apt-get install apache2
CMD [“/bin/bash”]
执行 docker build . 看看发生了什么。

生成的容器镜像如下:

当用 docker run 启动这个容器时,实际上在镜像的顶部添加了一个新的可写层。这个可写层也叫容器层。

容器启动后,其内的应用所有对容器的改动,文件的增删改操作都只会发生在容器层中,对容器层下面的所有只读镜像层没有影响。
要获取更多 Jerry 的原创文章,请关注公众号 ” 汪子熙 ”:

退出移动版