写在前面

在前面我们简单提到了如何使用Dockerfile文件来创建镜像,鉴于这种方式在日常工作中使用的较为频繁,因此这里专门拿出一篇文章来研究如何使用Dockerfile配置文件来创建镜像。这里会介绍Dockerfile比较典型的基本结构及其支持的众多指令,然后通过这些指令来编写定制镜像的Dockerfile文件,接着便使用该Dockerfile文件来生成镜像,最后会结合笔者自身工作经验来谈谈一些使用Dockerfile的最佳实践。

Dockerfile

Dockerfile是一个由一组指令组成的文本格式的配置文件,其中的每条指令对应Linux中的一条命令,它可以利用给定的指令描述基于某个父镜像来创建新镜像。Dockerfile是由一行行命令语句组成,并且支持以#号开头的注释行。

一般来说,Dockerfile主体内容分为4个部分:基础镜像信息、维护者信息、镜像操作指令和容器启动时执行指令。

下面是一个开源的名为renren-fast的项目构建使用的Dockerfile文件,如下所示:

1
2
3
4
5
6
7
FROM java:8
EXPOSE 8080

VOLUME /tmp
ADD renren-fast.jar /app.jar
RUN bash -c 'touch /app.jar'
ENTRYPOINT ["java","-jar","/app.jar"]

指令说明

Dockerfile中指令的格式一般为INSTRUCTION arguments,它包括“配置指令”(配置镜像信息)和“操作指令”(执行具体操作)这两部分,如下表所示:

分类 指令 说明
配置指令 ARG 定义创建镜像过程中使用的变量
配置指令 FROM 指定所创建镜像的基础镜像
配置指令 LABEL 为生成的镜像添加元数据标签信息
配置指令 EXPOSE 声明镜像内服务监听的端口
配置指令 ENV 指定环境变量
配置指令 ENTRYPOINT 指定镜像的默认入口命令
配置指令 VOLUME 创建一个数据卷挂载点
配置指令 USER 指定运行容器时的用户名或UID
配置指令 WORKDIR 配置工作目录
配置指令 ONBUILD 创建子镜像时指定自动执行的操作指令
配置指令 STOPSIGNAL 指定退出的信号值
配置指令 HEALTHCHECK 配置所启动容器如何进行健康检查
配置指令 SHELL 指定默认shell类型
操作指令 RUN 运行指定命令
操作指令 CMD 启动容器时指定默认执行的命令
操作指令 ADD 添加内容到镜像
操作指令 COPY 复制内容到镜像

由于这些指令在实际工作中使用的概率非常大,因此接下来将对上述指令进行更为细致的介绍。

配置指令

ARG

ARG用于定义创建镜像过程中使用的变量,其对应格式为ARG <name>[=<default value>]

举个例子,如下所示:

1
2
ARG envy
ARG name=envythink

当然开发者也可以在执行docker build命令时,通过使用--build-arg=variable参数来为变量赋值。请注意,当镜像编译成功后,ARG指定的变量将不再存在(但是ENV指定的变量依旧存在于镜像中)。

Docker内置了一些镜像创建变量,用户可以直接使用而无须声明,包括(不区分大小写):HTTP_PROXY、HTTPS_PROXY、FTP_PROXY、NO_PROXY。

FROM

FROM用于指定所创建镜像的基础镜像,其对应格式如下:

1
2
3
FROM <images> [AS <name>]
FROM <images>:<tag> [AS <name>]
FROM <images>@<digest> [AS <name>]

上面命令格式中的tag和digest是可选的,开发者如果不使用这两个值时,则默认使用latest版本的基础镜像。

请注意,在任何Dockerfile文件中,FROM必须是第一个指令,而且如果在同一个Dockerfile中创建多个镜像时,可以使用多个FROM指令,但是每个镜像只能使用一次。

举个例子,通常为了保证镜像精简,都会使用体积较小的镜像,如Alpine或者Debian作为基础镜像,如下所示:

1
2
ARG VERSION=9.6
FROM debian:${VERSION}

再举个例子,使用MySQL:

1
FROM mysql:5.7.21
LABEL

LABEL用于为生成的镜像添加元数据标签信息,这些信息可以用来辅助过滤出特定的镜像。其对应格式如下:

1
LABEL <key>=<value> <key>=<value> <key>=<value> ...

举个例子,如下所示:

1
LABEL version="1.0" description="这是一个Web服务器" by="余思博客"

请注意,在使用LABEL指令指定元数据时,一条LABEL指令可以指定一或多条元数据。在指定多条元数据时,不同元数据之间通过空格进行分隔,笔者推荐将所有的元数据通过一条LABEL指令来指定,避免生成过多的中间镜像。

EXPOSE

EXPOSE用于声明镜像内服务监听的端口(或者说指定与外界交互的端口),其对应格式如下:

1
EXPOSE <port> [<port>/<protocol>...]

举个例子,如下所示:

1
2
3
EXPOSE 80 443
EXPOSE 8080
EXPOSE 1181/tcp 1181/udp

请注意,该指令只是起到声明作用,并不会自动完成端口映射。如果要将端口映射出来,那么就可以在启动容器的时候使用-P(大写)参数(此时Docker主机会自动分配一个宿主机也就是虚拟机的临时端口),或者使用-p HOST_PORT:CONTAINER_PORT(小写)参数(用于具体指定所映射的本地端口)。

ENV

ENV用于指定环境变量,在镜像生成过程中会被后续RUN指令使用,在镜像启动的容器中也会存在(前面介绍的ARG仅仅存在于镜像创建之前这一过程)。

它有两种格式,之间有一些不同之处。对于下面这种格式来说,之后的所有内容均会被视为其的组成部分,因此一次只能设置一个变量:

1
ENV <key> <value>  

而对于下面这种格式,由于每个变量采用了<key>=<value>键值对形式,因此可以设置多个变量。如果中包含空格,可以使用\来进行转义,也可以通过””来进行标示,另外反斜线也可以用于续连:

1
ENV <key>=<value> ... 

举个例子,如下所示:

1
2
ENV APP_HOME=/usr/local/app
ENV PATH $PATH:/usr/local/bin

当然上述通过ENV指定的环境变量,可以在运行时被覆盖掉,如docker run --env <key>=<value> envythink_image

请注意,当一条ENV指令同时为多个环境变量赋值,并且值也是从环境变量中读取时,会为变量都赋值后再更新。举个例子,如下面的指令:

1
2
ENV key1=value2
ENV key1=value1 key2=${key1}

此时最终结果为key1=value1 key2=value2

ENTRYPOINT

ENTRYPOINT用于指定镜像的默认入口命令,该入口命令会在启动容器时作为根命令来执行,所有传入值作为该命令的参数。

它有两种命令格式,如下所示:

1
2
ENTRYPOINT ["executable", "param1", "param2"] (exec调用执行, 优先)
ENTRYPOINT command param1 param2 (shell内部命令,在shell中执行)

此时CMD指令指定值将作为根命令的参数。

请注意,每个Dockerfile中只能有一个ENTRYPOINT,当指定多个时,只有最后一个才生效。ENTRYPOINT中设置的参数可以在运行时被--entrypoint参数覆盖掉,如docker run --entrypoint

ENTRYPOINT与CMD非常类似,不同的是通过docker run执行的命令不会覆盖ENTRYPOINT,而docker run命令中指定的任何参数,都会被当做参数再次传递给ENTRYPOINT。

VOLUME

VOLUME用于创建一个数据卷挂载点或者说是指定一个数据持久化目录。其对应的格式为:

1
VOLUME ["/path/to/dir"]

举个例子,如下所示:

1
2
VOLUME ["/data"]
VOLUME ["/var/www", "/var/log/apache", "/etc/apache"]

运行容器时,可以从本地主机或者其他容器挂载数据卷,一般用来存放数据库和需要保存的数据。数据卷可以容器间共享和重用;容器不一定要和其他容器共享卷;修改数据卷后会立即生效;对数据卷的修改不会对镜像产生影响;卷会一直存在,直到没有任何容器在使用它。

USER

USER用于指定运行容器时的用户名或UID,注意后续的RUN等指令也会使用指定的用户身份。其对应的格式为:

1
2
3
4
5
6
USER user
USER user:group
USER uid
USER uid:gid
USER user:gid
USER uid:group

请注意,使用USER指定用户时,可以使用用户名、UID、GID或是两者的组合。当服务不需要管理员权限时,可以通过该命令指定运行用户,并且可以在Dockerfile中创建所需要的用户。

举个例子来说,如下所示:

1
RUN groupadd -r envythink && useradd --no-log-init -r -g envythink envythink 

如果需要临时获取管理员权限可以使用gosu命令。

使用USER指定用户后,Dockerfile中其后的命令RUN、CMD、ENTRYPOINT都将使用该用户。镜像构建完成后,通过docker run命令运行容器时,可以通过-u参数来覆盖所指定的用户。

WORKDIR

WORKDIR用于为后续的RUN、CMD、ENTRYPOINT指令配置工作目录。其对应的格式为:

1
WORKDIR /path/to/workdir

开发者可以使用多个WORKDIR指令,后续命令如果是相对路径,则会基于之前命令指定的路径。举个例子,如下所示;

1
2
3
4
WORKDIR /a
WORKDIR b
WORKDIR c
RUN pwd

那么上面最终会输出路径为/a/b/c,因此为了避免出现路径错误,建议在WORKDIR指令中只使用绝对路径。

ONBUILD

ONBUILD指定当基于所生成镜像创建子镜像时,自动执行的操作指令。其对应的格式为:

1
ONBUILD [INSTRUCTION]

举个例子,使用如下的Dockerfile来创建父镜像ParentImage,此时可以指定ONBUILD指令:

1
2
3
4
5
# Dockerfile for ParentImage
[...]
ONBUILD ADD . /app/src
ONBUILD RUN /usr/local/bin/python-build --dir /app/src
[...]

使用docker build命令创建子镜像ChildImage时(FROM ParentImage),会首先执行ParentImage中配置的ONBUILD指令:

1
2
# Dockerfile for ChildImage
FROM ParentImage

其实上面就相当于在ChildImage的Dockerfile文件中添加了下面的指令:

1
2
3
# Automatically run the following when building ChildImage
ADD . /app/src
RUN /usr/local/bin/python-build --dir /app/src

由于ONBUILD指令是隐式执行的,因此推荐在使用它的镜像标签中进行标注。举个例子,如ruby:2.2-onbuild

ONBUILD指令在创建专门用于自动编译、检查等操作的基础镜像时,可以发挥最大作用。

STOPSIGNAL

STOPSIGNAL用于指定所创建镜像启动的容器接收退出的信号值。其对应的格式为:

1
STOPSIGNAL signal
HEALTHCHECK

HEALTHCHECK用于配置所启动容器如何进行健康检查,即如何判断健康与否,这个是Docker自1.12开始就支持的。它有两种格式,第一种是HEALTHCHECK [OPTIONS] CMD command,可根据所执行命令的返回值是否为0来进行判断;第二种是HEALTHCHECK NONE,用于禁止基础镜像中的健康检查。

需要说明的是,其中的OPTIONS支持如下参数:

1
2
3
4
5
6
# 过多久检查一次
-interval=DURATION(default:30s)
# 每次检查等待结果的超时
-timeout=DURATION(default:30s)
# 如果失败了,重试几次才最终确定失败
-retries=N(default:3)
SHELL

SHELL所示配置指令中最后一个指令,用于指定其他命令使用shell时的默认shell类型。其对应的格式为:

1
SHELL ["executable","parameters]

其默认值为[“/bin/bash”,”-c”]。请注意,对于Windows系统来说,SHELL路径中使用了\作为分隔符,建议在Dockerfile开头添加`# escape=’``来指定转义符。

操作指令

RUN

RUN指令用于在镜像容器中执行命令,其对应的格式为:

1
2
RUN <command>
RUN ["executable","param1","param2"]

请注意下面那种指令会被解析为JOSN数组,因此必须使用双引号。前者默认将在SHELL终端中运行命令,也就是/bin/sh -c;后者则使用exec命令来执行,它不会启动SHELL环境。言外之意,如果开发者想指定其他终端类型,那么可以使用后者,如下所示:

1
RUN ["/bin/bash","-c","echo hello"]

每条RUN指令将在当前镜像基础上执行指定命令,并提交为新的镜像层。当命令较长时,可以使用\来换行。

请注意,RUN指令创建的中间镜像会被缓存,并在下次构建中使用。如果不想使用这些缓存镜像,可以在构建时添加--no-cache参数。举个例子,如docker build --no-cache这一命令。

CMD

CMD指令在构建容器后调用,用于指定启动容器时默认执行的命令。它支持三种格式,如下所示:
(1)CMD ["executable","param1","param2"],其实就相当于执行executable param1 param2命令,推荐使用这种方式。
(2)CMD command param1 param2,直接在默认的SHELL中执行,提供给需要交互的应用;
(3)CMD ["param1","param2"],提供给ENTRYPOINT的默认参数。

请注意,每个Dockerfile只能有一条CMD命令,如果指定了多条命令,那么只会执行最后一条。如果用户在启动容器的时候,手动指定了运行的命令,也就是作为run命令的参数,那么将会覆盖掉CMD指定的命令。

ADD

ADD命令用于添加内容到镜像。其对应的格式如下所示:

1
2
ADD <src> <dest>
ADD ["<src>",... "<dest>"]

也就是复制指定的路径下的内容到容器中的路径下,注意后者支持包含空格的路径。

其中可以是Dockerfile所在目录的一个相对路径(文件或目录);可以是一个URL;也可以是一个tar文件(注意它会被自动解压为目录)。可以是镜像内的绝对路径,或者相对于工作目录(WORKHOME)的相对路径。

举个例子,如下所示:

1
2
3
4
5
6
7
8
 # 添加所有以"hom"开头的文件
ADD hom* /mydir/
# ? 替代一个单字符,例如:"home.txt"
ADD hom?.txt /mydir/
# 添加 "test" 到 `WORKDIR`/relativeDir/
ADD test relativeDir/
# 添加 "test" 到 /absoluteDir/
ADD test /absoluteDir/
COPY

COPY命令用于复制内容到镜像。其对应的格式如下所示:

1
2
COPY <src> <dest>
COPY ["<src>",... "<dest>"]

也就是复制本地主机(这里的是Dockerfile所在目录的相对路径,可以是文件或者目录)路径下的内容到容器中的路径下,当目标路径不存在时就会自动创建。它同样支持正则表达式,可以发现COPY和ADD的指令功能非常相似,当本地目录为源目录,即目录时,推荐使用COPY指令。

Dockfile常用操作指令总结

接下来对前面介绍的一些常用Dockerfile操作指令进行总结,如下表所示:

指令 说明
FROM镜像 用于指定新镜像所基于的镜像,注意它必须是第一条指令
MAINTAINER 名字 新镜像的维护人信息
RUN 命令 在所基于的镜像上执行命令,并提交到新镜像中
EXPOSE端口号 指定新镜像加载到Docker时开启的端口号
ENV 环境变量 变量值 设置一个环境变量的值,之后的RUN会使用
ADD 源文件/目录 目标文件/目录 将源文件复制到目标文件,源文件要与Docker位于同一目录下,或者为一个URL
COPY 源文件/目录 目标文件/目录 将本地主机上的源文件/目录复制到目标地点,源文件/目录要与Dockerfile在同一目录下
VOLUME[“目录”] 在容器中创建一个挂载点
USER 用户名 /UID 指定运行容器时的用户
WORKDIR 路径 为后续的RUN、CMD、ENTRYPOINT指定工作目录
ONBUILD命令 指定所生成的镜像作为一个基础镜像时所要运行的命令
CMD[“要运行的程序”,”参数1”,”参数2”] 指定启动容器时运行的命令或脚本,只能有一条CMD命令,多条时只有最后一条被执行

举个例子,接下来通过介绍基于envyubuntu:latest镜像来安装Python3,进而构成一个新的python:3镜像,相应的操作如下:

第一步,创建镜像工作目录并切换至该目录,在/home/envythink目录下新建pyhello目录,然后切换至该目录:

1
2
[envythink@localhost ~]$ mkdir pyhello
[envythink@localhost ~]$ cd pyhello/

第二步,在pyhello目录下新建Dockerfile文件,并在里面添加配置信息:

1
2
3
4
5
6
7
8
#基于ubuntu:latest镜像
FROM ubuntu:latest

#维护人的信息
LABEL version="1.0" maintainer="docker envy <envyzhan@aliyun.com>"

#创建镜像时执行的脚本文件
RUN yum update && yum install -y python3

第三步,创建镜像。开发者可以使用docker build [image] .命令来创建镜像,编译成功后本地将多出一个python:3的镜像,如下所示:

1
docker build -t python:3 .

这样我们就通过上述命令创建出一个python:3的镜像。请注意该命令最后面有一个.号,请注意这个.号不是用来指定Dockerfile文件的所在位置,实际上使用-f参数来指定Dockerfile的路径。那么问题来了这个.号的作用是什么?

其实Docker在运行时分为Docker引擎(服务器守护进程)和客户端工具,而当我们使用docker各种命令的时候,其实就是在使用客户端工具与Docker引擎进行交互,而我们在使用docker build命令构建镜像时,其实这个过程是在Docker引擎内完成的,而不是在本地客户端。那么问题来了,如果开发者在Dockerfile中使用了类似于COPY、ADD等指令来操作文件时,Docker引擎是如何获取这些文件呢?

因此这里就有一个镜像构建上下文的概念,当构建镜像的时候,用户来指定构建镜像的上下文路径,而docker build命令会将这个路径下所有的文件都打包上传给Docker引擎,之后Docker引擎将这些内容展开,就能获取到所有指定上下文中的文件。

还记得前面在介绍COPY指令的时候,特别要求源文件要与Dockerfile在同一目录下,如COPY ./hello.txt /test命令,该命令并不是复制本地当前目录下的hello.txt文件,而是docker引擎中展开的构建上下文中的文件,所以如果复制的文件超出了docker引擎中展开的构建上下文的范围,那么docker引擎是无法找到那些文件。综上所述,上述docker build .命令中的.号是指在指定镜像构建过程中的上下文环境的目录。

在理解了这个镜像构建上下文以后,接下来思考这个.dockerignore文件的作用,如果你之前有使用过git,那么肯定可以知道.gitignore文件的作用,它用来配置需要忽略上传的文件或者文件夹信息,因此接着这个设计理念自然可以猜到这个.dockerignore文件就是用于指定在构建镜像过程中的上下文环境目录需要忽略的文件或者文件夹。

Dockerfile文件实例

下面举一个创建Nginx镜像的例子,完整的Dockerfile文件内容如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# The Example  Dockerfile For Nginx
# Version 1.0

# Base images 基础镜像
FROM centos

#MAINTAINER 维护者信息
MAINTAINER envythink

#ENV 设置环境变量
ENV PATH /usr/local/nginx/sbin:$PATH

#ADD 将nginx文件放在当前目录下,添加到容器中时会自动解压
ADD nginx-1.8.0.tar.gz /usr/local/
ADD epel-release-latest-7.noarch.rpm /usr/local/

#RUN 执行以下命令
RUN rpm -ivh /usr/local/epel-release-latest-7.noarch.rpm
RUN yum install -y wget lftp gcc gcc-c++ make openssl-devel pcre-devel pcre && yum clean all
RUN useradd -s /sbin/nologin -M envy

#WORKDIR 相当于cd命令
WORKDIR /usr/local/nginx-1.8.0

#RUN 执行以下命令
RUN ./configure --prefix=/usr/local/nginx --user=envy --group=envy --with-http_ssl_module --with-pcre && make && make install

#RUN 执行以下命令
RUN echo "daemon off;" >> /etc/nginx.conf

#EXPOSE 映射端口
EXPOSE 80

#CMD 运行如下命令
CMD ["nginx"]

我从网上找了一张比较详细的图片,可以帮助记忆Dockerfile文件的组成结构:

这样关于如何使用Dockerfile的学习就先学习到这,后续开始学习如何使用Dockerfile来创建出自定义镜像。