容器 -OS 内核知识 ¶
约 4352 个字 35 行代码 预计阅读时间 22 分钟
容器技术起源于 Linux,是一种内核虚拟化技术,提供轻量级的虚拟化,以便隔离进程和资源。相较于虚拟机,容器更轻量,启动更快,资源占用更少,更适合于云环境。
容器本质上是宿主机上的进程。例如最常见的容器引擎 Docker 通过 namespace 实现了资源隔离,通过 cgroups 实现了资源限制,通过写时复制机制实现了高效的文件操作。本文主要学习容器层所利用的操作系统内核机制。
namespace¶
Linux Namespace 提供了一种内核级别隔离系统资源的方法,通过将系统的全局资源放在不同的 Namespace 中,来实现资源隔离的目的。不同 Namespace 的程序,可以享有一份独立的系统资源。目前 Linux 中提供了六类系统资源的隔离机制,分别是:
Namespace | 系统调用参数 | 隔离内容 |
---|---|---|
UTS | CLONE_NEWUTS | 主机名与域名 |
IPC | CLONE_NEWIPC | 信号量、消息队列和共享内存 |
PID | CLONE_NEWPID | 进程编号 |
Network | CLONE_NEWNET | 网络设备、网络栈、端口等等 |
Mount | CLONE_NEWNS | 挂载点(文件系统) |
User | CLONE_NEWUSER | 用户和用户组 |
Time | CLONE_NEWTIME | 系统时钟 |
从 Kernel 3.8 开始,用户可以在/proc/$pid/ns
文件中找到进程的 Namespace 信息,例如:
上述文件都是对应 namespace 的文件描述符,方括号中的值是 namespace 的 inode,若 inode 相同则两个进程处于同一 namespace 中。
namespace API¶
Linux 提供了一系列 Namespace API,包括 clone()、setns()、unshare() 和 /proc 下部分文件。
clone:
通过 clone 系统调用,创建一个新 Namespace 的进程,函数描述如下:
- child_func:子进程的入口函数
- child_stack:子进程的栈空间
- flags:控制子进程的特性,例如使用
CLONE_*
标志位创建新的 Namespace、是否与父进程共享虚拟内存等,包括 - CLONE_FILES: 子进程一般会共享父进程的文件描述符,如果子进程不想共享父进程的文件描述符了,可以通过这个 flag 来取消共享。
- CLONE_FS: 使当前进程不再与其他进程共享文件系统信息。
- CLONE_SYSVSEM: 取消与其他进程共享 SYS V 信号量。
- CLONE_NEW*(如前文表格): 创建新的 namespace,并将该进程加入进来。
- arg:传递给子进程的参数
setns:
setns()
函数可以把进程加入到指定的Namespace,函数描述如下:
- fd:Namespace 文件描述符
- nstype:Namespace 类型,可能的取值有:
- 0:可以加入任意的 namespace
- CLONE_NEWIPC:fd 必须指向 IPC namespace
- CLONE_NEWNET:fd 必须指向 network namespace
- CLONE_NEWNS:fd 必须指向 mount namespace
- CLONE_NEWPID:fd 必须指向 PID namespace
- CLONE_NEWUSER: fd 必须指向 user namespace
- CLONE_NEWUTS: fd 必须指向 UTS namespace
unshare: unshare()系统调用用于将当前进程和所在的Namespace分离,移入新创建的Namespace中。
- flags:与
clone
系统调用的 flags 参数相似
Tips
unshare() 和 setns() 系统调用对 PID Namespace 的处理与其它 namespace 不同。
unshare() 允许用户在原有进程中建立命名空间进行隔离。但是创建了 PID namespace 后,原先 unshare() 调用者进程并不进入新的 PID namespace,接下来创建的子进程才会进入新的 namespace,这个子进程也就随之成为新 namespace 中的 init 进程。
类似的,调用 setns() 创建新 PID namespace 时,调用者进程也不进入新的 PID namespace,而是随后创建的子进程进入。
这是因为调用进入新的 pid namespace 会导致 pid 变化。而对用户态的程序和库函数来说,他们都认为进程的 pid 是一个常量,pid 的变化会引起这些进程崩溃。
UTS namespace¶
UTS(UNIX Time-sharing System)namespace 提供了主机名和域名的隔离,这样每个 Docker 容器就可以拥有独立的主机名和域名,在网络上可以被视作一个独立的节点,而非宿主机上的一个进程。在 Docker 中,每个镜像基本都以自身所提供的服务名称来命名镜像的 hostname,且不会对宿主机产生任何影响,这就是 UTS namespace 的作用。
例如下面的代码,创建一个新的 UTS namespace,并在其中运行一个 bash shell:
#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 child_stack[STACK_SIZE];
char *const child_args[] = {
"/bin/bash",
NULL};
int child_main(void *args)
{
printf("在子进程中!\n");
sethostname("NewNamespace", 12);
execv(child_args[0], child_args);
return 1;
}
int main()
{
printf("程序开始: \n");
int child_pid = clone(child_main, child_stack + STACK_SIZE,
CLONE_NEWUTS | SIGCHLD, NULL);
waitpid(child_pid, NULL, 0);
printf("已退出\n");
return 0;
}
IPC namespace¶
进程间通信(Inter-Process Communication,IPC)涉及到的 IPC 资源包括常见的信号量、消息队列和共享内存。然而与虚拟机不同的是,容器内部进程间通信对宿主机来说,实际上是具有相同 PID namespace 中的进程间通信,因此需要一个唯一的标识符来进行区别。申请 IPC 资源就是申请了一个全局唯一的 32 位 ID,所以 IPC namespace 中实际上包含了系统 IPC 标识符以及实现 POSIX 消息队列的文件系统。在同一个 IPC namespace 下的进程彼此可见,不同 IPC namespace 下的进程则互相不可见。
目前使用 IPC namespace 机制的系统不多,其中比较有名的有 PostgreSQL。Docker 当前也使用 IPC namespace 实现了容器与宿主机、容器与容器之间的 IPC 隔离。
PID namespace¶
PID namespace 提供了进程编号的隔离,使得容器内部的进程可以拥有自己的 PID 空间,内核为所有的 PID namespace 维护了一个树状结构,最顶层的是系统初始时创建的,被称为 root namespace。它创建的新 PID namespace 就称为 child namespace(树的子节点
init 进程
在传统的 UNIX 系统中,PID 为 1 的进程是 init,地位非常特殊。它作为所有进程的父进程,维护一张进程表,不断检查进程的状态,一旦有某个子进程因为父进程错误成为了“孤儿”进程,init 就会负责回收这个子进程占用的资源并结束子进程。所以在要实现的容器中,启动的第一个进程也需要实现类似 init 的功能,维护所有后续启动进程的运行状态。
当系统中存在树状嵌套结构的 PID namespace 时,若某个子进程成为孤儿进程,收养该子进程的责任就交给了该子进程所属的 PID namespace 中的 init 进程。
信号
PID namespace 中的 init 进程拥有信号屏蔽。如果 init 中没有编写处理某个信号的代码逻辑,那么与 init 在同一个 PID namespace 下的进程(即使有超级权限)发送给它的该信号都会被屏蔽。这个功能的主要作用是防止 init 进程被误杀。
对于父节点中的进程发送给子节点 init 的信号,如果不是 SIGKILL(销毁进程)或 SIGSTOP(暂停进程)也会被忽略。但如果发送 SIGKILL 或 SIGSTOP,子节点的 init 会强制执行(无法通过代码捕捉进行特殊处理
mount namespace¶
mount namespace 通过隔离文件系统挂载点对隔离文件系统提供支持,它是历史上第一个 Linux namespace,所以它的标识位比较特殊,就是CLONE_NEWNS
。隔离后,不同 mount namespace 中的文件结构发生变化也互不影响。你可以通过/proc/$pid/mounts
查看到所有挂载在当前 namespace 中的文件系统,还可以通过/proc/$pid/mountstats
看到 mount namespace 中文件设备的统计信息,包括挂载文件的名字、文件系统类型、挂载位置等等。
进程在创建 mount namespace 时,会把当前的文件结构复制给新的 namespace。新 namespace 中的所有 mount 操作都只影响自身的文件系统,对外界不会产生任何影响。这样做法非常严格地实现了隔离。
Tips
对某些特殊情况,这种隔离并不适用。例如父节点 namespace 中的进程挂载了一张 CD-ROM,这时子节点 namespace 复制的目录结构是无法自动挂载上这张 CD-ROM,因为该操作会影响到父节点的文件系统。
挂载传播(mount propagation)解决了这个问题,挂载传播定义了挂载对象(mount object)之间的关系,这样的关系包括共享关系和从属关系,系统用这些关系决定任何挂载对象中的挂载事件如何传播到其他挂载对象。
- 共享关系(share relationship
) 。如果两个挂载对象具有共享关系,那么挂载对象中的挂载事件会互相传播。 - 从属关系(slave relationship
) 。如果两个挂载对象形成从属关系,只有 master 中的挂载事件会传播到 slave 挂载对象。
一个挂载状态可能为如下的其中一种:
- 共享挂载(shared)
- 从属挂载(slave)
- 私有挂载(private)
- 不可绑定挂载(unbindable)
既不传播也不接收传播事件的挂载对象称为私有挂载(private mount
Linux 挂载系统更细节的知识可以查看参考资料。
network namespace¶
network namespace 主要提供了关于网络资源的隔离,包括网络设备、IPv4 和 IPv6 协议栈、IP 路由表、防火墙、/proc/net
目录、/sys/class/net
目录、socket 等等。一个物理的网络设备最多存在在一个 network namespace 中,可以通过创建 veth pair(虚拟网络设备对:类似管道,使数据互相传播)在不同的 network namespace 间创建通道,以此达到通信的目的。
一般情况下,物理网络设备都分配在最初的 root namespace(表示系统默认的 namespace)中。如果有多块物理网卡,也可以把其中一块或多块分配给新创建的 network namespace。需要注意的是,当新创建的 network namespace 被释放时(所有内部的进程都终止并且 namespace 文件没有被挂载或打开
user namespace¶
user namespace 主要隔离了安全相关的标识符(identifiers)和属性(attributes
cgroups¶
cgroups(Control Groups)最初叫 Process Container,由 Google 工程师(Paul Menage 和 Rohit Seth)于 2006 年提出,后来因为 Container 有多重含义容易引起误解,就在 2007 年更名为 Control Groups,并被整合进 Linux 内核。顾名思义就是把进程放到一个组里面统一加以控制。官方的定义如下:
cgroups 是 Linux 内核提供的一种机制,这种机制可以根据需求把一系列系统任务及其子任务整合(或分隔)到按资源划分等级的不同组内,从而为系统资源管理提供一个统一的框架。
通俗的来说,cgroups 可以限制、记录任务组使用的物理资源(包括:CPU、Memory、IO 等
对开发者来说,cgroups 有如下四个特点:
- cgroups 的 API 以一个伪文件系统的方式实现,用户态的程序可以通过文件操作实现 cgroups 的组织管理。
- cgroups 的组织管理操作单元可以细粒度到线程级别,用户态代码可以创建和销毁 cgroup,从而实现资源再分配和管理。
- 所有资源管理的功能都以子系统的方式实现,接口统一。
- 子进程创建之初与其父进程处于同一个 cgroups 的控制组。
本质上来说,cgroups 是内核附加在程序上的一系列 hook,通过程序运行时对资源的调度触发相应的钩子以达到资源追踪和限制的目的。
相关术语:
- task(任务
) :在 cgroups 的术语中,任务表示系统的一个进程或线程。 - cgroup(控制组
) :cgroups 中的资源控制都以 cgroup 为单位实现。cgroup 表示按某种资源控制标准划分而成的任务组,包含一个或多个子系统。一个任务可以加入某个 cgroup,也可以从某个 cgroup 迁移到另外一个 cgroup。 - subsystem(子系统
) :cgroups 中的子系统就是一个资源调度控制器。比如 CPU 子系统可以控制 CPU 时间分配,内存子系统可以限制 cgroup 内存使用量。 - 子系统实际上就是 cgroups 的资源控制系统,每种子系统独立地控制一种资源,目前 Docker 使用如下九种子系统,其中,net_cls 任务子系统在内核中已经广泛实现,但是 Docker 尚未使用。以下是它们的用途:
- blkio:任务可以为块设备设定输入 / 输出限制,比如物理驱动设备(包括磁盘、固态硬盘、USB 等
) 。 - cpu:任务使用调度程序控制任务对 CPU 的使用。
- cpuacct:自动生成 cgroup 中任务对 CPU 资源使用情况的报告。
- cpuset:可以为 cgroup 中的任务分配独立的 CPU(此处针对多处理器系统)和内存。
- devices:可以开启或关闭 cgroup 中任务对设备的访问。
- freezer:可以挂起或恢复 cgroup 中的任务。
- memory:可以设定 cgroup 中任务对内存使用量的限定,并且自动生成这些任务对内存资源使用情况的报告。
- perfevent:使用后使 cgroup 中的任务可以进行统一的性能测试。
- net_cls:Docker 没有直接使用它,它通过使用等级识别符 (classid) 标记网络数据包,从而允许 Linux 流量控制程序(Traffic Controller,TC)识别从具体 cgroup 中生成的数据包。
- blkio:任务可以为块设备设定输入 / 输出限制,比如物理驱动设备(包括磁盘、固态硬盘、USB 等
- hierarchy(层级
) :层级由一系列 cgroup 以一个树状结构排列而成,每个层级通过绑定对应的子系统进行资源控制。层级中的 cgroup 节点可以包含零或多个子节点,子节点继承父节点挂载的属性。整个系统可以有多个层级。
cgroups 作用 ¶
实现 cgroups 的主要目的是为不同用户层面的资源管理,提供一个统一化的接口。从单个进程的资源控制到操作系统层面的虚拟化。cgroups 提供了以下四大功能:
- 资源限制:cgroups 可以对任务使用的资源总额进行限制。如设定应用运行时使用内存的上限,一旦超过这个配额就发出 OOM(Out of Memory
) 。 - 优先级分配:通过分配的 CPU 时间片数量及硬盘 IO 带宽大小,实际上就相当于控制了任务运行的优先级。
- 资源统计: cgroups 可以统计系统的资源使用量,如 CPU 使用时长、内存用量等等,这个功能非常适用于计费。
- 任务控制:cgroups 可以对任务执行挂起、恢复等操作。
过去内核开发者甚至把 namespace 也作为一个 cgroups 的子系统加入进来,也就是说 cgroups 曾经甚至还包含了资源隔离的能力。但是资源隔离会给 cgroups 带来许多问题,如 pid namespace 加入后,PID 在循环出现的时候,cgroup 会出现了命名冲突、cgroup 创建后进入新的 namespace 导致其他子系统资源脱离了控制等等,所以在 2011 年就被移除了。
组织结构 ¶
系统中的多个 cgroup 是由多个 hierarchy 构成的森林,在 Doccker 中,为了方便管理,每个子系统独自控制一个 hierarchy。
其中 cgroups 的管理遵循如下规则:
- 同一个层级可以附加多个子系统,例如 CPU 和 Memory 子系统可以同时附加到同一个 hierarchy 上。
- 一个子系统可以管理多个 hierarchy,当且仅当子系统是这些 hierarchy 的唯一一个子系统。
- 系统每次新建 hierarchy 时,会自动创建默认包含该系统上的所有任务的 cgroup(root cgroup
) 。一个任务在一个层级中只能属于一个 cgroup,但可以属于不同层级的多个 cgroups。 - 任务在 fork/clone 自身时创建的子任务默认与原任务在同一个 cgroup 中,但是子任务允许被移动到不同的 cgroup 中。
参考资料 ¶
创建日期: 2024年10月14日 20:55:39