Linux之Namespace

    Namespace是对全局系统资源的一种封装隔离,使得处于不同namespace的进程拥有独立的全局系统资源,改变一个namespace中的系统资源只会影响当前namespace里的进程,对其他namespace中的进程没有影响。

  目前,Linux内核里面实现了7种不同类型的namespace。

名称        宏定义             隔离内容
Cgroup      CLONE_NEWCGROUP   Cgroup root directory (since Linux 4.6)
IPC         CLONE_NEWIPC      System V IPC, POSIX message queues (since Linux 2.6.19)
Network     CLONE_NEWNET      Network devices, stacks, ports, etc. (since Linux 2.6.24)
Mount       CLONE_NEWNS       Mount points (since Linux 2.4.19)
PID         CLONE_NEWPID      Process IDs (since Linux 2.6.24)
User        CLONE_NEWUSER     User and group IDs (started in Linux 2.6.23 and completed in Linux 3.8)
UTS         CLONE_NEWUTS      Hostname and NIS domain name (since Linux 2.6.19)

注意: 由于Cgroup namespace在4.6的内核中才实现,并且和cgroup v2关系密切,现在普及程度还不高,比如docker现在就还没有用它,所以在namespace这个系列中不会介绍Cgroup namespace。

 

查看进程所属的namespaces

系统中的每个进程都有/proc/[pid]/ns/这样一个目录,里面包含了这个进程所属namespace的信息,里面每个文件的描述符都可以用来作为setns函数(后面会介绍)的参数。

复制代码
#查看当前bash进程所属的namespace
dev@ubuntu:~$ ls -l /proc/$$/ns     
total 0
lrwxrwxrwx 1 dev dev 0 7月 7 17:24 cgroup -> cgroup:[4026531835] #(since Linux 4.6)
lrwxrwxrwx 1 dev dev 0 7月 7 17:24 ipc -> ipc:[4026531839]       #(since Linux 3.0)
lrwxrwxrwx 1 dev dev 0 7月 7 17:24 mnt -> mnt:[4026531840]       #(since Linux 3.8)
lrwxrwxrwx 1 dev dev 0 7月 7 17:24 net -> net:[4026531957]       #(since Linux 3.0)
lrwxrwxrwx 1 dev dev 0 7月 7 17:24 pid -> pid:[4026531836]       #(since Linux 3.8)
lrwxrwxrwx 1 dev dev 0 7月 7 17:24 user -> user:[4026531837]     #(since Linux 3.8)
lrwxrwxrwx 1 dev dev 0 7月 7 17:24 uts -> uts:[4026531838]       #(since Linux 3.0)
复制代码
  • 上面每种类型的namespace都是在不同的Linux版本被加入到/proc/[pid]/ns/目录里去的,比如pid namespace是在Linux 3.8才被加入到/proc/[pid]/ns/里面,但这并不是说到3.8才支持pid namespace,其实pid namespace在2.6.24的时候就已经加入到内核了,在那个时候就可以用pid namespace了,只是有了/proc/[pid]/ns/pid之后,使得操作pid namespace更方便了

  • 虽然说cgroup是在Linux 4.6版本才被加入内核,可是在Ubuntu 16.04上,尽管内核版本才4.4,但也支持cgroup namespace,估计应该是Ubuntu将4.6的cgroup namespace这部分代码patch到了他们的4.4内核上。

  • 以ipc:[4026531839]为例,ipc是namespace的类型,4026531839是inode number,如果两个进程的ipc namespace的inode number一样,说明他们属于同一个namespace。这条规则对其他类型的namespace也同样适用。

  • 从上面的输出可以看出,对于每种类型的namespace,进程都会与一个namespace ID关联。

跟namespace相关的API

和namespace相关的函数只有三个,这里简单的看一下,后面介绍UTS namespace的时候会有详细的示例

clone: 创建一个新的进程并把他放到新的namespace中

int clone(int (*child_func)(void *), void *child_stack
            , int flags, void *arg);

flags: 
    指定一个或者多个上面的CLONE_NEW*(当然也可以包含跟namespace无关的flags), 
    这样就会创建一个或多个新的不同类型的namespace, 
    并把新创建的子进程加入新创建的这些namespace中。

setns: 将当前进程加入到已有的namespace中

复制代码
int setns(int fd, int nstype);

fd: 
    指向/proc/[pid]/ns/目录里相应namespace对应的文件,
    表示要加入哪个namespace

nstype:
    指定namespace的类型(上面的任意一个CLONE_NEW*):
    1. 如果当前进程不能根据fd得到它的类型,如fd由其他进程创建,
    并通过UNIX domain socket传给当前进程,
    那么就需要通过nstype来指定fd指向的namespace的类型
    2. 如果进程能根据fd得到namespace类型,比如这个fd是由当前进程打开的,
    那么nstype设置为0即可
复制代码

unshare: 使当前进程退出指定类型的namespace,并加入到新创建的namespace(相当于创建并加入新的namespace)

int unshare(int flags);

flags:
    指定一个或者多个上面的CLONE_NEW*,
    这样当前进程就退出了当前指定类型的namespace并加入到新创建的namespace

clone和unshare的区别

clone和unshare的功能都是创建并加入新的namespace, 他们的区别是:

  • unshare是使当前进程加入新的namespace

  • clone是创建一个新的子进程,然后让子进程加入新的namespace,而当前进程保持不变

其它

当一个namespace中的所有进程都退出时,该namespace将会被销毁。当然还有其他方法让namespace一直存在,假设我们有一个进程号为1000的进程,以ipc namespace为例:

  1. 通过mount --bind命令。例如mount --bind /proc/1000/ns/ipc /other/file,就算属于这个ipc namespace的所有进程都退出了,只要/other/file还在,这个ipc namespace就一直存在,其他进程就可以利用/other/file,通过setns函数加入到这个namespace

  2. 在其他namespace的进程中打开/proc/1000/ns/ipc文件,并一直持有这个文件描述符不关闭,以后就可以用setns函数加入这个namespace。

 

UTS namespace用来隔离系统的hostname以及NIS domain name。

  这两个资源可以通过sethostname(2)和setdomainname(2)函数来设置,以及通过uname(2), gethostname(2)和getdomainname(2)函数来获取.(这里括号中的2表示这个函数是system call,具体其他数字的含义请参看man的帮助文件)

  术语UTS来自于调用函数uname()时用到的结构体: struct utsname. 而这个结构体的名字源自于"UNIX Time-sharing System".

由于UTS namespace最简单,所以放在最前面介绍,在这篇文章中我们将会熟悉UTS namespace以及和namespace相关的三个系统调用的使用。

注意: NIS domain name和DNS没有关系,关于他的介绍可以看这里,由于本人对它不太了解,所以在本文中不做介绍。

下面的所有例子都在ubuntu-server-x86_64 16.04下执行通过

创建新的UTS namespace

多说无益,直接上代码,我尽量将注释写的足够详细,请仔细看代码和输出结果

注意:

  1. 为了代码简单起见,只在clone函数那做了错误处理,关于clone函数的详细介绍请参考man-pages

  2. 为了描述方便,某些地方会用hostname来区分UTS namespace,如hostname为container001的namespace,将会被描述成namespace container001。  

复制代码
#define _GNU_SOURCE
#include <sched.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

#define NOT_OK_EXIT(code, msg); {if(code == -1){perror(msg); exit(-1);} }

//子进程从这里开始执行
static int child_func(void *hostname)
{
    //设置主机名
    sethostname(hostname, strlen(hostname));

    //用一个新的bash来替换掉当前子进程,
    //执行完execlp后,子进程没有退出,也没有创建新的进程,
    //只是当前子进程不再运行自己的代码,而是去执行bash的代码,
    //详情请参考"man execlp"
    //bash退出后,子进程执行完毕
    execlp("bash", "bash", (char *) NULL);

    //从这里开始的代码将不会被执行到,因为当前子进程已经被上面的bash替换掉了

    return 0;
}

static char child_stack[1024*1024]; //设置子进程的栈空间为1M

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

    if (argc < 2) {
        printf("Usage: %s <child-hostname>\n", argv[0]);
        return -1;
    }

    //创建并启动子进程,调用该函数后,父进程将继续往后执行,也就是执行后面的waitpid
    child_pid = clone(child_func,  //子进程将执行child_func这个函数
                    //栈是从高位向低位增长,所以这里要指向高位地址
                    child_stack + sizeof(child_stack),
                    //CLONE_NEWUTS表示创建新的UTS namespace,
                    //这里SIGCHLD是子进程退出后返回给父进程的信号,跟namespace无关
                    CLONE_NEWUTS | SIGCHLD,
                    argv[1]);  //传给child_func的参数
    NOT_OK_EXIT(child_pid, "clone");

    waitpid(child_pid, NULL, 0); //等待子进程结束

    return 0;    //这行执行完之后,父进程结束
}
复制代码

在上面的代码中:

  • 父进程创建新的子进程,并且设置CLONE_NEWUTS,这样就会创建新的UTS namespace并且让子进程属于这个新的namespace,然后父进程一直等待子进程退出

  • 子进程在设置好新的hostname后被bash替换掉

  • 当bash退出后,子进程退出,接着父进程也退出

下面看看输出效果

复制代码
#------------------------第一个shell窗口------------------------
#将上面的代码保存为namespace_uts_demo.c, 
#然后用gcc将它编译成可执行文件namespace_uts_demo
dev@ubuntu:~/code$ gcc namespace_uts_demo.c -o namespace_uts_demo   

#启动程序,传入参数container001
#创建新的UTS namespace需要root权限,所以用到sudo
dev@ubuntu:~/code$ sudo ./namespace_uts_demo container001

#新的bash被启动,从shell的提示符可以看出,hostname已经被改成了container001
#这里bash的提示符是‘#’,表示bash有root权限,
#这是因为我们是用sudo来运行的程序,于是我们程序创建的子进程有root权限
root@container001:~/code#

#用hostname命令再确认一下
root@container001:~/code# hostname
container001

#pstree是用来查看系统中进程之间父子关系的工具
#下面的输出过滤掉了跟namespace_uts_demo无关的内容
#本次操作是通过ssh客户端远程连接到Linux主机进行的,
#所以bash(24429)的父进程是一系列的sshd进程,
#我们在bash(24429)里面执行了sudo ./namespace_uts_demo container001
#所以有了sudo(27332)和我们程序namespace_uts_d(27333)对应的进程,
#我们的程序自己clone了一个新的子进程,由于clone的时候指定了参数CLONE_NEWUTS,
#所以新的子进程属于一个新的UTS namespace,然后这个新进程调用execlp后被bash替换掉了,
#于是有了bash(27334), 这个bash进程拥有所有当前子进程的属性, 
#由于我们的pstree命令是在bash(27334)里面运行的,
#所以这里pstree(27345)是bash(27334)的子进程
root@container001:~/code# pstree -pl
systemd(1)───sshd(24351)───sshd(24428)───bash(24429)───sudo(27332)──
─namespace_uts_d(27333)───bash(27334)───pstree(27345)

#验证一下我们运行的bash进程是不是bash(27334)
#下面这个命令可以输出当前bash的PID
root@container001:~/code# echo $$
27334

#验证一下我们的父进程和子进程是否不在同一个UTS namespace
root@container001:~/code# readlink /proc/27333/ns/uts
uts:[4026531838]
root@container001:~/code# readlink /proc/27334/ns/uts
uts:[4026532445]
#果然不属于同一个UTS namespace,说明新的uts namespace创建成功

#默认情况下,子进程应该继承父进程的namespace
#systemd(1)是我们程序父进程namespace_uts_d(27333)的祖先进程,
#他们应该属于同一个namespace
root@container001:~/code# readlink /proc/1/ns/uts
uts:[4026531838]

#所有bash(27334)里面执行的进程应该和bash(27334)属于同样的namespace
#self指向当前运行的进程,在这里即readlink进程
root@container001:~/code# readlink /proc/self/ns/uts
uts:[4026532445]

#------------------------第二个shell窗口------------------------
#重新打开一个新的shell窗口,确认这个shell和上面的namespace_uts_d(27333)属于同一个namespace
dev@ubuntu:~/code$ readlink /proc/$$/ns/uts
uts:[4026531838]

#老的namespace中的hostname还是原来的,不受新的namespace影响
dev@ubuntu:~/code$ hostname     
ubuntu
#有兴趣的同学可以在两个shell窗口里面分别用命令hostname设置hostname试试,
#会发现他们两个之间相互不受影响,这里就不演示了


#------------------------第一个shell窗口------------------------
#继续回到原来的shell,试试在container001里面再运行一下那个程序会怎样
root@container001:~/code# ./namespace_uts_demo container002

#创建了一个新的UTS namespace,hostname被改成了container002
root@container002:~/code#
root@container002:~/code# hostname
container002

#新的UTS namespace
root@container002:~/code# readlink /proc/$$/ns/uts
uts:[4026532455]

#进程间的关系和上面的差不多,在后面又生成了namespace_uts_d(27354)和bash(27355)
root@container002:~/code# pstree -pl
systemd(1)───sshd(24351)───sshd(24428)───bash(24429)───sudo(27332)──
─namespace_uts_d(27333)───bash(27334)───namespace_uts_d(27354)──
─bash(27355)───pstree(27367)

#退出bash(27355)后,它的父进程namespace_uts_d(27354)也接着退出,
#于是又回到了进程bash(27334)中,hostname于是也回到了container001
#注意: 在bash(27355)退出的过程中,并没有任何进程的namespace发生变化,
#只是所有属于namespace container002的进程都执行完退出了
root@container002:~/code# exit
exit
root@container001:~/code#
root@container001:~/code# hostname
container001
复制代码

退出当前namespace并加入新创建的namespace

继续看代码

复制代码
#define _GNU_SOURCE
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define NOT_OK_EXIT(code, msg); {if(code == -1){perror(msg); exit(-1);} }

static void usage(const char *pname)
{
    char usage[] = "Usage: %s [optins]\n"
                   "Options are:\n"
                   "    -i   unshare IPC namespace\n"
                   "    -m   unshare mount namespace\n"
                   "    -n   unshare network namespace\n"
                   "    -p   unshare PID namespace\n"
                   "    -u   unshare UTS namespace\n"
                   "    -U   unshare user namespace\n";
    printf(usage, pname);
    exit(0);
}

int main(int argc, char *argv[])
{
    int flags = 0, opt, ret;

    //解析命令行参数,用来决定退出哪个类型的namespace
    while ((opt = getopt(argc, argv, "imnpuUh")) != -1) {
        switch (opt) {
            case 'i': flags |= CLONE_NEWIPC;        break;
            case 'm': flags |= CLONE_NEWNS;         break;
            case 'n': flags |= CLONE_NEWNET;        break;
            case 'p': flags |= CLONE_NEWPID;        break;
            case 'u': flags |= CLONE_NEWUTS;        break;
            case 'U': flags |= CLONE_NEWUSER;       break;
            case 'h': usage(argv[0]);               break;
            default:  usage(argv[0]);
        }
    }

    if (flags == 0) {
        usage(argv[0]);
    }

    //执行完unshare函数后,当前进程就会退出当前的一个或多个类型的namespace,
    //然后进入到一个或多个新创建的不同类型的namespace
    ret = unshare(flags);
    NOT_OK_EXIT(ret, "unshare");

    //用一个新的bash来替换掉当前子进程
    execlp("bash", "bash", (char *) NULL);

    return 0;
}
复制代码

看运行效果:

复制代码
#将上面的代码保存为文件namespace_leave.c并编译
dev@ubuntu:~/code$ gcc namespace_leave.c -o namespace_leave

#查看当前bash所属的UTS namespace
dev@ubuntu:~/code$ readlink /proc/$$/ns/uts
uts:[4026531838]

#执行程序, -u表示退出并加入新的UTS namespace
dev@ubuntu:~/code$ sudo ./namespace_leave -u
root@ubuntu:~/code#

#再次查看UTS namespace,已经变了,说明已经离开原来的namespace并加入了新的namespace
#细心的同学可能已经发现这里的inode number刚好和上面namespace container002的相同,
#这说明在container002被销毁后,inode number被回收再利用了
root@ubuntu:~/code# readlink /proc/$$/ns/uts
uts:[4026532455]

#反复执行几次,得到类似的结果
root@ubuntu:~/code# ./namespace_leave -u
root@ubuntu:~/code# readlink /proc/$$/ns/uts
uts:[4026532456]
root@ubuntu:~/code# ./namespace_leave -u
root@ubuntu:~/code# readlink /proc/$$/ns/uts
uts:[4026532457]
root@ubuntu:~/code# ./namespace_leave -u
root@ubuntu:~/code# readlink /proc/$$/ns/uts
uts:[4026532458]
复制代码

内核中的实现

上面演示了这三个函数的功能,那么UTS namespace在内核中又是怎么实现的呢?

在老版本中,UTS相关的信息保存在一个全局变量中,所有进程都共享这个全局变量,gethostname()的实现大概如下

asmlinkage long sys_gethostname(char __user *name, int len)
{
  ...
  if (copy_to_user(name, system_utsname.nodename, i))
    errno = -EFAULT;
  ...
}

在新的Linux内核中,在每个进程对应的task结构体struct task_struct中,增加了一个叫nsproxy的字段,类型是struct nsproxy

复制代码
struct task_struct {
  ...
  /* namespaces */
  struct nsproxy *nsproxy;
  ...
}

struct nsproxy {
  atomic_t count;
  struct uts_namespace *uts_ns;
  struct ipc_namespace *ipc_ns;
  struct mnt_namespace *mnt_ns;
  struct pid_namespace *pid_ns_for_children;
  struct net       *net_ns;
  struct cgroup_namespace *cgroup_ns;
};
复制代码

于是新的gethostname()的实现大概就是这样

复制代码
static inline struct new_utsname *utsname(void)
{
  //current指向当前进程的task结构体
  return &current->nsproxy->uts_ns->name;
}

SYSCALL_DEFINE2(gethostname, char __user *, name, int, len)
{
  struct new_utsname *u;
  ...
  u = utsname();
  if (copy_to_user(name, u->nodename, i)){
    errno = -EFAULT;
  }
  ...
}
复制代码

处于不同UTS namespace中的进程,它task结构体里面的nsproxy->uts_ns所指向的结构体是不一样的,于是达到了隔离UTS的目的。

其他类型的namespace基本上也是差不多的原理。

总结

  • namespace的本质就是把原来所有进程全局共享的资源拆分成了很多个一组一组进程共享的资源

  • 当一个namespace里面的所有进程都退出时,namespace也会被销毁,所以抛开进程谈namespace没有意义

  • UTS namespace就是进程的一个属性,属性值相同的一组进程就属于同一个namespace,跟这组进程之间有没有亲戚关系无关

  • clone和unshare都有创建并加入新的namespace的功能,他们的主要区别是:

    • unshare是使当前进程加入新创建的namespace

    • clone是创建一个新的子进程,然后让子进程加入新的namespace

  • UTS namespace没有嵌套关系,即不存在说一个namespace是另一个namespace的父namespace

IPC namespace用来隔离System V IPC objectsPOSIX message queues。其中System V IPC objects包含Message queues、Semaphore sets和Shared memory segments.

对于其他几种IPC,下面是我的理解,有可能不对,仅供参考,欢迎指正:

  • signal没必要隔离,因为它和pid密切相关,当pid隔离后,signal自然就隔离了,能不能跨pid namespace发送signal则由pid namespace决定

  • pipe好像也没必要隔离,对匿名pipe来说,只能在父子进程之间通讯,所以隔离的意义不大,而命名管道和文件系统有关,所以只要做好文件系统的隔离,命名管道也就隔离了

  • socket和协议栈有关,而不同的network namespace有不同的协议栈,所以socket就被network namespace隔离了

下面的所有例子都在ubuntu-server-x86_64 16.04下执行通过

namespace相关tool

从这篇文章开始,不再像介绍UTS namespace那样自己写代码,而是用ubuntu 16.04中现成的两个工具,他们的实现和上一篇文章中介绍UTS namespace时的代码类似,只是多了一些参数处理

  • nsenter:加入指定进程的指定类型的namespace,然后执行参数中指定的命令。详情请参考帮助文档代码

  • unshare:离开当前指定类型的namespace,创建且加入新的namespace,然后执行参数中指定的命令。详情请参考帮助文档代码

示例

这里将以消息队列为例,演示一下隔离效果,在本例中将用到两个ipc相关的命令

  • ipcmk - 创建shared memory segments, message queues, 和semaphore arrays

  • ipcs - 查看shared memory segments, message queues, 和semaphore arrays的相关信息

为了使演示更直观,我们在创建新的ipc namespace的时候,同时也创建新的uts namespace,然后为新的utsnamespace设置新hostname,这样就能通过shell提示符一眼看出这是属于新的namespace的bash,后面的文章中也采取这种方式启动新的bash。

在这个示例中,我们将用到两个shell窗口

复制代码
#--------------------------第二个shell窗口----------------------
#重新打开一个shell窗口,确认和上面的shell是在同一个namespace,
#能看到上面创建的message queue
dev@ubuntu:~$ readlink /proc/$$/ns/uts /proc/$$/ns/ipc
uts:[4026531838]
ipc:[4026531839]
dev@ubuntu:~$ ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages
0x12aa0de5 0          dev        644        0            0

#运行unshare创建新的ipc和uts namespace,并且在新的namespace中启动bash
#这里-i表示启动新的ipc namespace,-u表示启动新的utsnamespace
dev@ubuntu:~$ sudo unshare -iu /bin/bash
root@ubuntu:~#

#确认新的bash已经属于新的ipc和uts namespace了
root@ubuntu:~# readlink /proc/$$/ns/uts /proc/$$/ns/ipc
uts:[4026532455]
ipc:[4026532456]

#设置新的hostname以便和第一个shell里面的bash做区分
root@ubuntu:~# hostname container001
root@ubuntu:~# hostname
container001

#当hostname改变后,bash不会自动修改它的命令行提示符
#所以运行exec bash重新加载bash
root@ubuntu:~# exec bash
root@container001:~#
root@container001:~# hostname
container001

#现在各个bash进程间的关系如下
#bash(24429)是shell窗口打开时的bash
#bash(27668)是运行sudo unshare创建的bash,和bash(24429)不在同一个namespace
root@container001:~# pstree -pl
├──sshd(24351)───sshd(24428)───bash(24429)───sudo(27667)───bash(27668)───pstree(27695)

#查看message queues,看不到原来namespace里面的消息,说明已经被隔离了
root@container001:~# ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

#创建一条新的message queue
root@container001:~# ipcmk -Q
Message queue id: 0
root@container001:~# ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages
0x54b08fc2 0          root       644        0            0

#--------------------------第一个shell窗口----------------------
#回到第一个shell窗口,看看有没有受到新namespace的影响
dev@ubuntu:~$ ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages
0x12aa0de5 0          dev        644        0            0
#完全无影响,还是原来的信息

#试着加入第二个shell窗口里面bash的uts和ipc namespace
#-t后面跟pid用来指定加入哪个进程所在的namespace
#这里27668是第二个shell中正在运行的bash的pid
#加入成功后将运行/bin/bash
dev@ubuntu:~$ sudo nsenter -t 27668 -u -i /bin/bash

#加入成功,bash的提示符也自动变过来了
root@container001:~# readlink /proc/$$/ns/uts /proc/$$/ns/ipc
uts:[4026532455]
ipc:[4026532456]

#显示的是新namespace里的message queues
root@container001:~# ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages
0x54b08fc2 0          root       644        0            0
复制代码

结束语

上面介绍了IPC namespace和两个常用的跟namespace相关的工具,从演示过程可以看出,IPC namespace差不多和UTS namespace一样简单,没有太复杂的逻辑,也没有父子namespace关系。不过后续将要介绍的其他namespace就要比这个复杂多了。

 

Mount namespace用来隔离文件系统的挂载点, 使得不同的mount namespace拥有自己独立的挂载点信息,不同的namespace之间不会相互影响,这对于构建用户或者容器自己的文件系统目录非常有用。

当前进程所在mount namespace里的所有挂载信息可以在/proc/[pid]/mounts、/proc/[pid]/mountinfo和/proc/[pid]/mountstats里面找到。

Mount namespaces是第一个被加入Linux的namespace,由于当时没想到还会引入其它的namespace,所以取名为CLONE_NEWNS,而没有叫CLONE_NEWMOUNT。

每个mount namespace都拥有一份自己的挂载点列表,当用clone或者unshare函数创建新的mount namespace时,新创建的namespace将拷贝一份老namespace里的挂载点列表,但从这之后,他们就没有关系了,通过mount和umount增加和删除各自namespace里面的挂载点都不会相互影响。

本篇所有例子都在ubuntu-server-x86_64 16.04下执行通过

演示

复制代码
#--------------------------第一个shell窗口----------------------
#先准备两个iso文件,用于后面的mount测试
dev@ubuntu:~$ mkdir iso
dev@ubuntu:~$ cd iso/
dev@ubuntu:~/iso$ mkdir -p iso01/subdir01
dev@ubuntu:~/iso$ mkdir -p iso02/subdir02
dev@ubuntu:~/iso$ mkisofs -o ./001.iso ./iso01
dev@ubuntu:~/iso$ mkisofs -o ./002.iso ./iso02
dev@ubuntu:~/iso$ ls
001.iso  002.iso  iso01  iso02
#准备目录用于mount
dev@ubuntu:~/iso$ sudo mkdir /mnt/iso1 /mnt/iso2

#查看当前所在的mount namespace
dev@ubuntu:~/iso$ readlink /proc/$$/ns/mnt
mnt:[4026531840]

#mount 001.iso 到 /mnt/iso1/
dev@ubuntu:~/iso$ sudo mount ./001.iso /mnt/iso1/
mount: /dev/loop1 is write-protected, mounting read-only

#mount成功
dev@ubuntu:~/iso$ mount |grep /001.iso
/home/dev/iso/001.iso on /mnt/iso1 type iso9660 (ro,relatime)

#创建并进入新的mount和uts namespace
dev@ubuntu:~/iso$ sudo unshare --mount --uts /bin/bash
#更改hostname并重新加载bash
root@ubuntu:~/iso# hostname container001
root@ubuntu:~/iso# exec bash
root@container001:~/iso#

#查看新的mount namespace
root@container001:~/iso# readlink /proc/$$/ns/mnt
mnt:[4026532455]

#老namespace里的挂载点的信息已经拷贝到新的namespace里面来了
root@container001:~/iso# mount |grep /001.iso
/home/dev/iso/001.iso on /mnt/iso1 type iso9660 (ro,relatime)

#在新namespace中mount 002.iso
root@container001:~/iso# mount ./002.iso /mnt/iso2/
mount: /dev/loop0 is write-protected, mounting read-only
root@container001:~/iso# mount |grep iso
/home/dev/iso/001.iso on /mnt/iso1 type iso9660 (ro,relatime)
/home/dev/iso/002.iso on /mnt/iso2 type iso9660 (ro,relatime)

#umount 001.iso
root@container001:~/iso# umount /mnt/iso1
root@container001:~/iso# mount |grep iso
/home/dev/iso/002.iso on /mnt/iso2 type iso9660 (ro,relatime)

#/mnt/iso1目录变为空
root@container001:~/iso# ls /mnt/iso1
root@container001:~/iso#

#--------------------------第二个shell窗口----------------------
#打开新的shell窗口,老namespace中001.iso的挂载信息还在
#而在新namespace里面mount的002.iso这里看不到
dev@ubuntu:~$ mount |grep iso
/home/dev/iso/001.iso on /mnt/iso1 type iso9660 (ro,relatime)
#iso1目录里面也有内容
dev@ubuntu:~$ ls /mnt/iso1
subdir01
#说明两个namespace中的mount信息是隔离的
复制代码

Shared subtrees

在某些情况下,比如系统添加了一个新的硬盘,这个时候如果mount namespace是完全隔离的,想要在各个namespace里面用这个硬盘,就需要在每个namespace里面手动mount这个硬盘,这个是很麻烦的,这时Shared subtrees就可以帮助我们解决这个问题。

关于Shared subtrees的详细介绍请参考Linux mount (第二部分),里面有他的详细介绍以及bind nount的例子。

演示

对Shared subtrees而言,mount namespace和bind mount的情况差不多,这里就简单演示一下shared和private两种类型

复制代码
#--------------------------第一个shell窗口----------------------
#准备4个虚拟的disk,并在上面创建ext2文件系统,用于后续的mount测试
dev@ubuntu:~/iso$ cd && mkdir disks && cd disks
dev@ubuntu:~/disks$ dd if=/dev/zero bs=1M count=32 of=./disk1.img
dev@ubuntu:~/disks$ dd if=/dev/zero bs=1M count=32 of=./disk2.img
dev@ubuntu:~/disks$ dd if=/dev/zero bs=1M count=32 of=./disk3.img
dev@ubuntu:~/disks$ dd if=/dev/zero bs=1M count=32 of=./disk4.img
dev@ubuntu:~/disks$ mkfs.ext2 ./disk1.img
dev@ubuntu:~/disks$ mkfs.ext2 ./disk2.img
dev@ubuntu:~/disks$ mkfs.ext2 ./disk3.img
dev@ubuntu:~/disks$ mkfs.ext2 ./disk4.img
#准备两个目录用于挂载上面创建的disk
dev@ubuntu:~/disks$ mkdir disk1 disk2
dev@ubuntu:~/disks$ ls
disk1  disk1.img  disk2  disk2.img  disk3.img  disk4.img


#显式的分别以shared和private方式挂载disk1和disk2
dev@ubuntu:~/disks$ sudo mount --make-shared ./disk1.img ./disk1
dev@ubuntu:~/disks$ sudo mount --make-private ./disk2.img ./disk2
dev@ubuntu:~/disks$ cat /proc/self/mountinfo |grep disk| sed 's/ - .*//'
164 24 7:1 / /home/dev/disks/disk1 rw,relatime shared:105
173 24 7:2 / /home/dev/disks/disk2 rw,relatime

#查看mount namespace编号
dev@ubuntu:~/disks$ readlink /proc/$$/ns/mnt
mnt:[4026531840]

#--------------------------第二个shell窗口----------------------
#重新打开一个新的shell窗口
dev@ubuntu:~$ cd ./disks
#创建新的mount namespace
#默认情况下,unshare会将新namespace里面的所有挂载点的类型设置成private,
#所以这里用到了参数--propagation unchanged,
#让新namespace里的挂载点的类型和老namespace里保持一致。
#--propagation参数还支持private|shared|slave类型,
#和mount命令的那些--make-private参数一样,
#他们的背后都是通过调用mount(...)函数传入不同的参数实现的
dev@ubuntu:~/disks$ sudo unshare --mount --uts --propagation unchanged /bin/bash
root@ubuntu:~/disks# hostname container001
root@ubuntu:~/disks# exec bash
root@container001:~/disks# 

#确认已经是在新的mount namespace里面了
root@container001:~/disks# readlink /proc/$$/ns/mnt
mnt:[4026532463]

#由于前面指定了--propagation unchanged,
#所以新namespace里面的/home/dev/disks/disk1也是shared,
#且和老namespace里面的/home/dev/disks/disk1属于同一个peer group 105
#因为在不同的namespace里面,所以这里挂载点的ID和原来namespace里的不一样了
root@container001:~/disks# cat /proc/self/mountinfo |grep disk| sed 's/ - .*//'
221 177 7:1 / /home/dev/disks/disk1 rw,relatime shared:105
222 177 7:2 / /home/dev/disks/disk2 rw,relatime

#分别在disk1和disk2目录下创建disk3和disk4,然后挂载disk3,disk4到这两个目录
root@container001:~/disks# mkdir ./disk1/disk3 ./disk2/disk4
root@container001:~/disks# mount ./disk3.img ./disk1/disk3/
root@container001:~/disks# mount ./disk4.img ./disk2/disk4/
root@container001:~/disks# cat /proc/self/mountinfo |grep disk| sed 's/ - .*//'
221 177 7:1 / /home/dev/disks/disk1 rw,relatime shared:105
222 177 7:2 / /home/dev/disks/disk2 rw,relatime
223 221 7:3 / /home/dev/disks/disk1/disk3 rw,relatime shared:107
227 222 7:4 / /home/dev/disks/disk2/disk4 rw,relatime

#--------------------------第一个shell窗口----------------------
#回到第一个shell窗口

#可以看出由于/home/dev/disks/disk1是shared,且两个namespace里的这个挂载点都属于peer group 105,
#所以在新namespace里面挂载的disk3,在老的namespace里面也看的到
#但是看不到disk4的挂载信息,那是因为/home/dev/disks/disk2是private的
dev@ubuntu:~/disks$ cat /proc/self/mountinfo |grep disk| sed 's/ - .*//'
164 24 7:1 / /home/dev/disks/disk1 rw,relatime shared:105
173 24 7:2 / /home/dev/disks/disk2 rw,relatime
224 164 7:3 / /home/dev/disks/disk1/disk3 rw,relatime shared:107

#我们可以随时修改挂载点的propagation type
#这里我们通过mount命令将disk3改成了private类型
dev@ubuntu:~/disks$ sudo mount --make-private /home/dev/disks/disk1/disk3
dev@ubuntu:~/disks$ cat /proc/self/mountinfo |grep disk3| sed 's/ - .*//'
224 164 7:3 / /home/dev/disks/disk1/disk3 rw,relatime

#--------------------------第二个shell窗口----------------------
#回到第二个shell窗口,disk3的propagation type还是shared,
#表明在老的namespace里面对propagation type的修改不会影响新namespace里面的挂载点
root@container001:~/disks# cat /proc/self/mountinfo |grep disk3| sed 's/ - .*//'
223 221 7:3 / /home/dev/disks/disk1/disk3 rw,relatime shared:107
复制代码

关于mount命令和mount namespace的配合,里面有很多技巧,后面如果需要用到更复杂的用法,会再做详细的介绍。

 

 

PID namespaces用来隔离进程的ID空间,使得不同pid namespace里的进程ID可以重复且相互之间不影响。

PID namespace可以嵌套,也就是说有父子关系,在当前namespace里面创建的所有新的namespace都是当前namespace的子namespace。父namespace里面可以看到所有子孙后代namespace里的进程信息,而子namespace里看不到祖先或者兄弟namespace里的进程信息。

目前PID namespace最多可以嵌套32层,由内核中的宏MAX_PID_NS_LEVEL来定义

Linux下的每个进程都有一个对应的/proc/PID目录,该目录包含了大量的有关当前进程的信息。 对一个PID namespace而言,/proc目录只包含当前namespace和它所有子孙后代namespace里的进程的信息。

在Linux系统中,进程ID从1开始往后不断增加,并且不能重复(当然进程退出后,ID会被回收再利用),进程ID为1的进程是内核启动的第一个应用层进程,一般是init进程(现在采用systemd的系统第一个进程是systemd),具有特殊意义,当系统中一个进程的父进程退出时,内核会指定init进程成为这个进程的新父进程,而当init进程退出时,系统也将退出。

除了在init进程里指定了handler的信号外,内核会帮init进程屏蔽掉其他任何信号,这样可以防止其他进程不小心kill掉init进程导致系统挂掉。不过有了PID namespace后,可以通过在父namespace中发送SIGKILL或者SIGSTOP信号来终止子namespace中的ID为1的进程。

由于ID为1的进程的特殊性,所以每个PID namespace的第一个进程的ID都是1。当这个进程运行停止后,内核将会给这个namespace里的所有其他进程发送SIGKILL信号,致使其他所有进程都停止,于是namespace被销毁掉。

本篇所有例子都在ubuntu-server-x86_64 16.04下执行通过

简单示例

复制代码
#查看当前pid namespace的ID
dev@ubuntu:~$ readlink /proc/self/ns/pid
pid:[4026531836]

#启动新的pid namespace
#这里同时也启动了新的uts和mount namespace
#新的uts是为了设置一个新的hostname,便于和老的namespace区分
#新的mount namespace是为了方便我们修改新namespace里面的mount信息,
#因为这样不会对老namespace造成影响
#这里--fork是为了让unshare进程fork一个新的进程出来,然后再用bash替换掉新的进程
#这是pid namespace本身的限制,进程所属的pid namespace在它创建的时候就确定了,不能更改,
#所以调用unshare和nsenter后,原来的进程还是属于老的namespace,
#而新fork出来的进程才属于新的namespace
dev@ubuntu:~$ sudo unshare --uts --pid --mount --fork /bin/bash
root@ubuntu:~# hostname container001
root@ubuntu:~# exec bash
root@container001:~#

#查看进程间关系,当前bash(31646)确实是unshare的子进程
root@container001:~# pstree -pl
├─sshd(955)─┬─sshd(17810)───sshd(17891)───bash(17892)───sudo(31644)──
─unshare(31645)───bash(31646)───pstree(31677)
#他们属于不同的pid namespace
root@container001:~# readlink /proc/31645/ns/pid
pid:[4026531836]
root@container001:~# readlink /proc/31646/ns/pid
pid:[4026532469]

#但为什么通过这种方式查看到的namespace还是老的呢?
root@container001:~# readlink /proc/$$/ns/pid
pid:[4026531836]

#由于我们实际上已经是在新的namespace里了,并且当前bash是当前namespace的第一个进程
#所以在新的namespace里看到的他的进程ID是1
root@container001:~# echo $$
1
#但由于我们新的namespace的挂载信息是从老的namespace拷贝过来的,
#所以这里看到的还是老namespace里面的进程号为1的信息
root@container001:~# readlink /proc/1/ns/pid
pid:[4026531836]
#ps命令依赖/proc目录,所以ps的输出还是老namespace的视图
root@container001:~# ps ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 7月07 ?       00:00:06 /sbin/init
root         2     0  0 7月07 ?       00:00:00 [kthreadd]
 ...
root     31644 17892  0 7月14 pts/0   00:00:00 sudo unshare --uts --pid --mount --fork /bin/bash
root     31645 31644  0 7月14 pts/0   00:00:00 unshare --uts --pid --mount --fork /bin/bash

#所以我们需要重新挂载我们的/proc目录
root@container001:~# mount -t proc proc /proc

#重新挂载后,能看到我们新的pid namespace ID了
root@container001:~# readlink /proc/$$/ns/pid
pid:[4026532469]
#ps的输出也正常了
root@container001:~# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 7月14 pts/0   00:00:00 bash
root        44     1  0 00:06 pts/0    00:00:00 ps -ef
复制代码

PID namespace嵌套

  • 调用unshare或者setns函数后,当前进程的namespace不会发生变化,不会加入到新的namespace,而它的子进程会加入到新的namespace。也就是说进程属于哪个namespace是在进程创建的时候决定的,并且以后再也无法更改。

  • 在一个PID namespace里的进程,它的父进程可能不在当前namespace中,而是在外面的namespace里面(这里外面的namespace指当前namespace的祖先namespace),这类进程的ppid都是0。比如新namespace里面的第一个进程,他的父进程就在外面的namespace里。通过setns的方式加入到新namespace中的进程的父进程也在外面的namespace中。

  • 可以在祖先namespace中看到子namespace的所有进程信息,且可以发信号给子namespace的进程,但进程在不同namespace中的PID是不一样的。

嵌套示例

复制代码
--------------------------第一个shell窗口----------------------
#记下最外层的namespace ID
dev@ubuntu:~$ readlink /proc/$$/ns/pid
pid:[4026531836]

#创建新的pid namespace, 这里--mount-proc参数是让unshare自动重新mount /proc目录
dev@ubuntu:~$ sudo unshare --uts --pid --mount --fork --mount-proc /bin/bash
root@ubuntu:~# hostname container001
root@ubuntu:~# exec bash
root@container001:~# readlink /proc/$$/ns/pid
pid:[4026532469]

#再创建新的pid namespace
root@container001:~# unshare --uts --pid --mount --fork --mount-proc /bin/bash
root@container001:~# hostname container002
root@container001:~# exec bash
root@container002:~# readlink /proc/$$/ns/pid
pid:[4026532472]

#再创建新的pid namespace
root@container002:~# unshare --uts --pid --mount --fork --mount-proc /bin/bash
root@container002:~# hostname container003
root@container002:~# exec bash
root@container003:~# readlink /proc/$$/ns/pid
pid:[4026532475]

#目前namespace container003里面就一个bash进程
root@container003:~# pstree -p
bash(1)───pstree(22)
#这样我们就有了三层pid namespace,
#他们的父子关系为container001->container002->container003

#--------------------------第二个shell窗口----------------------
#在最外层的namespace中查看上面新创建的三个namespace中的bash进程
#从这里可以看出,这里显示的bash进程的PID和上面container003里看到的bash(1)不一样
dev@ubuntu:~$ pstree -pl|grep bash|grep unshare
|-sshd(955)-+-sshd(17810)---sshd(17891)---bash(17892)---sudo(31814)--
-unshare(31815)---bash(31816)---unshare(31842)---bash(31843)--
-unshare(31864)---bash(31865)
#各个unshare进程的子bash进程分别属于上面的三个pid namespace
dev@ubuntu:~$ sudo readlink /proc/31816/ns/pid
pid:[4026532469]
dev@ubuntu:~$ sudo readlink /proc/31843/ns/pid
pid:[4026532472]
dev@ubuntu:~$ sudo readlink /proc/31865/ns/pid
pid:[4026532475]

#PID在各个namespace里的映射关系可以通过/proc/[pid]/status查看到
#这里31865是在最外面namespace中看到的pid
#45,23,1分别是在container001,container002和container003中的pid
dev@ubuntu:~$ grep pid /proc/31865/status
NSpid:  31865   45     23      1

#创建一个新的bash并加入container002
dev@ubuntu:~$ sudo nsenter --uts --mount --pid -t 31843 /bin/bash
root@container002:/#

#这里bash(23)就是container003里面的pid 1对应的bash
root@container002:/# pstree -p
bash(1)───unshare(22)───bash(23)
#unshare(22)属于container002
root@container002:/# readlink /proc/22/ns/pid
pid:[4026532472]
#bash(23)属于container003
root@container002:/# readlink /proc/23/ns/pid
pid:[4026532475]

#为什么上面pstree的结果里面没看到nsenter加进来的bash呢?
#通过ps命令我们发现,我们新加进来的那个/bin/bash的ppid是0,难怪pstree里面显示不出来
#从这里可以看出,跟最外层namespace不一样的地方就是,这里可以有多个进程的ppid为0
#从这里的TTY也可以看出哪些命令是在哪些窗口执行的,
#pts/0对应第一个shell窗口,pts/1对应第二个shell窗口
root@container002:/# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 04:39 pts/0    00:00:00 bash
root        22     1  0 04:39 pts/0    00:00:00 unshare --uts --pid --mount --fork --mount-proc /bin/bash
root        23    22  0 04:39 pts/0    00:00:00 bash
root        46     0  0 04:52 pts/1    00:00:00 /bin/bash
root        59    46  0 04:53 pts/1    00:00:00 ps -ef

#--------------------------第三个shell窗口----------------------
#创建一个新的bash并加入container001
dev@ubuntu:~$ sudo nsenter --uts --mount --pid -t 31816 /bin/bash
root@container001:/#

#通过pstree和ps -ef我们可看到所有三个namespace中的进程及他们的关系
#bash(1)───unshare(22)属于container001
#bash(23)───unshare(44)属于container002
#bash(45)属于container003,而68和84两个进程分别是上面两次通过nsenter加进来的bash
#同上面ps的结果比较我们可以看出,同样的进程在不同的namespace里面拥有不同的PID
root@container001:/# pstree -pl
bash(1)───unshare(22)───bash(23)───unshare(44)───bash(45)
root@container001:/# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 04:37 pts/0    00:00:00 bash
root        22     1  0 04:39 pts/0    00:00:00 unshare --uts --pid --mount --fork --mount-proc /bin/bash
root        23    22  0 04:39 pts/0    00:00:00 bash
root        44    23  0 04:39 pts/0    00:00:00 unshare --uts --pid --mount --fork --mount-proc /bin/bash
root        45    44  0 04:39 pts/0    00:00:00 bash
root        68     0  0 04:52 pts/1    00:00:00 /bin/bash
root        84     0  0 05:00 pts/2    00:00:00 /bin/bash
root        95    84  0 05:00 pts/2    00:00:00 ps -ef

#发送信号给contain002中的bash
root@container001:/# kill 68

#--------------------------第二个shell窗口----------------------
#回到第二个窗口,发现bash已经被kill掉了,说明父namespace是可以发信号给子namespace中的进程的
root@container002:/# exit
dev@ubuntu:~$
复制代码

“init”示例

当一个进程的父进程被kill掉后,该进程将会被当前namespace中pid为1的进程接管,而不是被最外层的系统级别的init进程接管。

当pid为1的进程停止运行后,内核将会给这个namespace及其子孙namespace里的所有其他进程发送SIGKILL信号,致使其他所有进程都停止,于是当前namespace及其子孙后代的namespace都被销毁掉。

复制代码
#还是继续以上面三个namespace为例
#--------------------------第一个shell窗口----------------------
#在003里面启动两个新的bash,使他们的继承关系如下
root@container003:~# bash
root@container003:~# bash
root@container003:~# pstree
bash───bash───bash───pstree

#利用unshare、nohup和sleep的组合,模拟出我们想要的父子进程
#unshare --fork会使unshare创建一个子进程
#nohup sleep sleep 3600&会让这个子进程在后台运行并且sleep一小时
root@container003:~# unshare --fork nohup sleep 3600&
[1] 77

#于是我们得到了我们想要的进程间关系结构
root@container003:~# pstree -p
bash(1)───bash(26)───bash(36)─┬─pstree(80)
                              └─unshare(77)───sleep(78)

#如我们所期望的,kill掉unshare(77)后, sleep就被当前pid namespace的bash(1)接管了
root@container003:~# kill 77
root@container003:~# pstree -p
bash(1)─┬─bash(26)───bash(36)───pstree(82)
        └─sleep(78)

#重新回到刚才的状态,后面将尝试在第三个窗口中kill掉这里的unshare进程
root@container003:~# kill 78
root@container003:~# unshare --fork nohup sleep 3600&
root@container003:~# pstree -p
bash(1)───bash(26)───bash(36)─┬─pstree(85)
                              └─unshare(83)───sleep(84)

#--------------------------第三个shell窗口----------------------
#来到第三个窗口
root@container001:/# pstree -p
bash(1)───unshare(22)───bash(23)───unshare(44)───bash(45)───bash(113)─
──bash(123)───unshare(170)───sleep(171)

#kill掉sleep(171)的父近程unshare(170),
root@container001:/# kill 170
#结果显示sleep(171)被bash(45)接管了,而不是bash(1),
#进一步说明container003里的进程只会被container003里的pid 1进程接管,
#而不会被外面container001的pid 1进程接管
root@container001:/# pstree -p
bash(1)───unshare(22)───bash(23)───unshare(44)──
─bash(45)─┬─bash(113)───bash(123)
          └─sleep(171)

#kill掉container002中pid 1的bash进程,在container001中,对应的是bash(23)
root@container001:/# kill 23
#根本没反应,说明bash不接收TERM信号(kill默认发送SIGTERM信号)
root@container001:/# pstree -p
bash(1)───unshare(22)───bash(23)───unshare(44)──
─bash(45)─┬─bash(113)───bash(123)
          └─sleep(171)

#试试SIGSTOP,貌似也不行      
root@container001:/# kill -SIGSTOP 23
root@container001:/# pstree -p
bash(1)───unshare(22)───bash(23)───unshare(44)──
─bash(45)─┬─bash(113)───bash(123)
          └─sleep(171)

#最后试试杀手锏SIGKILL,马到成功
root@container001:/# kill -SIGKILL 23
root@container001:/# pstree -p
bash(1)

#--------------------------第一个shell窗口----------------------
#container003和container002的bash退出了,
#第一个shell窗口直接退到了container001的bash
root@container003:~# Killed
root@container001:~#

#--------------------------第二个shell窗口----------------------
#通过nsenter方式加入到container002的bash也被kill掉了
root@container002:/# Killed
dev@ubuntu:~$

#从结果可以看出,container002的“init”进程被杀死后,
#内核将会发送SIGKILL给container002里的所有进程,
#这样导致container002及它所有子孙namespace里的进程都杀死,
#同时container002和container003也被销毁
复制代码

man-pages里面说SIGSTOP也可以kill掉子namespace里的“init”进程,但我在上面试了下,没效果,具体原因未知。

其他

  • 通常情况下,如果PID namespace中的进程都退出了,这个namespace将会被销毁,但就如在前面“Namespace概述”里介绍的,有两种情况会导致就算进程都退出了,这个namespace还会存在。但对于PID namespace来说,就算namespace还在,由于里面没有“init”进程,Kernel不允许其它进程加入到这个namespace,所以这个存在的namespace没有意义

  • 当一个PID通过UNIX domain socket在不同的PID namespace中传输时(请参考unix(7)里面的SCM_CREDENTIALS),PID将会自动转换成目的namespace中的PID.

 

network namespace用来隔离网络设备, IP地址, 端口等. 每个namespace将会有自己独立的网络栈,路由表,防火墙规则,socket等。

每个新的network namespace默认有一个本地环回接口,除了lo接口外,所有的其他网络设备(物理/虚拟网络接口,网桥等)只能属于一个network namespace。每个socket也只能属于一个network namespace。

当新的network namespace被创建时,lo接口默认是关闭的,需要自己手动启动起

标记为"local devices"的设备不能从一个namespace移动到另一个namespace,比如loopback, bridge, ppp等,我们可以通过ethtool -k命令来查看设备的netns-local属性。

#这里“on”表示该设备不能被移动到其他network namespace
dev@ubuntu:~$ ethtool -k lo|grep netns-local
netns-local: on [fixed]

示例

本示例将演示如何创建新的network namespace并同外面的namespace进行通信。

复制代码
#--------------------------第一个shell窗口----------------------
#记录默认network namespace ID
dev@ubuntu:~$ readlink /proc/$$/ns/net
net:[4026531957]

#创建新的network namespace
dev@ubuntu:~$ sudo unshare --uts --net /bin/bash
root@ubuntu:~# hostname container001
root@ubuntu:~# exec bash
root@container001:~# readlink /proc/$$/ns/net
net:[4026532478]

#运行ifconfig啥都没有
root@container001:~# ifconfig
root@container001:~#

#启动lo (这里不详细介绍ip这个tool的用法,请参考man ip)
root@container001:~# ip link set lo up
root@container001:~# ifconfig
lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

root@container001:~# 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.070 ms
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.015 ms

#获取当前bash进程的PID
root@container001:~# echo $$
15812

#--------------------------第二个shell窗口----------------------
#创建新的虚拟以太网设备,让两个namespace能通讯
dev@ubuntu:~$ sudo ip link add veth0 type veth peer name veth1

#将veth1移动到上面第一个窗口中的namespace
#这里15812是上面bash的PID
dev@ubuntu:~$ sudo ip link set veth1 netns 15812

#为veth0分配IP并启动veth0
dev@ubuntu:~$ sudo ip address add dev veth0 192.168.8.1/24
dev@ubuntu:~$ sudo ip link set veth0 up
dev@ubuntu:~$ ifconfig veth0
veth0     Link encap:Ethernet  HWaddr 9a:4d:d5:96:b5:36
          inet addr:192.168.8.1  Bcast:0.0.0.0  Mask:255.255.255.0
          inet6 addr: fe80::984d:d5ff:fe96:b536/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:8 errors:0 dropped:0 overruns:0 frame:0
          TX packets:8 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:648 (648.0 B)  TX bytes:648 (648.0 B)

#--------------------------第一个shell窗口----------------------
#为veth1分配IP地址并启动它
root@container001:~# ip address add dev veth1 192.168.8.2/24
root@container001:~# ip link set veth1 up
root@container001:~# ifconfig veth1
veth1     Link encap:Ethernet  HWaddr 6a:dc:59:79:3c:8b
          inet addr:192.168.8.2  Bcast:0.0.0.0  Mask:255.255.255.0
          inet6 addr: fe80::68dc:59ff:fe79:3c8b/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:8 errors:0 dropped:0 overruns:0 frame:0
          TX packets:8 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:648 (648.0 B)  TX bytes:648 (648.0 B)

#连接成功
root@container001:~# ping 192.168.8.1
PING 192.168.8.1 (192.168.8.1) 56(84) bytes of data.
64 bytes from 192.168.8.1: icmp_seq=1 ttl=64 time=0.098 ms
64 bytes from 192.168.8.1: icmp_seq=2 ttl=64 time=0.023 ms
复制代码

到目前为止,两个namespace之间可以网络通信了,但在container001里还是不能访问外网。下面将通过NAT的方式让container001能够上外网。这部分内容完全是网络相关的知识,跟namespace已经没什么关系了。

复制代码
#--------------------------第二个shell窗口----------------------
#回到上面示例中的第二个窗口

#确认IP forward是否已经开通,这里1表示开通了
#如果你的机器上是0,请运行这个命令将它改为1: sudo sysctl -w net.ipv4.ip_forward=1
dev@ubuntu:~$ cat /proc/sys/net/ipv4/ip_forward
1

#添加NAT规则,这里ens32是机器上连接外网的网卡
#关于iptables和nat都比较复杂,这里不做解释
dev@ubuntu:~$ sudo iptables -t nat -A POSTROUTING -o ens32 -j MASQUERADE

#--------------------------第一个shell窗口----------------------
#回到第一个窗口,添加默认网关
root@container001:~# ip route add default via 192.168.8.1
root@container001:~# route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
0.0.0.0         192.168.8.1     0.0.0.0         UG    0      0        0 veth1
192.168.8.0     0.0.0.0         255.255.255.0   U     0      0        0 veth1

#这样就可以访问外网了
#由于测试环境的限制,所以采用下面的方式检测网络是否畅通
#如果网络没有什么限制的话,随便ping一个外部的IP测试就可以了
root@container001:~# curl -I www.google.com
HTTP/1.1 200 OK
Date: Fri, 15 Jul 2016 08:12:03 GMT
复制代码

network namespace的概念比较简单,但如何做好网络的隔离和连通却比较难,包括性能和安全相关的考虑,需要很好的Linux网络知识。后续在介绍docker网络管理的时候会对Linux网络做一个更详细的介绍。

ip netns

在单独操作network namespace时,ip netns是一个很方便的工具,并且它可以给namespace取一个名字,然后根据名字来操作namespace。那么给namespace取名字并且根据名字来管理namespace里面的进程是怎么实现的呢?请看下面的脚本(也可以直接看它的源代码):

复制代码
#开始之前,获取一下默认network namespace的ID
dev@ubuntu:~$ readlink /proc/$$/ns/net
net:[4026531957]

#创建一个用于绑定network namespace的文件,
#ip netns将所有的文件放到了目录/var/run/netns下,
#所以我们这里重用这个目录,并且创建一个我们自己的文件netnamespace1
dev@ubuntu:~$ sudo mkdir -p /var/run/netns
dev@ubuntu:~$ sudo touch /var/run/netns/netnamespace1

#创建新的network namespace,并在新的namespace中启动新的bash
dev@ubuntu:~$ sudo unshare --net bash
#查看新的namespace ID
root@ubuntu:~# readlink /proc/$$/ns/net
net:[4026532448]

#bind当前bash的namespace文件到上面创建的文件上
root@ubuntu:~# mount --bind /proc/$$/ns/net /var/run/netns/netnamespace1
#通过ls -i命令可以看到文件netnamespace1的inode号和namespace的编号相同,说明绑定成功
root@ubuntu:~# ls -i /var/run/netns/netnamespace1
4026532448 /var/run/netns/netnamespace1

#退出新创建的bash
root@ubuntu:~# exit
exit
#可以看出netnamespace1的inode没变,说明我们使用了bind mount后
#虽然新的namespace中已经没有进程了,但这个新的namespace还存在
dev@ubuntu:~$ ls -i /var/run/netns/netnamespace1
4026532448 /var/run/netns/netnamespace1

#上面的这一系列操作等同于执行了命令: ip netns add netnamespace1
#下面的nsenter命令等同于执行了命令: ip netns exec netnamespace1 bash

#我们可以通过nsenter命令再创建一个新的bash,并将它加入netnamespace1所关联的namespace(net:[4026532448])
dev@ubuntu:~$ sudo nsenter --net=/var/run/netns/netnamespace1 bash
root@ubuntu:~# readlink /proc/$$/ns/net
net:[4026532448]
复制代码

从上面可以看出,给namespace取名字其实就是创建一个文件,然后通过mount --bind将新创建的namespace文件和该文件绑定,就算该namespace里的所有进程都退出了,内核还是会保留该namespace,以后我们还可以通过这个绑定的文件来加入该namespace。

通过这种办法,我们也可以给其他类型的namespace取名字(有些类型的 namespace可能有些特殊,本人没有一个一个的试过)。

 

User namespace用来隔离user权限相关的Linux资源,包括user IDs and group IDskeys , 和capabilities.

这是目前实现的namespace中最复杂的一个,因为user和权限息息相关,而权限又事关容器的安全,所以稍有不慎,就会出安全问题。

user namespace可以嵌套(目前内核控制最多32层),除了系统默认的user namespace外,所有的user namespace都有一个父user namespace,每个user namespace都可以有零到多个子user namespace。 当在一个进程中调用unshare或者clone创建新的user namespace时,当前进程原来所在的user namespace为父user namespace,新的user namespace为子user namespace.

在不同的user namespace中,同样一个用户的user ID 和group ID可以不一样,换句话说,一个用户可以在父user namespace中是普通用户,在子user namespace中是超级用户(超级用户只相对于子user namespace所拥有的资源,无法访问其他user namespace中需要超级用户才能访问资源)。

从Linux 3.8开始,创建新的user namespace不需要root权限。

本篇所有例子都在ubuntu-server-x86_64 16.04下执行通过

创建user namespace

复制代码
#--------------------------第一个shell窗口----------------------
#先记录下目前的id,gid和user namespace
dev@ubuntu:~$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev),4(adm),24(cdrom),27(sudo)
dev@ubuntu:~$ readlink /proc/$$/ns/user
user:[4026531837]

#创建新的user namespace
dev@ubuntu:~$ unshare --user /bin/bash
nobody@ubuntu:~$ readlink /proc/$$/ns/user
user:[4026532464]
nobody@ubuntu:~$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)
复制代码

很奇怪,为什么上面例子中显示的用户名是nobody,它的id和gid都是65534?

这是因为我们还没有映射父user namespace的user ID和group ID到子user namespace中来,这一步是必须的,因为这样系统才能控制一个user namespace里的用户在其他user namespace中的权限。(比如给其他user namespace中的进程发送信号,或者访问属于其他user namespace挂载的文件)

如果没有映射的话,当在新的user namespace中用getuid()和getgid()获取user id和group id时,系统将返回文件/proc/sys/kernel/overflowuid中定义的user ID以及proc/sys/kernel/overflowgid中定义的group ID,它们的默认值都是65534。也就是说如果没有指定映射关系的话,会默认映射到ID65534。

下面看看这个user能干些什么

复制代码
#--------------------------第一个shell窗口----------------------
#ls的结果显示/root目录属于nobody
nobody@ubuntu:~$ ls -l /|grep root
drwx------   3 nobody nogroup  4096 7月   8 18:39 root
#但是当前的nobody账号访问不了,说明这两个nobody不是一个ID,他们之间没有映射关系
nobody@ubuntu:~$ ls /root
ls: cannot open directory '/root': Permission denied

#这里显示/home/dev目录属于nobody
nobody@ubuntu:~$ ls -l /home/
drwxr-xr-x 11 nobody nogroup 4096 7月   8 18:40 dev
#touch成功,说明虽然没有显式的映射ID,但还是能访问父user namespace里dev账号拥有的资源
#说明他们背后还是有映射关系
nobody@ubuntu:~$ touch /home/dev/temp01
nobody@ubuntu:~$
复制代码

映射user ID和group ID

通常情况下,创建新的user namespace后,第一件事就是映射user和group ID. 映射ID的方法是添加配置到/proc/PID/uid_map和/proc/PID/gid_map(这里的PID是新user namespace中的进程ID,刚开始时这两个文件都是空的).

这两个文件里面的配置格式如下(可以有多条):

    ID-inside-ns   ID-outside-ns   length

举个例子, 0 1000 256这条配置就表示父user namespace中的1000~1256映射到新user namespace中的0~256。

系统默认的user namespace没有父user namespace,但为了保持一致,kernel提供了一个虚拟的uid和gid map文件,看起来是这样子的:
dev@ubuntu:~$ cat /proc/$$/uid_map
0 0 4294967295

那么谁可以向这个文件中写配置呢?

/proc/PID/uid_map和/proc/PID/gid_map的拥有者是创建新user namespace的这个user,所以和这个user在一个user namespace的root账号可以写。但这个user自己有没有写map文件权限还要看它有没有CAP_SETUID和CAP_SETGID的capability。

注意:只能向map文件写一次数据,但可以一次写多条,并且最多只能5条

关于capability的详细介绍可以参考这里,简单点说,原来的Linux就分root和非root,很多操作只能root完成,比如修改一个文件的owner,后来Linux将root的一些权限分解了,变成了各种capability,只要拥有了相应的capability,就能做相应的操作,不需要root账户的权限。

下面我们来看看如何用dev账号映射uid和gid

复制代码
#--------------------------第一个shell窗口----------------------
#获取当前bash的pid
nobody@ubuntu:~$ echo $$
24126

#--------------------------第二个shell窗口----------------------
#dev是map文件的owner
dev@ubuntu:~$ ls -l /proc/24126/uid_map /proc/24126/gid_map
-rw-r--r-- 1 dev dev 0 7月  24 23:11 /proc/24126/gid_map
-rw-r--r-- 1 dev dev 0 7月  24 23:11 /proc/24126/uid_map

#但还是没有权限写这个文件
dev@ubuntu:~$ echo '0 1000 100' > /proc/24126/uid_map
bash: echo: write error: Operation not permitted
dev@ubuntu:~$ echo '0 1000 100' > /proc/24126/gid_map
bash: echo: write error: Operation not permitted
#当前用户运行的bash进程没有CAP_SETUID和CAP_SETGID的权限
dev@ubuntu:~$ cat /proc/$$/status | egrep 'Cap(Inh|Prm|Eff)'
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000

#为/binb/bash设置capability,
dev@ubuntu:~$ sudo setcap cap_setgid,cap_setuid+ep /bin/bash
#重新加载bash以后我们看到相应的capability已经有了
dev@ubuntu:~$ exec bash
dev@ubuntu:~$ cat /proc/$$/status | egrep 'Cap(Inh|Prm|Eff)'
CapInh: 0000000000000000
CapPrm: 00000000000000c0
CapEff: 00000000000000c0


#再试一次写map文件,成功了
dev@ubuntu:~$ echo '0 1000 100' > /proc/24126/uid_map
dev@ubuntu:~$ echo '0 1000 100' > /proc/24126/gid_map
dev@ubuntu:~$
#再写一次就失败了,因为这个文件只能写一次
dev@ubuntu:~$ echo '0 1000 100' > /proc/24126/uid_map
bash: echo: write error: Operation not permitted
dev@ubuntu:~$ echo '0 1000 100' > /proc/24126/gid_map
bash: echo: write error: Operation not permitted

#后续测试不需要CAP_SETUID了,将/bin/bash的capability恢复到原来的设置
dev@ubuntu:~$ sudo setcap cap_setgid,cap_setuid-ep /bin/bash
dev@ubuntu:~$ getcap /bin/bash
/bin/bash =

#--------------------------第一个shell窗口----------------------
#回到第一个窗口,id已经变成0了,说明映射成功
nobody@ubuntu:~$ id
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)

#--------------------------第二个shell窗口----------------------
#回到第二个窗口,确认map文件的owner,这里24126是新user namespace中的bash
dev@ubuntu:~$ ls -l /proc/24126/
......
-rw-r--r-- 1 dev dev 0 7月  24 23:13 gid_map
dr-x--x--x 2 dev dev 0 7月  24 23:10 ns
-rw-r--r-- 1 dev dev 0 7月  24 23:13 uid_map
......

#--------------------------第一个shell窗口----------------------
#重新加载bash,提示有root权限了
nobody@ubuntu:~$ exec bash
root@ubuntu:~#

#0000003fffffffff表示当前运行的bash拥有所有的capability
root@ubuntu:~# cat /proc/$$/status | egrep 'Cap(Inh|Prm|Eff)'
CapInh: 0000000000000000
CapPrm: 0000003fffffffff
CapEff: 0000003fffffffff
#--------------------------第二个shell窗口----------------------
#回到第二个窗口,发现owner已经变了,变成了root
#目前还不清楚为什么有这样的机制
dev@ubuntu:~$ ls -l /proc/24126/
......
-rw-r--r-- 1 root root 0 7月  24 23:13 gid_map
dr-x--x--x 2 root root 0 7月  24 23:10 ns
-rw-r--r-- 1 root root 0 7月  24 23:13 uid_map
......
#虽然不能看目录里有哪些文件,但是可以读里面文件的内容
dev@ubuntu:~$ ls -l /proc/24126/ns
ls: cannot open directory '/proc/24126/ns': Permission denied
dev@ubuntu:~$ readlink /proc/24126/ns/user
user:[4026532464]


#--------------------------第一个shell窗口----------------------
#和第二个窗口一样的结果
root@ubuntu:~# ls -l /proc/24126/ns
ls: cannot open directory '/proc/24126/ns': Permission denied
root@ubuntu:~# readlink /proc/24126/ns/user
user:[4026532464]

#仍然不能访问/root目录,因为他的拥有着是nobody
root@ubuntu:~# ls -l /|grep root
drwx------   3 nobody nogroup  4096 7月   8 18:39 root
root@ubuntu:~# ls /root
ls: cannot open directory '/root': Permission denied

#对于原来/home/dev下的内容,显示的owner已经映射过来了,由dev变成了新namespace中的root,
#当前root用户可以访问他里面的内容
root@ubuntu:~# ls -l /home
drwxr-xr-x 8 root root 4096 7月  21 18:35 dev
root@ubuntu:~# touch /home/dev/temp01
root@ubuntu:~#

#试试设置主机名称
root@ubuntu:~# hostname container001
hostname: you must be root to change the host name
#修改失败,说明这个新user namespace中的root账号在父user namespace里面不好使
#这也正是user namespace所期望达到的效果,当访问其他user namespace里的资源时,
#是以其他user namespace中的相应账号的权限来执行的,
#比如这里root对应父user namespace的账号是dev,所以改不了系统的hostname
复制代码

那是不是把系统默认user namespace的root账号映射到新的user namespace中,新user namespace的root就可以修改默认user namespace中的hostname呢?

复制代码
#--------------------------第三个shell窗口----------------------
#重新打开一个窗口
#这里不再手动映射uid和gid,而是利用unshare命令的-r参数来帮我们完成映射,
#指定-r参数后,unshare将会帮助我们将当前运行unshare的账号映射成新user namesapce的root账号
#这里用了sudo,目的是让root账号来运行unshare命令,
#这样就将外面的root账号映射成新user namespace的root账号
dev@ubuntu:~$ sudo unshare --user -r /bin/bash
root@ubuntu:~# id
uid=0(root) gid=0(root) groups=0(root)

#确认是用root映射root
root@ubuntu:~# echo $$
24283
root@ubuntu:~# cat /proc/24283/uid_map
         0          0          1
root@ubuntu:~# cat /proc/24283/gid_map
         0          0          1

#可以访问/root目录下的东西,但无法操作/home/dev/下的文件
root@ubuntu:~# ls -l / |grep root$
drwx------   6 root root  4096 8月  14 23:11 root
root@ubuntu:~# touch /root/temp01
root@ubuntu:~# ls -l /home
drwxr-xr-x 11 nobody nogroup 4096 8月  14 23:13 dev
root@ubuntu:~# touch /home/dev/temp01
touch: cannot touch '/home/dev/temp01': Permission denied

#尝试修改hostname,还是失败
root@ubuntu:~# hostname container001
hostname: you must be root to change the host name
复制代码

上面的例子中虽然是将root账号映射到了新user namespace的root账号上,但修改hostname、访问/home/dev下的文件依然失败,那是因为不管怎么映射,当用子user namespace的账号访问父user namespace的资源的时候,它启动的进程的capability都为空,所以这里子user namespace的root账号到父namespace中就相当于一个普通的账号。

注意:对于map文件来说,在父user namespace和子user namespac中打开子user namespace中进程的这个文件看到的都是同样的内容,但如果是在其他的user namespace中打开这个map文件,‘ID-outside-ns’表示的就是映射到当前user namespace的ID.这里听起来有点绕,看下面的例子

复制代码
#--------------------------打开一个新窗口----------------------
#创建一个新的user namespace,并取名container001
dev@ubuntu:~$ unshare --user --uts -r /bin/bash
root@ubuntu:~# hostname container001
root@ubuntu:~# exec bash
#记下bash的pid
root@container001:~# echo $$
27898

#在container001里面创建新的namespace container002
root@container001:~# unshare --user --uts -r /bin/bash
root@container001:~# hostname container002
root@container001:~# exec bash
#记下bash的pid
root@container002:~# echo $$
28066

#查看自己namespace中进程的uid map文件
#这里表示父user namespace的0映射到了当前namespace的0
root@container002:~# cat /proc/28066/uid_map
         0          0          1

#--------------------------再打开一个新窗口----------------------
#在系统默认namespace中查看同样这个文件,发现和上面的显示的不一样
#因为默认namespace是container002的爷爷,所以他们两个里面看到的东西有可能不一样
#这里表示当前user namespace的账号1000映射到了进程28066所在user namespace的账号0
#当然如果上面是用root账号创建的container001,这里显示的内容就和上面一样了
dev@ubuntu:~$ cat /proc/28066/uid_map
         0       1000          1

#我们再进入到container001,在里面看看这个文件,发现和在ontainer002看到的结果一样
#说明对于进程28066来说,在他自己所在的user namespace和他的父user namespace看到的map文件内容是一样的
dev@ubuntu:~$ nsenter --user --uts -t 27898 --preserve-credentials bash
root@container001:~# cat /proc/28066/uid_map
         0          0          1
#默认情况下,nsenter会调用setgroups函数去掉root group的权限,
#这里--preserve-credentials是为了让nsenter不调用setgroups函数,因为调用这个函数需要root权限

#测试完成后可以关闭这两个窗口,后面不会再用到了
复制代码

user namespace的owner

当一个用户创建一个新的user namespace的时候,这个用户就是这个新user namespace的owner,在父user namespace的这个用户就会拥有新user namespace及其所有子孙user namespace的所有capabilities.

复制代码
#--------------------------第四个shell窗口----------------------
#新建用户test用于测试
dev@ubuntu:~$ sudo useradd test
dev@ubuntu:~$ sudo passwd test
Enter new UNIX password:
Retype new UNIX password:
passwd: password updated successfully

#切换到test账户并创建新的user namespace
#为了便于区分,同时创建新的uts namespace
dev@ubuntu:~$ su test
Password:
test@ubuntu:/home/dev$ unshare --user --uts -r /bin/bash

#设置一个容易区分的hostname
root@ubuntu:/home/dev# hostname container001
root@ubuntu:/home/dev# exec bash
root@container001:/home/dev# readlink /proc/$$/ns/user
user:[4026532463]
root@container001:/home/dev# echo $$
24419

#--------------------------第五个shell窗口----------------------
#使用dev账号新建一个user namespace
dev@ubuntu:~$ unshare --user --uts -r /bin/bash
root@ubuntu:~# hostname container002
root@ubuntu:~# exec bash
root@container002:~# readlink /proc/$$/ns/user
user:[4026532464]
root@container002:~# echo $$
24435

#--------------------------第六个shell窗口----------------------
#用dev账号往container002中加入新的进程/bin/bash成功,因为dev是container002的owner
dev@ubuntu:~$ nsenter --user -t 24435 --preserve-credentials --uts /bin/bash
root@container002:~# id
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
root@container002:~# readlink /proc/$$/ns/user
user:[4026532464]

#回到默认user namespace
root@container002:~# exit
exit
dev@ubuntu:~$

#因为container001的owner是test,用dev账号往container001中加入新的进程/bin/bash失败
dev@ubuntu:~$ nsenter --user -t 24419 --preserve-credentials --uts /bin/bash
nsenter: cannot open /proc/24419/ns/user: Permission denied

#用root账号往container001中加入新的进程/bin/bash成功
dev@ubuntu:~$ sudo nsenter --user -t 24419 --preserve-credentials --uts /bin/bash
nobody@container001:~$ readlink /proc/$$/ns/user
user:[4026532463]
#由于root账号没有映射到container001中,所以这里在container001中看到的账号是nobody
nobody@container001:~$ id
uid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup)

#退出container001,便于后续测试
nobody@container001:~$ exit
dev@ubuntu:~$
#--------------------------第五个shell窗口----------------------
#回到第5个窗口,继续创建一个新的user namespace
root@container002:~# unshare --user --uts -r /bin/bash
root@container002:~# hostname container003
root@container002:~# exec bash
root@container003:~# readlink /proc/$$/ns/user
user:[4026532471]
root@container003:~# echo $$
24533

#--------------------------第六个shell窗口----------------------
#回到第6个窗口,用dev账号往container003(孙子user namespace)中加入新的bash进程,成功,
#说明dev拥有孙子user namespace的capabilities
dev@ubuntu:~$ nsenter --user -t 24533 --preserve-credentials --uts /bin/bash
root@container003:~# readlink /proc/$$/ns/user
user:[4026532471]
复制代码

结束语

本文先介绍了user namespace的一些概念,然后介绍如何配置mapping文件,最后介绍了user namespace的owner。从上面的介绍中可以看出,user namespace还是比较复杂的,要了解user namespace,需要对Linux下的权限有一个基本的了解。下一篇中将继续介绍user namespace和其他namespace的关系,以及一些其他的注意事项。

 

本篇将主要介绍user namespace和其他类型的namespace的关系。

权限涉及的范围非常广,所以导致user namespace比其他的namespace要复杂; 同时权限也是容器安全的基础,所以user namespace非常重要。

本篇所有例子都在ubuntu-server-x86_64 16.04下执行通过

和其他类型的namespace一起使用

除了user namespace外,创建其它类型的namespace都需要CAP_SYS_ADMIN的capability。当新的user namespace创建并映射好uid、gid了之后, 这个user namespace的第一个进程将拥有完整的所有capabilities,意味着它就可以创建新的其它类型namespace

复制代码
#先记下默认的user namespace编号
dev@ubuntu:~$ readlink /proc/$$/ns/user
user:[4026531837]

#用非root账号创建新的user namespace
dev@ubuntu:~$ unshare --user -r /bin/bash
root@ubuntu:~# readlink /proc/$$/ns/user
user:[4026532463]

#虽然新user namespace的root账号映射到外面的dev账号
#但还是能创建新的ipc namespace,因为当前bash进程拥有全部的capabilities
root@ubuntu:~# cat /proc/$$/status | egrep 'Cap(Inh|Prm|Eff)'
CapInh: 0000000000000000
CapPrm: 0000003fffffffff
CapEff: 0000003fffffffff
root@ubuntu:~# readlink /proc/$$/ns/ipc
ipc:[4026531839]
root@ubuntu:~# unshare --ipc /bin/bash
root@ubuntu:~# readlink /proc/$$/ns/ipc
ipc:[4026532469]
复制代码

当然我们也可以不用这么一步一步的创建,而是一步到位

 

复制代码
dev@ubuntu:~$ readlink /proc/$$/ns/user
user:[4026531837]
dev@ubuntu:~$ readlink /proc/$$/ns/ipc
ipc:[4026531839]
dev@ubuntu:~$ unshare --user -r --ipc /bin/bash
root@ubuntu:~# readlink /proc/$$/ns/user
user:[4026532463]
root@ubuntu:~# readlink /proc/$$/ns/ipc
ipc:[4026532469]
复制代码

在unshare的实现中,就是传入了CLONE_NEWUSER | CLONE_NEWIPC,大致如下

 

unshare(CLONE_NEWUSER | CLONE_NEWIPC);

在上面这种情况下,内核会保证CLONE_NEWUSER先被执行,然后执行剩下的其他CLONE_NEW*,这样就使得不用root账号而创建新的容器成为可能,这条规则对于clone函数也同样适用。

和其他类型namespace的关系

Linux下的每个namespace,都有一个user namespace和他关联,这个user namespace就是创建相应namespace时进程所属的user namespace,相当于每个namespace都有一个owner(user namespace),这样保证对任何namespace的操作都受到user namespace权限的控制。这也是上一篇中为什么sethostname失败的原因,因为要修改的uts namespace属于的父user namespace,而新user namespace的进程没有老user namespace的任何capabilities。

这里可以看看uts namespace的结构体,里面有一个指向user namespace的指针,指向他所属于的user namespace,其他类型的namespace也类似。

struct uts_namespace {
  struct kref kref;
  struct new_utsname name;
  struct user_namespace *user_ns;
  struct ns_common ns;
};

不和任何user namespace关联的资源

在系统中,有些需要特权操作的资源没有跟任何user namespace关联,比如修改系统时间(需要CAP_SYS_MODULE)、创建设备(需要CAP_MKNOD),这些操作只能由initial user namespace里有相应权限的进程来操作(这里initial user namespace就是系统启动后的默认user namespace)。

和mount namespace的关系

  • 当和mount namespace一起用时,不能挂载基于块设备的文件系统,但是可以挂载下面这些文件系统

复制代码
#摘自user namespaces帮助文件: 
     #http://man7.org/linux/man-pages/man7/user_namespaces.7.html
     * /proc (since Linux 3.8)  
     * /sys (since Linux 3.8) 
     * devpts (since Linux 3.9)
     * tmpfs (since Linux 3.9)
     * ramfs (since Linux 3.9)
     * mqueue (since Linux 3.9)
     * bpf (since Linux 4.4)
复制代码

示例

复制代码
#创建新的user和mount namespace
dev@ubuntu:~$ unshare --user -r --mount bash
root@ubuntu:~# mkdir ./mnt
#查找挂载到根目录的设备
root@ubuntu:~#  mount|grep " / "
/dev/mapper/ubuntu--vg-root on / type ext4 (rw,relatime,errors=remount-ro,data=ordered)
#确认这个设备肯定是块设备
root@ubuntu:~# ls -l /dev/mapper/ubuntu--vg-root
lrwxrwxrwx 1 nobody nogroup 7 7月  30 11:22 /dev/mapper/ubuntu--vg-root -> ../dm-0
root@ubuntu:~# file /dev/dm-0
/dev/dm-0: block special (252/0)
#尝试把他挂载到其他目录,结果挂载失败,说明在新的user namespace下没法挂载块设备
root@ubuntu:~# mount /dev/mapper/ubuntu--vg-root ./mnt
mount: /dev/mapper/ubuntu--vg-root is write-protected, mounting read-only
mount: cannot mount /dev/mapper/ubuntu--vg-root read-only
#即使是root账号映射过去也不行(这里mount的错误提示的好像不太准确)
root@ubuntu:~$ exit
exit
dev@ubuntu:~$ sudo unshare --user -r --mount bash
root@ubuntu:~# mount /dev/mapper/ubuntu--vg-root ./mnt
mount: /dev/mapper/ubuntu--vg-root is already mounted or /home/dev/mnt busy
       /dev/mapper/ubuntu--vg-root is already mounted on /

#由于当前pid namespace不属于当前的user namespace,所以挂载/proc失败
root@ubuntu:~# mount -t proc none ./mnt
mount: permission denied
#创建新的pid namespace,然后挂载成功
root@ubuntu:~# unshare --pid --fork bash
root@ubuntu:~# mount -t proc none ./mnt
root@ubuntu:~# mount |grep mnt|grep proc
none on /home/dev/mnt type proc (rw,nodev,relatime)
root@ubuntu:~# exit
exit

#只能通过bind方式挂载devpts,直接mount报错
root@ubuntu:~# mount -t devpts devpts ./mnt
mount: wrong fs type, bad option, bad superblock on devpts,
       missing codepage or helper program, or other error

       In some cases useful info is found in syslog - try
       dmesg | tail or so.
root@ubuntu:~# mount --bind /dev/pts ./mnt
root@ubuntu:~# mount|grep mnt|grep devpts
devpts on /home/dev/mnt type devpts (rw,nosuid,noexec,relatime,mode=600,ptmxmode=000)

#sysfs直接mount和bind mount都不行
root@ubuntu:~# mount -t sysfs sysfs ./mnt
mount: permission denied
root@ubuntu:~# mount --bind /sys ./mnt
mount: wrong fs type, bad option, bad superblock on /sys,
       missing codepage or helper program, or other error

       In some cases useful info is found in syslog - try
       dmesg | tail or so.
#TODO: 对于sysfs和devpts,和帮助文件中描述的对不上,不确定是我理解有问题,还是测试环境有问题,等以后有新的理解后再来更新

# 挂载tmpfs成功
root@ubuntu:~# mount -t tmpfs tmpfs ./mnt
root@ubuntu:~# mount|grep mnt|grep tmpfs
tmpfs on /home/dev/mnt type tmpfs (rw,nodev,relatime,uid=1000,gid=1000)
#ramfs和tmpfs类似,都是内存文件系统,这里就不演示了

#对mqueue和bpf不太熟悉,在这里也不演示了
复制代码
  • 当mount namespace和user namespace一起用时,就算老mount namespace中的mount point是shared并且用unshare命令时指定了--propagation shared,新mount namespace里面的挂载点的propagation type还是slave。这样就防止了在新user namespace里面mount的东西被外面父user namespace中的进程看到。

复制代码
#准备目录和disk
dev@ubuntu:~$ mkdir -p disks/disk1
dev@ubuntu:~$ dd if=/dev/zero bs=1M count=32 of=./disks/disk1.img
dev@ubuntu:~$ mkfs.ext2 ./disks/disk1.img

#mount好disk,确认是shared
dev@ubuntu:~$ sudo mount /home/dev/disks/disk1.img /home/dev/disks/disk1
dev@ubuntu:~$ cat /proc/self/mountinfo |grep disk| sed 's/ - .*//'
164 24 7:1 / /home/dev/disks/disk1 rw,relatime shared:105

#先不创建user namespace,看看效果,便于和后面的结果比较,
#当不和user namespace一起用时,新mount namespace中的挂载点为shared
dev@ubuntu:~$ sudo unshare --mount --propagation shared /bin/bash
root@ubuntu:~# cat /proc/self/mountinfo |grep disk| sed 's/ - .*//'
220 174 7:1 / /home/dev/disks/disk1 rw,relatime shared:105
root@ubuntu:~# exit
exit

#创建mount namesapce的同时,创建user namespace,
#可以看出,虽然指定的是--propagation shared,但得到的结果还是slave(master:105)
#由于指定了--propagation shared, 系统为我们新创建了一个peer group(shared:154),
#并让新mount namespace中的挂载点属于它,
#这里同时也说明一个挂载点可以属于多个peer group。
dev@ubuntu:~$ unshare --user -r --mount --propagation shared /bin/bash
root@ubuntu:~# cat /proc/self/mountinfo |grep disk| sed 's/ - .*//'
220 174 7:1 / /home/dev/disks/disk1 rw,relatime shared:154 master:105
复制代码

其他可以写map文件的情况

  1. 在有一种情况下,没有CAP_SETUID的权限也可以写uid_map和gid_map,那就是在父user namespace中用新user namespace的owner来写,但是限制条件是只能在里面映射自己的账号,不能映射其他的账号。

  2. 在新user namespace中用有CAP_SETUID权限的账号可以来写map文件,但跟上面的情况一样,只能映射自己。细心的朋友可能觉察到了,那就是都还没有映射,新user namespace里的账号怎么有CAP_SETUID的权限呢?关于这个问题请参考下一节(创建新user namespace时capabilities的变迁)的内容。

由于演示第二种情况需要写代码(可以参考unshare的实现),这里就只演示第一种情况:

复制代码
#--------------------------第一个shell窗口----------------------
#创建新的user namespace并记下当前shell的pid
dev@ubuntu:~$ unshare --user /bin/bash
nobody@ubuntu:~$ echo $$
25430
#--------------------------第二个shell窗口----------------------
#映射多个失败
dev@ubuntu:~$ echo '0 1000 100' > /proc/25430/uid_map
-bash: echo: write error: Operation not permitted
#只映射自己成功,1000是dev账号的ID
dev@ubuntu:~$ echo '0 1000 1' > /proc/25430/uid_map

#设置setgroups为"deny"后,设置gid_map成功
dev@ubuntu:~$ echo '0 1000 1' > /proc/25430/gid_map
-bash: echo: write error: Operation not permitted
dev@ubuntu:~$ cat /proc/25430/setgroups
allow
dev@ubuntu:~$ echo "deny" > /proc/25430/setgroups
dev@ubuntu:~$ cat /proc/25430/setgroups
deny
dev@ubuntu:~$ echo '0 1000 1' > /proc/25430/gid_map
dev@ubuntu:~$

#--------------------------第一个shell窗口----------------------
#回到第一个窗口后重新加载bash,显示当前账号已经是root了
nobody@ubuntu:~$ exec bash
root@ubuntu:~# id
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)
复制代码

上面写了"deny"到文件/proc/[pid]/setgroups, 是为了限制在新user namespace里面调用setgroups函数来设置groups,这个主要是基于安全考虑。考虑这样一种情况,一个文件的权限为"rwx---rwx",意味着other group比自己group有更大的权限,当用setgroups(2)去掉自己所属的相应group后,会获得更大的权限,这个在没有user namespace之前不是个问题,因为调用setgroups需要CAP_SETGID权限,但有了user namespace之后,一个普通的账号在新的user namespace中就有了所有的capabilities,于是他可以通过调用setgroups的方式让自己获得更大的权限。

创建新user namespace时capabilities的变迁

复制代码
            clone函数                              unshare函数
+----------------------------------+    +----------------------------------+
|   父进程        |    子进程       |    |              当前进程             |
|----------------------------------+    |----------------------------------+
|   启动                           |    |               启动                |
|    | ①                           |    |                | ①               |
|    ↓                             |    |                ↓                 |
|  clone  ----------→  启动        |    |              unshare             |
|    |                  | ②        |    |                | ②               |
|    |                  ↓          |    |                ↓                 |
|    | ④               exec        |    |               exec               |
|    |                  | ③        |    |                | ③               |
|    ↓                  ↓          |    |                ↓                 |
|   结束               结束         |    |               结束               |
+----------------------------------+    +----------------------------------+
复制代码

上面描述了进程在调用不同函数后所处的不同阶段,clone函数会创建新的进程,unshare不会。

  • ①: 处于父user namespace中,进程拥有的capabilities由调用该进程的user决定

  • ②: 处于子user namespace中,这个时候进程拥有所在子user namespace的所有capabilities,所以在这里可以写当前进程的uid/gid map文件,但只能映射当前账号,不能映射任意账号。这里就回答了上一节中为什么没有映射账号但在子user namespace有CAP_SETUID权限的问题。

  • ③: 处于子user namespace中,调用exec后,由于没有映射,系统会去掉当前进程的所有capabilities(这个是exec的机制),所以到这个位置的时候当前进程已经没有任何capabilities了

  • ④: 处于父user namespace中,和①一样。但这里是一个很好的点来设置子user namespace的map文件,如果能在exec执行之前设置好子进程的map文件,exec执行完后当前进程还是有相应的capabilities。但是如果没有在exec执行之前设置好,而是exec之后设置,当前进程还是没有capabilities,就需要再次调用exec后才有。如何在④这个地方设置子进程的map文件需要一点点技巧,可以参考帮助文件最后面的示例代码。

对于unshare来说,由于没有④,所有没法映射任意账号到子user namespace,这也是为什么unshare命令只能映射当前账号的原因。

其它

和pid namespace类似,当在程序中用UNIX domain sokcet将一个user namespace的uid或者gid发送给另一个user namespace中的进程时,内核会自动映射成目的user namespace中对应的uid或者gid。

 

 

本文将演示如何利用namespace创建一个完整的容器,并在里面运行busybox。如果对namespace不是很熟悉,请先参考前面几遍介绍不同类型namespace的文章。

busybox是一个Linux下的可执行程序,采用静态链接,不依赖于其他任何动态库。他里面实现了一些Linux下常用的命令,如ls,hostname,date,ps,mount等等,详细的介绍请参考它的官网

本篇所有例子都在ubuntu-server-x86_64 16.04下执行通过

准备container的根目录

复制代码
#创建一个单独的目录,后续所有的操作都在该目录下进行,目录名称无特殊要求
dev@ubuntu:~$ mkdir chroot && cd chroot
#下载编译好的busybox可执行文件
dev@ubuntu:~/chroot$ wget https://busybox.net/downloads/binaries/1.21.1/busybox-x86_64
#创建new_root/bin目录,new_root将会是新容器的根目录,bin目录用来放busybox
#由于/bin默认就在PATH中,所以里面放的程序都可以直接在shell里面执行,不需要带完整的路径
dev@ubuntu:~/chroot$ mkdir -p new_root/bin
dev@ubuntu:~/chroot$ chmod +x ./busybox-x86_64

#将busybox-x86_64移到bin目录下,并重命名为busybox
dev@ubuntu:~/chroot$ mv busybox-x86_64 new_root/bin/busybox
#运行ls试试,确保busybox能正常工作
dev@ubuntu:~/chroot$ ./new_root/bin/busybox ls
new_root

#安装busybox到bin目录,不安装的话每次执行ls命令都需要使用上面那种格式: busybox ls
#安装之后就会创建一个ls到busybox的硬链接,这样执行ls的时候就不用再输入前面的busybox了
dev@ubuntu:~/chroot$ ./new_root/bin/busybox --install ./new_root/bin/

#运行下bin下面的ls,确保安装成功
dev@ubuntu:~/chroot$ ls -l ./new_root/bin/ls
-rwxrwxr-x 348 dev dev 973200 7月   9  2013 ./new_root/bin/ls
dev@ubuntu:~/chroot$ ./new_root/bin/ls
new_root

#使用chroot命令,切换根目录
dev@ubuntu:~/chroot$ sudo chroot ./new_root/ sh

#切换成功,由于new_root下面只有busybox,没有任何配置文件,
#所以shell的提示符里只包含当前目录
#尝试运行几个命令,一切正常
/ # ls
bin
/ # which ls
/bin/ls
/ # hostname
ubuntu
/ # id
uid=0 gid=0 groups=0
/ # exit
dev@ubuntu:~/chroot$ 
复制代码

创建容器并做相关配置

 

复制代码
#新建/data目录用来在主机和容器之间共享数据
dev@ubuntu:~/chroot$ sudo mkdir /data
dev@ubuntu:~/chroot$ sudo chown dev:dev /data
dev@ubuntu:~/chroot$ touch /data/001

#创建新的容器,指定所有namespace相关的参数,
#这里--propagation private是为了让容器里的mount point都变成private的,
#这是因为pivot_root命令需要原来根目录的挂载点为private,
#只有我们需要在host和container之间共享挂载信息的时候,才需要使用shared或者slave类型
dev@ubuntu:~/chroot$ unshare --user --mount --ipc --pid --net --uts -r --fork --propagation private bash
#设置容器的主机名
root@ubuntu:~/chroot# hostname container01
root@ubuntu:~/chroot# exec bash

#创建old_root用于pivot_root命令,创建data目录用于绑定/data目录
root@container01:~/chroot# mkdir -p ./new_root/old_root/ ./new_root/data/

#由于pivot_root命令要求老的根目录和新的根目录不能在同一个挂载点下,
#所以这里利用bind mount,在原地创建一个新的挂载点
root@container01:~/chroot# mount --bind ./new_root/ ./new_root/

#将/data目录绑定到new_root/data,这样pivot_root后,就能访问/data下的东西了
root@container01:~/chroot# mount --bind /data ./new_root/data

#进入new_root目录,然后切换根目录
root@container01:~/chroot# cd new_root/
root@container01:~/chroot/new_root# pivot_root ./ ./old_root/

#但shell提示符里显示的当前目录还是原来的目录,没有切换到‘/’下,
#这是因为当前运行的shell还是host里面的bash
root@container01:~/chroot/new_root# ls
bin  data  old_root

#重新加载new_root下面的shell,这样contianer和host就没有关系了,
#从shell提示符中可以看出,当前目录已经变成了‘/’
root@container01:~/chroot/new_root# exec sh
/ # 
#由于没有/etc目录,也就没有相关的profile,于是shell的提示符里面只包含当前路径。

#设置PS1环境变量,让shell提示符好看点,这里直接写了root在提示符里面,
#是因为我们新的container里面没有账号相关的配置文件,
#虽然系统知道当前账号的ID是0,但不知道账号的用户名是什么。
/ # export PS1='root@$(hostname):$(pwd)# '
root@container01:/# 

#没有/etc目录,没有user相关的配置文件,所以不知道ID为0的用户名是什么
root@container01:/# whoami
whoami: unknown uid 0

#mount命令依赖于/proc目录,所以这里mount操作失败
root@container01:/# mount
mount: no /proc/mounts

#重新mount /proc
root@container01:/# mkdir /proc
root@container01:/# mount -t proc none /proc

#这时可以看到所有的mount信息了,从host复制过来的mount信息都挂载在/old_root目录下
root@container01:/# mount
/dev/mapper/ubuntu--vg-root on /old_root type ext4 (rw,relatime,errors=remount-ro,data=ordered)
udev on /old_root/dev type devtmpfs (rw,nosuid,relatime,size=1005080k,nr_inodes=251270,mode=755)
devpts on /old_root/dev/pts type devpts (rw,nosuid,noexec,relatime,mode=600,ptmxmode=000)
tmpfs on /old_root/dev/shm type tmpfs (rw,nosuid,nodev)
......
/dev/mapper/ubuntu--vg-root on / type ext4 (rw,relatime,errors=remount-ro,data=ordered)
/dev/mapper/ubuntu--vg-root on /data type ext4 (rw,relatime,errors=remount-ro,data=ordered)
none on /proc type proc (rw,nodev,relatime)

#umount掉/old_root下的所有mount point
root@container01:/# umount -l /old_root
#这时候就只剩下根目录,/proc,/data三个挂载点了
root@container01:/# mount
/dev/mapper/ubuntu--vg-root on / type ext4 (rw,relatime,errors=remount-ro,data=ordered)
/dev/mapper/ubuntu--vg-root on /data type ext4 (rw,relatime,errors=remount-ro,data=ordered)
none on /proc type proc (rw,nodev,relatime)

#试试cd命令,提示失败, $HOME还是指向老的/home/dev,
#除了$HOME之外,还有其他一些环境变量也有同样的问题。
#这主要是由于我们新的container中缺少配置文件,导致很多环境变量没有更新。
root@container01:/# cd
sh: cd: can not cd to /home/dev

#试试ps,显示的是container里面启动的进程
root@container01:/# ps
PID   USER     TIME   COMMAND
    1 0          0:00 sh
   55 0          0:00 ps

#touch文件001成功
root@container01:/# touch /data/001
#新创建一个002文件
root@container01:/# touch /data/002
root@container01:/# ls /data
001  002

#退出contianer01,在/data目录能看到我们上面在container01种创建的002文件
root@container01:/# exit
dev@ubuntu:~/chroot$ ls /data
001  002
复制代码

结束语

本文利用busybox和pivot_root演示了如何创建一个简单的容器,并且实现了在host和container之间共享文件夹。这个容器的功能非常简单,很多目录都没有构建,导致只能运行busybox里面的部分命令,有些命令运行时会有异常。要想构造一个完整易用的容器,还需要很多工作要做,这里只演示了冰山一角,在后续的“docker系列”中,将深入分析docker是如何一步一步帮助我们构建安全易用的contianer的,敬请期待。

 

 

摘自文档: https://segmentfault.com/a/1190000009732550

 

posted @   韩增  阅读(616)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?
点击右上角即可分享
微信分享提示