文章目录
前后写了三篇 Dockerfile,感觉都不够详细,这里整合一下,完善一下。
Dockerfile 是什么?
Dockerfile 是用来 构建 Docker 镜像 的文本文件,是由一条条构建镜像所需的指令和参数构成的脚本。
有 docker commit,为什么还要 DockerFile ?
此前我构建镜像都是使用 docker commit,简单又明确。但是后来我就犯迷糊了,commit 出来的镜像都是什么啊,那个描述信息真的会有人很详细的去写?弄多了自己都记不住了。而且,commit 到底打包了些什么东西啊?我后来突然意识到。是像虚拟机快照那样吗?会把当时的容器状态全都打包进去吗?还是说只是单纯的打包一下当时的文件?
docker commit 一个很不方便的地方就在于,难以回顾它是怎么来的,比方说我这里使用 commit 构建了一个 CentOS + vim 镜像,查看它的构建历史发现:
[root@centos7 ~]# docker history centos-vim:v1.0
IMAGE CREATED CREATED BY SIZE COMMENT
8df43092cfd1 6 minutes ago /bin/bash 140MB
说真的,这要不是我自己创建的,我还真不知道它有些什么。而且,就算是我自己创建的,我也仅是知道它有 CentOS + vim,至于还有没有点别的什么,不知道啊!!!
docker commit 的弊端:
如果是安装软件包、编译构建,那会有大量的无关内容被添加进来,如果不小心清理,将会导致镜像极为臃肿。
此外,使用 docker commit 意味着所有对镜像的操作都是黑箱操作,生成的镜像也被称为 黑箱镜像,换句话说,就是除了制作镜像的人知道执行过什么命令、怎么生成的镜像,别人根本无从得知。而且,即使是这个制作镜像的人,过一段时间后也无法记清具体在操作的。虽然 docker diff 或许可以告诉得到一些线索,但是远远不到可以确保生成一致镜像的地步。这种黑箱镜像的后期维护工作是非常痛苦的。
而且,镜像所使用的分层存储,除当前层外,之前的每一层都是不会发生改变的,换句话说,任何修改的结果仅仅是在当前层进行标记、添加、修改,而不会改动上一层。如果使用 docker commit 制作镜像,以及后期修改的话,每一次修改都会让镜像更加臃肿一次,所删除的上一层的东西并不会丢失,会一直如影随形的跟着这个镜像,即使根本无法访问到。这会让镜像更加臃肿。
docker build 方式镜像生成新的镜像:
docker build的方式生成新镜像的前提条件是有一个旧的基础镜像,在此基础上通过docker build 命令执行dockerfile 文件从而生成一个新的镜像,不同于docker commit,是镜像–> 镜像的转化。当然,是否转化正确是需要将镜像 docker run起来的哦。
Dockerfile的优点:
能够自由灵活的与宿主机联系,比如,某些配置文件在宿主机验证并使用过后很好用,那么,可以将文件copy到镜像中,(这个动作是写在dockerfile里),add 远程主机的配置文件到镜像中,定义onbuild动作等等各种灵活的功能。docker commit不能做到这些事情,因为是在一个封闭的在运行中的容器中,无法做复制拷贝宿主机文件的事情。
dockerfile本身就是一个比较详细的构建文档,有这个文档就可以清楚的知道新构建的镜像经历了怎样的变化。没有黑箱操作的困扰了,后期的维护更为方便了。
后期可扩展性强,一个文件就可以在哪都可以运行镜像了。(前提有网,有安装docker环境)
DockerFile 怎么写?
FROM && RUN
先来看一个最简单的示例:
FROM ubuntu:latest
RUN apt update \
&& aptinstall -y vim \
&& apt clean all
FROM:定制的镜像都是基于 FROM 的镜像,这里的 nginx 就是定制需要的基础镜像。后续的操作都是基于 nginx。
RUN:用于执行后面跟着的命令行命令。有以下俩种格式:
shell 格式:
RUN <命令行命令>
# <命令行命令> 等同于,在终端操作的 shell 命令。
exec 格式:
RUN ["可执行文件", "参数1", "参数2"]
# 例如:
# RUN ["./test.php", "dev", "offline"] 等价于 RUN ./test.php dev offline
开始构建镜像
1、新建一个目录,将 Dockerfile 文件存放在目录下。
2、在 Dockerfile 文件的存放目录下,执行构建动作。
注:最后的 . 代表本次执行的上下文路径,后面会介绍。
$ docker build -t testbuild .
该命令会寻找当前路径下名为 Dockerfile 的文件。
上下文路径
上下文路径,是指 docker 在构建镜像,有时候想要使用到本机的文件(比如复制),docker build 命令得知这个路径后,会将路径下的所有内容打包。
解析:由于 docker 的运行模式是 C/S。我们本机是 C,docker 引擎是 S。实际的构建过程是在 docker 引擎下完成的,所以这个时候无法用到我们本机的文件。这就需要把我们本机的指定目录下的文件一起打包提供给 docker 引擎使用。
如果未说明最后一个参数,那么默认上下文路径就是 Dockerfile 所在的位置。
注意:上下文路径下不要放无用的文件,因为会一起打包发送给 docker 引擎,如果文件过多会造成过程缓慢。
其他指令详解
COPY
复制指令,从上下文目录中复制文件或者目录到容器里指定路径。
格式:
COPY [--chown=<user>:<group>] <源路径1>... <目标路径>
COPY [--chown=<user>:<group>] ["<源路径1>",... "<目标路径>"]
[--chown=<user>:<group>]
:可选参数,用户改变复制到容器内文件的拥有者和属组。
<源路径>
:源文件或者源目录,这里可以是通配符表达式,其通配符规则要满足 Go 的 filepath.Match 规则。例如:
COPY hom* /mydir/
COPY hom?.txt /mydir/
<目标路径>
:容器内的指定路径,该路径不用事先建好,路径不存在的话,会自动创建。
ADD
ADD 指令和 COPY 的使用格类似(同样需求下,官方推荐使用 COPY)。功能也类似,不同之处如下:
ADD 的优点:在执行 <源文件> 为 tar 压缩文件的话,压缩格式为 gzip, bzip2 以及 xz 的情况下,会自动复制并解压到 <目标路径>。
ADD 的缺点:在不解压的前提下,无法复制 tar 压缩文件。会令镜像构建缓存失效,从而可能会令镜像构建变得比较缓慢。具体是否使用,可以根据是否需要自动解压来决定。
CMD
类似于 RUN 指令,用于运行程序,但二者运行的时间点不同:
CMD 在docker run 时运行。
RUN 是在 docker build。
作用:为启动的容器指定默认要运行的程序,程序运行结束,容器也就结束。CMD 指令指定的程序可被 docker run 命令行参数中指定要运行的程序所覆盖。
注意:如果 Dockerfile 中如果存在多个 CMD 指令,仅最后一个生效。
格式:
CMD <shell 命令>
CMD ["<可执行文件或命令>","<param1>","<param2>",...]
CMD ["<param1>","<param2>",...] # 该写法是为 ENTRYPOINT 指令指定的程序提供默认参数
推荐使用第二种格式,执行过程比较明确。第一种格式实际上在运行的过程中也会自动转换成第二种格式运行,并且默认可执行文件是 sh。
(哦,所以如果我要构建 redis 集群节点的镜像可以用这个)
ENTRYPOINT
类似于 CMD 指令,但其不会被 docker run 的命令行参数指定的指令所覆盖,而且这些命令行参数会被当作参数送给 ENTRYPOINT 指令指定的程序。
但是, 如果运行 docker run 时使用了 --entrypoint 选项,将覆盖 ENTRYPOINT 指令指定的程序。
优点:在执行 docker run 的时候可以指定 ENTRYPOINT 运行所需的参数。
注意:如果 Dockerfile 中如果存在多个 ENTRYPOINT 指令,仅最后一个生效。
格式:
ENTRYPOINT ["<executeable>","<param1>","<param2>",...]
可以搭配 CMD 命令使用:一般是变参才会使用 CMD ,这里的 CMD 等于是在给 ENTRYPOINT 传参,以下示例会提到。
示例:
假设已通过 Dockerfile 构建了 nginx:test 镜像:
FROM nginx
ENTRYPOINT ["nginx", "-c"] # 定参
CMD ["/etc/nginx/nginx.conf"] # 变参
1、不传参运行
$ docker run nginx:test
容器内会默认运行以下命令,启动主进程。
nginx -c /etc/nginx/nginx.conf
2、传参运行
$ docker run nginx:test -c /etc/nginx/new.conf
容器内会默认运行以下命令,启动主进程(/etc/nginx/new.conf:假设容器内已有此文件)
nginx -c /etc/nginx/new.conf
ENV
设置环境变量,定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。
格式:
ENV <key> <value>
ENV <key1>=<value1> <key2>=<value2>...
以下示例设置 NODE_VERSION = 7.2.0 , 在后续的指令中可以通过 $NODE_VERSION 引用:
ENV NODE_VERSION 7.2.0
RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
&& curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc"
ARG
构建参数,与 ENV 作用一致。不过作用域不一样。ARG 设置的环境变量仅对 Dockerfile 内有效,也就是说只有 docker build 的过程中有效,构建好的镜像内不存在此环境变量。
构建命令 docker build 中可以用 --build-arg <参数名>=<值>
来覆盖。
格式:
ARG <参数名>[=<默认值>]
VOLUME
定义匿名数据卷。在启动容器时忘记挂载数据卷,会自动挂载到匿名卷。
作用:
避免重要的数据,因容器重启而丢失,这是非常致命的。
避免容器不断变大。
格式:
VOLUME ["<路径1>", "<路径2>"...]
VOLUME <路径>
在启动容器 docker run 的时候,我们可以通过 -v 参数修改挂载点。
EXPOSE
仅仅只是声明端口。
作用:
帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射。
在运行时使用随机端口映射时,也就是 docker run -P 时,会自动随机映射 EXPOSE 的端口。
格式:
EXPOSE <端口1> [<端口2>...]
WORKDIR
指定工作目录。用 WORKDIR 指定的工作目录,会在构建镜像的每一层中都存在。(WORKDIR 指定的工作目录,必须是提前创建好的)。
docker build 构建镜像过程中的,每一个 RUN 命令都是新建的一层。只有通过 WORKDIR 创建的目录才会一直存在。
格式:
WORKDIR <工作目录路径>
USER
用于指定执行后续命令的用户和用户组,这边只是切换后续命令执行的用户(用户和用户组必须提前已经存在)。
格式:
USER <用户名>[:<用户组>]
HEALTHCHECK
用于指定某个程序或者指令来监控 docker 容器服务的运行状态。
格式:
HEALTHCHECK [选项] CMD <命令>:设置检查容器健康状况的命令
HEALTHCHECK NONE:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令
HEALTHCHECK [选项] CMD <命令> : 这边 CMD 后面跟随的命令使用,可以参考 CMD 的用法。
ONBUILD
用于延迟构建命令的执行。简单的说,就是 Dockerfile 里用 ONBUILD 指定的命令,在本次构建镜像的过程中不会执行(假设镜像为 test-build)。当有新的 Dockerfile 使用了之前构建的镜像 FROM test-build ,这时执行新镜像的 Dockerfile 构建时候,会执行 test-build 的 Dockerfile 里的 ONBUILD 指定的命令。
格式:
ONBUILD <其它指令>
LABEL
LABEL 指令用来给镜像添加一些元数据(metadata),以键值对的形式,语法格式如下:
LABEL <key>=<value> <key>=<value> <key>=<value> ...
比如我们可以添加镜像的作者:
LABEL org.opencontainers.image.authors="runoob"
实践出真知
现在,指令也都看完了,我们动手实践一下,会有更多的收获。
任务一:打包一个镜像,能够运行 muduo 网络库。
准备环境:boost 源代码、muduo 源代码。
参考博客:
安装 boost 库
安装 muduo 库
实现
经过我一番的挣扎,写出了如下的代码(好不好我们另说):
FROM ubuntu:latest
ADD boost_* /app
COPY muduo ./moduo
RUN apt-get update \
&& apt-get -y install gcc g++ python3 bzip2 cmake \
&& apt-get clean all \
&& ./app/boost_1_79_0/bootstrap.sh \
&& ./app/boost_1_79_0/b2 \
&& ./app/boost_1_79_0/b2 install \
&& ./app/muduo/build.sh && ./app/muduo/build.sh install \
&& mv /app/build/release-install-cpp11/include/muduo/ /usr/include/ \
&& mv /app/build/release-install-cpp11/lib/* /usr/local/lib/ \
&& rm -rf /app
其中有个需要注意的点是 ADD 和 COPY,ADD 是会将 tar 等压缩文件自动解压的,而 COPY 不会。不过二者都有一个共同的特点,那就是所引用的文件需要在 Dockerfile 的同级目录开始搜寻。
然后呢,由于 ADD 会自动解压,所以就会和 COPY 有一点不一样的,ADD 会多出一层目录来,这点对于要对文件进行二次操作的朋友要注意。
还有呢,就是 Dockerfile 执行之后形成 “层”,我们且称之为 “层”,就是前面说要减少的那个。是会有缓存的,如果失败了重新执行,缓存是可以回收利用的。所以,可以把一些比较稳定的任务放在前面,走一次之后就可以一直重复利用的。
(所以我才会说,测试阶段可以多来几层,如果觉得没把握一次到位的话。)
而且不用怕 “层” 多,如果某条命令没有留下实际的东西,那么那一“层” 是会被清理掉的,比方说“ RUN ls”,是会有一 “层”,但是执行完就被 clean 掉了,不要怕的啥命令都非要凑一堆了一起走。
Dockerfile 优化方向
我搜罗到以下的优化方向,一会儿我们来一一辨析一下:
不要在构建中升级版本
为了降低复杂性、减少依赖、减小文件大小、节约构建时间,你应该避免安装任何不必要的包。
apt-get upgrade会使得镜像构建过程非常不稳定,在构建时不确定哪些包会被安装,此时可能会产生不一致的镜像。因此通常我们会删掉apt-get upgrade。
对于这一点我是直接认同的。
将变化频率一样的RUN指令合一
Docker镜像是分层的,类似于洋葱,它们都有很多层,为了修改内层,则需要将外面的层都删掉。Docker镜像有如下特性:
- Dockerfile中的每个指令都会创建一个新的镜像层。
- 镜像层将被缓存和复用。
- Dockerfile修改后,复制的文件变化了或者构建镜像时指定的变量不同了,对应的镜像层缓存就会失效。
- 某一层的镜像缓存失效之后,它之后的镜像层缓存都会失效。
- 镜像层是不可变的,如果我们在某一层中添加一个文件,然后在下一层中删除它,则镜像中依然会包含该文件,只是这个文件在Docker容器中不可见。
将变化频率一样的指令合并在一起,目的是为了更好的将镜像分层,避免带来不必要的成本。
对于这一点,我需要修改一下我的 Dockerfile 了。
FROM ubuntu:latest
ADD boost_* /app
COPY muduo ./moduo
RUN apt-get update \
&& apt-get -y install gcc g++ cmake \
&& apt-get clean all
RUN ./app/boost_1_79_0/bootstrap.sh \
&& ./app/boost/b2 \
&& ./app/boost/b2 install \
&& rm -rf /app/boost
RUN ./app/muduo/build.sh && ./app/muduo/build.sh install \
&& mv /app/build/release-install-cpp11/include/muduo/ /usr/include/ \
&& mv /app/build/release-install-cpp11/lib/* /usr/local/lib/ \
&& rm -rf /app
使用特定的标签
当镜像没有指定标签时,将默认使用latest标签。因此,FROM ubuntu指令等同于FROM ubuntu:latest。当镜像更新时,latest标签会指向不同的镜像,这时构建镜像有可能失败。
对于这一点,我也是直接认同的,一会儿得修改一下我的 Dockerfile 了。
FROM ubuntu:20.04
ADD boost_* /app
COPY muduo ./moduo
RUN apt-get update \
&& apt-get -y install gcc g++ cmake \
&& apt-get clean all
RUN ./app/boost_1_79_0/bootstrap.sh \
&& ./app/boost/b2 \
&& ./app/boost/b2 install \
&& rm -rf /app/boost
RUN ./app/muduo/build.sh && ./app/muduo/build.sh install \
&& mv /app/build/release-install-cpp11/include/muduo/ /usr/include/ \
&& mv /app/build/release-install-cpp11/lib/* /usr/local/lib/ \
&& rm -rf /app
删除多余文件
假设我们更新了apt-get源,下载解压并安装了一些软件包,它们都保存在“/var/lib/apt/lists/”目录中。
但是,运行应用时Docker镜像中并不需要这些文件。因此最好将它们删除,因为它会使Docker镜像变大。
这个使用 apt clean 是不是一样的道理呢?一会儿可以试一下呀。不过对于一些源码文件是需要及时的删除掉的,不然那一层过了之后就删不了了,层一旦封装,就成了只读文件了。
选择合适的基础镜像
在示例中,我们选择了ubuntu作为基础镜像。
可以在 dockerhub 上搜一下 Ubuntu 镜像,会有新的感悟的:
我读书少,你不要骗我…
可以了解一下 alpine 镜像。
设置WORKDIR和CMD
WORKDIR指令可以设置默认目录,也就是运行RUN / CMD / ENTRYPOINT指令的地方。
使用ENTRYPOINT(可选)
ENTRYPOINT指令并不是必须的,因为它会增加复杂度。ENTRYPOINT是一个脚本,它会默认执行,并且将指定的命令作为其参数。它通常用于构建可执行的Docker镜像。
编写.dockerignore文件
.dockerignore的作用和语法类似于.gitignore,可以忽略一些不需要的文件,这样可以有效加快镜像构建时间,同时减少Docker镜像的大小。
构建镜像时,Docker需要先准备context,将所有需要的文件收集到进程中。默认的context包含Dockerfile目录中的所有文件,但是实际上,我们并不需要.git目录等内容。
示例如下:
.git/
这个一会儿可以研究一下。
ARG与ENV
两种指令都可以用来定义变量,但是使用上有很多要注意的点:
- FROM 前的 ARG 只能在 FROM 中使用,如果在 FROM 后也要使用,需要重新声明
ARG key=value
FROM xxx${key}xxxx
ARG key # 这里需要再次声明才能使用
-
ARG 变量的作用范围是 build 阶段 ARG 之后的指令,不会带入镜像。
-
ENV 环境变量作用范围是 build 阶段 ENV 声明的指令,并且会编入镜像,容器运行时也会这些环境变量也生效。
-
CMD 和 ENTRYPOINT 中不能使用 ARG 和 ENV 定义的变量。
-
当 ARG 和 ENV 变量同名时(无论是谁先定义),ENV 环境变量的值会覆盖 ARG 变量。
-
ENV 会产生中间层(layer),被编入镜像,即使使用 unset 也无法去掉。
multi-stage builds:多阶段构建
编写Dockerfile的时候会遇到一个问题,制作镜像的时候由于需要编译可执行文件而引入了大量运行时不需要的工具,导致镜像特别大。比如:为了编译一个go的可执行文件,需要引入golang这个官方镜像进行编译,这个镜像大小是810M,但是真正运行的时候是不需要如此大的镜像的。那么 multi-stage builds 就起到了很大的作用
hello.go
package main
import (
"fmt"
"time"
)
func main() {
for {
fmt.Println("hello world")
time.Sleep(time.Second)
}
}
Dockerfile
FROM golang as compile_stage
COPY ./hello.go /tmp/hello.go
RUN go build -o /tmp/hello /tmp/hello.go
FROM alpine
# 从之前那个golang镜像环境中将编译好的hello拷贝过来
COPY --from=compile_stage /tmp/hello .
CMD ["./hello"]
多阶段构建出来的镜像是以最后那个FROM为基础的
这样go build -t hello . 制作出来的镜像只有7M多,而golang镜像本身就有810M,相差甚大
关于 alphine,后面再出一篇咯…