前言
根据 CNCF 的定义,云原生的代表技术包括**容器、服务网格、微服务、不可变基础设施以及声明式应用编程接口 (API) **。容器作为云原生的关键技术,催生了云原生思潮,而云原生生态也推动了容器技术发展。
近年来,以 K8s 和 Docker 为代表的容器技术已发展成一项通用技术,但容器技术涉及到众多概念,因此本文将用通俗易懂的语言描述容器底层技术的实现方式,加深读者对容器技术的理解。本文将从四个方面,来介绍容器是如何实现运行时的环境隔离与数据交互。
定义
容器的一个专业定义是:容器就是一个视图隔离、资源可限制、独立文件系统的进程集合。一个通俗的解释是:像集装箱一样,把应用“装”起来的技术。这个“装”,有两个含义,一是在应用之间设立了边界,避免互相干扰;二是能够方便的搬来搬去,随开随用。
我们知道,“程序”是由数据和二进制文件组成的,是进程的静态表现。一旦“程序”被运行起来,就变成了各种值、指令、状态的一个集合,是进程的动态表现。而容器技术的核心功能,就是通过约束和修改进程的动态表现,为其创造一个“边界”。制造约束手段是通过 Cgroups 技术实现的,修改进程的动态表示是通过 Namespace 技术实现的。下面将通过具体的操作来理解这两项技术。
Namespce
首先创建一个容器,该命令是告诉 Docker 启动一个 ubuntu 的容器,并开启一个可以交互的命令行终端。
$ docker run -it ubuntu /bin/sh
此时执行 ps 命令查看容器内的进程,就会发现容器内只有两个进程,其中 /bin/sh 进程的 PID=1,说明 Docker 已经把容器和宿主机隔离开。
# ps
PID TTY TIME CMD
1 pts/0 00:00:00 sh
6 pts/0 00:00:00 ps
而在实际的过程中,当在宿主机上启动一个 /bin/sh 的程序,操作系统就会分配一个进程号,比如 PID=10,可以简单地理解为操作系统中的第10个进程,在该程序前面还有着若干进程。而 Docker 通过某种技术,让 PID=10 的进程看不到它前面的进程,误以为自己1号进程。这种技术就是 Linux 的 Namespace 机制。
Namespce 的实现方式我们并不陌生,其实就是在 Linux 创建新进程命令的一个可选参数,比如下面常见的命令 clone():
int pad = clone(function, stack_size, CLONE_NEWPID | SIGCHLD, NULL)
通过指定 CLONE_NEWPID 参数,从新创建的进程的视角来看,它的 PID 就是1。而从宿主机的视角来看,它的 PID 还是原始的数值,比如10。
除了 PID Namespace,Linux 还提供了 Mount、User、IPC等 Namespace 。
Namespace | 系统调用参数 | 作用 |
---|---|---|
IPC | CLONE_NEWIPC | 提供进程间通信的隔离能力 |
PID | CLONE_NEWPID | 提供进程ID的隔离能力 |
Mount | CLONE_NEWNS | 提供文件系统挂载点的隔离能力 |
Network | CLONE_NEWNET | 提供网络设备的隔离能力 |
User | CLONE_NEWUSER | 提供用户和用户组ID的隔离能力 |
UTS | CLONE_NEWUTS | 提供主机名和域名的隔离能力 |
Cgroup | CLONE_NEWCGROUP | 提供进程所属的控制组的身份隔离 |
Time | CLONE_NEWTIME | 提供时间的隔离能力 |
所以说,容器就是一种特殊的进程,在创建容器进程的时候,通过指定一组 Namespace 参数,使得容器看到的是当前 Namespace 所隔离出来的资源、文件、设备等,各个容器运行在宿主机上,互相看不到。
此时,附上虚拟机和容器的经典对比图。
通过对 Namespace 的介绍就能容易地理解这两者的区别。虚拟机通过硬件虚拟化功能,模拟出了一个操作系统需要的各种硬件,然后在这些硬件上安装了一个操作系统,运行在该操作系统中的应用进程,能看到的也只是该操作系统下的文件和设备,自然起到了隔离的作用。而容器与虚拟机不同,它的进程运行在宿主机的操作系统中,只是在创建进程的时候,加了一些 Namespace 参数,通过施加“障眼法”达到隔离的作用。
此外,再简单说一下这两者各自的优势和不足。
首先,虚拟机是真实存在的,是通过虚拟化技术运行了一个完整的操作系统,导致占用了不少的内存,而且虚拟机里的应用在对宿主机的操作系统进行调用时,需要虚拟化软件的拦截处理,对计算资源、磁盘I/O的损耗很大。而容器中的应用依然是宿主机上的一个进程,也就没有额外的性能损耗和资源占用,“敏捷”和“高性能”是容器和虚拟机相比的最大优势。
不过,Linux 的 Namespace 的隔离机制也有不足之处。主要的问题就是隔离不彻底。因为容器也是宿主机上的进程,所以这些进程还是共享同一个宿主机的操作系统内核,也就是说,无法在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器。而虚拟机就方便得多,比如 Microsoft 的云计算平台 Azure 就是运行在 Windows 服务器集群上的,但是我们却可以在上面创建出各自 Linux 虚拟机。
Cgroups
在介绍完 Namespace 技术后,开始介绍 Cgroups 技术,研究一下容器的“限制”问题。
前面已经说了,容器通过 Namespace 技术使得容器中的进程只能看到容器中的情况。但也是运行在宿主机上的一个进程,也是要和其他进程竞争资源的。也就是说,容器中的进程能够使用的CPU、内存等资源,是可以被其他进程占用的,也可以自己把资源全部占用。这些并不是容器想要的结果,于是 Google 的工程师在2006年提出了“进程容器”(Process Container)的概念,后来改名为 Linux** Control Group** ,简称为 Linux Cgroups ,来限制一个进程组能够使用的资源上限,如 CPU ,内存,磁盘,网络带宽等,还包括进程的优先级设置、挂起和恢复。
在 Linux 中,Cgroups 是以文件和目录的方式组织在操作系统的 /sys/fs/cgroup 下。可使用下面的命令展示出来:
可以看出, /sys/fs/cgroup 路径下有很多子目录,如 cpu、memory、pids,叫做子系统(subsystem),表示当前机器可被 Cgroups 限制的资源种类。一般 Linux 支持的子系统有下面几种:
subsystem | 作用 |
---|---|
blkio | 为块设备设置输入/输出限制 |
cpu | 设置 cgroup 中进程的 CPU 被调度的策略 |
cpuacct | 统计 cgroup 中进程的 CPU 占用 |
cpuset | 在多核机器上设置 cgroup 中进程可以使用的 CPU 和内存 |
devices | 限制 cgroup 中进程对设备的访问 |
freezer | 负责挂起和恢复 cgroup 中的进程 |
memory | 限制 cgroup 中进程的内存占用 |
net_cls | 标记 cgroups 中进程的网络数据包,然后可以使用 tc(traffic control) 模块对数据包进行控制 |
进入到 cpu 子目录中,就可以看到该资源可以被限制的方法。
具体的使用方式就是在子系统目录下创建一个目录,比如 mkdir container_test,就会发现在该目录下,操作系统自动生成了上面介绍的子系统。
拿 cpu.cfs_period_us 和 cpu.cfs_quota_us 这两个参数举例。这两个参数的作用是限制进程在一段 cfs_period 时间范围内,只能被分配到总量为 cpu.cfs_quota_us 的 CPU 时间。cpu.cfs_quota_us 默认为-1表示对 container_test 控制组里的 CPU 没做限制;cpu.cfs_period_us 默认则是100000us(100ms)
root@n37-159-200:/sys/fs/cgroup/cpu/container_test# cat cpu.cfs_quota_us
-1
root@n37-159-200:/sys/fs/cgroup/cpu/container_test# cat cpu.cfs_period_us
100000
现在执行一个死循环的脚本测试一下。
#/bin/sh
a=1
while (( 1 ))
do
let a++
done
使用 top 命令,可以看到当前的 CPU 已经被占满。
现在向 container_test 目录中的 cpu.cfs_quota_us 文件写入20000us(20ms),表示每100ms内,该控制组内的进程只能分配到20ms的 CPU 时间,即只能占用20%的 CPU。
$ echo 20000 > cpu.cfs_quota_us
tasks 文件记录了被资源限制的进程的 PID,把刚才的脚步 PID 写入到 tasks 文件中就会立刻生效。
$ echo 724406 > tasks
再次使用 top 命令查看,会看到该进程只占用了20%左右的 CPU。
从上面的操作来看, Linux Cgroups 还是简单易用的,可以理解为一个子系统加上一组资源限制文件的组合。如果要对容器进行资源限制,只需在每个子系统下面创建一个控制组,然后在容器进程启动后,把进程的 PID 写入到 tasks 文件中即可。这些操作,通过执行 docker run 指定的参数就可以完成,如:
$ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash
不过 Cgroups 也有不足的地方,就是在容器里执行 top 命令时,它显示的信息却是宿主机的 CPU 和内存数据,显然不是我们预期的结果。这是因为 top 命令查看的 CPU 使用情况、内存占用率等系统信息来自于 Linux 的 /proc 目录,但是 /proc 文件系统并不了解 Cgroups 限制的存在。要解决这个问题,就需要安装 lxcfs 文件系统。lxcfs 专门处理以下文件路径的请求。
/proc/cpuinfo
/proc/diskstats
/proc/meminfo
/proc/stat
/proc/swaps
/proc/uptime
/sys/devices/system/cpu/online
当容器启动时,/proc/xxx 会被挂载成宿主机上 lxcfs 的目录。当程序读取 /proc/meminfo 的信息时,请求就会被导向lxcfs,lxcfs 就会通过 cgroup 的信息来返回正确的值给容器内的程序。整个流程如下:
本节通过简单的实验,介绍了如何通过 Linux Cgroups 实现资源的限制,再结合上一节 Namespace 的介绍,可以理解一个运行的容器,就是一个启用了多个 Linux Namespace 的应用进程,并通过 Cgroups 限制其资源的使用。这也是容器中的一个重要概念:容器是一个“单进程”模型。
镜像
通过上两节的介绍,我们已经了解了 Namespace 负责资源隔离, Cgroups 负责资源限制。它们共同组成了一个“边界”,将应用打包进“沙盒”里,避免了相互干扰。
但是还有一个重要的点需要说一下,就是文件系统。理想情况下,容器里的进程通过 Mount Namspace,应该看到一个完整独立的文件系统,在容器目录的操作不受宿主机的影响。但是实际情况是即便开启了 Mount Namspace,容器进程看到的文件系统和宿主机一样。导致这个结果的原因是因为Mount Namespace修改的,是容器进程对文件系统“挂载点”的认知。在执行“挂载”操作之前,新创建的容器继承了宿主机的所有挂载点,只有在“挂载”操作发生之后,进程的视图才会发生改变。解决方法就是在启用 Mount Namspace 的同时,指定哪些目录需要重新挂载。这样容器内部看到的挂载目录的内容才和宿主机上的不同,且宿主机上也看不到该挂载。这里就能看出 Mount Namspace和其他 Namespace 的不同之处,就是它对进程视图的改变,需要伴随挂载操作才能生效。
从用户的角度出发,在创建容器后,想看到一个完整的、与宿主机隔离的根目录“/”。而 Linux 中的 chroot 命令可以改变进程的根目录到指定位置,且被 chroot 的进程也不会感知到自己的根目录被修改了。Mount Namspace 就是根据 chroot 不断改良出来的。一般情况下,会将一个完整的操作系统文件系统挂载到容器的根目录下。
挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。也叫作:rootfs(根文件系统)。
正是有了 rootfs 的存在,使得容器有了一个很重要的特性:一致性。这是因为 rootfs 不仅打包应用,还有操作系统的文件和目录,也就是将所有依赖都封装在了一起。这样只要用户解压了容器镜像,就能在任何一台机器上运行容器中的程序。
但是这又引入了一个新的问题,就是如果每做一次新的修改就保存一个镜像,久而久之就会占用很大空间。为此,Docker 在制作镜像的时候,使用了**联合文件系统****(Union File System)**的技术,支持对文件系统的修改作为一次提交来一层层的叠加,把不同目录联合挂载到同一目录下。这样做的好处是可以共享资源,宿主机只需要保存一份基础镜像,然后根据基础镜像构建出不同的镜像。
Docker 中最常用的联合文件系统有三种:AUFS、Devicemapper 和 OverlayFS。可以使用 docker info | grep Storage 命令查看当前机器使用的是哪种联合文件系统。
root@n37-159-200:~# docker info | grep Storage
Storage Driver: overlay2
以我的开发机环境来看,Docker 使用的是 OverlayFs。OverlayFS 文件驱动被分为了两种,一种是早期的 overlay,不推荐在生产环境中使用,另一种是更新和更稳定的 overlay2。overlay2 主要由 merged、lowerdir、upperdir 和 workdir 组成。其中 lowerdir 对应底层文件系统,是能被上层文件系统 upperdir 所共享的只读层;upperdir 是更高一层的可读写层;workdir 可以理解为 overlay2 的工作目录,用于完成 Copy-on-write 等工作;merged 是被挂载的目录,内容为 lowerdir 和 upperdir 结合后的文件。
如图是 Docker 官网给出的 overlay 原理图,overlay2 和其类似,只不过 lowerdir 变成了很多层。下面将通过具体的操作,加深一下对 overlay2 的理解。
首先创建若干文件夹和文件。
root@n37-159-200:~/overlay2_test# mkdir lower1 lower2 merged upper work
root@n37-159-200:~/overlay2_test# tree
.
├── lower1
├── lower2
├── merged
├── upper
└── work
5 directories, 0 files
root@n37-159-200:~/overlay2_test# echo "I'm file1, belong to lower1" > lower1/file1.txt
root@n37-159-200:~/overlay2_test# echo "I'm file2, belong to lower2" > lower2/file2.txt
root@n37-159-200:~/overlay2_test# echo "I'm file3, belong to upper" > upper/file3.txt
root@n37-159-200:~/overlay2_test# tree
.
├── lower1
│ └── file1.txt
├── lower2
│ └── file2.txt
├── merged
├── upper
│ └── file3.txt
└── work
5 directories, 3 files
接着使用 overlay 挂载这些目录,merged 就出现了 lowerdir 和 upperdir 的内容。
root@n37-159-200:~/overlay2_test# mount -t overlay -o lowerdir=lower1:lower2,upperdir=upper,workdir=work overlay merged
root@n37-159-200:~/overlay2_test# tree
.
├── lower1
│ └── file1.txt
├── lower2
│ └── file2.txt
├── merged
│ ├── file1.txt
│ ├── file2.txt
│ └── file3.txt
├── upper
│ └── file3.txt
└── work
├── index
└── work
7 directories, 6 files
root@n37-159-200:~/overlay2_test# cat merged/*
I'm file1, belong to lower1
I'm file2, belong to lower2
I'm file3, belong to upper
此时修改 merged 中的 file1.txt 文件,会发现 merged 中的 file1.txt 文件确实修改了,但是 lower1 中的 file1.txt 文件并未改动,upperdir 层多了一个 file1.txt,这就是 Copy-on-write。验证了 lowerdir 是只读层,upperdir 是可读写层。
root@n37-159-200:~/overlay2_test# echo 'file1 has been changed' > merged/file1.txt
root@n37-159-200:~/overlay2_test# cat merged/*
file1 has been changed
I'm file2, belong to lower2
I'm file3, belong to upper
root@n37-159-200:~/overlay2_test# cat lower1/file1.txt
I'm file1, belong to lower1
root@n37-159-200:~/overlay2_test# tree
.
├── lower1
│ └── file1.txt
├── lower2
│ └── file2.txt
├── merged
│ ├── file1.txt
│ ├── file2.txt
│ └── file3.txt
├── upper
│ ├── file1.txt
│ └── file3.txt
└── work
├── index
│ └── 00fb1d0001f3f36b49806c4c6289f0d0eb70868878120b16006aadc33c
└── work
7 directories, 8 files
同样的,试试删除 file2.txt 文件,会发现在 upperdir 中生成了一个 file2.txt 的带特殊字符文件。在 merged 中遇到这种特殊字符就会忽略其文件。
root@n37-159-200:~/overlay2_test# rm merged/file2.txt
root@n37-159-200:~/overlay2_test# tree
.
├── lower1
│ └── file1.txt
├── lower2
│ └── file2.txt
├── merged
│ ├── file1.txt
│ └── file3.txt
├── upper
│ ├── file1.txt
│ ├── file2.txt
│ └── file3.txt
└── work
├── index
│ ├── 00fb1d0001f3f36b49806c4c6289f0d0eb70868878120b16006aadc33c
│ └── 00fb1d0001f3f36b49806c4c6289f0d0eb70868878130b16007ff71285
└── work
7 directories, 9 files
root@n37-159-200:~/overlay2_test# cat merged/*
file1 has been changed
I'm file3, belong to upper
root@n37-159-200:~/overlay2_test# cat lower2/file2.txt
I'm file2, belong to lower2
root@n37-159-200:~/overlay2_test# ls -l upper/file2.txt
c--------- 1 root root 0, 0 Jan 11 20:04 upper/file2.txt
大多数的容器镜像都不是从头开始制作的,而是建立在基础镜像上,比如 Debian 基础镜像。镜像都是只读的,当启动容器后,就会在镜像的最上层添加一个可读写层,进程对容器文件的增删改都只发生在可读写层,并不影响下层的只读层。如果不同层出现两个相同路径的文件,上层的文件会覆盖下层的文件。联合文件系统使得镜像的复用、定制变得更为容易。
数据持久化
从上面介绍的联合文件系统中可以知道,在容器内对文件的更改都发生在可读写层,但是容器的生命周期并不是永久的。也就是说,当容器关闭后,容器里的数据也就跟着消失了。因此需要增加容器的数据可持久化功能,将数据从宿主机挂载到容器中。为此,Docker 提供了三种不同的方式:
-
volume:Docker管理宿主机文件系统的一部分,默认位于 /var/lib/docker/volumes 目录中。
-
bind mount :可以存储在宿主机系统的任意位置。
-
tmpfs:存储在宿主机系统的内存中,不会写入宿主机的文件系统。
本文选择最常用的 volume 进行介绍。
首先创建一个自定义的容器卷。
root@n37-159-200:~# docker volume create nginx-vol
nginx-vol
root@n37-159-200:~# docker volume inspect nginx-vol
[
{
"CreatedAt": "2023-01-12T10:14:03+08:00",
"Driver": "local",
"Labels": {},
"Mountpoint": "/var/lib/docker/volumes/nginx-vol/_data",
"Name": "nginx-vol",
"Options": {},
"Scope": "local"
}
]
Docker 的 volume 使用通常有两种声明方式:
$ docker run -v /test
$ docker run -v /home:/test
如果没有指明宿主机上的目录,那么 Docker 就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,挂载到容器的 /test 目录上。第二种就是将宿主机的 /home 目录挂载到了容器的 /test 目录上。
创建一个 nginx 容器,把刚才创建的 nginx-vol 卷挂载到 nginx 的默认网页目录,并进入到容器里面查看。
root@n37-159-200:~# docker run -d -it --name=nginx-test -p 8800:80 -v nginx-vol:/usr/share/nginx/html nginx
root@n37-159-200:~# docker exec -it nginx-test bash
root@56b802889ae5:/# cd /usr/share/nginx/html/
root@56b802889ae5:/usr/share/nginx/html# ls
50x.html index.html
然后进入到宿主机的数据卷里面,可以看到容器里的文件已经关联到宿主机的数据卷中
root@n37-159-200:/var/lib/docker/volumes/nginx-vol/_data# ls
50x.html index.html
如果暂停容器实例并删除容器实例,会发现宿主机中的数据卷依然存在容器卷的文件,说明数据卷里边的东西是可以持久化。如果下次还需要创建一个 nginx 容器,那么还是复用当前数据卷里面的文件。
root@n37-159-200:~# docker stop nginx-test
nginx-test
root@n37-159-200:~# docker rm nginx-test
nginx-test
root@n37-159-200:/var/lib/docker/volumes/nginx-vol/_data# ls
50x.html index.html
因为挂载操作是在“容器进程”创建后发生的,此时已经开启了 Mount Namespace,所以宿主机是看不到这个挂载点的,保证了 volume 不会打破容器的隔离性。(这里的“容器进程”指的是 Docker 创建容器的初始化进程,并不是应用进程,它负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程)。
如图所示,绑定挂载其实是一个 inode 替换的过程。inode 可以理解为存放文件内容的“对象”,而 inode 上面的目录,可以理解为访问这些对象的“指针”。所以,当把宿主机的 /home 挂载到容器的 /test 上,实际就是将 /test 重定向到 /home 的 inode 对象上,所有的修改内容也都发生在 /home 的 inode 上。
总结
通过上面的介绍,我们明白了一个完整的容器创建需要:
-
启动 Linux Namespace 配置
-
指定 Cgroups 参数
-
切换进程的根目录
最后通过一张图来总结一下 Docker 容器。图中的容器进程“python app.py”运行在由 Namespace 和 Cgroups 组成的隔离环境内,而它运行所需要的文件,如 app.py 以及操作系统的各个文件,都是由 rootfs 提供的。Docker 镜像的只读层在 rootfs 的最下面;中间层是 init 层,负责存放临时修改的文件;最上层是可读写层,会以 Copy-On-Write 的方式存放对只读层的修改,并且 Volume 也在这一层。
参考资料
什么是云原生?| Oracle 中国
深入剖析Kubernetes_容器_K8s-极客时间
Use the OverlayFS storage driver
Docker:overlay2浅析_NiXGo的博客-CSDN博客
你必须知道的Docker数据卷(Volume) - EdisonZhou - 博客园