概述
首先实现一个简单版本的 run 命令,类似于 docker run -ti [command] 。然后再后续逐步添加 network mount filesystem 等功能。为了方便了解Docker 启动容器的原理,该简单版本的实现参考 runC 实现。
源码结构
目前的代码文件结构如下。
mydocker/
|-- container
| |-- container_process.go
| `-- init.go
|-- Godeps
| |-- Godeps.json
| `-- Readme
|-- main_command.go
|-- main.go
|-- network
| `-- test_linux.go
|-- README.md
|-- run.go
`-- vendor
源码解析
main.go
package main
import (
log "github.com/Sirupsen/logrus"
"github.com/urfave/cli"
"os"
)
const usage = `mydocker is a simple container runtime implementation.
The purpose of this project is to learn how docker works and how to write a docker by ourselves
Enjoy it, just for fun.`
func main() {
app := cli.NewApp()
app.Name = "mydocker"
app.Usage = usage
app.Commands = []cli.Command{
initCommand,
runCommand,
}
app.Before = func(context *cli.Context) error {
// Log as JSON instead of the default ASCII formatter.
log.SetFormatter(&log.JSONFormatter{})
log.SetOutput(os.Stdout)
return nil
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
使用 github.com/urfave/cli 提供的命令行工具,定义了mydocker 的几个 基本命令 ,包括runCommand和initCommand ,然后在 app Before 内初始化 logrus 的日志。
main_command.go
package main
import (
"fmt"
log "github.com/Sirupsen/logrus"
"github.com/urfave/cli"
"github.com/xianlubird/mydocker/container"
)
var runCommand = cli.Command{
Name: "run",
Usage: `Create a container with namespace and cgroups limit
mydocker run -ti [command]`,
Flags: []cli.Flag{
cli.BoolFlag{
Name: "ti",
Usage: "enable tty",
},
},
Action: func(context *cli.Context) error {
if len(context.Args()) < 1 {
return fmt.Errorf("Missing container command")
}
cmd := context.Args().Get(0)
tty := context.Bool("ti")
Run(tty, cmd)
return nil
},
}
var initCommand = cli.Command{
Name: "init",
Usage: "Init container process run user's process in container. Do not call it outside",
Action: func(context *cli.Context) error {
log.Infof("init come on")
cmd := context.Args().Get(0)
log.Infof("command %s", cmd)
err := container.RunContainerInitProcess(cmd, nil)
return err
},
}
- runCommand 定义了 Flags ,其作用类似于运行命令时使用:指定参数;
- Action: func是 run 命令执行的真正函数。
- 判断参数是否包含 command
- 获取用户指定的 command
- 调用 Run function 去准备启动容器
- initCommand 的操作为内部方法,禁止外部调用
- 获取传递过来的 command 参数
- 执行容器初始化操作
container_process.go
package container
import (
"syscall"
"os/exec"
"os"
)
func NewParentProcess(tty bool, command string) *exec.Cmd {
args := []string{"init", command}
cmd := exec.Command("/proc/self/exe", args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
}
if tty {
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
}
return cmd
}
这里是父进程,也就是当前进程执行的内容。
- 这里的/proc/self/exe 调用中,/proc/self/指的是当前运行进程自己的环境, exec 其实就是自己调用了自己,使用这种方式对创建出来的进程进行初始化;
- 后面的 args 是参数,其中 init 是传递给本进程的第1个参数,在本例中,其实就是会去调用 initCornmand去初始化进程的一些环境和资源;
- 下面的 clone 参数就是去 fork 出来一个新进程,并且使用了 namespace 隔离新创建的进程和外部环境;
- 如果用户指定了-ti 参数,就需要把当前进程的输入输出导入到标准输入输出上。
run.go
package main
import (
"github.com/xianlubird/mydocker/container"
log "github.com/Sirupsen/logrus"
"os"
)
func Run(tty bool, command string) {
parent := container.NewParentProcess(tty, command)
if err := parent.Start(); err != nil {
log.Error(err)
}
parent.Wait()
os.Exit(-1)
}
这里的 Start 方法是真正开始前面创建好的 command 的调用,它首先会 clone 出来 namespace 隔离的进程,然后在子进程中,调用/proc/self/exe ,也就是调用自己,发送 init 参数,调用我们写的init 方法,去初始化容器的一些资源。
init.go
这里的 init 函数是在容器内部执行的,也就是说代码执行到这里后容器所在进程其实就已经创建出来了,这是本容器执行的第1个进程。
使用 mount 先去挂载 proc 文件系统,以便后面通过 ps 等系统命令去查看当前进程资源的情况。
package container
import (
"os"
"syscall"
"github.com/Sirupsen/logrus"
)
func RunContainerInitProcess(command string, args []string) error {
logrus.Infof("command %s", command)
defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")
argv := []string{command}
if err := syscall.Exec(command, argv, os.Environ()); err != nil {
logrus.Errorf(err.Error())
}
return nil
}
这里 MountFlag 意思如下:
- MS_NOEXEC 在本文件系统中不允许运行其他程序;
- MS_NOSUID 在本系统中运行程序 的时候,不允许set-user-ID或者set-group-ID;
- MS_NODEV 这个参数是自Linux 2.4,所有的mount都会默认设定的参数。
本函数最后syscall.Exec实现系统调用,并将用户进程运行起来的操作:
首先,使用 Docker 建起来一个容器之后会发现容器内的第一个程序,也就是 PID为1的那个进程,是指定的前台进程。
容器创建之后,执行的第1个进程并不是用户的进程,而是 init 初始化的进程。
这时,如通过ps -ef命令查看就会发现,容器内第一个进程变成了自己 init,和预想的是不一 样的。你可能会想大不了,把这个进程给kill掉。但这里又有一个头疼的问题PID为1的进程是不能kill的,如果该进程被 kill 的容器也就不存在了。 那么这里 execve系统调用就可以大显神威了。
sysCall.Exec这个方法其实最终调用了 Kernel int execve(const char *filename, char *const argv[] , char *const envp[]) ;这个系统函数的作用是执行档期filename对应的程序。它会覆盖当前进程的镜像、数据、堆栈等信息,包括PID这些会被将要运行的进程覆盖掉。
也就是说,调用这个方法,将用户指定的进程运行起来,把最初的init进程给替换掉,这样当进入到容器内部的时候, 会发现容器内的第一个程序就是我们指定的进程了。这其实也是目Docker 使用的容器引擎 rune 实现方式之一。
测试
下面来将其编译运行 一下。
go build .
./mydocker run -ti /bin/sh
{"level":"info","msg":"init come on","time":"2020-07-21T16:07:25+08:00"}
{"level":"info","msg":"command /bin/sh","time":"2020-07-21T16:07:25+08:00"}
{"level":"info","msg":"command /bin/sh","time":"2020-07-21T16:07:25+08:00"}
ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 16:07 pts/0 00:00:00 /bin/sh
root 5 1 0 16:07 pts/0 00:00:00 ps -ef
ls
container Godeps main_command.go main.go mydocker network README.md run.go vendor
由于没有 chroot ,所以目前的系统文件系统是继承自父进程的。运行了一下 ls 命令,发现容器启动起来以后,打印出了当前目录的内容。