阅读 540

谈一手:容器基础

在学习张磊大神的 深入剖析Kubernetes 课程中,结合自己的一些实践,记录下心得

首先我们要理解这样一个道理 容器本身没有价值,有价值的是“容器编排”

我们先从基础做起,弄懂 容器,到底是怎么一回事?

容器其实是一种沙盒技术,顾名思义沙盒就像是一种集装箱,能够把应用“装”起来,应用与应用之间有了边界,互相不干扰,而应用被装进了沙盒也方便了搬来搬去。

  • 容器边界的实现

现在你要写一个计算加法的小程序,这个程序需要的输入来自于一个文件,计算完成后的结果则输出到另一个文件中。由于计算机只认识 0 和 1,所以无论用哪种语言编写这段代码,最后都需要通过某种方式翻译成二进制文件,才能在计算机操作系统中运行起来。而为了能够让这些代码正常运行,我们往往还要给它提供数据,比如我们这个加法程序所需要的输入文件。这些数据加上代码本身的二进制文件,放在磁盘上,就是我们平常所说的一个“程序”,也叫代码的可执行镜像(executable image)。

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

所以对于进程来讲,它的静态表现就是程序,平常都安安静静的躺在磁盘里;而一旦运行起来就是计算机里数据和状态的总和,这就是它的动态表现。

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

对于docker等大多数Linux容器来说,Cgroups 技术是用来制造约束的主要手段,而 Namespace 技术则是用来修改进程视图的主要方法。

让我们先来尝试一下,创建一个docker容器

docker run -it busybox /bin/sh
复制代码

此时我的计算机就变成了一个宿主机,而一个运行着 /bin/sh 的容器,就跑在了这个宿主机里面。接着我们在容器里执行ps命令 可以看到我们在 Docker 里最开始执行的 /bin/sh,就是这个容器内部的第 1 号进程(PID=1),我们自己执行的ps命令是第二个进程,容器总共就两个进程在运行。前面执行的 /bin/sh,以及我们刚刚执行的 ps,已经被 Docker 隔离在了一个跟宿主机完全不同的世界当中。

当我们在宿主机中运行了一个 /bin/sh 程序,操作系统会给它分配一个进程编号:PID,比如100。这个编号就是进程的唯一标识,相当于员工的工牌,它就是宿主机的第100皓员工。

而现在,我们通过 Docker 把这个 /bin/sh 程序运行在一个容器当中。这时候,Docker 就会给这个100号进程施展一个“障眼法”,让他永远看不到前面的其他 99 个进程,他就会错误地以为自己就是第一号进程。

这种机制,其实就是对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号,这种技术就是 Linux 里面的 Namespace 机制

Namespace 是 Linux 创建新进程的一个可选参数。我们知道,在 Linux 系统中创建线程的系统调用是 clone()

int pid = clone(main_function, stack_size, SIGCHLD, NULL);
复制代码

当我们用clone()系统调用创建一个新进程时,就可以在参数中指定 CLONE_NEWPID 参数

int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);
复制代码

此时新创建的这个进程会“看到”一个全新的进程空间,在这个进程空间里它的PID是1,之所以用“看到”,是因为这只是一个“障眼法”,在宿主机真实的进程空间里,这个进程的 PID 还是真实的数值,比如 100。

我们可以多次调用上面的clone()方法,创建多个Namespace,每个Namespace里的应用进程都会认为自己是当前容器的第一号进程,它们既看不到宿主机里真正的进程空间,也看不到其他 PID Namespace 里的具体情况。

除了我们刚刚用到的 PID Namespace,Linux 操作系统还提供了 Mount、UTS、IPC、Network 和 User 这些 Namespace,用来对各种不同的进程上下文进行“障眼法”操作。

所以docker容器说白了就是在创建容器进程时,指定了这个进程所需要启用的一组 Namespace 参数,容器,其实是一种特殊的进程而已。

  • 容器和虚拟机的区别
  1. 虚拟机通过硬件虚拟化,模拟出一个操作系统的各种硬件,比如 CPU、内存、I/O 设备等等。然后,它在这些虚拟的硬件上安装了一个新的操作系统,即 Guest OS。

  2. “docker容器”还是宿主机里面原来的应用进程,只不过在创建这些进程时,Docker 为它们加上了各种各样的 Namespace 参数,这些进程会自认为自己的PID Namespace是第一号进程,只能看到各自 Mount Namespace 里挂载的目录和文件,只能访问到各自 Network Namespace 里的网络设备,就仿佛运行在一个个“容器”里面,与世隔绝。

这也是为什么Docker项目比虚拟机更受欢迎,因为使用虚拟技术来作为应用沙盒就必须要由 Hypervisor 来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的 Guest OS 才能执行用户的应用进程。**这就不可避免地带来了额外的资源消耗和占用。**此外,用户应用运行在虚拟机里面,它对宿主机操作系统的调用就不可避免地要经过虚拟化软件的拦截和处理,这本身又是一层性能损耗,尤其对计算资源、网络和磁盘 I/O 的损耗非常大。

相比之下,容器化后的应用程序就是宿主机上的普通进程,虚拟化带来的性能消耗都是不存在的,而使用Namespace作为隔离手段,并不需要单独的Guest OS,这就使得容器的额外资源几乎可以忽略不计。

“敏捷”和“高性能”是容器相较于虚拟机最大的优势

但是基于Linux Namespace的隔离机制相比于虚拟化技术,最重要的不足就是隔离的不彻底,容器只是运行在虚拟机上的一种特殊进程,多个容器之间使用的还是同一个宿主机的操作系统内核,**而且在Linux内核中有很多资源和对象是不能被Namespace化的,**最典型的例子就是:时间。比如说你在容器中的程序使用settimeofday(2) 系统调用修改了时间,整个宿主机的时间都会被随之修改。

相比于在虚拟机里面可以随便折腾的自由度,在容器里部署应用的时候,“什么能做,什么不能做”,就是我们必须考虑的一个问题。,此外由于共享宿主机内核,容器给应用暴露出来的攻击面是相当大的

聊完了Namespace用来修改进程视图,让容器只能“看到”某些指定的内容之后,来继续讲讲Cgroups用来为进程设置资源限制

为什么要对容器做限制

我们还是用PID Namespace 为例。 虽然容器内的第 1 号进程在“障眼法”的干扰下只能看到容器里的情况,但是宿主机上,它作为第 100 号进程与其他所有进程之间依然是平等的竞争关系。这就意味着,虽然第 100 号进程表面上被隔离了起来,但是它所能够使用到的资源(比如 CPU、内存),却是可以随时被宿主机上的其他进程(或者其他容器)占用的。当然,这个 100 号进程自己也可能把所有资源吃光。这些情况,显然都不是一个“沙盒”应该表现出来的合理行为。

Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能。他的最主要作用就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。

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

可以看到,在sys/fs/cgroup 下面有很多诸如cpuset、cpu、 memory 这样的子目录,也叫子系统,这些都是我这台机器当前可以被 Cgroups 进行限制的资源种类。而在子系统对应的资源种类下,你就可以看到该类资源具体可以被限制的方法。

# ls /sys/fs/cgroup/cpu
cgroup.clone_children  cpu.cfs_period_us  cpu.rt_period_us   cpu.shares  notify_on_release
cgroup.procs	       cpu.cfs_quota_us   cpu.rt_runtime_us  cpu.stat	 tasks
复制代码

注意cfs_period和cfs_quota这两个关键词,这两个参数需要组合使用,可以用来限制进程在长度为 cfs_period 的一段时间内,只能被分配到总量为 cfs_quota 的 CPU 时间。

我们在对应的子系统下创建一个目录,比如,我们进入/sys/fs/cgroup/cpu,创建一个目录container

# mkdir container
# ls
cgroup.clone_children  container	  cpu.cfs_quota_us  cpu.rt_runtime_us  cpu.stat		  tasks
cgroup.procs	       cpu.cfs_period_us  cpu.rt_period_us  cpu.shares	    notify_on_release
复制代码

如果是在 docker 里操作需要加上 --privileged 参数

docker run -it --privileged   ubuntu:16.04 /bin/sh
复制代码

并且在容器里执行

mount -o remount rw /
复制代码

否者会报 Read-only file system 错误

这个目录就称为一个“控制组”。操作系统会在你新创建的 container 目录下,自动生成该子系统对应的资源限制文件。

# cd container
# ls
cgroup.clone_children  cpu.cfs_period_us  cpu.rt_period_us   cpu.shares  notify_on_release
cgroup.procs	       cpu.cfs_quota_us   cpu.rt_runtime_us  cpu.stat	 tasks
复制代码

现在,我们在后台执行一条脚本

while : ; do : ; done &
复制代码

它执行了一条死循环,把CPU打到100%,使用top命令,确认一下有没打满

此时,cpu的使用率已经100%了 查看container目录下的文件,看到container控制组的CPU quota还没有任何限制(值是-1)CPU period 则是默认的 100 ms(100000 us)

# cat cpu.cfs_quota_us
-1
# cat cpu.cfs_period_us
100000
#
复制代码

接下来,我们可以通过修改这些文件的内容来设置限制。比如,向 container 组里的 cfs_quota 文件写入 20 ms(20000 us):

echo 20000 >/sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
复制代码

此时就意味着在每个100ms的时间里,该控制组限制的进程只能使用20ms的CPU时间,也就是说这个进程只能使用到 20% 的 CPU 带宽。

把被限制的进程的 PID 写入 container 组里的 tasks 文件,上面的设置就会对该进程生效了:

echo 10 > /sys/fs/cgroup/cpu/container/tasks
复制代码

此时再用top命令查看 计算机的 CPU 使用率立刻降到了 20%

除 CPU 子系统外,Cgroups 的每一个子系统都有其独有的资源限制能力,比如:

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

Linux Cgroups 简单粗暴地理解就是一个子系统目录加上一组资源限制文件的组合。 对于docker等Linux容器而言,只需要在每个子系统下面为每个容器创建一个控制器(创建一个新的目录)然后在启动容器进程之后把进程对应的PID填写到对应控制组的tasks文件中

用户可以在执行docker run 时,用参数指定需要往资源文件里填的值

docker run -it --cpu-period=100000 --cpu-quota=20000   ubuntu:16.04 /bin/sh
复制代码

在启动这个容器后,我们可以通过查看 Cgroups 文件系统下,CPU 子系统中,“docker”这个控制组里的资源限制文件的内容来确认

跟 Namespace 的情况类似,Cgroups 对资源的限制能力也有很多不完善的地方,被提及最多的自然是 /proc 文件系统的问题。

Linux 下的 /proc 目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如 CPU 使用情况、内存占用率等,这些文件也是 top 指令查看系统信息的主要数据来源。

但是,你如果在容器里执行 top 指令,就会发现,它显示的信息居然是宿主机的 CPU 和内存数据,而不是当前容器的数据。

造成这个问题的原因就是,/proc 文件系统并不知道用户通过 Cgroups 给这个容器做了什么样的资源限制,即:/proc 文件系统不了解 Cgroups 限制的存在。

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

总结

一个正在运行的 Docker 容器,其实就是一个启用了多个 Linux Namespace 的应用进程,而这个进程能够使用的资源量,则受 Cgroups 配置的限制。容器是一个“单进程”模型

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