從零開始寫 Docker(八)---實現 mydocker run -d 支援後臺執行容器

探索云原生發表於2024-03-21

mydocker-run-d.png

本文為從零開始寫 Docker 系列第八篇,實現類似 docker run -d 的功能,使得容器能夠後臺執行。


完整程式碼見:https://github.com/lixd/mydocker
歡迎 Star

推薦閱讀以下文章對 docker 基本實現有一個大致認識:

  • 核心原理深入理解 Docker 核心原理:Namespace、Cgroups 和 Rootfs
  • 基於 namespace 的檢視隔離探索 Linux Namespace:Docker 隔離的神奇背後
  • 基於 cgroups 的資源限制
    • 初探 Linux Cgroups:資源控制的奇妙世界
    • 深入剖析 Linux Cgroups 子系統:資源精細管理
    • Docker 與 Linux Cgroups:資源隔離的魔法之旅
  • 基於 overlayfs 的檔案系統Docker 魔法解密:探索 UnionFS 與 OverlayFS
  • 基於 veth pair、bridge、iptables 等等技術的 Docker 網路揭秘 Docker 網路:手動實現 Docker 橋接網路

開發環境如下:

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-engine-arch.png

為了簡單起見,我們就按照 Docker 早期架構實現吧。在我們的實現中:

  • 當前執行命令的 mydocker 是主程序
  • 容器是被當前 mydocker 程序 fork 出來的子程序。

這樣看來,mydocker 可以看做是圖中的 containerd,mydocker 中具體實現 Namespace 隔離,cgroups 資源限制的部分程式碼則可以看做是 runC或者 libcontainer。

具體實現就是,fork 出子程序後,mydocker 程序直接退出掉。是當 mydocker 程序退出後,容器程序就會被 init 程序接管,這時容器程序還是執行著的。

也算是實現了一個簡易版本的後臺執行。

3. 實現

首先,需要在 main-command.go 裡面新增 -d flag,表示這個容器啟動的時候後臺在執行:

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要後臺執行就不能再去等待,建立容器之後,父程序直接退出即可。

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 命令:

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 看一下 建的容器程序是否存在:

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 檔案,具體見第四篇第二節。

# 克隆程式碼
git clone -b feat-run-d https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依賴並編譯
go mod tidy
go build .
# 測試 
./mydocker run top -d

相關文章