深入篇(1):docker是如何隔离与限制的?

208 阅读7分钟

为什么需要隔离?

  • 被其他容器修改文件,导致安全问题
  • 资源的并发写入导致不一致性
  • 资源的抢占,导致其他容器被影响

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

进程的静态表现就是程序,平时待在磁盘上。当它运行起来就变成计算机里的数据和状态的总和,包括内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合,这就是它的动态表现。

对于 Docker 来说,Cgroups 是制造约束的主要手段,而 Namespace 是用来修改进程视图的主要方法。

namespace

示例

执行以下指令:

docker run -it alpine sh

该命令以交互形式启动了一个容器,并在容器中执行了 sh,这样,我的机器变成了一台宿主机,而运行着 sh 的容器在宿主机里。

在容器中执行 ps 指令

image.png

最开始执行的 sh,是容器内部的 1 号进程(PID = 1),而容器里一共只有两个进程在运行。即之前执行的 sh,和现在执行的 ps,已经被 Docker 隔离在了一个跟宿主机完全不同的世界当中。

这是怎么做到的?

原理

当我们在宿主机上运行 sh 时,OS会给它分配一个进程编号,例如 PID=100,这是进程的唯一标识。

现在我们通过 Docker 把这个 sh 程序运行在容器中,Docker 会施展一个障眼法,让它看不见前面的进程,这样它便会错误地认为自己是第一个进程。

这种机制,其实是对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号,比如 PID=1。可实际上,他们在宿主机的OS里,还是原来的第 100 号进程。

这种技术,就是 Linux 里面的 Namespace 机制。

如果我们创建了多个 PID Namespace,每个 Namespace 里的应用进程,都会认为自己是当前容器里的第 1 号进程,它们既看不到宿主机里真正的进程空间,也看不到其他 PID Namespace 里的具体情况。

除了刚刚用到的 PID Namespace,Linux 还提供了 Mount、Network 和 User 等 Namespace,用来对各种不同的进程上下文进行障眼法操作。例如 Network Namespace,用于让被隔离进程看到当前 Namespace 里的网络设备和配置。

总结一下,Docker 容器实际上是在创建容器进程时,指定了这个进程所需要启用的一组 Namespace 参数。这样容器就只能看到当前 Namespace 所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。所以说,容器只是一个特殊的进程。

docker exec 是怎么进入容器里的呢?

Linux Namespace 创建的隔离空间虽然看不见摸不着,但一个进程的 Namespace 信息在宿主机上是确实存在的,并且是以一个文件的方式存在。

我们先查询容器的进程号为7219。

docker inspect -f '{{.State.Pid}}' [container_name/id]

然后可以通过查看宿主机的 proc 文件,看到这个进程的所有 Namespace 对应的文件:

ls -l /proc/7219/ns
lrwxrwxrwx 1 root root 0 6月  11 18:09 ipc -> ipc:[4026532503]
lrwxrwxrwx 1 root root 0 6月  11 18:09 mnt -> mnt:[4026532501]
lrwxrwxrwx 1 root root 0 6月  11 18:06 net -> net:[4026532506]
lrwxrwxrwx 1 root root 0 6月  11 18:09 pid -> pid:[4026532504]
lrwxrwxrwx 1 root root 0 6月  11 18:09 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 6月  11 18:09 uts -> uts:[4026532502]

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

也就是说,一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达到进入这个进程所在容器的目的,这正是 docker exec 的实现原理。

image.png

Namespace 的问题

相比于虚拟化技术存在一个严重缺陷:隔离得不彻底。

由于容器只是运行在宿主机上的一种特殊的进程,容器间使用的就还是同一个宿主机的OS内核。

尽管可以在容器里通过Mount Namespace单独挂载其他版本的OS,但这并不能改变共享宿主机内核的事实。如果你要在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行高版本的 Linux 容器,都是行不通的。

另外,Linux 内核的很多资源和对象是不能被 Namespace 化的,比如时间。如果你在容器中修改了时间,那么整个宿主机的时间都会被随之修改,这显然不符合用户的预期。

Cgroups

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

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

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

在 /sys/fs/cgroup 下面有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统。这些都是可以被 Cgroups 进行限制的资源种类。而在子系统对应的资源种类下,可以看到这类资源具体可以被限制的方法。比如对CPU子系统来说我们可以看到如下几个配置文件

image.png

比如可以通过 cfs_period 和 cfs_quota 限制进程在 cfs_period时间内只能被分配到 cfs_quota 的CPU时间。

这样的配置文件如何使用呢?你需要在对应的子系统下面创建目录,现在进入 /sys/fs/cgroup/cpu目录:

image.png

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

现在执行以下命令:

while : ; do : ; done &

[1] 106801

image.png

可以看到cpu占用率到了100%

查看container控制组里的CPU_quota还没有任何限制(-1), CPU_period则是默认的100ms(100000us):

image.png

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

echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us

即被该控制组限制的进程只能用到20%的CPU带宽。接下来把被限制的进程的PID写入container组里的tasks文件,上面的设置就会对该进程生效

echo 106801 > /sys/fs/cgroup/cpu/container/tasks

image.png

可以把 Linux Cgroups 理解成一个子系统目录加上一组资源限制文件的组合。而对于 Docker 等 Linux 容器项目来说,只需要在每个子系统下面,为每个容器创建一个控制组,然后在启动容器进程之后,把进程的 PID 填到对应控制组的 tasks 文件中就可以了。而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行 docker run 时的参数指定了。