| 知识来自于极客时间 张磊老师的《深入剖析Kubernetes》,这里是作为学习笔记存放。

Docker 深入理解

虚拟机与容器

以前,想要搭建一个开发环境或者是博客,就需要找一篇教程,按部就班的一步步走下来。后来我想能不能把整个系统打包下来,每次到新的电脑上,直接下载这个系统装上去就行了。如果真的打包一个系统肯定不可能,那么打包一个虚拟机还是很容易的。

虚拟机本身就是模拟出一整套的硬件和系统,可以说虚拟机里的所作所为与宿主机没有任何关系。缺点就是性能消耗太大了,要知道,在一个操作系统上跑一个另外一个完整的操作系统这件事本身就占据了极大的资源开支,更别说还需要在虚拟机上运行程序。常见的虚拟机有VMware。

如果说要把虚拟机的所有内容打包下来给别人使用,这个压缩包也太大了。起码5G以上。有没有什么办法可以尽可能的减少一些我不需要的内容呢? 比如像操作系统这样的我就是不想需要的,因为我能运行开发环境或者博客,肯定已经有了操作系统了。

Docker 容器应运而生。容器技术本身就是通过操作系统的隔离(namespace)做出了一个完整的rootfs文件系统,里面包含了应用程序所需要的依赖,对于程序最大的依赖而言就是操作系统,其与宿主机共享使用一个内核,通过CGroup(control group)来对应用程序能够占用到的资源进行限制。

下图清晰的说明了容器和虚拟机的区别。

这幅图的左边,画出了虚拟机的工作原理。其中,名为 Hypervisor 的软件是虚拟机最主要的部分。它通过硬件虚拟化功能,模拟出了运行一个操作系统需要的各种硬件,比如 CPU、内存、I/O 设备等等。然后,它在这些虚拟的硬件上安装了一个新的操作系统,即 Guest OS。

这样,用户的应用进程就可以运行在这个虚拟的机器中,它能看到的自然也只有 Guest OS 的文件和目录,以及这个机器里的虚拟设备。这就是为什么虚拟机也能起到将不同的应用进程相互隔离的作用。

而这幅图的右边,则用一个名为 Docker Engine 的软件替换了 Hypervisor。这也是为什么,很多人会把 Docker 项目称为“轻量级”虚拟化技术的原因,实际上就是把虚拟机的概念套在了容器上。

虚拟机和容器的区别

“敏捷”和“高性能”是容器相较于虚拟机最大的优势,也是它能够在 PaaS 这种更细粒度的资源管理平台上大行其道的重要原因。

不过,有利就有弊,基于 Linux Namespace 的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是:隔离得不彻底。

相比于虚拟化技术,Linux 的隔离机制也有诸多限制,首要的就是内核使用的任然是同一个,其次是很多资源和兑现无法被Namespace化,例如时间。

隔离与限制

Namespace

下面来写一个简单的容器:

#define _GNU_SOURCE
#include <sys/mount.h> 
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#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");
  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_NEWNS | SIGCHLD , NULL);
  waitpid(container_pid, NULL, 0);
  printf("Parent - container stopped!\n");
  return 0;
}

这段代码的功能非常简单:在 main 函数里,我们通过 clone() 系统调用创建了一个新的子进程 container_main,并且声明要为它启用 Mount Namespace(即:CLONE_NEWNS 标志)。

而这个子进程执行的,是一个“/bin/bash”程序,也就是一个 shell。所以这个 shell 就运行在了 Mount Namespace 的隔离环境中。

编译启动后发现我们看到的文件系统和宿主机完全一样。

这是怎么回事呢?

仔细思考一下,你会发现这其实并不难理解:Mount Namespace 修改的,是容器进程对文件系统“挂载点”的认知。但是,这也就意味着,只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。

也就是说,我们在容器进程执行前可以添加重新挂载/tmp目录的操作。

int container_main(void* arg)
{
  printf("Container - inside the container!\n");
  // 如果你的机器的根目录的挂载类型是 shared,那必须先重新挂载根目录
  // mount("", "/", NULL, MS_PRIVATE, "");
  mount("none", "/tmp", "tmpfs", 0, "");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}

启动运行后发现/tmp目录下是没有东西的。

这就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。

CGroup

容器镜像

Docker

小结

容器技术中一个非常重要的概念,即:容器是一个“单进程”模型。

由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里 PID=1 的进程,也是其他后续创建的所有进程的父进程。这就意味着,在一个容器中,你没办法同时运行两个不同的应用,除非你能事先找到一个公共的 PID=1 的程序来充当两个不同应用的父进程,这也是为什么很多人都会用 systemd 或者 supervisord 这样的软件来代替应用本身作为容器的启动进程。

这是因为容器本身的设计,就是希望容器和应用能够同生命周期,这个概念对后续的容器编排非常重要。否则,一旦出现类似于“容器是正常运行的,但是里面的应用早已经挂了”的情况,编排系统处理起来就非常麻烦了。