自制LinuxContainer

预备知识

进程和clone系统调用

进程(Process)是运行中的程序实例,又可以称为任务(Task),进程在Linux内核中使用一个PCB来表示,主要包含运行状态、信号、进程号、父进程号、运行时间累计值、正在使用的文件(文件描述符表)、本任务的局部描述符及任务状态段信息。
不同于fork,clone系统调用允许子进程共享部分父进程的上下文,如内存空间、文件描述符表、信号等。

1
2
3
4
5
6
7
/* Prototype for the glibc wrapper function */
#define _GNU_SOURCE
#include <sched.h>
int clone(int (*fn)(void *), void *child_stack,
int flags, void *arg, ...
/* pid_t *ptid, void *newtls, pid_t *ctid */ );
/* For the prototype of the raw system call, see NOTES */

clone常用于实现多线程,因为子进程和父进程可以共享内存。
不同于fork创建的子进程会从调用的位置开始执行,clone创建的子进程会执行实参传入的fn(arg),并将实参中的arg传入。
fn(arg)返回后子进程页会终止,返回值即为子进程的exit code,当然子进程在遇到显式的exit调用或终止信号也会立刻退出。
子进程与父进程共享内存,它们不能(也不应该)使用同一个栈,因此必须使用child_stack参数指定子进程使用的栈所在的内存空间。栈是从上向下生长的,因此最好指定最顶层的一个地址。
flags的低位包含子进程退出了发送给父进程的信号,If this signal is specified as anything other than SIGCHLD, then the parent process must specify the __WALL or __WCLONE options when waiting for the child with wait(2). If no signal is specified, then the parent process is not signaled when the child terminates.
flags 还可以指定子进程和父进程间可以共享的内容,具体内容见man clone

虚拟网络设备和veth pair

Linux container 中用到一个叫做veth的东西,这是一种新的设备,专门为 container 所建。veth 从名字上来看是 Virtual ETHernet 的缩写,它的作用很简单,就是要把从一个 network namespace 发出的数据包转发到另一个 namespace。veth 设备是成对的,一个是 container 之中,另一个在 container 之外,即在真实机器上能看到的。

veth设备实现原理

VETH设备总是成对出现,送到一端请求发送的数据总是从另一端以请求接受的形式出现。创建并配置正确后,向其一端输入数据,VETH会改变数据的方向并将其送入内核网络子系统,完成数据的注入,而在另一端则能读到此数据。(Namespace,其中往veth设备上任意一端上RX到的数据,都会在另一端上以TX的方式发送出去)veth工作在L2数据链路层,veth-pair设备在转发数据包过程中并不串改数据包内容。
这里写图片描述
显然,仅有veth-pair设备,容器是无法访问网络的。因为容器发出的数据包,实质上直接进入了veth1设备的协议栈里。如果容器需要访问网络,需要使用bridge等技术,将veth1接收到的数据包通过某种方式转发出去。

VETH: Typically used when you are trying to connect two entities which would want to “get hold of” (for lack of better phrase) an interface to forward/receive frames. These entities could be containers/bridges/ovs-switch etc. Say you want to connect a docker/lxc container to OVS. You can create a veth pair and push the first interface to the docker/lxc (say, as a phys interface) and push the other interface to OVS. You cannot do this with TAP.

veth设备特点

  • veth和其它的网络设备都一样,一端连接的是内核协议栈
  • veth设备是成对出现的,另一端两个设备彼此相连
  • 一个设备收到协议栈的数据发送请求后,会将数据发送到另一个设备上去

常用命令

1
2
# 创建veth
ip link add name veth0 type veth0 peer name veth1

其他细节见Linux篇《虚拟化-网络设备》

资源隔离、namespace和cgroup

namespace的主要作用是对两个系统内的标识符的命名进行隔离,namespace有以下几种。
Linux-namespace种类
这几个flag 可以在调用clone进行进程创建的时候作为参数传入,从而实现namespace的隔离,从这个角度来说,container主要是进程角度的隔离,而不是传统的虚拟机(一些虚拟机实现同样是基于对操作系统层的虚拟化,所以应该和container是类似的,比如普通vmware),因为container底层用的是同一个内核来调度。

cgroup 是linux 内核的另外一个控制和隔离进程的特性,他分为cpu ,memory,net,io等几个子系统,从而实现对进程cpu,内存,磁盘,网络等资源使用的控制。

虚拟文件系统(VFS)和chroot

将根目录设置成另外一个目录

自制容器

docker 只是一个工具,container 技术的核心还是linux 内核的cgroup + chroot + namespace 技术。

制作image

制作自己容器,需要一个image ,可以从网上下一个,也可以自己制作,制作很简单,新装一个操作系统,安装一些需要用到的软件包,然后用tar 制作 / 目录下的压缩包,去掉一些虚拟文件系统的文件,本文用的是自己制作的centos 6.5 的image。

容器实现过程

容器实现过程可以归纳为

  1. 用clone系统调用创建子进程,传入namespace的那几个参数,实现namespace的隔离;
  2. 父进程中创建veth pair ,一个veth在自己的namespace,将另一个设置为子进程的namespace,实现container和宿主机的网络通信;
  3. 父进程创建cgroup memory和cpuset子系统,将子进程attach到cgroup子系统上,实现container 的资源限制和隔离;
  4. 子进程在自己的namespace里,设置主机名,mount proc虚拟文件系统,设置veth ip,chroot到centos 6镜像的位置, 最终将进程镜像替换成/bin/bash;
  5. 父进程调用waitpid 等待子进程退出。

容器实现

见下面的《源码》部分

实验

在解压好镜像后,该镜像根目录下可能还没有必须的命令文件及其依赖的动态链接库,主要是由于用chroot改变根目录后,原来路径下的一些文件在当前镜像内都找不到了,一般安装完操作系统后这些文件应该都安装好了,如果需要可以通过手动移动来解决

1
2
ldd /bin/bash
cp --parents /lib/x86_64-linux-gnu/libtinfo.so.5 ./

编译执行容器代码。

1
2
gcc lxc_demo.c -o lxc_demo -lcgroup
sudo ./lxc_demo

接下来分别在宿主机和容器内执行命令,可以得出一些结论:

  1. 容器和宿主机镜像可能不同,但是内核相同;
    1
    2
    3
    4
    5
    6
    7
    # (宿主机内)
    hgc@hgc-X555LD:~$ uname -r
    4.13.0-45-generic

    # (容器内)
    bash-4.4# uname -r
    4.13.0-45-generic
  2. 根目录下的文件不同;
    1
    ls /
  3. hostname,宿主机内为用户设定的主机名,容器内为mydocker,说明UTS namespace隔离成功;
    1
    hostname
  4. 网络方面有回环网络卡lo和veth1,veth1 169.254.1.2 能ping 通veth0的地址(宿主机上的veth)169.254.2.1,如果在外面加iptables 做nat 转换的话,container里面还可以和外面通信。我们看不到外面宿主机的eth0 和 eth1,说明container 的network namespace 隔离成功。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    # (宿主机内)
    $ ip addr
    ......
    19: veth0@if18: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 46:c0:eb:a8:34:8d brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 169.254.1.1/30 scope global veth0
    valid_lft forever preferred_lft forever
    inet6 fe80::44c0:ebff:fea8:348d/64 scope link
    valid_lft forever preferred_lft forever

    # (容器内)网络方面有回环网络卡lo和veth1
    bash-4.4# ip addr
    1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
    valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
    valid_lft forever preferred_lft forever
    18: veth1@if19: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether ce:04:78:b6:5a:2d brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 169.254.1.2/30 scope global veth1
    valid_lft forever preferred_lft forever
    inet6 fe80::cc04:78ff:feb6:5a2d/64 scope link
    valid_lft forever preferred_lft forever
    # 尝试ping宿主机上的veth0设备
    bash-4.4# ping 169.254.1.1
    PING 169.254.1.1 (169.254.1.1) 56(84) bytes of data.
    64 bytes from 169.254.1.1: icmp_seq=1 ttl=64 time=0.101 ms
    ......
  5. 目前container 里面只有/bin/bash , 且进程号为 1,不是我们常见的init进程,或者systemd 。因为/bin/bash 为该namespace 下的第一个进程,说明我们的pid namespace隔离成功。
    1
    2
    3
    4
    5
    # 查看进程
    bash-4.4# ps -aux
    USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
    0 1 0.0 0.0 9924 3264 ? S 01:02 0:00 /bin/bash
    0 8 0.0 0.0 30524 2820 ? R+ 01:02 0:00 ps -aux
  6. mount 显示挂载的文件系统,和宿主机的不一样,说明mount namespace隔离成功。
    1
    2
    3
    4
    # 查看挂载情况(这里有点问题,原文里还有rootfs、sysfs等)
    bash-4.4# mount
    proc on /proc type proc (rw,relatime)
    ......
  7. 就cgroup的隔离情况来说,在cgroup文件系统内,memory的限制是我们设置的512M,cpu使用的是0-1号。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    hgc@hgc-X555LD:~/tools/virtualmachines/ubuntu-16.04$ cd /sys/fs/cgroup/memory/mydocker_1530580167/
    hgc@hgc-X555LD:/sys/fs/cgroup/memory/mydocker_1530580167$ cat memory.limit_in_bytes
    536870912
    hgc@hgc-X555LD:/sys/fs/cgroup/memory/mydocker_1530580167$ cd /sys/fs/cgroup/cpuset/mydocker_1530580167/
    hgc@hgc-X555LD:/sys/fs/cgroup/cpuset/mydocker_1530580167$ cat cpuset.cpus
    0-1
    hgc@hgc-X555LD:/sys/fs/cgroup/cpuset/mydocker_1530580167$ cat cpuset.mems
    0
    hgc@hgc-X555LD:/sys/fs/cgroup/cpuset/mydocker_1530580167$ top
    从下面执行情况可以看出,只有0号和1号cpu idle为0 ,其他的都接近100%,说明cgroup隔离效果是很好的。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    # (宿主机内)执行top命令并点击1查看CPU负载情况
    hgc@hgc-X555LD:/sys/fs/cgroup/cpuset/mydocker_1530580167$ top
    top - 10:02:22 up 13:19, 1 user, load average: 0.74, 0.68, 0.72
    Tasks: 348 total, 1 running, 346 sleeping, 0 stopped, 1 zombi
    %Cpu0 : 5.3 us, 2.3 sy, 0.0 ni, 91.7 id, 0.0 wa, 0.0 hi, 0.7
    %Cpu1 : 8.3 us, 12.6 sy, 0.0 ni, 78.8 id, 0.0 wa, 0.0 hi, 0.3
    %Cpu2 : 4.0 us, 2.0 sy, 0.0 ni, 93.9 id, 0.0 wa, 0.0 hi, 0.0
    %Cpu3 : 3.0 us, 2.4 sy, 0.0 ni, 94.6 id, 0.0 wa, 0.0 hi, 0.0
    %Cpu4 : 7.1 us, 5.7 sy, 0.0 ni, 87.2 id, 0.0 wa, 0.0 hi, 0.0
    %Cpu5 : 4.4 us, 1.4 sy, 0.0 ni, 93.9 id, 0.3 wa, 0.0 hi, 0.0
    %Cpu6 : 3.3 us, 2.7 sy, 0.0 ni, 93.7 id, 0.0 wa, 0.0 hi, 0.3
    %Cpu7 : 3.7 us, 1.3 sy, 0.0 ni, 94.6 id, 0.0 wa, 0.0 hi, 0.3
    ......

    # (容器内)执行多个死循环任务,强行使CPU忙碌
    bash-4.4# while true; do echo > /dev/null; done &
    [1] 10
    bash-4.4# while true; do echo > /dev/null; done &
    [2] 11
    bash-4.4# while true; do echo > /dev/null; done &
    [3] 12
    bash-4.4# while true; do echo > /dev/null; done &
    [4] 13
    bash-4.4# while true; do echo > /dev/null; done &
    [5] 14
    bash-4.4# while true; do echo > /dev/null; done &
    [6] 15
    bash-4.4# while true; do echo > /dev/null; done &
    [7] 16

    # (宿主机内)同样是执行top命令并点击1,可以看到头两个CPU的负载情况有了明显变化
    hgc@hgc-X555LD:/sys/fs/cgroup/cpuset/mydocker_1530580167$ top
    top - 10:05:05 up 13:22, 1 user, load average: 4.25, 1.68, 1.06
    Tasks: 355 total, 1 running, 353 sleeping, 0 stopped, 1 zombi
    %Cpu0 : 9.9 us, 34.8 sy, 0.0 ni, 53.2 id, 2.0 wa, 0.0 hi, 0.0
    %Cpu1 : 14.4 us, 43.6 sy, 0.0 ni, 40.2 id, 1.7 wa, 0.0 hi, 0.0
    %Cpu2 : 2.2 us, 0.6 sy, 0.0 ni, 92.0 id, 0.0 wa, 0.0 hi, 5.1
    %Cpu3 : 2.6 us, 1.3 sy, 0.0 ni, 95.0 id, 0.0 wa, 0.0 hi, 1.0
    %Cpu4 : 6.4 us, 10.0 sy, 0.0 ni, 83.6 id, 0.0 wa, 0.0 hi, 0.0
    %Cpu5 : 2.3 us, 1.2 sy, 0.0 ni, 95.3 id, 0.0 wa, 0.0 hi, 1.2
    %Cpu6 : 1.7 us, 0.7 sy, 0.0 ni, 97.6 id, 0.0 wa, 0.0 hi, 0.0
    %Cpu7 : 2.0 us, 2.7 sy, 0.0 ni, 95.3 id, 0.0 wa, 0.0 hi, 0.0
    ......

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
#define _GNU_SOURCE

#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>
#include <sys/mount.h>
// 必须先安装:sudo apt-get install libcgroup-dev
#include <libcgroup.h>
#include <time.h>
#include <signal.h>
#include <string.h>
#include <fcntl.h>

#define STACK_SIZE (1024 * 1024)
#define MEMORY_LIMIT (512*1024*1024)

//const char *rootfs = "/data1/centos6/rootfs/"; //centos6 镜像位置
//const char *rootfs = "/home/hgc/tools/virtualmachines/ubuntu-16.04/"; // 镜像位置
//const char *hostname = "mydocker"; //container 主机名
static char child_stack[STACK_SIZE];
//char *const child_args[] = {
// "/bin/bash",
// NULL
//};
int pipe_fd[2]; //父子进程同步

int child_main(void *args) {
char c;
// TODO:子进程里无法使用全局变量rootfs和hostname
// 镜像位置
const char *rootfs = "/home/hgc/tools/virtualmachines/ubuntu-16.04/";
// container 主机名
const char *hostname = "mydocker";
// 在子进程(容器)内执行的任务
// TODO:因为chroot改变了根目录的位置,所以在保证目标命令存在容器内的基础上(这里是/bin/bash),
// TODO:必须保证该命令依赖的资源同样存在于容器内(动态链接库,使用ldd查看)
char *const child_args[] = {
"/bin/bash",
0
};

printf("In child process(container)\n");
chroot(rootfs); //用chroot 切换根目录
if (errno != 0) {
perror("chroot()");
exit(1);
}
// TODO:这里不能使用sizeof
//clone 调用中的 CLONE_NEWUTS起隔离主机名和域名的作用
sethostname(hostname, strlen(hostname));
if (errno != 0) {
perror("sethostname()!");
exit(1);
}
//挂载proc子系统,CLONE_NEWNS 起隔离文件系统作用
// 需要在rootfs目录下创建proc目录
mount("proc", "/proc", "proc", 0, NULL);
if (errno != 0) {
perror("Mount(proc)");
exit(1);
}
//切换的根目录
chdir("/");
close(pipe_fd[1]);
read(pipe_fd[0], &c, 1);
//设置veth1 网络
system("ip link set lo up");
system("ip link set veth1 up");
system("ip addr add 169.254.1.2/30 dev veth1");
//将子进程的镜像替换成bash
printf("[%s]\n", child_args[0]);
if (execv(child_args[0], child_args) == -1) {
perror("execv(path, argv)");
}
return 1;
}

struct cgroup *cgroup_control(pid_t pid) {
struct cgroup *cgroup = NULL;
int ret;
ret = cgroup_init();
char *cgname = malloc(19 * sizeof(char));
if (ret) {
printf("error occurs while init cgroup.\n");
return NULL;
}
time_t now_time = time(NULL);
sprintf(cgname, "mydocker_%d", (int) now_time);
printf("%s\n", cgname);
cgroup = cgroup_new_cgroup(cgname);
if (!cgroup) {
ret = ECGFAIL;
printf("Error new cgroup%s\n", cgroup_strerror(ret));
goto out;
}
//添加cgroup memory 和 cpuset子系统
struct cgroup_controller *cgc = cgroup_add_controller(cgroup, "memory");
struct cgroup_controller *cgc_cpuset = cgroup_add_controller(cgroup, "cpuset");
if (!cgc || !cgc_cpuset) {
ret = ECGINVAL;
printf("Error add controller %s\n", cgroup_strerror(ret));
goto out;
}
// 内存限制 512M
if (cgroup_add_value_uint64(cgc, "memory.limit_in_bytes", MEMORY_LIMIT)) {
printf("Error limit memory.\n");
goto out;
}
//限制只能使用0和1号cpu
if (cgroup_add_value_string(cgc_cpuset, "cpuset.cpus", "0-1")) {
printf("Error limit cpuset cpus.\n");
goto out;
}
//限制只能使用0和1块内存
// TODO:使用0-1作为参数会报错“Invalid argument”
if (cgroup_add_value_string(cgc_cpuset, "cpuset.mems", "0")) {
printf("Error limit cpuset mems.\n");
goto out;
}
ret = cgroup_create_cgroup(cgroup, 0);
if (ret) {
printf("Error create cgroup%s\n", cgroup_strerror(ret));
goto out;
}
ret = cgroup_attach_task_pid(cgroup, pid);
if (ret) {
printf("Error attach_task_pid %s\n", cgroup_strerror(ret));
goto out;
}
return cgroup;
out:
if (cgroup) {
cgroup_delete_cgroup(cgroup, 0);
cgroup_free(&cgroup);
}
return NULL;
}

int main() {
char *cmd;
printf("main process: \n");
pipe(pipe_fd);
if (errno != 0) {
perror("pipe()");
exit(1);
}
// 调用clone创建子进程,传入namespace的几个flag参数,实现namespace的隔离
// 子进程执行child_main函数,其堆栈空间使用child_stack参数指定
// clone与线程的实现息息相关:http://www.xuebuyuan.com/1422353.html
int child_pid = clone(child_main, child_stack + STACK_SIZE, \
CLONE_NEWNET | CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
struct cgroup *cg = cgroup_control(child_pid);
// 添加veth pair,设置veth1 在子进程的的namespace,veth0 在父进程的namespace,为了实现container和宿主机之间的网络通信
// linl3 实现起来太繁琐,借用命令行工具ip 实现
system("ip link add veth0 type veth peer name veth1");
asprintf(&cmd, "ip link set veth1 netns %d", child_pid); // asprintf根据字符串长度申请足够的内存空间,但在之后必须手动释放
system(cmd);
system("ip link set veth0 up");
system("ip addr add 169.254.1.1/30 dev veth0");
free(cmd);
//等执行以上命令,通知子进程,子进程设置自己的网络
close(pipe_fd[1]);
waitpid(child_pid, NULL, 0);
if (cg) {
cgroup_delete_cgroup(cg, 0); //删除cgroup 子系统
}
printf("child process exited.\n");
return 0;
}

参考

LXC

  1. Linux Containers
  2. 理解 chroot

虚拟网络设备

  1. Linux-虚拟网络设备-veth pair

clone

  1. linux的Clone()函数详解

Linux namespace

  1. Linux Namespaces机制
  2. 介绍 Linux 的命名空间