目录

隐藏
  1. 容器磁盘可以限制配额么?
  2. 容器内的数据该保存在镜像里还是物理机里?
  3. 看到总说要保持容器无状态,那什么是无状态?
  4. 数据容器、数据卷、命名卷、匿名卷、挂载目录这些都有什么区别?
  5. 卷和挂载目录有什么区别?
  6. 为什么绑定了宿主的文件到容器,宿主修改了文件,容器内看到的还是旧的内容啊?
  7. 多个 Docker 容器之间共享数据怎么办?NFS ?
  8. 既然一个容器一个应用,那么我想在该容器中用计划任务 cron 怎么办?
  9. 如何初始化卷?
  10. 为什么说数据库不适合放在 Docker 容器里运行?
  11. 如何列出容器和所使用的卷的关系?
容器磁盘可以限制配额么?
对于 devicemapper, btrfs, zfs 来说,可以通过 --storage-opt size=100G 这种形式限制 rootfs 的大小。

docker create -it --storage-opt size=120G fedora /bin/bash
参考官网文档: https://docs.docker.com/engine/reference/commandline/run/#/set-storage-driver-options-per-container

容器内的数据该保存在镜像里还是物理机里?
如果所谓数据是指运行时动态的数据,那么这部分数据文件不应该保存于镜像内。在运行时要保持容器基础文件不可变的特性,而变化部分使用挂载宿主目录,或者数据卷来解决。

建议看一下官网 docker volume 的文档: https://docs.docker.com/engine/tutorials/dockervolumes/

看到总说要保持容器无状态,那什么是无状态?
这里说到的有两个层面的无状态:

容器存储层的无状态

这里提到的存储层是指用于存储镜像、容器各个层的存储,一般是 Union FS,如 AUFS,或者是使用块设备的一些机制(如 snapshot )进行模拟,如 devicemapper。

Union FS 这类存储系统,相当于是在现有存储上,再加一层或多层存储,这类存储的读写性能并不好。并且对于 CentOS 这类只能使用 devicemapper 的系统而言,存储层的读写还经常出 bug。因此,在 Docker 使用过程中,要避免存储层的读写。频繁读写的部分,应该使用卷。需要持久化的部分,可以使用命名卷进行持久化。由于命名卷的生存周期和容器不同,容器消亡重建,卷不会跟随消亡。所以容器可以随便删了重新run,而其挂载的卷则会保持之前的数据。

服务层面的无状态

使用卷持久化容器状态,虽然从存储层的角度看,是无状态的,但是从服务层面看,这个服务是有状态的。

从服务层面上说,也存在无状态服务。就是说服务本身不需要写入任何文件。比如前端 nginx,它不需要写入任何文件(日志走Docker日志驱动),中间的 php, node.js 等服务,可能也不需要本地存储,它们所需的数据都在 redis, mysql, mongodb 中了。这类服务,由于不需要卷,也不发生本地写操作,删除、重启、不保存自身状态,并不影响服务运行,它们都是无状态服务。这类服务由于不需要状态迁移,不需要分布式存储,因此它们的集群调度更方便。

之前没有 docker volume 的时候,有些人说 Docker 只可以支持无状态服务,原因就是只看到了存储层需求无状态,而没有 docker volume 的持久化解决方案。

现在这个说法已经不成立,服务可以有状态,状态持久化用 docker volume。

当服务可以有状态后,如果使用默认的 local 卷驱动,并且使用本地存储进行状态持久化的情况,单机服务、容器的再调度运行没有问题。但是顾名思义,使用本地存储的卷,只可以为当前主机提供持久化的存储,而无法跨主机。

但这只是使用默认的 local 驱动,并且使用 本地存储 而已。使用分布式/共享存储就可以解决跨主机的问题。docker volume 自然支持很多分布式存储的驱动,比如 flocker、glusterfs、ceph、ipfs 等等。常用的插件列表可以参考官方文档: https://docs.docker.com/engine/extend/legacy_plugins/#/volume-plugins

数据容器、数据卷、命名卷、匿名卷、挂载目录这些都有什么区别?
首先,挂载分为挂载本地宿主目录 和 挂载数据卷(Volume)。而数据卷又分为匿名数据卷和命名数据卷。

绑定宿主目录的概念很容易理解,就是将宿主目录绑定到容器中的某个目录位置。这样容器可以直接访问宿主目录的文件。其形式是

docker run -d -v /var/www:/app nginx
这里注意到 -v 的参数中,前半部分是绝对路径。在 docker run 中必须是绝对路径,而在 docker-compose 中,可以是相对路径,因为 docker-compose 会帮你补全路径。

另一种形式是使用 Docker Volume,也就是数据卷。这是很多看古董书的人不了解的概念,不要跟数据容器(Data Container)弄混。数据卷是 Docker 引擎维护的存储方式,使用 docker volume create 命令创建,可以利用卷驱动支持多种存储方案。其默认的驱动为 local,也就是本地卷驱动。本地驱动支持命名卷和匿名卷。

顾名思义,命名卷就是有名字的卷,使用 docker volume create --name xxx 形式创建并命名的卷;而匿名卷就是没名字的卷,一般是 docker run -v /data 这种不指定卷名的时候所产生,或者 Dockerfile 里面的定义直接使用的。

有名字的卷,在用过一次后,以后挂载容器的时候还可以使用,因为有名字可以指定。所以一般需要保存的数据使用命名卷保存。

而匿名卷则是随着容器建立而建立,随着容器消亡而淹没于卷列表中(对于 docker run 匿名卷不会被自动删除)。对于二代 Swarm 服务而言,匿名卷会随着服务删除而自动删除。 因此匿名卷只存放无关紧要的临时数据,随着容器消亡,这些数据将失去存在的意义。

此外,还有一个叫数据容器 (Data Volume) 的概念,也就是使用 --volumes-from 的东西。这早就不用了,如果看了书还在说这种方式,那说明书已经过时了。按照今天的理解,这类数据容器,无非就是挂了个匿名卷的容器罢了。

在 Dockerfile 中定义的挂载,是指 匿名数据卷。Dockerfile 中指定 VOLUME 的目的,只是为了将某个路径确定为卷。

我们知道,按照最佳实践的要求,不应该在容器存储层内进行数据写入操作,所有写入应该使用卷。如果定制镜像的时候,就可以确定某些目录会发生频繁大量的读写操作,那么为了避免在运行时由于用户疏忽而忘记指定卷,导致容器发生存储层写入的问题,就可以在 Dockerfile 中使用 VOLUME 来指定某些目录为匿名卷。这样即使用户忘记了指定卷,也不会产生不良的后果。

这个设置可以在运行时覆盖。通过 docker run 的 -v 参数或者 docker-compose.yml 的 volumes 指定。使用命名卷的好处是可以复用,其它容器可以通过这个命名数据卷的名字来指定挂载,共享其内容(不过要注意并发访问的竞争问题)。

比如,Dockerfile 中说 VOLUME /data,那么如果直接 docker run,其 /data 就会被挂载为匿名卷,向 /data 写入的操作不会写入到容器存储层,而是写入到了匿名卷中。但是如果运行时 docker run -v mydata:/data,这就覆盖了 /data 的挂载设置,要求将 /data 挂载到名为 mydata 的命名卷中。所以说 Dockerfile 中的 VOLUME 实际上是一层保险,确保镜像运行可以更好的遵循最佳实践,不向容器存储层内进行写入操作。

数据卷默认可能会保存于 /var/lib/docker/volumes,不过一般不需要、也不应该访问这个位置。

卷和挂载目录有什么区别?
卷 (Docker Volume) 是受控存储,是由 Docker 引擎进行管理维护的。因此使用卷,你可以不必处理 uid、SELinux 等各种权限问题,Docker 引擎在建立卷时会自动添加安全规则,以及根据挂载点调整权限。并且可以统一列表、添加、删除。另外,除了本地卷外,还支持网络卷、分布式卷。

而挂载目录那就没人管了,属于用户自行维护。你就必须手动处理所有权限问题。特别是在 CentOS 上,很多人碰到 Permission Denied,就是因为没有使用卷,而是挂载目录,而且还对 SELinux 安全权限一无所知导致。

为什么绑定了宿主的文件到容器,宿主修改了文件,容器内看到的还是旧的内容啊?
在绑定宿主内容的形式中,有一种特殊的形式,就是绑定宿主文件,既:

docker run -d -v $PWD/myapp.ini:/app/app.ini myapp
在 myapp.ini 文件不发生改变的情况下,这样的绑定是和绑定宿主目录性质一样,同样是将宿主文件绑定到容器内部,容器内可以看到这个文件。但是,一旦文件发生改变,情况则有不同。

简单的文件修改,比如 echo "name = jessie" >> myapp.ini,这类修改依旧还是原来的文件,宿主(或容器)对文件进行的改动,另一方是可以看到的。

而复杂的文件操作,比如使用 vim,或者其它编辑器编辑文件,则很有可能会导致一方的修改,另一方看不到。

其原因是这类编辑器在保存文件的时候,经常会采用一种避免写入过程中发生故障而导致文件丢失的策略,既先把内容写到一个新的文件中去,写好了后,再删除旧的文件,然后把新文件改名为旧的文件名,从而完成保存的操作。从这个操作流程可以看出,虽然修改后的文件的名字和过去一样,但对于文件系统而言是一个新的文件了。换句话说,虽然是同名文件,但是旧的文件的 inode 和修改后的文件的 inode 不同。

$ ls -i
268541 hello.txt
$ vi hello.txt
$ ls -i
268716 hello.txt
如上面的例子可以看到,经过 vim 编辑文件后,inode 从 268541 变为了 268716,这就是刚才说的,名字还是那个名字,文件已不是原来的文件了。

而 Docker 的 绑定宿主文件,实际上在文件系统眼里,针对的是 inode,而不是文件名。因此容器内所看到的,依旧是之前旧的 inode 对应的那个文件,也就是旧的内容。

这就出现了之前的那个问题,在宿主内修改绑定文件的内容,结果发现容器内看不到改变,其原因就在于宿主的那个文件已不是原来的文件了。

这类问题解决办法很简单,如果文件可能改变,那么就不要绑定宿主文件,而是绑定一个宿主目录,这样只要目录不跑,里面文件爱咋改就咋改。

多个 Docker 容器之间共享数据怎么办?NFS ?
如果是同一个宿主,那么可以绑定同一个数据卷,当然,程序上要处理好并发问题。

如果是不同宿主,则可以使用分布式数据卷驱动,让分布在不同宿主的容器都可以访问到的分布式存储的位置。如S3之类:

https://docs.docker.com/engine/extend/plugins/#volume-plugins


既然一个容器一个应用,那么我想在该容器中用计划任务 cron 怎么办?
cron 其实是另一个服务了,所以应该另起一个容器来进行,如需访问该应用的数据文件,那么可以共享该应用的数据卷即可。而 cron 的容器中,cron 以前台运行即可。

比如,我们希望有个 python 脚本可以定时执行。那么可以这样构建这个容器。

首先基于 python 的镜像定制:

FROM python:3.5.2
ENV TZ=Asia/Shanghai
RUN apt-get update \
&& apt-get install -y cron \
&& apt-get autoremove -y
COPY ./cronpy /etc/cron.d/cronpy
CMD ["cron", "-f"]
其中所提及的 cronpy 就是我们需要计划执行的 cron 脚本。

* * * * * root /app/task.py >> /var/log/task.log 2>&1
在这个计划中,我们希望定时执行 /app/task.py 文件,日志记录在 /var/log/task.log 中。这个 task.py 是一个非常简单的文件,其内容只是输出个时间而已。

#!/usr/local/bin/python
from datetime import datetime
print("Cron job has run at {0} with environment variable ".format(str(datetime.now())))
这 task.py 可以在构建镜像时放进去,也可以挂载宿主目录。在这里,我以挂载宿主目录举例。

# 构建镜像
docker build -t cronjob:latest .
# 运行镜像
docker run \
--name cronjob \
-d \
-v $(pwd)/task.py:/app/task.py \
-v $(pwd)/log/:/var/log/ \
cronjob:latest
需要注意的是,应该在构建主机上赋予 task.py 文件可执行权限。

如何初始化卷?
卷(Volume),是用于动态数据持久化的。因此其内存储的都是动态数据,运行时会变化。如果这里面需要初始化里面的数据,需要在运行时进行。或者在镜像里加入初始化的脚本,比如 mysql 镜像中的初始化目录中的脚本;或者自己单独制作纯粹用于初始化卷用的镜像,单独一次性运行以将初始化数据灌入卷中。

举个例子来说,假设你需要个卷 mydata,然后里面需要有个 hello.txt 文件是必须存在的,否则容器运行就要出大事儿了……(这需求很傻我知道,好吧,假设如此)。

当然,我们得先有这个卷。

docker volume create --name mydata
那怎么把这个超重要的 hello.txt 文件放入卷中呢?有几种办法。

正常挂载该 mydata 卷,然后 docker cp 进去

这是个很傻的办法,不过如果容器运行并不依赖于 hello.txt 的话,这样做是可以的。

$ docker run -d --name web -v mydata:/data nginx
$ docker cp ./hello.txt web:/data/
这样是先让容器启动,启动后,再把所需数据导入卷里面去。以后容器就可以使用 /data/hello.txt 文件了。

但是,如果容器是严重依赖于这个 hello.txt 文件的话,这样做就会出问题。容器会因为 hello.txt 文件不存在,而报错退出,导致根本没有 docker cp 的机会。

这种情况,我们可以变通一下。

$ docker run --rm \
-v $PWD:/source \
-v mydata:/data \
busybox \
cp /source/hello.txt /data/
$ docker run -d --name web -v mydata:/data nginx
这里我们先启动了一个 busybox 容器,分别挂载要复制的源以及目标的 mydata 卷,然后用 cp 命令将 hello.txt 复制到 mydata 中去。数据导入结束后,我们再正式挂载 mydata 卷到正式的容器上并启动。这个时候严重依赖 /data/hello.txt 的这个容器就可以顺利运行了。

专门制作初始化镜像

手动的去执行 docker cp,或者 docker run ... cp ... 并不是很正规。可以写个脚本让一切都标准化,但是,除了流程外,还需要确保当前环境中的初始化数据的版本必须是所期望的,否则初始化了错误的数据,也会让运行时状态达不到预期的效果。

因此,另一种办法是专门制作一个初始化卷的镜像,这样的做法也比较方便在 CI/CD 流程中对初始化数据的过程进行测试确认。

FROM busybox
COPY hello.txt /source/
VOLUME /data
CMD ["cp", "/source/hello.txt", "/data/"]
这样的镜像只有一个生存目的,就是挂载 mydata 卷,并且把数据导入进去。假设构建好的镜像名为 volume-prepare,只需要执行下面的命令就可以完成导入:

$ docker run --rm -v mydata:/data volume-prepare
在镜像的 Dockerfile 制作中,加入初始化部分

在之前的问答中我们已经了解到,官方镜像 mysql 中可以使用 Dockerfile 来添加初始化脚本,并且会在运行时判断是否为第一次运行,如果确实需要初始化,则执行定制的初始化脚本。

我们也可以使用这种方法将 hello.txt 在初始化的时候加入到 mydata 卷中去。

首先我们需要写一个进入点的脚本,用以确保在容器执行的时候都会运行,而这个脚本将判断是否需要数据初始化,并且进行初始化操作。

#!/bin/bash
# entrypoint.sh
if [ ! -f "/data/hello.txt" ]; then
cp /source/hello.txt /data/
fi
exec "$@"
名为 entrypoint.sh 的这个脚本很简单,判断一下 /data/hello.txt 是否存在,如果不存在就需要初始化。初始化行为也很简单,将实现准备好的 /source/hello.txt 复制到 /data/ 目录中去,以完成初始化。程序的最后,将执行送入的命令。

我们可以这样写 Dockerfile:

FROM nginx
COPY hello.txt /source/
COPY entrypoint.sh /
VOLUME /data
ENTRYPOINT ["/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
当我们构建镜像、启动容器后,就会发现 /data 目录下已经存在了 hello.txt 文件了,初始化成功了。

为什么说数据库不适合放在 Docker 容器里运行?
不为什么,因为这个说法不对,大部分认为数据库必须放到容器外运行的人根本不知道 Docker Volume 为何物。

在早年 Docker 没有 Docker Volume 的时候,其数据持久化是一个问题,但是这已经很多年过去了。现在有 Docker Volume 解决持久化问题,从本地目录绑定、受控存储空间、块设备、网络存储到分布式存储,Docker Volume 都支持,不存在数据读写类的服务不适于运行于容器内的说法。

Docker 不是虚拟机,使用数据卷是直接向宿主写入文件,不存在性能损耗。而且卷的生存周期独立于容器,容器消亡卷不消亡,重新运行容器可以挂载指定命名卷,数据依然存在,也不存在无法持久化的问题。

建议去阅读一下官方文档:

https://docs.docker.com/engine/tutorials/dockervolumes/
https://docs.docker.com/engine/reference/commandline/volume_create/
https://docs.docker.com/engine/extend/legacy_plugins/#/volume-plugins

如何列出容器和所使用的卷的关系?
要感谢强大的 Go Template,可以使用下面的命令来显示:

docker inspect --format '{{.Name}} => {{with .Mounts}}{{range .}}
{{.Name}},{{end}}{{end}}' $(docker ps -aq)
注意这里的换行和空格是有意如此的,这样就可以再返回结果控制缩进格式。其结果将是如下形式:

$ docker inspect --format '{{.Name}} => {{with .Mounts}}{{range .}}
{{.Name}}{{end}}{{end}}' $(docker ps -aq)
/device_api_1 =>
/device_dashboard-debug_1 =>
/device_redis_1 =>
device_redis-data
/device_mongo_1 =>
device_mongo-data
61453e46c3409f42e938324d7feffc6aeb6b7ce16d2080566e3b128c910c9570
/prometheus_prometheus_1 =>
fc0185ed3fc637295de810efaff7333e8ff2f6050d7f9368a22e19fb2c1e3c3f