云原生之容器技术

2023年 8月 26日 31.4k 0

前言

根据 CNCF 的定义,云原生的代表技术包括**容器、服务网格、微服务、不可变基础设施以及声明式应用编程接口 (API) **。容器作为云原生的关键技术,催生了云原生思潮,而云原生生态也推动了容器技术发展。

近年来,以 K8s 和 Docker 为代表的容器技术已发展成一项通用技术,但容器技术涉及到众多概念,因此本文将用通俗易懂的语言描述容器底层技术的实现方式,加深读者对容器技术的理解。本文将从四个方面,来介绍容器是如何实现运行时的环境隔离与数据交互。

定义

容器的一个专业定义是:容器就是一个视图隔离、资源可限制、独立文件系统的进程集合。一个通俗的解释是:像集装箱一样,把应用“装”起来的技术。这个“装”,有两个含义,一是在应用之间设立了边界,避免互相干扰;二是能够方便的搬来搬去,随开随用。

image

我们知道,“程序”是由数据和二进制文件组成的,是进程的静态表现。一旦“程序”被运行起来,就变成了各种值、指令、状态的一个集合,是进程的动态表现。而容器技术的核心功能,就是通过约束和修改进程的动态表现,为其创造一个“边界”。制造约束手段是通过 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 所隔离出来的资源、文件、设备等,各个容器运行在宿主机上,互相看不到。

此时,附上虚拟机和容器的经典对比图。

云原生之容器技术-1

通过对 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 下。可使用下面的命令展示出来:

image

可以看出, /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 子目录中,就可以看到该资源可以被限制的方法。

image

具体的使用方式就是在子系统目录下创建一个目录,比如 mkdir container_test,就会发现在该目录下,操作系统自动生成了上面介绍的子系统。

image

拿 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 已经被占满。

image

现在向 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。

image

从上面的操作来看, 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 的信息来返回正确的值给容器内的程序。整个流程如下:

image

本节通过简单的实验,介绍了如何通过 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 结合后的文件。

image

如图是 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 基础镜像。镜像都是只读的,当启动容器后,就会在镜像的最上层添加一个可读写层,进程对容器文件的增删改都只发生在可读写层,并不影响下层的只读层。如果不同层出现两个相同路径的文件,上层的文件会覆盖下层的文件。联合文件系统使得镜像的复用、定制变得更为容易。

image

数据持久化

从上面介绍的联合文件系统中可以知道,在容器内对文件的更改都发生在可读写层,但是容器的生命周期并不是永久的。也就是说,当容器关闭后,容器里的数据也就跟着消失了。因此需要增加容器的数据可持久化功能,将数据从宿主机挂载到容器中。为此,Docker 提供了三种不同的方式:

  • volume:Docker管理宿主机文件系统的一部分,默认位于 /var/lib/docker/volumes 目录中。

  • bind mount :可以存储在宿主机系统的任意位置。

  • tmpfs:存储在宿主机系统的内存中,不会写入宿主机的文件系统。

本文选择最常用的 volume 进行介绍。

image

首先创建一个自定义的容器卷。

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 的进程)。

image

如图所示,绑定挂载其实是一个 inode 替换的过程。inode 可以理解为存放文件内容的“对象”,而 inode 上面的目录,可以理解为访问这些对象的“指针”。所以,当把宿主机的 /home 挂载到容器的 /test 上,实际就是将 /test 重定向到 /home 的 inode 对象上,所有的修改内容也都发生在 /home 的 inode 上。

总结

通过上面的介绍,我们明白了一个完整的容器创建需要:

  • 启动 Linux Namespace 配置

  • 指定 Cgroups 参数

  • 切换进程的根目录

image

最后通过一张图来总结一下 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 - 博客园

相关文章

服务器端口转发,带你了解服务器端口转发
服务器开放端口,服务器开放端口的步骤
产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
如何使用 WinGet 下载 Microsoft Store 应用
百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

发布评论