[译] 通过 Linux 命名空间隔离你的系统焦虑

原文地址: https://www.toptal.com/linux/separation-anxiety-isolating-your-system-with-linux-namespaces

进程命名空间

历史上, Linux 通过持有一个单一的进程树. 这棵树使用亲-子结构的方式包含了所有正在运行程序的引用. 一个进程, 给予足够的权限和适合的条件, 可以检查或者结束另外一个线程.

随着 Linux 命名空间的引入, 拥有多个“嵌套”进程树成为可能. 每个进程可以拥有完全隔离的一组进程. 这确保了一个进程树的进程. 无法探查或者杀死其他兄弟或父进程树中的进程, 甚至无法察觉其他进程的存在.

每次启动Linux 的时候, 开始时是只有一个进程号为 1 的进程. 这个进程是进程树的根, 它通过执行适当的维护工作并启动正确的守护进程/服务来启动系统的其余部分. 所有其他进程在树中的该进程下方启动. PID 命名空间允许人们派生出一棵新树, 并拥有自己的 PID 1 进程.

执行此操作的进程保留在原始树中的父名称空间中, 但使子进程成为其自己的进程树的根. 通过 PID 命名空间隔离, 子命名空间中的进程无法知道父进程的存在.

但是, 父命名空间中的进程具有子命名空间中进程的完整视图, 就像它们是父命名空间中的任何其他进程一样:

创建一组嵌套的子命名空间也是可能的: 一个进程在新的 PID 命名空间中启动一个子进程, 并且该子进程在新的 PID 命名空间中生成另一个进程, 依此类推.

随着 PID 命名空间的引入, 单个进程现在可以有多个与其关联的 PID, 每个 PID 对应于它所属的每个命名空间.
在 Linux 源代码中, 我们可以看到名为 pid 的结构体过去仅跟踪单个 PID, 现在通过使用名为 upid 的结构体来跟踪多个 PID:

1
2
3
4
5
6
7
8
9
10
11
12
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 程序来演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#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 权限编译运行, 将会得到这样的输出:

1
2
clone() = 5304
PID: 1

child_fn 中打印的 PID 将会是 1.

尽管上面的命名空间教程代码并不比某些语言中的 “Hello, world” 长多少, 但幕后却发生了很多事情. 正如期望的那样 clone() 函数通过克隆当前进程创建了一个新进程, 并在 child_fn() 函数的开头开始执行. 然而, 在这样做的同时, 它将新进程从原始进程树中分离出来, 并为新进程创建了一个单独的进程树.

尝试用以下代码替换 static int child_fn() 函数, 以从隔离进程的角度打印父 PID:

1
2
3
4
static int child_fn() {
printf("Parent PID: %ld\n", (long)getppid());
return 0;
}

这次运行程序会产生以下输出:

1
2
clone() = 11449
Parent PID: 0

从隔离进程的角度来看, 父进程 PID 为 0, 表示没有父进程. 从 clone() 函数调用中删除 CLONE_NEWPID 标志并再次运行:

1
pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);

现在 PID 就不再是 0 了

1
2
clone() = 11561
Parent PID: 11560

不过这只是我们实现隔离的第一步. 这些进程仍然可以不受限制地访问其他共有或共享资源. 例如网络接口: 如果之前创建的子进程要侦听端口 80, 它将阻止系统上的所有其他进程侦听该端口.

网络命名空间

网络命名空间能让每个进程看到一组完全不同的网络接口. 甚至每个网络命名空间的环回接口也是不同的.

将进程隔离到其自己的网络命名空间需要向 clone() 函数调用引入另一个标志: CLONE_NEWNET

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#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;
}

输出:

1
2
3
4
5
6
7
8
9
10
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

怎么会这样? 就如 ip 工具所展示的, 物理以太网设备 enp4s0 属于全局网络命名空间.

不过, 物理接口在新的网络命名空间中不可用. 此外, 环回设备在原始网络命名空间中处于活动状态, 但在子网络命名空间中处于 "关闭" 状态.

为了在子命名空间中提供可用的网络接口, 有必要设置跨多个命名空间的额外 "虚拟" 网络接口. 一旦完成, 就可以创建以太网桥, 甚至可以在命名空间之间路由数据包.

最后, 为了使整个工作正常进行, 必须在全局网络命名空间中运行 "路由进程" 以接收来自物理接口的流量, 并通过适当的虚拟接口将其路由到正确的子网络命名空间.

下面是手动创建一对虚拟以太网连接:

1
ip link add name veth0 type veth peer name veth1 netns <pid>

<pid> 应替换为父命名空间中进程的进程 ID. 运行此命令会在这两个命名空间之间建立类似管道的连接. 父命名空间保留 veth0 设备, 并将 veth1 设备传递给子命名空间. 任何进入一端的东西都会通过另一端出来, 就像一对真实节点之间的以太网连接. 因此, 必须为该虚拟以太网连接的两端分配 IP 地址.

目录命名空间

Linux 也维护着一个数据结构, 包含系统中所有的挂载点信息. 它包含诸如哪些磁盘分区被挂载, 它们被挂载在哪里, 是否为只读等信息. 在 Linux 名字空间下, 可以复制这个数据结构, 因此不同名字空间下的进程可以改变挂载点, 而不会互相影响.

创建单独的挂载名字空间的效果类似执行 chroot() 操作. chroot() 很好用, 但是并不能提供完全的隔离, 它对根挂载点的影响也非常有限. 创建单独的挂载名字空间, 可以让这些隔离的进程完全不同地看待整个系统的挂载点结构. 这使你可以为每个隔离的进程设置不同的根目录, 以及其他只属于该进程的挂载点.

实现此目的所需的 clone() 标志是 CLONE_NEWNS:

1
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 命名空间是如何被隔离的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#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;
}

将产生这个结果:
1
2
3
4
5
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()”. 这个特殊的系统调用允许一个进程从原始名字空间中隔离出来, 而不是一开始由父进程隔离子进程. 例如, 下面的代码与之前在网络名字空间部分提到的代码有完全相同的效果:

这个情况是因为你当前系统上有一些网络命名空间已经创建, 但是没有使用 “ip netns” 命令显式地命名.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#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 容器等工具架构的一部分.

在大多数情况下, 最好直接使用这些已有的经过验证和测试的工具. 但在某些情况下, 拥有你自己定制的进程隔离机制可能更有意义.

Author: Sean
Link: https://blog.whileaway.io/posts/9ed02891/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.