关于后端:从零开始写-Docker一实现-mydocker-run-命令

62次阅读

共计 12756 个字符,预计需要花费 32 分钟才能阅读完成。

本文为从零开始写 Docker 系列第一篇,次要实现 mydocker run 命令,结构了一个具备根本的 Namespace 隔离的简略容器。

<!–more–>


如果你对云原生技术充斥好奇,想要深刻理解更多相干的文章和资讯,欢送关注微信公众号。

搜寻公众号【摸索云原生】即可订阅



本文次要实现咱们的第一个命令 mydocker run,相似于 docker run -it [command]

docker run 命令是通过创立新的 namespace 对新的过程进行视图隔离。

残缺代码见:https://github.com/lixd/mydocker
欢送 Star

urfave/cli 工具

次要用到了 urfave/cli 来实现命令行工具,具体用法参考官网文档。

两个罕用 cli 库比照:

urfave/cli 比拟简洁,实现简略的 cli 工具举荐应用。
spf13/cobra 功能强大,实现简单的 cli 工具举荐应用。

一个简略的 urfave/cli Demo 如下:

// urfaveCli cli 包简略应用,具体能够参考官网文档
func urfaveCli() {app := cli.NewApp()

    // 指定全局参数
    app.Flags = []cli.Flag{
        cli.StringFlag{
            Name:  "lang, l",
            Value: "english",
            Usage: "Language for the greeting",
        },
        cli.StringFlag{
            Name:  "config, c",
            Usage: "Load configuration from `FILE`",
        },
    }
    // 指定反对的命令列表
    app.Commands = []cli.Command{
        {
            Name:    "complete",
            Aliases: []string{"c"},
            Usage:   "complete a task on the list",
            Action: func(c *cli.Context) error {log.Println("run command complete")
                for i, v := range c.Args() {log.Printf("args i:%v v:%v\n", i, v)
                }
                return nil
            },
        },
        {
            Name:    "add",
            Aliases: []string{"a"},
            // 每个命令上面还能够指定本人的参数
            Flags: []cli.Flag{cli.Int64Flag{
                Name:  "priority",
                Value: 1,
                Usage: "priority for the task",
            }},
            Usage: "add a task to the list",
            Action: func(c *cli.Context) error {log.Println("run command add")
                for i, v := range c.Args() {log.Printf("args i:%v v:%v\n", i, v)
                }
                return nil
            },
        },
    }

    err := app.Run(os.Args)
    if err != nil {log.Fatal(err)
    }
}

具体成果如下:

$ go run main.go -h
NAME:
   main - A new cli application

USAGE:
   main [global options] command [command options] [arguments...]

COMMANDS:
   complete, c  complete a task on the list
   add, a       add a task to the list
   help, h      Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --lang value, -l value  Language for the greeting (default: "english")
   --config FILE, -c FILE  Load configuration from FILE
   --help, -h              show help

能够看到指定的指令和参数,就是这么简略。

具体实现

目录构造

mydocker 我的项目当前目录构造如下:

$ tree .
.
├── LICENSE
├── Makefile
├── README.md
├── container
│   ├── container_process.go
│   └── init.go
├── example
│   └── main.go
├── go.mod
├── go.sum
├── main.go
├── main_command.go
└── run.go
  • main.go 作为我的项目入口
  • main_command.go 中蕴含了所有的 command
  • run.go 则是 run 命令外围逻辑
  • container 目录则是一些 container 的外围实现

main.go

首先是 main 文件:

应用 urfave/cli 提供 的命令行工具定义了 mydocker 的几个根本命令,包含 runCommandinitCommand,而后在 app.Before 内初始化 logrus 的日志配置。

package main

import (
    "os"

    log "github.com/sirupsen/logrus"

    "github.com/urfave/cli"
)

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)
    }
}

main_command.go

main_command 中蕴含了具体的命令定义:

var runCommand = cli.Command{
    Name: "run",
    Usage: `Create a container with namespace and cgroups limit
            mydocker run -it [command]`,
    Flags: []cli.Flag{
        cli.BoolFlag{
            Name:  "it", // 简略起见,这里把 -i 和 -t 参数合并成一个
            Usage: "enable tty",
        },
    },
    /*
        这里是 run 命令执行的真正函数。1. 判断参数是否蕴含 command
        2. 获取用户指定的 command
        3. 调用 Run function 去筹备启动容器:
    */
    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("it")
        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",
    /*
        1. 获取传递过去的 command 参数
        2. 执行容器初始化操作
    */
    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
    },
}

要实现 run 命令咱们须要实现 run、init 两个命令。

run.go

接着看下 Run 函数做了写什么:

// Run 执行具体 command
/*
这里的 Start 办法是真正开始后面创立好的 command 的调用,它首先会 clone 进去一个 namespace 隔离的
过程,而后在子过程中,调用 /proc/self/exe, 也就是调用本人,发送 init 参数,调用咱们写的 init 办法,去初始化容器的一些资源。*/
func Run(tty bool, cmd string) {parent := container.NewParentProcess(tty, cmd)
    if err := parent.Start(); err != nil {log.Error(err)
    }
    _ = parent.Wait()
    os.Exit(-1)
}

Run 命令次要调用 NewParentProcess 构建 os/exec.Cmd 对象并执行,执行实现立马退出。

留神辨别 os/exec 包中的 Cmd 对象和 urfave/cli 包中的 command 对象。

// NewParentProcess 启动一个新过程
/*
这里是父过程,也就是以后过程执行的内容。1. 这里的 /proc/se1f/exe 调用中,/proc/self/ 指的是以后运行过程本人的环境,exec 其实就是本人调用了本人,应用这种形式对创立进去的过程进行初始化
2. 前面的 args 是参数,其中 init 是传递给本过程的第一个参数,在本例中,其实就是会去调用 initCommand 去初始化过程的一些环境和资源
3. 上面的 clone 参数就是去 fork 进去一个新过程,并且应用了 namespace 隔离新创建的过程和外部环境。4. 如果用户指定了 -it 参数,就须要把以后过程的输入输出导入到规范输入输出上
*/
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
}

能够看到,NewParentProcess 办法构建的命令为 /proc/self/exe,这个示意调用 以后文件 ,咱们后续编译进去是二进制文件是 mydocker,那么这个命令执行的就是 mydocker。
第一个参数为 init,也就是说这个命令最终会执行mydocker init 这个命令。

这也就是为什么咱们除了实现 run 命令之外,还要实现一个 init 命令。

另外比拟重要的就是对于 tty 的:

    if tty {
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
    }

将 cmd 的输出和输入连贯到终端,以便咱们能够与命令进行交互,并看到命令的输入。

即:cmd 能够从规范输出读取输出,也能够把后果打印到规范输入或者谬误输入上。

那么当咱们执行mydocker run -it /bin/ls 这种命令时,最初的 /bin/ls 就会作为规范输出给到容器过程,因而容器过程就会执行 /bin/ls 命令, 列出当前目录下的文件。

init.go

最初再看下 initCommand 的具体内容:

// RunContainerInitProcess 启动容器的 init 过程
/*
这里的 init 函数是在容器外部执行的,也就是说,代码执行到这里后,容器所在的过程其实就曾经创立进去了,这是本容器执行的第一个过程。应用 mount 先去挂载 proc 文件系统,以便前面通过 ps 等系统命令去查看以后过程资源的状况。*/
func RunContainerInitProcess(command string, args []string) error {log.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 {log.Errorf(err.Error())
    }
    return nil
}

这里 Mount 意思如下:

  • MS_NOEXEC 在本文件系统 许运行其 程序。
  • MS_NOSUID 在本零碎中运行程序的时候,容许 set-user-ID set-group-ID
  • MS_NOD 这个参数是自 Linux 2.4,所有 mount 的零碎都会默认设定的参数。

本函数最初的 syscall.Exec 是最为重要的一句黑魔法,正是这个零碎调用实现了实现初始化动作并将用户过程运行起来的操作。

首先,应用 Docker 创立起来一个容器之后,会发现容器内的第一个程序,也就是 PID 为 1 的那个过程,是指定的前台过程。然而,咱们晓得容器创立之后,执行的第一个过程并不是用户的过程,而是 init 初始化的过程。这时候,如果通过 ps 命令查看就会发现,容器内第一个过程变成了本人的 init, 这和料想的是不一样的。

有没有什么方法把本人的过程变成 PID 为 1 的过程呢?

这里 execve 零碎调用就是用来做这件事件的。

syscall.Exec 这个办法,其实最终调用了 Kernel 的 int execve(const char *filename, char *const argv[], char *const envp[);这个零碎函数。它的作用是执行以后 filename 对应的程序, 它会笼罩以后过程的镜像、数据和堆栈等信息,包含 PID,这些都会被将要运行的过程笼罩掉

也就是说,调用这个办法,将用户指定的过程运行起来,把最后的 init 过程给替换掉,这样当进入到容器外部的时候,就会发现容器内的第一个程序就是咱们指定的过程了。

这其实也是目前 Docker 应用的容器引擎 runC 的实现形式之一。

具体启动流程如下图:

  • 1)流程开始,用户手动执行 mydocker run 命令
  • 2)urfave/cli 工具解析传递过去的参数
  • 3)解析实现后发现第一个参数是 run,于是执行 run 命令,调用 runCommand 办法,该办法中持续调用 NewParentProcess 函数构建一个 cmd 对象
  • 4)NewParentProcess 将构建好的 cmd 对象返回给 runCommand 办法
  • 5)runCommand 办法中调用 cmd.exec 执行上一步构建好的 cmd 对象
  • 6)容器启动后,依据 cmd 中传递的参数,/proc/self/exe init 实则最终会执行 mydocker init 命令,初始化容器环境
  • 7)init 命令外部实现就是通过 mount 命令挂载 proc 文件系统
  • 8)容器创立实现,整个流程完结

测试

root@mydocker:~/mydocker# go build .
root@mydocker:~/mydocker# ./mydocker run -it /bin/sh
{"level":"info","msg":"init come on","time":"2024-01-03T14:44:35+08:00"}
{"level":"info","msg":"command: /bin/sh","time":"2024-01-03T14:44:35+08:00"}
{"level":"info","msg":"command:/bin/sh","time":"2024-01-03T14:44:35+08:00"}
# ps -ef
UID          PID    PPID  C STIME TTY          TIME CMD
root           1       0  0 09:47 pts/1    00:00:00 /bin/sh
root           5       1  0 09:47 pts/1    00:00:00 ps -ef

在容器运行 ps 时,能够发现 /bin/sh 程是容器内的第一个过程,PID 为 1。ps 过程是 PID 为 1 的父过程创立进去的。

来比照 Docker 运行的容器的成果,如下:

[root@docker ~]# docker run -it ubuntu /bin/sh
# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 01:49 pts/0    00:00:00 /bin/sh
root         7     1  0 01:49 pts/0    00:00:00 ps -ef

不能说很类似,只能说是截然不同了。

这里的 /bin/sh 是一个会在前台始终运行的过程,那么能够试一下如果指定一个运行完就会退出的过程会是什么成果:

root@mydocker:~/mydocker# ./mydocker run -it /bin/ls
{"level":"info","msg":"init come on","time":"2024-01-03T14:51:48+08:00"}
{"level":"info","msg":"command: /bin/ls","time":"2024-01-03T14:51:48+08:00"}
{"level":"info","msg":"command:/bin/ls","time":"2024-01-03T14:51:48+08:00"}
LICENSE  Makefile  README.md  container  example  go.mod  go.sum  main.go  main_command.go  mydocker  run.go

运行了一下 ls 命令,发现容器启动起来当前,打印出了当前目录的内容,而后便退出了,这个后果和 Docker 要求容器必须有一个始终在前台运行的过程的要求统一。

因为没有 chroot,所以目前的零碎文件系统是继承自父过程的。

至此,咱们的 mydocker run 命令就算是实现实现,根本能实现 docker run 的成果。

小结

run 命令实现中感觉几个比拟重要的点:

  • /proc/self/exe:调用本身 init 命令,初始化容器环境
  • tty:实现交互
  • Namespace 隔离:通过在 fork 时指定对应 Cloneflags 来实现创立新 Namespace
  • proc 隔离:通过从新 mount /proc 文件系统来实现过程信息隔离
  • execve 零碎调用:应用指定过程笼罩 init 过程

/proc/self/exe

/proc/self/exe 是 Linux 零碎中的一个符号链接,它指向以后过程的可执行文件。这个门路是一个虚构门路,实际上并不对应于文件系统中的一个文件,而是通过 /proc 文件系统提供的一种形式来拜访过程相干的信息。

具体而言,/proc/self 是一个指向以后过程本身的符号链接,而 exe 则是一个非凡的文件,通过这个文件能够拜访以后过程的可执行文件。因而,/proc/self/exe 实际上是以后过程可执行文件的门路

也就是说在 mydocker run 命令中执行的 /proc/self/exe init 实际上最终执行的是 mydocker init,即 run 命令会调用 init 命令来初始化容器环境。

tty

    if tty {
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr
    }

当用户指定 -it 参数时,就将 cmd 的输出和输入连贯到终端,以便咱们能够与命令进行交互,并看到命令的输入。

Namespace 隔离

    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,
    }

fork 新过程时,通过指定 Cloneflags 会创立对应的 Namespace 以实现隔离,这里包含 UTS(主机名)、PID(过程 ID)、挂载点、网络、IPC 等方面的隔离。

proc 隔离

/proc 文件系统是一个虚构的文件系统,提供了对内核和运行中过程的信息的拜访。通过挂载 /proc,零碎中的许多信息和管制接口能够通过文件的模式在这个目录下找到。

例如,你能够通过 /proc 查看零碎的一些信息,如过程列表、内存应用状况、CPU 信息等。

比方以后机器上的过程信息:

[root@docker ~]# ls /proc
1     1147  16     18531  20     247    325  350  468  632  74    79   85    908        cmdline    driver       ioports    kpagecgroup  misc          pressure     stat           tty
10    1150  16968  18533  20728  3      326  351  491  633  75    8    86    978        consoles   execdomains  irq        kpagecount   modules       sched_debug  swaps          uptime
100   1152  17     18534  21     31652  327  352  508  635  76    80   87    acpi       cpuinfo    fb           kallsyms   kpageflags   mounts        schedstat    sys            version
1094  1155  18     18549  22     31797  333  353  544  637  7675  813  88    buddyinfo  crypto     filesystems  kcore      loadavg      mtrr          scsi         sysrq-trigger  vmallocinfo
11    13    18433  18550  244    31818  347  354  587  641  7694  82   8897  bus        devices    fs           keys       locks        net           self         sysvipc        vmstat
1100  14    18435  19     245    323    348  355  6    643  77    832  89    capi       diskstats  interrupts   key-users  mdstat       pagetypeinfo  slabinfo     thread-self    zoneinfo
1141  15    18505  2      246    324    349  4    620  73   78    84   9     cgroups    dma        iomem        kmsg       meminfo      partitions    softirqs     timer_list

而在容器环境中,为了和宿主机的 /proc 环境隔离,因而在 mydocker init 命令中咱们会从新挂载 /proc 文件系统,即:

syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")

对应 mount 命令为:

mount -t proc proc /proc

而以后过程在 fork 时指定了syscall.CLONE_NEWPID 等等标记,因而是在新的 Namespace 中的,那就意味着看不到宿主机上的过程信息,那么从新挂载后的 /proc 文件系统天然也就只有以后 Namespace 下的过程信息。

这也就是为什么在容器中执行 ps 命令只能看到容器中的过程信息

execve 零碎调用

execve 零碎调用用于取代以后过程的映像(即,以后过程的可执行文件),并用一个新的程序来代替

原型如下:

int execve(const char *filename, char *const argv[], char *const envp[);
  • filename 参数指定了要执行的新程序的文件门路。
  • argv 参数是一个字符串数组,蕴含了新程序的命令行参数。数组的第一个元素通常是新程序的名称,随后的元素是命令行参数。
  • envp 参数是一个字符串数组,蕴含了新程序执行时应用的环境变量。

execve 的工作形式是加载指定的程序文件,并将它代替以后过程的内存映像。因而,执行 execve 后,原过程的代码、数据等内容都会被新程序的内容代替。

即:它的作用是执行以后 filename 对应的程序, 它会笼罩以后过程的镜像、数据和堆栈等信息,包含 PID,这些都会被将要运行的过程笼罩掉。

在 Go 中的调用形式为 syscall.Exe。通过该零碎调用,能够应用用户指定的命令启动新过程来笼罩 mydocker 过程作为容器环境中的 PID 1 过程。

即:在 init 命令中解析拿到用户指定的命令并通过 syscall.Exe 应用该命令创立新过程来笼罩 mydocker 过程。

这也就是为什么咱们执行 mydocker run -it /bin/sh 后 sh 会成为 PID 1 过程。

看到这里的话,mydocker run 命令的具体实现及其关键点,都残缺介绍了一遍了,再回过头来看一下具体流程,应该就更清晰了:

  • 1)流程开始,用户手动执行 mydocker run 命令
  • 2)urfave/cli 工具解析传递过去的参数
  • 3)解析实现后发现第一个参数是 run,于是执行 run 命令,调用 runCommand 办法,该办法中持续调用 NewParentProcess 函数构建一个 cmd 对象
  • 4)NewParentProcess 将构建好的 cmd 对象返回给 runCommand 办法
  • 5)runCommand 办法中调用 cmd.exec 执行上一步构建好的 cmd 对象
  • 6)容器启动后,依据 cmd 中传递的参数,/proc/self/exe init 实则最终会执行 mydocker init 命令,初始化容器环境
  • 7)init 命令外部实现就是通过 mount 命令挂载 proc 文件系统
  • 8)容器创立实现,整个流程完结

FAQ

以下是在实现 mydocker run 命令时可能呈现的问题

对应代码曾经提交,应用本仓库代码不会呈现该问题

fork/exec /proc/self/exe: no such file or directory

在失常第二次 mydocker 命令时呈现该谬误,具体如下:

root@mydocker:~/mydocker# ./mydocker run -it /bin/ls
{"level":"info","msg":"init come on","time":"2024-01-03T15:07:27+08:00"}
{"level":"info","msg":"command: /bin/ls","time":"2024-01-03T15:07:27+08:00"}
{"level":"info","msg":"command:/bin/ls","time":"2024-01-03T15:07:27+08:00"}
LICENSE  Makefile  README.md  container  example  go.mod  go.sum  main.go  main_command.go  mydocker  run.go
root@mydocker:~/mydocker# ./mydocker run -it /bin/ls
{"level":"error","msg":"fork/exec /proc/self/exe: no such file or directory","time":"2024-01-03T15:07:28+08:00"}

起因

这个是因为代码中会将容器过程的 proc 信息挂载为 proc 文件系统,具体代码如下:

// container/init.go#RunContainerInitProcess 办法
    defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
_ = syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")

这部分代码会在 mydocker init 中执行,也就是说实际上是在容器过程中执行的 mount,当咱们的 mydocker 过程运行完结退出后,容器过程就隐没了。

而在引入了 systemd 之后的 linux 中,mount namespace 是 shared by default,也就是说宿主机上的 /proc 目录也被影响了。

即:宿主机 /proc 目录的内容仍旧是运行 mydocker 时的信息,而此时因为 mydocker 曾经退出了,对应的过程信息天然就不存在了,所以会在执行 mydocker run 中的 /proc/self/exe 这个命令时呈现这个谬误。

解决方案

长期解决方案:在宿主机手动执行一次 mount,从新挂载 /proc 目录,即可将 /proc 目录复原为失常数据

mount -t proc proc /proc

后续每次运行 mydocker 命令都会毁坏掉 /proc 目录数据。

永恒解决方案 :将 mount 事件显式指定为 private 即可防止挂载事件外泄,这样就不会毁坏宿主机 /proc 目录数据了。
具体代码调整如下:

// container/init.go#RunContainerInitProcess 办法
// systemd 退出 linux 之后, mount namespace 就变成 shared by default, 所以你必须显示申明你要这个新的 mount namespace 独立。// 即 mount proc 之前先把所有挂载点的流传类型改为 private,防止本 namespace 中的挂载事件外泄。syscall.Mount("","/","", syscall.MS_PRIVATE|syscall.MS_REC, "")
// 如果不先做 private mount,会导致挂载事件外泄,后续再执行 mydocker 命令时 /proc 文件系统异样
// 能够执行 mount -t proc proc /proc 命令从新挂载来解决
// --- 分割线 ---
defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
_ = syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), "")

外围为这一句:

syscall.Mount("","/","", syscall.MS_PRIVATE|syscall.MS_REC, "")

把所有挂载点的流传类型改为 private,防止本 namespace 中的挂载事件外泄。

相干探讨:#33、#41#issuecomment-478799767、#58


如果你对云原生技术充斥好奇,想要深刻理解更多相干的文章和资讯,欢送关注微信公众号。

搜寻公众号【摸索云原生】即可订阅


残缺代码见:https://github.com/lixd/mydocker
欢送 Star

相干代码见 feat-run 分支, 测试脚本如下:

# 克隆代码
git clone -b feat-run https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依赖并编译
go mod tidy
go build .
# 测试
./mydocker run -it /bin/ls

正文完
 0