文章目录
本篇博客旨在分享 Dockerfile 的使用技巧,无论你是刚接触 Docker 还是已经使用了一段时间,相信在看完本篇博客后你也能使用 Dockerfile 创建属于你自己的镜像。
本篇博客的主题如下:
- Dockerfile 简介以及使用 Dockerfile 的好处
- Dockerfile 命令介绍
- 案例分享:从 0 开始构建并运行一个 Spring Boot 项目镜像
什么是 Dockerfile
Dockerfile 是一个包含构建自定义 Docker 镜像指令的脚本,每个 Dockerfile 中的指令都会创建镜像中的一个新层,最终镜像是由这些层依次叠加组成的。
Dockerfile 的优点可以概括为:简单易读、自动化构建、可重复性。Dockerfile 使用的语法简单易于理解,可以使用文本编辑器创建和编辑;一旦 Dockerfile 被创建,就可以使用 docker build 命令自动创建镜像;通过 Dockerfile ,可以确保每次构建的镜像具有相同的环境和配置,确保了可重复性。
Dockerfile 包含一些列命令用于安装基础镜像(FROM)、拷贝文件 (COPY)、设置环境变量 (ENV)等操作。每个指令为镜像添加一个新层,每一层包含前一层中指定的指令,最终镜像由 Dockerfile 中指定的所有指令构建而成。
我认为使用 Dockerfile 的好处有如下几点:
- Dockerfile 可以更快、更高效地部署应用程序,一旦构建了 Docker 镜像,可以在任何支持 Docker 的环境中进行部署(Linux、Mac、Windows Docker Desktop等)。
- 大厂的开发-测试-发布流程中,会使用 Dockerfile 与 CI/CD 流水线集成。开发人员每次代码变更 push 到远程 git 仓库后,自动触发编译、Dockerfile 构建镜像、部署应用程序到实例这一流水线。这减少了编译、部署过程人为操作的错误,极大提升了开发上线的效率。
- Dockerfile 可以用于自动化测试,为不同的服务构建、运行测试环境,每次测试无需手动设置和配置。
Dockerfile 命令详解
FROM
FROM 命令指定将在其上构建容器的基本镜像,该指令通常是 Dockerfile 中的第一个指令,用于设置容器的基本映像。命令格式如下:
Dockerfile FROM <image>
例如:
FROM java
该指令告诉 Docker 使用java
镜像作为容器的基础镜像,如果需要指定基础镜像的版本,可以加上:version
记号,例如java:8-jre-alpine
。
WORKDIR
在 Dockerfile 中,WORKDIR 指令为 Dockerfile 中在它之后的所有命令设置工作目录,这意味着在容器中运行的任何命令都将相对于指定的目录执行。
例如:
WORKDIR /home/root
该指令指示 Docker 将容器的工作目录设置为/home/root
,随后的诸如COPY
、RUN
等命令将在该目录下执行。(注:如果目录不存在,WORKDIR
将会创建该目录)
COPY vs ADD
COPY 指令从宿主机的上下文目录中复制文件到容器文件系统的指定路径下。命令格式为:
COPY [--chown=<user>:<group>] <source_path1>... <target_path>
- source_path:源文件或源目录,相对于 docker build 命令中指定的上下文路径。
- target_path:容器内的指定路径,该路径不需要事先建好,路径不存在会自动创建。
例如,我需要复制上下文路径下的 target/api-gateway-center.war
文件到容器根目录下:
COPY target/api-gateway-center.war /api-gateway-center.war
如果需要复制宿主机当前目录下的所有文件到容器内的工作目录,可以使用如下指令:
COPY . .
宿主机上下文路径目录:
容器使用 WORKDIR 设置工作目录为/home/root
,使用 docker exec 进入容器查看该目录下的文件:
ADD 指令与 COPY 指令类似,还具有解压缩功能,如果源文件是 gzip、bzip2 等格式的压缩文件,ADD 指令会自动复制并解压缩到目标路径下。
这也意味着无法在不解压的前提下复制 tar 压缩文件,一般情况下推荐使用 COPY 指令复制文件到 Docker 容器中。
RUN
RUN 指定在镜像构建过程中执行的命令,该命令有两种格式:
- shell 格式
RUN <命令行>
- exec 格式:
RUN ["executable file", "arg1", "arg2"]
例如 ln -snf /usr/share/zoneinfo/$TZ /etc/localtime
命令:
# shell 格式
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime
# exec 格式
RUN["ln", "-snf", "/usr/share/zoneinfo/$TZ", "/etc/localtime"]
Docker 指令每一次运行,都会在原先的镜像上新建一层,为了避免过多无意义的层,当存在多个命令需要使用 RUN 指令运行时,尽量选择&&
符号连接命令,这样仅创建 1 层镜像。
ENV
ENV 指令用于在镜像中设置环境变量,这些变量将在构建时和运行中的容器中可用。设置格式为
ENV <key> <value>
ENV <key1>=<value1> <key2>=<value2>...
以下示例设置环境变量JAVA_OPTS
虚拟机参数:
ENV JAVA_OPTS="-Xmx512m -Xms256m"
CMD vs ENTRYPOINT
CMD指令:
在Dockerfile中,CMD 指令设置启动镜像运行容器默认要执行的程序,程序运行结束,容器也就结束。
这里需要与 RUN 区分,RUN 指令在镜像构建阶段(docker build)执行,而 CMD 是在镜像部署阶段(docker run)执行。
CMD 有两个需要关注的特点,我将在随后演示一个案例加以说明:
- 特点一:Dockerfile 中如果存在多个 CMD 指定,仅生效最后一个;
- 特点二:docker run 命令行参数中指定运行的程序可以覆盖 Dockerfile CMD 指定运行的程序。
案例:下面是 docker-demo 镜像 Dockerfile 文件的最后两行,使用了 CMD 命令指定容器运行的命令。
echo 命令用于回显输入参数到控制台输出;tail 等待从空设备/dev/null
读取数据,因此会一直阻塞:
CMD ["echo", "hello world"]
CMD ["tail", "-f", "/dev/null"]
docker build 构建镜像后,docker run 运行镜像:
docker run --name docker-demo -d dockerfile-demo:1.0
随后,使用 docker inspect 命令查看 docker 容器启动时执行的命令:
docker inspect --format '{{.Config.Cmd}}' dockerfile-demo:1.0
执行结果如下,容器运行的是 tail 命令,echo 命令被覆盖(特点一):
我们不修改dockerfile-demo:1.0
镜像,而是使用 docker run 运行第二个容器:
docker run --name docker-demo-02 -d dockerfile-demo:1.0 sleep 300
注意,我在命令行参数末尾添加了sleep 300
让容器主进程睡眠 300 秒。然后,我使用 docker inspect 命令查看,果然 CMD 指定的 tail 命令被 docker run 指定的 sleep 命令覆盖(特点二):
ENTRYPOINT 指令:
类似于 CMD 指令,但是其不会被 docker run 命令行参数指定的指令覆盖,这些命令行参数将作为参数传递给 ENTRYPOINT 指令指定的程序 。
假设 Dockerfile 中的命令为:
ENTRYPOINT [ "sleep" ]
构建镜像后,使用 docker run 运行:
docker run --name docker-demo -d dockerfile-demo:1.0 300
docker exec 进入 docker-demo 容器,使用 ps 命令查看 1 号进程,进程指令为sleep 300
:
案例:Docker部署Spring Boot项目
本节,我将介绍如何将 Spring Boot 项目打包为镜像,从而部署到容器中。项目演示目录如下:
步骤一:编写 Dockerfile
# 基础镜像
FROM openjdk:8-jre-slim
# 镜像作者
MAINTAINER wzz
# 时区
ENV TZ=PRC
# 创建链接
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 拷贝文件
COPY target/api-gateway-center.war /api-gateway-center.war
COPY application_additional.yml /application_additional.yml
# 设置镜像执行的命令, 使用 exec 命令确保 java 程序为容器 1 号进程
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar api-gateway-center.war $PARAMS"]
- FROM 指令:引入 java 8 运行环境作为基础镜像;
- ENV 指令:声明环境变量 TZ,即将时区指定为中国;
- RUN 指令:在镜像构建时,执行 ln、echo 命令,将时区配置写入
/etc/localtime
文件; - COPY 指令:将相对于上下文路径下的 SpringBoot 额外配置文件 application_additional.yml 和 war 包
target/api-gateway-center.war
复制到容器根目录下。(上下文路径在步骤二详细解释) - ENTRYPOINT 指令:指定镜像执行的命令,使用 sh 程序的原因是它能够解析环境变量
$JAVA_OPTS
和$PARAMS
环境变量。
而按照如下方式设置,Docker 会尝试执行 java 命令,$JAVA_OPTS
被视为 java 程序的参数,而不是环境变量。# 错误的写法 ENTRYPOINT ["java","-$JAVA_OPTS","-jar","api-gateway-center.war","$PARAMS"]
步骤二:构建镜像
如果当前所在的目录为 Dockerfile 文件的存放目录,执行如下构建 api-gateway-center:1.0.1
镜像,镜像名称为 api-gateway-center:
,标签为 1.0.1
,上下文路径为当前目录.
,
docker build -f ./Dockerfile -t api-gateway-center:1.0.1 .
Docker 的运行模式是 server-client 架构,宿主机为客户端,docker 引擎为服务端。构建过程在 Docker 引擎下完成的,无法用到宿主机的文件,因此需要将宿主机指定目录下的文件打包提供给 docker 引擎,这些文件就是 Docker 上下文路径 下所有文件。
上下文路径下的多余文件 不会自动打包到容器中。上下文路径是 Docker 构建过程中 Docker 守护进程可以访问的文件和目录集合。虽然这些文件对构建过程可见,但只有通过 Dockerfile 中的指令(例如 COPY)显式指定的文件和目录会被添加到构建的镜像中。
步骤三:运行 Docker 镜像
docker run -p 80:80 --name api-gateway-center --network api_gateway_net \
-v /home/wsxzei/software/docker/volumns/nginx/conf/nginx.conf:/etc/nginx/nginx.conf \
-e JAVA_OPTS=-Dspring.config.additional-location=/application_additional.yml \
-d api-gateway-center:1.0.1
--network
:将容器加入到名为 api_gateway_net 的网络。注:如果该网络不存在,docker 会报错。若网络不存在,需要执行docker network create api_gateway_net
创建。-p
:端口映射,冒号前为宿主机端口,冒号后为容器内的端口,即宿主机上访问 80 端口的网络请求会被交给容器的 80 端口。-d
:后台运行容器;--name
:指定容器名称,本例中名称为 api-gateway-center。-v
:挂载配置文件 nginx.conf 到容器中,便于 SpringBoot 应用刷新另一个 Nginx 容器配置。冒号前为宿主机文件路径,冒号后为挂载到容器中的路径。
Docker部署的Java Spring应用如何优雅关闭
对于基于 Spring 框架的 Java 应用,有时需要监听 Spring 上下文关闭事件 org.springframework.context.event.ContextClosedEvent
,进行一些”善后“工作。例如:监听端口的优雅关闭、网关实例下线事件发布等。
对于 Docker 容器化部署的 Java 应用,我之前想当然地认为 docker stop 或 docker kill 关闭容器时,Spring 上下文关闭事件监听器会被回调。但事实是不一定发生,这取决于 Dockerfile CMD 或 ENTRYPOINT 命令指定的容器运行程序:
case1:使用sh -c exec java
运行
ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -jar api-gateway-center.war $PARAMS"]
case2:使用sh-c java
运行
ENTRYPOINT ["sh","-c","java $JAVA_OPTS -jar api-gateway-center.war $PARAMS"]
先说结论:case1 运行的 Spring 项目能正常发布 ContextClosedEvent,监听器会执行处理函数;而 case2 ContextClosedEvent
监听器却没有被调用。
原因:
当使用docker stop命令时,Docker 会向容器的主进程发送SIGTERM
信号,给予它机会进行优雅的关闭。
当使用 case2 的方式运行 Docker 容器时,容器的主进程(1号进程)是一个 shell 进程 sh -c java $JAVA_OPTS -jar /api-gateway-engine.jar $PARAMS
,而 Java 进程(Spring 应用) 是该shell进程的子进程。
这意味着SIGTERM
信号被发送到 shell 进程,而不是发送到 Java进程。这导致容器退出时,Java Spring 应用没有机会发布ContextClosedEvent
事件。
在 Linux 操作系统中,如果父进程被杀死,它的子进程会成为孤儿进程,被init进程(进程号为1)收养。
而使用 case1 sh -c "exec java"
命令时,exec
命令告诉 shell 用 Java 进程替换当前的 shell 进程,而不是创建一个新的子进程。因此,在exec java执行后,Java进程就取代了 shell 成为了容器的PID 1 进程。当使用 docker stop 命令时,SIGTERM
信号将被 Spring 应用接收,从而发布 ContextClosedEvent 优雅地关闭。
验证理论:
case1 容器内 ps 命令运行结果:java 应用为容器 1 号进程
case2 容器内的 ps 命令运行结果:sh 为 1 号进程,java 为 1 号进程的子进程