大家好!我是大聪明-PLUS!

本文将详细介绍所有这些内容。不过别担心,即使不了解 Golang 工具,也能理解我们讨论的所有内容。它只是对 Go 语言爱好者的一项额外福利。

每个仓库都有单独的描述,但最好先阅读文章 :)

词汇表

  1. 程序是一个包含编程语言代码的文本文件;

  2. 进程是操作系统的一种抽象概念,它允许你监控程序的执行进度;

  3. 内核是操作系统底层的程序,用系统语言(例如 C 语言)编写;

  4. 操作系统——内核和标准用户应用程序;

  5. 系统调用是操作系统的一种 API,用户进程可以通过它来访问系统资源(内存分配、网卡访问、处理键盘按键等);

  6. 宿主系统是指部署容器或虚拟机的操作系统。

这篇文章是写给谁看的?

这篇文章是写给谁的?在这个人们更看重对工具及其使用场景的基本理解,而非了解其所有选项和在开发过程中可能出现的各种场景下的行为的世界里,那种如同孩童拆开圣诞树下礼物般真挚而浓厚的兴趣,充其量会被遗忘,最坏的情况则是彻底消失。因此,这篇文章是写给那些真正关心工具的人,写给那些想要为原本死气沉沉的终端命令或用户界面按钮注入活力的人。

下面,我们将探讨支持各种容器化机制的 Linux 内核功能。理解下文的讨论,具备 Linux 基础知识会有所帮助,但并非必要条件。我们将尽力(以最严谨的方式)全面讲解本主题的各个步骤。我们将根据需要,逐步深入探讨所有需要理解的 Linux 方面,从一般到具体,从简单到复杂。但是,请做好心理准备,并非所有内容都能在第一次阅读时就完全理解。许多复杂的概念需要一些初步的“铺垫”才能解释清楚。因此,我建议您仔细阅读本文,并至少研究两遍示例,阅读之间最好休息一下,比如睡个午觉。

Linux,你是什么?

不仅是容器化机制,许多其他知名工具也完全依赖于Linux内核。 “内核”这个词本身一点也不可怕,原因如下。

我们在 Linux、Windows 或 macOS 上运行的任何程序都运行在一个我们称之为操作系统的环境中,更准确地说,是运行在一个操作系统内核中。毕竟,操作系统包含了每个人从小就熟悉的标准应用程序。然而,内核本身无需任何环境即可运行。它只有一个处理器,负责执行一组特定的命令(例如加法、减法、进位等),以及一个BIOS,负责在按下电脑机箱上的电源按钮后将操作系统内核加载到 RAM 中。内核的基本功能是管理进程,并为它们创建一个“舒适的环境”,使它们能够“舒适地”运行。

Chroot:容器化的起源

一点理论

在任何流行的 Linux 发行版中,文件系统目录树的根目录都用斜杠 - 表示/。系统中运行的任何进程都可以访问磁盘上的任何文件(当然,前提是它拥有相应的权限)。

chroot 会改变进程目录树的根目录。换句话说,它会欺骗进程,使其认为根目录不是 `/usr/local` /,而是启动时指定的任何其他目录chroot。例如,`/usr/local/bin`。这意味着进程将无法访问/usr高于` /usr/local`、`/usr /local` 、`/usr/local` 的/usr目录。它的文件系统可见性将被限制在 ` /usr /local` 范围内。/bin/lib/dev/etc/usr

多加练习!

首先,我们来看一下命令结构:

man chroot

首先,您需要指定新的根目录,然后指定应该运行的程序,该程序会认为其目录树根目录位于指定的目录中。

CHROOT(8) User Commands CHROOT(8)
NAME
	chroot - run command or interactive shell with special root directory 

SYNOPSIS 
	chroot [OPTION] NEWROOT [COMMAND [ARG]...]

让我们创建一个新目录,并尝试运行一个进程,bash该进程会认为其根目录不在 `<path>` /,而是在 `<path> /hello-habr`。为此,让我们创建一个目录/hello-habr并尝试运行chroot:

mkdir /hello-habr
chroot /hello-habr bash

结果,我们收到一个错误chroot: failed to run command ‘bash’: No such file or directory……

关键在于,当我们通过终端启动一个程序,指定程序名称而不是可执行文件的完整路径时,终端(或命令 shell,在这种情况下,两者的区别并不那么重要)会在标准目录中查找具有指定名称的可执行文件(这些目录的路径位于环境变量中$PATH,可以通过在终端中输入命令来查看命令 shell 的环境env)。

以下是存储程序可执行文件的最常见目录:/bin、、和。/sbin/usr/bin/usr/sbin/usr/local/bin

当然,它会从根目录开始查找。但由于我们新的根目录/hello-habr下没有任何内容,bash终端找不到可执行文件。

我们把它加上去吧!同时ls:

mkdir /hello-habr/bin
cp /bin/bash /bin/ls /hello-dacongming/bin/

我们再试chroot一次:

chroot /hello-habr bash

但我们又遇到了错误chroot: failed to run command ‘bash’: No such file or directory……

问题在于可执行文件bash依赖ls于动态库,而我们的新环境中缺少这些库。让我们使用这个工具来查看可执行文件依赖于哪些库,ldd并将它们添加到新根目录的标准路径中/hello-dacongming:

现在我们来尝试运行一下:

太棒了,成功了!现在我们的进程bash被隔离在文件系统上下文中,并将主机系统上非根目录视为其根目录。

原来,chroot它会先更改根目录,然后在新的环境中查找并启动程序。如果新根目录中缺少可执行文件或其依赖项,则启动会失败。

命名空间:任何容器的基础

示例中的 Linux 命名空间

抛开网上那些不靠谱的类比不谈,我们尽量用最简单明了的方式来解释。命名空间是进程访问操作系统资源的入口点(句柄)。每个进程默认创建在默认命名空间中,但可以在执行过程中更改命名空间,或者直接启动到新的命名空间。为了更好地理解这一点,我们用 C++ 写一个简单的例子。这里我只讨论代码框架并提供一些解释;

假设我们系统中的进程拥有两种基本资源:数组和字符串。它们可以管理(更改/删除)这些资源,并执行各种操作fork。这意味着它们可以创建新进程,从而成为新进程的父进程,形成进程树(层级结构)。(在 Linux 系统中,可以使用 `pwd` 工具查看进程树pstree。)

我们示例中的过程可以用以下结构来描述:

/**
* 
*/
struct process {
	int process_id;      
	string process_name; 
	child_proc *children;           
	process_namespaces *namespaces; 
	
	void unshare(NAMESPACES ns);
	void setNewString(string str);
	void setNewArray(vector<int> arr);
	
	process *forkProcess(string new_process_name);
};

这里我们看到了什么?该进程包含字段和方法。字段包括进程名称、进程 ID、指向进程子进程列表的指针,以及最重要的,指向进程命名空间的指针。

进程结构指向的命名空间结构包含指向每个命名空间的两个指针。在我们的示例中,只有两个命名空间:字符串空间和数组空间,它们分别代表可能的进程资源。

struct process_namespaces {
   array_ns  *ans; 
   string_ns *sns; 
};

那么这些资源究竟是什么呢?它们其实就是一个数组和一个包含长度信息的字符串,以及默认构造函数,我们稍后会讨论这些构造函数。

struct array_ns {
   int         arr_len; 
   vector<int> array;   
   
   
   array_ns();
};

struct string_ns {
   int    str_len; 
   string str;     

   
   string_ns();
};

你还需要创建一个 init 进程,就像在任何 Linux 系统中一样,它是所有进程的父进程,位于进程树的根部。

/**
* 
*/
process *CreateInitProcess(string init_process_name) {
   
   process *init_proc = new process;

   
   init_proc->process_id = process_count;
   init_proc->process_name = init_process_name;

   
   process_namespaces *init_ns = new process_namespaces;
   
   
   array_ns* init_ans = new array_ns;
   string_ns* init_sns = new string_ns;
   
   
   init_ns->ans = init_ans;
   init_ns->sns = init_sns;

   
   init_proc->namespaces = init_ns;
   
   
   process_count += 1;
   	
   
   init_proc->children = nullptr;

   return init_proc;
}

其他进程(常规/用户进程)使用该方法创建forkProcess。此方法与函数几乎相同CreateInitProcess,区别在于:

  1. 我们使用子进程的链表(这对于我们的示例来说并不特别重要。我们这样做是为了能够可视化进程树);

  2. 我们不创建新的命名空间,而是直接从父进程继承它们(Linux 就是这样工作的)。

/**
* 
*/
process *process::forkProcess(string new_process_name) {
   
   process *new_proc = new process;
   
   
   new_proc->process_id = process_count;
   new_proc->process_name = new_process_name;

   
   new_proc->namespaces = this->namespaces;

   
   child_proc* new_child = new child_proc;
   new_child->proc = new_proc;
   new_child->next = nullptr;

   
   if (this->children == nullptr) {
       this->children = new_child;
   
   } else {
       child_proc* curr_child = this->children;
       while (curr_child->next) {
           curr_child = curr_child->next;
       }
       curr_child->next = new_child;
   }

   
   process_count += 1;

   
   new_proc->children = nullptr;

   return new_proc;
}

我们快完成了!让我们在 main() 函数中创建几个进程,并查看它们的进程树:


process *init_proc = CreateInitProcess("init_proc");


process *floppa_cat_proc = init_proc->forkProcess("floppa_cat_proc");
process *ploob_cat_proc = init_proc->forkProcess("ploob_cat_proc");


process *komaru_cat_proc = floppa_cat_proc->forkProcess("komaru_cat_proc");
process *zigmund_cat_proc = floppa_cat_proc->forkProcess("zigmund_cat_proc");


process *barsik_cat_proc = zigmund_cat_proc->forkProcess("barsik_cat_proc");
process *murzik_cat_proc = zigmund_cat_proc->forkProcess("murzik_cat_proc");


DrawProcessesTree(init_proc);

这就是新创建的流程树状图:

.
├── init_proc (PID: 1)
├── floppa_cat_proc (PID: 2)
     ├── komaru_cat_proc (PID: 4)
     ├── zigmund_cat_proc (PID: 5)
         ├── barsik_cat_proc (PID: 6)
         ├── murzik_cat_proc (PID: 7)
├── ploob_cat_proc (PID: 3)

现在我们来看一下流程资源:


DumpProccessInfo(init_proc);
DumpProccessInfo(komaru_cat_proc);
DumpProccessInfo(murzik_cat_proc);

例如,以下是 init_proc 的内容:

[init_proc] :: ------------------------------------------
PID: 1
Namespaces:
Array NS:
     Array Len: 5
     Array: [1 2 3 4 5 ]
String NS:
     String Len: 12
     String: Hello, Dacongming!

字符串“Hello, Habr!”和数组[1 2 3 4 5]是创建新命名空间时默认构造函数中设置的默认值(您可以在源代码中查看更详细的说明)。由于在创建`init_proc`时创建了一个新的命名空间,并且所有其他进程都继承了它,因此每个进程的输出对于数组和字符串部分都将相同。您可以通过克隆存储库并运行它,make run或者查看README.md来验证这一点。

现在 komaru_cat_proc 进程想要创建一个新的行空间并更改行,以及更改数组,但不想更改数组空间:


komaru_cat_proc->unshare(NAMESPACES::STRING_NS);


komaru_cat_proc->setNewString("I am from a new Namesapce, unshared by komaru_cat_proc");


komaru_cat_proc->setNewArray({123, 345, 789, 101112, 131415});

现在我们来看一下 init_proc、komaru_cat_proc 和 murzik_cat_proc:

[init_proc] :: ------------------------------------------
PID: 1
Namespaces:
Array NS:
     Array Len: 5
     Array: [123 345 789 101112 131415 ]
String NS:
     String Len: 12
     String: Hello, Habr!

[komaru_cat_proc] :: ------------------------------------------
PID: 4
Namespaces:
Array NS:
     Array Len: 5
     Array: [123 345 789 101112 131415 ]
String NS:
     String Len: 45
     String: I am from a new Namesapce, unshared by komaru_cat_proc

[murzik_cat_proc] :: ------------------------------------------
PID: 7
Namespaces:
Array NS:
     Array Len: 5
     Array: [123 345 789 101112 131415 ]
String NS:
     String Len: 12
     String: Hello, Habr!

由于 komaru_cat_proc 进程在未创建新数组空间的情况下修改了数组,因此其修改影响了系统中的所有进程;现在所有进程都拥有了新的数组。然而,只有 komaru_cat_proc 进程拥有了新的字符串,因为该进程在新字符串空间中修改了字符串。

因此,komaru_cat_proc 进程现在是隔离的,它可以更改字符串而不会影响其他进程的字符串。

为了避免文章中充斥着过多的例子,其他场景可以在文章开头链接的存储库中找到。

真正的 Linux 命名空间

Linux 命名空间是对操作系统资源的一种抽象。当然,这可能有点令人困惑。在操作系统内核的上下文中,抽象究竟是什么?让我们来看看 Linux 内核中进程的表示,它也是物理系统资源分布的一种抽象。是的,它只是一个结构体,虽然有几百行,但包含代表所有进程属性的标准字段。没错,它很复杂,但并不难理解。task_struct在这个结构体中,再往后四百行,隐藏着对我们来说最重要的字段——nsproxy ,它代表进程命名空间。

在本例中,该层是通过以下字段在流程结构中实现的:

process_namespaces *namespaces;

在Linux内核中,它是这样的:

struct nsproxy *nsproxy

是的,因为我们用的是 C++ 而不是 C,所以开头不需要使用 struct 这个词。

使用命名空间:取消共享

很多人可能都听说过Linux中的“一切皆文件”,但他们并不完全理解这意味着什么。

首先,我们得承认,硬盘本身就是一个庞大的字节数组。对我们来说,它不过是一堆字节而已。硬盘(HDD)是在磁头下方的机壳里旋转,还是固态硬盘(SSD)的电荷水平在变化,这些都无关紧要。重要的是,这项技术奇迹能够存储信息。此外,硬盘必须具备特定的布局,也就是预先存储了一些系统信息。就像这篇文章一样,它有引言、目录、正文(包含各个章节)、结论和参考文献。如果没有这种结构,读者就无从下手,文章也会变成一团乱麻,而不是一套结构清晰、便于学习的信息。硬盘和电脑也是如此,它们能够读取并显示存储在硬盘上的文件。

因此,普通文件(例如图片、诗歌、Python 程序)物理存储在硬盘上。但在 Linux 系统中,文件系统中的许多文件并非存储在硬盘上,而是存储在内存(RAM)中,并且仅在电脑开机期间存在。这些文件可以代表物理设备,例如鼠标、键盘或网卡(位于 `/etc/sys.txt` 目录中/dev),也可以代表逻辑系统资源,例如进程信息(位于 `/etc/sys.txt` 目录中/proc)。

例如,在物理设备的上下文中,这种方法的妙处在于,我们只需通过两个系统调用 `read()` 和 `write()`(就像操作存储在硬盘上的普通文件一样)即可从代码中与所有设备通信。这同样适用于隐藏在文件系统之下的任何其他逻辑和物理实体。通过这种方法,我们获得了一个便捷而强大的接口,用于与任何资源进行交互,而这些资源正是类 UNIX 操作系统的基本抽象——文件系统!

歌词就说到这里吧,我们感兴趣的是目录/proc。或者,正如人们所称的,procfs(进程文件系统)。

该目录的内容如下所示:

每个目录都有一个代表特定进程 PID(进程 ID)的数字。每个目录都包含与该 PID 对应的进程的详细信息。这些文件并不存储在磁盘上;内核只是模拟它们是文件的样子。实际上,它们只是操作系统资源(在本例中是进程及其属性),被映射到文件系统,从而提供了一个方便的接口来访问它们的信息。理解这一点在我们稍后研究 cgroups 时会很有用。

当然,procfs 文件包含了进程所属命名空间的信息。让我们查看进程 ID 为 117 的进程的目录,并在终端中显示 ns(命名空间的缩写)目录的内容。

代表各个命名空间的这些文件并没有存储在磁盘上。它们是指向内核资源的指针,括号中的数字是特定命名空间的 ID。

我们的 C++ 实现中有一个方法unshare允许进程更改其命名空间。Linux 也有一个同名的实用程序;让我们来看一下它的描述:

man unshare
UNSHARE(1)                       User Commands                      UNSHARE(1)

NAME
       unshare - run program in new namespaces

SYNOPSIS
       unshare [options] [program [arguments]]

因此,我们首先需要指定选项(我们要创建哪些空间),然后指定我们要在这些新空间中运行的程序本身。

首先,我们打开两个终端(两个不同的进程),看看它们的命名空间:

我们可以看到,它们是相同的,因为进程默认继承基本命名空间。

我的 shell 名为 fish,它的进程 ID (PID)(因为 shell 也是一个进程,而每个进程都有一个 PID)存储在 $fish_pid 环境变量中。在 bash 中,它的 PID 存储在 $$ 变量中。

让我们来体验一下 UTS 命名空间,这个命名空间负责机器的主机名和域名。

正如我们所见,unshare在左侧终端输入主机名之前,更改主机名会影响完全不同的进程(右侧终端)。然而,在使用 `--uts` 标志调用 `unshare` 命令后,启动了一个新的 fish 进程,该进程现在位于不同的 UTS 命名空间中。现在,更改左侧终端中的主机名不会影响右侧终端,反之亦然,就像我们的 C++ 示例一样!

请注意,这两个进程的所有空间 ID 都相同,除了 UTS。没错,UTS 代表 UNIX 分时系统。为什么?谁知道呢……

理解进程获取资源的需求与资源本身之间的“层”这一基本概念至关重要。命名空间就扮演着这一层的角色。这样的例子不胜枚举:

  1. 挂载命名空间——其作用几乎相同chroot,限制进程在文件系统上下文中的可见性。

  2. PID命名空间——在新的PID命名空间中创建的进程将看不到宿主系统的进程树;它只能看到自己创建的进程树。换句话说,它会认为自己是一个PID为1的初始化进程。

  3. 网络命名空间——不同的进程可以访问不同的网络接口。例如,我们的电脑可能同时配备用于无线数据传输的网卡和以太网端口。一个进程可能只看到以太网接口,而不知道它运行的系统也支持无线数据传输;而另一个进程则可能看到相反的情况,以此类推。

Cgroups:新功能

对照组:理论

命名空间可用于隔离进程与逻辑资源之间的关系,而控制组 (Cgroups) 则允许隔离进程与系统物理资源之间的关系。我们不会深入探讨 Cgroups V1 的历史和细节,而是重点介绍 Cgroups V2(以下简称 Cgroups),它目前是大多数主流 Linux 发行版的默认设置。

正如我们之前看到的,Linux 中的许多功能都提供了便捷的文件系统接口,Cgroups 也不例外。要限制一组进程的资源,只需创建一个文件夹并将相应的值写入几个文件即可。但在那之前,让我们先了解一下它的工作原理以及可以限制哪些物理资源。进程组由一个或多个进程组成。

我们可能会对以下方面施加限制:

  1. 处理器(CPU) ——例如,表示一组给定的进程只能在特定的处理器核心上运行,或者表示一组进程只能使用处理器的一部分时间;

  2. 内存——我们可以为进程组设置内存限制,一旦该组中的任何进程超过该限制,该进程就会被Linux内核

  3. 线程- 您可以限制一个组中所有进程总共拥有的进程/线程数(毕竟,从内核的角度来看,线程是同一个进程,但这完全是另一回事)。

除了磁盘或网络输入/输出以及一些其他与理解对照组完全无关的特定事项之外。

对照组:练习

头脑风暴

文件系统中包含控制组目录的标准路径位于/sys/fs/cgroup。这与 类似procfs,/proc被称为cgroupfs。

我们已经熟悉操作系统内核管理的“特殊”文件和文件夹的概念;我们现在要处理的正是这些文件和文件夹。这并不复杂;你只需要了解每个文件的作用,同时记住这些文件有些“特殊”。

最重要的一点:

  1. 控制组是目录中的一个文件夹/sys/fs/cgroup,文件夹可以相互嵌套,形成控制组的层次结构;

  2. 资源是指控制组文件夹中的文件。当创建控制组(文件夹)或添加新控制器时,资源会自动创建,如下所示。

需要注意的是,控制组不仅仅是一个文件夹,而是 Linux 内核的一个抽象层,它通过cgroupfs.映射到文件系统。/sys/fs/cgroup对我们来说,它看起来就像是包含文件的文件夹,这提供了一个方便的接口。

其实没那么难,对吧?这就是“一切皆文件”理念的力量。

我们/sys/fs/cgroup看到根控制组。该文件cgroup.controllers包含所有可能的资源约束列表。该文件cqroup.subtree_control还包含一个资源列表(在 cgroup 术语中称为控制器),这些资源的约束在给定的控制组(在本例中为根控制组)中使用。要添加新的控制器,只需在文件中写入cqroup.subtree_control。

该文件cgroup.procs包含属于控制组的进程。

如果你直接运行命令cat cgroup.procs,你会看到这个控制组中所有进程的 PID 列表。但我们现在只关心进程的数量,所以我把输出重定向到了wc -l。

原则上,根控制组中的进程数应该等于系统中的进程数,这似乎很明显,但由于某种原因,事实并非如此,如上面的屏幕截图所示。

关键在于操作系统会创建多个系统 cgroup(例如 system.slice,它包含十几个子 cgroup,如上图所示),而这些 cgroup 内部又会创建更多子 cgroup,形成一个层级结构。如果将cgroup.procs根 cgroup 的所有子 cgroup 加起来,结果将等于系统中所有进程的数量。

当然,我们不能就此止步;让我们编写一个脚本,将根控制组中Python所有嵌套组的值相加:cgroup.procs

import os

def count_processes_in_cgroups(root_dir):
	total = 0
	for dirpath, _, filenames in os.walk(root_dir):
		if 'cgroup.procs' in filenames:
			try:
				with open(os.path.join(dirpath, 'cgroup.procs'), 'r') as f:
				total += len(f.readlines())
			except (PermissionError, IOError):
				continue
	return total

if __name__ == "__main__":
	total = count_processes_in_cgroups("/sys/fs/cgroup/")
	print(total)

结论如下:

zpnst@debian ~/Documents> python3 cg.py
404
zpnst@debian ~/Documents> pgrep -a -c .
403

你为什么认为 Python 脚本会多算一个进程?问题在于,脚本本身就是同一个进程,它默认继承了父进程(我的终端)的 cgroup,而父进程又继承了 init 进程的根 cgroup。我想你应该明白这些省略号的含义了;这和命名空间是一样的。如果没有显式指定进程,它就会进入 init 进程所属的基本命名空间或基本(根)cgroup(就像我们的 C++ 例子一样……)。由于我们的脚本位于它想要统计的进程的根 cgroup 中,所以它把自己也算进去了。而专门的统计工具pgrep不会这样做。

太好了,现在一切都恢复正常了。我可以安心地继续生活了!

上面的截图中进程数量较少,是因为我在编写脚本期间系统发生了一些变化,导致进程数量略有增加。您可以自行测试一下进程数量的变化。

自身对照组

这已经很好了,但我们来创建一个干净的对照组,向其中添加一个进程,设置一个内存限制,然后尝试突破这个限制 :)

我将使用我在容器实用程序实现中提供的这个Python 示例(链接的仓库中还有另一个示例),该示例的链接在文章开头。示例如下:

import time

buffer = []

def main():
    while True:
        buffer.append(" " * 100 * 1024 * 1024)
        print(f"[{time.time()}] :: 100 MiB was allocated")

if __name__ == "__main__":
    main()

脚本非常简单,它只是无限期地分配 100 兆字节的空间。

但我们还是回到对照组的创建上来吧:

我们做了什么?

  1. 我们进入了根对照组;

  2. 我们在其中创建了一个新的对照组my-new-group并将其输入;

  3. 我们显示了新创建的组(文件夹)的内容;正如我们之前所说,这些文件是由 Linux 内核自动为我们创建的;

  4. 让我们看看终端进程的PID是什么;

  5. 让我们看看我们新的控制组中目前有哪些流程;结果发现目前还没有任何流程;

  6. 让我们把终端进程添加到组中my-new-group;

  7. 让我们看看现在哪些进程属于这个组。第一个 PID 实际上属于终端,那么第二个 PID 在这里做什么呢?但我们已经熟悉这个概念了。由于控制组会被子进程继承,而该实用程序cat是终端的子进程,因此它自动属于该组my-new-group。第二个数字正是该进程的 PID cat;

  8. 我们来看一下内存限制——它还没有设置;

  9. 让我们把内存限制设置为 512 兆字节。是的,只需在文件中写入“512M”这一行,就这么简单;

  10. 我们再检查一下内存限制,很好,我们成功设置了;

  11. 让我们检查一下交换分区中可以上传到硬盘的页面数量限制;

  12. 让我们把这些页面的值设为 0 并检查一下。如果不这样做,我们就无法超过 512 MB 的限制,因为当进程达到这个限制时,操作系统会将内存页面交换到硬盘上的交换分区。这就是交换空间的作用。

我们已经知道进程会继承其父进程的组,因此,如果我们在此终端运行一个无限期分配 100 MB 内存的 Python 脚本,它将自动添加到我们的组中my-new-group。

理论上,当程序第六次尝试分配 100 兆字节内存时,它应该会被 OOM Killer(我们上面已经讨论过)终止,所以让我们来验证一下:

下面显示的是我们之前使用的终端。您可以将此终端的进程 ID (PID) 与上面的屏幕截图中的 PID 进行比较。顶部终端显示的是 Python 脚本运行完毕后使用该工具生成的内核日志dmesg。

正如预期的那样,脚本在第六次尝试分配 100 MB 内存时终止了。在内核日志中,我们可以看到 OOM Killer 跟踪了我们的进程(该进程浪费内存分配),并将其无情地终止。日志中还显示了我们的控制组的名称my-new-group。

好了。如果你已经读到这里,并且理解了以上所有内容,那么你已经掌握了容器化几乎所有的基本机制。但在我们深入探讨该领域的标准和 Docker 本身之前,我们需要再深入学习一下容器文件系统的工作原理,以及为什么 Docker 被称为“千层蛋糕”。我相信,在学习了这些之后,你理解起来应该不会有任何问题。

OverlayFS:覆盖文件系统

挂载系统调用

安装U盘

首先,我们来了解一下什么是挂载。我们之前已经讨论过,为了让电脑正确读取磁盘上的数据,它必须使用熟悉的磁盘分区系统。这一点值得牢记。使用这个工具,lsblk你可以查看系统可以访问哪些设备(驱动器)。我的笔记本电脑有一个 1TB 的硬盘,分成了五个分区:

zpnst@debian ~> lsblk
NAME        MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
nvme0n1     259:0    0 953.9G  0 disk
├─nvme0n1p1 259:1    0    16G  0 part [SWAP]
├─nvme0n1p2 259:2    0     1G  0 part
├─nvme0n1p3 259:3    0   512M  0 part /boot/efi
├─nvme0n1p4 259:4    0   150G  0 part /
└─nvme0n1p5 259:5    0 786.4G  0 part /home

挂载点显示在右侧。例如,/我在系统安装期间为根磁盘分配了 150 GB 的空间:

├─nvme0n1p4 259:4    0   150G  0 part /

而/home其余部分则位于(俗称仓鼠的)下方:

└─nvme0n1p5 259:5    0 786.4G  0 part /home

此外,还有其他几个用于系统正常运行的标准分区,/boot包括存放操作系统启动文件的分区和交换空间SWAP(我们之前已经多次提到过)。遗憾的是,这超出了本文的讨论范围。

让我们连接U盘:

zpnst@debian ~> lsblk
NAME        MAJ:MIN RM   SIZE RO TYPE MOUNTPOINTS
sda           8:0    1     0B  0 disk
sdb           8:16   1  30.2G  0 disk
└─sdb1        8:17   1  30.2G  0 part
nvme0n1     259:0    0 953.9G  0 disk
├─nvme0n1p1 259:1    0    16G  0 part [SWAP]
├─nvme0n1p2 259:2    0     1G  0 part
├─nvme0n1p3 259:3    0   512M  0 part /boot/efi
├─nvme0n1p4 259:4    0   150G  0 part /
└─nvme0n1p5 259:5    0 786.4G  0 part /home

现在,代表闪存驱动器的文件位于该路径上/dev/sdb1(我们之前讨论过设备文件系统)。该文件由内核自动分配给设备。

它可能叫别的名字,我们再插上同一个U盘,现在它已经插好了/dev/sdd1。简而言之,这是Linux内核的职责。

这里,我们使用 mount 命令将 U 盘的内容挂载到文件系统中/home/zpnst/Documents/habr。这个路径现在被称为挂载点。U 盘中只有一个文件,内容是“Hello, Habr!”。如果我们物理移除 U 盘,这个文件也会file.txt消失。这就是我们文件系统中看到的、存储在硬盘上的文件的工作原理。

系统能够识别该设备,并且我能够挂载并检查其内容,这表明我的U盘布局与我的操作系统相符,并且我已经安装了能够识别这种布局的驱动程序。

挂载目录

这里还有另一个例子。我们创建一个目录a并将其挂载到目标目录下,b这样对目标目录的任何更改a都会自动应用到目标目录b。

这里发生了什么?

  1. 我们创建了两个目录a;b

  2. 将目录挂载a到目录中b;

  3. 我们检查过,这两个目录都还没有包含任何内容;

  4. 在目录中创建文件a;

  5. 我们检查发现它也出现在b;

  6. 我们再次测试,我们检查;

  7. 用这种方法umount我们可以移除安装点;

  8. 因此,该目录b现在为空。就像我们从电脑机箱中取出U盘一样,其文件从挂载点消失了。

再次强调,这里没有什么魔法;一切都是内核功能。现在我们可以开始学习容器文件系统了!

容器文件系统:overlayfs

简易理论

OverlayFS 允许您将一个目录树(顶层)叠加在另一个目录树(底层)之上。底层是只读的,而顶层是可写的。别着急,下面的例子会解释得很清楚。

要使 overlayfs 正常工作,您需要创建 4 个目录:

  1. diff- 包含更改的目录(顶层);

  2. lower- 一个具有基本文件结构的目录,更改将应用​​于该目录(底层);

  3. merged- 基本目录lower+ 包含更改的目录diff。还是那种分层结构 :)

  4. work- overlayfs 工作目录,我们对此并不感兴趣,overlayfs 只是要求它存在。

您可以随意命名目录,这并不重要,这些名称只是标准名称,反映了这些目录的本质。

练习同样简单

我们先来看一个具体的例子。最底层是一个目录mycatalogs,其中包含几个其他目录。

创建完成后,mycatalogs我们按照上述步骤创建 overlayfs 操作所需的目录,并使用系统调用mount通过标志指定文件系统类型-t。我们还在该标志之后指定哪些目录负责哪些功能-o。

就这样,从现在开始,这些目录由内核管理,容器的文件系统可以称为目录merged。正如我们所看到的,内核自动迁移了merged所有目录mycatalogs。关键在于,merged是的,mycatalogs并且叠加了diff,这在(本节开头的图片清楚地说明了这一点)中有所体现。diffmycatalogsmerged

我们来尝试修改一下merged:

将 文件添加到` / mergedetc diff...​mycatalogsgamesdiffgamesgamesmerged

举个例子,我们 merged再添加几个文件夹:

现在想想,如果你chroot对目录执行此操作会发生什么merged?

等等,如果我们也在单独的命名空间和控制组中运行该进程,并使用chroot它来创建根目录呢merged?

没错!你会得到一个容器!

在容器中,基本目录树通常不是像我们这样的随机文件夹mycatalogs,而是一个标准的目录树ubuntu,debian或者说alpine Linux。这样的树被称为minirootfs。

例如,您可以将 Python 层叠加在基础文件系统层之上alpine linux。什么是 Python 层?简单来说,它就是一个可执行的 Python 解释器文件。diff它会出现在 `<path>` 文件夹中/usr/bin/python,而包含文件系统的基础目录树alpine linux将保持不变。

这一切都被称为分层蛋糕,容器只在基础目录树之上存储更改以节省资源,启动时,overlayfs会创建一个新的目录树merged,重新创建正在运行的容器的目录树。

带有真正 minirootfs 的 Overlayfs Alpine Linux

让我们动手完成上面提到的所有步骤。

让我们创建一个用于存放 Alpine Linux 的目录,下载包含 minirootfs 的归档文件,并将其解压到新创建的目录中:

mkdir alpine
wget https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.0-x86_64.tar.gz
tar -xvzf alpine-minirootfs-3.20.0-x86_64.tar.gz -C ./alpine/

现在我们按照前面的例子做同样的事情,只是将高山指定为底层。

我认为没有必要再次解释结果,它们与之前的例子完全相同。

现在让我们开始这个过程bash,并告诉它它的目录树根将是目录merged。

chroot还记得文章开头提到的启动问题吗?我们bash当时既没有可执行文件,也没有标准库bash。

当然,这类问题不应该出现,因为这正是 minirootfs 的意义所在。它只包含运行程序所需的最基本环境。该文件夹/bin已经alpine包含了bash许多其他实用程序和应用程序,以及/lib所有标准库。

以下是它们:

现在chroot:

正如我们所见,这里alpine/bin没有句点bash(.),但有一个常规的 shell sh,所以我们就使用它。现在我们进入了这个临时容器,hello.cpp之前示例中的文件仍然存在。当然,如果我们添加新的文件和文件夹,所有这些更改都会反映在容器中diff。

现在,我们可以启动另一个进程,它的根目录位于不同的文件夹中merged,但使用相同的目录树alpine linux。最重要的是,我们不会更改底层文件系统alpine;它对每个人都一样,就像一个快照。

OCI:容器化的黎明

开放容器倡议

开放容器倡议(OCI)于 2015 年 6 月宣布,是一套容器化领域的开放标准。

OCI包含两个主要规范:

  1. 运行时规范——描述容器的生命周期、执行方式以及配置其隔离各个方面的顺序;

  2. 镜像规范- 描述用于启动容器的容器镜像格式及其存储方式(与 Windows 上的 .exe 文件以及运行该文件创建的进程相同,或 Linux 上的 ELF)。

这些规范确保了不同容器化工具(Docker、Podman 等)之间的兼容性,并简化了容器在不同平台间的移植。

RunC 练习

该命令runc spec会创建一个特殊的config.jsonJSON 文件。该文件存储有关新进程(容器)的命名空间和 cgroups 的信息,以及根文件系统、环境变量、挂载点等的路径。简而言之,它是对需要应用于新进程(容器)的所有隔离机制(以及更多内容)的标准化描述。

这是输入命令后得到的结果。系统将创建一个包含最基本设置的runc spec标准模板文件。config.json

zpnst@debian ~/D/habr> runc spec
zpnst@debian ~/D/habr> ls
config.json

现在让我们仔细看一下这个json文件的某些部分:

{
  "ociVersion": "1.2.0",
  "process": {
    "terminal": true,
    "user": {
      "uid": 0,
      "gid": 0
    },
    "args": [
      "sh"
    ],
    "env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "TERM=xterm"
    ]
}

首先,指定了规范版本,在 args 中指定了将在容器中启动的程序(默认情况下,这是最常见的 shell),以及环境变量。

在文件的这一部分,我们可以看到进程将在哪些命名空间中启动:

{
  "linux": {
    "resources": {
      "devices": [
        {
          "allow": false,
          "access": "rwm"
        }
      ]
    },
    "namespaces": [
      {
        "type": "pid"
      },
      {
        "type": "network"
      },
      {
        "type": "ipc"
      },
      {
        "type": "uts"
      },
      {
        "type": "mount"
      },
      {
        "type": "cgroup"
      }
    ]
}

基本模板没有控制组设置,但它可能看起来像这样:

{
  "linux": {
	"resources": {
	  "memory": {
	    "limit": 536870912,
	    "swap": 536870912 
	  },
	  "cpu": {
	    "shares": 512,
	    "quota": 50000,
	    "period": 100000
	  },
	  "pids": {
	    "limit": 100
	  }
	}
}

再次强调,这里没有什么魔法。他们只是制定了一个标准,用 JSON 文件描述未来容器的所有属性。然后,runC程序解析这个文件,并根据其中的内容生成容器。

现在我们需要把我们已经熟悉的那个文件添加到同一个文件夹中rootfs。就用同一个文件吧alpine Linux。

zpnst@debian ~/D/habr> ls -l
total 4
drwxr-xr-x 1 zpnst zpnst  114 May 22  2024 alpine/
-rw-r--r-- 1 zpnst zpnst 2500 Aug  3 17:28 config.json
zpnst@debian ~/D/habr> ls alpine
bin/  dev/  etc/  home/  lib/  media/  mnt/  opt/  proc/  root/  run/  sbin/  srv/  sys/  tmp/  usr/  var/
zpnst@debian ~/D/habr>

文件夹的内容也就是rootfs它的config.json名称OCI Bundle,以及它所处理的内容runC。

但在创建容器之前,您需要更改以下设置config.json:

{
  "root": {
    "path": "rootfs",
    "readonly": true
  }
}

默认情况下,runC它会在目录中查找容器的基础文件系统文件夹rootfs,在本例中该目录名为alpine。您需要将路径字段中的 rootfs 行更改为 alpine。

现在我们已做好充分准备,可以启动容器了:

在下方终端中,我们使用命令启动了容器runC;在上方终端中,我们显示了已知容器的列表runC。请注意 Bundle 的路径;它habr正如预期位于该文件夹中。

我还列出了进程和网络接口。请注意,我们的sh进程 ID 为 1,这意味着容器中的进程将自身视为主进程。它也只有一个网络接口(回环接口或本地主机接口)。这一切都是命名空间的作用。

恭喜!现在你几乎了解了 Docker 的所有知识!为了避免文章过于冗长,我省略了一些比较细微的要点。所有这些要点都会在文章末尾进行讲解,并提供一些有用的链接供你深入学习。当然,这部分内容是可选的,仅供那些想要深入了解该主题的人参考。

Docker:伟大而美丽

为什么需要 Docker

经过七千多字的讨论,我们终于来到了 Docker 部分。上一节runC很好地介绍了如何启动容器。我们还可以管理容器、查看容器列表以及更改设置。那么,我们为什么需要 Docker?它又有哪些优势呢?

正如我之前所说,Docker 是runC底层技术,它的真正价值不在于运行容器的能力(当然,这方面也很重要),而在于它的基础设施、与其他众多工具的向后兼容性以及便利性。然而,这种便利性往往掩盖了人们对它工作原理的完全不了解。我曾经就是这种情况,而你现在正在阅读这篇文章,也说明你曾经也是这样。

与鲸鱼玩耍

我们不妨基于已有的知识来玩转镜像和容器。由于深入剖析 Docker 的基础架构过于繁琐,已经有很多相关的资料,但很少涉及技术细节或实验。

克隆图像

所以一开始我没有任何镜像或容器,让我们克隆一个镜像ubuntu:

现在图像已出现在本地,命令​​输出显示如此docker images。

此目录/var/lib/docker/image/overlay2/imagedb/content/sha256/<image-id>存储图像元数据:

我们不会详细介绍每个字段;您可以自行查看。

目前,我们只有包含元数据的镜像,还没有容器。接下来,我们将创建一个容器,并了解其文件系统和配置文件存储的位置和方式,以及 OCI Bundle 的组成。Docker 使用 runC 来启动容器。如上所述,runC 与 OCI Bundle 协同工作。

因此,很明显,从镜像配置和设置Dockerfile或命令行参数来看docker run,Docker 必须形成一个config.json能够被理解的镜像runC。

启动容器

我们做了什么?

  1. 我们使用命令创建了一个容器docker create(-it需要添加标志才能使终端正常工作,以便将bash容器中的进程终端显示在我们自己的终端上。这很直观,就是这样);

  2. 我们检查了容器列表;现在有一个名为 `<container_name>` 的容器condescending_nightingale。容器名称是自动生成的,因为我们没有使用 `--name` 标志显式指定它--name。它很有趣,很有教育意义,而且充满了彩蛋。

  3. 我们使用它来启动容器(同样,终端需要docker start标志,我们不会关注这一点);-ia

  4. 太好了,我们已经进入容器内部了,让我们创建一个/home文件,filetofind.txt以便在容器外部找到它,并了解我们容器的文件系统在主机系统中的存储位置和方式;

  5. 我们使用命令退出容器exit。

探索容器的文件系统

现在到了最有趣的部分:让我们尝试filetofind.txt在主机系统上找到容器内创建的文件。

我们做了什么?

  1. 我们直接来看/var/lib/docker,大部分与 Docker 相关的文件都存储在那里;

  2. 我们找到了文件filetofind.txt。不出所料,它位于该目录中diff,因为我们是将其添加到基础图层之上的ubuntu;

  3. 该文件link存储了我们图层的唯一标识符;

  4. 该文件lower通过以下方式存储底层图层的标识符:。

您可以在这里看到所有图层:/var/lib/docker/overlay2/l/。

现在注意你的手。让我们看看文件中的图层里有什么link(它应该包含filetofind.txt,表示我们的更改),并确认底层是的假设rootfs ubuntu。

感兴趣的用户可以仔细查看屏幕截图,确保所有信息正确无误。或者,他们也可以在自己的电脑上重复这些步骤。

所以!上面两张截图中的目录在哪里merged?问题是,当我在终端显示这些信息时,容器并没有运行。

我们运行一下看看结果:

首先,我输入了[ ](这和在 shell 中输入ll是一样的)。然后,我在下面的终端中启动了容器。之后,我再次查看了目录。果然如此。这很合理,因为当容器没有运行时,我们不需要存储所有文件。我们只需要存储基础文件系统(在本例中)以及容器对其所做的更改(文件夹)。也就是说,对于所有运行在该基础文件系统上的容器,文件中找到的最底层始终是[ ] !ls -lfishmergedubuntudifflinkrootfs ubuntuubuntu

找到 OCI 捆绑包

该路径/var/lib/docker/containers/<container-id>存储持久化容器文件。这些文件将一直保留在那里,直到我们显式删除容器为止。其中包含基本的容器配置和 Docker 特有的设置:

这就是config.json(如下截图所示),和我们团队创建的那个一模一样runc spec。

在 Docker 中,容器启动时会根据配置(上图)和用户设置动态生成最终配置。最终配置会被存储config.json,容器通过runC[in /run/containerd/io.containerd.runtime.v2.task/moby/<container-id>] 启动。

但是,一旦我们停止容器(不是删除它,只是停止它,中断它的运行),所有文件都会消失。它们会/run/containerd/io.containerd.runtime.v2.task/moby/<container-id>在启动时重新构建。config.json

我们尚未提及的一些细节问题

我郑重声明,您已经掌握了使用 Docker 的基本知识。如果您多次阅读本文并和我一起进行了实践,那么您很可能已经理解了这一切的必要性和原理 :)

我承诺会在这里重点阐述我遗漏的要点。

无根容器

首先,我们假设所有命名空间更改和容器启动runc都是以 root 用户身份执行的。实际上,unshare您可以通过创建一个新的用户命名空间来绕过 root 权限,从而让系统误以为您是 root 用户。但这对于本文而言并不特别重要。用户命名空间的工作原理并不十分直观,因此我会在参考文献中附上 Michael Kerrisk 关于此主题的讲座链接。

Docker 容器无需 root 权限运行的原因有所不同。有一个特殊的后台进程(守护进程)containerd以 root 权限运行,docker cli并将所有特权操作委托给它。

能力

标准用户的权限非常有限,而 root 用户拥有非常广泛的权限。然而,以 root 身份运行的进程通常并不需要完整的 root 权限。能力机制的出现正是为了限制 root 用户的权限。这是一种限制进程及其子进程可以执行的特权系统操作的方法。

本质上,它们将所有 root 权限划分为一系列单独的权限。权限通常用于细粒度的容器配置,并config.json根据 OCI 规范进行指定。这种机制非常具体,需要掌握许多 Linux 概念。我还会将 Michael Kerrisk 关于权限的讲座链接放在参考列表中。

在 Golang 中实现 Containy 容器工具

这里我就不详细讲解代码了,因为并非所有人都了解 Go 语言,也可能对此不感兴趣。

它类似于简化版的runC。它还能解析config.json(此处称为configy.json),并基于解析结果创建一个容器。当然,该工具并未实现 OCI 规范,因为它最初只是一个“学术玩具”。

Containy可以在新的命名空间中启动进程,并为其配置 cgroup 限制。它还可以使用overlayfs和配置标准挂载点,例如/proc。

结论

从这篇关于容器化机制的文章中可以得出的主要结论是,该领域的一切都基于 Linux 内核的标准和功能;其中没有任何魔法成分。

本文讨论了以下内容:

  1. Chroot是第一个流行的在文件系统上下文中隔离进程的机制;

  2. 命名空间作为一种机制,代表了进程获取资源的需求与资源本身之间的一个层;

  3. Cgroups作为另一种进程隔离机制,但要结合系统的物理资源;

  4. OverlayFS通过巧妙地操作容器文件系统来节省空间。最重要的是,我们明白了为什么 Docker 像一个分层蛋糕 :)

  5. OCI 标准是整个现代容器基础设施和 runC 工具的基础,而 runC 工具是 OCI 的参考实现;

  6. 我们还深入体验了 Docker,发现容器并非什么陌生的东西,而只是一个进程。在启动容器之前,Docker 会预先配置好我们在文章中讨论的所有内容。我们也了解了镜像和容器在主机系统上的存储位置和方式。

我希望你不仅对 Docker 有了很多了解,而且对 Linux 也有了更深入的认识。

更多推荐