《自己动手写Docker》笔记x03:构建镜像

这是《自己动手写Docker》的读书笔记,本篇为第四章的笔记,主要的内容为如何使用GO语言、busybox以及AUFS构建一个可以运行的容器,并实现Docker中的Volume功能。

实验环境参数如下

  • 虚拟机 Parallel14
  • Ubuntu 16.04
  • Linux内核 4.10.0-28-generic
  • Golang v1.12.4

本笔记的代码也是在Github仓库schwarzeni/write-your-own-docker-learning上,cpt4 文件下的内容。


使用busybox

首先使用Docker安装busybox镜像,并使其运行起来,同时将其打包成tar。

busybox中有非常多的UNIX中的空间,它提供了一个非常完整且小巧的文件系统,这个可以为我们所用。

首先,为了确保Docker的速度,最好使用阿里云的Docker镜像加速,免费且上面有详细的使用教程。之后就输几行命令即可

1
2
3
4
5
6
7
8
docker pull busybox

docker run -d busybox top -b

docker ps

# CONTAINER ID IMAGE COMMAND ...
# 0b7d7d5ec541 busybox "top -b"

获取其容器ID后,就可以导出了

1
2
3
4
5
# 导出
docker export -o busybox.tar 0b7d

# 解压
tar -xvf busybox.tar -C busybox/

pivot_root

pivot_root 为Linux的系统调用,主要功能是改变当前的root文件系统。pivot_root 可以将当前进程的root文件系统移动到 put_old 文件夹中,然后使 new_root 成为新的root文件系统。

chroot 是针对某个进程,系统的其他部分依旧运行在老的root目录下。而pivot_root 是把整个系统切换到一个新的root目录中,移除对之前root文件系统的依赖,这样你就可以umount原先的文件系统。

以下代码对应 cpt4/cpt4-1-pivote-root

关键函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// container/init.go
func pivotRoot(root string) (err error) {
// 为了使当前root的 老root 和 新root 不在同一个文件系统下,需要将root重新mount一次
// bind mount 将相同的内容换一个挂载点
if err = syscall.Mount(root, root, "bind", syscall.MS_BIND|syscall.MS_REC, ""); err != nil {
return fmt.Errorf("mount rootfs to itself error: %v", err)
}

// 创建 rootfs/.pivot_root 存储到 old_root 中
pivotDir := filepath.Join(root, ".pivot_root")
if err = os.Mkdir(pivotDir, 0777); err != nil {
return
}

// 将 pivot_root 挂载到新的rootfs,现在老的 old_root 是挂载在 rootfs/.pivot_root
// 挂载点现在依然可以在mount命令中看到
if err = syscall.PivotRoot(root, pivotDir); err != nil {
return fmt.Errorf("pivot_root %v", err)
}

// 修改当前的工作目录到根目录
if err = syscall.Chdir("/"); err != nil {
return fmt.Errorf("chdir / %v", err)
}

pivotDir = filepath.Join("/", ".pivot_root")

// unmount rootfs/.pivot_root
if err = syscall.Unmount(pivotDir, syscall.MNT_DETACH); err != nil {
return fmt.Errorf("unmount pivot_root dir %v", err)
}

// 删除临时文件夹
return os.Remove(pivotDir)
}

这样就可以调用此函数进程mount操作了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// container/init.go
// 设置挂载点
func setUpMount() {
pwd, err := os.Getwd()
if err != nil {
log.Errorf("Get current location error %v", err)
return
}
log.Infof("Current location is %s", pwd)

if err = syscall.Mount("", "/", "", syscall.MS_PRIVATE | syscall.MS_REC, ""); err != nil {
return
}

// 改变root
if err = pivotRoot(pwd); err != nil {
return
}

// mount proc
defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
if err = syscall.Mount("proc", "/proc", "proc", uintptr(defaultMountFlags), ""); err != nil {
return
}
// tmpfs 基于内存的文件系统,可以使用RAM或swap分区来存储
if err = syscall.Mount("tmpfs", "/dev", "tmpfs", syscall.MS_NOSUID|syscall.MS_STRICTATIME, "mode=755"); err != nil {
return
}
}

使用AUFS

AUFS 可以构建一个拥有二层模式的镜像,对于最上层 可写层 的修改不会影响到 基础层

mount

Docker在使用镜像启动一个容器时,会新建2个layer:write layercontainer-init layer。前者为容器唯一的可读写层;后者为容器新建的只读层,用来存储容器启动时传入的系统信息。最后把 write layercontainer-init layer 以及相关镜像的layers都mount到一个mnt目录下,然后将mnt目录作为容器启动的根目录。

应用AUFS的主要目的是不让在容器中进程的操作对镜像产生任何的影响。主要实现如下几个函数

  • CreateReadOnlyLayer 新建 busybox 文件夹,将busybox.tar解压到 busybox 文件夹下,作为容器的 只读层
  • CreateWriteLayer 创建名为 writeLayer 的文件夹,作为容器唯一的 可写层
  • CreateMountPoint 创建 mnt 文件夹作为挂载点,然后将 writeLayer 目录和 busybox 目录mount到mnt目录下

NewParentProcess 函数中调用这几个函数,将容器使用的宿主机目录换成 mnt 目录。相关代码对应于目录 cpt4/cpt4-2-3-aufs-volume

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// container/container_process.go

// 用于创建容器的文件系统
func NewWorkspace(rootURL string, mntURL string, volume string) {
CreateReadOnlyLayer(rootURL)
CreateWriteLayer(rootURL)
CreateMountPoint(rootURL, mntURL)
// ...
}

// 创建busybox文件夹,将busybox.tar解压到其目录下
// 作为容器的只读层
func CreateReadOnlyLayer(rootURL string) {
busyboxURL := path.Join(rootURL, "busybox/")
busyboxTarURL := path.Join(rootURL, "busybox.tar")

exist, err := PathExists(busyboxURL)
if err != nil {
log.Infof("Fail to judge whether dir %s exists. %v", err)
}

if !exist {
if err := os.Mkdir(busyboxURL, 0777); err != nil {
log.Errorf("Mkdir dir %s for busybox error, %v", busyboxURL, err)
}

// 解压
if _, err := exec.Command("tar", "-xvf", busyboxTarURL, "-C", busyboxURL).CombinedOutput(); err != nil {
log.Errorf("Untar dir %s error. %v", busyboxTarURL, err)
}
}
}

// 创建名为writeLayer的文件夹
// 作为容器唯一的可写层
func CreateWriteLayer(rootURL string) {
writeURL := path.Join(rootURL, "writeLayer/")
if err := os.Mkdir(writeURL, 0777); err != nil {
log.Errorf("Mkdir dir %s for writelayer error. %v", writeURL, err)
}
}

// 首先创建mnt文件夹作为挂载点
// 然后把writeLayer目录和busybox目录mount到mnt目录下
func CreateMountPoint(rootURL string, mntURL string) {
if err := os.Mkdir(mntURL, 0777); err != nil {
log.Errorf("Mkdir dir %s for mnt error. %v", mntURL, err)
}
// TODO: 体会一下
dirs := "dirs=" + path.Join(rootURL, "writeLayer") + ":" + path.Join(rootURL, "busybox")
cmd := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", mntURL)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Errorf("error when mount aufs. %v", err)
}
}

// 判断文件路径是否存在
func PathExists(sourceURL string) (exist bool, err error) {
_, err = os.Stat(sourceURL)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}

umount

Docker会在删除容器的时候,将容器对应的 Write LayerContainer-init Layer 删除,而保留镜像的所有内容。Go语言主要实现 DeleteWorkspace 函数,内部包括:

  • DeleteMountPount umount掉 mnt 目录,随后删除
  • DeleteWriteLayer 删除掉 writeLayer 文件夹,因为从之前的代码(如下)中可以看出这个文件夹是AUFS中用于记录变化的
1
2
3
4
// ....
dirs := "dirs=" + path.Join(rootURL, "writeLayer") + ":" + path.Join(rootURL, "busybox")
cmd := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", mntURL)
// ...

具体的函数实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// container/container_process.go
// 在容器退出时 unmount mnt目录,删除mnt和writeLayer文件夹
func DeleteWorkspace(rootURL string, mntURL string, volume string) {
DeleteMountPoint(mntURL)
DeleteWriteLayer(rootURL)
}

// unmount mnt
// delete mnt
func DeleteMountPoint(mntURL string) {
cmd := exec.Command("umount", mntURL)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Errorf("error when umount mnt %s. %v", mntURL, err)
}
if err := os.RemoveAll(mntURL); err != nil {
log.Errorf("error when remove mnt folder %s. %v", mntURL, err)
}
}

// delete writeLayer文件夹
func DeleteWriteLayer(rootURL string) {
writeURL := path.Join(rootURL, "writeLayer")
if err := os.RemoveAll(writeURL); err != nil {
log.Errorf("error when remove writeLayer folder %s. %v", writeURL, err)
}
}

从书中偷一个时序图

当启动程序时,对容器内部进行修改,发生改变的只是 writeLayer 中的内容,busybox 中的内容没有改变


实现Volume

此功能将外部系统的文件和容器内部的文件实现了映射,相当于是挂载到了同一设备上。主要使用了如下的mount系统调用

1
mount -t aufs -o dirs=<parentURL> none <mnt>/<containerURL>

本节对应的代码位置同上

首先,添加命令行flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// main_command.go
var runCommand = cli.Command{
Name: "run",
Usage: "mydocker run -ti [command]",
Flags: []cli.Flag{
// ....
// 加在这里
cli.StringFlag{
Name: "v",
Usage: "volume",
},
},
Action: func(ctx *cli.Context) error{
// ....
volumn := ctx.String("v")
Run(tty, cmdArr, resConf, volumn)
return nil
},
}

其次,添加 MountVolumeDeleteMountPointWithVolume,同时要对 NewWorkspaceDeleteWorkspace 做出修改

1
2
3
4
5
6
// 解析用户传入的volume
func volumeUrlExtract(volume string) ([]string) {
var volumeURLS []string
volumeURLS = strings.Split(volume, ":")
return volumeURLS
}

MountVolume

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

// 挂载 volume
func MountVolume(rootURL string, mntURL string, volumesURLs []string) {

// 创建宿主机文件目录
parentURL := volumesURLs[0]
if err := os.Mkdir(parentURL, 0777); err != nil {
log.Infof("Mkdir parent dir %s error. %v", parentURL, err)
}

// 在容器文件系统中创建挂载点
containerURL := volumesURLs[1]
containerVolumeURL := path.Join(mntURL, containerURL)
if err := os.Mkdir(containerVolumeURL, 0777); err != nil {
log.Infof("Mkdir container dir %s error. %v", containerVolumeURL, err)
}

// 将宿主机文件目录挂载到容器挂载点
dirs := "dirs=" + parentURL
cmd := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", containerVolumeURL)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Errorf("Mount volume failed %v", err)
}
}

DeleteMountPointWithVolume

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

// 删除 volume
func DeleteMountPointWithVolume(rootURL string, mntURL string, volumeURLs []string) {
// umount 掉
containerURL := path.Join(mntURL, volumeURLs[1])
cmd := exec.Command("umount", containerURL)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
log.Errorf("Unmount volumn failed. %v", err)
}

// 执行剩余的操作
// umount掉整个容器的挂载点
// 删除其挂载点
DeleteMountPoint(mntURL)
}

NewWorkspace

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 用于创建容器的文件系统
func NewWorkspace(rootURL string, mntURL string, volume string) {
CreateReadOnlyLayer(rootURL)
CreateWriteLayer(rootURL)
CreateMountPoint(rootURL, mntURL)

if volume != "" {
volumeURLs := volumeUrlExtract(volume)
if len(volumeURLs) == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" {
MountVolume(rootURL, mntURL, volumeURLs)
log.Infof("mount volume: %q", volumeURLs)
} else {
log.Infof("Volume parameter input is not correct")
}
}

}

DeleteWorkspace

1
2
3
4
5
6
7
8
9
10
11
// 在容器退出时 unmount mnt目录,删除mnt和writeLayer文件夹
func DeleteWorkspace(rootURL string, mntURL string, volume string) {
volumeURLs := volumeUrlExtract(volume)
if len(volumeURLs) == 2 && volumeURLs[0] != "" && volumeURLs[1] != "" {
DeleteMountPointWithVolume(rootURL, mntURL, volumeURLs)
} else {
DeleteMountPoint(mntURL)
}

DeleteWriteLayer(rootURL)
}

再偷一个时序图


镜像打包

这个思路比较简单,就不在这里写了,就是把 mnt 打个包