《Docker进阶与实战》——2.4节SparkContext概述

本节书摘来自华章社区《Docker进阶与实战》一书中的第2章,第2.4节SparkContext概述,作者华为Docker实践小组,更多章节内容可以访问云栖社区“华章社区”公众号查看

2.4 Namespace介绍
2.4.1 Namespace是什么
Namespace是将内核的全局资源做封装,使得每个Namespace都有一份独立的资源,因此不同的进程在各自的Namespace内对同一种资源的使用不会互相干扰。
这样的解释可能不清楚,举个例子,执行sethostname这个系统调用时,可以改变系统的主机名,这个主机名就是一个内核的全局资源。内核通过实现UTS Namespace,可以将不同的进程分隔在不同的UTS Namespace中,在某个Namespace修改主机名时,另一个Namespace的主机名还是保持不变。
目前Linux内核总共实现了6种Namespace:
IPC:隔离System V IPC和POSIX消息队列。
Network:隔离网络资源。
Mount:隔离文件系统挂载点。
PID:隔离进程ID。
UTS:隔离主机名和域名。
User:隔离用户ID和组ID。
2.4.2 Namespace的接口和使用
对Namespace的操作,主要是通过clone、setns和unshare这3个系统调用来完成的。
clone可以用来创建新的Namespace。它接受一个叫flags的参数,这些flag包括CLONE_NEWNS、CLONE_NEWIPC、CLONE_NEWUTS、CLONE_NEWNET、CLONE_NEWPID和CLONE_NEWUSER,我们通过传入这些CLONE_NEW*来创建新的Namespace。这些flag对应的Namespace都可以从字面上看出来,除了CLONE_NEWNS,这是用来创建Mount Namespace的。指定了这些flag后,由clone创建出来的新进程,就位于全新的Namespace里了,并且很自然地这个新进程以后创建出来的进程,也都在这个Namespace中。

示 Mount Namespace是第一个实现的Namespace,当初实现时并不是为了实现Linux容器,因此也就没有预料到会有新的Namespace出现,因此用了CLONE_NEWNS而不是CLONE_NEWMNT之类的名字。

那么,能不能为已有的进程创建新的Namespace呢?答案是可以,unshare就是用来达到这个目的的。调用这个系统调用的进程,会被放进新创建的Namespace里,要创建什么Namespace由flags参数指定,可以使用的flag也就是上面提到的那些。
以上两个系统调用都是用来创建新的Namespace的,而setns则可以将进程放到已有的Namespace里,问题是如何指定已有的Namespace?答案在procfs里。每个进程在procfs下都有一个目录,在那里面就有Namespace相关的信息,如下。

# ls –l /proc/$$/ns
total 0
lrwxrwxrwx 1 root root 0 Jun 16 14:39 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 Jun 16 14:39 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 root root 0 Jun 16 14:39 net -> net:[4026531957]
lrwxrwxrwx 1 root root 0 Jun 16 14:39 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Jun 16 14:39 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Jun 16 14:39 uts -> uts:[4026531838]

这里每个虚拟文件都对应了这个进程所处的Namespace。因此,如果另一个进程要进入这个进程的Namespace,可以通过open系统调用打开这里面的虚拟文件并得到一个文件描述符,然后把文件描述符传给setns,调用返回成功的话,就进入这个进程的Namespace了。
docker exec命令的实现原理就是setns。

以下是一个简单的程序,在Linux终端调用这个程序就会进入新的Namespace,同时也可以打开另一个终端,这个终端是在host的Namespace里,这样就可以对比两个Namespace的区别了。

#define _GNU_SOURCE
#include <sys/wait.h>
#include <sys/utsname.h>
#include <sched.h>
#include <stdio.h>

#define STACK_SIZE (1024 * 1024)
static char stack[STACK_SIZE];
static char* const child_args[] = { "/bin/bash", NULL };

static int child(void *arg)
{
        execv("/bin/bash", child_args);
        return 0;
}

int main(int argc, char *argv[])
{
        pid_t pid;

        pid = clone(child, stack+STACK_SIZE, SIGCHLD|CLONE_NEWUTS, NULL);

        waitpid(pid, NULL, 0);
}

这个程序创建了UTS Namespace,可以通过修改flag,创建其他Namespace,也可以创建几个Namespace的组合。这个程序将会用来为下面的内容做演示。
2.4.3 各个Namespace介绍

  1. UTS Namespace
    UTS Namespace用于对主机名和域名进行隔离,也就是uname系统调用使用的结构体struct utsname里的nodename和domainname这两个字段,UTS这个名字也是由此而来的。

那么,为什么要使用UTS Namespace做隔离?这是因为主机名可以用来代替IP地址,因此,也就可以使用主机名在网络*问某台机器了,如果不做隔离,这个机制在容器里就会出问题。
调用之前的程序后,在Namespace终端执行以下命令:

# hostname container
# hostname
container
这里已经改变了主机名,现在通过host终端来看看host的主机名:
# hostname
linux-host

可以看到,host的主机名并没有变化,这就是Namespace所起的作用。

  1. IPC Namespace
    IPC是Inter-Process Communication的简写,也就是进程间通信。Linux提供了很多种进程间通信的机制,IPC Namespace针对的是SystemV IPC和Posix消息队列。这些IPC机制都会用到标识符,例如用标识符来区别不同的消息队列,然后两个进程通过标识符找到对应的消息队列进行通信等。

IPC Namespace能做到的事情是,使相同的标识符在两个Namespace中代表不同的消息队列,这样也就使得两个Namespace中的进程不能通过IPC进程通信了。
举个例子,在namespace终端创建了一个消息队列:

# ipcmk -Q
Message queue id: 65536
# ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages
0x0ec037c7 65536      root       644        0            
这个消息队列的标识符是65536,现在在host终端看一下:
# ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

在这里看不到任何消息队列,IPC隔离的效果达到了。

  1. PID Namespace
    PID Namespace用于隔离进程PID号,这样一来,不同的Namespace里的进程PID号就可以是一样的了。

当创建一个PID Namespace时,第一个进程的PID号是1,也就是init进程。init进程有一些特殊之处,例如init进程需要负责回收所有孤儿进程的资源。另外,发送给init进程的任何信号都会被屏蔽,即使发送的是SIGKILL信号,也就是说,在容器内无法“杀死”init进程。
但是当用ps命令查看系统的进程时,会发现竟然可以看到host的所有进程:

# ps ax
    PID TTY      STAT   TIME COMMAND
      1 ?        Ss     0:24 init [5]
      2 ?        S      0:06 [kthreadd]
      3 ?        S      1:37 [ksoftirqd/0]
      5 ?        S<     0:00 [kworker/0:0H]
      7 ?        S      0:16 [kworker/u33:0]
...
7585 pts/0       S+     0:00 sleep 1000
这是因为ps命令是从procfs读取信息的,而procfs并没有得到隔离。虽然能看到这些进程,但由于它们其实是在另一个PID Namespace中,因此无法向这些进程发送信号:
# kill -9 7585
-bash: kill: (7585) - No such process
  1. Mount Namespace
    Mount Namespace用来隔离文件系统挂载点,每个进程能看到的文件系统都记录在/proc/$$/mounts里。在创建了一个新的Mount Namespace后,进程系统对文件系统挂载/卸载的动作就不会影响到其他Namespace。

之前看到,创建PID Namespace后,由于procfs没有改变,因此通过ps命令看到的仍然是host的进程树,其实可以通过在这个PID Namespace里挂载procfs来解决这个问题,如下:

# mount –t proc none /proc
# ps ax
    PID TTY      STAT   TIME COMMAND
        1 pts/2    S+     0:00 newns
        3 pts/2    R+     0:00 ps ax
但此时由于文件系统挂载点没有隔离,因此host看到的procfs也会是这个新的procfs,这样在host上就会出问题:
# ps ax
Error, do this: mount -t proc none /proc

可如果同时使用Mount Namespace和PID Namespace,新的Namespace里的进程和host上的进程将会看到各自的procfs,故而也就不存在上面的问题了。

  1. Network Namespace
    这个Namespace会对网络相关的系统资源进行隔离,每个Network Namespace都有自己的网络设备、IP地址、路由表、/proc/net目录、端口号等。网络隔离的必要性是很明显的,举一个例子,在没有隔离的情况下,如果两个不同的容器都想运行同一个Web应用,而这个应用又需要使用80端口,那就会有冲突了。

新创建的Network Namespace会有一个loopback设备,除此之外不会有任何其他网络设备,因此用户需要在这里面做自己的网络配置。IP工具已经支持Network Namespace,可以通过它来为新的Network Namespace配置网络功能。首先创建Network Namespace:

# ip netns add new_ns
使用“ip netns exec”命令可以对特定的Namespace执行网络管理:
# ip netns exec new_ns ip link list
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
看到确实只有loopback这个网络接口,并且它还因处于DOWN状态而不可用:
# ip netns exec new_ns ping 127.0.0.1
connect: Network is unreachable
通过以下命令可以启用loopback网络接口:
# ip netns exec new-ns ip link set dev lo up
# ip netns exec new-ns ping 127.0.0.1
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.053 ms
...
最后可以这样删除Namespace:
# ip netns delete new_ns

容器的网络配置是一个很大的话题,后面有专门的章节讲解,因此这里暂不展开。

  1. User Namespace
    User Namespace用来隔离用户和组ID,也就是说一个进程在Namespace里的用户和组ID与它在host里的ID可以不一样,这样说可能读者还不理解有什么实际的用处。User Namespace最有用的地方在于,host的普通用户进程在容器里可以是0号用户,也就是root用户。这样,进程在容器内可以做各种特权操作,但是它的特权被限定在容器内,离开了这个容器它就只有普通用户的权限了。


意 容器内的这类root用户,实际上还是有很多特权操作不能执行,基本上如果这个特权操作会影响到其他容器或者host,就不会被允许。

在host上,可以看到我们是lizf用户。

$ id
uid=1000(lizf) gid=100(users) groups=100(users)
现在创建新的User Namespace,看看又是什么情况?
$ new-userns
$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
可以看到,用户名和组名都变了,变成65534,不再是原来的1000和100。
接下来的问题是,怎么设定Namespace和host的UID的映射关系?方法是在创建新的Namespace后,设置这个Namespace里进程的/proc/<PID>/uid_map。在Namespace终端看到的是这样的:
$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
$ echo $$
17074
$ cat /proc/17074/uid_map
$
可以看到uid_map是空的,也就是还没有UID的映射。这可以在host终端上通过root用户设置,如下。
# echo "0 1000 65536" > /proc/17074/uid_map
上面命令表示要将[1000, 66536]的UID在Namespace里映射成[0, 65536]。再切回到Namespace终端看看:
$ id
uid=0(root) gid=65534(nogroup) 65534(nogroup)

可以看到,我们成功地将lizf用户映射成容器里的root用户了。对于gid,也可以做类似的操作。
至此,关于Namespace和Cgroup的知识就讲解完了,可以看到,Namespace和Cgroup的使用是很灵活的,同时这里面又有不少需要注意的地方,因此直接操作Namespace和Cgroup并不是很容易。正是因为这些原因,Docker通过Libcontainer来处理这些底层的事情。这样一来,Docker只需要简单地调用Libcontainer的API,就能将完整的容器搭建起来。而作为Docker的用户,就更不用操心这些事情了,而只需要学习Docker的使用手册,就能通过一两条简单的Docker命令启动容器。

上一篇:使用 Yum 历史查找已安装或已删除的软件包信息


下一篇:开放下载!藏经阁发布新书《OSS运维进阶实战手册》 | 技术日报(11期)