其实本篇文章并不适合放在这里将,建议先阅读过 玩转Docker | 图解镜像与容器 之后再回头看该文章。

在学习 Dockerfile 指令构建镜像时,初学者大多会发现一个很让人迷惑不解的指令:Volume。之所以说这个指令让人迷惑不解的原因是与之相关的还有一个 docker volume 命令以及在运行容器时还有一个 docker run -v 命令。

实际上如果你对 Linux 文件目录结构以及文件挂在系统了解的话,那么 Docker 的 Volume 也自然而然的明白了。

如果你是新手,对 Linux 的文件挂在系统不熟悉的话。别急,现在就来慢慢的揭开这条遮羞布~

小提示:阅读本节你需要知道如何利用 Dockerfile 构建镜像,同时需要知道 Dockerfile 中的各个指令的用法。

一个小示例

先从一个小示例开始。我当前目录下有一个打好包的 springboot 项目:

  1. $ ls
  2. docker-web-demo.jar

现在就来写一个简单的 Dockerfile 示例,内容如下:

  1. FROM openjdk:8
  2. WORKDIR app
  3. ADD docker-web-demo.jar app.jar
  4. RUN mkdir /data
  5. ARG OPS="-Xms64m -Xmx256m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:/data/gc.log"
  6. ENV JAVA_OPS=$OPS
  7. ENTRYPOINT ["/bin/bash", "-c", "java $JAVA_OPS -jar app.jar > /data/app.log"]

看下这个 Dockerfile 文件内容,首先我们使用 RUN 指令在根目录创建了一个 /data 目录。之后在 JVM 参数值输出了 GC 日志,该日志输出到了 /data 目录下的 gc.log 文件中。

再看看 ENTRYPOINT 指令做了什么事,在运行 java 服务时我们使用 > 语法将输出的日志都记录到了 /data 目录下 app.log 文件中了。

为什么使用重定向 **>** 将日志输出到 /data/app.log ,而不是 Java 的日志系统输出日志呢?这里主要是为了方便,慢慢来,之后会继续做说明。

现在我们构建并运行这个镜像:

$ docker build -t web:v5 .

$ docker run web:v5

注意看 docker run 命令,我没有使用 -d 参数让他在后台跑。

想下在正常情况下,如果不在后台跑服务的话,在控制台上应该会输出日志信息才对?看下现在的情况:

image.png

你会发现回车之后命令出于“假死”状态,实际上并不是假死。服务是在正常运行的,之所以这样原因是我们将日志全部重定向到 /data/app.log 文件中了,所以控制台上没有日志输出。

那现在怎么去看这个日志呢?

你可能会想到使用 docker container logs $container_id 命令去看这条日志,很抱歉。也是看不了的,因为日志已经被我们重定向了~

现在有一个办法,就是进入容器中去看日志:

image.png

在实际生产环境也不可能去这么查看日志对吧?如果在实际中这么玩不觉得这个 docker 开发的太傻比了吗?

那怎么解决该问题呢?现在就有了 -v 参数的用途了。

使用 -v 命令实现宿主机与容器文件挂载

-v 挂载是什么意思?

简单地说就是一个挂载点,绑定容器和宿主机之间的目录或文件的映射关系。如果对 Linux 文件挂载系统有一定了解的话这里就很容易理解。

-vdocker run 命令的一个可选参数,看下 docker 对该参数的解释:

-v, --volume list                    Bind mount a volume
    --volume-driver string           Optional volume driver for the container
    --volumes-from list              Mount volumes from the specified container(s)

意思应该都能看懂,那到底怎么去使用呢?下面是 -v 的基本语法:

docker run -v [host-dir:]container-dir

docker run -v [VOLUME_NAME:]container_path

第一条语法是实现宿主机的目录到容器目录的映射,第二条语法是宿主机卷 volume 到容器目录的映射(注意,这里是宿主机 volume)。

注意:在使用 **-v** 挂载时 **:** 前面的一定要是宿主机的目录或者卷,后面的才是容器的目录。

现在来一个一个讲解:

宿主机目录到容器目录映射

在上面的示例中,在容器中将日志输出到了 /data 目录下了,现在我们就将该目录映射到我们宿主机目录。

首先呢,在宿主机上随便创建一个目录,比如在 /opt 目录下创建一个 /data 目录:

sudo mkdir -p /opt/data

之后我们就是使用 -v 参数实现宿主机目录到容器目录的映射:

$ docker run -d -v /opt/data/:/data web:v5

现在来看下宿主机 /opt/data 目录下发生了什么:

image.png

快看,宿主机上多出了两个文件!

这意思是将容器中 /data 目录下的文件,全部 “拷贝” 到宿主机我们指定的 /opt/data 目录下了?

这就是 docker 文件挂载!!!

你如果使用命令去查看这两个文件的内容的话你就会发现这个日志是实时输出的,

之前想要查看日志似乎很难,不过现在……

相信到这里各位应该就能体会到了 -v 挂载的作用以及在实际中该怎么去用它。现在再回头看下宿主机 volume 如何实现相同功能的。

宿主机volume 到容器目录映射

关于 volume 到底是什么意思先不管,先来看下在宿主机上如果管理 volume。

docker 提供了管理 volume 的相关命令,可以使用 docker volume --help 进行查看:

Usage:  docker volume COMMAND

Manage volumes

Commands:
  create      Create a volume
  inspect     Display detailed information on one or more volumes
  ls          List volumes
  prune       Remove all unused local volumes
  rm          Remove one or more volumes

现在就来创建一个 VOLUME:

$ docker volume create vol_logs

之后使用 inspect 命令看下我们创建的卷 vol_logs 的信息:

$ docker volume inspect vol_logs
[
    {
        "CreatedAt": "2021-07-01T01:40:22-04:00",
        "Driver": "local",
        "Labels": {},
        "Mountpoint": "/var/lib/docker/volumes/vol_logs/_data",
        "Name": "vol_logs",
        "Options": {},
        "Scope": "local"
    }
]

其中很重要的信息是 Mountpoint,这个指定的是宿主机器的目录(由Docker自动创建)。在之后的示例中你会发现 -v 参数所谓的宿主机卷到容器目录的映射实际上就是该目录到容器目录的映射,只不过这个目录是由 docker 帮我们管理。可以理解为 volume 就是 docker 对宿主机目录做的抽象或者封装。

想下 docker 为什么要多此一举呢?都是目录到目录的挂载,为什么要单独抽象一层 volume 呢?

说到底还是为了方便,如果宿主机只有一个容器还好,挂载的目录我们一眼就看出来的,但是如果宿主机启动了十个、百个容器该怎么办呢?这么一看 volume 的优势就体现出来了。

因为 volume 是由我们自己创建的,volume 的名称我们完全可以根据业务名进行定义。这样服务就“显示”的与 volume 有了一层绑定关系,是不是更便于数据管理呢?

现在我们来看下具体的应用。

$ docker run -d -v vol_logs:/data web:v5

看下这个命令,这回宿主机我使用的不再是目录而是刚刚创建的一个 volume。

前面说了,docker volume 实际上就是卷的挂载点(Mountpoint)到容器目录的映射,那到底是不是呢?如果真的是这样,那么这个卷的挂载点目可以下肯定会有 app.log 和 gc.log 这两个文件。现在看下这个目录下到底有没有这两个文件:

image.png

事实胜于雄辩,现在就没有啥可争议的了。

现在如果我们在使用 docker container inspect $container 命令查看容器的挂载信息的话就会看到容器与卷的绑定关系了。

比如上面运行的容器 id 是 bfbdf9c6c1bd

$ docker container inspect bfbdf9c6c1bd

image.png

通过这个命令,你也可以看到该容器与卷的绑定关系了。比如上面的 Name,表示的就是卷的名称。Source 表示的就是卷在宿主机上的目录,Destination 表示的就是容器中的目录。另外还有一个非常重要的信息,就是 RW,这个表示当前卷的读写权限,为 true 就表示可写。这个是一个很重要的东西,以后再慢慢说~

基本上 -v 参数与卷就这么说,现在接着往下看:

关于 Dockerfile 中的 VOLUME 指令说明

你有没有注意过在官网的 Dockerfile 的教程中,在该文件中也可以使用 VOLUME 指令?基本上是如下所示:

FROM openjdk:8

WORKDIR app

ADD docker-web-demo.jar app.jar

RUN mkdir /data

VOLUME ["/data"]

ARG OPS="-Xms64m -Xmx256m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:/data/gc.log"

ENV JAVA_OPS=$OPS

ENTRYPOINT ["/bin/bash", "-c", "java $JAVA_OPS -jar app.jar > /data/app.log"]

需要特别说明的一点是:如果使用了 VOLUME 指令指定了目录,那么即使该目录在容器中不存在在运行时也会自动创建,也就是说上面的 RUN 指令可以不写,即:

FROM openjdk:8

WORKDIR app

ADD docker-web-demo.jar app.jar

VOLUME ["/data"]

ARG OPS="-Xms64m -Xmx256m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:/data/gc.log"

ENV JAVA_OPS=$OPS

ENTRYPOINT ["/bin/bash", "-c", "java $JAVA_OPS -jar app.jar > /data/app.log"]

那么这个 VOLUME 指令到底是什么意思呢?其实就是创建一个卷(VOLUME),而此卷与之前说的卷有些区别。

这个卷其实是隐式的创建的,意思就是当我们这个镜像构建完成并运行成容器之后,docker 会隐式的在宿主机上创建一个卷与容器目录 /data 进行映射。而卷的名称是随机的,一般是一串很长的ID。为了说明直接来构建一个镜像并运行看看:

$ docker build -t web:v6 .

$ docker run -d web:v6

运行之后再使用 docker volume ls 命令查看宿主机上的卷你就会发现多了一个,卷的名称是一串很长的随机ID:

image.png

到这里也没必要多说了,总的来说感觉得有鸡肋,因为我们完全可以使用 -v 进行显示的指定卷为什么非要使用隐式的呢?

关于为什么 Dockerfile 中的实际用途是什么我搜索到一篇帖子 Dockerfile中VOLUME的实际用途是什么?,有兴趣的可以去看下,这里就不再赘述了。

现在来看下 volume 的高级使用。

-v 的高级使用

前面说了那么多无伤大雅的东西其实都是为了这里做铺垫,因为这里才是实际中的具体应用,相信看到下面的示例之后你就会豁然开朗,对 docker 的文件挂载会有更深层次的理解。

这次同样使用 springboot 项目做测试,不过这次打包的 jar 文件中没有配置文件,即在 pom.xml 文件中增加下面的配置来实现打包时排除掉 resources 目录下的所有 properties 文件:

<build>
  <resources>
    <!-- 打包时过滤掉所有 *properties 文件 -->
    <resource>
      <directory>src/main/resources</directory>
      <excludes>
        <exclude>*.properties</exclude>
      </excludes>
      <includes>
        <include>application.properties</include>
      </includes>
    </resource>
  </resources>
</build>

打包后的文件是:docker-web-without-config.jar。现在来编写 Dockerfile:

FROM openjdk:8

WORKDIR app

ADD docker-web-without-config.jar app.jar

RUN mkdir config

ARG OPS="-Xms64m -Xmx256m"

ENV JAVA_OPS=$OPS

ENTRYPOINT ["/bin/bash", "-c", "java $JAVA_OPS -jar app.jar -Dspring.config.location=config/"]

看下这个 Dockerfile 与之前有什么区别:

1) 创建了一个 config 目录,由于在上面使用了 WORKDIR 指令设置了工作目录是 app,所以这个 config 目录全路径是:/app/config

2) 在启动 JAVA 服务时使用了 JVM 参数 -Dspring.config.location= 进行指定外部配置文件。这是 springboot 相关的知识点,如果不知道的话可以面向百度了解一下。

另外要说的一点是,上面 -Dspring.config.location 使用的是相对路径,也可以指定绝对路径:/app/config/

现在想一个问题,我们即使使用上面的 Dockerfile 构建了一个镜像,但是 config 目录下有配置文件吗?肯定是没有的,那我们该怎么去指定这个配置文件呢?

别急,继续看。

在宿主机上创建一个 config 目录,编写三个 properties 配置文件,分别是:

application.properties:

# 应用名称
spring.application.name=docker-web

# 应用服务 WEB 访问端口
server.port=8080

# 默认激活 local 配置文件
spring.profiles.active=local

application-local.propertie:

i18n.title=本地化
spring.jackson.time-zone=GMT+8

application-i18n.propertie:

i18n.title=国际化

这三个配置文件没有什么好讲的,先看下当前目录下的内容:

$ ls
config    Dockerfile  docker-web-without-config.jar

$ ls config/
application-i18n.properties  application-local.properties  application.properties

现在开始构建镜像:

docker build -t web_no_config:v1 .

之后开始运行镜像,注意下下面的 -v 参数:

$ docker run -d -p 8080:8080 -v /opt/app/config/:/app/config/ web_no_config:v1

-v 参数中,我们指定了宿主机与容器目录的映射。宿主机目录 /opt/app/config/ 下就是我们定义的三个配置文件,而 /app/config/ 就是容器指定的配置文件目录。

这样,当我们运行这个容器时,在运行阶段 docker 就会将宿主机目录 /opt/app/config/ 下的所有文件全部 “拷贝” 到容器目录 /app/config/下。而我们在容器中启动的 JAVA 服务指定的配置文件目录就是 /app/config/,这是不是就达到我们要的效果了呢?

回车后运行容器。

那现在该怎么验证呢?有两种方式,第一种看容器日志,第二种直接发起 http 请求。先使用 docker container logs $container 命令看下容器日志:

image.png

看下启动日志中激活的配置文件,是不是就是我们默认激活的配置文件 local?如果还不信可以继续发起 HTTP 请求:

$ curl -XGET localhost:8080

# 输出
{
  "name" : "张三",
  "age" : 18,
  "i18nTitle" : "本地化",   <===== 看下这里
  "date" : "2021-07-03",
  "time" : "05:57:29",
  "dateTime" : "2021-07-03 05:57:29"
}

现在似乎没什么疑问了,那现在我要是想要使用 i18n 这个配置文件该怎么办呢?这个简单,在 Dockerfile 中我们不是定义了一个环境变量 JAVA_OPS 吗?而且在启动 JAVA 服务时也使用了这个环境变量,所以只需要在运行容器时使用 -e 重新定义 JAVA_OPS ,并指定 spring.profiles.active 是不是就可以了呢?

看下示例:

$ docker run \
-d \
-p 8081:8080 \
-e "JAVA_OPS=-Xms64m -Xmx256m -Dspring.profiles.active=i18n" \
-v /opt/app/config/:/app/config/ \
web_no_config:v1

注意上面的端口映射(**-p 8081:8080**),由于之前启动的容器并没有停止。而且端口已经映射到了宿主机 8080,所以就无法再次映射到 8080,这次映射到 8081。

启动之后先看下容器的环境变量 JAVA_OPS:

$ docker exec 27ad62329949 env

image.png

从环境变量中看似乎已经生效了,眼见为实看下启动日志:

image.png

嗯… 似乎很完美,在使用 HTTP 请求试下:

$ curl -XGET localhost:8081

# 输出
{
  "name" : "张三",
  "age" : 18,
  "i18nTitle" : "国际化", <===== 看下这里
  "date" : "2021-07-03",
  "time" : "06:17:47",
  "dateTime" : "2021-07-03 06:17:47"
}

现在,我又有了一个需求,想要将容器的日志持久化到宿主机上,现在又该怎么办呢?

当然是使用卷(VOLUME)了~

先改下配置文件 **application.properties**,配置日志输出目录以及日志文件名:

# 应用名称
spring.application.name=docker-web

# 应用服务 WEB 访问端口
server.port=8080

spring.profiles.active=local

# 增加日志目录
logging.path=/var/log/${spring.application.name}
logging.file=${logging.path}/app.log

Dockerfile 就不需要修改了,因为 log 目录 JAVA 服务会自动创建。

在宿主机上先创建一个卷(volume),名字就叫做 vol_web_log:

$ docker volume create vol_web_log

之后使用 inspect 命令看下这个卷在宿主机上存储的目录信息:

$ docker volume inspect vol_web_log

[
    {
        "CreatedAt": "2021-07-03T02:23:51-04:00",
        "Driver": "local",
        "Labels": {},
        "Mountpoint": "/var/lib/docker/volumes/vol_web_log/_data",
        "Name": "vol_web_log",
        "Options": {},
        "Scope": "local"
    }
]

所以,如果使用该卷与容器做映射之后所有的日志信息都会存储在宿主机的 /var/lib/docker/volumes/vol_web_log/_data 目录下对吧。

现在就来运行容器:

$ docker run \
-d \
-p 9000:8080 \
-e "JAVA_OPS=-Xms64m -Xmx256m -Dspring.profiles.active=i18n" \
-v /opt/app/config/:/app/config/ \
-v vol_web_log:/var/log/docker-web/ \
web_no_config:v1

这次的运行命令中有两个 -v 参数进行目录挂载,第一个 -v 的作用是实现宿主机上的配置文件到容器目录的映射。第二个 -v 的作用将容器中的目录到宿主机卷的映射。

回车运行服务,启动日志就不需要看了,肯定会使用到配置文件 i18n 的,我们要看的是日志到底有没有映射到宿主机卷 vol_web_log。看下该卷具体的目录:

$ ls /var/lib/docker/volumes/vol_web_log/_data
app.log

Perfect~

总结

好吧,最后还是做下总结吧。

Docker 镜像实际是是多个只读层堆叠起来的产物,而容易仅仅是在镜像之上增加了一个读写层。不过默认情况下容器停止或销毁之后相应的数据也会丢失,所以为了解决该问题 Docker 提供了 VOLUME 即卷。

卷的主要作用是数据持久化,同时还有目录挂载的作用,所谓的挂载就是将宿主机目录与容器目录实现映射。这样,容器中的目录如果产生数据文件也会同步映射到宿主机上的目录(实际上之后宿主机的目录有数据)。

同时呢,如果宿主机上的目录发生数据变更也会同步映射到容器中。这样我们就可以使用在不停止容器的情况下实现数据的变更,最典型的应用就是 Nginx 的 HTML 文件。如果我们修改了宿主机上的 HTML 文件,容器是实时生效的。

另一个作用呢是实现容器间的数据共享,什么意思呢?就是说多个容器可以同时映射到宿主机的同一个目录,说到这里相信也明白该怎么去玩了。

总结下来呢,就是挺花里胡哨的~

而实现目录的映射主要有两种方式:

第一种是在第一次运行容器时使用 -v 参数进行指定挂载的目录,个人更称之为显示映射。
第二种是在 Dockerfile 中使用 VOLUME 指令实现目录挂载,因为这种方式在容器第一次启动时实际上会在宿主机上隐式的创建一个 volume,所以个人比较喜欢称它之为隐式映射。同时呢,笔者觉得这种映射方式比较鸡肋,更多的仅仅是实现容器数据的持久化,如果想要实现数据共享需要使用 --from 参数进行设置。

当然呢,隐式的 VOLUME 还是有一个典型的用例的。比如如果你构建的是一个 Gitlab 镜像,一般这种镜像只需要构建一个就好,没有需要数据共享的目的。而且产生的数据体量一般也比较大,所以目录越深越好。而 Docker 就帮我们做了这点,对于不知道的目录我们删除时一般都比较保守小心,所以我个人觉得隐式的 volume 在这里用处还是比较大的。

好了,基本上就这么多吧。

完结,撒花💫