操作系统级虚拟化

KVM、XEN等虚拟化技术允许各个虚拟机拥有自己独立的操作系统。与KVM、XEN等虚拟化技术不同,所谓操作系统级虚拟化,也被称作容器化,是操作系统自身的一个特性,它允许多个相互隔离的用户空间实例的存在。这些用户空间实例也被称作为容器。普通的进程可以看到计算机的所有资源而容器中的进程只能看到分配给该容器的资源。通俗来讲,操作系统级虚拟化将操作系统所管理的计算机资源,包括进程、文件、设备、网络等分组,然后交给不同的容器使用。容器中运行的进程只能看到分配给该容器的资源。从而达到隔离与虚拟化的目的。

实现操作系统虚拟化需要用到Namespace及cgroups技术。

命名空间(Namespace)

在编程语言中,引入命名空间的概念是为了重用变量名或者服务例程名。在不同的命名空间中使用同一个变量名而不会产生冲突。Linux系统引入命名空间也有类似的作用。例如,在没有操作系统级虚拟化的Linux系统中,用户态进程从1开始编号(PID)。引入操作系统虚拟化之后,不同容器有着不同的PID命名空间,每个容器中的进程都可以从1开始编号而不产生冲突。

目前,Linux中的命名空间有6种类型,分别对应操作系统管理的6种资源:

  • 挂载点(mount point) CLONE_NEWNS
  • 进程(pid) CLONE_NEWPID
  • 网络(net) CLONE_NEWNET
  • 进程间通信(ipc) CLONE_NEWIPC
  • 主机名(uts) CLONE_NEWUTS
  • 用户(uid) CLONW_NEWUSER

将来还会引入时间、设备等对应的namespace.

Linux 2.4.19版本引入了第一个命名空间——挂载点,因为那时还没有其他类型的命名空间,所以clone系统调用中引入的flag就叫做CLONE_NEWNS

与命名空间相关的三个系统调用(system calls)

下面3个系统调用用来操作命名空间:

  • clone() —— 用来创建新的进程及新的命名空间,新的进程会被放到新的命名空间中
  • unshare() —— 创建新的命名空间但并不创建新的子进程,之后创建的子进程会被放到新创建的命名空间中去
  • setns() —— 将进程加入到已经存在的命名空间中

注意:这3个系统调用都不会改变调用进程(calling process)的pid命名空间,而是会影响其子进程的pid命名空间

命名空间本身并没用名字(囧),不同的命名空间用不同的inode号来标识,这也符合Linux用文件一统天下的惯例。可以在proc文件系统中查看一个进程所属的命名空间,例如,查看PID为4123的进程所属的命名空间:

kelvin@desktop:~$ ls -l /proc/4123/ns/
总用量 0
lrwxrwxrwx 1 kelvin kelvin 0 1226 16:28 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 kelvin kelvin 0 1226 16:28 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 kelvin kelvin 0 1226 16:28 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 kelvin kelvin 0 1226 16:28 net -> net:[4026531963]
lrwxrwxrwx 1 kelvin kelvin 0 1226 16:28 pid -> pid:[4026531836]
lrwxrwxrwx 1 kelvin kelvin 0 1226 16:28 user -> user:[4026531837]
lrwxrwxrwx 1 kelvin kelvin 0 1226 16:28 uts -> uts:[4026531838]

下面的代码演示了如何利用上述3个系统调用来操作进程的命名空间:

#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

#define STACK_SIZE (10 * 1024 * 1024)

char child_stack[STACK_SIZE];

int child_main(void* args) {
    pid_t child_pid = getpid();
    printf("I'm child process and my pid is %d \n", child_pid);
    // 子进程会被放到clone系统调用新创建的pid命名空间中, 所以其pid应该为1
    sleep(300);
    // 命名空间中的所有进程退出后该命名空间的inode将会被删除, 为后续操作保留它
    return 0;
}

int main() {
    /* Clone */
    pid_t child_pid = clone(child_main, child_stack + STACK_SIZE, \
        CLONE_NEWPID | SIGCHLD, NULL);
    if(child_pid < 0) {
        perror("clone failed");
    }

    /* Unshare */
    int ret = unshare(CLONE_NEWPID); // 父进程调用unshare, 创建了一个新的命名空间,
    //但不会创建子进程. 之后再创建的子进程将会被加入到新的命名空间中
    if (ret < 0) {
        perror("unshare failed");
    }
    int fpid = fork();
    if (fpid < 0) {
        perror("fork error");
    } else if (fpid == 0) {
        printf("I am child process. My pid is %d  \n", getpid());
        // Fork后的子进程会被加入到unshare创建的命名空间中, 所以pid应该为1
        exit(0);
    } else {
    }
    waitpid(fpid, NULL, 0);

    /* Setns */
    char path[80] = "";
    sprintf(path, "/proc/%d/ns/pid", child_pid);
    int fd = open(path, O_RDONLY);
    if (fd == -1)
        perror("open error");
    if (setns(fd, 0) == -1)
    // setns并不会改变当前进程的命名空间, 而是会设置之后创建的子进程的命名空间
        perror("setns error");
    close(fd);

    int npid = fork();
    if (npid < 0) {
        perror("fork error");
    } else if (npid == 0) {
        printf("I am child process. My pid is %d  \n", getpid());
        // 新的子进程会被加入到第一个子进程的pid命名空间中, 所以其pid应该为2
        exit(0);
    } else {
    }
    return 0;
}

运行结果:

$ sudo ./ns
I'm child process and my pid is 1 
I am child process. My pid is 1  
I am child process. My pid is 2 

控制组(Cgroups)

如果说命名空间是从命名和编号的角度进行隔离,而控制组则是将进程进行分组,并真正的将各组进程的计算资源进行限制、隔离。控制组是一种内核机制,它可以对进程进行分组、跟踪限制其使用的计算资源。对于每一类计算资源,控制组通过所谓的子系统(subsystem)来进行控制,现阶段已有的子系统包括:

  • cpusets: 用来分配一组CPU给指定的cgroup,该cgroup中的进程只等被调度到该组CPU上去执行
  • blkio : 限制cgroup的块IO
  • cpuacct : 用来统计cgroup中的CPU使用
  • devices : 用来黑白名单的方式控制cgroup可以创建和使用的设备节点
  • freezer : 用来挂起指定的cgroup,或者唤醒挂起的cgroup
  • hugetlb : 用来限制cgroup中hugetlb的使用
  • memory : 用来跟踪限制内存及交换分区的使用
  • net_cls : 用来根据发送端的cgroup来标记数据包,流量控制器(traffic controller)会根据这些标记来分配优先级
  • net_prio : 用来设置cgroup的网络通信优先级
  • cpu :用来设置cgroup中CPU的调度参数
  • perf_event : 用来监控cgroup的CPU性能

与命名空间不同,控制组并没有增加系统调用,而是实现了一个文件系统,通过文件及目录操作来管理控制组。下面通过一个例子来看一看cgroup是如何利用cpuset子系统来把进程绑定到指定的CPU上去执行的。

1. 创建一个一直执行的shell脚本

#!/bin/bash

x=0

while [ True ];do
    :
done;

2. 在后台执行这个脚本

# bash run.sh &
[1] 20553

3. 查看该脚本在哪个CPU上运行

# ps -eLo ruser,lwp,psr,args | grep 20553 | grep -v grep
root     20553   3 bash run.sh

可以看到PID为20553的进程运行在编号为3的CPU上,下面利用cgroups将其绑定到编号为2的CPU上去执行

4. 挂载cgroups类型的文件系统到一个新创建的目录cgroups中

# mkdir cgroups
# mount -t cgroup -o cpuset cgroups ./cgroups/
# ls cgroups/
cgroup.clone_children   cpuset.memory_pressure_enabled
cgroup.procs            cpuset.memory_spread_page
cgroup.sane_behavior    cpuset.memory_spread_slab
cpuset.cpu_exclusive    cpuset.mems
cpuset.cpus             cpuset.sched_load_balance
cpuset.effective_cpus   cpuset.sched_relax_domain_level
cpuset.effective_mems   docker
cpuset.mem_exclusive    tasks
cpuset.mem_hardwall     notify_on_release
cpuset.memory_migrate   release_agent
cpuset.memory_pressure

5. 创建一个新的组group0

# mkdir group0
# ls group0/
cgroup.clone_children  cpuset.mem_exclusive       cpuset.mems
cgroup.procs           cpuset.mem_hardwall        cpuset.sched_load_balance
cpuset.cpu_exclusive   cpuset.memory_migrate      cpuset.sched_relax_domain_level
cpuset.cpus            cpuset.memory_pressure     notify_on_release
cpuset.effective_cpus  cpuset.memory_spread_page  tasks
cpuset.effective_mems  cpuset.memory_spread_slab

6. 将上面的进程20553加入到新建的控制组中:

# echo 20553 >> group0/tasks 
# cat group0/tasks 
20553

7. 限制该组的进程只能运行在编号为2的CPU上

# echo 2 > group0/cpuset.cpus
# cat group0/cpuset.cpus
2

8. 查看PID为20553的进程所运行的CPU编号

# ps -eLo ruser,lwp,psr,args | grep 20553 | grep -v grep
root     20553   2 bash run.sh

上面的例子简单的展示了如何使用控制组。控制组通过文件和目录来操作,文件系统又是树形结构,因此如果不对cgroups的使用做一些限制的话,配置会变得异常复杂和混乱。因此,在新版的cgroups中做了一些限制。

小结

本文简要介绍了操作系统虚拟化的概念,以及实现操作系统虚拟化的技术——命名空间及控制组。并通过两个简单的例子演示了命名空间及控制组的使用方法。