1. 引入

在docker的基本知识讲解中,提到了docker镜像是由一层一层文件系统构成的。这一系列文件系统是一系列的只读层。当我们创建一个容器的时候,Docker会读取镜像(只读),并在镜像的顶部再添加一层读写层

这种读写层和只读层的组合被称为联合文件系统(Union File System / Unoin FS),结构抽象如下表所示,以防图片加载不出来。

层级说明
Running Container正在运行的容器
Storage Driver存储驱动层
write layer读写层
image layer镜像只读层
HOST宿主机

image.png

如果正在运行的容器修改了现有的文件,这些文件会被拷贝出底层的只读层,放到最顶部的容器读写层中,读写层中文件的未修改版本仍然存放在镜像的只读层中。

文件说明
读写层app.ini当出现写操作的时候,从只读层中拷贝到读写层
只读层app.ini

当基于相同的镜像创建第二个容器时,还是会创建一个没有任何数据修改的全新容器。在之前的容器中的任何修改只会保留在原有容器中,实现了容器和镜像的隔离。

这种读写层的操作带来了以下的问题:

  1. 当容器不再存在的时候,数据不持久化;
  2. 如果另外一个进程需要使用容器内的数据,难以将其从容器内取出;
  3. 容器的可写层与容器当前运行的宿主机紧密相连,难以将其移动到另外一台主机上;
  4. 写入容器的可写层需要存储驱动Storage Dirver来管理这个文件系统,存储驱动提供了一个使用Linux内核的联合文件系统;与直接将数据写入宿主机的文件系统的方式,这种额外的抽象层降低了性能。

为了能持久化这些修改过的数据,并且能够很容易实现容器间进行数据的共享,docker提出了volume的概念,同时也提供了多种数据持久化的方式。

2. docker提供的持久化策略

docker提供两种文件持久化的策略,分别是volume和mount,其中mount还分为bind mount(将容器内路径和宿主机的文件路径绑定)和tmpfs mount(数据只存在于宿主机的内存中)。

通过volume和bind mount持久化的文件都可以称之为docker的数据卷。数据卷是在容器默认的联合文件系统之外的文件或目录,它可以在宿主机上直接被访问。即便容器删除,数据卷中的内容也不会丢失。

image.png

tips:

  • Volumes are stored in a part of the host filesystem which is managed by Docker (/var/lib/docker/volumes/ on Linux). Non-Docker processes should not modify this part of the filesystem. Volumes are the best way to persist data in Docker.
  • Bind mounts may be stored anywhere on the host system. They may even be important system files or directories. Non-Docker processes on the Docker host or a Docker container can modify them at any time.
  • tmpfs mounts are stored in the host system’s memory only, and are never written to the host system’s filesystem.

下文将对这三种不同的文件持久化方式进行测试

3. volumes

3.1. 测试:自动创建的volume

mysql:5.7镜像为例,下面是一个创建容器的命令

1
2
3
4
docker run -d \
--name="testMysql" \
-e MYSQL_ROOT_PASSWORD="123456" \
mysql:5.7

创建容器之前,先看看当前系统上的docker volume有哪些

1
2
3
4
$ docker volume ls
DRIVER VOLUME NAME
local 53b8da5cc9f94a856e263d36ae69aea754be90a5a8b5b4848850af6e35503770
local act-toolcache

执行了这个命令后,mysql容器被创建且正常运行,再次查看当前系统上的docker volume,可以发现多了一个新的volume。

1
2
3
4
DRIVER    VOLUME NAME
local 0cd65f1432c653ec08d7d8c3c50f645b97e0f26d139f1debb0c7fead3dafdfa4
local 53b8da5cc9f94a856e263d36ae69aea754be90a5a8b5b4848850af6e35503770
local act-toolcache

进入新创建的这个docker volume在宿主机上的路径,看看这里面有什么东西

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
❯ sudo ls -al /var/lib/docker/volumes/0cd65f1432c653ec08d7d8c3c50f645b97e0f26d139f1debb0c7fead3dafdfa4/_data
total 188484
drwxrwxrwt 5 999 docker 4096 May 6 16:56 .
drwx-----x 3 root root 4096 May 6 16:56 ..
-rw-r----- 1 999 docker 56 May 6 16:56 auto.cnf
-rw------- 1 999 docker 1676 May 6 16:56 ca-key.pem
-rw-r--r-- 1 999 docker 1112 May 6 16:56 ca.pem
-rw-r--r-- 1 999 docker 1112 May 6 16:56 client-cert.pem
-rw------- 1 999 docker 1676 May 6 16:56 client-key.pem
-rw-r----- 1 999 docker 1318 May 6 16:56 ib_buffer_pool
-rw-r----- 1 999 docker 79691776 May 6 16:56 ibdata1
-rw-r----- 1 999 docker 50331648 May 6 16:56 ib_logfile0
-rw-r----- 1 999 docker 50331648 May 6 16:56 ib_logfile1
-rw-r----- 1 999 docker 12582912 May 6 16:56 ibtmp1
drwxr-x--- 2 999 docker 4096 May 6 16:56 mysql
lrwxrwxrwx 1 999 docker 27 May 6 16:56 mysql.sock -> /var/run/mysqld/mysqld.sock
drwxr-x--- 2 999 docker 4096 May 6 16:56 performance_schema
-rw------- 1 999 docker 1680 May 6 16:56 private_key.pem
-rw-r--r-- 1 999 docker 452 May 6 16:56 public_key.pem
-rw-r--r-- 1 999 docker 1112 May 6 16:56 server-cert.pem
-rw------- 1 999 docker 1676 May 6 16:56 server-key.pem
drwxr-x--- 2 999 docker 12288 May 6 16:56 sys

如果你对MySQL比较熟悉,应该就能认出来,这就是MySQL在/var/lib/mysql中存放的数据,我们可以做个简单的验证,使用如下命令,直接链接到这个新创建的容器的MySQL命令行中。

1
docker exec -it testMysql mysql -uroot -p123456

我们在MySQL里面创建一个testdb数据库和一个stu表

1
2
3
4
5
6
7
8
9
-- 创建数据库
create database testdb;
-- 进入testdb数据库
use testdb;
-- 创建stu表
create table stu(
id int primary key,
name varchar(30) NOT NULL
);

操作完成后,exit退出容器,再次查看刚刚的volume目录。可以看到多了一个名为testdb的文件夹。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
❯ sudo ls -al /var/lib/docker/volumes/0cd65f1432c653ec08d7d8c3c50f645b97e0f26d139f1debb0c7fead3dafdfa4/_data
total 188488
drwxrwxrwt 6 999 docker 4096 May 6 17:15 .
drwx-----x 3 root root 4096 May 6 16:56 ..
-rw-r----- 1 999 docker 56 May 6 16:56 auto.cnf
-rw------- 1 999 docker 1676 May 6 16:56 ca-key.pem
-rw-r--r-- 1 999 docker 1112 May 6 16:56 ca.pem
-rw-r--r-- 1 999 docker 1112 May 6 16:56 client-cert.pem
-rw------- 1 999 docker 1676 May 6 16:56 client-key.pem
-rw-r----- 1 999 docker 1318 May 6 16:56 ib_buffer_pool
-rw-r----- 1 999 docker 79691776 May 6 17:15 ibdata1
-rw-r----- 1 999 docker 50331648 May 6 17:15 ib_logfile0
-rw-r----- 1 999 docker 50331648 May 6 16:56 ib_logfile1
-rw-r----- 1 999 docker 12582912 May 6 16:56 ibtmp1
drwxr-x--- 2 999 docker 4096 May 6 16:56 mysql
lrwxrwxrwx 1 999 docker 27 May 6 16:56 mysql.sock -> /var/run/mysqld/mysqld.sock
drwxr-x--- 2 999 docker 4096 May 6 16:56 performance_schema
-rw------- 1 999 docker 1680 May 6 16:56 private_key.pem
-rw-r--r-- 1 999 docker 452 May 6 16:56 public_key.pem
-rw-r--r-- 1 999 docker 1112 May 6 16:56 server-cert.pem
-rw------- 1 999 docker 1676 May 6 16:56 server-key.pem
drwxr-x--- 2 999 docker 12288 May 6 16:56 sys
drwxr-x--- 2 999 docker 4096 May 6 17:15 testdb

查看该文件夹,能看到刚刚我们创建的stu表的本地文件。可见这就是MySQL的本地路径。如果你在宿主机上直接使用apt安装一个MySQL/MariaDB,也可以在宿主机的/var/lib/mysql中看到类似的文件。

1
2
3
4
5
6
7
❯ sudo ls -al /var/lib/docker/volumes/0cd65f1432c653ec08d7d8c3c50f645b97e0f26d139f1debb0c7fead3dafdfa4/_data/testdb
total 120
drwxr-x--- 2 999 docker 4096 May 6 17:15 .
drwxrwxrwt 6 999 docker 4096 May 6 17:15 ..
-rw-r----- 1 999 docker 65 May 6 17:15 db.opt
-rw-r----- 1 999 docker 8586 May 6 17:15 stu.frm
-rw-r----- 1 999 docker 98304 May 6 17:15 stu.ibd

通过docker inspect testMysql命令,可以查询到这个容器的配置详情,其中的Mount部分就有刚刚看到的volume,其中Source字段就是这个volume在宿主机上的路径,Destination字段是volume对应的容器内路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"Mounts": [
{
"Type": "volume",
"Name": "0cd65f1432c653ec08d7d8c3c50f645b97e0f26d139f1debb0c7fead3dafdfa4",
"Source": "/var/lib/docker/volumes/0cd65f1432c653ec08d7d8c3c50f645b97e0f26d139f1debb0c7fead3dafdfa4/_data",
"Destination": "/var/lib/mysql",
"Driver": "local",
"Mode": "",
"RW": true,
"Propagation": ""
}
]
}

再去找找MySQL容器的dockerfile,也可以在里面看到一行关于volume的配置

1
VOLUME /var/lib/mysql

由此可见,对于创建容器,如果没有在run命令中主动mount某个volume或路径时,docker会自动创建一个随机命名的volume(保持唯一性),并将容器内的路径和这个volume绑定。

image.png

另外,一个volume的只能对应容器内的一个路径。如果容器在dockerfile中指定了多个不同路径的volume,则Docker也会创建多个volume与之对应。

3.2. 测试:主动指定volume

我们可以在run命令中指定容器路径和某个volume进行绑定,也可以写入一个volume的名字,在创建容器的同时创建这个volume。

下面这两种创建方式,都会在/var/lib/docker/volumes中创建一个名为test_mysql_2的volume。

1
2
3
4
5
6
7
8
# 创建volume
docker volume create test_mysql_2
# 绑定
docker run -d \
--name="testMysql2" \
-v test_mysql_2:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD="123456" \
mysql:5.7
1
2
3
4
5
6
# 创建容器的时候直接创建volume
docker run -d \
--name="testMysql2" \
-v test_mysql_2:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD="123456" \
mysql:5.7

执行命令后,可以看到新创建出来的volume

1
2
3
4
5
6
❯ docker volume ls
DRIVER VOLUME NAME
local 0cd65f1432c653ec08d7d8c3c50f645b97e0f26d139f1debb0c7fead3dafdfa4
local 53b8da5cc9f94a856e263d36ae69aea754be90a5a8b5b4848850af6e35503770
local act-toolcache
local test_mysql_2

通过docker inspect testMysql2命令,可以看到Mount中的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"Mounts": [
{
"Type": "volume",
"Name": "test_mysql_2",
"Source": "/var/lib/docker/volumes/test_mysql_2/_data",
"Destination": "/var/lib/mysql",
"Driver": "local",
"Mode": "z",
"RW": true,
"Propagation": ""
}
]
}

3.3. 绑定volume的权限选项

这里能发现字段Mode有变化,由空串变成了小写的z。这个是什么意思呢?

  • z(小写):代表绑定的目录由多个容器共享,其他容器也可以挂载这个volume;
  • Z(大写):代表绑定的目录由单个容器私有,其他容器无法挂载;

在使用-v绑定某个路径的时候,可以在路径后面再添加一个选项,来指定权限和绑定模式。方式如下,在容器内路径后再追加一个冒号

1
-v volume名字或宿主机路径:容器内路径:[权限选项]

权限的可选项有四种,默认情况下,给定的是rw读写权限。

  • 大写Z
  • 小写z
  • ro(只读)
  • rw(读写)

其中:z:Z选项是和SELinux有关的,具体可以参考官方文档Linux中国的文章。

在Ubuntu上,SELinux工具集默认应该是没有启用的。

注意,如果你使用:Z(大写)选项绑定了宿主机中诸如//usr/home的目录,你可能会因为权限问题,直接无法使用宿主机!使用该选项的时候需要慎重!

4. mount

4.1. bind mount

4.1.1. 说明

bind mount是docker早期就已经存在的数据持久化方式,其支持将容器的内的路径映射到某个宿主机上的路径,实现容器和宿主机文件的同步。绑定挂载直接使用了宿主机的文件系统,性能更佳。

绑定路径在docker run命令中和volume类似,都可以使用-v选项来实现

1
-v 宿主机路径:容器内路径

下面是一个示例

1
2
3
4
5
6
7
8
# 使用root用户,在宿主机上创建路径
mkdir /data/mysql
# docker容器绑定这个路径
docker run -d \
--name="testMysql3" \
-v /data/mysql:/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD="123456" \
mysql:5.7

这样这个docker安装的MySQL容器内的所有数据都会被写入宿主机的/data/mysql路径中,我们可以直接备份这个路径实现对MySQL数据的保留。

image.png

4.1.2. 源路径的说明

注意绑定挂载时-v选项中的路径volume的区别。我们知道,在Linux命令行中,直接输入一个目录/文件的名称,会默认是当前路径下的内容

1
2
cd  folder   等价于 cd ./folder
vim test.txt 等价于 vim ./test.txt

而在docker run命令的-v选项中,源路径source输入直接为某个目录名的时候,会认为是volume的名称!而不是当前路径下的文件!

假设我们当前运行docker run的终端路径中有一个folder文件夹,我们想将这个文件夹映射到docker容器内的/data路径,推荐的写法如下(推荐使用绝对路径来设置源主机上的路径)。

1
-v ${PWD}/folder:/data

错误的写法如下,直接写一个folder会以之为名创建一个新的volume,不符合我们的需要!

1
-v folder:/data

这一点在新建容器的时候一定要注意!个人推荐维持一个原则,即使用bind mount的时候一定要用绝对路径来设置宿主机上的文件路径。

4.1.3. bind mount的弊端

绑定挂载也有弊端

  • Bind mounts allow access to sensitive files One side effect of using bind mounts, for better or for worse, is that you can change the host filesystem via processes running in a container, including creating, modifying, or deleting important system files or directories. This is a powerful ability which can have security implications, including impacting non-Docker processes on the host system.

翻译过来就是,绑定挂载(特别是以读写方式挂载)会让docker容器有权限修改宿主机的任何文件,甚至包括宿主机的系统文件。存在安全性问题。

这一点在Linux中国关于SELinux的文章中就有介绍,比如我们将宿主机的/路径直接绑定到容器的/test路径中时,使用docker exec进入这个容器的终端,我们会拥有容器内的root权限(即可以对当前登录的这个容器内的文件做任意修改),此时就直接可以通过编辑容器内的/test路径,来删除/修改宿主机上的重要文件。

4.1.4. docker run 的 –mount 选项

除了-v选项,还可以用--mount选项来挂载数据卷,效果一致。

1
2
3
4
5
docker run -d \
--name="testMysql4" \
--mount type=bind,source=/data/mysql,target=/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD="123456" \
mysql:5.7

mount选项中,绑定的选项都用参数名写出来了,相对来说会更好理解,但是命令也变得复杂了。

keyvalue
typebind/volume/tmpfs
source/srcdocker host上的一个目录或文件
destination/dst/target容器内的一个目录或文件
readonly挂载为只读
option额外选项

如果需要指定readonly,直接在target后面添加该选项即可。添加了只读选项后,容器内对于这个路径就只能读,不能写入了。

1
--mount type=bind,source=/data/mysql,target=/var/lib/mysql,readonly

当使用mount选项来绑定volume的时候,可以省略type,此时docker会自动以source写入的字符串作为volume的名字,创建一个新volume并与当前容器进行绑定。

1
2
3
4
5
docker run -d \
--name="testMysql4" \
--mount source=mysql_vol,target=/var/lib/mysql \
-e MYSQL_ROOT_PASSWORD="123456" \
mysql:5.7

4.2. tmpfs mount

当容器为了性能原因,需要高频读写某些缓存文件(比如jellyfin镜像就有一个cache目录的volume,内部是一些缓存文件),或者为了安全性考虑不打算将一些数据写入磁盘的时候,我们可以使用tmpfs mount,将指定的路径绑定到宿主机的内存上。

对于nginx容器而言,其默认会有一个nginx的欢迎页面,存放在/usr/share/nginx/html路径中,这个欢迎页面可能会被经常的读取,占用空间也不大,所以我们可以将其放入内存中。

可以使用mount选项来进行绑定

1
2
3
4
5
6
7
8
9
10
11
12
# 直接绑定
docker run -d -it \
-p 80:80 \
--name tmptest \
--mount type=tmpfs,target=/usr/share/nginx/html \
nginx:latest
# 绑定时添加权限选项,1770代表全局可写
docker run -d -it \
-p 80:80 \
--name tmptest \
--mount type=tmpfs,target=/usr/share/nginx/html,tmpfs-mode=1770 \
nginx:latest

也可以使用tmpfs选项来绑定

1
2
3
4
5
docker run -d -it \
-p 80:80 \
--name tmptest \
--tmpfs /usr/share/nginx/html \
nginx:latest

更多相关的参数,可以参考docker的官方文档storage/tmpfs

4.3. bind mount和volume的区别

docker官方其实一直都推荐我们使用volume来实现数据持久化,而不是使用bind mount。来看看二者的区别吧。

区别bind mountvolume
source位置任意指定/var/lib/docker/volumes
source路径为空覆盖容器中的内容容器内数据复制到volume
权限控制读写/只读读写/只读
单个文件支持不支持,只能是目录
移植性弱,与hostpath绑定强,无需指定hostpath

5. 持久化和数据卷

数据卷的最大特点是它的生命周期独立于容器的生命周期,即便容器被删除,数据卷中的内容也不会被删除(tmpfs除外,它的内容本来就没有写入磁盘)。当使用docker rm删除某个容器的时候,docker并不会主动删除和容器关联的数据卷。

  • 数据卷可在容器之间共享或重用数据。
  • 数据卷的更改可以直接生效。
  • 数据卷的生命周期一直持续到没有容器使用它为止。
  • 对数据卷操作不会影响到镜像本身。
  • 数据卷可以完成容器到宿主机、宿主机到容器以及容器到容器之间的数据共享。

可见数据卷的好处还是多多的。所以,当你打算删除某个数据卷的时候,一定要确保这个数据卷里面的文件是完全无用了!

6. 参考文档