目录

容器基础

这个系列我们开始学习 k8s,但是想学好 k8s 并不容易,网络、操作系统、分布式原理都是 k8s 重要的组成部分。我自己将整个学习分成了如下几个系列:

  1. k8s 的设计和使用: 这个系列我们从使用层次上,明白 k8s 高层次的抽象和设计,达到能高效使用 k8s 的目的
  2. k8s 的源码解析: 这个系列我们从源码层次上,透析 k8s 的设计与实现,并学习 k8s 里面优秀的代码设计
  3. k8s 的网络模型: 这个系列我们从网络层次上,学习 k8s 上不同的网络插件对应的网络模型及其实现

这篇文章开始,我们学习 k8s 的设计和使用,选用的教材是:

  1. 极客时间张磊老师的专栏-深入剖析 Kubernetes
  2. Kubernetes in Action中文版

而今天的内容则是跟容器有关。容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”,而使用到的核心技术就是:

  1. Namespace: 修改进程视图,创建进程的边界
  2. Cgroups: 限制容器的资源使用量
  3. chroot/容器镜像: 容器镜像配合 chroot 更改容器看到的根目录

1. Namespace

1.1 Namespace 原理

Namespace 用来修改应用进程看待整个计算机“视图”,即应用进程的“视线”被操作系统做了限制,只能“看到”某些指定的内容。Linux 操作系统提供了六种 Namespace:

  1. PID: 用于让被隔离进程只看到当前 Namespace 里启动的进程
  2. Mount: 用于让被隔离进程只看到当前 Namespace 里的挂载点信息
  3. Network: 用于让被隔离进程看到当前 Namespace 里的网络设备和配置
  4. UTS: 隔离域名
  5. IPC: 隔离进程间通信
  6. User: 隔离用户

要知道在 Namespace 的隔离技术是在,操作系统发展的后期演化的,他们都是在已有的进程创建扩展而来。所以这些 Namespace 实际上是在创建容器进程时,指定的一组 Namespace 参数。这样,容器就只能“看”到当前 Namespace 所限定的资源、文件、设备、状态,或者配置。所以说,容器,其实是一种特殊的进程而已。对于宿主机来说,这些被“隔离”了的进程跟其他进程并没有太大区别。

Namespace 的底层实现原理参见,耗子叔的博客:

  1. DOCKER基础技术:LINUX NAMESPACE(上)
  2. DOCKER基础技术:LINUX NAMESPACE(下)

1.2 进程 Namespace 查看

一个进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 1. 查看容器对应的进程 PID
$ docker inspect --format '{{ .State.Pid }}'  3cfcb77fe21b

# 2. 查看容器进程的所有 Namespace 对应的文件
$ ll /proc/82155/ns
总用量 0
lrwxrwxrwx. 1 root root 0 3月   5 21:05 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx. 1 root root 0 3月   5 21:05 ipc -> 'ipc:[4026532619]'
lrwxrwxrwx. 1 root root 0 3月   5 21:05 mnt -> 'mnt:[4026532617]'
lrwxrwxrwx. 1 root root 0 3月   5 20:20 net -> 'net:[4026532622]'
lrwxrwxrwx. 1 root root 0 3月   5 21:05 pid -> 'pid:[4026532620]'
lrwxrwxrwx. 1 root root 0 3月   5 21:05 pid_for_children -> 'pid:[4026532620]'
lrwxrwxrwx. 1 root root 0 3月   5 21:05 time -> 'time:[4026531834]'
lrwxrwxrwx. 1 root root 0 3月   5 21:05 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx. 1 root root 0 3月   5 21:05 user -> 'user:[4026531837]'
lrwxrwxrwx. 1 root root 0 3月   5 21:05 uts -> 'uts:[4026532618]'

正是因为 Namespace 对应的是一个个正是的文件,所以一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达到“进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。而这个操作所依赖的,乃是一个名叫 setns() 的 Linux 系统调用。

Docker 还专门提供了一个参数,可以让你启动一个容器并“加入”到另一个容器的 Network Namespace 里,这个参数就是 -net,比如:

1
$ docker run -it --net container:4ddf4638572d busybox ifconfig

如果指定–net=host,就意味着这个容器不会为进程启用 Network Namespace,而共享宿主机的物理网络。

1.2 Cgroups

Linux Cgroups 是 Linux 内核中用来为进程设置资源限制的一个重要功能。Linux Cgroups 的全称是 Linux Control Group 用来限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。此外,Cgroups 还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。

Cgroups 接口

在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 路径下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 查看 cgroup 挂载点
$ mount -t cgroup

$ pwd
/sys/fs/cgroup

$ ll
dr-xr-xr-x. 6 root root  0 10月 19 06:26 blkio
lrwxrwxrwx. 1 root root 11 10月 19 06:26 cpu -> cpu,cpuacct
lrwxrwxrwx. 1 root root 11 10月 19 06:26 cpuacct -> cpu,cpuacct
dr-xr-xr-x. 6 root root  0 10月 19 06:26 cpu,cpuacct
dr-xr-xr-x. 2 root root  0 10月 19 06:26 cpuset
dr-xr-xr-x. 6 root root  0 10月 19 06:26 devices
dr-xr-xr-x. 2 root root  0 10月 19 06:26 freezer
dr-xr-xr-x. 2 root root  0 10月 19 06:26 hugetlb
dr-xr-xr-x. 6 root root  0 10月 19 06:26 memory
lrwxrwxrwx. 1 root root 16 10月 19 06:26 net_cls -> net_cls,net_prio
dr-xr-xr-x. 2 root root  0 10月 19 06:26 net_cls,net_prio
lrwxrwxrwx. 1 root root 16 10月 19 06:26 net_prio -> net_cls,net_prio
dr-xr-xr-x. 2 root root  0 10月 19 06:26 perf_event
dr-xr-xr-x. 6 root root  0 10月 19 06:26 pids
dr-xr-xr-x. 2 root root  0 10月 19 06:26 rdma
dr-xr-xr-x. 6 root root  0 10月 19 06:26 systemd

/sys/fs/cgroup 目录下的每个子目录代表着一种资源的子系统,比如

  1. blkio,为​​​块​​​设​​​备​​​设​​​定​​​I/O 限​​​制,一般用于磁盘等设备;
  2. cpuset,为进程分配单独的 CPU 核和对应的内存节点;
  3. memory,为进程设定内存使用的限制。

使用方法也很简单:

  1. 首先进入到想限制的资源的目录,然后创建一个目录,这个目录就是一个控制组
  2. cgroup 挂载的虚拟文件系统会在创建目录的同时,在目录内创建一系列资源限制文件
  3. 往这些文件里面写入进程的资源使用限额即可
  4. 当前目录下有一个 task 文件,将被限制进程 PID 写入 task,就可以指定资源限定的目标

Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。

而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行 docker run 时的参数指定了,比如这样一条命令:

 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
$ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash

$ docker ps
CONTAINER ID   IMAGE           COMMAND        CREATED          STATUS          PORTS     NAMES
3cfcb77fe21b   ubuntu:latest   "sleep 3600"   19 seconds ago   Up 18 seconds             strange_leakey

$ ll /sys/fs/cgroup/cpu/docker/3cfcb77fe21b26db68d05aceaa6790ba998ac157a566bdd241b8fc0de13600a4/
总用量 0
-rw-r--r--. 1 root root 0 3月   5 20:21 cgroup.clone_children
-rw-r--r--. 1 root root 0 3月   5 20:20 cgroup.procs
-r--r--r--. 1 root root 0 3月   5 20:21 cpuacct.stat
-rw-r--r--. 1 root root 0 3月   5 20:21 cpuacct.usage
-r--r--r--. 1 root root 0 3月   5 20:21 cpuacct.usage_all
-r--r--r--. 1 root root 0 3月   5 20:21 cpuacct.usage_percpu
-r--r--r--. 1 root root 0 3月   5 20:21 cpuacct.usage_percpu_sys
-r--r--r--. 1 root root 0 3月   5 20:21 cpuacct.usage_percpu_user
-r--r--r--. 1 root root 0 3月   5 20:21 cpuacct.usage_sys
-r--r--r--. 1 root root 0 3月   5 20:21 cpuacct.usage_user
-rw-r--r--. 1 root root 0 3月   5 20:21 cpu.cfs_period_us
-rw-r--r--. 1 root root 0 3月   5 20:21 cpu.cfs_quota_us
-rw-r--r--. 1 root root 0 3月   5 20:21 cpu.rt_period_us
-rw-r--r--. 1 root root 0 3月   5 20:21 cpu.rt_runtime_us
-rw-r--r--. 1 root root 0 3月   5 20:21 cpu.shares
-r--r--r--. 1 root root 0 3月   5 20:21 cpu.stat
-rw-r--r--. 1 root root 0 3月   5 20:21 notify_on_release
-rw-r--r--. 1 root root 0 3月   5 20:21 tasks

$  cat /sys/fs/cgroup/cpu/docker/3cfcb77fe21b26db68d05aceaa6790ba998ac157a566bdd241b8fc0de13600a4/cpu.cfs_period_us
100000
$ cat /sys/fs/cgroup/cpu/docker/3cfcb77fe21b26db68d05aceaa6790ba998ac157a566bdd241b8fc0de13600a4/cpu.cfs_quota_us
20000

1.3 容器镜像

容器镜像和 Mount Namespace

要理解容器镜像的关键是搞清楚容器镜像与 Mount Namespace 之间的关系

首先 Mount Namespace 修改的,是容器进程对文件系统“挂载点”的认知。但是,这也就意味着,只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。这就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。

所以处理 Mount Namespace 我们还需要在容器进程启动之前重新挂载它的整个根目录“/”,而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)

在 Linux 操作系统里,有一个名为 chroot 的命令可以帮助你在 shell 中方便地完成重新挂载整个根目录“/”。顾名思义,它的作用就是帮你“change root file system”,即改变进程的根目录到你指定的位置。实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。

容器镜像带来的改变

对一个应用来说,操作系统本身才是它运行所需要的最完整的“依赖库”。由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。

1.4 容器启动过程

所以对 Docker 项目来说,它最核心的原理实际上就是为待创建的用户进程:

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

不过,Docker 项目在最后一步的切换上会优先使用 pivot_root 系统调用,如果系统不支持,才会使用 chroot。这两个系统调用虽然功能类似,但是也有细微的区别。另外,需要明确的是,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。

1.5 容器的缺陷

容器的缺陷,根本原因在于隔离不够彻底

共享内核

首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。这意味着,如果你要在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通的。

同时这也意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。

Namespace 隔离粒度不够

其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。这就意味着,如果你的容器中的程序使用 settimeofday(2) 系统调用修改了时间,整个宿主机的时间都会被随之修改,这显然不符合用户的预期。

资源统计不准确

另外,跟 Namespace 的情况类似,Cgroups 对资源的限制能力也有很多不完善的地方,被提及最多的自然是 /proc 文件系统的问题。Linux 下的 /proc 目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息。但是,你如果在容器里执行 top 指令,就会发现,它显示的信息居然是宿主机的 CPU 和内存数据,而不是当前容器的数据。造成这个问题的原因就是,/proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,即:/proc 文件系统不了解 Cgroups 限制的存在。

在生产环境中,这个问题必须进行修正,否则应用程序在容器里读取到的 CPU 核数、可用内存等信息都是宿主机上的数据,这会给应用的运行带来非常大的困惑和风险。这也是在企业中,容器化应用碰到的一个常见问题,也是容器相较于虚拟机另一个不尽如人意的地方。注: 解决办法参见 lxcfs。

安全限制不够

此外,由于上述问题,尤其是共享宿主机内核的事实,容器给应用暴露出来的攻击面是相当大的,应用“越狱”的难度自然也比虚拟机低得多。尽管在实践中我们确实可以使用 Seccomp 等技术,对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但这种方法因为多了一层对系统调用的过滤,必然会拖累容器的性能。何况,默认情况下,谁也不知道到底该开启哪些系统调用,禁止哪些系统调用。

所以,在生产环境中,没有人敢把运行在物理机上的 Linux 容器直接暴露到公网上。当然,我后续会讲到的基于虚拟化或者独立内核技术的容器实现,则可以比较好地在隔离与性能之间做出平衡。

1.6 容器的单进程模型

正因为容器的 Namespace 和 Cgroups 是施加在单个进程上的,所以容器技术中一个非常重要的概念,即:容器是一个“单进程”模型。

由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里 PID=1 的进程,也是其他后续创建的所有进程的父进程。这就意味着,在一个容器中,你没办法同时运行两个不同的应用,除非你能事先找到一个公共的 PID=1 的程序来充当两个不同应用的父进程,这也是为什么很多人都会用 systemd 或者 supervisord 这样的软件来代替应用本身作为容器的启动进程。

在容器的设计模式中,容器本身的设计,是希望容器和应用能够同生命周期,这个概念对后续的容器编排非常重要。否则,一旦出现类似于“容器是正常运行的,但是里面的应用早已经挂了”的情况,编排系统处理起来就非常麻烦了。

2. 联合文件系统

为了提高容器镜像的复用能力,Docker 在容器镜像的制作上采用了一个叫联合文件系统的新实现。联合文件系统(Union File System)的能力。Union File System 也叫 UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。

2.1 overlay2

在我的 Centos 机器上,docker 使用的联合文件系统是 overlay2,它的关键目录位于 /var/lib/docker/overlay2。我在前面运行了一个 ubuntu 的容器,docker 自动将 ubuntu 的镜像拉取了本地。

这个所谓的“镜像”,实际上就是一个 Ubuntu 操作系统的 rootfs,它的内容是 Ubuntu 操作系统的所有文件和目录。而这个 Ubuntu 的镜像实际上是由多个层组成的。

首先我们看一下上面我们启动的 8cc9715e0e9d rootfs 挂载在哪:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
docker inspect 3cfcb77fe21b

        "GraphDriver": {
            "Data": {
                "LowerDir": "/var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200-init/diff:/var/lib/docker/overlay2/7c200b7659c9c12d5ab0baeae54d03bbeb7d490c3d97f6a85d18c8ae8d1a2f0e/diff",
                "MergedDir": "/var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/merged",
                "UpperDir": "/var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/diff",
                "WorkDir": "/var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/work"
            },
            "Name": "overlay2"
        },

在 overlay2 中:

  1. LowerDir:指向镜像层;
  2. UpperDir:指向容器层,在容器中创建文件后,文件出现在此目录;
  3. MergedDir:容器挂载点 ,lowerdir和upperdir整合起来提供统一的视图给容器,作为根文件系统;
  4. WorkDir:用于实现copy_up操作。

所以 3cfcb77fe21b 的镜像由下面三个层组成:

  1. /var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/diff
  2. /var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200-init/diff
  3. /var/lib/docker/overlay2/7c200b7659c9c12d5ab0baeae54d03bbeb7d490c3d97f6a85d18c8ae8d1a2f0e/diff

并被联合挂载在 /var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/merged

而 7c200b7659c9c12d5ab0baeae54d03bbeb7d490c3d97f6a85d18c8ae8d1a2f0e/diff 正是 ubuntu 镜像的容器层:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ docker image inspect  2b4cba85892a|less

"GraphDriver": {
            "Data": {
                "MergedDir": "/var/lib/docker/overlay2/7c200b7659c9c12d5ab0baeae54d03bbeb7d490c3d97f6a85d18c8ae8d1a2f0e/merged",
                "UpperDir": "/var/lib/docker/overlay2/7c200b7659c9c12d5ab0baeae54d03bbeb7d490c3d97f6a85d18c8ae8d1a2f0e/diff",
                "WorkDir": "/var/lib/docker/overlay2/7c200b7659c9c12d5ab0baeae54d03bbeb7d490c3d97f6a85d18c8ae8d1a2f0e/work"
            },
            "Name": "overlay2"
        },

通过系统的 mount 信息,可以看到同样的挂载信息:

1
2
3
4
5
6
7
8
$ cat /proc/mounts |grep overlay
overlay /var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/merged overlay rw,seclabel,relatime,lowerdir=/var/lib/docker/overlay2/l/72ZL2SJGZXEDYBHENYYYYXVRYS:/var/lib/docker/overlay2/l/GRNLLRA54UDERQYOS6AQFNC5K4,upperdir=/var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/diff,workdir=/var/lib/docker/overlay2/b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/work 0 0

$ ll /var/lib/docker/overlay2/l
总用量 0
lrwxrwxrwx. 1 root root 72 3月   5 20:20 2QUYA4NJODHDXHYSIDH3TEFKSW -> ../b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/diff
lrwxrwxrwx. 1 root root 77 3月   5 20:20 72ZL2SJGZXEDYBHENYYYYXVRYS -> ../b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200-init/diff
lrwxrwxrwx. 1 root root 72 3月   5 15:49 GRNLLRA54UDERQYOS6AQFNC5K4 -> ../7c200b7659c9c12d5ab0baeae54d03bbeb7d490c3d97f6a85d18c8ae8d1a2f0e/diff

2.2 容器的 rootfs 组成

从这个结构可以看出来,这个容器的 rootfs 由如下图所示的三部分组成:

  1. 第一部分,只读层: 这个容器的 rootfs 最下面的一层
  2. 第二部分,可读写层:
  • 这个容器的 rootfs 最上面的一层
  • 在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中
  • 我们使用 docker commit 和 push 指令,保存的正是这个被修改过的可读写层
  1. 第三部分,Init 层:
  • Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息
  • 这一部分属于私密信息,即便用户修改后也不希望被提交到 docker hub 上去,所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。

/images/k8s/k8s_use/rootfs.webp

注: 图片摘录自专栏,上面的 overlay2 就是如下三层:

1
2
3
b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200/diff
b09b7979ceab286d53ed72fe122c2807cb2145e14b60bdc33ab3de3267a73200-init/diff
7c200b7659c9c12d5ab0baeae54d03bbeb7d490c3d97f6a85d18c8ae8d1a2f0e/diff

2.3 copy-on-write

由于使用了联合文件系统,你在容器里对镜像只读层的 rootfs 所做的任何修改,都会被操作系统先复制到最上层的可读写层,然后再修改。这就是所谓的:Copy-on-Write。

3. docker volume

3.1 volume 介绍

有了容器镜像,我们还有两个问题需要考虑:

  1. 容器里进程新建的文件,怎么才能让宿主机获取到?
  2. 宿主机上的文件和目录,怎么才能让容器里的进程访问到?

这正是 Docker Volume 要解决的问题:Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。

在 Docker 项目里,它支持两种 Volume 声明方式

1
2
3

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

而这两种声明方式的本质,实际上是相同的:只不过,在第一种情况下,由于你并没有显示声明宿主机目录,那么 Docker 就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的 /test 目录上。而在第二种情况下,Docker 就直接把宿主机的 /home 目录挂载到容器的 /test 目录上。

3.2 volume 原理

前面已经介绍过,当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。在容器进程启动后这些容器的镜像层就会被联合挂载到 /var/lib/docker/overlay2/[container_id]/merged 下,这样容器所需的 rootfs 就准备好了。

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

更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。

注意:这里提到的"容器进程",是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。

而这里要使用到的挂载技术,就是 Linux 的**绑定挂载(bind mount)**机制。它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

其实,如果你了解 Linux 内核的话,就会明白,绑定挂载实际上是一个 inode 替换的过程。在 Linux 操作系统中,inode 可以理解为存放文件内容的“对象”,而 dentry,也叫目录项,就是访问这个 inode 所使用的“指针”。

/images/k8s/k8s_use/bind.webp

正如上图所示,mount –bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何,一旦执行 umount 命令,/test 目录原先的内容就会恢复:因为修改真正发生在的,是 /home 目录里。

所以,在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。这样,进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录里,而不会影响容器镜像的内容。

这个 /test 目录里的内容,既然挂载在容器 rootfs 的可读写层,它会不会被 docker commit 提交掉呢?也不会。这个原因其实我们前面已经提到过。容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录,始终是空的。

不过,由于 Docker 一开始还是要创建 /test 这个目录作为挂载点,所以执行了 docker commit 之后,你会发现新产生的镜像里,会多出来一个空的 /test 目录。毕竟,新建目录操作,又不是挂载操作,Mount Namespace 对它可起不到“障眼法”的作用。

1
2
3
4
5
6
7
$ sudo docker run -it -v /test ubuntu:latest bash
$ docker volume ls
DRIVER    VOLUME NAME
local     a4c01d9046842c0f19addd9012c2f01556af04ea02fdb6a826d0d342ce35808e

$ ll /var/lib/docker/volumes/a4c01d9046842c0f19addd9012c2f01556af04ea02fdb6a826d0d342ce35808e/_data/
总用量 0

3.3 容器的总结

Docker 容器,我们就可以用下面这个“全景图”描述出来:

/images/k8s/k8s_use/container.webp

其包含如下几个部分:

  1. 这个容器进程“python app.py”,运行在由 Linux Namespace 和 Cgroups 构成的隔离环境里;
  2. 它运行所需要的各种文件,比如 python,app.py,以及整个操作系统文件,则由多个联合挂载在一起的 rootfs 层提供。
  3. rootfs 层的最下层,是来自 Docker 镜像的只读层
  4. 只读层之上,是 Docker 自己添加的 Init 层,用来存放被临时修改过的 /etc/hosts 等文件
  5. rootfs 的最上层是一个可读写层,它以 Copy-on-Write 的方式存放任何对只读层的修改,容器声明的 Volume 的挂载点,也出现在这一层

4. 从容器到 kubernetes

4.1 kubernetes 架构

/images/k8s/k8s_use/k8s_architecture.webp

Kubernetes 的架构由 Master 和 Node 两种节点组成,而这两种角色分别对应着控制节点和计算节点。

  1. 控制节点,即 Master 节点,由三个紧密协作的独立组件组合而成,它们分别是:
  • 负责 API 服务的 kube-apiserver
  • 负责调度的 kube-scheduler
  • 负责容器编排的 kube-controller-manager
  • 整个集群的持久化数据,则由 kube-apiserver 处理后保存在 Etcd 中
  1. 计算节点上最核心的部分,则是一个叫作 kubelet 的组件
  • kubelet 主要负责同容器运行时(比如 Docker 项目)打交道。这个交互所依赖的,是一个称作 **CRI(Container Runtime Interface)**的远程调用接口,这个接口定义了容器运行时的各项核心操作
  • 具体的容器运行时,比如 Docker 项目,则一般通过 OCI 这个容器运行时规范同底层的 Linux 操作系统进行交互,即:把 CRI 请求翻译成对 Linux 操作系统的调用(操作 Linux Namespace 和 Cgroups 等)
  • kubelet 还通过 gRPC 协议同一个叫作 Device Plugin 的插件进行交互。这个插件,是 Kubernetes 项目用来管理 GPU 等宿主机物理设备的主要组件,也是基于 Kubernetes 项目进行机器学习训练、高性能作业支持等工作必须关注的功能
  • kubelet 的另一个重要功能,则是调用网络插件和存储插件为容器配置网络和持久化存储。这两个插件与 kubelet 进行交互的接口,分别是 **CNI(Container Networking Interface)**和 CSI(Container Storage Interface)

Kubernetes 从 Google Borg 系统演化而来。因此从一开始就把关注点放到了如何编排、管理、调度用户提交的作业上。这个出发点来自于 Borg 的研究人员在论文中提到的一个非常重要的观点:运行在大规模集群中的各种任务之间,实际上存在着各种各样的关系。这些关系的处理,才是作业编排和管理系统最困难的地方。

4.2 kubernetes 核心功能

所以 Kubernetes 项目最主要的设计思想是,从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系留有余地。除了应用与应用之间的关系外,应用运行的形态是影响“如何容器化这个应用”的第二个重要因素。

正是基于容器间关系和形态两个维度,Kubernetes 演化出了下面的核心功能:

/images/k8s/k8s_use/k8s_function.webp

当我们在使用这些核心功能时 Kubernetes 所推崇的使用方法是:

  1. 首先,通过一个“编排对象”,比如 Pod、Job、CronJob 等,来描述你试图管理的应用;
  2. 然后,再为它定义一些“服务对象”,比如 Service、Secret、Horizontal Pod Autoscaler(自动水平扩展器)等。这些对象,会负责具体的平台级功能。

这种使用方法,就是所谓的声明式 API。这种 API 对应的编排对象和服务对象,都是 Kubernetes 项目中的 API 对象(API Object)。

过去很多的集群管理项目(比如 Yarn、Mesos,以及 Swarm)所擅长的,都是把一个容器,按照某种规则,放置在某个最佳节点上运行起来。这种功能,我们称为“调度”。而 Kubernetes 项目所擅长的,是按照用户的意愿和整个系统的规则,完全自动化地处理好容器之间的各种关系。这种功能,就是我们经常听到的一个概念:编排。所以说,Kubernetes 项目的本质,是为用户提供一个具有普遍意义的容器编排工具。