从零开始写 Docker(八)---实现 mydocker run -d 支持后台运行容器

本文为从零开始写 Docker 系列第八篇,实现类似 docker run -d 的功能,使得容器能够后台运行。


完整代码见:https://github.com/lixd/mydocker

欢迎 Star

推荐阅读以下文章对 docker 基本实现有一个大致认识:


开发环境如下:

bash 复制代码
root@mydocker:~# lsb_release -a
No LSB modules are available.
Distributor ID:	Ubuntu
Description:	Ubuntu 20.04.2 LTS
Release:	20.04
Codename:	focal
root@mydocker:~# uname -r
5.4.0-74-generic

注意:需要使用 root 用户

1. 概述

经过前面的 7 篇文章,我们已经基本实现了一个简单的 docker 了。

不过与 Docker 创建的容器相比,我们还缺少以下功能

  • 1)指定后台运行容器,也就是 detach 功能
  • 2)通过 docker ps 查看目前处于运行中的容器
  • 3)通过docker logs 查看容器的输出
  • 4)通过 docker exec 进入到一个已经创建好了的容器中

后续几篇文章主要就是一一实现这些功能,本文首先实现 mydocker run -d 让容器后台运行。

2. 原理分析

在 Docker 早期版本,所有的容器 init 进程都是从 docker daemon 这个进程 fork 出来的,这也就会导致一个众所周知的问题,如果 docker daemon 挂掉,那么所有的容器都会宕掉,这给升级 docker daemon 带来很大的风险。

子进程的结束和父进程的运行是一个异步的过程,即父进程永远不知道子进程到底什么时候结束。如果创建子进程的父进程退出,那么这个子进程就成了没人管的孩子,俗称孤儿进程。为了避免孤儿进程退出时无法释放所占用的资源而僵死,进程号为 1 的 init 进程就会接受这些孤儿进程。

即:Docker 早期架构中,docker daemon挂掉后,所有容器作为子进程都会被 init 进程托管,实际上还是可以运行的,但是 docker daemon 挂了会导致他维护的一些资源也没了,所以容器实际上是不能正常运行的。

为了解决该问题后来,Docker 使用了 containerd, 负责管理容器的生命周期,包括创建、运行、停止等。同时 containerd 为每个进程都启动了一个 init 进程(图中的 containerd-shim),containerd-shim 进程负责接收来自 containerd 的命令,启动容器中的进程,并监控它们的生命周期。

便可以实现即使 daemon 挂掉,容器依然健在的功能了,其结构如下图所示。

为了简单起见,我们就按照 Docker 早期架构实现吧。在我们的实现中:

  • 当前运行命令的 mydocker 是主进程
  • 容器是被当前 mydocker 进程 fork 出来的子进程。

这样看来,mydocker 可以看做是图中的 containerd,mydocker 中具体实现 Namespace 隔离,cgroups 资源限制的部分代码则可以看做是 runC或者 libcontainer。

具体实现就是,fork 出子进程后,mydocker 进程直接退出掉。是当 mydocker 进程退出后,容器进程就会被 init 进程接管,这时容器进程还是运行着的。

也算是实现了一个简易版本的后台运行。

3. 实现

首先,需要在 main-command.go 里面添加 -d flag,表示这个容器启动的时候后台在运行:

go 复制代码
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",
       },
       cli.BoolFlag{
          Name:  "d",
          Usage: "detach container",
       },
        // 省略其他代码
    },
    /*
       这里是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")
       }

       var cmdArray []string
       for _, arg := range context.Args() {
          cmdArray = append(cmdArray, arg)
       }
       // tty和detach只能同时生效一个
       tty := context.Bool("it")
       detach := context.Bool("d")

       if tty && detach {
          return fmt.Errorf("it and d paramter can not both provided")
       }
       resConf := &subsystems.ResourceConfig{
          MemoryLimit: context.String("mem"),
          CpuSet:      context.String("cpuset"),
          CpuCfsQuota: context.Int("cpu"),
       }
       volume := context.String("v")
       Run(tty, cmdArray, resConf, volume)
       return nil
    },
}

然后调整 Run 方法,只有指定 tty 的时候才执行 parent.Wait。

parent.Wait() 主要是用于父进程等待子进程结束,这在交互式创建容器的步骤里面是没问题的,但是指定了 -d要后台运行就不能再去等待,创建容器之后,父进程直接退出即可。

go 复制代码
func Run(tty bool, comArray []string, res *subsystems.ResourceConfig, volume string) {
	parent, writePipe := container.NewParentProcess(tty, volume)
	if parent == nil {
		log.Errorf("New parent process error")
		return
	}
	if err := parent.Start(); err != nil {
		log.Errorf("Run parent.Start err:%v", err)
		return
	}
	// 创建cgroup manager, 并通过调用set和apply设置资源限制并使限制在容器上生效
	cgroupManager := cgroups.NewCgroupManager("mydocker-cgroup")
	defer cgroupManager.Destroy()
	_ = cgroupManager.Set(res)
	_ = cgroupManager.Apply(parent.Process.Pid, res)

	// 在子进程创建后才能通过pipe来发送参数
	sendInitCommand(comArray, writePipe)
	if tty { // 如果是tty,那么父进程等待,就是前台运行,否则就是跳过,实现后台运行
		_ = parent.Wait()
		container.DeleteWorkSpace("/root/", volume)
	}
}

4. 测试

运行一个 top 命令:

shell 复制代码
root@mydocker:~/feat-run-d/mydocker# go build .
root@mydocker:~/feat-run-d/mydocker# ./mydocker run -d top
{"level":"info","msg":"createTty false","time":"2024-01-24T16:58:16+08:00"}
{"level":"info","msg":"resConf:\u0026{ 0  }","time":"2024-01-24T16:58:16+08:00"}
{"level":"info","msg":"busybox:/root/busybox busybox.tar:/root/busybox.tar","time":"2024-01-24T16:58:16+08:00"}
{"level":"info","msg":"mount overlayfs: [/usr/bin/mount -t overlay overlay -o lowerdir=/root/busybox,upperdir=/root/upper,workdir=/root/work /root/merged]","time":"2024-01-24T16:58:16+08:00"}
{"level":"info","msg":"command all is top","time":"2024-01-24T16:58:16+08:00"}

可以看到,mydocker 命令直接退出了。

使用 top 作为容器内前台进程。然后在宿主机上执行 ps -ef 看一下 建的容器进程是否存在:

shell 复制代码
root@mydocker:~/feat-run-d/mydocker# ps -ef|grep -e PPID -e top
UID          PID    PPID  C STIME TTY          TIME CMD
root      166637       1  0 16:5 pts/8    00:00:00 top

可以看到,top 命令的进程正在运行着,它的父进程是 1。

这说因为mydocker 主进程退出了,但是 fork 出来的容器子进程依然存在,由于父进程消失,它就被 PID为 1 的 init 进程给托管了,由此就实现了 mydocker run -d 命令,即容器的后台运行。

4. 总结

本篇实现的 mydocker run -d 比较简单,就是启动完子进程(容器)后,直接退出父进程,让 init 进程去接管子进程。

不过现在比较大的问题是,虽然容器在后台运行了,但是已经找不到了,因此下一篇需要实现 mydocker ps 命令来查看运行中的容器。


【从零开始写 Docker 系列】 持续更新中,搜索公众号【探索云原生】订阅,阅读更多文章。



完整代码见:https://github.com/lixd/mydocker

欢迎 Star

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

需要提前在 /root 目录准备好 busybox.tar 文件,具体见第四篇第二节。

bash 复制代码
# 克隆代码
git clone -b feat-run-d https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依赖并编译
go mod tidy
go build .
# 测试 
./mydocker run top -d
相关推荐
2301_794333912 小时前
实验室服务器配置|通过Docker实现Linux系统多用户隔离与安全防控
linux·服务器·docker·实验室
JCGKS3 小时前
Docker|“ssh: connect to host xxx.xxx.xxx.xxx port 8000: Connection refused“问题解决
docker·ssh·端口·listen·tcp三次握手
荣光波比3 小时前
Nginx 实战系列(一)—— Web 核心概念、HTTP/HTTPS协议 与 Nginx 安装
linux·运维·服务器·nginx·云计算
惜.己4 小时前
Docker启动失败 Failed to start Docker Application Container Engine.
spring cloud·docker·eureka
scugxl4 小时前
centos7 docker离线安装
运维·docker·容器
绿箭柠檬茶6 小时前
Ubuntu 使用 Samba 共享文件夹
linux·运维·ubuntu
计算机小手6 小时前
AI 驱动数据分析:开源 SQLBot 项目探索,基于大模型和 RAG 实现精准问数与图表挖掘
经验分享·docker·开源软件
工藤新一¹7 小时前
Linux —— 虚拟进程地址空间
linux·运维·服务器·c/c++·虚拟进程地址空间
AI大模型7 小时前
基于Docker+DeepSeek+Dify :搭建企业级本地私有化知识库超详细教程
docker·llm·deepseek
Aspiresky7 小时前
浅析Linux内核scatter-gather list实现
linux·dma·scatter/gather