Docker和Kubernetes等基于容器的技术在近些年来得到了越来越多的应用,很多人不理解容器到底是什么,它与虚拟机又有什么区别。深入学习一项技术最有效的办法就是重新实现它,这篇文章使用C++语言一步步实现一个简单的容器,仔细读完之后,相信你对容器的理解会跃升一个层次。
本文主要参考自英文博客 https://cesarvr.github.io/post/2018-05-22-create-containers/和Liz Rice的视频https://www.youtube.com/watch?v=_TsSmSu57Zo。
Hello World!
首先用C++实现一个简单的打印hello world的函数。
#include <iostream>
int main() {
printf("Hello, World! \n");
return EXIT_SUCCESS;
}
编译代码:
g++ container.cc -o container
这会产生名为container的二进制文件,执行该文件:
./container
# Hello World!
创建一个进程
我们准备实现的第一个功能是在一个程序中运行另外的程序。现在进程结构长这样:
+--------+
| parent |
|--------|
| main() |
+--------+
要想创建一个新进程,需要克隆当前进程,并编写一个函数,让它在新进程中运行。将这个函数命名为jail:
int jail(void *args) {
printf("Hello !! ( child ) \n");
return EXIT_SUCCESS;
}
现在进程长这样:
+--------+
| parent |
|--------|
| main() |
|--------|
| jail() |
+--------+
下一步通过系统调用函数clone来创建子进程,子进程需要新的内存,所以先创建一个分配内存的函数
char* stack_memory() {
const int stackSize = 65536;
auto *stack = new (std::nothrow) char[stackSize];
if (stack == nullptr) {
printf("Cannot allocate memory \n");
exit(EXIT_FAILURE);
}
return stack+stackSize; //move the pointer to the end of the array because the stack grows backward.
}
在这个函数中,分配65k大小的内存,然后返回一个指向数组末尾的指针,因为在进程中堆栈是向下增长的。
最后的代码长这样:
#include <iostream>
#include <sched.h>
#include <sys types.h="">
#include <unistd.h>
#include <sys wait.h="">
char* stack_memory() {
const int stackSize = 65536;
auto *stack = new (std::nothrow) char[stackSize];
if (stack == nullptr) {
printf("Cannot allocate memory \n");
exit(EXIT_FAILURE);
}
return stack+stackSize; //move the pointer to the end of the array because the stack grows backward.
}
int jail(void *args) {
printf("Hello !! ( child ) \n");
return EXIT_SUCCESS;
}
int main(int argc, char** argv) {
printf("Hello, World! ( parent ) \n");
clone(jail, stack_memory(), SIGCHLD, 0);
return EXIT_SUCCESS;
}
在clone函数中,第一个参数是入口点函数,第二个参数是分配内存的函数,第三个参数SIGCHLD标志告诉进程完成时发出信号,第四个参数只有需要给jail函数传参时才需要,在这里我们传入0。
+--------+ +--------+
| parent | | copy |
|--------| |--------|
| main() | clone --> | jail() |
|--------| +--------+
| jail() |
+--------+
创建新进程后我们要告诉它的父进程等待子进程执行结束,否则子进程会变成僵尸进程,系统调用wait可以实现这个功能。
wait(nullptr); //wait for every child.
更新后的代码:
#include <iostream>
#include <sched.h>
#include <sys types.h="">
#include <unistd.h>
#include <sys wait.h="">
int jail(void *args) {
printf("Hello !! ( child ) \n");
return EXIT_SUCCESS;
}
int main(int argc, char** argv) {
printf("Hello, World! ( parent ) \n");
clone(jail, stack_memory(), SIGCHLD, 0);
wait(nullptr);
return EXIT_SUCCESS;
}
编译执行:
./container
#Hello, World! ( parent )
#Hello !! ( child )
父进程首先打印出Hello world,接着克隆进程后运行jail函数,打印出Hello。
运行shell
接下来我们在子进程中运行shell,在容器中运行shell可以让我们看到容器到底发生了什么变化。加载一个程序可以使用execvp,它会替换当前进程。
execvp("<path-to-executable>", {array-of-parameters-including-executable});
使用execvp运行shell可以这样写:
char *_args[] = {"/bin/sh", (char *)0 };
execvp("/bin/sh", _args);
简洁起见,以及为方便重用,将其包装成一个函数:
//we can call it like this: run("/bin/sh");
int run(const char *name) {
char *_args[] = {(char *)name, (char *)0 };
execvp(name, _args);
}
这个版本的代码已经可以满足我们的要求了,但是它不支持多参数。使用c++的模板实现一个可以接收多参数的版本:
//we can call it like this: run("/bin/sh","-c", "echo hello!");
template <typename... P>
int run(P... params) {
//basically generating the arguments array at compile time.
char *args[] = {(char *)params..., (char *)0};
return execvp(args[0], args);
}
修改子进程中的入口点函数:
int jail(void *args) {
run("/bin/sh"); // load the shell process.
return EXIT_SUCCESS;
}
编译运行:
process created with pid: 12406
sh-4.4$
环境变量
你会发现我们的shell并没有实现容器所要求的隔离功能。为了能够看清楚环境变量的改变对进程的影响,我们将shell进程的环境变量清除,将环境变量清除只需要在将进程控制权交给shell之前执行clearenv函数。
int jail(void *args) {
clearenv(); // remove all environment variables for this process.
run("/bin/sh");
return EXIT_SUCCESS;
}
再次运行程序,然后在shell中执行env命令:
# env
SHLVL=1
PWD=/
另外,修改子进程的环境变量不会影响到父进程。
Linux namespaces
Universal Time Sharing(UTS)
Linux的namespace可以为进程提供隔离功能,比如使不同的进程所看到的主机名不一样。
Linux Kernel
+-----------------------------------------------+
Global Namespace's { UTS, PID, MOUNTS ... }
+-----------------------------------------------+
parent child process
+-------------------+ +---------+
| | | |
| childEntryPoint() | clone --> | /bin/sh |
| | | |
+-------------------+ +---------+
系统中的所有进程共享UTS namespace,假设我们想要不同的进程拥有不同的UTS namespace:
Linux Kernel
+-----------------------------------------------------+
Global Namespace { UTS, ... } UTS
+-----------------------------+ +----------------+
parent child process
+-------------------+ +---------+
| | | |
| jail() | clone --> | /bin/sh |
| | | |
+-------------------+ +---------+
要想将全局的UTS复制一份到子进程,只需将CLONE_NEWUTS标志传递给clone函数,修改后的代码:
int jail(void *args) {
clearenv(); // remove all environment variables for this process.
run("/bin/sh");
return EXIT_SUCCESS;
}
int main(int argc, char** argv) {
printf("Hello, World! ( parent ) \n");
clone(jail, stack_memory(), CLONE_NEWUTS | SIGCHLD, 0);
# ^^ new flag
wait(nullptr);
return EXIT_SUCCESS;
}
编译运行:
./container
error: clone(): Operation not permitted
这是因为复制UTS namespace需要更高的权限:
sudo ./container
[sudo] password for cesar:
process created with pid: 12906
sh-4.4#
成功了!现在可以看看如果修改主机名结果会是怎样:
全新的进程树
现在我们准备将shell进程与其他进程隔离开来,在shell进程看来,它是机器上唯一运行的进程。跟前面的例子一样,我们只需要传递CLONE_PID标志即可。为了更清楚地看出这个标志有什么作用,可以使用getpid()将进程号显示出来:
int jail(void *args) {
clearenv();
printf("child process: %d", getpid());
run2("/bin/sh");
return EXIT_SUCCESS;
}
int main(int argc, char** argv) {
printf("Hello, World! ( parent ) \n");
printf("parent %d", getpid());
clone(jail, stack_memory(), CLONE_NEWPID | CLONE_NEWUTS | SIGCHLD, 0);
# ^^ new flag
wait(nullptr);
return EXIT_SUCCESS;
}
编译运行:
sudo ./container
parent pid: 3306
child pid: 1
/ #
可以看到子进程的进程号是1,在它看来,它是这台机器上唯一的进程。通过ps命令确认我们是否可以查看其他进程:
可以看到,我们仍然可以列出系统上的其他进程,这是因为我们可以访问/proc目录。在下一节中,将学习如何将进程可以访问的文件夹隔离开来。
隔离一个文件系统
改变根目录
改变一个进程的根目录使用chroot即可,我们可以选择一个目录,将进程隔离在该目录里,理论上该进程是无法访问该目录以外的目录的。
folders our process can access
----------------------------
a
|
b --- c
|
----
| |
d e
根目录在这里表示为a,执行chroot(‘b’)的话,目录树将变成这样:
folders our process can access
----------------------------
b
|
----
| |
d e
我们可以把敏感的文件存在a目录里,因为进程无法访问b目录以外的目录。
切换根目录的代码:
void setup_root(const char* folder){
chroot(folder);
chdir("/");
}
代码中,首先设定根目录,然后将进程切换到新的根目录。
准备好根目录
我们可以将根目录切换到一个空文件夹,但是这样做的话,在我们的容器中也就使用不了ls、cd等等这些基本的工具了。我们可以使用一个包含这些工具的Linux基本文件夹,比如非常轻量的Alpine Linux 。
安装:
mkdir root && cd root
curl -Ol http://nl.alpinelinux.org/alpine/v3.7/releases/x86_64/alpine-minirootfs-3.7.0-x86_64.tar.gz
将文件解压到创建的root文件夹:
tar -xvf alpine-minirootfs-3.7.0_rc1-x86_64.tar.gz
配置环境变量
需要配置一些环境变量使得shell能够找得到二进制文件,以及使得进程知道屏幕类型。将之前的clearenv替换成一个可以处理这些任务的函数:
void setup_variables() {
clearenv();
setenv("TERM", "xterm-256color", 0);
setenv("PATH", "/bin/:/sbin/:usr/bin:/usr/sbin", 0);
}
现在的代码长这样:
void setup_variables() {
clearenv();
setenv("TERM", "xterm-256color", 0);
setenv("PATH", "/bin/:/sbin/:usr/bin:/usr/sbin", 0);
}
void setup_root(const char* folder){
chroot(folder);
chdir("/");
}
int jail(void *args) {
printf("child process: %d", getpid());
setup_variables();
setup_root("./root");
run("/bin/sh");
return EXIT_SUCCESS;
}
int main(int argc, char** argv) {
printf("parent %d", getpid());
clone(jail, stack_memory(), CLONE_NEWPID | CLONE_NEWUTS | SIGCHLD, 0);
wait(nullptr);
return EXIT_SUCCESS;
}
执行效果:
现在通过ps命令已经看不到进程了,这是因为alpine中的/proc目录是空的,在下一节将挂载(mount)proc文件系统。
挂载文件系统
在Linux中,一切皆是文件。Linux中几乎所有的数据实体(比如硬盘、网络等)都被抽象成一个统一的接口–文件来看待。procfs是Linux的一种伪文件系统,包含有关进程的信息和其他系统信息,当它被映射到文件系统中时,我们便可以简单直接地通过echo或cat这样的文件操作命令对进程信息进行查取和调整了。接下来我们就使用系统调用函数mount把procfs挂载到alpine自带的/proc目录中:
mount("proc", "/proc", "proc", 0, 0);
第一个参数是要挂载的资源,第二个参数是目标文件夹,第三个参数是文件系统的类型,在这里是procfs。
代码实现:
int jail(void *args) {
printf("child process: %d", getpid());
setup_variables();
setup_root("./root");
mount("proc", "/proc", "proc", 0, 0);
run("/bin/sh");
return EXIT_SUCCESS;
}
int main(int argc, char** argv) {
printf("parent %d", getpid());
clone(jail, stack_memory(), CLONE_NEWPID | CLONE_NEWUTS | SIGCHLD, 0);
wait(nullptr);
return EXIT_SUCCESS;
}
Unmount
挂载文件系统后,当我们不再用时,最好解除挂载。卸载文件系统用unmount命令。
umount("<mounted-folder>")
在进程退出前解除挂载:
mount("proc", "/proc", "proc", 0, 0);
run("/bin/sh");
umount("/proc");
return EXIT_SUCCESS;
这里有个小问题,当运行run函数后,进程会被新进程取代,接下来的unmount将被忽略而执行不了。我们可以将函数放到另外一个进程中去运行,就像前面一样,使用clone实现。
首先将进程创建指令封装到一个函数中:
int main(int argc, char** argv) {
printf("parent %d", getpid());
clone(jail, stack_memory(), CLONE_NEWPID | CLONE_NEWUTS | SIGCHLD, 0);
wait(nullptr);
return EXIT_SUCCESS;
}
可以通过使用优美一点的接口重写代码:
template <typename Function>
void clone_process(Function&& function, int flags){
auto pid = clone(function, stack_memory(), flags, 0);
wait(nullptr);
}
这里使用C++模板创建了一个叫做Function的泛型,将传进来的函数传递给clone,另外也将flag作为整数传入。
使用编写的函数来重写main函数:
int main(int argc, char** argv) {
printf("parent pid: %d\n", getpid());
clone_process(jail, CLONE_NEWPID | CLONE_NEWUTS | SIGCHLD );
return EXIT_SUCCESS;
}
现在可以使用这个函数在子进程中来运行shell:
int jail(void *args) {
printf("child pid: %d\n", getpid());
setup_variables();
setup_root("./root");
mount("proc", "/proc", "proc", 0, 0);
auto runThis = [](void *args) ->int { run("/bin/sh"); };
clone_process(runThis, SIGCHLD);
umount("/proc");
return EXIT_SUCCESS;
}
稍微解释一下代码:
auto runThis = [](void *args) ->int { run("/bin/sh"); };
clone_process(runThis, SIGCHLD);
这里定义一个lambda函数runthis,然后将其传递给clone_process。
最终版本:
int jail(void *args) {
printf("child pid: %d\n", getpid());
setHostName("my-container");
setup_variables();
setup_root("./root");
mount("proc", "/proc", "proc", 0, 0);
auto runThis = [](void *args) ->int { run("/bin/sh"); };
clone_process(runThis, SIGCHLD);
umount("/proc");
return EXIT_SUCCESS;
}
int main(int argc, char** argv) {
printf("parent pid: %d\n", getpid());
clone_process(jail, CLONE_NEWPID | CLONE_NEWUTS | SIGCHLD );
return EXIT_SUCCESS;
}
现在我们的程序可以做到成功地挂载procfs,并在退出时卸载。
How it works?
当我们创建子进程(jail)时用了CLONE_NEWPID标志,这个标志使得子进程拥有自己的进程树。
正常情况下,系统是这样的:
Init-1
------
| child's
|
----------------------
| | |
systemd-2 bash-3 our-container-4
|
jail - 5
|
shell - 6
当传入CLONE_NEWPID标志后:
Init-1
------
| child's
|
----------------------
| | |
systemd-2 bash-3 our-container-4
|
jail - 5
|
shell - 6
从全局来看并没有什么变化,但是从子进程的角度来看是这样的:
jail - 1
------
| child's
|
shell-2
在容器中运行ps会得到这样的结果:
PID USER TIME COMMAND
1 root 0:00 ./container
2 root 0:00 /bin/sh
要点在于当我们克隆了PID树之后,进程就不能查看到其他进程了,但是它可以查看自己的子进程。
Control Group
为了限制容器中的应用过多地消耗资源,需要使用Linux的Control Group(cgroup)特性,它可以限制每个进程使用的资源数量,比如限制进程可以创建的子进程数。
限制进程的创建
cgroup跟procfs一样,可以挂载到文件系统中,然后通过访问文件的方式来访问它。cgroup通常挂载在这里:
/sys/fs/cgroup
cgroup中有个pids控制组可以用来限制进程创建的子进程数量:
/sys/fs/cgroup/pids/
在这个目录下,我们可以创建一个文件夹用来包含我们设定的规则,可以随便命名,这里将其命名为container。
/sys/fs/cgroup/pids/container/
编写代码来创建文件夹:
#include <sys stat.h="">
#include <sys types.h="">
#define CGROUP_FOLDER "/sys/fs/cgroup/pids/container/"
void limitProcessCreation() {
// create a folder
mkdir( CGROUP_FOLDER, S_IRUSR | S_IWUSR);
}
当我们创建这个文件夹的时候,cgroup会自动在该文件夹里创建一些文件,这些文件规定了属于这个控制组的进程应当遵循的规则。现在这个控制组还没有属于它的进程。
/sys/fs/cgroup/pids/container/$ ls
cgroup.clone_children cgroup.procs notify_on_release pids.current pids.events pids.max tasks
添加进程只需将进程写入到cgroup.procs文件中即可。
#include <string.h>
#include <fcntl.h>
#define CGROUP_FOLDER "/sys/fs/cgroup/pids/container/"
#define concat(a,b) (a"" b)
// update a given file with a string value.
void write_rule(const char* path, const char* value) {
int fp = open(path, O_WRONLY | O_APPEND );
write(fp, value, strlen(value));
close(fp);
}
void limitProcessCreation() {
// create a folder
mkdir( PID_CGROUP_FOLDER, S_IRUSR | S_IWUSR);
//getpid() give us a integer and we transform it to a string.
const char* pid = std::to_string(getpid()).c_str();
write_rule(concat(CGROUP_FOLDER, "cgroup.procs"), pid);
}
在pids.max中可以限定进程可以创建的子进程数量,我们设定为5:
void limitProcessCreation() {
// create a folder
mkdir( PID_CGROUP_FOLDER, S_IRUSR | S_IWUSR);
//getpid give us a integer and we transform it to a string.
const char* pid = std::to_string(getpid()).c_str();
write_rule(concat(CGROUP_FOLDER, "cgroup.procs"), pid);
write_rule(concat(CGROUP_FOLDER, "pids.max"), "5");
}
进程结束之后,最好释放资源,这样内核就可以将我们之前创建的container文件夹清除。因此在notify_on_release中写入1:
void limitProcessCreation() {
// create a folder
mkdir( PID_CGROUP_FOLDER, S_IRUSR | S_IWUSR);
//getpid give us a integer and we transform it to a string.
const char* pid = std::to_string(getpid()).c_str();
write_rule(concat(CGROUP_FOLDER, "cgroup.procs"), pid);
write_rule(concat(CGROUP_FOLDER, "notify_on_release"), "1");
write_rule(concat(CGROUP_FOLDER, "pids.max"), "5");
}
现在可以在主函数中调用刚刚编写的函数:
int jail(void *args) {
limitProcessCreation();
#...
}
我们需要在更改根目录之前调用它,这样才能设置执行环境。现在编译运行应该得到这样的结果:
可以看到,当创建的进程数量超过限定数量时,系统拒绝执行。
结语
恭喜你把这么长的一篇文章读完了,希望这篇文章能帮助你对容器有更好的理解。现在我们可以回答VM和容器到底有什么区别了—VM尝试去模拟完整的计算机,比如BIOS,CPU,和内存等等,但容器只不过是一种特殊的进程而已!
这里可以获得项目的完整源代码。感谢阅读:-)