分离焦虑:使用 Linux 命名空间隔离系统的教程
已发表: 2022-03-11随着 Docker、Linux 容器等工具的出现,将 Linux 进程隔离到它们自己的小系统环境中变得非常容易。 这使得在一台真正的 Linux 机器上运行一系列应用程序成为可能,并确保它们中的任何一个都不会相互干扰,而不必求助于使用虚拟机。 这些工具对 PaaS 提供商来说是一个巨大的福音。 但是引擎盖下到底发生了什么?
这些工具依赖于 Linux 内核的许多特性和组件。 其中一些功能是最近才引入的,而其他功能仍然需要您修补内核本身。 但自 2008 年 2.6.24 版发布以来,使用 Linux 命名空间的关键组件之一一直是 Linux 的一项功能。
任何熟悉chroot
的人都已经对 Linux 命名空间可以做什么以及如何使用命名空间有了基本的了解。 正如chroot
允许进程将任意目录视为系统的根目录(独立于其余进程),Linux 命名空间也允许独立修改操作系统的其他方面。 这包括进程树、网络接口、挂载点、进程间通信资源等等。
为什么使用命名空间进行进程隔离?
在单用户计算机中,单个系统环境可能没问题。 但是在您想要运行多个服务的服务器上,服务之间尽可能地相互隔离对于安全性和稳定性至关重要。 想象一个运行多个服务的服务器,其中一个被入侵者入侵。 在这种情况下,入侵者可能能够利用该服务并以他的方式访问其他服务,甚至可能危及整个服务器。 命名空间隔离可以提供一个安全的环境来消除这种风险。
例如,使用命名空间,可以在您的服务器上安全地执行任意或未知的程序。 最近,编程竞赛和“黑客马拉松”平台越来越多,例如 HackerRank、TopCoder、Codeforces 等等。 他们中的许多人利用自动化管道来运行和验证参赛者提交的程序。 往往无法提前知道参赛者节目的真实性质,有的甚至可能含有恶意成分。 通过在与系统其余部分完全隔离的情况下运行这些命名空间的程序,可以测试和验证软件,而不会使机器的其余部分处于危险之中。 同样,在线持续集成服务,例如 Drone.io,会自动获取您的代码存储库并在自己的服务器上执行测试脚本。 同样,命名空间隔离使安全地提供这些服务成为可能。
像 Docker 这样的命名空间工具还可以更好地控制进程对系统资源的使用,使得此类工具非常适合 PaaS 提供商使用。 Heroku 和 Google App Engine 等服务使用此类工具在同一真实硬件上隔离和运行多个 Web 服务器应用程序。 这些工具允许他们运行每个应用程序(可能已由多个不同用户中的任何一个部署),而不必担心其中一个使用过多的系统资源,或干扰和/或与同一台机器上的其他部署服务发生冲突。 通过这样的进程隔离,甚至可以为每个隔离环境拥有完全不同的依赖软件(和版本)堆栈!
如果您使用过像 Docker 这样的工具,那么您已经知道这些工具能够将进程隔离在小型“容器”中。 在 Docker 容器中运行进程就像在虚拟机中运行它们,只是这些容器比虚拟机轻得多。 虚拟机通常在操作系统之上模拟硬件层,然后在其之上运行另一个操作系统。 这允许您在虚拟机中运行进程,与您的真实操作系统完全隔离。 但是虚拟机很重! 另一方面,Docker 容器使用真实操作系统的一些关键特性,包括命名空间,并确保类似级别的隔离,但无需模拟硬件并在同一台机器上运行另一个操作系统。 这使它们非常轻巧。
进程命名空间
从历史上看,Linux 内核一直维护一个单一的进程树。 该树包含对当前在父子层次结构中运行的每个进程的引用。 一个进程,如果它有足够的权限并且满足某些条件,它可以通过附加一个跟踪器来检查另一个进程,甚至可以杀死它。
随着 Linux 命名空间的引入,拥有多个“嵌套”进程树成为可能。 每个进程树都可以有一组完全隔离的进程。 这可以确保属于一个进程树的进程不能检查或杀死 - 事实上甚至不知道存在 - 其他兄弟或父进程树中的进程。
每次使用 Linux 的计算机启动时,它只启动一个进程,进程标识符 (PID) 为 1。此进程是进程树的根,它通过执行适当的维护工作并启动系统的其余部分来启动正确的守护进程/服务。 所有其他进程在树中的该进程下方开始。 PID 命名空间允许使用自己的 PID 1 进程衍生出一棵新树。 执行此操作的进程保留在原始树中的父名称空间中,但使子进程成为其自己进程树的根。
使用 PID 命名空间隔离,子命名空间中的进程无法知道父进程的存在。 但是,父命名空间中的进程具有子命名空间中进程的完整视图,就好像它们是父命名空间中的任何其他进程一样。
可以创建一组嵌套的子命名空间:一个进程在新的 PID 命名空间中启动一个子进程,该子进程在新的 PID 命名空间中生成另一个进程,依此类推。
随着 PID 命名空间的引入,单个进程现在可以有多个与之关联的 PID,一个对应于它所属的每个命名空间。 在 Linux 源代码中,我们可以看到一个名为pid
的结构,它过去只跟踪一个 PID,现在通过使用一个名为upid
的结构来跟踪多个 PID:
struct upid { int nr; // the PID value struct pid_namespace *ns; // namespace where this PID is relevant // ... }; struct pid { // ... int level; // number of upids struct upid numbers[0]; // array of upids };
要创建一个新的 PID 命名空间,必须使用特殊标志CLONE_NEWPID
调用clone()
系统调用。 (C 提供了一个包装器来公开这个系统调用,许多其他流行语言也是如此。)虽然下面讨论的其他命名空间也可以使用unshare()
系统调用创建,但 PID 命名空间只能在一个新的进程是使用clone()
生成的。 一旦使用此标志调用clone()
,新进程将立即在新进程树下的新 PID 命名空间中启动。 这可以用一个简单的 C 程序来演示:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("PID: %ld\n", (long)getpid()); return 0; } int main() { pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); printf("clone() = %ld\n", (long)child_pid); waitpid(child_pid, NULL, 0); return 0; }
以 root 权限编译并运行此程序,您会注意到类似以下的输出:
clone() = 5304 PID: 1
从child_fn
中打印的 PID 将为1
。
尽管上面的这个命名空间教程代码在某些语言中并不比“Hello, world”长多少,但在幕后发生了很多事情。 正如您所料, clone()
函数通过克隆当前进程创建了一个新进程,并在child_fn()
函数的开头开始执行。 但是,在这样做的同时,它将新进程从原始进程树中分离出来,并为新进程创建了一个单独的进程树。
尝试将static int child_fn()
函数替换为以下内容,以从隔离进程的角度打印父 PID:
static int child_fn() { printf("Parent PID: %ld\n", (long)getppid()); return 0; }
这次运行程序会产生以下输出:
clone() = 11449 Parent PID: 0
请注意,从隔离进程的角度来看,父 PID 为 0,表示没有父进程。 再次尝试运行相同的程序,但这一次,从clone()
函数调用中删除CLONE_NEWPID
标志:
pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);
这一次,你会注意到父 PID 不再是 0:
clone() = 11561 Parent PID: 11560
然而,这只是我们教程的第一步。 这些进程仍然可以不受限制地访问其他公共或共享资源。 例如,网络接口:如果上面创建的子进程要监听端口 80,它将阻止系统上的所有其他进程能够监听它。
Linux 网络命名空间
这就是网络命名空间变得有用的地方。 网络命名空间允许这些进程中的每一个看到一组完全不同的网络接口。 甚至每个网络命名空间的环回接口都不同。
将一个进程隔离到它自己的网络命名空间中涉及向clone()
函数调用引入另一个标志: CLONE_NEWNET
;
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
输出:
Original `net` Namespace: 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 2: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000 link/ether 00:24:8c:a1:ac:e7 brd ff:ff:ff:ff:ff:ff New `net` Namespace: 1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
这里发生了什么? 物理以太网设备enp4s0
属于全局网络命名空间,如从该命名空间运行的“ip”工具所示。 但是,物理接口在新的网络命名空间中不可用。 此外,环回设备在原始网络命名空间中处于活动状态,但在子网络命名空间中“关闭”。

为了在子命名空间中提供可用的网络接口,有必要设置跨越多个命名空间的附加“虚拟”网络接口。 一旦完成,就可以创建以太网桥,甚至在命名空间之间路由数据包。 最后,为了使整个事情正常工作,必须在全局网络命名空间中运行一个“路由进程”,以接收来自物理接口的流量,并通过适当的虚拟接口将其路由到正确的子网络命名空间。 也许您可以看到为什么像 Docker 这样为您完成所有这些繁重工作的工具如此受欢迎!
为此,您可以通过从父命名空间运行单个命令,在父命名空间和子命名空间之间创建一对虚拟以太网连接:
ip link add name veth0 type veth peer name veth1 netns <pid>
此处, <pid>
应替换为父级观察到的子命名空间中进程的进程 ID。 运行此命令会在这两个命名空间之间建立类似管道的连接。 父命名空间保留veth0
设备,并将veth1
设备传递给子命名空间。 任何进入一端的东西,都会从另一端出来,就像您对两个真实节点之间的真实以太网连接所期望的一样。 因此,必须为该虚拟以太网连接的两端分配 IP 地址。
挂载命名空间
Linux 还为系统的所有挂载点维护了一个数据结构。 它包括诸如挂载哪些磁盘分区、挂载位置、是否为只读等信息。 使用 Linux 命名空间,可以克隆这种数据结构,以便不同命名空间下的进程可以更改挂载点,而不会相互影响。
创建单独的挂载命名空间具有类似于执行chroot()
的效果。 chroot()
很好,但它不提供完全隔离,而且它的作用仅限于根挂载点。 创建单独的挂载命名空间允许这些隔离进程中的每一个对整个系统的挂载点结构具有与原始视图完全不同的视图。 这允许您为每个隔离进程以及特定于这些进程的其他挂载点拥有不同的根。 根据本教程小心使用,您可以避免暴露有关底层系统的任何信息。
实现此目的所需的clone()
标志是CLONE_NEWNS
:
clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)
最初,子进程看到与其父进程完全相同的挂载点。 但是,在新的挂载命名空间下,子进程可以挂载或卸载它想要的任何端点,并且更改不会影响其父命名空间,也不会影响整个系统中的任何其他挂载命名空间。 例如,如果父进程在根目录挂载了一个特定的磁盘分区,则隔离进程将在开始时看到完全相同的磁盘分区挂载在根目录。 但是,当隔离进程尝试将根分区更改为其他内容时,隔离挂载命名空间的好处是显而易见的,因为更改只会影响隔离的挂载命名空间。
有趣的是,这实际上使得直接使用CLONE_NEWNS
标志生成目标子进程不是一个好主意。 更好的方法是使用CLONE_NEWNS
标志启动一个特殊的“init”进程,让该“init”进程根据需要更改“/”、“/proc”、“/dev”或其他挂载点,然后启动目标进程. 这将在本命名空间教程的结尾部分更详细地讨论。
其他命名空间
这些进程还可以被隔离到其他命名空间中,即用户、IPC 和 UTS。 用户命名空间允许进程在命名空间内拥有 root 权限,而不会授予它访问命名空间之外的进程的权限。 通过 IPC 命名空间隔离进程为其提供了自己的进程间通信资源,例如 System V IPC 和 POSIX 消息。 UTS 命名空间隔离了系统的两个特定标识符: nodename
和domainname
。
一个简单的例子展示了如何隔离 UTS 命名空间:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/utsname.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static void print_nodename() { struct utsname utsname; uname(&utsname); printf("%s\n", utsname.nodename); } static int child_fn() { printf("New UTS namespace nodename: "); print_nodename(); printf("Changing nodename inside new UTS namespace\n"); sethostname("GLaDOS", 6); printf("New UTS namespace nodename: "); print_nodename(); return 0; } int main() { printf("Original UTS namespace nodename: "); print_nodename(); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWUTS | SIGCHLD, NULL); sleep(1); printf("Original UTS namespace nodename: "); print_nodename(); waitpid(child_pid, NULL, 0); return 0; }
该程序产生以下输出:
Original UTS namespace nodename: XT New UTS namespace nodename: XT Changing nodename inside new UTS namespace New UTS namespace nodename: GLaDOS Original UTS namespace nodename: XT
在这里, child_fn()
打印nodename
,将其更改为其他内容,然后再次打印。 自然,更改只发生在新的 UTS 命名空间内。
有关所有命名空间提供和隔离的更多信息,请参见此处的教程
跨命名空间通信
通常有必要在父命名空间和子命名空间之间建立某种通信。 这可能是为了在一个孤立的环境中进行配置工作,也可能只是为了保留从外部窥视该环境状况的能力。 一种方法是让 SSH 守护程序在该环境中运行。 您可以在每个网络命名空间内拥有一个单独的 SSH 守护程序。 但是,运行多个 SSH 守护程序会占用大量宝贵的资源,例如内存。 这就是再次证明有一个特殊的“init”过程是一个好主意的地方。
“init”进程可以在父命名空间和子命名空间之间建立通信通道。 该通道可以基于 UNIX 套接字,甚至可以使用 TCP。 要创建跨越两个不同挂载命名空间的 UNIX 套接字,您需要首先创建子进程,然后创建 UNIX 套接字,然后将子进程隔离到单独的挂载命名空间中。 但是我们怎样才能先创建流程,然后再隔离呢? Linux 提供unshare()
。 这个特殊的系统调用允许进程将自己与原始命名空间隔离,而不是让父进程首先隔离子进程。 例如,以下代码与之前在网络命名空间部分中提到的代码具有完全相同的效果:
#define _GNU_SOURCE #include <sched.h> #include <stdio.h> #include <stdlib.h> #include <sys/wait.h> #include <unistd.h> static char child_stack[1048576]; static int child_fn() { // calling unshare() from inside the init process lets you create a new namespace after a new process has been spawned unshare(CLONE_NEWNET); printf("New `net` Namespace:\n"); system("ip link"); printf("\n\n"); return 0; } int main() { printf("Original `net` Namespace:\n"); system("ip link"); printf("\n\n"); pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL); waitpid(child_pid, NULL, 0); return 0; }
由于“init”进程是你设计的,你可以让它先完成所有必要的工作,然后在执行目标子进程之前将它自己与系统的其余部分隔离开来。
结论
本教程只是对如何在 Linux 中使用命名空间的概述。 它应该让您了解 Linux 开发人员如何开始实施系统隔离,这是 Docker 或 Linux 容器等工具架构的一个组成部分。 在大多数情况下,最好只使用这些现有工具中的一种,这些工具已经众所周知并经过测试。 但在某些情况下,拥有自己的自定义进程隔离机制可能是有意义的,在这种情况下,本命名空间教程将极大地帮助您。
幕后发生的事情比我在本文中介绍的要多得多,并且您可能希望通过更多方式限制目标进程以增加安全性和隔离性。 但是,希望这对于有兴趣了解更多关于 Linux 命名空间隔离如何真正起作用的人来说是一个有用的起点。