What Are Namespaces and cgroups, and How Do They Work?

本文属于我们的容器技术系列:

最近,我一直在研究 NGINX Unit,这是我们的开源多语言应用服务器。在研究中,我注意到 Unit 同时支持命名空间和 cgroups,从而实现进程隔离。本文将介绍这两项重要的 Linux 技术,它们也是容器的基础。

容器及 Docker、Kubernetes 等相关工具已经出现一段时间,改变了现代应用环境中软件的开发与交付方式。容器让每个软件都能在各自隔离的环境中快速部署和运行,而无需分别创建虚拟机(VM)。

大多数人可能很少思考容器底层如何工作,但我认为理解底层技术很重要,因为这能帮助我们做出决策。对我个人而言,弄清事物如何运作本身就很令人愉快!

什么是命名空间?

命名空间大约从2002年起就是 Linux 内核的一部分。此后,相关工具和命名空间类型不断增加。不过,Linux 内核直到2013年才加入真正的容器支持,这让命名空间变得格外有用,并得到广泛使用。

命名空间到底是什么?下面是 维基百科 中较为完整的定义:

“命名空间是 Linux 内核的一项功能,可以划分内核资源,使一组进程看到一组资源,而另一组进程看到另一组资源。”

换言之,命名空间的关键作用是让进程彼此隔离。在运行多种服务的服务器上,将每项服务及相关进程与其他服务隔离,可以缩小变更的影响范围,也能减少安全问题波及的范围。更主要的是,服务隔离符合 Martin Fowler 所描述的微服务架构风格。

在开发中使用容器,会让开发者获得一个外观和体验都像完整虚拟机的隔离环境。但它不是虚拟机,而是运行在某台服务器上的进程。如果启动两个容器,就会有两个进程运行在同一台服务器上,只是彼此隔离。

命名空间的类型

Linux 内核包含不同类型的命名空间,每种都有各自的特性:

  • 一个用户命名空间拥有自己的用户 ID 和组 ID 集合,可以分配给进程。这尤其意味着,进程可以在自己的用户命名空间内拥有 root 权限,而在其他用户命名空间中没有该权限。
  • 一个进程 ID(PID)命名空间为进程分配一套独立于其他命名空间的 PID。新命名空间中的第一个进程的 PID 为1,子进程依次获得后续 PID。如果子进程创建了自己的 PID 命名空间,它在该空间中也有 PID 1,同时还保留在父进程命名空间中的 PID。参见下文的示例。
  • 一个网络命名空间拥有独立的网络栈,包括自己的私有路由表、IP 地址集合、套接字列表、连接跟踪表、防火墙和其他网络资源。
  • 一个挂载命名空间拥有命名空间内进程所能看到的独立挂载点列表。因此,在该命名空间中挂载或卸载文件系统,不会影响宿主机文件系统。
  • 一个进程间通信(IPC)命名空间拥有自己的 IPC 资源,例如 POSIX 消息队列。
  • 一个UNIX 分时系统(UTS)命名空间允许单个系统向不同进程呈现不同的主机名和域名。

父子 PID 命名空间示例

下图包含三个 PID 命名空间:一个父命名空间和两个子命名空间。父命名空间中有四个进程,名称从 PID1 到 PID4。它们是普通进程,可以互相看到并共享资源。

在父命名空间中 PID 为 PID2 和 PID3 的子进程,也各自属于自己的 PID 命名空间,在其中 PID 为1。从子命名空间内部看,PID1 进程看不到外部任何进程。例如,两个子命名空间中的 PID1 都看不到父命名空间中的 PID4。

这就在不同命名空间中的进程之间提供了隔离。

父PID命名空间及两个子空间中的四个进程示意图

创建命名空间

了解理论之后,实际创建一个新命名空间来加深理解。Linux 的 unshare 命令是很好的起点。它的手册页表明,它正好能完成我们需要的操作:

NAME

          unshare – run program in new name namespaces

我目前以普通用户 svk 登录,该用户有自己的用户 ID、组等,但没有 root 权限:

svk $ id
uid=1000(svk) gid=1000(svk) groups=1000(svk) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c.1023

现在运行以下 unshare 命令,创建一个具有独立用户和 PID 命名空间的新命名空间。我把 root 用户映射到新空间,也就是说,我在新命名空间内拥有 root 权限;挂载一个新的 proc 文件系统,并在新空间中派生我的进程,这里是 bash。

svk $ unshare –user –pid –map-root-user –mount-proc –fork bash

(熟悉容器的读者会发现,这与在运行中的容器里执行 <runtime> exec -it </image> /bin/bash 命令达成同样的效果。)

ps -ef 命令显示有两个进程在运行:bash 和 ps 命令本身。id 命令确认我在新命名空间中是 root,变化后的命令提示符也反映了这一点:

root # ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 14:46 pts/0 00:00:00 bash
root 15 1 0 14:46 pts/0 00:00:00 ps -ef
root # id
uid=0(root) gid=0(root) groups=0(root) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c.1023

关键之处是,我只能看到自己命名空间内的两个进程,看不到系统上其他运行中的进程。我被完全隔离在自己的命名空间内。

从外部观察命名空间

虽然在命名空间内部看不到其他进程,但使用 lsns(列出命名空间)命令,可以从父命名空间,也就是新空间之外的视角,列出所有可用命名空间并查看信息。

输出显示三个命名空间,类型分别为 user、mnt 和 pid,对应前面 unshare 命令的参数。从外部视角看,各命名空间以用户 svk 运行,而不是 root;但空间内部的进程以 root 身份运行,可以访问所有预期资源。(为了便于阅读,输出被分成两行。)

root # lsns –output-all | head -1; lsns –output-all | grep svk
NS TYPE PATH NPROCS PID PPID …
4026532690 user /proc/97964/ns/user 2 97964 97944 …
4026532691 mnt /proc/97964/ns/mnt 2 97964 97944 …
4026532692 pid /proc/97965/ns/pid 1 97965 97964 …

… COMMAND UID USER
… unshare –user –map-root-user –fork –pid –mount-proc bash 1000 svk
… unshare –user –map-root-user –fork –pid –mount-proc bash 1000 svk
… bash 1000 svk

命名空间与容器

命名空间是构建容器的基础技术之一,用来强制隔离资源。前面展示了手动创建的方法,而 Docker、rkt 和 podman 等容器运行时会代为创建命名空间,让过程更加容易。同样,NGINX Unit 中的 isolation 应用对象会创建命名空间和 cgroups。

什么是 cgroups?

控制组(cgroup)是 Linux 内核的一项功能,可以限制、统计并隔离一组进程对 CPU、内存、磁盘 I/O、网络等资源的使用。

cgroups 提供以下能力:

  • 资源限制:可以配置 cgroup,限制进程能够使用的某项资源量,例如内存或 CPU。
  • 优先级:发生资源竞争时,可以控制某个进程相对于另一个 cgroup 内进程能够使用的 CPU、磁盘或网络资源量。
  • 统计:在 cgroup 层面监控和报告资源限制。
  • 控制:用一条命令改变 cgroup 中所有进程的状态,例如冻结、停止或重启。

基本上,cgroups 用来控制一个或一组进程可以访问或使用多少关键资源,包括 CPU、内存、网络和磁盘 I/O。它是容器的重要组成部分,因为一个容器内通常运行多个进程,需要一起管理。在 Kubernetes 环境中,cgroups 可用于实现 Pod 层面的资源请求和限制以及对应的 QoS 类别。

下图展示:把一定比例的可用系统资源分配给某个 cgroup,这里是 cgroup-1 后,剩余比例可供系统中的其他 cgroup 及单独进程使用。

cgroup分配系统资源比例的示意图

cgroup 版本

据 维基百科介绍,第一版 cgroups 在2007年底或2008年初合入 Linux 内核主线,而 cgroups-v2 的文档在2016年首次出现于 Linux 内核。v2 的许多变化中,主要变化包括显著简化的树形结构、cgroup 层级中的新功能和接口,以及更好地支持 UID 不为零的“无根”容器。

v2 中我最喜欢的新接口是 压力阻塞信息(PSI)。它能以比过去更细的粒度观察每个进程的内存使用与分配。这超出了本文范围,但很值得深入了解。

创建 cgroup

以下命令创建一个名为 foo 的 v1 cgroup,可从路径格式看出其版本,并将内存限制设为50,000,000 字节,即50 MB。

root # mkdir -p /sys/fs/cgroup/memory/foo
root # echo 50000000 > /sys/fs/cgroup/memory/foo/memory.limit_in_bytes

现在可以把进程分配给该 cgroup,使其受内存限制约束。我编写了一个名为 test.sh 的 shell 脚本,先向屏幕打印 cgroup testing tool,然后等待而不做其他事情。对这个实验而言,它就是一个持续运行、直到我手动停止的进程。

我在后台启动 test.sh,得到 PID 2428。脚本输出内容后,我把该 PID 写入 cgroup 文件 /sys/fs/cgroup/memory/foo/cgroup.procs,从而把进程加入 cgroup。

root # ./test.sh &
[1] 2428
root # cgroup testing tool
root # echo 2428 > /sys/fs/cgroup/memory/foo/cgroup.procs

为了验证进程确实受 cgroup foo 所定义的内存限制约束,我运行以下 ps 命令。-o cgroup 标志显示指定进程2428所属的 cgroup,输出确认其内存 cgroup 为 foo。

root # ps -o cgroup 2428
CGROUP
12:pids:/user.slice/user-0.slice/\
session-13.scope,10:devices:/user.slice,6:memory:/foo,…

默认情况下,进程超过所属 cgroup 定义的资源限制时,操作系统会终止该进程。

结语

命名空间和 cgroups 是容器及现代应用的基本构件。在把应用重构为更现代的架构时,理解它们的工作方式很重要。

命名空间隔离系统资源;cgroups 对这些资源提供精细控制,并强制执行限制。

容器并非使用命名空间和 cgroups 的唯一方式。它们的接口内建于 Linux 内核,因此其他应用也可以利用它们提供隔离与资源约束。

进一步了解 NGINX Unit,并下载源代码亲自试用。

原文配图
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容