分离焦虑(使用Linux命名空间隔离系统的教程)

本文概述

  • 为什么使用命名空间进行进程隔离?
  • 进程命名空间
  • Linux网络命名空间
  • 挂载命名空间
  • 其他命名空间
  • 跨命名空间通信
  • 总结
随着Docker, Linux Containers等工具的出现, 将Linux进程隔离到自己的小型系统环境中已变得非常容易。这样就可以在一台真正的Linux机器上运行所有应用程序, 并确保其中两个应用程序之间不会相互干扰, 而不必诉诸使用虚拟机。这些工具对PaaS提供商是一个巨大的福音。但是幕后到底发生了什么?
这些工具依赖于Linux内核的许多功能和组件。其中一些功能是最近才引入的, 而其他功能仍然需要你修补内核本身。但是自2008年发布2.6.24版本以来, 使用Linux名称空间的关键组件之一一直是Linux的功能。
熟悉chroot的任何人都已经基本了解Linux命名空间可以做什么以及通常如何使用命名空间。就像chroot允许进程将任何目录视为系统的根目录一样(独立于其余进程), Linux名称空间也允许操作系统的其他方面也被独立修改。这包括进程树, 网络接口, 安装点, 进程间通信资源等。
为什么使用命名空间进行进程隔离? 在单用户计算机中, 单个系统环境可能很好。但是, 在要运行多个服务的服务器上, 对于服务的安全性和稳定性至关重要的是, 这些服务应尽可能彼此隔离。想象一台服务器运行着多种服务, 其中一项被入侵者破坏。在这种情况下, 入侵者可能能够利用该服务并将自己的方式用于其他服务, 甚至可能损害整个服务器。命名空间隔离可以提供一个安全的环境来消除这种风险。
例如, 使用命名空间, 可以安全地在服务器上执行任意或未知程序。最近, 越来越多的编程竞赛和” hackathon” 平台, 例如HackerRank, TopCoder, Codeforces等。他们中的许多人利用自动化管道来运行和验证参赛者提交的程序。通常不可能事先知道参赛者程序的真实性质, 有些甚至可能包含恶意元素。通过以完全与系统其余部分完全隔离的方式运行这些命名空间的程序, 可以对软件进行测试和验证, 而不会使其余机器受到威胁。同样, 在线连续集成服务(例如Drone.io)会自动获取你的代码存储库并在其自己的服务器上执行测试脚本。同样, 名称空间隔离使安全地提供这些服务成为可能。
诸如Docker之类的命名空间工具还可以更好地控制进程对系统资源的使用, 从而使此类工具在PaaS提供商中非常受欢迎。诸如Heroku和Google App Engine之类的服务使用此类工具在相同的真实硬件上隔离并运行多个Web服务器应用程序。这些工具允许他们运行每个应用程序(可能已由许多不同用户部署), 而不必担心其中一个使用过多的系统资源, 或与同一台计算机上的其他已部署服务干扰和/或冲突。通过这样的进程隔离, 甚至可以为每个隔离的环境提供完全不同的依赖软件堆栈(和版本)!
如果你使用过Docker之类的工具, 那么你已经知道这些工具能够隔离小型” 容器” 中的进程。在Docker容器中运行进程就像在虚拟机中运行进程一样, 只有这些容器比虚拟机轻得多。虚拟机通常在操作系统之上模拟硬件层, 然后在该之上运行另一个操作系统。这样, 你就可以与虚拟机完全隔离, 在虚拟机中运行进程。但是虚拟机很重!另一方面, Docker容器使用实际操作系统的一些关键功能(包括名称空间), 并确保相似的隔离级别, 但无需模拟硬件并在同一台机器上运行另一个操作系统。这使它们非常轻巧。
进程命名空间 从历史上看, Linux内核只维护一个进程树。该树包含对当前在父子层次结构中运行的每个进程的引用。如果一个进程具有足够的特权并满足某些条件, 则可以通过将跟踪程序附加到另一个进程来检查另一个进程, 甚至可以杀死它。
随着Linux名称空间的引入, 拥有多个” 嵌套” 进程树成为可能。每个进程树可以具有一组完全隔离的进程。这可以确保属于一个进程树的进程无法检查或杀死-实际上甚至无法知道其他兄弟进程树或父进程树中进程的存在。
每次使用Linux的计算机启动时, 它仅以一个进程启动, 该进程带有进程标识符(PID)1。此进程是进程树的根, 它通过执行适当的维护工作并启动来启动系统的其余部分。正确的守护程序/服务。所有其他进程都在树中此进程下方开始。 PID名称空间允许使用它自己的PID 1进程衍生出一棵新树。执行此操作的进程保留在原始树的父名称空间中, 但使子进程成为其自己的进程树的根。
使用PID名称空间隔离后, 子名称空间中的进程将无法知道父进程的存在。但是, 父名称空间中的进程具有子名称空间中进程的完整视图, 就像它们是父名称空间中的任何其他进程一样。
分离焦虑(使用Linux命名空间隔离系统的教程)

文章图片
【分离焦虑(使用Linux命名空间隔离系统的教程)】可以创建一组嵌套的子名称空间:一个进程在新的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()函数的开头开始执行。但是, 这样做时, 它将新流程与原始流程树分离, 并为新流程创建了单独的流程树。
尝试将静态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:ffNew `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这样的工具为你如此繁重的工作如此受欢迎!
分离焦虑(使用Linux命名空间隔离系统的教程)

文章图片
为此, 你可以通过从父名称空间运行单个命令来在父名称空间和子名称空间之间创建一对虚拟以太网连接:
ip link add name veth0 type veth peer name veth1 netns < pid>

在这里, < pid> 应该由父名称空间观察到的子名称空间中的进程的进程ID替换。运行此命令将在这两个名称空间之间建立类似管道的连接。父名称空间保留veth0设备, 并将veth1设备传递给子名称空间。进入一端的任何事物都会通过另一端出现, 就像你从两个真实节点之间的真实以太网连接中所期望的那样。因此, 必须为该虚拟以太网连接的两侧分配IP地址。
挂载命名空间 Linux还为系统的所有安装点维护数据结构。它包括诸如以下信息:安装了哪些磁盘分区, 安装了磁盘分区, 是否为只读等等。使用Linux名称空间, 可以克隆此数据结构, 以便位于不同名称空间下的进程可以更改安装点而不会互相影响。
创建单独的安装名称空间的作用与执行chroot()相似。 chroot()很好, 但是它不能提供完全的隔离, 它的作用仅限于根安装点。创建一个单独的挂载名称空间, 可使每个隔离的进程对整个系统的挂载点结构的视图与原始视图完全不同。这样, 你可以为每个隔离的进程以及这些进程特定的其他安装点使用不同的根。按照本教程谨慎使用, 可以避免公开有关基础系统的任何信息。
分离焦虑(使用Linux命名空间隔离系统的教程)

文章图片
实现此操作所需的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命名空间隔离了系统的两个特定标识符:节点名和域名。
下面显示了一个简单的示例, 展示了如何隔离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()打印节点名称, 将其更改为其他名称, 然后再次打印。自然, 更改仅发生在新的UTS名称空间内部。
有关所有名称空间提供和隔离的信息, 请参见此处的教程。
跨命名空间通信 通常, 有必要在父名称空间和子名称空间之间建立某种通信。这可能是为了在隔离的环境中进行配置工作, 或者仅仅是保持从外部窥视该环境条件的能力。一种方法是使SSH守护程序保持在该环境中运行。每个网络名称空间中都可以有一个单独的SSH守护程序。但是, 运行多个SSH守护程序会占用大量宝贵资源, 例如内存。在这里, 采用特殊的” 初始化” 过程再次被证明是个好主意。
” 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 Containers等工具体系结构的组成部分。在大多数情况下, 最好只使用这些已经众所周知并且经过测试的现有工具之一。但是在某些情况下, 拥有自己的自定义流程隔离机制可能很有意义, 在这种情况下, 此名称空间教程将极大地帮助你。
除了我在本文中介绍的内容外, 还有很多其他事情要做, 并且你可能想通过更多方法来限制目标流程, 以提高安全性和隔离性。但是, 希望这对于那些有兴趣进一步了解Linux命名空间隔离的工作原理的人来说是一个有用的起点。

    推荐阅读