在Linux中使用namespace和cgroup实现进程的资源隔离和限制

这几年,Docker容器和其它资源虚拟化平台(比如Mesos)研究非常多,各种应用技术层出不穷,但是要想使用好容器,其底层实现技术原理必须要掌握清楚。其中,无论是LXC、Mesos还是Docker容器,都依赖于Linux内核底层技术namespace和cgroup,本文以简要方式演示如何使用namespace和cgroup来实现进程的资源隔离和限制。

1. 什么是Namespace和Cgroups

在Linux中,敲击man帮助手册命令,

- man namespaces
- man cgroups

可以获取到namespace和cgoups的相关文档定义,

A namespace wraps a global system resource in an abstraction that makes it appear to the processes within the namespace that they have their own isolated instance of the global resource. Changes to the global resource are visible to other processes that are members of the namespace, but are invisible to other processes. One use of namespaces is to implement containers……

Control cgroups, usually referred to as cgroups, are a Linux kernel feature which provides for grouping of tasks and resource tracking and limitations for those groups……

从上述定义,可以知道namespace/cgroup是可以控制指定进程对系统资源的支配调度,系统资源包括网络、进程间通信、进程空间、文件系统及其用户权限等。换句话说,namespace/cgroup对系统资源做了一层抽象,让系统资源对进程部分可见,在同一个namespace中的进程,可看到系统资源是一样的。

Linux提供Namespace/Cgroup功能,主要是为了实现Linux容器技术。

2. 启动一个进程

在进入演示步骤前,我们先了解下Linux如何启动一个进程。

在Linux中有三种方法来启动一个子进程,分别为

1) clone:从父进程中创建子进程,和fork不一样之处在于,可以更加灵活的指定子进程的运行上下文,也具有更为强大的进程调度功能。man namespaces
2) fork:该方法创造的子进程是几乎是父进程的完整副本(进程ID\内存锁等除外)。子进程和父进程各自独立执行,先后顺序不定。
3) vfork:该方法创建的子进程和父进程共享物理空间,包括堆栈,而且和fork不一样之处是,其会阻塞父进程的执行。换句话说,vfork保证子进程在父进程之前执行完毕。

演示2-1

创建一个代码文件clone.c

#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/utsname.h>
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

#define STACK_SIZE (1024*1024)

static char child_stack[STACK_SIZE];

int child_main(void * args) {
   printf("in child process, pid=%d\n", getpid());
   printf("quit child process...\n");
   return EXIT_SUCCESS;
}

int main(){
   printf("start...\n");
   printf("in parent process, pid=%d\n", getpid());
   int child_pid = clone(child_main, child_stack + STACK_SIZE, SIGCHLD, NULL);
   waitpid(child_pid, NULL, 0);
   printf("quit...\n");
   return EXIT_SUCCESS;
}

编译命令gcc clone.c -o clone.o

运行可执行文件clone.o,有如下console输出结果,

start...
in parent process, pid=4673
in child process, pid=4674
quit child process...
quit...

可以看到一个子进程(ID=4674)通过父进程启动,然后先后退出。

演示2-2

创建一个代码文件fork.c

#include <stdio.h>
#include <stdlib.h>

#include <sys/types.h>
#include <unistd.h>

int main(void) {
    int count = 1;
    int child;

    child = fork();

    if(child < 0) {
        perror("fork error : ");
    } else if(child == 0) {
        ++count;
        printf("in child process, pid=%d, count=%d (%p)\n", getpid(), count, &count);
    } else {
        ++count;
        printf("in parent process, pid=%d, count=%d (%p)\n", getpid(), count, &count);
    }

    printf("pid=%d quit now...\n", getpid());
    return EXIT_SUCCESS;
}

编译命令gcc fork.c -o fork.o

运行可执行文件fork.o,有如下console输出结果,

in parent process, pid=4253, count=2 (0x7ffd29cf1180)
pid=4253 quit now...
in child process, pid=4254, count=2 (0x7ffd29cf1180)
pid=4254 quit now...

可以看到在fork之后,程序分成两个进程同时执行并且分两次退出,其中上面的输出结果告知父进程先退出。

演示2-3

创建一个代码文件vfork.c

#include <stdio.h>
#include <stdlib.h>

#include <sys/types.h>
#include <unistd.h>

int main(void) {
    int count = 1;
    int child;

    child = vfork();

    if(child < 0) {
        perror("fork error : ");
    } else if(child == 0) {
        ++count;
        printf("in child process, pid=%d, count=%d (%p)\n", getpid(), count, &count);
        sleep(2);
        printf("pid=%d sleep 2 seconds, and now quit...\n", getpid());
        exit(0);
    } else {
        ++count;
        printf("in parent process, pid=%d, count=%d (%p)\n", getpid(), count, &count);
        printf("pid=%d quit now...\n", getpid());
        exit(0);
    }

    return EXIT_SUCCESS;
}

编译命令gcc vfork.c -o vfork.o

运行可执行文件vfork.o,有如下输出结果,

in child process, pid=4317, count=2 (0x7ffdbbed5830)
pid=4317 sleep 2 seconds, and now quit...
in parent process, pid=4316, count=3 (0x7ffdbbed5830)
pid=4316 quit now...

可以看到在vfork之后,程序分成两个进程同时执行并且分两次退出,其中父进程被子进程阻塞,在等待子进程sleep两秒退出后,父进程才退出。

3. 通过clone启动一个进程

通过clone方法可以启动一个进程,其中调用方法可以指定flags参数来标记父子进程之间需要共享/隔离的资源,

flag标记位 功能 隔离的资源 Linux
内核版本支持
CLONE_NEWIPC 在新的 IPC namespace启动新进程 进程间通信,包括信号量、消息队列和共享内存 since 2.6.19
CLONE_NEWNET 在新的network namespace启动新进程 网络设备、网络栈、端口 since 2.6.24
CLONE_NEWNS 在新的mount namespace启动新进程 挂载点(文件系统) since 2.6.19
CLONE_NEWPID 在新的PID namespace启动新进程 进程编号(新的进程树) since 2.6.24
CLONE_NEWUSER 在新的user namespace启动新进程 用户和用户组 在2.6.23引入,在3.5和3.8有更新
CLONE_NEWUTS 在新的UTS namespace启动新进程 主机名和域名 since 2.6.19
CLONE_NEWCGROUP 在新的cgroup namespace启动新进程 CPU、内存、磁盘读写等 since 4.6

更详细信息可以通过man clone命令来查看。

下面将上述各标记位来一步步实现新启子进程的各项资源隔离。

4. 隔离进程的主机名和域名

演示4

创建一个代码文件demo_uts.c

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

#define STACK_SIZE (1024*1024)

static char child_stack[STACK_SIZE];
char* const child_args[] = {
  "/bin/bash",
  NULL
};

int child_main(void * args) {
   printf("in child process\n");
   sethostname("NewHostName", 12);
   execv(child_args[0], child_args);
   printf("quit child process…\n");
   return 1;
}

int main(){
   printf("start parent process...\n");
   int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWUTS | SIGCHLD, NULL);
   waitpid(child_pid, NULL, 0);
   printf("quit parent process...\n");
   return 0;
}

编译命令gcc demo_uts.c -o demo_uts.o
请切换到root用户权限下(sudo su -),运行可执行文件demo_uts.o,有如下输出结果,

root@test:/home/test# ./demo_uts.o
start parent process...
in child process
root@NewHostName:/home/test#

可以看到当前进入子进程后,当前的host名字已经更改为NewHostName,这是当前进程在网络中使用的新主机名。

演示完毕后,别忘了输入exit以退出当前子进程,然后父进程安全退出,整个程序演示完毕。

root@NewHostName:/home/test# exit
exit
quit parent process...
root@test:/home/test#

注意,上述和后续演示需要切换到root用户权限下执行,这是因为子进程会更改网络等系统设置,需要相应的权限,这也是docker run需要root权限的原因。

5. 隔离进程间通信

在Linux中,敲击man帮助手册命令,

- ipcs:查看进程间通信设施
- ipcmk:创建进程间通信设施

在使用ipcs命令可以查看当前进程所能使用的通信设施,主要包括

- 消息队列
- 共享内存
- 信号量

如果想创建一个消息队列,可以通过命令ipcmk -Q test。运行Linux命令ipcs -q可以看到刚刚创建的test消息队列,

root@test:~# ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages
0x01bbd371 0 root 644 0 0

通过上述两个命令可以查看进程间的通信是否隔离。

演示5

创建一个代码文件demo_ipc.c

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

#define STACK_SIZE (1024*1024)

static char child_stack[STACK_SIZE];
char* const child_args[] = {
  "/bin/bash",
  NULL
};

int child_main(void * args) {
   printf("in child process\n");
   sethostname("NewHostName", 12);
   execv(child_args[0], child_args);
   printf("quit child process…\n");
   return 1;
}

int main(){
   printf("start parent process...\n");
   int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
   waitpid(child_pid, NULL, 0);
   printf("quit parent process...\n");
   return 0;
}

编译命令gcc demo_ipc.c -o demo_ipc.o
请切换到root用户权限下运行可执行文件demo_ipc.o,进入子进程,然后执行命令ipcs查看当前可见的进程间通信资源,

root@test:/home/test# ./demo_ipc.o
start parent process...
in child process
root@NewHostName:/home/test# ipcs -q
------ Message Queues --------
key msqid owner perms used-bytes messages

可以看到当前进入子进程后,当前没有刚刚创建的test消息队列,这说明子进程已经看不到父进程的进程间通信资源。如果此时在子进程中创建进程间通信资源,然后到root进程空间查看,root进程也是看不到子进程的通信资源。
新启动的进程间通信资源已经被隔离。
演示完毕后,别忘了输入exit以退出当前子进程和父进程,结束整个演示。

6. 隔离进程空间

Linux中有个进程树的概念,在进程树中,有一个PID=1的进程,该进程是一个非常特殊的进程,它拥有特权,可以对所有后续启动的子进程资源监控和回收。
在Linux系统中可以通过pstree –g来查看整个进程树和进程ID。
在进程树中,所有父节点可以看到子节点的进程,并通过信号灯方式对子节点的进程产生影响(比如发送OOM信号,告知子进程退出)。子节点看不到父节点PID namespace中的任何内容。
下面的演示将为新启的进程实现独立的进程空间,拥有自己的进程树和pid为1的特权进程。

演示6

创建一个代码文件demo_pid.c

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

#define STACK_SIZE (1024*1024)

static char child_stack[STACK_SIZE];
char* const child_args[] = {
  "/bin/bash",
  NULL
};

int child_main(void * args) {
   printf("in child process\n");
   sethostname("NewHostName", 12);
   execv(child_args[0], child_args);
   printf("quit child process…\n");
   return 1;
}

int main(){
   printf("start parent process...\n");
   int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
   waitpid(child_pid, NULL, 0);
   printf("quit parent process...\n");
   return 0;
}

编译命令gcc demo_pid.c -o demo_pid.o
请切换到root用户权限下运行可执行文件demo_pid.o,进入子进程,

root@test:/home/test# ./demo_pid.o
start parent process...
in child process
root@NewHostName:/home/test# mount -t proc proc /proc
root@NewHostName:/home/test# pstree -g
bash(1)───pstree(13)

可以看到在子进程中看到了一个全新的进程树,里面有个pid=1的进程,按照Linux的定义,这个进程将拥有管理当前进程空间的特权。对于子进程来说,它已经拥有了一个独立的进程空间。

注意,上述中执行了mount -t proc proc /proc命令,由于子进程和父进程的文件系统还没有隔离,该命令会重新加载子进程中的proc文件,从而执行pstree时能够读取到更新后的进程空间信息。该命令会对父进程的文件系统产生影响,若要恢复,同样在父进程中执行一次mount –t proc proc /proc命令

7. 隔离文件系统

Linux的文件系统挂载方式主要包括如下几种,

- 共享挂载(shared):父子进程空间的文件变化共享
- 从属挂载(slave):
- 私有挂载(private):子进程空间的文件变化不影响
- 不可绑定挂载(unbindable):该文件不允许挂载

在Linux中可以通过命令mount –help查看支持的文件挂载方式,不同的挂载方式决定了在各个挂载点发生文件变化时,如何通知其他挂载点。
进程中是通过隔离文件系统挂载点来隔离文件系统。

演示7

获取演示6的程序代码,将flags添加CLONE_NEWNS,则新启动的进程拥有隔离的文件系统。

8. 隔离用户权限

在Linux中用户ID为0的用户是一个拥有特权的超级用户。在内核版本2.2之前,Linux只区分普通用户和超级用户(root)的进程,超级用户的进程不受任何权限检查。从内核版本2.2之后,Linux将超级用户的权限进行单元区分,各个权限单元就叫capabilities,关于capabilities的权限列表,可以通过命令man capabilities查看。
下面的演示将为新启动的进程运行在一个ID为0的超级用户中。

演示8

创建一个代码文件demo_user.c

#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/utsname.h>
#include <sys/capability.h>
#include <sched.h>
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

#define STACK_SIZE (1024*1024)

static char child_stack[STACK_SIZE];
char* const child_args[] = {
  "/bin/bash",
  NULL
};

void set_uid_map(pid_t pid, int inside_id, int outside_id, int length){
   char path[256];
   sprintf(path, "/proc/%d/uid_map", pid);
   FILE* uid_map = fopen(path, "w");
   fprintf(uid_map, "%d %d %d", inside_id, outside_id, length);
   fclose(uid_map);
}

void set_gid_map(pid_t pid, int inside_id, int outside_id, int length){
   char path[256];
   sprintf(path, "/proc/%d/gid_map", pid);
   FILE* gid_map = fopen(path, "w");
   fprintf(gid_map, "%d %d %d", inside_id, outside_id, length);
   fclose(gid_map);
}

int child_main(void * args) {
   printf("in child process\n");
   cap_t caps;
   set_uid_map(getpid(), 0, 1000, 1);
   set_gid_map(getpid(), 0, 1000, 1);
   printf("eUID = %ld; eGID = %ld; ", (long) geteuid(), (long) getegid());
   caps = cap_get_proc();
   printf("capabilities: %s\n", cap_to_text(caps, NULL));
   execv(child_args[0], child_args);
   printf("quit child process…\n");
   return 1;
}

int main(){
   printf("start parent ...\n");
   int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWUSER | SIGCHLD, NULL);
   waitpid(child_pid, NULL, 0);
   printf("quit parent ...\n");
   return 0;
}

编译命令gcc demo_user.c -lcap -o demo_user.o
若编译出现下面的报错信息,

fatal error: sys/capability.h: No such file or directory
#include <sys/capability.h>

可以尝试安装如下依赖包来解决。

sudo apt-get install libcap-dev

为了方便查看效果,请切换到普通用户权限下运行可执行文件demo_user.o,进入子进程,

test@test:/home/test# ./demo_user.o
start parent process...
in child process
eUID = 0; eGID = 65534; capabilities: = cap_chown,……
root@test:/home/test# id
uid=0(root)……

可以看到当前子进程运行在root用户下。

注意,在程序代码中,

set_uid_map(getpid(), 0, 1000, 1);
set_gid_map(getpid(), 0, 1000, 1)

里面的1000是当前执行用户ID,该ID可能会随着系统用户发生变化,请使用Linux命令id查看用户ID,并相应进行替代。

9. Cgroup实现原理

在进行cgroup演示前,需要了解几个概念,

- Task (任务):一个进程或者线程
- Cgroup (控制组):cgroup技术对资源的控制以控制组为单位
- Subsystem(子系统):一个子系统就是一个资源调度器
- Hierachy (层级):控制组之间可以组织成层级的形式,子控制组继承父控制组的属性。

在Linux中可以通过如下命令查看相关信息,

- lscgroup:列出所有的cgroup
- lssubsys:列出所有的子系统,其中lssubsys –M列出子系统挂载点

若提示命令不存在,可以执行apt install cgroup-tools进行安装

接下来我们将演示如何通过cgroup来控制一个进程的cpu使用率。

演示9

创建一个循环执行的bash脚本long_task.sh

#!/bin/bash
x=0
while [ True ];do
    x=$x+1
    #sleep(100)
done;

执行该脚本。
通过Linux top命令,可以看到该任务已经在执行中,

PID USER %CPU %MEM TIME+ COMMAND
5947 test 97.4 0.4 1:23.82 bash

进程ID为5947,CPU占用率为97.4%,接近100%。

下一步我们切换目录到cpu子系统,创建一个test目录

cd /sys/fs/cgroup/cpu/
mkdir ./test

这个时候查看test目录,可以看到该目录下已经有了很多文件,

ls ./test
drwxr-xr-x 2 root root 0 10月 16 21:03 ./
dr-xr-xr-x 6 root root 0 10月 16 15:13 ../
-rw-r--r-- 1 root root 0 10月 16 21:03 cgroup.clone_children
-rw-r--r-- 1 root root 0 10月 16 21:03 cgroup.procs
-r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.stat
-rw-r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage
-r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_all
-r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_percpu
-r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_percpu_sys
-r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_percpu_user
-r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_sys
-r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_user
-rw-r--r-- 1 root root 0 10月 16 21:03 cpu.cfs_period_us
-rw-r--r-- 1 root root 0 10月 16 21:03 cpu.cfs_quota_us
-rw-r--r-- 1 root root 0 10月 16 21:03 cpu.shares
-r--r--r-- 1 root root 0 10月 16 21:03 cpu.stat
-rw-r--r-- 1 root root 0 10月 16 21:03 notify_on_release
-rw-r--r-- 1 root root 0 10月 16 21:03 tasks

我们执行如下命令,将进程5947的CPU使用率控制到50%,

cd /sys/fs/cgroup/cpu/test
echo 5947 > tasks
echo 50000 > cpu.cfs_quota_us

这个时候再通过Linux top命令查看任务执行情况,

PID USER %CPU %MEM TIME+ COMMAND
5947 test 50.1 0.4 1:23.82 bash

可以看到5947的CPU使用率已经从100%降到了50.1%,接近50%的CPU控制。

10. 结束语

到目前为止,我们使用Linux内核底层技术namespace和cgroup一步步地演示了进程的资源隔离和限制,通过这些演示可以很好地了解轻量级容器技术的实现原理。我们不仅要隔离进程的运行空间、文件系统、用户权限,让进程拥有独立的网络实体身份,而且要配置进程的CPU、内存、磁盘读写等。

目前轻量级虚拟化技术都在进程级别,可以看到,其进程的隔离性和安全性面临着挑战。

演示代码

本文演示资料运行在Ubuntu 16.10, Linux 内核版本为4.8.0-59-generic。
演示代码仓库地址:https://gitee.com/pphh/blog,可以通过如下git clone命令获取仓库代码,

git clone git@gitee.com:pphh/blog.git

上述代码样例在文件路径blog\171019_namespace_cgroup中。

参考资料