本文为从零开始写 Docker 系列第六篇,实现类似 docker -v 的功能,通过挂载数据卷将容器中部分数据持久化到宿主机。


完整代码见:https://github.com/lixd/mydocker

欢迎 Star

推荐阅读以下文章对 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

从零开始写 Docker(六)---实现 mydocker run -v 支持数据卷挂载的更多相关文章

  1. docker数据卷挂载

    docker数据卷挂载笔记 我们的服务运行时必不可少的会产生一些日志,或是我们需要把容器内的数据进行备份,甚至多个容器之间进行数据共享,这必然涉及容器的数据管理操作. 容器中管理数据主要有两种方式: ...

  2. 「Docker学习系列教程」9-Docker容器数据卷介绍

    通过前面8篇文章的学习,我们已经学会了docker的安装.docker常用的命令已经docker镜像修改后提交的远程镜像仓库及提交到公司的私服仓库中.接下来,我们再来学学Docker另外一个重要的东西 ...

  3. Docker 安装mysql容器数据卷挂载到宿主机

    环境 Centos:7 Docker: 17.05-ce Mysql: 5.7 1. Mysql外部数据和配置文件路径 msyql配置文件路径:/etc/mysql mysql数据卷路径:/var/l ...

  4. docker 数据卷挂载总结

    原文

  5. Docker容器学习梳理 - Volume数据卷使用

    之前部署了Docker容器学习梳理--基础环境安装,接下来看看Docker Volume的使用. Docker volume使用 Docker中的数据可以存储在类似于虚拟机磁盘的介质中,在Docker ...

  6. Docker容器数据卷-Volume详解

    Docker中的数据可以存储在类似于虚拟机磁盘的介质中,在Docker中称为数据卷(Data Volume).数据卷可以用来存储Docker应用的数据,也可以用来在Docker容器间进行数据共享.数据 ...

  7. docker 数据卷之进阶篇

    笔者在<Docker 基础 : 数据管理>一文中介绍了 docker 数据卷(volume) 的基本用法.随着使用的深入,笔者对 docker 数据卷的理解与认识也在不断的增强.本文将在前 ...

  8. 聊聊Docker数据卷和数据卷容器

    当程序在容器运行的时候,特别是需要与其他容器中的程序或容器外部程序进行沟通交流,这时需要进行数据交换,作为常用的两种沟通数据的方式,网络通信与文件读写是需要提供给程序的支持, [数据卷] 文件是数据持 ...

  9. docker 数据卷 ---- 进阶篇

    笔者在<Docker 基础 : 数据管理>一文中介绍了 docker 数据卷(volume) 的基本用法.随着使用的深入,笔者对 docker 数据卷的理解与认识也在不断的增强.本文将在前 ...

  10. docker定义数据卷及数据卷的备份恢复

    前言:生产环境中使用docker时,往往需要对数据进行持久化(只有把容器导出为镜像,才能够保存写的数据,否则容器删除或者停止,所有数据都会没有),或者需要在多个容器之间进行数据共享,这必然涉及容器的数 ...

随机推荐

  1. SpringBoot基于Spring Security的HTTP跳转HTTPS

    简单说说 之所以采用Spring Security来做这件事,一是Spring Security可以根据不同的URL来进行判断是否需要跳转(不推荐), 二是不需要新建一个TomcatServletWe ...

  2. Window Server+IIS配置实现一台服务器绑定多个HTTPS证书

    参考原文链接:https://blog.csdn.net/lengyiqiu/article/details/89182239 此处做个记录防止丢失: 直接上步骤: 1.选安装好SSL证书,供下面配置 ...

  3. CentOS7.5上卸载Oracle19c

    最近遇到一个麻烦的事情,由于公司开发的数据库备份容灾系统,对于备份容灾的数据库必须使用LVM(pv.vg.lv),所以之前安装的Oracle19C 必须卸载掉,然后使用空白磁盘通过parted.fdi ...

  4. [JVM]逃逸分析

    逃逸分析 JVM的内存分配策略 首先回顾一下JVM的内存分配策略. JVM的内存包括方法区.堆.虚拟机栈.本地方法栈.程序计数器.一般情况下JVM运行时的数据都是存在栈和堆上的.栈用来存放一些基本变量 ...

  5. NC18389 收益

    题目链接 题目 题目描述 小N是一家金融公司的项目经理.他准备投资一个项目,这个项目要融资L元,融资成功后会得到M元的利润.现在有n个客户.对于第i个客户,他有mi元钱.小N承诺假如最后筹够钱,会给这 ...

  6. FireFox 报错Security Connection Failed解决方案

    1.在浏览器中输入:about:config; 2.搜索security.ssl.enable_ocsp_stapling,双击将其修改为FALSE: 3.返回重新访问之前的网站,问题解决

  7. MySQL树形结构表设计

    两个字段: pid:父级ID parent_ids:所有经过的路径节点ID 这样设计有个好处是,可以查任意节点的所有子节点,从任意节点开始既可以向上查,也可以向下查 select * from ent ...

  8. STC MCU的软件和硬件PCA/PWM输出

    软件方式输出PWM PWM用于输出强度的控制, 例如灯的亮度, 轮子速度等, STC89/90系列没有硬件PWM, 需要使用代码模拟 使用纯循环的方式实现PWM 非中断的实现(SDCC环境编译) #i ...

  9. 【Unity3D】地面网格特效

    1 前言 ​ 本文实现了地面网格特效,包含以下两种模式: 实时模式:网格线宽度和间距随相机的高度实时变化: 分段模式:将相机高度分段,网格线宽度和间距在每段中对应一个值. ​ 本文完整资源见→Unit ...

  10. redis7源码分析:redis 多线程模型解析

    多线程模式中,在main函数中会执行InitServerLast void InitServerLast() { bioInit(); // 关键一步, 这里启动了多条线程,用于执行命令,redis起 ...