如果您觉得我们写得还不错,记得 点赞 + 关注 + 评论 三连🥰🥰🥰,鼓励我们写出更好的教程💪
数据是一切应用和服务的核心,特别是目睹了一次次“删库跑路”引发的惨剧之后,我们更能深入体会到数据存储与备份的重要性。Docker 也为我们提供了方便且强大的方式去处理容器的数据。在这一篇文章中,我们将带你通过理论和实战的方式掌握 Docker 的两种常用的数据管理方式:数据卷(Volume)和绑定挂载(Bind Mount),从而能够游刃有余地处理好数据,为你的应用提供强有力的支撑和保障。
好久不见,欢迎继续阅读“筑梦师系列” Docker 教程,前情回顾:
而在这一篇教程中,我们将带你上手 Docker 数据管理,搭建起”梦境“(容器环境)与”现实“(主机环境)的桥梁。Docker 数据的管理方式主要分为三种:
注意
tmpfs 挂载只适用于 Linux 操作系统。
我们马上通过几个小实验来体验一下(已经比较熟悉的同学可以直接移步下面的”实战演练“环节)。
正如在上一篇中最后“记住几十个 Docker 命令小诀窍”所提到的,数据卷(Volume)也是常见的 Docker 对象类型的一种,因此也支持 create
(创建)、inspect
(查看详情)、ls
(列出所有数据卷)、prune
(删除无用数据卷)和 rm
(删除)等操作。
我们来走一个流程体验一下。首先创建一个数据卷:
docker volume create my-volume 复制代码
查看当前所有的数据卷:
docker volume ls 复制代码
输出了刚刚创建的 my-volume
数据卷:
local my-volume 复制代码
查看 my-volume
数据卷的详细情况:
docker volume inspect my-volume 复制代码
可以看到输出了 JSON 格式的 my-volume
信息:
[ { "Driver": "local", "Labels": {}, "Mountpoint": "/var/lib/docker/volumes/my-vol/_data", "Name": "my-volume", "Options": {}, "Scope": "local" } ] 复制代码
提示
好奇的同学可能会去查看
/var/lib/docker/volumes
目录下面是不是真的有数据卷,答案是:对于非 Linux 系统而言(Windows 和 Mac 系统),该目录不存在于你的文件系统中,而是存在于 Docker 虚拟机中。
最后删除 my-volume
数据卷:
docker volume rm my-volume 复制代码
单独创建一个数据卷意义不大,毕竟它本来的作用就是为容器的数据管理服务。请看下图(来源 Safari Books Online):
可以看到,数据卷在“主机环境”和“容器环境”之间架起了“一道桥梁”。通常,我们在容器中将需要存储的数据写入数据卷所挂载的路径(位置),然后就会立即、自动地将这些数据存储到主机对应的区域。
在创建带有数据卷的容器时,通常有两种选择:1)命名卷(Named Volume);2)匿名卷(Anonymous Volume)。接下来我们就分别详细讲解。
首先我们来演示一下如何创建带有命名卷的容器,运行以下命令:
docker run -it -v my-vol:/data --name container1 alpine 复制代码
可以看到,我们通过 -v
(或者 --volume
)参数指定了数据卷的配置为 my-vol:/data
,其中(你应该猜到了)my-vol
就是数据卷的名称,/data
就是容器中数据卷的路径。
在进入容器中后,我们向 /data
目录中添加一个文件后退出:
/ # touch /data/file.txt / # exit 复制代码
注意
/ #
是 alpine 镜像默认的命令提示符,后面的touch /data/file.txt
才是真正要执行的命令哦。
为了验证 /data
中的数据是否真的保存下来,我们删除 container1
容器,然后再创建一个新的容器 container2
,查看其中的 /data
目录内容:
docker rm container1 docker run -it -v my-vol:/data --name container2 alpine / # ls /data file.txt / # exit 复制代码
可以看到刚刚在 container1
中创建的 file.txt
文件!事实上,这种在容器之间共享数据卷的模式非常常见,Docker 提供了一个方便的参数 --volumes-from
来轻松实现数据卷共享:
docker run -it --volumes-from container2 --name container3 alpine / # ls /data file.txt 复制代码
同样,container3
中也能访问到数据卷中的内容。
创建匿名卷的方式就很简单了,之前我们通过 my-vol:/data
作为 -v
的参数,而创建匿名卷只需省略数据卷名称(my-vol
即可):
docker run -v /data --name container4 alpine 复制代码
我们通过 inspect
命令来查看一下 container4
的情况:
docker inspect container4 复制代码
我们可以在其中的 Mounts
字段中看到如下数据:
"Mounts": [ { "Type": "volume", "Name": "dfee1d707956e427cc1818a6ee6060699514102e145cde314d4d938ceb12dfd3", "Source": "/var/lib/docker/volumes/dfee1d707956e427cc1818a6ee6060699514102e145cde314d4d938ceb12dfd3/_data", "Destination": "/data", "Driver": "local", "Mode": "", "RW": true, "Propagation": "" } ] 复制代码
我们来分析一下重要的字段:
Name
即数据卷的名称,由于是匿名卷,所以 Name
字段就是一串长长的随机数,命名卷则为指定的名称Source
为数据卷在主机文件系统中的存储路径(之前说了,Windows 和 Mac 在 Docker 虚拟机中)Destination
为数据卷在容器中的挂载点RW
指可读写(Read-Write),如果为 false
,则为只读数据卷在 Dockerfile 中使用数据卷非常简单,只需通过 VOLUME
关键词指定数据卷就可以了:
VOLUME /data # 或者通过 JSON 数组的方式指定多个数据卷 VOLUME ["/data1", "/data2", "/data3"] 复制代码
有两点需要注意:
docker run -v
指定数据卷时,Dockerfile 中的配置会被覆盖绑定挂载(Bind Mount)是出现最早的 Docker 数据管理和存储解决方案,它的大致思路和数据卷是一致的,只不过是直接建立本机文件系统和容器文件系统之间的映射关系,非常适合简单、灵活地在本机和容器之间传递数据。
我们可以试着把自己机器的桌面(或者其他路径)挂载到容器中:
docker run -it --rm -v ~/Desktop:/desktop alpine 复制代码
我们还是通过 -v
参数来进行配置,~/Desktop
是本机文件系统路径,/desktop
则是容器中的路径,~/Desktop:/desktop
则是将本机路径和容器路径进行绑定,仿佛架起了一道桥梁。这里的 --rm
选项是指在容器停止之后自动删除(关于容器生命周期的更多细节,请参考第一篇文章)。
进入到容器之后,可以试试看 /desktop
下面有没有自己桌面上的东西,然后再在容器中创建一个文件,看看桌面上有没有收到这个文件:
/# ls /desktop # 我自己桌面上的很多东西 :D /# touch /desktop/from-container.txt 复制代码
你应该能看到自己的桌面上多了容器中创建的 from-container.txt
文件!
我们贴出官方文档这张示意图:
可以看到:
在指定数据卷或绑定挂载时,-v
参数的格式为 <first_field>:<second_field>:<rw_options>
(注意通过冒号分隔),包括三个字段,分别是:
ro
(Read-only),则为只读提示
Docker 在 17.06 版本之后引入了
--mount
参数,功能与-v
/--volume
参数几乎一致,通过键值对的方式指定数据卷的配置,更为冗长但也更清晰。这篇文章将详细讲解更为常见和普遍的-v
参数,--mount
参数的更多使用可参考文档。
好的,终于到了实战演练环节——继续部署我们之前一直在做的全栈待办事项项目(React 前端 + Express 后端 + MongoDB 数据库)。如果你没有阅读之前的教程,想直接从这一步开始做起,请运行以下命令:
git clone -b volume-start https://github.com/tuture-dev/docker-dream.git cd docker-dream 复制代码
在之前项目的基础上,我们打算
OK,我们在 server/Dockerfile
中添加 VOLUME
配置,并且指定 LOG_PATH
(日志输出路径环境变量,可参考 server/index.js
的源码)为 /var/log/server/access.log
,代码如下:
# ... # 指定工作目录为 /usr/src/app,接下来的命令全部在这个目录下操作 WORKDIR /usr/src/app VOLUME /var/log/server # ... # 设置环境变量(服务器的主机 IP 和端口) ENV MONGO_URI=mongodb://dream-db:27017/todos ENV HOST=0.0.0.0 ENV PORT=4000 ENV LOG_PATH=/var/log/server/access.log # ... 复制代码
然后 build 服务器镜像:
docker build -t dream-server server/ 复制代码
稍等片刻后,我们把整个项目开起来:
# 创建网络,便于容器互联 docker network create dream-net # 启动 MongoDB 容器(dream-db) docker run --name dream-db --network dream-net -d mongo # 启动 Express API 容器(dream-api) docker run -p 4000:4000 --name dream-api --network dream-net -d dream-server # 构建提供 React 前端页面的 Nginx 服务器 docker build -t dream-client client # 启动 Nginx 服务器容器(client) docker run -p 8080:80 --name client -d dream-client 复制代码
通过 docker ps
确保三个容器都已经开启:
访问 localhost:8080
,进入到待办事项页面,创建几个事项:
之前我们把日志数据存储到了匿名卷中,由于直接获取数据卷中的数据是比较麻烦的,推荐的做法是通过创建一个新的临时容器,通过共享数据卷的方式来备份数据。听着有点晕?请看下图:
按照以下步骤进行:
第一步,实现 dream-api
容器和数据卷之间的数据共享(已实现)。
第二步,创建临时容器,获取 dream-api
的数据卷。运行以下命令:
docker run -it --rm --volumes-from dream-api -v $(pwd):/backup alpine 复制代码
上面这句命令同时用到了上面讲解的数据卷和绑定挂载:
--volumes-from dream-api
用于容器之间共享数据卷,这里我们获取 dream-api
的数据卷-v $(pwd):/backup
用于建立当前本机文件路径(pwd
命令获取)和临时容器内 /backup
路径的绑定挂载第三步,进入临时容器之后,我们把日志数据压缩成 tar 包放到 /backup
目录下,然后退出:
/ # tar cvf /backup/backup.tar /var/log/server/ tar: removing leading '/' from member names var/log/server/ var/log/server/access.log / # exit 复制代码
退出之后,是不是在当前目录看到了日志的备份 backup.tar
?事实上,我们可以通过一条命令搞定:
docker run -it --rm --volumes-from dream-api -v $(pwd):/backup alpine tar cvf /backup/backup.tar /var/log/server 复制代码
如果你觉得上面这条命令难以理解的话,答应我,一定要去仔细看看上一篇文章中的”回忆与升华“-”理解命令:梦境的主旋律“这一部分!
接下里就是这篇文章的重头戏,各位打起十二分的精神!我们的应用会不会遭遇删库跑路的危机全看你有没有学会这一节的操作技巧了!
提示
我们这里使用 MongoDB 自带的备份与恢复命令(
mongodump
与mongorestore
),其他数据库(例如 MySQL)也有类似的命令,都可以借鉴本文的方式。
按照之前共享数据卷的思路,我们也尝试通过一个临时 Mongo 容器来备份数据。示意图如下:
首先,我们的临时容器得连接上 dream-db
容器,并配置好绑定挂载,命令如下:
docker run -it --rm -v $(pwd):/backup --network dream-net mongo sh 复制代码
和之前备份日志数据相比,我们要把这个临时容器连接到 dream-net
网络中,它才能访问到 dream-db
的数据进行备份(不熟悉 Docker 网络的同学可复习前一篇文章)。
第二步,进入到这个临时容器后,运行 mongodump
命令:
/ # mongodump -v --host dream-db:27017 --archive --gzip > /backup/mongo-backup.gz 复制代码
此时,由于绑定挂载,输出到 /backup
的文件将保存到当前目录(pwd
)中。退出后,就可以在当前目录下看到 mongo-backup.gz
文件了。
在前一篇教程的”回忆与升华“部分,我们轻描淡写地讲解了通过 docker exec
执行 mongodump
命令来做备份,但是当时输出的备份文件还是停留在容器中,只要容器被删除,备份文件也就消失了。于是一个很自然的想法就出现了:我们能不能在创建数据库容器的时候就做好绑定挂载,然后通过 mongodump
把数据备份到挂载区域?
事实上,之前在创建数据库容器的时候,运行以下命令:
docker run --name dream-db --network dream-net -v $(pwd):/backup -d mongo 复制代码
然后再通过 docker exec
执行 mongodump
命令:
docker exec dream-db sh -c 'mongodump -v --archive --gzip > /backup/mongo-backup.gz' 复制代码
就可以轻松实现。这里我们用 sh -c
来执行一整条 Shell 命令(字符串形式),这样避免了重定向符 >
引发的歧义(不理解的话可以把 sh -c 'xxx'
替换成 xxx
)。可以看到,mongodump
的命令简单了许多,我们再也不需要指定 --host
参数,因为数据库就在本容器内。
但是有个问题:如果已经创建了数据库,并且没有提前做绑定挂载,这种方法就行不通了!
有了数据库备份文件,我们就可以肆无忌惮地来做一波”演习“了。通过以下命令,直接端了目前的数据库和 API 服务器:
docker rm -f --volumes dream-db docker rm -f dream-api 复制代码
没错,通过 --volumes
开关,我们不仅把 dream-db
容器删了,还顺带把挂载的数据卷全部删除!演习就是要足够逼真才行。这时候再访问 localhost:8080
,之前的待办数据全部丢失!
开始灾后重建,让我们再次创建新的 dream-db
容器:
docker run --name dream-db --network dream-net -v $(pwd):/backup -d mongo 复制代码
注意到,我们通过绑定挂载的方式把当前目录映射到容器的 /backup
目录,这意味着可以在这个新的容器中通过 /backup/mongo-backup.gz
来恢复数据,运行以下命令:
docker exec dream-db sh -c 'mongorestore --archive --gzip < /backup/mongo-backup.gz' 复制代码
我们应该会看到输出了一些日志,提示我们数据恢复成功。最后重新开启 API 服务器:
docker run -p 4000:4000 --name dream-api --network dream-net -d dream-server 复制代码
回头访问我们的待办应用,数据是不是都回来了!?
之前,我们通过共享数据卷或者绑定挂载的方式来把容器的数据传送到容器之外。事实上,在容器和本机之间还可以通过另一种方式传递和共享数据:docker cp
命令。没错,如果你用过 cp
命令拷贝文件,它的用法一定不会陌生。例如,我们将 dream-api
容器内的日志文件拷贝到当前目录下:
docker cp dream-api:/var/log/server/access.log . 复制代码
看!access.log
就有了!当然,我们还可以”反向操作“一波,把本地的文件拷贝到容器里面去:
docker cp /path/to/some/file dream-api:/dest/path 复制代码
可以看到,docker cp
用起来非常方便,很适合一次性的操作。缺陷也很明显:
在备份和恢复数据库时,有一个更加简单粗暴的思路:为什么我们不能直接备份整个容器呢?事实上,Docker 确实为我们提供了两个命令来搞定整个容器的打包和装载:export
和 import
。
例如,通过以下命令将整个容器的文件系统导出为 tar
包:
docker export my-container > my-container.tar 复制代码
注意
export
命令不会导出容器相关数据卷的内容。
然后可以通过 import
命令创建拥有完全相同内容的镜像:
docker import my-container.tar 复制代码
import
命令会输出一个 SHA256 字符串,就是镜像的 UUID。接着可以用 docker run
命令启动这个镜像(可以指定 SHA256 串,也可以先通过 docker tag
打个标签)。
如果你刚刚尝试了 export
和 import
命令,一定会发现一个相当严重的问题:容器打包之后的 tar 包有好几百兆。很显然,简单粗暴地打包容器也包括了很多根本无用的数据(例如操作系统中的其他文件),对硬盘的压力陡然增加。
在学习和实践了数据卷的知识后,我们还接触了一下 docker cp
和 docker export/import
命令。至此,我们不禁追问,镜像和容器的本质到底是什么,其中的数据是怎样存储的?
或者我们提一个更具体的问题:为什么镜像中的数据(例如操作系统中的各种文件)每次创建容器时都会存在,而在创建容器后写入的数据会在容器删除后却丢失?
这背后的一切就是 Docker 赖以生存的 Union File System(UFS)机制。我们通过一张图(来源:The Docker Ecosystem)来大致感受一下:
我们来一点点分析上面这张 UFS 示意图的要点:
而我们这一篇文章所讲解的数据管理技巧(数据卷、绑定挂载),则是完全绕开了 UFS,让重要的业务数据独立存储,并且可备份、可恢复,而不是陷入在容器的可写层中让整个容器变得臃肿不堪。
再回过头看上面的问题,是不是有思路了?
想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。
本文所涉及的源代码都放在了 Github 上,如果您觉得我们写得还不错,希望您能给❤️这篇文章点赞+Github仓库加星❤️哦