Docker实现基础技术

Docker 是一个使用 Linux Namespace 和 Cgroups 的虚拟化工具。

Linux Namespace 和 Cgroups 是什么?有什么用?在 Docker 中是怎么被使用的?

Linux Namespace

基本概念

Linux Namespace 是 Kernel 的一个功能,它可以隔离一系列的系统资源(ProcessID、UserID、Network)。

PID 映射关系图:

image.png

当前 Linux 一共实现了 6 种不同类型的 Namespace。

Namespace类型 系统调用参数 内核版本 功能说明
Mount Namespace CLONE_NEWNS 2.4.19 磁盘挂载点和文件系统的隔离能力
UTS Namespace CLONE_NEWUTS 2.6.19 主机名隔离能力
IPC Namespace CLONE_NEWIPC 2.6.19 进程间通信的隔离能力
PID Namespace CLONE_NEWPID 2.6.24 进程隔离能力
Network Namespace CLONE_NEWNET 2.6.29 网络隔离能力
User Namespace CLONE_NEWUSER 3.8 用户隔离能力

Namespace 的 API 主要使用如下 3 个系统调用:

  1. clone() 创建新进程。根据系统调用参数来判断哪些类型的 Namespace 被创建,而且它们的子进程也会被包含到这些 Namespace 中。
  2. unshare() 将进程移除某个 Namespace。
  3. setns() 将进程加入到 Namespace 中。

UTS Namespace

UTS Namespace 主要用来隔离 nodename 和 domainname 两个系统标识。

在 UTS Namespace 里面,每个 Namespace 允许有自己的 hostname。

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
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)

static char container_stack[STACK_SIZE];
char* const container_args[] = {
"/bin/bash",
NULL
};

// 容器进程运行的程序主函数
int container_main(void *args)
{
printf("在容器进程中!\n");
execv(container_args[0], container_args); // 执行/bin/bash return 1;
}

int main(int args, char *argv[])
{
printf("程序开始\n");
// clone 容器进程
int container_pid = clone(container_main, container_stack + STACK_SIZE, SIGCHLD, NULL);
// 等待容器进程结束
waitpid(container_pid, NULL, 0);
return 0;
}

该程序骨架调用 clone() 函数实现了子进程的创建工作,并定义子进程的执行函数,clone() 第二个参数指定了子进程运行的栈空间大小,第三个参数即为创建不同 namespace 隔离的关键。

对于 UTS namespace,传入 CLONE_NEWUTS,如下:

1
int container_pid = clone(container_main, container_stack + STACK_SIZE, SIGCHLD | CLONE_NEWUTS, NULL);

为了能够看出容器内和容器外主机名的变化,我们子进程执行函数中加入:

1
sethostname("container", 9);

image.png

相关 Linux 命令:

1
2
$ hostname # 查看
$ hostname -b xxx # 可以改变当前的 hostname

IPC Namespace

IPC Namespace 用来隔离 System V IPC 和 POSIX message queues。(隔离进程间通信)

代码只需要修改一下 clone 的参数即可:

1
int container_pid = clone(container_main, container_stack + STACK_SIZE, SIGCHLD | CLONE_NEWUTS | CLONE_NEWIPC, NULL);

效果:

image.png

相关 Linux 命令:

1
2
$ ipcs -q # 查看现有的 ipc message queues
$ ipcmk -Q # 创建一个 msg queue

PID Namespace

PID Namespace 是用来隔离进程 ID 的。

1
int container_pid = clone(container_main, container_stack + STACK_SIZE, SIGCHLD | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID, NULL);

效果:PID 号,发生了变化;但是 ps 之类的没有发生变化。

原因是 ps/top 之类的命令底层调用的是文件系统的 /proc 文件内容,由于 /proc 文件系统(procfs)还没有挂载到一个与原 /proc 不同的位置,自然在容器中显示的就是宿主机的进程。

在容器中重新挂载 /proc 即可实现隔离:mount -t proc proc /proc

这种方式会破坏 root namespace 中的文件系统,当退出容器时,如果 ps 会出现错误,只有再重新挂载一次 /proc 才能恢复。

一劳永逸地解决这个问题最好的方法就是用 mount namespace。

image.png

image.png

相关 Linux 命令:

1
$ echo $$ # 输出当前 shell pid

Mount Namespace

通过隔离文件系统挂载点对隔离文件系统提供支持,它是历史上第一个Linux namespace,所以它的标识位比较特殊,就是CLONE_NEWNS。

CLONE_NEWNS:New Namespace 的缩写,当时的人们貌似没有意识到,以后还会有很多类型的 Namespace 加入 Linux 家庭。

1
int container_pid = clone(container_main, container_stack + STACK_SIZE, SIGCHLD | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID | CLONE_NEWNS, NULL);

注意内核版本问题导致的退出容器后需要重新挂载,参考:

https://github.com/xianlubird/mydocker/issues/41

效果如下:实现了 ps / top 等的隔离。

image.png

相关 Linux 命令:

1
$ ps

User Namespace

主要隔离了安全相关的标识符和属性,包括用户 ID、用户组 ID、root 目录、key 以及特殊权限。

简单说,就是一个普通用户的进程通过 clone() 之后在新的 user namespace 中可以拥有不同的用户和用户组,比如可能是超级用户。

同样,可以加入 CLONE_NEWUSER 参数来创建一个 User namespace。然后再子进程执行函数中加入 getuid() 和 getpid() 得到 namespace 内部的 User ID。

相关 Linux 命令:

1
2
$ id # 查看用户相关信息
uid=0(root) gid=0(root) groups=0(root)

Network Namespace

Network namespace 实现了网络资源的隔离,包括网络设备、IPv4 和 IPv6 协议栈,IP 路由表,防火墙,/proc/net 目录,/sys/class/net 目录,套接字等。

Network Namespace 可以让每个容器拥有自己独立的(虚拟的)网络设备,而且容器内的应用可以绑定到自己的端口,每个 Namespace 内的端口都不会互相冲突。在宿主机上搭建网桥之后,就能很方便的实现容器之间的通信,而且不同容器上的应用可以使用相同的端口。

Network namespace 不同于其他 namespace 可以独立工作,要使得容器进程和宿主机或其他容器进程之间通信,需要某种“桥梁机制”来连接彼此(并没有真正的隔离),这是通过创建 veth pair (虚拟网络设备对,有两端,类似于管道,数据从一端传入能从另一端收到,反之亦然)来实现的。当建立 Network namespace 后,内核会首先建立一个 docker0 网桥,功能类似于 Bridge,用于建立各容器之间和宿主机之间的通信,具体就是分别将 veth pair 的两端分别绑定到 docker0 和新建的 namespace 中。

image.png

image.png

Linux Cgroups

Linux Cgroups(Control Groups)提供了对一组进程及将来子进程的资源限制、控制和统计的能力。

这些组员包括:CPU、内存、存储、网络等。

通过 Cgroups,可以方便地限制某个进程资源占用,并且可以实时监控进程的监控和统计信息。

Cgroups 的 3 个组件:

  1. cgroup:是对进程分组管理的一种机制,一个 cgroup 包含一组进程,并可以在这个 cgroup 上增加 Linux subsystem 的各种参数配置,将一组进程和一组 subsystem 的系统参数关联起来。

  2. subsystem:是一组资源控制的模块,一般包含如下几项:

    • blkio 设置对块设备(比如硬盘)输入输出的访问控制。

    • cpu 设置 cgroup 中进程的 CPU 被调度的策略。

    • cpuacct 可以统计 cgroup 中进程的 CPU 占用。

    • cpuset 在多核机器上设置 cgroup 中进程可以使用的 CPU 和内存(此处内存仅用于 NUMA 架构)
    • devices 控制 cgroup 中进程对设备的访问。
    • freezer 用于挂起(suspend)和恢复(resume)cgroup 中的进程。
    • memory 用于控制 cgroup 中进程的内存占用。
    • net_cls 用于将 cgroup 中进程产生的网络包分类,以便 Linux 的 tc(traffic controller)可以根据分类区分出来自某个 cgroup 的包并做限流或监控。
    • net_prio 设置 cgroup 中进程产生的网络流量的优先级。
    • ns 这个 subsystem 比较特殊,它的作用是使 cgroup 中的进程在新的 Namespace 中 fork 新进程(NEWNS)时,创建出一个新的 cgroup,这个 cgroup 包含新的 Namespace 中的进程。

    每个 subsystem 会关联到定义了相应限制的 cgroup 上,并对这个 cgroup 中的进程做相应的限制和控制。这些 subsystem 是逐步合并到内核中的,如何看到当前的内核支持哪些 subsystem 呢?可以安装 cgroup 的命令行工具apt-get install cgroup-bin,然后通过lssubsys -a看到 Kernel 支持的 subsystem。

  3. hierarchy 的功能是把一组 cgroup 串成一个树状的结构,一个这样的树便是一个 hierarchy,通过这种树状结构,Cgroups 可以做到继承。

三个组件相互的关系:

  • 系统在创建了新的 hierarchy 之后,系统中所有的进程都会加入这个 hierarchy 的 cgroup 根节点,这个 cgroup 根节点是 hierarchy 默认创建的。
  • 一个 subsystem 只能附加到一个 hierarchy 上面。
  • 一个 hierarchy 可以附加多个 subsystem。
  • 一个进程可以作为多个 cgroup 的成员,但是这些 cgroup 必须在不同的 hierarchy 中。
  • 一个进程 fork 处子进程时,子进程是和父进程在同一个 cgroup 中的,也可以根据需要将其移动到其他 cgroup 中。

Kernel 为了使对 Cgroups 的配置更直观,是通过一个虚拟的树状文件系统配置 Cgroups,通过层级的目录虚拟出 cgroup 树。

栗子:

  1. 创建并挂在一个 hierarchy(cgroup 树),如下:

    1
    2
    3
    $ mkdir cgroup-test # 创建一个 hierarchy 挂载点
    $ sudo mount -t cgroup -o none,name=cgroup-test cgroup-test ./cgroup-test # 挂在一个 hierarchy
    $ ls ./cgroup-test # 挂在后可以看到系统在这个目录下生成了一些默认文件

    这些文件就是这个 hierarchy 中 cgroup 根节点的配置项,文件含义分别如下:

    • cgroup.clone_children:cpuset 的 subsystem 会读取这个配置文件,如果这个值是 1(默认是 0),子 cgroup 才会继承父 cgroup 的 cpuset 的配置。
    • cgroup.procs:树种当前节点 cgroup 中的进程组 ID,现在的位置是在根节点,这个文件中会有现在系统中所有进程组的 ID。
    • notify_on_release 和 release_agent 会一起使用。notify_on_release 标识当这个 cgroup 最后一个进程退出的时候是否执行了 release_agent;release_agent 则是一个路径,通常用作进程退出之后自动清理掉不再使用的 cgroup。
    • tasks:标识该 cgroup 下面的进程 ID,如果把一个进程 ID 写到 tasks 中,便会将相应的进程加入到这个 cgroup 中。
  2. 然后在 cgroup-test 上 cgroup 根节点中扩展出的两个子 cgroup:

    1
    2
    3
    # 在 cgroup-test 文件夹下
    $ mkdir cgroup-1 # 创建子 cgroup cgroup-1
    $ mkdir cgroup-2 # 创建子 cgroup cgroup-2

    在一个 cgroup 目录下创建文件夹时,Kernel 会把文件夹标记为这个 cgroup 的子 cgroup,他们会继承父 cgroup 的属性。

  3. 在 cgroup 中添加和移动进程。

    一个进程在一个 Cgroups 的 hierarchy 中,只能在一个 cgroup 节点上存在,系统的所有进程都会默认在根节点上存在,可以将进程移动到其他 cgroup 节点,只需要将进程 ID 写到移动到的 cgroup 节点的 tasks 文件中即可。

    1
    2
    3
    # 在 cgroup-1 文件夹下
    $ sh -c "echo $$ >> tasks" # 将现在所在的终端移动到 cgroup-1中
    $ cat /proc/${$$}/cgroup # 可以看到所属 cgroup
  4. 通过 subsystem 限制 cgroup 中进程的资源。

    在上面创建 hierarchy 的时候,这个 hierarchy 并没有关联到任何 subsystem,所以没办法通过那个 hierarchy 中的 cgroup 节点限制进程的资源占用,其实系统默认已经为每个 subsystem 创建了一个默认的 hierarchy,比如 memory 的 hierarchy。

    1
    $ mount | grep memory  # 可以看到目录的挂在情况

    下面通过在这个 hierarchy 中创建 cgroup,限制如下进程占用的内存。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 首先在不做限制的情况下,启动一个占用内存的 stress 进程
    $ stress --vm-bytes 200m --vm-keep -m 1
    # 创建一个 cgroup
    $ mkdir test-limit-memory && cd test-limit-memory
    # 设置最大 cgroup 的最大内存占用为 100MB
    $ sh -c "echo "100m" > memory.limit_in_bytes"
    # 将当前进程移动到这个 cgroup 中
    $ sh -c "echo $$ > tasks"
    # 再次运行占用内存 200MB 的 stress 进程
    $ stress --vm-bytes 200m --vm-keep -m 1

参考:

《自己动手写 Docker》

https://blog.csdn.net/liumiaocn/article/details/52549659

https://mp.weixin.qq.com/s/10HgkUE14wVI_RNmFdqkzA

https://github.com/xianlubird/mydocker/issues/41


未完,EOF

文章作者: Ahoj
文章链接: https://ahoj.cc/2020/02/Docker实现基础技术/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Ahoj's Blog