上篇博文中提到了在实际工作中构建镜像更多使用的Dockerfile,今天我们再来详细的看看这个有趣有内涵的小可爱。

一、Dockerfile构建镜像的流程

还是简简单单的看下上篇博文中最后的Dockerfile。

#Version:0.0.1  # 版本信息
FROM centos:latest  # 表示从哪个基础镜像开始构建
MAINTAINER Yuan "earlyuan@163.com"  # 表示作者以及邮箱

# 以下就是Dockerfile的执行,每条命令都是以RUN来开始,表示开始执行命令。这里很简单的跳转到根目录下,创建readme目录,在readme目录下再创建readme.md文件,并向其中写入内容
RUN cd /
RUN mkdir readme
RUN touch /readme/readme.md
RUN echo "This is readme file created by dockerfile" > /readme/readme.md

以上内容很简单,

  1. 进入根目录。
  2. 创建readme。
  3. 在/readme/下创建readme.md文件。
  4. 向readme.md文件中追加内容。

执行命令docker build -t="centos/dockerfile_test:0.0.1" .后会有如下输出:

[root@localhost dockerfile_test]# docker build -t="centos/dockerfile_test:0.0.1" .
Sending build context to Docker daemon  2.048kB
Step 1/6 : FROM centos:latest
 ---> 470671670cac
Step 2/6 : MAINTAINER Yuan "earlyuan@163.com"
 ---> Running in 4452ab2a7bbf
Removing intermediate container 4452ab2a7bbf
 ---> 9c9b6692a1dc
Step 3/6 : RUN cd /
 ---> Running in 386c520eb51f
Removing intermediate container 386c520eb51f
 ---> 7b621d54b17b
Step 4/6 : RUN mkdir readme
 ---> Running in 97c3cc32af97
Removing intermediate container 97c3cc32af97
 ---> c781c868ece5
Step 5/6 : RUN touch /readme/readme.md
 ---> Running in e60270cfa7af
Removing intermediate container e60270cfa7af
 ---> 5b1b86a3daf4
Step 6/6 : RUN echo "This is readme file created by dockerfile" > /readme/readme.md
 ---> Running in 7ff787a8a110
Removing intermediate container 7ff787a8a110
 ---> d3a80ce9dda6
Successfully built d3a80ce9dda6
Successfully tagged centos/dockerfile_test:0.0.1

由上面的输出可以大致了解到通过Dockerfile执行docker build命令构建镜像的流程,如下:

  1. 从基础镜像运行一个容器,如Step 1/6。
  2. 执行一条Dockerfile里RUN指令后配置的操作命令,对当前容器做出修改。
  3. 在Docker内部执行类似于docker commit的操作,将当前操作容器提交成一个缓存的新镜像。
  4. Docker再基于刚构建的缓存的新镜像再运行一个容器,在该容器中继续执行Dockerfile里配置的指令,直到所有指令执行完成。

以上是正确的Dockerfile构建的过程,如果在其中RUN指令后的某一步骤配置错误,那么就不会完成正确的构建,但是会生成已经成功的最后一步的那个镜像,我们可以根据该镜像创建出容器,进入到容器内部调测我们错误的那一步配置。例如,如果我把RUN touch /readme/readme.md写成了RUN tauch /readme/readme.md那么执行构建的时候,就会有如下输出:

[root@localhost dockerfile_test]# docker build -t="centos/dockerfile_test:0.0.2.error" .
Sending build context to Docker daemon  2.048kB
Step 1/6 : FROM centos:latest
 ---> 470671670cac
Step 2/6 : MAINTAINER Yuan "earlyuan@163.com"
 ---> Using cache
 ---> 9c9b6692a1dc
Step 3/6 : RUN cd /
 ---> Using cache
 ---> 7b621d54b17b
Step 4/6 : RUN mkdir readme
 ---> Using cache
 ---> c781c868ece5
Step 5/6 : RUN tauch /readme/readme.md
 ---> Running in dae2437bf215
/bin/sh: tauch: command not found
The command '/bin/sh -c tauch /readme/readme.md' returned a non-zero code: 127

输出显示执行到第5步时出现了错误,因此我们可以使用第4步构建出来的镜像ID是c781c868ece5的镜像运行容器,docker run -it c781c868ece5 /bin/bash,进入容器调测第5步执行的命令。当然这里很简单看起来不需要进入容器调测就可以知道是哪里出了问题,但是对于一些更为复杂的指令编排,进入容器查看就会更加方便了。

这里要注意的是,虽然说每一步执行都提交了一个新的缓存镜像,但是当执行出问题时,我们并不可以通过docker images查询到出问题以前提交的缓存镜像,这个缓存镜像是在Docker内部有效而不对使用者可见的。此外,有趣的是,我们不能通过docker images查询到缓存镜像,但是我们为了排查问题原因根据缓存镜像创建的容器,在执行docker ps命令时,在容器列表中却是可以看到缓存镜像ID。

对于镜像是如何构建的,我们可以通过docker history 镜像ID来查看构建过程,如下是文章开篇正常构建dockerfile_test:0.0.1镜像的历史过程,可以看到Dockerfile中配置命令的执行步骤:

[root@localhost dockerfile_test]# docker history d3a80ce9dda6
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
d3a80ce9dda6        10 days ago         /bin/sh -c echo "This is readme file created…   42B                 
5b1b86a3daf4        10 days ago         /bin/sh -c touch /readme/readme.md              0B                  
c781c868ece5        10 days ago         /bin/sh -c mkdir readme                         0B                  
7b621d54b17b        10 days ago         /bin/sh -c cd /                                 0B                  
9c9b6692a1dc        10 days ago         /bin/sh -c #(nop)  MAINTAINER Yuan "earlyuan…   0B                  
470671670cac        4 months ago        /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B                  
<missing>           4 months ago        /bin/sh -c #(nop)  LABEL org.label-schema.sc…   0B                  
<missing>           4 months ago        /bin/sh -c #(nop) ADD file:aa54047c80ba30064…   237MB
二、Dockerfile指令
  1. CMD

CMD指令用于指定容器启动时要运行的命令。格式为CMD [命令,命令执行参数1,命令执行参数2...]。CMD指令是在数据结构中存放所有要执行的命令,需要注意的是这里的命令只可以有一条。说点有趣的话题吧,我在刚开始接触这个指令的时候,看到CMD指令存放在数组中,我天真地认为这里可以放多条命令,于是乎在我放了多条命令后,虽然也构建了镜像,但是根据该镜像启动容器时却报错了,提示error: garbage option,在我以为容器创建失败时,通过docker ps -a命令查询容器时又意外的可以查询到这个容器。这里说这个题外话是想提醒大家,CMD虽然是数组结构存放,但是只能在数组第一个元素存放命令,后面的元素均为第一条命令的参数。下面看一个CMD指令的小例子:

[root@localhost dockerfile_command_test]# vi command_test_first 
#Version:0.0.1
FROM centos:latest
MAINTAINER Yuan "earlyuan@163.com"
CMD ["/bin/ps","-aux"]

上面的Dockerfile中我设置了一条CMD指令,在容器启动时执行ps -aux命令。使用命令docker build -t="centos/command_test_first:0.0.1" -f /opt/earl_docker_test/dockerfile_command_test/command_test_first .构建镜像后,执行命令docker run -it centos/command_test_first:0.0.1可以看到如下输出:

[root@localhost dockerfile_command_test]# docker run -it centos/command_test_first:0.0.1 
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0  46340  1700 pts/0    Rs+  05:53   0:00 /bin/ps -aux

在容器启动时,查询了系统进程的信息。这个时候可以通过docker ps -a命令查询容器列表,可以看到有一个创建了但是刚刚退出的容器,这就是我们通过上述镜像创建的容器,因为没有执行/bin/bash的原因,所以容器执行完ps -aux命令后就退出运行了。

针对上面的现象,如果我们不希望容器启动就停止运行了,我们可以在执行docker run命令的时候再次指定启动容器时要运行的命令,因为docker run命令是可以覆盖CMD指令的,例如docker run -it centos/command_test_first:0.0.1 /bin/bash,这样就在启动容器后,创建了shell窗口,可以对容器内部进行操作。

  1. ENTRYPOINT

ENTRYPOINT指令与CMD指令非常类似,但是它们有一个非常大的区别就是CMD指令会被docker run命令中的参数所覆盖,而ENTRYPOINT不会被覆盖,实际上,docker run命令中的任何参数都可以被当做参数传递给ENTRYPOINT指令中指定的命令。比如我们看下面这个例子:

[root@localhost dockerfile_entrypoint_test]# vi command_entrypoint 
#Version:0.0.1
FROM centos:latest
MAINTAINER Yuan "earlyuan@163.com"
ENTRYPOINT ["/bin/ls"]

执行命令docker build -t="centos/command_test_second:0.0.1" -f /opt/earl_docker_test/dockerfile_entrypoint_test/command_entrypoint .构建镜像后,我们通过镜像启动容器,输入如下命令docker run -it centos/command_test_second:0.0.1 "-l",可以看到docker run命令后有一个参数"-l",于是乎我们就得到了如下的输出:

[root@localhost dockerfile_entrypoint_test]# docker run -it centos/command_test_second:0.0.1 "-l"
total 0
lrwxrwxrwx.   1 root root   7 May 11  2019 bin -> usr/bin
drwxr-xr-x.   5 root root 360 Jun  9 05:28 dev
drwxr-xr-x.   1 root root  66 Jun  9 05:28 etc
drwxr-xr-x.   2 root root   6 May 11  2019 home
lrwxrwxrwx.   1 root root   7 May 11  2019 lib -> usr/lib
lrwxrwxrwx.   1 root root   9 May 11  2019 lib64 -> usr/lib64
drwx------.   2 root root   6 Jan 13 21:48 lost+found
drwxr-xr-x.   2 root root   6 May 11  2019 media
drwxr-xr-x.   2 root root   6 May 11  2019 mnt
drwxr-xr-x.   2 root root   6 May 11  2019 opt
dr-xr-xr-x. 113 root root   0 Jun  9 05:28 proc
dr-xr-x---.   2 root root 162 Jan 13 21:49 root
drwxr-xr-x.  11 root root 163 Jan 13 21:49 run
lrwxrwxrwx.   1 root root   8 May 11  2019 sbin -> usr/sbin
drwxr-xr-x.   2 root root   6 May 11  2019 srv
dr-xr-xr-x.  13 root root   0 Jun  9 03:04 sys
drwxrwxrwt.   7 root root 145 Jan 13 21:49 tmp
drwxr-xr-x.  12 root root 144 Jan 13 21:49 usr
drwxr-xr-x.  20 root root 262 Jan 13 21:49 var

容器创建成功,并且将"-l"参数传递给了Dockerfile中我们定义的ENTRYPOINT指令后的命令/bin/ls,输出了当前目录下的详细信息。

我们可以使用ENTRYPOINT和CMD组合起来玩点有趣的东西,就像下面的例子:

[root@localhost dockerfile_entrypoint_test]# vi command_entrypoint 
#Version:0.0.1
FROM centos:latest
MAINTAINER Yuan "earlyuan@163.com"
ENTRYPOINT ["/bin/ls"]
CMD ["-alt"]

执行命令docker build -t="centos/command_test_second:0.0.2" -f /opt/earl_docker_test/dockerfile_entrypoint_test/command_entrypoint .构建镜像后,我们通过镜像启动容器,输入如下命令docker run -it centos/command_test_second:0.0.2,这次我们不给启动命令传递参数,于是乎我们就得到了如下的输出:

[root@localhost dockerfile_entrypoint_test]# docker run -it centos/command_test_second:0.0.2
total 0
drwxr-xr-x.   5 root root 360 Jun  9 14:46 dev
dr-xr-xr-x. 114 root root   0 Jun  9 14:46 proc
drwxr-xr-x.   1 root root   6 Jun  9 14:46 .
drwxr-xr-x.   1 root root   6 Jun  9 14:46 ..
drwxr-xr-x.   1 root root  66 Jun  9 14:46 etc
-rwxr-xr-x.   1 root root   0 Jun  9 14:46 .dockerenv
dr-xr-xr-x.  13 root root   0 Jun  9 03:04 sys
dr-xr-x---.   2 root root 162 Jan 13 21:49 root
drwxr-xr-x.  11 root root 163 Jan 13 21:49 run
drwxrwxrwt.   7 root root 145 Jan 13 21:49 tmp
drwxr-xr-x.  20 root root 262 Jan 13 21:49 var
drwxr-xr-x.  12 root root 144 Jan 13 21:49 usr
drwx------.   2 root root   6 Jan 13 21:48 lost+found
lrwxrwxrwx.   1 root root   7 May 11  2019 bin -> usr/bin
drwxr-xr-x.   2 root root   6 May 11  2019 home
lrwxrwxrwx.   1 root root   7 May 11  2019 lib -> usr/lib
lrwxrwxrwx.   1 root root   9 May 11  2019 lib64 -> usr/lib64
drwxr-xr-x.   2 root root   6 May 11  2019 media
drwxr-xr-x.   2 root root   6 May 11  2019 mnt
drwxr-xr-x.   2 root root   6 May 11  2019 opt
lrwxrwxrwx.   1 root root   8 May 11  2019 sbin -> usr/sbin
drwxr-xr-x.   2 root root   6 May 11  2019 srv

可以看到,即使我们没有指定启动参数,在根据Dockerfile创建的镜像启动时,依旧会将CMD指令的配置作为参数继续执行。如果我们指定了启动参数,那么根据docker run会覆盖CMD的原则,那么就不会执行Dockerfile中CMD的配置了。

  1. WORKDIR

在容器内部设置工作目录。我们可以使用WORKDIR指令在Dockerfile中指定接下来的操作的工作目录,类似于linux命令行cd到某个目录下进行操作。我们来看个例子:

[root@localhost dockerfile_workdir_test]# vi command_workdir 
#Version:0.0.1
FROM centos:latest
MAINTAINER Yuan "earlyuan@163.com"

RUN yum install -y wget
RUN mkdir /opt/download
WORKDIR /opt/download
RUN wget https://mirrors.aliyun.com/centos/7.8.2003/sclo/x86_64/rh/Packages/r/rh-nginx116-nginx-1.16.1-4.el7.x86_64.rpm

上面的例子中,我们先通过yum安装了wget命令,由于设置的工作目录必须存在,才可以使用WORKDIR指令,所以我们必须先创建一个目录download,然后设置工作目录,最后使用wget命令进行资源的下载。

执行命令docker build -t="centos/command_test_third:0.0.1" -f /opt/earl_docker_test/dockerfile_workdir_test/command_workdir .构建镜像后,我们通过镜像启动容器,可以在/opt/download目录下看到我们刚刚下载的rpm包。

如果我们偶尔有这样的需求,那就是在Dockerfile中通过WORKDIR指令指定了工作目录,但是在创建容器时需要使用另外的路径作为工作目录,那么怎么办呢?Docker为我们提供了这样一个参数,“docker run -w newWorkDir”,我们来看下面这个例子。我们在创建容器时输入以下的命令docker run -it -w /home centos:latest pwd,这行命令会使用centos基础镜像创建一个容器,并将工作目录设定到/home目录下,再输出当前目录路径。

[root@localhost dockerfile_workdir_test]# docker run -it -w /home centos:latest pwd
/home
  1. ENV

ENV指令用于设置环境变量。我们先来看下面这个例子:

[root@localhost dockerfile_env_test]# vi command_env 
#Version:0.0.1
FROM centos:latest
MAINTAINER Yuan "earlyuan@163.com"

ENV MY_TEST_DIR /opt/test
RUN mkdir $MY_TEST_DIR
RUN echo "i love docker" > $MY_TEST_DIR/test.txt

我们设置了环境变量MY_TEST_DIR,并通过“$变量名”的方式引用它的值,在其中创建文件,写入内容。通过命令docker build -t="centos/command_test_fourth:0.0.1" -f /opt/earl_docker_test/dockerfile_env_test/command_env .构建镜像后,我们根据镜像启动容器,可以看到在/opt/test目录下创建了test.txt文件,并在其中有i love docker的内容。我们可以在容器内输入命令env,可以看到有如下输出:

[root@5c371ca0128e test]# env
LANG=en_US.UTF-8
HOSTNAME=5c371ca0128e
OLDPWD=/opt
PWD=/opt/test
HOME=/root
MY_TEST_DIR=/opt/test
TERM=xterm
SHLVL=1
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LESSOPEN=||/usr/bin/lesspipe.sh %s
_=/usr/bin/env

可以看到MY_TEST_DIR已经设置到环境变量中了。环境变量一旦设置成功,根据其镜像创建的容器中也将永久有效。

  1. COPY

COPY指令可以将构建上下文目录中的文件复制到容器中,来看看下面的例子:

[root@localhost dockerfile_copy_test]# vi command_copy 
#Version:0.0.1
FROM centos:latest
MAINTAINER Yuan "earlyuan@163.com"

COPY copyTest.txt /opt/docker/test/

在构建上下文中我们创建一个copyTest.txt的文件,通过命令docker build -t="centos/command_test_sixth:0.0.1" -f /opt/earl_docker_test/dockerfile_copy_test/command_copy .构建镜像后,我们根据镜像启动容器,可以看到Docker帮我们把copyTest.txt就复制到了/opt/docker/test/目录下。虽然我们容器中一开始并没有/opt/docker/test/这个目录,但是当目的地址不存在时,Docker可以帮我们创建所需要的目录结构,就像是Docker帮我们敲了一个mkdir -p命令一样。

这里需要注意的是,需要复制的文件或者目录必须位于当前的构建上下文中,否则是无法实现从本地复制到镜像容器中的。因为在构建时,会将构建上下文传递到Docker守护进程中,而复制的操作是在Docker守护进程中完成的。

  1. ADD

ADD指令与COPY指令非常类似,都是将构建上下文目录中的文件或目录复制到镜像中,格式为ADD 构建上下文中的源文件路径 镜像中的目标路径。我们来看看下面这个例子:

[root@localhost dockerfile_add_test]# vi command_add 
#Version:0.0.1
FROM centos:latest
MAINTAINER Yuan "earlyuan@163.com"

ADD testZip.zip /opt/test/testZip.zip

在构建上下文中,我们创建了testZip.zip的压缩包,通过命令docker build -t="centos/command_test_seventh:0.0.1" -f /opt/earl_docker_test/dockerfile_add_test/command_add .构建镜像后,我们根据镜像启动容器,可以看到Docker帮我们把testZip.zip就复制到了/opt/test/目录下。这样的操作与COPY指令没有什么区别。

但是,不同与COPY指令只做复制操作,ADD指令在复制的基础上,还可以做一丢丢的解压操作。针对于gzip,bzip2,xz类型的归档文件,Docker在执行ADD指令时会自动将他们解压到指定的位置。我们看下面这个例子:

[root@localhost dockerfile_add_test]# vi command_add 
#Version:0.0.1
FROM centos:latest
MAINTAINER Yuan "earlyuan@163.com"

ADD test.tar.gz /opt/test/

这里我们将构建上下文中的test.tar.gz包添加到镜像中的/opt/test目录中去,通过命令docker build -t="centos/command_test_seventh:0.0.2" -f /opt/earl_docker_test/dockerfile_add_test/command_add .,构建镜像后,我们根据镜像启动容器,可以看到Docker帮我们把test.tar.gz包直接解压缩到了/opt/test目录中,这一点是COPY指令不能做到的。

  1. LABEL

LABEL指令用于为镜像添加元数据,按照键值对的形式表示,格式为LABEL key="value"。例如下面的例子:

[root@localhost dockerfile_add_test]# vi command_add 
#Version:0.0.1
FROM centos:latest
MAINTAINER Yuan "earlyuan@163.com"

LABEL version="0.0.1" type="docker test"
ADD test.tar.gz /opt/test/

通过命令docker build -t="centos/command_test_seventh:0.0.3" -f /opt/earl_docker_test/dockerfile_add_test/command_add .,构建镜像后,我们根据镜像启动容器,输入命令docker inspect centos/command_test_seventh:0.0.3,可以看到镜像的详细信息,如下:

"Config": {
    "Labels": {
        "org.label-schema.build-date": "20200114",
        "org.label-schema.license": "GPLv2",
        "org.label-schema.name": "CentOS Base Image",
        "org.label-schema.schema-version": "1.0",
        "org.label-schema.vendor": "CentOS",
        "org.opencontainers.image.created": "2020-01-14 00:00:00-08:00",
        "org.opencontainers.image.licenses": "GPL-2.0-only",
        "org.opencontainers.image.title": "CentOS Base Image",
        "org.opencontainers.image.vendor": "CentOS",
        "type": "docker test",
        "version": "0.0.1"
    }
}

可以看到我们添加的元数据就已经添加到镜像中了。这里推荐在Dockerfile中将所有元数据按照上面例子一样,写到一条LABEL指令中,这样就可以避免过多的元数据添加创建过多的镜像层。