前言
之前的几篇文章对docker依赖的Linux底层技术进行了剖析讲解, 我们现在简单回顾一下:
- 容器实际上是通过Linux的Namespace机制隔离起来的一个”沙箱”, 沙箱中运行着一组进程。
- 容器通过cgroup对容器进行分组和使用资源限制。
- 容器通过aufs组织文件系统,将不变的镜像作为只读层,在其之上是容器的读写层。
- 容器利用虚拟网络设备和iptables进行网络通讯,典型的网络模型为bridge网络。
那从这篇开始,我们就根据这些特性实现一个mini版的docker。
代码已经开源在github: miniDocker
准备工作
创建容器涉及的代码文件
.
├── boot
│ └── init.go // 环境初始化
├── cmd
│ ├── init_cmd.go // mdocker init 逻辑入口
│ └── run_cmd.go // mdocker run 逻辑入口
├── config
│ └── global_config.go // 全局配置
├── container
│ ├── cgroups
│ │ ├── cgroup_manager.go // cgroup管理
│ │ └── subsystems // subsystem具体设置处理逻辑
│ └── container_init
│ └── container_process.go // 创建容器的init进程
├── entry.go // 程序入口文件
└── utils
└── logger_utils.go // print log方法
创建容器
如何创建一个容器?
我们先来看一个命令:
mdocker run -ti busybox sh
当你敲下这行命令,一个以busybox
为镜像的容器就启动了,在容器中运行着一个sh
进程。
现在让我们来思考一个问题,之前我们在讲解namespace时分别用了unshare
和go程序调用shell命令来启动新进程同时创建namespace,那我们在mdocker中如何来做这件事的呢?
如上图所示,mdocker run
启动容器的流程为:
- 创建一个新的进程
mdocker init
,这个命令只用于创建容器时使用且为非用户指令。创建进程时,会新建一个pipe并将一端设置到init进程上,这个pipe用于传递用户命令。 mdocker run
进程在创建init进程后,还需要在外部执行一些环境设置逻辑,处理完成后将user command通过pipe发送给init进程
。- init进程读取到用户命令后,先执行容器环境设置,如文件系统等。之后调用
exec
执行用户命令。
// run命令主逻辑
func run(ctx *cli.Context) error {
// 创建 mdocker init进程
initCmd, initPipe, err := container_init.NewContainerProcess()
if err != nil {
return err
}
if err = initCmd.Start(); err != nil {
return err
}
// 将用户命令指定命令通过pipe传递给init进程
err = sendInitCommandParams(cmdArr, initPipe)
if err != nil {
return err
}
// 当设置了ti参数时, 等待init进程退出
if ctx.Bool(runCmdFlagTty) {
_ = initCmd.Wait()
}
return nil
}
容器启动涉及到2个进程,run进程和init进程。run进程是用户直接创建的进程,由于是用户直接创建的,所以run进程是运行在container外的。
而container是在创建init进程时,指定clone_flag
创建namespace后形成的。
这样设置的目的是:
- run进程和init进程处于容器内外,可以分别负责为容器设置环境。如run进程负责记录一些容器信息、设置cgroup、初始化网络等。
- 实现起来比较简单。
exec替换init进程
容器的最终目的是要运行用户目标程序,所以init进程还有一个任务就是要负责启动目标程序。最直接的办法当然是直接创建一个新进程。 但init进程在container的namespace中是pid为1的进程,当init进程退出时namespace会被操作系统自动销毁,并且在namespace下的所有进程都会收到signal退出运行。
这里就要使用到linux的一个系统调用-exec
。exec会用目标程序替换init进程,这个过程中保持pid不变,只是原init进程的上下文和数据栈等被替换为目标程序。
所以我们在init进程处理完容器初始化后,用exec来启动目标进程,这样就能让目标程序在容器中的进程id为1。
// ContainerProcessInit 容器init进程初始化
func ContainerProcessInit() error {
// call exec to replace current process, cmdArray: exec file path(only used for display), params...
if err = syscall.Exec(cmdPath, cmdArray[0:], os.Environ()); err != nil {
utils.LoggerUtil.Fatalf("mount fail: %s", err.Error())
}
return nil
}
在linux环境下,也可以用exec命令来使用这个系统调用:
$ echo $$
24871
$ exec /bin/bash
$ echo $$ # 进程id没有变化
24871
cgroup hierarchy节点创建
我们需要为container创建一个cgroup节点,然后将init进程分配到对应的节点中。
在介绍cgroup hierarchy时,我们提到linux默认在/sys/fs/cgroup
下创建了默认的cgroup hierarchy tree并将subsystem与之绑定。
对于一个cgroup,我们按照以下结构去创建container节点:
.
├── ...
├── mdocker
├── <container_id 1>
└── <container_id 2>
在cgroup根目录下创建mdocker节点,作为所有容器节点的根节点。在mdocker下,则是对应每个container对应的cgroup node。
cgroup的处理逻辑通过cgroupManager
实现,具体操作则通过路径创建/删除、文件写入实现。
结束语
本文讲解了mdocker创建容器的流程,解析了关键的逻辑点。但是具体的代码实现还是要大家自己去结合注释去阅读、调试。本系列文章也不会一行行代码地去解释,而是讲解实现上的关键点,欢迎大家去阅读github仓库中的代码。