本文是翻译一篇国外博客的内容:https://www.toptal.com/linux/separation-anxiety-isolating-your-system-with-linux-namespaces
随着Docker、Linux Containers等工具的出现,把Linux进程隔离到进程所属的系统环境中变的很容易。让一组程序在不需要虚拟机的情况下,运行在一个真实的Linux机器上并且这些程序不会互相影响变的完全有可能。这些工具对于PaaS供应商来说如虎添翼。但是到底发生了什么在这种现象下面?
这些工具依赖了一些Linux kernel的功能和组件。一些其中的功能是最近一段时间才发布的,并且需要给kernel打补丁才能使用。其中的一个重要的组建Linux namespaces是2.6.24版本中的一个功能,这个版本在2008年发布。
已经熟悉**chroot**的人已经有了一些关于**Linux namespaces**能做什么和怎么样使用**namespaces**有了一个基本的认知。**chroot**允许进程作为root去访问任意目录(不会影响其他的进程), **Linux namespaces**允许操作系统的其他方面也变的可以独立的被修改,包括进程树、网络接口,进程间通信资源等。
## 为什么使用Namespaces隔离进程
在只有一个用户的计算机中,一个单独的系统环境就可以满足。但是在一个想要运行多个服务的服务器中,各个服务是隔离运行的是有必要的,这样会更加安全和稳定。想象一种情况,有一个服务器中运行着多个服务,其中一个服务被入侵了,在这种情况下,这个入侵者就能够利用这个服务并以自己的方式去入侵其他服务,并且有可能对整个服务器都造成影响。**Namespaces**隔离可以提供一个安全的环境去排除这种风险。
像Docker这种命名空间工具可以很好的控制进程对系统资源的使用,PaaS提供商让这些工具变的很流行。像Heroku和Google App Engine使用这些工具去隔离和运行多个web服务在同一个真实的硬件上。这些工具允许他们去运行每一个程序并且不用担心其中的程序使用了太多系统资源或者干扰运行在同一台机器上的程序活着发生冲突。当这些进程隔离时,甚至可以让每个隔离环境拥有一套不同的依赖软件集合。
如果你已经使用过了像Docker这样的工具,你已经知道了这些工具是有着把进程隔离到一个小的**containers**的能力。运行在Docker Container中的进程就像运行在虚拟机上,不过**container**是比虚拟机轻量许多。一个典型的虚拟机是在你的操作系统上模仿硬件层,并且虚拟机会运行另外一个操作系统在虚拟硬件层上。这样你可以运行进程在一个虚拟机内,并且完全和真正的操作系统隔离。但是虚拟机非常重!从另一个方面讲,**Docker Container**使用了一些真实的操作系统中重要的功能,包括napespaces,并确保类似于虚拟机的隔离级别,**Docker Container**并没有模拟仿真硬件,也不会让一台机器上运行另一个操作系统. 这让Docker container变得很轻量。
## Process Namespace 进程命名空间
从历史上看,Linux内核一直维护着一个进程树。这个树包含了每一个指向运行在父子层上的进程的引用。给一个进程足够的权限和并满足一些条件,这个进程就可以访问其他进程通过附着一个追踪器,甚至可以杀死其它进程。
根据**Linux namespaces**的介绍,实现多个嵌套进程树变得有可能。每一个进程树可以拥有整个隔离的进程集合。这样能够保证属于某个进程树的进程不会被访问或杀死,事实上,甚至无法知道其它兄弟或父进程里面是否有进程。
每一次Linux系统在启动的时候,它都会从一个进程ID是1的进程开始。这个进程是进程树的根,并且它通过执行正确的维护工作和开始正确的守护程序/服务初始化剩下的系统。所有的其它进程都会在这个根进程下运行。进程空间允许使用自己PID 1进程衍生出新的进程树。执行此操作的进程保留在原始树中的父进程空间中,但它使子进程成为其自己的进程树的根。
在PID命名空间的隔离下,在子命名空间下的进程没有办法知道父进程的存在。然后,在父命名空间的进程能够看到在子命名空间的全部内容, 就好像这些进程是在父命名空间内。
![父子命名空间](https://upload-images.jianshu.io/upload_images/18827121-6bcbd407c5f122e8.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
创建一个内嵌子命名空间是可能的,一个进程开始一个子进程在一个新的PID命名空间,并且这个子进程创建出另外的进程在新的PID命名空间,以此类推。
根据PID namespaces的介绍,单个进程能够关联多个进程,每个进程空间对应一个进程。在Linux的源码中,我们能看到一个名字是**pid**的结构体,这个结构体原本是用来追踪单个PID,现在通过使用一个名字是upid的结构体用来追踪多个PIDs。
```
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()**这个系统调用,并且传入一个特殊的标示**CLONE_NEWPID**。然后一些在下面讨论的其它命名空间可以使用**unshare()**系统调用创建,一个**PID namespace**只能在一个新的进程被启动之后使用**clone()**创建。一旦**clone()**被调用并传入这个标识,这个新进程立即在一个新的**PID namespace**中运行并且在一个新的进程树下面。这个过程是可以使用一个简单的C语言来演示的:
```
#define _GNU_SOURCE
#include
#include
#include
#include
#include
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
```
这个是1的PID是**child_fn**打印出来的.
虽然上面关于namespaces的代码比起在其它语言中的“Hello, world”并没有长多少,但是在这背后很多事情会发生。**clone()**函数就如果你想象的那样,会通过克隆当前的进程并且开始执行在**child_fn()**中的代码创建一个新的进程. 然后,在做这些的同时,新进程会从原来的进程树中分离,并且系统会为这个新进程创建一个新的进程树。
用下面的函数去替换**static int child_fn()**函数,可以把在被隔离的空间的父进程的PID打印出来:
```
static int child_fn() {
println("Parent PIDL %ld\n", (long)getpid());
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 Network Namespace
**Network namespaces**在这变得很有用。一个网络命名空间允许每个进程去监听一组完全不同的网络接口,甚至每个网络命名空间的环回接口也不同。
使用**CLONE_NEWNET**和**clone()**函数可以把一个进程隔离到它自己的网络命名空间。
```
#define _GNU_SOURCE
#include
#include
#include
#include
#include
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;
}
```
Output:
```
Original `net` Namespace:
1: lo:
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: enp4s0:
link/ether 00:24:8c:a1:ac:e7 brd ff:ff:ff:ff:ff:ff
New `net` Namespace:
1: lo:
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
```
这里发生了什么? 物理以太网设备 enp4s0 属于全局网络命名空间,正如在此命名空间运行的“ip”工具所指示的那样。然后,在新的网络命名空间中的物理接口是不可用的。而且,在原本网络命名空间的回环设备是激活的,但是在子网络命名空间是不可用的。
为了在子命令空间中提供一个有用的网络接口,有必要去创建额外的虚拟网络接口,这个接口可以横跨多个命名空间。一旦完成这些,就可以去创建以太网桥了,甚至可以在命名空间之间路由数据包。最后,为了让这些功能工作,一个**rounting process**必须运行在全局网络命名空间去接收来自物理接口的流量并且通过合适的虚拟接口把流量路由到正确的子网络命名空间。或许你现在能明白了为什么类似于Docker的帮忙做了全部繁重的事情的工具如此受欢迎!
![network namespace](https://upload-images.jianshu.io/upload_images/18827121-d81e6edd3d042254.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
想要手动做到这些,你可以创建一对虚拟以太网,它通过在父命名空间运行一条命令来连接父子命名空间。
```
ip link add name veth0 type veth peer name veth1 netns
```
这里,**pid**应该被在子命名空间的进程ID替换,这个子命名空间可以被父命名空间看到。运行这个命令会建立一个类似于管道的连接在两个命名空间之间。父命名空间维护着**veth0**,子命名空间维护着**veth1**。任何从一端进入的流量都会从另一端出来,就像连接两个真实节点的真实网卡那样。因此,这边的虚拟以太网连接必须被分配一个IP地址。
## Mount Namespace
Linux为系统所有的挂载点维护了一个数据结构。它包含了一些类似下面的信息:那个磁盘分区被挂载了,它们被挂载到了哪里,它们是否是可读的等等。使用 Linux 命名空间,可以克隆这种数据结构,以便不同命名空间下的进程可以更改挂载点,而不会相互影响。
创建一个隔离的挂载命名空间与执行**chroot()**有着相似的效果。**chroot()**是一个不错的方法,但是它并不能完全的隔离,并且它的作用只局限在root挂载点。创建一个隔离的挂载命名空间允许每一个隔离的进程有完全不同的关于整个系统挂载点的视图。这允许你为每个隔离的进程设置不同的根挂载点以及被指定的其他挂载点。小心的使用本教材,你可以避免任何系统底层信息的泄漏。
![](https://upload-images.jianshu.io/upload_images/18827121-7cc41ff760bbddb4.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
想要达到这种效果需要给**clone()**函数穿一个**CLONE_NEWNS**的标识。
```
clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)
```
在刚开始的时候,子进程能看到跟父进程完全一样的挂载点。但是子进程位于一个新的挂载命名空间时,这个子进程能挂载或者取消挂载任何想要挂载的内容,并且这个改变不会影响到在这个系统中的父命名空间以及其他挂载命名空间。例如,如果父进程有一个特殊的磁盘分区挂载到了根节点,这个隔离的进程刚开始将会看到相同的挂载到root节点的磁盘分区,但是,当隔离进程尝试将根分区更改为其他分区时,隔离挂载命名空间的好处是显而易见的,因为更改只会影响隔离挂载命名空间。
## 其它命名空间
还有一个其它的命名空间,**user namespace**, **IPC namespace**, *UTS namespace*, 进程可以被隔离到其中。**user namespace**允许一个进程在namespace中拥有root的权限,同时不能访问在命名空间之外的进程。被**IPC namespace**隔离的进程可以有自己的进程间通信资源,例如System V IPC 和 POSIX 消息。UTS 命名空间隔离了系统的两个特定标识符:节点名和域名。
这有一个小例子展示**UTS namespace**是如何隔离的
```
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#include
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 命名空间内。
## Cross-Namespace Communication 跨Namespace通信
建立一种在父子命名空间的通信是有必要的。这可能是为了在隔离的环境中进行配置工作,或者只是保留从外部查看该环境状况的能力。一种能够做到这样的方法是运行一个SSH后台程序在namespace中。你可以有一个隔离的SSH后台程序在每一个网络命名空间中。但是有多个SSH后台程序运行会使用很多重要的资源例如内存。拥有一个特殊的“init”过程再次被证明是一个好主意的地方。
“init”进程可以在父命名空间和子命名空间之间建立通信通道。该通道可以基于 UNIX 套接字,甚至可以使用 TCP。想要创建一个跨越两个不同挂载命名空间的UNIX套接字,你需要先创建一个子进程,然后创建UNIX套接字,然后把子进程隔离到一个分割的挂载命名空间。但是怎么能先创建进程之后再隔离它?Linux提供了**unshare()**。这个特殊的系统调用允许一个进程将自己与原始命名空间隔离,而不是让父进程让子进程隔离。例如,下面的代码有着跟上面提到的网络命名空间同样的效果:
```
#define _GNU_SOURCE
#include
#include
#include
#include
#include
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 容器等工具架构的一个组成部分。在大多数情况下,使用现有的工具是一个很好的选择,例如Docker,这些工具已经众所周知并经过测试。 但在某些情况下,拥有你自己的、自定义的进程隔离机制可能是有意义的,在这种情况下,本命名空间教程将极大地帮助你。
除了我在本文中介绍的之外,还有更多的事情发生,并且你可能希望通过更多方式来限制目标进程以增加安全性和隔离性。 不管如何,希望这可以作为一个有用的起点,对于那些有兴趣了解更多关于 Linux 的命名空间隔离如何真正起作用的人。