容器技术底层原理—实现一个简单容器

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 &amp;&amp; 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) -&gt;int { run("/bin/sh"); };

  clone_process(runThis, SIGCHLD);

  umount("/proc");
  return EXIT_SUCCESS;
}

稍微解释一下代码:

auto runThis = [](void *args) -&gt;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) -&gt;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,和内存等等,但容器只不过是一种特殊的进程而已!
这里可以获得项目的完整源代码。感谢阅读:-)

  • 9
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值