写一个简单的 Docker

写一个简单的 Docker

tk_sky 183 2023-06-22

写一个简单的docker


Docker是个典型的依靠底层技术实现上层应用革命的东西。本质上他依靠内核提供的namespace,cgourps,aufs等技术来实现隔离和分层式增量管理,从而实现一种轻量的容器环境。

正好看到一本书《自己动手写Docker》,遂动手写一个玩具Docker试试。

实现简单的 minidocker run

这里使用Golang来要实现一个简单的容器。容器在进程上通过linux内核提供的namespace来对资源完成隔离,使得在进程视角呈现一种相互独立的虚拟机的形态。

这里使用urfave/cli库来实现简单的命令行CLI交互程序:

 func main() {
     app := cli.NewApp()
     app.Name = "minidocker"
     app.Usage = usage
     log.SetOutput(os.Stdout)
     setUpCommands(app)
 ​
     if err := app.Run(os.Args); err != nil {
         log.Fatal(err)
     }
 }
 ​
 // set up cli commands
 func setUpCommands(app *cli.App) {
     // miniDocker run command
     runCommand := cli.Command{
         Name:  "run",
         Usage: `Create a container`,
         Flags: []cli.Flag{
             cli.BoolFlag{
                 Name:  "ti",
                 Usage: "run with terminal",
             },
         },
         Action: func(context *cli.Context) error {
             if len(context.Args()) < 1 {
                 return fmt.Errorf("usage: minidocker run [cmd]")
             }
             cmd := context.Args().Get(0)
             tty := context.Bool("ti")
             RunHandler(tty, cmd)
             return nil
         },
     }
 ​
     initCommand := cli.Command{
         Name:  "init",
         Usage: `an inner command for container initiation`,
         Action: func(context *cli.Context) error {
             cmd := context.Args().Get(0)
             err := RunContainerInitProcess(cmd, nil)
             return err
         },
     }
 ​
     app.Commands = []cli.Command{
         runCommand,
         initCommand,
     }
 }

这里注册了两个命令:

  • minidocker run [command]:

    用于启动一个新容器并运行用户指定的command。这里可以和docker意义用 -ti 命令行选项来在容器中打开一个终端(实际上是将容器的输入输出流定向当当前终端)

  • minidocker init [command]:

    这个命令不由外部调用,用于以分配了独立namespace的新进程的身份调用自己,然后再以这个独立namespace的身份执行用户command。

用户在命令行调用run指令后,会调用实际执行的RunHandler

 // NewParentProcess make new process in new namespace
 func NewParentProcess(tty bool, cmd string) *exec.Cmd {
     args := []string{"init", cmd}
     retCmd := exec.Command("/proc/self/exe", args...)
     // set process attribute for new namespace
     retCmd.SysProcAttr = &syscall.SysProcAttr{
         Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS |
             syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
     }
     if tty {
         retCmd.Stdin = os.Stdin
         retCmd.Stdout = os.Stdout
         retCmd.Stderr = os.Stderr
     }
     return retCmd
 }
 ​
 // RunHandler handler for minidocker run command, call new process to run init command
 func RunHandler(tty bool, cmd string) {
     parentProcess := NewParentProcess(tty, cmd)
     if err := parentProcess.Start(); err != nil {
         log.Error(err)
     }
     _ = parentProcess.Wait()
     os.Exit(-1)
 }

RunHandler首先创建了一个新的进程parentProcess,创建时指定其Clone参数,从而使用内核的namespace来隔离新创建的进程和外部环境,如PID、网络等。

通过这个新创建的进程,我们又去调用本程序的init命令,从而以独立namespace的身份去执行后续的操作。

 // RunContainerInitProcess Initiate the new container
 func RunContainerInitProcess(command string, args []string) error {
     log.Infof("Initiating: %s", command)
 ​
     // set mount flag for proc filesystem
     mountFlags := syscall.MS_NOEXEC | // disallow other process to access
         syscall.MS_NOSUID | // disallow set-user-ID or set-group-ID
         syscall.MS_NODEV
     // mount proc to /proc as filesystem proc
     err := syscall.Mount("proc", "/proc", "proc", uintptr(mountFlags), "")
     if err != nil {
         log.Error(err)
     }
     // start a process to replace the init process for user command
     err = syscall.Exec(command, []string{command}, os.Environ())
     if err != nil {
         log.Error(err)
     }
     return nil
 }

RunContainerInitProcess()负责处理来自之前创建的进程的请求。

通过添加mountFlags,可以限制容器内的进程修改PID、防止外部访问等。

挂载完proc后,即可使用syscall.Exec 来创建一个新的进程来替代当前进程,并运行用户的command。这样做的好处是执行用户command的进程覆盖了原来的进程,其PID为1,符合容器的特征。

通过linux内核的功能和使用golang来修改系统调用,就可以简单的创建一个容器并执行command了。

测试一下:

可以看到创建了一个新的进程来运行/bin/bash,且该进程的PID为1,这和docker的表现一致。

当然,到这一步只是实现了进程的隔离,还没有存储、网络等等方面的隔离和资源限制。

实现cpu/内存资源限制

Cgroup是linux内核提供的功能,用于限制/控制/隔离一组进程的资源(CPU,内存,网络等)。Cgroup是容器技术的核心。

Cgroups组成一个树状结构,每个节点都是一个Cgroup,子节点继承祖先节点的资源限制。系统内每个进程都属于一个cgroup,可以将某个进程迁移到某个cgroup中,达成限制该进程资源的目的。

Subsystem代表Cgroup中一种特定的资源限制,可以是CPU、内存、网络等。对Cgroup可以同时使用多个Subsystem来限制不同种类的资源。

Cgroup创建之后,会在系统目录下创建对应的虚拟目录:/sys/fs/cgroup/<subsystem>/<cgroup>。在对应的目录下创建文件,其内容和文件名对应对某个资源限制的低配置。

例如,使用下面的命令:

 cd /sys/fs/cgroup/cpu/mygroup
 echo 50000 > cpu.cfs_quota_us

即可为mygroup限制cpu使用率为50%。

下面实现使用Cgroup来按命令行参数要求限制容器的资源。

首先创建一个结构体记录资源的限制:

 // ResourceConfig 表征资源限制
 type ResourceConfig struct {
     MemoryLimit string
     CPUShare    string
     CPUSet      string
 }

然后创建多个subsystem都通用的subsystem接口,以及对应实例组:

 // Subsystem 接口
 type Subsystem interface {
     // Name 返回subsystem的名字
     Name() string
     // Set 设置某cgroup在该subsystem中的资源限制
     Set(path string, res *ResourceConfig) error
     // Apply 将进程添加到某cgroup中
     Apply(path string, pid int) error
     // Remove 移除cgroup
     Remove(path string) error
 }
 ​
 var SubsystemIns = []Subsystem{
     &CPUSubsystem{},
     &CPUSetSubsystem{},
     &MemorySubsystem{},
 }

写两个subsystem对应的方法,取得cgroup对应的虚拟目录:

 // FindCgroupMountPoint 从/proc/self/mountinfo 查找挂载该subsystem的cgroup目录
 func FindCgroupMountPoint(subsystem string) string {
     f, err := os.Open("/proc/self/mountinfo")
     if err != nil {
         log.Error(err)
         return ""
     }
     defer f.Close()
     scanner := bufio.NewScanner(f)
     for scanner.Scan() {
         txt := scanner.Text()
         fields := strings.Split(txt, " ")
         for _, opt := range strings.Split(fields[len(fields)-1], ",") {
             if opt == subsystem {
                 return fields[4]
                 // 把对应的段取出来
             }
         }
     }
     if err := scanner.Err(); err != nil {
         log.Error(err)
         return ""
     }
     return ""
 }
 // GetCgroupPath 取Cgroup在文件系统内的绝对路径
 func GetCgroupPath(subsystem string, cgroupPath string, autoCreate bool) (string, error) {
     cgroupRoot := FindCgroupMountPoint(subsystem)
     if _, err := os.Stat(path.Join(cgroupRoot, cgroupPath)); err == nil ||
         (autoCreate && os.IsNotExist(err)) {
         if os.IsNotExist(err) {
             if err := os.Mkdir(path.Join(cgroupRoot, cgroupPath), 0755); err != nil {
                 return "", fmt.Errorf("cannot create cgroup: %v", err)
             }
         }
         return path.Join(cgroupRoot, cgroupPath), nil
     } else {
         return "", fmt.Errorf("cgroup path error: %v", err)
     }
 }

为了更方便的管理托管在系统的Cgroup,创建Cgroup实体类封装对应的一些操作:

type Cgroup struct {
	// cgroup在hierarchy中的路径,是相对路径
	Path string
	// 资源限制
	Resource *ResourceConfig
}

func NewCgroup(path string) *Cgroup {
	return &Cgroup{Path: path}
}

// Apply 将线程PID加入到每个subsystem挂载的Cgroup中
func (c *Cgroup) Apply(pid int) error {
	for _, subSysIns := range SubsystemIns {
		err := subSysIns.Apply(c.Path, pid)
		if err != nil {
			log.Error(err)
		}
	}
	return nil
}

// Set 设置每个subsystem挂载的cgroup的资源限制
func (c *Cgroup) Set(res *ResourceConfig) error {
	for _, subSysIns := range SubsystemIns {
		err := subSysIns.Set(c.Path, res)
		if err != nil {
			log.Error(err)
		}
	}
	return nil
}

// Destroy 释放各subsystem挂载的cgroup
func (c *Cgroup) Destroy() error {
	for _, subsys := range SubsystemIns {
		if err := subsys.Remove(c.Path); err != nil {
			log.Warnf("remove cgroup fail: %v", err)
		}
	}
	return nil
}

接下来需要做的就是实现不同的subsystem,来实现subsystem接口,这里以内存subsystem为例:

func (s *MemorySubsystem) Name() string {
	return "memory"
}

// Set 设置某Cgroup的内存限制
func (s *MemorySubsystem) Set(cgroupPath string, res *ResourceConfig) error {
	// 取得当前subsystem在虚拟文件系统的路径
	if subsysCgroupPath, err := GetCgroupPath(s.Name(), cgroupPath, true); err == nil {
		if res.MemoryLimit != "" {
			if err := os.WriteFile(path.Join(subsysCgroupPath, "memory.limit_in_bytes"),
				[]byte(res.MemoryLimit), 0644); err != nil {
				return fmt.Errorf("set cgroup memory fail: %v", err)
			}
			log.Infof("set: memory at %s set to %s", subsysCgroupPath, res.MemoryLimit)
		}
		return nil
	} else {
		return err
	}
}

// Remove 删除对应cgroup
func (s *MemorySubsystem) Remove(cgroupPath string) error {
	if subsysCgroupPath, err := GetCgroupPath(s.Name(), cgroupPath, false); err == nil {
		return os.Remove(subsysCgroupPath)
	} else {
		return err
	}
}

// Apply 添加一个进程到该cgroup中
func (s *MemorySubsystem) Apply(cgroupPath string, pid int) error {
	if subsysCgroupPath, err := GetCgroupPath(s.Name(), cgroupPath, false); err == nil {
		if err := os.WriteFile(path.Join(subsysCgroupPath, "tasks"),
			[]byte(strconv.Itoa(pid)), 0644); err != nil {
			return fmt.Errorf("apply memory cgroup process fail: %v", err)

		}
		log.Infof("apply pid %d succeed to %s", pid, subsysCgroupPath)
		return nil
	} else {
		return err
	}
}

同理,可以再完成cpu、cpuset两个subsystem完成对容器cpu/内存资源的限制。

可以看到具体的操作都是通过写入文件来完成的。由于要写入需要权限的目录,所以程序编译后必须以sudo运行。

除此之外,这里还引入了系统的管道机制来完成新老进程之间的通信和参数同步,取代之前的参数传递方法。同时还修改了CLI的参数配置以适应内存/cpu/cpuset 参数的传入。

进行测试,结果如下:

对比可以看出,对内存添加100mb的限制后,如果容器内进程内存要求过大时会被限制。

隔离根文件系统

到目前为止,我们创造的容器使用namespace和cgroup实现了简单的资源隔离,但是可以发现容器内的目录仍然是当前在本机运行的目录,这是因为文件系统还没有做到隔离,用mount命令在容器里可以看到继承自父进程的一大堆挂载点。

通过将文件系统的挂载相互隔离,docker才创造了“镜像”这种概念,让容器变得真正像虚拟机一样。

pivot_root是一个系统调用,主要功能是改变当前进程的root文件系统。相比chroot来说,pivot_root 不仅会修改该进程视角下的root目录,还会将旧的rootfs挂载为非rootfs,并创建一个新的文件系统来代替他。相比chroot,pivot_root通过创建文件系统的方式使得新进程不继承原来进程的挂载点,并且新进程不能访问原来的文件系统,达到隔离的效果。

用golang调用:

// 使用系统调用pivot_root来给当前进程创建一个新的root文件系统
func pivotRoot(root string) error {
	// 重新挂载一次,从而为root创建一个新的fs
	err := syscall.Mount(root, root, "bind", syscall.MS_BIND|syscall.MS_REC, "")
	if err != nil {
		return fmt.Errorf("fail remount root:%v", err)
	}
	// 旧root放在这(rootfs/.pivot_root)
	pivotDir := filepath.Join(root, ".pivot_root")
	if err := os.Mkdir(pivotDir, 0777); err != nil {
		return err
	}
	// 将当前进程的root fs移动到pivotDir,并使"root"成为新的文件系统
	if err := syscall.PivotRoot(root, pivotDir); err != nil {
		return fmt.Errorf("pivot fail: %v", err)
	}

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

	// 删除老root
	pivotDir = filepath.Join("/", ".pivot_root")
	if err := syscall.Unmount(pivotDir, syscall.MNT_DETACH); err != nil {
		return fmt.Errorf("fail unmount pivotdir")
	}
	return os.Remove(pivotDir)
}

接下来就只用在容器初始化的时候调用它,就可以实现文件系统隔离了。要注意pivot_root的manual上说它不会更改进程的工作目录,所以函数里要再修改一下程序的pwd为/

整理下初始化容器时有关mount的代码,和我们新写的代码合在一起:

// 初始化挂载点
func setupMount() {

	// 声明命名空间是独立的,避免再次运行出现bug
	err := syscall.Mount("", "/", "", syscall.MS_PRIVATE|syscall.MS_REC, "")
	if err != nil {
		log.Error(err)
	}

	pwd, err := os.Getwd()
	if err != nil {
		log.Error(err)
		return
	}
	err = pivotRoot(pwd)
	if err != nil {
		log.Errorf("pivot failed: %v", err)
		return
	}

	// 为proc文件系统设置mount参数
	mountFlags := syscall.MS_NOEXEC | // 不允许其他进程访问
		syscall.MS_NOSUID | // 禁止其他进程 set-user-ID / set-group-ID
		syscall.MS_NODEV
	// 挂载proc filesystem
	err = syscall.Mount("proc", "/proc", "proc", uintptr(mountFlags), "")
	if err != nil {
		log.Error(err)
	}

	// 为dev挂载一个基于内存的临时文件系统
	err = syscall.Mount("tmpfs", "/dev", "tmpfs", syscall.MS_NOSUID|syscall.MS_STRICTATIME,
		"mode=755")
	if err != nil {
		log.Error(err)
	}

}

要注意的是现在已经实现了文件系统的隔离,容器进程是以其工作目录作为root文件系统进行挂载的,所以在容器内现在只能访问到其工作目录下的文件,访问不到原来本机上的sh之类。

为了能在容器内访问shell等,我们要将需要的文件放入容器进程的工作目录。如何get到我们需要的这些常用文件呢?一个简单的方法是下载docker的busybox镜像,把他的root文件系统给扒出来:

docker pull busybox
docker run -d busybox top -b
docker export -o busybox.tar [容器id]
mkdir /root/busybox
tar -xvf busybox.tar -C /root/busybox/

查看/busybox/,可以看到其rootfs下挂载的目录结构,其中包含我们需要的/bin/。

接下来我们在子进程的启动参数临时加一句,修改它的工作目录:

retCmd.Dir = "/root/busybox"

ok,现在就可以跑sh等程序了。

测试一下:

可以看到shell跑起来了,当前目录已切换成了/,且文件系统已经实现了隔离,挂载点也没有继承父进程,只挂载了我手动挂载的需要的文件系统。

实现镜像-容器隔离

此前我们用pivot_root实现了让自定义目录作为容器的新根文件系统,但实际上其新根文件系统仍然在与宿主共用同一个目录,容器内的修改会对容器外生效。

对docker来说,镜像机制是其实现的非常重要的机制。通过镜像将“软件包”和具体容器隔离,实现docker“build once, run everywhere”的目标。所以我们需要实现一种机制让容器内的修改不影响镜像内容。

AUFS是一种联合文件系统,它实现了一种类似git的分层存储,通过将多个不同读写权限的文件系统用增量的方式合并可以创建一个高效的联合文件系统。由于其分层的特性,docker才有了轻量性和高效性。

我们通过系统调用可以直接挂载aufs类型的文件系统。为了实现隔离的同时挂载镜像,我们可以挂载busybox为只读层,同时挂载一个新的目录作为可写层,将他们挂载到一起。这样容器内产生的写操作就只会影响可写层了。

要合并一个可写文件层和一个可读文件层,shell可以这样写,其中lower1是可写文件层,lower2是只读文件层:

mount -t aufs -o dirs=/.../lower1:/.../lower2 none /mnt/aufs/merged

下面只需要在go里为容器建一个workSpace,用系统调用挂上分层的文件系统即可。

// 为容器挂载好工作空间
func newWorkSpace(root string, mnt string) {
	createReadOnlyLayer(root)
	createWriteLayer(root)
	createMountPoint(root, mnt)
}

func createReadOnlyLayer(root string) {
	busybox := root + "busybox/"
	exist, err := pathExists(busybox)
	if err != nil || exist == false {
		log.Errorf("fail to find busybox path:%v", err)
	}
}

func createWriteLayer(root string) {
	writeLayer := root + "writeLayer/"
	if err := os.Mkdir(writeLayer, 0777); err != nil {
		log.Error(err)
	}
}

// 挂载到新建的mnt目录
func createMountPoint(root string, mnt string) {
	// 创建mnt
	if err := os.Mkdir(mnt, 0777); err != nil {
		log.Errorf("mkdir err: %v", err)
	}
	// 使用aufs将只读和可写目录都mount到mnt目录下
	dirs := "dirs=" + root + "writeLayer:" + root + "busybox"
	cmd := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", mnt)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		log.Errorf("run mount fail: %v", err)
	}
}

func DeleteWorkSpace(root string, mnt string) {
	DeleteMountPoint(root, mnt)
	DeleteWriteLayer(root)
}

func DeleteMountPoint(root string, mnt string) {
	cmd := exec.Command("umount", mnt)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		log.Errorf("unmount failed: %v", err)
	}
	if err := os.RemoveAll(mnt); err != nil {
		log.Errorf("remove dir fail: %v", err)
	}
}

func DeleteWriteLayer(root string) {
	write := root + "writeLayer"
	if err := os.RemoveAll(write); err != nil {
		log.Error(err)
	}
}

// 工具函数,检测路径是否存在
func pathExists(path string) (bool, error) {
	_, err := os.Stat(path)
	if err == nil {
		return true, nil
	}
	if os.IsNotExist(err) {
		return false, nil
	}
	return false, err
}

其中,busybox层为只读层,writelayer为可写层。

之后修改父进程的逻辑,在运行子进程前为其准备好工作空间,同时修改子进程工作目录为新合并的mnt。

	mnt := "/root/mnt/"
	root := "/root/"
	newWorkSpace(root, mnt)
	retCmd.Dir = mnt

当然,还有父进程等待子进程结束后,调用deleteWorkSpace将工作目录unmount并删掉对应文件。毕竟容器内文件默认不做持久化考虑。

进入容器试一下,可以看到加载了busybox的目录,向tmp里随便写一个文件。

在容器内可以看到刚写进去的文件。

同时不关闭容器,用另一个ssh连接进入宿主机,查看root下目录的情况:

可以看到除了原有的busybox 多出了新合并出的mnt目录和writeLayer目录。查看busybox,发现其内容并没有被更改,tmp下位空;查看writeLayer:

可以发现writeLayer中有且只有我们对busybox做出修改的部分。这就是aufs的效果。

退出容器,发现仅剩下了原有的作为镜像的busybox,且没有造成任何修改。这样,就实现了将镜像的文件与容器内文件的轻量级隔离。

实现数据卷

Docker中一个很常用的操作是将宿主机的某个目录挂载进容器中,从而实现数据的共享和容器内数据的持久化。

回顾一下用aufs创建容器内文件系统的实现过程:

  • 创建只读层(busybox)

  • 创建容器读写层(writeLayer)

  • 创建挂载点(mnt),挂载只读层和读写层到挂载点;

  • 将挂载点作为容器的根目录

  • 容器运行完毕...

  • 卸载挂载点的文件系统

  • 删除挂载点

  • 删除读写层

可以看到,我们要让数据卷不在容器关闭后删除,只用在相同步骤内加一个文件挂载,结束时不删除即可。

这里的技术部分在上一节加入aufs的时候已经走通了,这里只需要实现下用户挂载数据卷的功能。

给命令行应用添加上新参数:

cli.StringFlag{
				Name:  "v",
				Usage: "volume",
			},

接下来写函数用于挂载数据卷:

// MountVolume 挂载数据卷
func MountVolume(mnt string, volumeUrls []string) {
	parentUrl := volumeUrls[0]
	if err := os.Mkdir(parentUrl, 0777); err != nil {
		log.Infof("mkdir for parent dir %s failed :%v", parentUrl, err)
	}
	// 在容器文件系统内创建挂载点
	containerUrl := volumeUrls[1]
	mntContainer := mnt + containerUrl
	if err := os.Mkdir(mntContainer, 0777); err != nil {
		log.Infof("mkdir for in-container dir %s failed :%v", mntContainer, err)
	}
	// 把宿主机目录挂载到容器内挂载点
	dirs := "dirs=" + parentUrl
	cmd := exec.Command("mount", "-t", "aufs", "-o", dirs, "none", mntContainer)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		log.Errorf("mount volume fail: %v", err)
	}
}

实际上还是使用aufs类型来把目录挂载到指定位置。

修改初始化工作空间的代码:

// 为容器挂载好工作空间
func newWorkSpace(root string, mnt string, volume string) {
	createReadOnlyLayer(root)
	createWriteLayer(root)
	createMountPoint(root, mnt)

	// 挂载数据卷,如果指定的话
	if volume != "" {
		volumeUrls := volumeUrlExtract(volume)
		if len(volumeUrls) == 2 && volumeUrls[0] != "" && volumeUrls[1] != "" {
			MountVolume(mnt, volumeUrls)
		} else {
			log.Info("Invalid volume input!")
		}
	}
}

这里的VolumeUrlExtract其实就是把docker宿主机目录:容器内目录的格式拆分一下。

最后删除部分,可以发现对于mnt或数据卷,都需要用unmount系统调用,只是对mnt还要多一个删除文件的步骤,索性加个参数写到一起:

func DeleteMountPoint(mnt string, remove bool) {
	cmd := exec.Command("umount", mnt)
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	if err := cmd.Run(); err != nil {
		log.Errorf("unmount failed: %v", err)
	}
	if !remove {
		return
	}
	if err := os.RemoveAll(mnt); err != nil {
		log.Errorf("remove dir fail: %v", err)
	}
}

在最后删除工作空间的时候加个区分即可:

func DeleteWorkSpace(root string, mnt string, volume string) {
	volumeUrls := volumeUrlExtract(volume)
	if len(volumeUrls) == 2 && volumeUrls[0] != "" && volumeUrls[1] != "" {
		DeleteMountPoint(mnt+volumeUrls[1], true)
	}
	DeleteMountPoint(mnt, true)
	DeleteWriteLayer(root)
}

剩下的工作就是处理新的volume传参,然后就可以测试了。

编译运行minidocker,用-v参数挂载宿主机/root/volume到容器内的/containerVolume试试。

执行ls,发现多了一个containerVolume目录。往里边写一个test.txt试试。

重新开一个会话,查看宿主机下目录的情况:

可以看到在容器内创建的test.txt。退出容器,再看一看:

发现只剩下了作镜像的busybox和数据卷volume,符合数据卷的预期。

这个时候重新以同样的参数启动容器,挂载同一个数据卷:

发现也正常挂载了。

实现简单的镜像打包

docker的一个比较重要的功能是对当前镜像进行一个打包,允许用户在修改容器后能够再打包成镜像以供其他用户使用。

这里可以先实现一个简单的打包功能,即不考虑镜像的layer,直接把涉及的目录都打个包。

具体代码比较简单,镜像需要的文件都挂载在mnt下,因此只需要在容器运行的时候打包mnt目录即可。

首先还是为CLI添加命令:

commitCommand := cli.Command{
		Name:  "commit",
		Usage: "commit a container into image",
		Action: func(context *cli.Context) error {
			if len(context.Args()) != 1 {
				return fmt.Errorf("minidocker commit [containerName]")
			}
			imageName := context.Args().Get(0)
			commitContainer(imageName)
			return nil
		},
	}

	app.Commands = []cli.Command{
		runCommand,
		initCommand,
		commitCommand,
	}

再实现一个用tar命令打包即可:

func commitContainer(imageName string) {
	mnt := "/root/mnt"
	imageTar := "/root/" + imageName + ".tar"
	if _, err := exec.Command("tar", "-czf", imageTar, "-C", mnt, ".").CombinedOutput(); err != nil {
		log.Errorf("commit error: %v", err)
	} else {
		fmt.Println("committed " + imageTar)
	}
}

实验一下:

此时打开另一个窗口,执行commit:

至此简单的打包功能就完成了。如果愿意的话还可以做一个使用镜像的命令,同样很简单,只需要解压然后作为只读层挂载即可。

通过根文件系统隔离、镜像机制的实现,我们通过实际代码的方式深入了镜像分层存储、打造基本容器环境、挂载外部文件系统的原理,实现了容器的最基本功能。