支持Docker运行的三大基础技术

  • Linux Namespace
  • Linux Cgroups
  • Union File System

Linux Namespace

这是Kernel的一个功能,可以用来隔离一系列的系统资源,比如PID、User ID、Network等。

类型 系统调用参数
Mount namespaces CLONE_NEWNS
UTS namespaces CLONE_NEWUTS
IPC namespaces CLONE_NEWIPC
PID namespaces CLONE_NEWPID
Network namespaces CLONE_NEWNET
User namespaces CLONE_NEWUSER

主要的三种系统调用方式

  • clone()– 实现线程的系统调用,用来创建一个新的进程,并可以通过设计上述参数达到隔离。
  • unshare() – 使某进程脱离某个namespace
  • setns()– 把某进程加入到某个namespace

以下将通过Clone调用分别介绍各个Namespace启用后的效果(本来代码实现是用的Go,目的是实现一个简化版的Docker。但是大家没学过,所以我去博客上找了C的代码)

原始程序

#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
 
/* 定义一个给 clone 用的栈,栈大小1M */
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
 
char* const container_args[] = {
    "/bin/bash",
    NULL
};
 
int container_main(void* arg)
{
    printf("Container - inside the container!\n");
    /* 直接执行一个shell,以便我们观察这个进程空间里的资源是否被隔离了 */
    execv(container_args[0], container_args); 
    printf("Something's wrong!\n");
    return 1;
}
 
int main()
{
    printf("Parent - start a container!\n");
    /* 调用clone函数,其中传出一个函数,还有一个栈空间的(为什么传尾指针,因为栈是反着的) */
    int container_pid = clone(container_main, container_stack+STACK_SIZE, SIGCHLD, NULL);
    /* 等待子进程结束 */
    waitpid(container_pid, NULL, 0);
    printf("Parent - container stopped!\n");
    return 0;
}

UST Namespaces

提供了主机名和域名的隔离,这样每个容器就拥有独立的主机名和域名了,在网络上就可以被视为一个独立的节点,在容器中对 hostname 的命名不会对宿主机造成任何影响。

修改代码

int container_main(void* arg)
{
    printf("Container - inside the container!\n");
    sethostname("container",10); /* 设置hostname */
    execv(container_args[0], container_args);
    printf("Something's wrong!\n");
    return 1;
}
 
int main()
{
    printf("Parent - start a container!\n");
    int container_pid = clone(container_main, container_stack+STACK_SIZE, 
            CLONE_NEWUTS | SIGCHLD, NULL); /*启用CLONE_NEWUTS Namespace隔离 */
    waitpid(container_pid, NULL, 0);
    printf("Parent - container stopped!\n");
    return 0;
}

效果如下:

arbus@ubuntu:~$ sudo ./uts
Parent - start a container!
Container - inside the container!
root@container:~# hostname
container
root@container:~# uname -n
container

IPC Namespcace

IPC全称 Inter-Process Communication,是Unix/Linux下进程间通信的一种方式,IPC有共享内存、信号量、消息队列等方法。所以,为了隔离,我们也需要把IPC给隔离开来,这样,只有在同一个Namespace下的进程才能相互通信。一组IPC有一个全局的ID,我们的目的是隔离这个ID。

首先创建IPC队列

arbus@ubuntu:~$ ipcmk -Q 
Message queue id: 0
 
arbus@ubuntu:~$ ipcs -q
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    
0xd0d56eb2 0          arbus      644        0            0

如果没有隔离,在container中,还会看到这个全局ID

arbus@ubuntu:~$ sudo ./uts
Parent - start a container!
Container - inside the container!
 
root@container:~# ipcs -q
 
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages    
0xd0d56eb2 0          arbus      644        0            0

修改代码:

int container_pid = clone(container_main, container_stack+STACK_SIZE, 
            CLONE_NEWUTS | CLONE_NEWIPC | SIGCHLD, NULL);

再次运行程序

root@ubuntu:~$ sudo./ipc
Parent - start a container!
Container - inside the container!
 
root@container:~/linux_namespace# ipcs -q
 
------ Message Queues --------
key        msqid      owner      perms      used-bytes   messages

PID Namespace

用来隔离进程ID。同一个进程在不同的PID Namespace中可以拥有不同的PID。使用后,在container内外查看进程,会发现容器外的进程ID号被映射成其他ID号。

├─sshd(23448)─┬─sshd(30442)───zsh(30444)───go(31498)─┬─main(31519)─┬─sh(31522)
           │             │                                      │             ├─{main}(31520)
           │             │                                      │             └─{main}(31521)
           │             │                                      ├─{go}(31499)
           │             │                                      ├─{go}(31500)
           │             │                                      ├─{go}(31501)
           │             │                                      ├─{go}(31502)
           │             │                                      ├─{go}(31515)
           │             │                                      └─{go}(31523)
           │             ├─sshd(30870)───zsh(30872)
           │             └─sshd(30932)───zsh(30934)───pstree(31534)
           ├─systemd-journal(325)
           ├─systemd-logind(475)
           ├─systemd-udevd(29645)
           ├─tmux: server(25827)─┬─zsh(25828)
           │                     ├─zsh(25917)
           │                     ├─zsh(25952)
           │                     └─zsh(26185)
           └─tuned(19273)─┬─{tuned}(19279)
                          ├─{tuned}(19280)
                          ├─{tuned}(19281)
                          └─{tuned}(19309)

增加PID Namespace后,在容器内执行echo $$(当前Shell进程ID。对于 Shell 脚本,就是这些脚本所在的进程ID)。

sh-4.2# echo $$
1

(PID为1的进程又叫超级进程,也叫根进程。它负责产生其他所有用户进程。所有的进程都会被挂在这个进程下,如果这个进程退出了,那么所有的进程都被 kill 。)

但是,在子进程的shell里输入ps,top等命令,我们还是可以看得到所有进程。说明并没有完全隔离。这是因为,像ps, top这些命令会去读/proc文件系统,所以,因为/proc文件系统在父进程和子进程都是一样的,所以这些命令显示的东西都是一样的。

Mount Namespace

Mount Namespace用来隔离各个进程上看到的挂载点视图,从而达到对文件系统的隔离。也就是说,在不同的Namespace中看到的文件系统是不一样的。

示例:启用mount namespace并在子进程中重新mount /proc文件系统。

➜  ~ ls /proc
1      15825  19087  227    2528   26     30872  39   59    9815       bus        driver       kallsyms    mdstat        sched_debug  sysrq-trigger  zoneinfo
10     16     19160  230    2529   26185  30932  390  6035  9817       cgroups    execdomains  kcore       meminfo       schedstat    sysvipc
104    16931  19226  233    2542   26881  30934  397  7     9818       cmdline    fb           keys        misc          scsi         timer_list
12     17     19273  23448  255    28     31498  40   7662  9819       consoles   filesystems  key-users   modules       self         timer_stats
13     18     19453  235    256    29645  31519  435  794   9820       cpuinfo    fs           kmsg        mounts        slabinfo     tty
14     18827  19456  237    25827  3      31522  469  796   9821       crypto     interrupts   kpagecount  mtrr          softirqs     uptime
15     18854  2      243    25828  30442  325    470  8     9875       devices    iomem        kpageflags  net           stat         version
15819  19     22276  25     25917  30444  36     475  9     acpi       diskstats  ioports      loadavg     pagetypeinfo  swaps        vmallocinfo
15824  19016  22373  250    25952  30870  38     5    9812  buddyinfo  dma        irq          locks       partitions    sys          vmstat

增加CLONE_NEWNS参数。

sh-4.2# ls /proc
1	   cgroups   devices	  fb	       ioports	 key-users   locks    mounts	    sched_debug  softirqs	sysvipc      version
4	   cmdline   diskstats	  filesystems  irq	 kmsg	     mdstat   mtrr	    schedstat	 stat		timer_list   vmallocinfo
acpi	   consoles  dma	  fs	       kallsyms  kpagecount  meminfo  net	    scsi	 swaps		timer_stats  vmstat
buddyinfo  cpuinfo   driver	  interrupts   kcore	 kpageflags  misc     pagetypeinfo  self	 sys		tty	     zoneinfo
bus	   crypto    execdomains  iomem        keys	 loadavg     modules  partitions    slabinfo	 sysrq-trigger	uptime

User Namespace

User Namespace主要是隔离用户的用户组ID,也就是说,一个进程所属User ID在不同User Namespace中可以不同。比较常见的是,在宿主机上一个普通用户创建一个User Namespace,在其中映射成Root用户。这样做可以很好的限制权限。

(代码略长,主要操作也是加入系统调用参数)

加入前:

arbus@ubuntu:~$ id
uid=1000(arbus) gid=1000(arbus) groups=1000(arbus)

加入后

root@container:~# id #<----我们可以看到容器里的用户和命令行提示符是root用户了
uid=0(root) gid=0(root) groups=0(root),65534(nogroup)

虽然容器里是root,但其实这个容器的/bin/bash进程是以一个普通用户来运行的。这样一来,我们容器的安全性会得到提高。

Network Namespace

Network namespace 实现了网络资源的隔离,包括网络设备、IPv4 和 IPv6 协议栈,IP 路由表,防火墙,/proc/net 目录,/sys/class/net 目录,套接字等。

Network namespace可以让每个容器都拥有独立的虚拟 网络设备,容器内的应用可以绑定到自己的端口而不会与其他Network namespace冲突。只要在宿主机上搭建网桥,就可以方便的通信。

(例子略)

Linux Cgroup

Linux Cgroup全称Linux Control Group,它提供了对一组进程及将来子进程的资源限制、控制和统计能力,这些资源包括CPU、内存、储存、网络等。通过这个功能,可以方便的限制资源的占用、并且可以实时监控。

三个组件:

  • cgroup: 一组按照某种标准划分的进程。Cgroups中的资源控制都是以控制组为单位实现。一个进程可以加入到某个控制组。
  • subsystem: 一个子系统就是一个资源控制器。子系统必须附加到一个层级上才能起作用,一个子系统附加到某个层级以后,这个层级上的所有控制族群都受到这个子系统的控制
  • hierarchy: 把一组cgroup串成一个树状结构。通过这种结构,cgroup可以实现继承,控制组树上的子节点继承父结点的属性。

例子:

有一个很占用内存的demo:

int main(void)
{
    int i = 0;
    for(;;) i++;
    return 0;
}

以超级用户权限执行,查看一下资源占用:

PID USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND     
3529 root      20   0    4196    736    656 R 99.6  0.1   0:23.13 demo

首先在/sys/fs/cgroup/cpu下创建了一个haoel的group。设置一下这个group的cpu利用的限制:

arbus@ubuntu:~# cat /sys/fs/cgroup/cpu/haoel/cpu.cfs_quota_us 
-1
root@ubuntu:~# echo 20000 > /sys/fs/cgroup/cpu/haoel/cpu.cfs_quota_us

这个进程的PID是3529,我们把这个进程加到这个cgroup中:

# echo 3529 >> /sys/fs/cgroup/cpu/haoel/tasks

重新查看,发现已经降至19.9:

PID USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND     
3529 root      20   0    4196    736    656 R 19.9  0.1   8:06.11 demo

AUFS

先介绍一下什么是UnionFS。它是为类Unix系统设计的,把其他文件系统联合到一个联合挂载点的文件系统服务。

Branch – 就是各个要被union起来的目录。Branch根据被union的顺序形成一个stack,一般来说最上面的是可写的,下面的都是只读的。当对这个虚拟后的联合文件系统进行写操作的时候,系统是真正写到了一个新的文件中。看起来这个虚拟后的联合文件系统是可以对任何文件进行操作的,但是其实它并没有改变原来的文件,这是因为 unionfs用到了一个重要的资源管理技术,叫写时复制。

写时复制(copy-on–Write,下文简称CoW)也叫隐式共享,是一种对可修改资源实现高效复制的资源管理技术。它的思想是,如果一个资源是重复的,但没有任何修改,这时并不需要立即创建一个新的资源,这个资源可以被新旧实例共享。创建新资源发生在第一次写操作,也就是对资源进行修改的时候。通过这种资源共享的方式,可以显著地减少未修改资源复制带来的消耗,但是也会在进行资源修改时增加小部分的开销。

AUFS有所有Union FS的特性,支持将不同目录挂载到同一个虚拟文件系统下的文件系统。这种文件系统可以一层一层地叠加修改文件。无论底下有多少层都是只读的,只有最上层的文件系统是可写的。当需要修改一个文件时,AUFS创建该文件的一个副本,使用CoW将文件从只读层复制到可写层进行修改,结果也保存在可写层。

$ tree
.
├── fruits
│   ├── apple
│   └── tomato
└── vegetables
    ├── carrots
    └── tomato
# 创建一个mount目录
$ mkdir mnt
 
# 把水果目录和蔬菜目录union mount到 ./mnt目录中
$ sudo mount -t aufs -o dirs=./fruits:./vegetables none ./mnt
 
#  查看./mnt目录
$ tree ./mnt
./mnt
├── apple
├── carrots
└── tomato

# 修改
$ echo mnt > ./mnt/apple
$ cat ./mnt/apple
mnt
$ cat ./fruits/apple
mnt 

# 再次修改
$ echo mnt_carrots > ./mnt/carrots
$ cat ./vegetables/carrots
 
$ cat ./fruits/carrots
mnt_carrots