從零開始寫 Docker(十四)---重構:實現容器間 rootfs 隔離

探索云原生發表於2024-05-10

refacotr-isolate-rootfs.png

本文為從零開始寫 Docker 系列第十四篇,實現容器間的 rootfs 隔離,使得多個容器間互不影響。


完整程式碼見: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. 概述

雖然在前面透過 pivotRoot、overlayfs 實現了容器和宿主機的 rootfs 隔離,但是多個容器還是共用的一個rootfs,多容器之間會互相影響。

之前容器都是用的宿主機上的 /root/merged 目錄作為自己的 rootfs,當啟動多個容器時可寫層會互相影響。

本篇透過為每個容器單獨準備一個 rootfs 來實現隔離,使得我們多個容器之間互不影響。

2. 實現

為了實現該功能,需要做以下工作:

  • 修改 mydocker commit 命令,實現對不同容器進行打包映象的功能。
  • 修改 mydocker run 命令,使用者可以指定不同映象,併為每個容器分配單獨的隔離檔案系統
    • 根據映象名稱找到對應 tar 檔案,解壓後作為overlay 中的 lower 目錄進行掛載
  • 修改 mydocker rm 命令,刪除容器時順帶刪除檔案系統

這三處調整實際上都是對宿主機上容器 rootfs 目錄的調整,把 rootfs 從原來的 /root/merged 調整為 /var/lib/mydocker/overlay2/{containerID}/merged ,這樣實現容器之間的隔離。

docker 也是使用的var/lib/docker/overlay2/{containerID}/merged 目錄作為 rootfs.可以使用docker inspect {containerID} -f '{{json .GraphDriver}}' 命令檢視。

2.1 commit 命令更新

之前 commit 命令直接把/root/merged 目錄壓縮為 tar 作為映象,現在需要根據 containerID 以/var/lib/mydocker/overlay2/{containerID}/merged 格式來拼接目錄。

首先,在 main_command.go 檔案中修改 commitCommand,將使用者輸入引數改為 containerID 和 imageName,並呼叫 commitContainer 方法實現 commit 操作。

var commitCommand = cli.Command{
	Name:  "commit",
	Usage: "commit container to image,e.g. mydocker commit 123456789 myimage",
	Action: func(context *cli.Context) error {
		if len(context.Args()) < 2 {
			return fmt.Errorf("missing container name and image name")
		}
		containerID := context.Args().Get(0)
		imageName := context.Args().Get(1)
		return commitContainer(containerID, imageName)
	},
}

然後 commitContainer 中調整一下壓縮路徑,根據 containerID 拼接要壓縮的目錄

var ErrImageAlreadyExists = errors.New("Image Already Exists")

func commitContainer(containerID, imageName string) error {
	mntPath := utils.GetMerged(containerID)
	imageTar := utils.GetImage(imageName)
	exists, err := utils.PathExists(imageTar)
	if err != nil {
		return errors.WithMessagef(err, "check is image [%s/%s] exist failed", imageName, imageTar)
	}
	if exists {
		return ErrImageAlreadyExists
	}
	log.Infof("commitContainer imageTar:%s", imageTar)
	if _, err = exec.Command("tar", "-czf", imageTar, "-C", mntPath, ".").CombinedOutput(); err != nil {
		return errors.WithMessagef(err, "tar folder %s failed", mntPath)
	}
	return nil
}

2.2 run 命令更新, 實現隔離檔案系統

run 命令改動比較大, 需要把涉及到目錄的都進行調整。

改動點:

  • 1)runCommand 命令中新增 imageName 引數,讓使用者可以指定映象啟動容器
  • 2)啟動容器時, rootfs 部分需要根據 containerID 拼接目錄

runCommand

runCommand 命令中新增 imageName 作為第一個引數輸入

var runCommand = cli.Command{
	Action: func(context *cli.Context) error {
		// 省略其他內容
		// get image name
		imageName := cmdArray[0]
		cmdArray = cmdArray[1:]

		tty := context.Bool("it")
		detach := context.Bool("d")

		// Run方法增加對應引數
		Run(tty, cmdArray, resConf, volume, containerName, imageName)
		return nil
	},
}

相關方法都要增加 imageName 引數:

func Run(tty bool, comArray []string, res *subsystems.ResourceConfig, volume, containerName, imageName string) {
	containerId := container.GenerateContainerID() // 生成 10 位容器 id

	// start container
	parent, writePipe := container.NewParentProcess(tty, volume, containerId, imageName)
	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
	}

	// record container info
	err := container.RecordContainerInfo(parent.Process.Pid, comArray, containerName, containerId)
	if err != nil {
		log.Errorf("Record container info error %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(containerId, volume)
		container.DeleteContainerInfo(containerId)
	}
}

rootfs 相關調整

rootfs 相關目錄定義成變數,並提供相應的 Get 方法,呼叫時指定 containerID 即可拿到對應目錄。

// 容器相關目錄
const (
	ImagePath       = "/var/lib/mydocker/image/"
	RootPath        = "/var/lib/mydocker/overlay2/"
	lowerDirFormat  = RootPath + "%s/lower"
	upperDirFormat  = RootPath + "%s/upper"
	workDirFormat   = RootPath + "%s/work"
	mergedDirFormat = RootPath + "%s/merged"
	overlayFSFormat = "lowerdir=%s,upperdir=%s,workdir=%s"
)

func GetRoot(containerID string) string { return RootPath + containerID }

func GetImage(imageName string) string { return fmt.Sprintf("%s%s.tar", ImagePath, imageName) }

func GetLower(containerID string) string {
	return fmt.Sprintf(lowerDirFormat, containerID)
}

func GetUpper(containerID string) string {
	return fmt.Sprintf(upperDirFormat, containerID)
}

func GetWorker(containerID string) string {
	return fmt.Sprintf(workDirFormat, containerID)
}

func GetMerged(containerID string) string { return fmt.Sprintf(mergedDirFormat, containerID) }

func GetOverlayFSDirs(lower, upper, worker string) string {
	return fmt.Sprintf(overlayFSFormat, lower, upper, worker)
}

另外則是 NewWorkSpace 和 DeleteWorkSpace 這兩個方法以及其內部的一系列方法涉及到的路徑全改成動態的,根據 containerID 進行拼接:

這裡貼一下 NewWorkSpace 和 DeleteWorkSpace 兩個方法:

// NewWorkSpace Create an Overlay2 filesystem as container root workspace
/*
1)建立lower層
2)建立upper、worker層
3)建立merged目錄並掛載overlayFS
4)如果有指定volume則掛載volume
*/
func NewWorkSpace(volume, imageName, containerName string) {
	err := createLower(imageName)
	if err != nil {
		log.Errorf("createLower err:%v", err)
		return
	}
	err = createUpperWorker(containerName)
	if err != nil {
		log.Errorf("createUpperWorker err:%v", err)
		return
	}
	err = mountOverlayFS(containerName)
	if err != nil {
		log.Errorf("mountOverlayFS err:%v", err)
		return
	}
	if volume != "" {
		volumeURLs := volumeUrlExtract(volume)
		if len(volumeURLs) == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" {
			err = mountVolume(containerName, volumeURLs)
			if err != nil {
				log.Errorf("mountVolume err:%v", err)
				return
			}
		} else {
			log.Infof("volume parameter input is not correct.")
		}
	}
}
// DeleteWorkSpace Delete the OverlayFS filesystem while container exit
/*
和建立相反
1)有volume則解除安裝volume
2)解除安裝並移除merged目錄
3)解除安裝並移除upper、worker層
*/
func DeleteWorkSpace(volume, containerName string) error {
	// 如果指定了volume則需要先umount volume
	if volume != "" {
		volumeURLs := volumeUrlExtract(volume)
		length := len(volumeURLs)
		if length == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" {
			err := umountVolume(containerName, volumeURLs)
			if err != nil {
				return errors.Wrap(err, "umountVolume")
			}
		}
	}
	// 然後umount整個容器的掛載點
	err := umountOverlayFS(containerName)
	if err != nil {
		return errors.Wrap(err, "umountOverlayFS")
	}
	// 最後移除相關資料夾
	err = removeUpperWorker(containerName)
	if err != nil {
		return errors.Wrap(err, "removeUpperWorker")
	}
	return nil
}

至此,基本改動完成了,建立出的每個容器都會單獨在/var/lib/mydocker/overlay2/ 目錄下生成一個 rootfs 目錄,這樣就避免了多個容器之間互相影響。

2.3 更新 rm 命令

之前,由於對應的檔案系統因為是共用的,所以沒有刪除, rm 命令只把容器資訊刪了,這次對 rm 命令進行調整,刪除時也把檔案系統刪了。

func removeContainer(containerId string, force bool) {
	containerInfo, err := getInfoByContainerId(containerId)
	if err != nil {
		log.Errorf("Get container %s info error %v", containerId, err)
		return
	}

	switch containerInfo.Status {
	case container.STOP: // STOP 狀態容器直接刪除即可
		// 先刪除配置目錄,再刪除rootfs 目錄
		if err = container.DeleteContainerInfo(containerId); err != nil {
			log.Errorf("Remove container [%s]'s config failed, detail: %v", containerId, err)
			return
		}
		container.DeleteWorkSpace(containerId, containerInfo.Volume)
	case container.RUNNING: // RUNNING 狀態容器如果指定了 force 則先 stop 然後再刪除
		if !force {
			log.Errorf("Couldn't remove running container [%s], Stop the container before attempting removal or"+
				" force remove", containerId)
			return
		}
		log.Infof("force delete running container [%s]", containerId)
		stopContainer(containerId)
		removeContainer(containerId, force)
	default:
		log.Errorf("Couldn't remove container,invalid status %s", containerInfo.Status)
		return
	}
}

增加了下面這一句:

container.DeleteWorkSpace(containerId, containerInfo.Volume)

3. 測試

rootfs 調整

用 busybox.tar 映象啟動一個容器,然後檢視/var/lib/mydocker/overlay2/ 目錄下是否生成對應內容。

首先在/var/lib/mydocker/image/目錄準備好映象

root@mydocker:~# mv busybox.tar /var/lib/mydocker/image/

然後使用該映象啟動容器

root@mydocker:~/refactor-isolate-rootfs/mydocker# go build .
root@mydocker:~/refactor-isolate-rootfs/mydocker# ./mydocker run -d -name rootfs busybox top
{"level":"info","msg":"createTty false","time":"2024-02-22T13:34:12+08:00"}
{"level":"info","msg":"resConf:\u0026{ 0  }","time":"2024-02-22T13:34:12+08:00"}
{"level":"info","msg":"lower:/var/lib/mydocker/overlay2/5341624332/lower image.tar:/var/lib/mydocker/image/busybox.tar","time":"2024-02-22T13:34:12+08:00"}
{"level":"info","msg":"mount overlayfs: [/usr/bin/mount -t overlay overlay -o lowerdir=/var/lib/mydocker/overlay2/5341624332/lower,upperdir=/var/lib/mydocker/overlay2/5341624332/upper,workdir=/var/lib/mydocker/overlay2/5341624332/work /var/lib/mydocker/overlay2/5341624332/merged]","time":"2024-02-22T13:34:12+08:00"}
{"level":"info","msg":"command all is top","time":"2024-02-22T13:34:12+08:00"}

檢視容器

root@mydocker:~/refactor-isolate-rootfs/mydocker# ./mydocker ps
ID           NAME        PID         STATUS      COMMAND     CREATED
5341624332   rootfs      219016      running     top         2024-02-22 13:34:12

檢視/var/lib/mydocker/overlay2 目錄下是否生成對應內容

root@mydocker:/var/lib/mydocker/overlay2# cd /var/lib/mydocker/overlay2/5341624332
root@mydocker:/var/lib/mydocker/overlay2/5341624332# ls
lower  merged  upper  work
root@mydocker:/var/lib/mydocker/overlay2/5341624332# ls lower
bin  dev  etc  home  proc  root  sys  tmp  usr  var
root@mydocker:/var/lib/mydocker/overlay2/5341624332# ls merged/
bin  dev  etc  home  proc  root  sys  tmp  usr  var

可以看到,在/var/lib/mydocker/overlay2/{containerID} 目錄下生成了,lower、merged、upper、work 等 overlay2 目錄。

其中 lower 中的內容由映象解壓得到,merged 則是容器 rootfs 掛載點。

然後進入容器建立檔案

root@mydocker:~/refactor-isolate-rootfs/mydocker# ./mydocker exec 5341624332 /bin/sh
{"level":"info","msg":"container pid:219016 command:/bin/sh","time":"2024-02-22T13:37:42+08:00"}
got mydocker_pid=219016
got mydocker_cmd=/bin/sh
/ # echo KubeExplorer > a.txt
/ # cat a.txt
KubeExplorer

接著到對應 merged 目錄檢視檔案是否存在

root@mydocker:/var/lib/mydocker/overlay2/5341624332# ls merged/
a.txt  bin  dev  etc  home  proc  root  sys  tmp  usr  var
root@mydocker:/var/lib/mydocker/overlay2/5341624332# cat merged/a.txt
KubeExplorer

至此,說明 rootfs 調整一切正常。

commit 命令

接下來測試一下 mydocker commit 命令,把剛才啟動的容器提交為映象。

root@mydocker:~/refactor-isolate-rootfs/mydocker# ./mydocker ps
ID           NAME        PID         STATUS      COMMAND     CREATED
5341624332   rootfs      219016      running     top         2024-02-22 13:34:12
root@mydocker:~/refactor-isolate-rootfs/mydocker# ./mydocker commit 5341624332 busybox-with-custom
{"level":"info","msg":"commitContainer imageTar:/var/lib/mydocker/image/busybox-with-custom.tar","time":"2024-02-22T13:43:33+08:00"}

然後檢視 var/lib/mydocker/image/ 目錄是否生成了對應的映象檔案

root@mydocker:/var/lib/mydocker/overlay2/5341624332# cd /var/lib/mydocker/image/
root@mydocker:/var/lib/mydocker/image# ls
busybox-with-custom.tar  busybox.tar

busybox-with-custom.tar 就是 commit 命令生成的映象。

接下來使用該映象啟動一個容器,檢視之前建立的檔案是否存在

root@mydocker:~/refactor-isolate-rootfs/mydocker# ./mydocker run -d -name rootfs2 busybox-with-custom top
{"level":"info","msg":"createTty false","time":"2024-02-22T13:45:53+08:00"}
{"level":"info","msg":"resConf:\u0026{ 0  }","time":"2024-02-22T13:45:53+08:00"}
{"level":"info","msg":"lower:/var/lib/mydocker/overlay2/8118341786/lower image.tar:/var/lib/mydocker/image/busybox-with-custom.tar","time":"2024-02-22T13:45:53+08:00"}
{"level":"info","msg":"mount overlayfs: [/usr/bin/mount -t overlay overlay -o lowerdir=/var/lib/mydocker/overlay2/8118341786/lower,upperdir=/var/lib/mydocker/overlay2/8118341786/upper,workdir=/var/lib/mydocker/overlay2/8118341786/work /var/lib/mydocker/overlay2/8118341786/merged]","time":"2024-02-22T13:45:53+08:00"}
{"level":"info","msg":"command all is top","time":"2024-02-22T13:45:53+08:00"}

進入容器檢視內容

root@mydocker:~/refactor-isolate-rootfs/mydocker# ./mydocker ps
ID           NAME        PID         STATUS      COMMAND     CREATED
5341624332   rootfs      219016      running     top         2024-02-22 13:34:12
8118341786   rootfs2     219109      running     top         2024-02-22 13:45:53
root@mydocker:~/refactor-isolate-rootfs/mydocker# ./mydocker exec 8118341786 /bin/sh
{"level":"info","msg":"container pid:219109 command:/bin/sh","time":"2024-02-22T13:46:14+08:00"}
got mydocker_pid=219109
got mydocker_cmd=/bin/sh
/ # cat a.txt
KubeExplorer

可以看到,提交的映象中包含了我們新建的 a.txt 檔案,說明 commit 命令也是正常的。

rm 命令

最後測試一下 mydocker rm 命令,能否刪除映象配置和對應的 rootfs 目錄。

ps 命令拿到 id

root@mydocker:~/refactor-isolate-rootfs/mydocker# ./mydocker ps
ID           NAME        PID         STATUS      COMMAND     CREATED
5341624332   rootfs      219016      running     top         2024-02-22 13:34:12
8118341786   rootfs2     219109      running     top         2024-02-22 13:45:53

根據 id 刪除容器

root@mydocker:~/refactor-isolate-rootfs/mydocker# ./mydocker rm 5341624332 -f
{"level":"info","msg":"force delete running container [5341624332]","time":"2024-02-22T13:47:36+08:00"}
{"level":"info","msg":"umountOverlayFS,cmd:/usr/bin/umount /var/lib/mydocker/overlay2/5341624332/merged","time":"2024-02-22T13:47:36+08:00"}

檢視一下 /var/lib/mydocker/overlay2 中的 rootfs 目錄是否刪除

cd /var/lib/mydocker/overlay2
root@mydocker:/var/lib/mydocker/overlay2# ls

可以看到,容器相關目錄都被移除了。

4. 小結

本小節主要完善了容器的檔案系統,在/var/lib/mydocker/overlay2/ 目錄下為每個容器單獨分配一個 rootfs,避免了多容器之間互相影響。


【從零開始寫 Docker 系列】持續更新中,搜尋公眾號【探索雲原生】訂閱,閱讀更多文章。



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

相關程式碼見 refactor-isolate-rootfs 分支,測試指令碼如下:

需要提前在 /var/lib/mydocker/image 目錄準備好 busybox.tar 檔案,具體見第四篇第二節。

# 克隆程式碼
git clone -b refactor-isolate-rootfs https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依賴並編譯
go mod tidy
go build .
# 測試 
./mydocker run -d -name c1 busybox top
# 檢視容器 Id
./mydocker ps
# stop 停止指定容器
./mydocker rm ${containerId} -f

相關文章