從零開始寫 Docker(六)---實現 mydocker run -v 支援資料卷掛載

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

volume-by-bind-mount.png

本文為從零開始寫 Docker 系列第六篇,實現類似 docker -v 的功能,透過掛載資料卷將容器中部分資料持久化到宿主機。


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

上一篇中基於 overlayfs 實現了容器和宿主機檔案系統間的寫操作隔離。但是一旦容器退出,容器可讀寫層的所有內容都會被刪除。

那麼,如果使用者需要持久化容器裡的部分資料該怎麼辦呢?

docker volume 就是用來解決這個問題的。

啟動容器時透過-v引數建立 volume 即可實現資料持久化。

本節將會介紹如何實現將宿主機的目錄作為資料卷掛載到容器中,並且在容器退出後,資料卷中的內容仍然能夠儲存在宿主機上。

具體實現主要依賴於 linux 的 bind mount 功能

bind mount 是一種將一個目錄或者檔案系統掛載到另一個目錄的技術。它允許你在檔案系統層級中的不同位置共享相同的內容,而無需複製檔案或數。

例如:

mount -o bind /source/directory /target/directory/

這樣,/source/directory 中的內容將被掛載到 /target/directory,兩者將共享相同的資料。對其中一個目錄的更改也會反映到另一個目錄。

基於該技術我們只需要將 volume 目錄掛載到容器中即可,就像這樣:

mount -o bind /host/directory /container/directory/

這樣容器中往該目錄裡寫的資料最終會共享到宿主機上,從而實現持久化。


如果你對雲原生技術充滿好奇,想要深入瞭解更多相關的文章和資訊,歡迎關注微信公眾號。

搜尋公眾號【探索雲原生】即可訂閱


2. 實現

volume 功能大致實現步驟如下:

  • 1)run 命令增加 -v 引數,格式個 docker 一致
    • 例如 -v /etc/conf:/etc/conf 這樣
  • 2)容器啟動前,掛載 volume
    • 先準備目錄,其次 mount overlayfs,最後 bind mount volume
  • 3)容器停止後,解除安裝 volume
    • 先 umount volume,其次 umount overlayfs,最後刪除目錄

注意:第三步需要先 umount volume ,然後再刪除目錄,否則由於 bind mount 存在,刪除臨時目錄會導致 volume 目錄中的資料丟失。

runCommand

首先在 runCommand 命令中添 -v flag,以接收 volume 引數。

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.StringFlag{
			Name:  "mem", // 限制程序記憶體使用量,為了避免和 stress 命令的 -m 引數衝突 這裡使用 -mem,到時候可以看下解決衝突的方法
			Usage: "memory limit,e.g.: -mem 100m",
		},
		cli.StringFlag{
			Name:  "cpu",
			Usage: "cpu quota,e.g.: -cpu 100", // 限制程序 cpu 使用率
		},
		cli.StringFlag{
			Name:  "cpuset",
			Usage: "cpuset limit,e.g.: -cpuset 2,4", // 限制程序 cpu 使用率
		},
		cli.StringFlag{ // 資料卷
			Name:  "v",
			Usage: "volume,e.g.: -v /ect/conf:/etc/conf",
		},
	},
	/*
		這裡是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 := context.Bool("it")
		resConf := &subsystems.ResourceConfig{
			MemoryLimit: context.String("mem"),
			CpuSet:      context.String("cpuset"),
			CpuCfsQuota: context.Int("cpu"),
		}
		log.Info("resConf:", resConf)
		volume := context.String("v")
		Run(tty, cmdArray, resConf, volume)
		return nil
	},
}

在 Run 函式中,把 volume 傳給建立容器的 NewParentProcess 函式和刪除容器檔案系統的 DeleteWorkSpace 函式。

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)
	_ = parent.Wait()
	container.DeleteWorkSpace("/root/", volume)
}

NewWorkSpace

在原有建立過程最後增加 volume bind 邏輯:

  • 1)首先判斷 volume 是否為空,如果為空,就表示使用者並沒有使用掛載引數,不做任何處理
  • 2)如果不為空,則使用 volumeUrlExtract 函式解析 volume 字串,得到要掛載的宿主機目錄和容器目錄,並執行 bind mount
func NewWorkSpace(rootPath, volume string) {
	createLower(rootPath)
	createDirs(rootPath)
	mountOverlayFS(rootPath)

	// 如果指定了volume則還需要mount volume
	if volume != "" {
		mntPath := path.Join(rootPath, "merged")
		hostPath, containerPath, err := volumeExtract(volume)
		if err != nil {
			log.Errorf("extract volume failed,maybe volume parameter input is not correct,detail:%v", err)
			return
		}
		mountVolume(mntPath, hostPath, containerPath)
	}
}

volumeExtract

語法和 docker run -v 一致,兩個路徑透過冒號分隔。

// volumeExtract 透過冒號分割解析volume目錄,比如 -v /tmp:/tmp
func volumeExtract(volume string) (sourcePath, destinationPath string, err error) {
	parts := strings.Split(volume, ":")
	if len(parts) != 2 {
		return "", "", fmt.Errorf("invalid volume [%s], must split by `:`", volume)
	}

	sourcePath, destinationPath = parts[0], parts[1]
	if sourcePath == "" || destinationPath == "" {
		return "", "", fmt.Errorf("invalid volume [%s], path can't be empty", volume)
	}

	return sourcePath, destinationPath, nil
}

mountVolume

掛載資料卷的過程如下。

  • 1)首先,建立宿主機檔案目錄
  • 2)然後,拼接處容器目錄在宿主機上的真正目錄,格式為:$mntPath/$containerPath
    • 因為之前使用了 pivotRoot 將$mntPath 作為容器 rootfs,因此這裡的容器目錄也可以按層級拼接最終找到在宿主機上的位置。
  • 3)最後,執行 bind mount 操作,至此對資料卷的處理也就完成了。
// mountVolume 使用 bind mount 掛載 volume
func mountVolume(mntPath, hostPath, containerPath string) {
	// 建立宿主機目錄
	if err := os.Mkdir(hostPath, constant.Perm0777); err != nil {
		log.Infof("mkdir parent dir %s error. %v", hostPath, err)
	}
	// 拼接出對應的容器目錄在宿主機上的的位置,並建立對應目錄
	containerPathInHost := path.Join(mntPath, containerPath)
	if err := os.Mkdir(containerPathInHost, constant.Perm0777); err != nil {
		log.Infof("mkdir container dir %s error. %v", containerPathInHost, err)
	}
	// 透過bind mount 將宿主機目錄掛載到容器目錄
	// mount -o bind /hostPath /containerPath
	cmd := exec.Command("mount", "-o", "bind", hostPath, containerPathInHost)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		log.Errorf("mount volume failed. %v", err)
	}
}

DeleteWorkSpace

刪除容器檔案系統時,先判斷是否掛載了 volume,如果掛載了則刪除時則需要先 umount volume。

注意:一定要要先 umount volume ,然後再刪除目錄,否則由於 bind mount 存在,刪除臨時目錄會導致 volume 目錄中的資料丟失。

func DeleteWorkSpace(rootPath, volume string) {
	mntPath := path.Join(rootPath, "merged")

	// 如果指定了volume則需要umount volume
	// NOTE: 一定要要先 umount volume ,然後再刪除目錄,否則由於 bind mount 存在,刪除臨時目錄會導致 volume 目錄中的資料丟失。
	if volume != "" {
		_, containerPath, err := volumeExtract(volume)
		if err != nil {
			log.Errorf("extract volume failed,maybe volume parameter input is not correct,detail:%v", err)
			return
		}
		umountVolume(mntPath, containerPath)
	}

	umountOverlayFS(mntPath)
	deleteDirs(rootPath)
}

umountVolume

和普通 umount 一致

func umountVolume(mntPath, containerPath string) {
	// mntPath 為容器在宿主機上的掛載點,例如 /root/merged
	// containerPath 為 volume 在容器中對應的目錄,例如 /root/tmp
	// containerPathInHost 則是容器中目錄在宿主機上的具體位置,例如 /root/merged/root/tmp
	containerPathInHost := path.Join(mntPath, containerPath)
	cmd := exec.Command("umount", containerPathInHost)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		log.Errorf("Umount volume failed. %v", err)
	}
}

3.測試

下面來驗證一下程式的正確性。

掛載不存在的目錄

第一個實驗是把一個宿主機上不存在的檔案目錄掛載到容器中。

首先還是要在 root 目錄準備好 busybox.tar,作為我們的映象只讀層。

$ ls
busybox.tar

啟動容器,把宿主機的 /root/volume 掛載到容器的 /tmp 目錄下。

root@mydocker:~/feat-volume/mydocker# ./mydocker run -it -v /root/volume:/tmp /bin/sh
{"level":"info","msg":"resConf:\u0026{ 0  }","time":"2024-01-18T16:47:29+08:00"}
{"level":"info","msg":"busybox:/root/busybox busybox.tar:/root/busybox.tar","time":"2024-01-18T16:47:29+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-18T16:47:29+08:00"}
{"level":"info","msg":"mkdir parent dir /root/volume error. mkdir /root/volume: file exists","time":"2024-01-18T16:47:29+08:00"}
{"level":"info","msg":"mkdir container dir /root/merged//tmp error. mkdir /root/merged//tmp: file exists","time":"2024-01-18T16:47:29+08:00"}
{"level":"info","msg":"command all is /bin/sh","time":"2024-01-18T16:47:29+08:00"}
{"level":"info","msg":"init come on","time":"2024-01-18T16:47:29+08:00"}
{"level":"info","msg":"Current location is /root/merged","time":"2024-01-18T16:47:29+08:00"}
{"level":"info","msg":"Find path /bin/sh","time":"2024-01-18T16:47:29+08:00"}

新開一個視窗,檢視宿主機 /root 目錄:

root@DESKTOP-9K4GB6E:~# ls
busybox  busybox.tar  merged  upper  volume  work

多了幾個目錄,其中 volume 就是我們啟動容器是指定的 volume 在宿主機上的位置。

同樣的,容器中也多了 containerVolume 目錄:

/ # ls
bin              dev              home             root             tmp              var
containerVolume  etc              proc             sys              usr

現在往 /tmp 目錄寫入一個檔案

/ # echo KubeExplorer > tmp/hello.txt
/ # ls /tmp
hello.txt
/ # cat /tmp/hello.txt
KubeExplorer

然後檢視宿主機的 volume 目錄:

root@mydocker:~# ls /root/volume/
hello.txt
root@mydocker:~# cat /root/volume/hello.txt
KubeExplorer

可以看到,檔案也在。

然後測試退出容器後是否能持久化。

退出容器:

/ # exit

宿主機中再次檢視 volume 目錄:

root@mydocker:~# ls /root/volume/
hello.txt

檔案還在,說明我們的 volume 功能是正常的。

掛載已經存在目錄

第二次實驗是測試掛載一個已經存在的目錄,這裡就把剛才建立的 volume 目錄再掛載一次:

root@mydocker:~/feat-volume/mydocker# ./mydocker run -it -v /root/volume:/tmp /bin/sh
{"level":"info","msg":"resConf:\u0026{ 0  }","time":"2024-01-18T17:02:48+08:00"}
{"level":"info","msg":"busybox:/root/busybox busybox.tar:/root/busybox.tar","time":"2024-01-18T17:02:48+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-18T17:02:48+08:00"}
{"level":"info","msg":"mkdir parent dir /root/volume error. mkdir /root/volume: file exists","time":"2024-01-18T17:02:48+08:00"}
{"level":"info","msg":"mkdir container dir /root/merged//tmp error. mkdir /root/merged//tmp: file exists","time":"2024-01-18T17:02:48+08:00"}
{"level":"info","msg":"command all is /bin/sh","time":"2024-01-18T17:02:48+08:00"}
{"level":"info","msg":"init come on","time":"2024-01-18T17:02:48+08:00"}
{"level":"info","msg":"Current location is /root/merged","time":"2024-01-18T17:02:48+08:00"}
{"level":"info","msg":"Find path /bin/sh","time":"2024-01-18T17:02:48+08:00"}

檢視剛才的檔案是否存在

/ # ls /tmp/hello.txt
/tmp/hello.txt
/ # cat /tmp/hello.txt
KubeExplorer

還在,說明目錄確實掛載進去了。

接下來更新檔案內容並退出:

/ # echo KubeExplorer222 > /tmp/hello.txt
/ # cat /tmp/hello.txt
KubeExplorer222
/ # exit

在宿主機上檢視:

root@mydocker:~# cat /root/volume/hello.txt
KubeExplorer222

至此,說明我們的 volume 功能是正常的。

4. 小結

本篇記錄瞭如何實現 mydocker run -v 引數,增加 volume 以實現容器中部分資料持久化。

一些比較重要的點:

首先要理解 linux 中的 bind mount 功能

bind mount 是一種將一個目錄或者檔案系統掛載到另一個目錄的技術。它允許你在檔案系統層級中的不同位置共享相同的內容,而無需複製檔案或數。

其次,則是要理解宿主機目錄和容器目錄之間的關聯關係

-v /root/volume:/tmp 引數為例:

  • 1)按照語法,-v /root/volume:/tmp 就是將宿主機/root/volume 掛載到容器中的 /tmp 目錄。

  • 2)由於前面使用了 pivotRoot 將 /root/merged 目錄作為容器的 rootfs,因此,容器中的根目錄實際上就是宿主機上的 /root/merged 目錄

    • 第四篇:
  • 3)那麼容器中的 /tmp目錄就是宿主機上的 /root/merged/tmp 目錄。

  • 4)因此,我們只需要將宿主機/root/volume 目錄掛載到宿主機的 /root/merged/tmp 目錄即可實現 volume 掛載。

在清楚這兩部分內容後,整體實現就比較容易理解了。


如果你對雲原生技術充滿好奇,想要深入瞭解更多相關的文章和資訊,歡迎關注微信公眾號。

搜尋公眾號【探索雲原生】即可訂閱



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

相關程式碼見 feat-volume 分支,測試指令碼如下:

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

# 克隆程式碼
git clone -b feat-volume https://github.com/lixd/mydocker.git
cd mydocker
# 拉取依賴並編譯
go mod tidy
go build .
# 測試 檢視檔案系統是否變化
./mydocker run -it  /bin/ls
./mydocker run -it -v /root/volume:/tmp /bin/sh

相關文章