《精通Docker第三版》完整目录:
第一章 Docker概览
第二章 创建容器镜像
第三章 存储和发布镜像
第四章 管理容器
第五章 Docker Compose
第六章 Windows容器
第七章 Docker Machine
第八章 Docker Swarm
第九章 Docker和Kubernetes
第十章 在公有云上运行Docker
第十一章 Portainer – 一个Docker的GUI
第十二章 Docker安全
第十三章 Docker工作流
第十四章 Docker进阶
本章中我们将开始构建容器镜像。我们会来看看使用Docker内置工具定义和构建镜像的不同方式。本章涵盖的内容主要有:
- Dockerfile简介
- 使用Dockerfile构建容器镜像
- 使用已有容器构建容器镜像
- 从0开始构建容器镜像
- 使用环境变量构建容器镜像
- 使用多阶段构建创建容器镜像
技术准备
上一章中,我们在如下的目标操作系统中安装了Docker:
- macOS High Sierra及以上版本
- Windows 10专业版
- Ubuntu 18.04
本章中,我们将使用所安装的Docker来构建镜像。虽然本章中的截屏来自我个人偏好的操作系统macOS,这些Docker命令可以运行在我们所安装的这三种操作系统中。但其中的一些非常少的支持命令,仅能用于macOS和基于Linux的操作系统。
本章中的代码可在以下链接找到:https://github.com/alanhou/mastering-docker/tree/master/Chapter02
通过如下视频可查看实时代码运行效果:http://t.cn/Ai9Q0FPO
Dockerfile简介
这一部分中,我们将深入讨论Dockerfile,以及一些最佳实践。那么什么是Dockerfile呢?
Dockerfile是一个包含一组用户定义指令的普通文本文件。下面我们就会看到,在Docker镜像构建命令调用Dockerfile时,它会被用于组装一个容器镜像。Dockerfile类似于下面这样:
1 2 3 4 5 6 7 8 9 10 11 12 |
FROM alpine:latest LABEL maintainer="Russ McKendrick <russ@mckendrick.io>" LABEL description="This example Dockerfile installs NGINX." RUN apk add --update nginx && \ rm -rf /var/cache/apk/* && \ mkdir -p /tmp/nginx/ COPY files/nginx.conf /etc/nginx/nginx.conf COPY files/default.conf /etc/nginx/conf.d/default.conf ADD files/html.tar.gz /usr/share/nginx/ EXPOSE 80/tcp ENTRYPOINT ["nginx"] CMD ["-g", "daemon off;"] |
我们可以看到,无需过多解释,很容易就可以知道每一步是做什么的。Dockerfile告诉构建命令执行的操作。
在我们学习以上文件之前,先快速的讲解一下Alpine Linux。
ℹ️Alpine Linux是一个小型、独立开发为安全所设计的非商业Linux发行版本,高效且易于使用。虽然是小型(参见前面的内容),它因含各类包的扩展仓库为容器镜像提供了一个稳固的基础,同时也要归功于非官方的grsecurity/PaX接口,它是内核的补丁,并提供对潜在的 0 day 及其它漏洞积极的保护措施。
Alpine Linux,因其大小及足够强大,成为了Docker官方提供的容器镜像的默认基础镜像。因此,本书中将会一直使用到它。为了解Alpine Linux的官方镜像到底有多小,我们来对比写本书时市面上的其它发行版本。
可以从Terminal的输出中看到,Alpine Linux 仅占用 5.53 MB,远小于最大的镜像Fedora,占用了275 MB。安装一个全新的Alpine Linux ,会占用130 MB的空间,这也仅是Fedora容器镜像的一半大小。
深入回顾Dockerfile
我们来看一下以上Dockerfile示例中所使用到的指令。以出现的顺序来进行讲解:
- FROM
- LABEL
- RUN
- COPY和ADD
- EXPOSE
- ENTRYPOINT和CMD
- 其它Dockerfile指令
FROM
FROM指令告诉Docker以什么来作为我们镜像的基础,这个前面已经讲到了,我们使用的是Alpine Linux,因此仅需将所需用到的镜像名称和发行标签写入即可。在我们的示例中,使用的是官方Alpine Linux镜像的最新版,这里仅仅需要添加alpine:latest。
LABEL
LABEL指令可用于为镜像添加额外的信息。这一信息可以是版本号、描述等各种信息。同时推荐不要添加过多的标签。一个好的标签结构有助于其他人随后的使用。
但是,使用过多的标签会导致镜像的低效,我推荐使用http://label-schema.org/中所详细描述的标签标准。我们可以通过使用如下的Docker查看命令来看容器的标签:
1 |
$ docker image inspect <IMAGE_ID> |
也可以使用如下命令来过滤出标签:
1 |
$ docker image inspect -f {{.Config.Labels}} <IMAGE_ID> |
在示例Dockerfile中,我们添加了两个标签:
- maintainer=”Russ McKendrick <russ@mckendrick.io>”添加帮助镜像的终端用户辨识的标签,表明由谁来维护镜像。
- description=”This example Dockerfile installs NGINX.”添加了镜像是什么的简短描述。
通常最好是在从镜像创建容器时定义标签,而不是在构建的时候,因此最好是仅用标签记录镜像的元数据,而不添加其它内容。
RUN
RUN指令用于与镜像交互来安装软件及运行脚本、命令和其它任务。可以看到在RUN指令中我们实际上运行了三条命令:
1 2 3 |
RUN apk add --update nginx && \ rm -rf /var/cache/apk/* && \ mkdir -p /tmp/nginx/ |
三条命令中的第一条和在 Alpine Linux主机上运行如下命令是等价的:
1 |
$ apk add --update nginx |
该命令使用Alpine Linux的包管理器来安装nginx。
小贴士:我们使用&&运算符来在前一条命令成功执行后运行下一条命令。为了让所运行的命令看起来更加清晰,我们使用了\,这样可以将命令分隔到多行中,更易于阅读。
这一链式命令中的下一条删除临时文件等内容来让镜像保持最小的尺寸:
1 |
$ rm -rf /var/cache/apk/* |
链式命令的最后一条命令使用/tmp/nginx/创建了一个文件夹,这样在运行容器时可以正确的启动nginx。
1 |
$ mkdir -p /tmp/nginx/ |
我们也可以在Dockerfile中使用如下命令来实现同样的效果:
1 2 3 |
RUN apk add --update nginx RUN rm -rf /var/cache/apk/* RUN mkdir -p /tmp/nginx/ |
但是和添加多个标签一样,这被视为一种效率较低的做法,因为会增加我们镜像的整体大小,这是我们所极力要避免的。这还有一些有效用例,在本章稍后我们会进行学习。最重要的是,这一运行命令的方法应避免在构建镜像时使用。
COPY和ADD
乍一看,COPY和ADD好像执行相同的任务,但是它们有很多重要的区别。COPY是更为直白的一条指令:
1 2 |
COPY files/nginx.conf /etc/nginx/nginx.conf COPY files/default.conf /etc/nginx/conf.d/default.conf |
你可能已经猜到了,我们从构建镜像的主机目录中拷贝了两个文件。第一个文件为nginx.conf,其中包含了一个基础的 Nginx 配置文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
user nginx; worker_processes 1; error_log /var/log/nginx/error.log warn; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile off; keepalive_timeout 65; include /etc/nginx/conf.d/*.conf; } |
这将重写RUN指令中APK安装中的NGINX的配置。下一个文件default.conf,是我们可以配置的最简单的虚拟主机代码块,内容如下:
1 2 3 4 5 |
server { location / { root /usr/share/nginx/html; } } |
同样,这将重写已有文件。看起来到这已经可以了,为什么还要使用ADD指令呢?本例中,这部分的内容如下:
1 |
ADD files/html.tar.gz /usr/share/nginx/ |
可以看出,我们添加了一个名为html.tar.gz的文件,但并没有在Dockerfile中做什么对压缩包进行解压的实际操作。这是因为ADD会自动上传、解压指定路径的文件并将内容放入结果文件夹中,本例中的这一目录为/usr/share/nginx/。这样我们的根目录即为/usr/share/nginx/html/,与我们复制到镜像中的default.conf 所定义的虚拟主机代码块一致。
ADD指令也可以用于从远程数据源添加内容。例如我们这样写:
1 |
ADD http://www.myremotesource.com/files/html.tar.gz /usr/share/nginx/ |
以上的命令行会从http://www.myremotesource.com/files/下载html.tar.gz并将文件放到镜像的 /usr/share/nginx/文件夹中。远程数据源的压缩文件会作为一个文件,不会进行解压缩,在使用时应考虑到这一点,这表示该文件应在RUN指令之前进行添加,这样我们将手动解压该文件夹并同时删除html.tar.gz文件。
EXPOSE
EXPOSE指令,告诉Docker在镜像被执行后,在运行时暴露所定义的端口和协议。该指定不会与主机的端口产生映射,而是打开相应端口,允许容器网络中对该服务的访问。
例如,此处的Dockerfile中,我们告诉Docker在每次镜像运行时打开80端口:
1 |
EXPOSE 80/tcp |
ENTRYPOINT和CMD
在CMD之上使用ENTRYPOINT的好处在于,下面我们也会看到,我们可以对它们进行配合使用。ENTRYPOINT可以独自使用,但仅当你想要让容器可执行时才独立使用ENTRYPOINT。
作为参照,如果想要使用一些CLI命令,你就不止是要指定CLI命令。你可能需要添加一些希望命令解析的其它参数。这就是独立使用ENTRYPOINT时的情况。
例如,如果我们想要在容器内执行一条默认命令,可以与下例进行类似的操作,但要确保要使用一条保持容器启动的命令。本例,我们使用了如下的内容:
1 2 |
ENTRYPOINT ["nginx"] CMD ["-g", "daemon off;"] |
这表示在从镜像启动容器时,执行nginx二进制命令,这在ENTRYPOINT中进行了定义,然后执行CMD中的内容,这与如下命令是等价的:
1 |
$ nginx -g daemon off; |
另一个如何使用ENTRYPOINT的示例如下:
1 |
$ docker container run --name nginx-version dockerfile-example -v |
这相当于在我们的主机上运行如下命令:
1 |
$ nginx -v |
注意我们并没有告诉Docker去使用nginx。因为我们将nginx二进制文件作为了我们的入口端点,我们传入的任意命令会重写在Dockerfile中所定义的CMD。
这将显示我们所安装的nginx的版本,我们的容器将会停止,因为nginx二进制仅会执行来显示版本信息,然后就会停止进程。本章稍后在构建好镜像后我们会来看一下这一示例。
其它Dockerfile指令
以上示例Dockerfile中还有一些未涉及的指令。下面我们就来一起看一下。
USER
USER指令让我们指定在命令运行时使用的用户名。USER指令可用于Dockerfile中的RUN指令、CMD指令或ENTRYPOINT指令。同时USER指令中定义的用户必须要是存在的,否则镜像的构建就会失败。使用USER指令还可能会带来权限问题,不仅是在容器自身中,还会存在我们挂载的数据卷中。
WORKDIR
WORKDIR指令可对USER指令作用的帧指令集设置工作目录(RUN, CMD和ENTRYPOINT)。它还允许我们使用CMD和ADD指令。
ONBUILD
ONBUILD指令让我们可以存储一系列要使用的命令,来作为另一个容器镜像的基础镜像在之后使用镜像时进行使用。
例如,在我们要给开发者一个镜像但他想要测试的代码基础不同时,我们可以使用ONBUILD指令来实际用到代码前建立一个基础。然后,开发者可以只需在你所告诉他们的目录中添加代码,这样在他们运行一个新的Docker构建命令时,就会将这些代码加到运行的镜像中。
ONBUILD指令可以与ADD和RUN指令一起使用,如下例所示:
1 |
ONBUILD RUN apk update && apk upgrade && rm -rf /var/cache/apk/* |
这样在每次将我们的镜像作为另一个容器镜像的基础时,就会运行一次更新以及包的升级。
ENV
ENV在构建以及执行时在镜像内设置环境变量。这些变量可以在启动镜像时进行重载。
Dockerfiles最佳实践
我们已经讲解了Dockerfile指令,下面来看一下编写我们自己的Dockerfile的最佳实践:
- 我们应习惯于使用 .dockerignore文件。下一节中我们会讲解 .dockerignore文件;如果你习惯于使用 .gitignore文件,它们非常的类似。它会在构建过程中忽略你所在文件中指定的内容。
- 记住在一个文件夹内仅使用一个Dockerfile来组织容器。
- 对Dockerfile使用版本控制系统,如Git;和其它文本类文件一样,版本控制有助于向前开发以及在必要时向后回退。
- 对镜像尽可能最小化包的数量。其中最大的一个目标是实现构建尽量小的镜像。不安装非必要的包将极大地有助于实现这一目标。
- 确保每个容器仅有一个应用进程。每当需要一个新的应用进程时,最佳实践是使用一个新的容器来运行该应用。
- 保持简洁;过度复杂的Dockerfile会导致臃肿,同时也会在不断使用中带来一些潜在的问题。
- 通过示例学习。Docker自身有详细的样式指南来指导在 Docker Hub上发布官方镜像。可在本章结束处的扩展阅读一节中找到这一链接 。
构建容器镜像
这一部分中,我们将讲解docker镜像的构建命令。这就是他们所说的新车上路了。是时候构建基础镜像来开始以后的镜像创建了。我们会看实现这一目标的不同方法。把它当成在早期通过虚拟机器创建的一个模板。它通过完成复杂工作来节省时间,我们将只需创建需添加到新镜像中的应用。
在使用docker build命令时有很多参数可以使用。我们就来对docker image build命令使用非常方便的–help参数查看所能执行的操作吧:
1 |
$ docker image build --help |
这时会列出很多的标记,可在构建镜像时传递。这里你可能会觉得一时难以消化,但在这些选项中,我们只需使用– tag或其简写-t来为镜像命名。
我们可以使用其它选项来限制构建的进程所会使用的CPU和内存。有些情况下,你可能不希望构建命令占用它所要占用的那么多CPU或内存。这样会导致运行更慢一些,但如果在本机或生产服务器上进行运行,而构建过程较长,则可能会需要设置一个限制。还有一些影响启动构建镜像的容器网络配置的选项。
日常你不需要使用–file或-f参数,因为会在Dockerfile文件所在文件夹中运行docker build命令。将Dockerfile放在不同的文件夹下可以对文件进行分类,并使用相同规则的文件名。
还有值得一提的是,我们可以在构建的时候传递额外的环境变量来作为参数,但这些变量在构建时使用,容器镜像并不会继承它们。这对于传递代理设置等信息非常有用,在初始构建/测试环境时可以使用。
前面所讨论到的.dockerignore文件,用于排除docker build使用的文件各目录,因为默认Dockerfile所在的的文件夹下的所有文件都会被上传。我们还讨论了将Dockerfile放在一个单独的文件夹中,对.dockerignore也是如此。它应放在Dockerfile所在的文件夹下。
将想要在镜像中使用的所有文件放在相同文件夹中,可以有助将.dockerignore文件(如果有的话)中的项目数量保持最少。
使用Dockerfile来构建容器镜像
我们要来看用于构建基础容器镜像的第一个方法是创建一个Dockerfile。事实上,我们会使用前面小节的Dockerfile,然后对其执行一个docker image build 命令来为我们获取一个nginx镜像。因此,我们再来看一看这个Dockerfile:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
FROM alpine:latest LABEL maintainer="Russ McKendrick <russ@mckendrick.io>" LABEL description="This example Dockerfile installs NGINX." RUN apk add --update nginx && \ rm -rf /var/cache/apk/* && \ mkdir -p /tmp/nginx/ COPY files/nginx.conf /etc/nginx/nginx.conf COPY files/default.conf /etc/nginx/conf.d/default.conf ADD files/html.tar.gz /usr/share/nginx/ EXPOSE 80/tcp ENTRYPOINT ["nginx"] CMD ["-g", "daemon off;"] |
小贴士:另外你还需要在files文件夹中放入default.conf, html.tar.gz和nginx.conf这些文件。你可以在相应的 GitHub 仓库中找到这些文件。
因此,我们有两种方式来构建这一镜像。第一种方式是在使用docker image build命令时指定-f参数。我们还将利用-t参数来给新的镜像一个唯一名称:
1 |
$ docker image build --file <path_to_Dockerfile> --tag <REPOSITORY>:<TAG> . |
这里,<REPOSITORY>通常为用于登录Docker Hub的用户名。我们会在第三章 存储和发布镜像中详细了解;现在我们将使用local,<TAG>是你想要指定的唯一容器值。通常这会是版本号或其它描述符:
1 |
$ docker image build --file /path/to/your/dockerfile --tag local:dockerfile-example . |
通常,不会使用 –file参数,这在需要向新镜像中添加其它文件时就会有些微妙了。一种简单的方式是将Dockerfile放在一个独立的文件夹中,和想要通过ADD或COPY指令注入到镜像的其它文件放在相同目录中:
1 |
$ docker image build --tag local:dockerfile-example . |
最重要的是要记得最后面的那个点号。这告诉docker image build命令在当前文件中构建镜像。在构建镜像时,你应该会看到类似如下这样的Terminal输出:
一旦完成了构建 ,你应该可以运行如下命令来查看镜像是否可用,以及该镜像的大小:
你可以通过如下这条命令来用新构建的镜像启动一个容器:
1 |
$ docker container run -d --name dockerfile-example -p 8080:80 local:dockerfile-example |
打开浏览器交访问http://localhost:8080/应该会显示一个很简单的网页,类似下面这样:
接下来,我们快速运行本章前面小节中所提到的几条命令,先从下面这条命令开始:
1 |
$ docker container run --name nginx-version local:dockerfile-example -v |
可以在如下的Terminal输出中看出,当前运行的nginx版本为1.14.2:
下面一条命令可查看运行中的容器,既然我们已经构建了自己的第一个镜像,它可以显示在构建时嵌入的各个标签。运行如下命令来查看这一信息:
1 |
$ docker image inspect -f {{.Config.Labels}} local:dockerfile-example |
在以下输出中可以看出,这条命令显示了我们所输入的信息:
在继续学习之前,你可以运行如下命令来停止并删除我们所启动的容器:
1 2 |
$ docker container stop dockerfile-example $ docker container rm dockerfile-example nginx-version |
我们会在第四章 管理容器中深入地学习Docker容器的命令。
使用已有容器
构建一个基础镜像的最简易方式是使用Docker Hub上的一个官方镜像。Docker还在它们的GitHub仓库中维护这些官方构建的Dockerfile。因此使用其他人已创建的现有镜像至少有两种选择。通过使用该Dockerfile,你可以看到构建的镜像中所包含的内容并添加自己的所需。然后如果需要稍后修改或进行分享你可以对该Dockerfile进行版本管理。
还有另一种方式来实现相同的使用,但是不推荐也不是一种良好实践,我强烈不推荐你使用它。
小贴士:我仅在原型阶段使用这种方法,用于在交互 shell 中查看命令是否如预期正常运行,然后再放到Dockerfile中。你应保持使用一个Dockerfile。
首先,我们应下载想要作为基础的镜像,和此前一样,我们将要使用Alpine Linux:
1 |
$ docker image pull alpine:latest |
接着我们需要在前台运行一个容器来与其进行交互:
1 |
$ docker container run -it --name alpine-test alpine /bin/sh |
一旦运行了容器,你可以在这种情况下使用apk命令来添加所需要的包,或者其它适用你的Linux习惯的包管理命令。
例如,如下命令将安装nginx:
1 2 3 4 5 6 |
$ apk update $ apk upgrade $ apk add --update nginx $ rm -rf /var/cache/apk/* $ mkdir -p /tmp/nginx/ $ exit |
在你安装了这些所需要的包之后,需要保存一下这个容器。在以上命令集最后的exit命令会停止在运行的容器,因为我们正在脱离的shell进程也正是保持容器在前台运行的进程。可以在如下的Terminal输出中看到:
小贴士:到这里你应该停下来了,我不推荐你使用前述的命令来创建和发布镜像,除非是本节下一部分中讲到的使用情况。
要保存我们所停止的容器为一个镜像,需要进行类似下面的操作:
1 |
$ docker container commit <container_name> <REPOSITORY>:<TAG> |
例如,我运行了如下命令来保存一个我们所启动和自定义的容器的拷贝:
1 |
$ docker container commit alpine-test local:broken-container |
注意到我们将这个镜像命名为broken-container了吗?因为这一方法的一种使用情况是,如果由于某种原因,你的容器出现了问题,那么将失败的镜像保存一个镜像将极为有用,或者甚至是如果你需要协助来对问题追根究底的话,将其导出为一个TAR文件来分享其它人。
要保存这个镜像文件,仅需运行如下命令:
1 |
$ docker image save -o <name_of_file.tar> <REPOSITORY>:<TAG> |
在本例中,我运行了如下命令:
1 |
$ docker image save -o broken-container.tar local:broken-container |
这会生成一个11MB 的名为broken-container.tar的文件。你可以解压这个文件来查看其中的结构如下:
这个镜像是由一组JSON文件、文件夹和其它的TAR文件组成。所有镜像都遵循这一结构,所以你可能会想,这种方法有什么不好呢?
最重要的原因是信任,前面也提到了,你的终端用户不能轻易地查看镜像中所运行的内容。你是否会从未知来源随机下载一个预打包的镜像,来执行工作,而不去查看这个镜像是如何构建的呢?天知道它是如何配置的以及安装了什么包?通过Dockerfile,你可以具体地看到执行了什么来创建该镜像,但使用这里所描述的方法,你什么也看不到。
另一个原因是,构建一个良好的默认包集合会非常的困难,例如,如果你要以这种方式构建一个镜像,那么你就无法利用ENTRYPOINT和CMD等的特性,或者甚至是最基础的指令,如EXPOSE。取而代之的是,用户在执行他们的docker container run命令时需要定义所有需要的内容。
在早期的Docker中,发布以这种方式准备的镜像司空见惯。事实上,我心存愧疚,因为在运维的背景下,启动一台“机器”,添加引导程序,然后创建最终版本非常的理所当然。幸运的是,在过去几年中,Docker对构建功能进行了扩展,以至于这种选项甚至不再被考虑。
从0开始构建容器镜像
至此,我们使用了Docker Hub已准备的镜像来作为我们的基础镜像。这在某种程度上是有机会规避的,并从0开始推出你自己的镜像。
这里,在你听到从0开始时,它字面表示你要从无到有。这正是我们这里所有的,你绝对什么都有,并需在其上进行构建。现在这可以是一件好事,因为可以保持镜像非常的小,但如果你刚刚入手Docker也会是一个不利的局面,因为它变得非常复杂。
Docker已经为我们做了一些苦力活,在Docker Hub上创建一个名为scratch的空的TAR文件;你可以在你的Dockerfile的FROM部分中使用它。你可以基于它来构建整个Docker,然后添加所需的部分。
我们再次来使用Alpine Linux来作为镜像的基础操作系统。这么做的原因不仅在于它以ISO发布Docker镜像或其它各种虚拟机器镜像,还在于整个操作系统都以压缩的TAR文件开放。你可在仓库中或者 Alpine Linux下载页面找到下载链接。
要下载一个拷贝,只需在下载页面选择适当的下载链接,这可以在https://www.alpinelinux.org/downloads/上找到。我所使用的是MINI ROOT FILESYSTEM部分中的x86_64。
一旦下载了该文件,你需要使用scratch创建一个Dockerfile并添加这一tar.gz文件,确保使用正确的文件,如下例所示:
1 2 3 |
FROM scratch ADD files/alpine-minirootfs-3.9.2-x86_64.tar.gz / CMD ["/bin/sh"] |
既然你已经有了自己的Dockerfile以及操作系统的TAR文件,你可以像其它Docker镜像一样通过运行如下命令来构建你自己的镜像:
1 |
$ docker image build --tag local:fromscratch . |
你可以通过运行如下命令与我们所构建的其它镜像对比镜像的大小:
1 |
$ docker image ls |
我们已经构建了自己的镜像,可通过运行如下命令来对其进行测试:
1 |
$ docker container run -it --name alpine-test local:fromscratch /bin/sh |
小贴士:如果你收到了一条报错,可能是你已经创建或运行了一个名为alpine-test的容器。通过运行docker container stop alpine-test接docker container rm alpine-test来删除这个容器。
这应该会启动Alpine Linux镜像的shell。你可以运行如下命令来进行查看:
1 |
$ cat /etc/*release |
这会显示容器所运行的发行版本的信息。了解整个过程中进行的操作,可参见如下Terminal输出:
这一切看起来非常的直接,主要归功于Alpine Linux对其操作系统的打包方式。当你选择其它方式打包的其它发行版本时,这会变得更为复杂。
有一些工具可用于生成操作系统包。这里我们不会深入到如何使用这些工具中的任何一个,因为如果你考虑用这种方法,你极有可能有非常具体的要求。在本章结束处的扩展阅读一节中有一个工具列表。
那么这些要求是什么呢?对大部分的人来说,这将会是已下线应用;比如,有一个应用要求用到不再被支持的操作系统或Docker Hub上已不存在,而你需要一个新的平台来支持这一应用。那么你应该可以调整镜像并安装该应用,允许你在一个新的、有支持服务的系统/架构上托管老的应用。
使用环境变量
这一部分,我们将讲解非常强大的环境变量(ENV),因为它们会经常出现。你可以在自己的Dockerfile中大量地使用到ENV来完成很多事情 。如果你对写代码不陌生的话,那么对它也会很熟悉的。
对于类似我自己的其他人,一开始会十分畏惧,但千万不要灰心。一旦你掌握了它们之后就会成为宝贵的资源。它们可用于在运行容器时设置信息,这表示你无需在服务器上运行的Dockerfile或脚本中大量的更新命令。
在Dockerfile中使用环境变量,你可以使用ENV指令。ENV指令的结构如下:
1 2 |
ENV <key> <value> ENV username admin |
另外,你也可以在两者之间使用等号:
1 2 |
ENV <key>=<value> ENV username=admin |
现在的问题是,为什么有两种定义环境变量的方法以及它们的区别是什么?使用第一个示例,你仅能在一行中设置一个ENV,但是它可很易于阅读和查看。使用第二个ENV,你可以在同一行中设置多个环境变量,如下所示:
1 |
ENV username=admin database=wordpress tableprefix=wp |
你可以使用docker inspect 命令来查看一个镜像上使用了哪些ENV:
1 |
$ docker image inspect <IMAGE_ID> |
既然我们知道了如何在Dockerfile中设置变量,下面就来进行实际操作吧。截至目前,我们已经使用Dockerfile来构建了一个刚刚安装了Nginx的简单镜像。我们再来构建一个更为动态的镜像。使用Alpine Linux,我们将进行如下操作:
- 设置一个ENV来定义我们想要安装的PHP版本
- 对我们所选的PHP版本安装Apache2
- 设置镜像来让Apache2可正常启动
- 删除默认的index.html并添加一个index.php文件来显示phpinfo命令的结果
- 在容器上暴露80端口
- 设置Apache来让其成为默认进程
我们的Dockerfile类似如下这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
FROM alpine:latest LABEL maintainer="Russ McKendrick <russ@mckendrick.io>" LABEL description="This example Dockerfile installs Apache & PHP." ENV PHPVERSION 7 RUN apk add --update apache2 php${PHPVERSION}-apache2 php${PHPVERSION} && \ rm -rf /var/cache/apk/* && \ # mkdir /run/apache2/ && \ rm -rf /var/www/localhost/htdocs/index.html && \ echo "<?php phpinfo(); ?>" > /var/www/localhost/htdocs/index.php && \ chmod 755 /var/www/localhost/htdocs/index.php EXPOSE 80/tcp ENTRYPOINT ["httpd"] CMD ["-D", "FOREGROUND"] |
译者注:原文的创建目录会产生报错,所以予以注释mkdir: can’t create directory ‘/run/apache2/’: File exists
如你所见,我们选择了安装PHP7,我们可以通过运行如下命令来构建镜像:
1 |
$ docker build --tag local/apache-php:7 . |
注意我们是如何微调了命令。这次我调用镜像local/apache-php并使用版本7来打标签。以上命令所获取的完整输出如下:
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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
Sending build context to Docker daemon 2.56kB Step 1/8 : FROM alpine:latest ---> 5cb3aa00f899 Step 2/8 : LABEL maintainer="Russ McKendrick <russ@mckendrick.io>" ---> Using cache ---> a3be246eb503 Step 3/8 : LABEL description="This example Dockerfile installs Apache & PHP." ---> Running in 40e0cef22c57 Removing intermediate container 40e0cef22c57 ---> 5169b2b52780 Step 4/8 : ENV PHPVERSION 7 ---> Running in 1a4793038ef9 Removing intermediate container 1a4793038ef9 ---> 98b83090ab07 Step 5/8 : RUN apk add --update apache2 php${PHPVERSION}-apache2 php${PHPVERSION} && rm -rf /var/cache/apk/* && rm -rf /var/www/localhost/htdocs/index.html && echo "<?php phpinfo(); ?>" > /var/www/localhost/htdocs/index.php && chmod 755 /var/www/localhost/htdocs/index.php ---> Running in 459fc753daaf fetch http://dl-cdn.alpinelinux.org/alpine/v3.9/main/x86_64/APKINDEX.tar.gz fetch http://dl-cdn.alpinelinux.org/alpine/v3.9/community/x86_64/APKINDEX.tar.gz (1/14) Installing libuuid (2.33-r0) (2/14) Installing apr (1.6.5-r0) (3/14) Installing expat (2.2.6-r0) (4/14) Installing apr-util (1.6.1-r5) (5/14) Installing pcre (8.42-r1) (6/14) Installing apache2 (2.4.38-r2) Executing apache2-2.4.38-r2.pre-install (7/14) Installing php7-common (7.2.14-r0) (8/14) Installing ncurses-terminfo-base (6.1_p20190105-r0) (9/14) Installing ncurses-terminfo (6.1_p20190105-r0) (10/14) Installing ncurses-libs (6.1_p20190105-r0) (11/14) Installing libedit (20181209.3.1-r0) (12/14) Installing libxml2 (2.9.9-r1) (13/14) Installing php7 (7.2.14-r0) (14/14) Installing php7-apache2 (7.2.14-r0) Executing busybox-1.29.3-r10.trigger OK: 27 MiB in 28 packages Removing intermediate container 459fc753daaf ---> 5d316cbaa6bb Step 6/8 : EXPOSE 80/tcp ---> Running in 5b442ff67ac8 Removing intermediate container 5b442ff67ac8 ---> 911f0462b6e8 Step 7/8 : ENTRYPOINT ["httpd"] ---> Running in b9a00b8bfecd Removing intermediate container b9a00b8bfecd ---> f7effbf6ac71 Step 8/8 : CMD ["-D", "FOREGROUND"] ---> Running in fc606466fa30 Removing intermediate container fc606466fa30 ---> afc91f3aad7f Successfully built afc91f3aad7f Successfully tagged local/apache-php:7 |
我们可以使用镜像来启动容器以查看运行是否如预期一样,运行命令如下:
1 |
$ docker container run -d -p 8080:80 --name apache-php7 local/apache-php:7 |
启动之后,打开浏览器并访问http://localhost:8080/,会看到一个已使用了PHP7的页面:
小贴士:不要与下一部分产生混淆,不存在PHP6。想要了解其中的原因,请访问https://wiki.php.net/rfc/php6。
下面,将你的Dockerfile中的PHPVERSION由7修改为5,然后运行如下命令来构建一个新的镜像:
1 |
$ docker image build --tag local/apache-php:5 . |
译者注:alpine 3.9中已取消了对 PHP5的支持(ERROR: unsatisfiable constraints: php5 (missing):),因此为执行这段操作,Alan 手动修改了 Dockerfile 中对应的版本号为3.8,点此查看支持的软件
从如下的Terminal输出中可以看出,大部分的输出内容相同,除了安装的包有所不同:
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 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
Sending build context to Docker daemon 2.56kB Step 1/9 : FROM alpine:3.8 3.8: Pulling from library/alpine c87736221ed0: Pull complete Digest: sha256:a4d41fa0d6bb5b1194189bab4234b1f2abfabb4728bda295f5c53d89766aa046 Status: Downloaded newer image for alpine:3.8 ---> dac705114996 Step 2/9 : LABEL maintainer="Russ McKendrick <russ@mckendrick.io>" ---> Running in aa89de1346ff Removing intermediate container aa89de1346ff ---> ac7bb3387381 Step 3/9 : LABEL description="This example Dockerfile installs Apache & PHP." ---> Running in 2c58bf80e932 Removing intermediate container 2c58bf80e932 ---> 74168bdc4896 Step 4/9 : ENV PHPVERSION=5 ---> Running in 0c2b39fa5e62 Removing intermediate container 0c2b39fa5e62 ---> 201c8de47cb7 Step 5/9 : RUN apk update ---> Running in 52ccdbd239d3 fetch http://dl-cdn.alpinelinux.org/alpine/v3.8/main/x86_64/APKINDEX.tar.gz fetch http://dl-cdn.alpinelinux.org/alpine/v3.8/community/x86_64/APKINDEX.tar.gz v3.8.4-6-g00a38c79f8 [http://dl-cdn.alpinelinux.org/alpine/v3.8/main] v3.8.4-4-gc27a9a0149 [http://dl-cdn.alpinelinux.org/alpine/v3.8/community] OK: 9544 distinct packages available Removing intermediate container 52ccdbd239d3 ---> 48160e1ed1bd Step 6/9 : RUN apk add --update apache2 php${PHPVERSION}-apache2 php${PHPVERSION} && rm -rf /var/cache/apk/* && mkdir /run/apache2/ && rm -rf /var/www/localhost/htdocs/index.html && echo "<?php phpinfo(); ?>" > /var/www/localhost/htdocs/index.php && chmod 755 /var/www/localhost/htdocs/index.php ---> Running in 99e09a53a753 (1/15) Installing libuuid (2.32-r0) (2/15) Installing apr (1.6.3-r1) (3/15) Installing expat (2.2.5-r0) (4/15) Installing apr-util (1.6.1-r3) (5/15) Installing pcre (8.42-r0) (6/15) Installing apache2 (2.4.38-r3) Executing apache2-2.4.38-r3.pre-install (7/15) Installing php5-common (5.6.40-r0) (8/15) Installing ncurses-terminfo-base (6.1_p20180818-r1) (9/15) Installing ncurses-terminfo (6.1_p20180818-r1) (10/15) Installing ncurses-libs (6.1_p20180818-r1) (11/15) Installing readline (7.0.003-r0) (12/15) Installing libxml2 (2.9.8-r1) (13/15) Installing php5-cli (5.6.40-r0) (14/15) Installing php5 (5.6.40-r0) (15/15) Installing php5-apache2 (5.6.40-r0) Executing busybox-1.28.4-r3.trigger OK: 48 MiB in 28 packages Removing intermediate container 99e09a53a753 ---> 8fd9d5967032 Step 7/9 : EXPOSE 80/tcp ---> Running in 38f92582a414 Removing intermediate container 38f92582a414 ---> 5039716f3676 Step 8/9 : ENTRYPOINT ["httpd"] ---> Running in 73d3407b47e9 Removing intermediate container 73d3407b47e9 ---> 169088fd2d73 Step 9/9 : CMD ["-D", "FOREGROUND"] ---> Running in 0c5e5e24649f Removing intermediate container 0c5e5e24649f ---> 32a39a04e2c4 Successfully built 32a39a04e2c4 Successfully tagged local/apache-php:5 |
我们可以启动一个容器,这次使用端口9090,通过使用如下命令:
1 |
$ docker container run -d -p 9090:80 --name apache-php5 local/apache-php:5 |
再次打开浏览器,这次我们访问http://localhost:9090/,应该会显示我们正在运行PHP5:
最后,我们可以通过运行如下命令来对比镜像的大小:
1 |
$ docker image ls |
在Terminal中应该会看到如下输出:
这里显示PHP7的镜像要远小于PHP5的镜像。我们来讨论一下构建这两个不同的容器镜像时所发生的事情。
所以到底发生了什么呢?在Docker启动Alpine Linux镜像来创建我们的镜像时,所做的第一件事是设置我们所定义的ENV,让它们在容器内的shell可以被使用。
我们很幸运,Alpine Linux中的PHP的命令机制只需我们修改版本号,保持了安装的包名相同,这表示我们运行的是如下的命令:
1 |
RUN apk add --update apache2 php${PHPVERSION}-apache2 php${PHPVERSION} |
实际上会被解释为:
1 |
RUN apk add --update apache2 php7-apache2 php7 |
或者对于PHP5,会被解释为如下命令:
1 |
RUN apk add --update apache2 php5-apache2 php5 |
这意味着我们无需进入整个Dockerfile,来手动替换掉版本号。这一方法对于从远程URL安装包时尤其有用,例如软件发布页面。
下面是更为高级的示例,一个安装并配置Consul by HashiCorp的Dockerfile。在Dockerfile中,我们使用环境变量来定义版本号以及我们所下载的文件的SHA256哈希值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
FROM alpine:latest LABEL maintainer="Russ McKendrick <russ@mckendrick.io>" LABEL description="An image with the latest version on Consul." ENV CONSUL_VERSION 1.2.2 ENV CONSUL_SHA256 7fa3b287b22b58283b8bd5479291161af2badbc945709eb5412840d91b912060 RUN apk add --update ca-certificates wget && \ wget -O consul.zip https://releases.hashicorp.com/consul/${CONSUL_VERSION}/consul_${CONSUL_VERSION}_linux_amd64.zip && \ echo "$CONSUL_SHA256 *consul.zip" | sha256sum -c - && \ unzip consul.zip && \ mv consul /bin/ && \ rm -rf consul.zip && \ rm -rf /tmp/* /var/cache/apk/* EXPOSE 8300 8301 8301/udp 8302 8302/udp 8400 8500 8600 8600/udp VOLUME [ "/data" ] ENTRYPOINT [ "/bin/consul" ] CMD [ "agent", "-data-dir", "/data", "-server", "-bootstrap-expect", "1", "-client=0.0.0.0"] |
可以看出,Dockerfile可以非常复杂,以及使用ENV会有助于文件的维护。不论何时发布了新版的Consul,我只需更新ENV行并提交到GitHub,它就会促发构建新的镜像,当然这是指我们进行了相关配置的情况下,我们会在下一章中来看这一情况。
你可能还注意到我们Dockerfile中使用了一个我们还未讲解的指令。不必担心,我们会在第四章 管理容器中讲解VOLUME指令。
使用多阶段构建
在这个我们使用Dockerfile和构建容器镜像旅程的最后一站中,我们将来看看构建镜像相对较新的方法。在本章前面的小节中,我们向镜像中添加二进制文件时,要么是通过Alpine Linux的APK这样的包管理器,要么像最后一个例子中那样下从软件商那里下载了预编译的二进制。
如果我们想要把我们自己的软件编译为构建的一部分呢?曾经,我们需要使用包含完整构建环境的容器镜像,这样会非常的大。这意味着我们可能需要通过如下的流程来拼凑出一个脚本:
- 下载构建环境容器镜像并启动一个build容器
- 将源代码拷贝到build容器中
- 在build容器上编译源代码
- 将编译后的二进制从build容器拷贝出来
- 删除build容器
- 使用一个预先写好的Dockerfile来构建一个镜像并将二进制文件拷入
这是大量的逻辑,在理想的世界中,它应该是Docker的一部分。所幸Docker社区也是这么想的,并且实现它的功能,称为多阶段构建,在Docker 17.05中进行了引入。
Dockerfile包含两种不同的构建阶段。第一个阶段称为构建器,使用官方 Docker Hub中的官方Go容器镜像。这里我们安装一个基础程序,从GitHub上直接下载源代码,然后将其编译为静态二进制文件:
1 2 3 4 5 6 7 8 9 |
FROM golang:latest as builder WORKDIR /go-http-hello-world/ RUN go get -d -v golang.org/x/net/html ADD https://raw.githubusercontent.com/geetarista/go-http-hello-world/master/hello_world/hello_world.go ./hello_world.go RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . FROM scratch COPY --from=builder /go-http-hello-world/app . CMD ["./app"] |
因为我们的静态二进行有内置的web服务器,在操作系统的视角看我们无需呈现其它内容。藉于此,我们可以使用scratch来作为基础镜像,这表示所有我们的镜像会包含一个我们从构建器镜像所拷贝的静态二进制,而不会包含任何的构建器环境。
要构建该镜像,我们仅需运行如下命令:
1 |
$ docker image build --tag local:go-hello-world . |
该命令的输出可在如下代码块中看到,有趣的部分发生在第5步和第6步:
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 36 37 38 39 40 41 42 43 44 45 |
Sending build context to Docker daemon 2.048kB Step 1/8 : FROM golang:latest as builder latest: Pulling from library/golang e79bb959ec00: Already exists d4b7902036fe: Pull complete 1b2a72d4e030: Pull complete d54db43011fd: Pull complete 963c818ebafc: Pull complete 2c6333e9b74a: Pull complete 3b0c71504fac: Pull complete Digest: sha256:62538d25400afa368551fdeebbeed63f37a388327037438199cdf60b7f465639 Status: Downloaded newer image for golang:latest ---> 213fe73a3852 Step 2/8 : WORKDIR /go-http-hello-world/ ---> Running in 789c694c32af Removing intermediate container 789c694c32af ---> e18b47fc4c9c Step 3/8 : RUN go get -d -v golang.org/x/net/html ---> Running in 49a22da0a96c Fetching https://golang.org/x/net/html?go-get=1 Parsing meta tags from https://golang.org/x/net/html?go-get=1 (status code 200) get "golang.org/x/net/html": found meta tag get.metaImport{Prefix:"golang.org/x/net", VCS:"git", RepoRoot:"https://go.googlesource.com/net"} at https://golang.org/x/net/html?go-get=1 get "golang.org/x/net/html": verifying non-authoritative meta tag Fetching https://golang.org/x/net?go-get=1 Parsing meta tags from https://golang.org/x/net?go-get=1 (status code 200) golang.org/x/net (download) Removing intermediate container 49a22da0a96c ---> 1560fa053804 Step 4/8 : ADD https://raw.githubusercontent.com/geetarista/go-http-hello-world/master/hello_world/hello_world.go ./hello_world.go Downloading 393B ---> fda6f96b0c31 Step 5/8 : RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . ---> Running in 7bb6979d75ef Removing intermediate container 7bb6979d75ef ---> 574921bde1c8 Step 6/8 : FROM scratch ---> Step 7/8 : COPY --from=builder /go-http-hello-world/app . ---> 1585b6143803 Step 8/8 : CMD ["./app"] ---> Running in ec6a5d623e1e Removing intermediate container ec6a5d623e1e ---> 9ef2f9281eb9 Successfully built 9ef2f9281eb9 Successfully tagged local:go-hello-world |
如你所见,在第5步和第6步之间,我们的二进制被进行了编译,并且包含构建器环境的容器被删除了,仅保留了存储我们二进制的镜像。第7步中将二进制拷贝到使用scratch启动的全新镜像,其中仅包含我们所需的内容。
如果你运行如下命令,就会明白为什么不使用带有完整构建环境的应用了:
1 |
$ docker image ls |
以下截图显示我们输出中golang镜像为774MB;加入了我们的源代码和基础程序,大小上升至817MB:
但是,最终的镜像仅为7.32MB。我相信你一定会同意这节省了大量的空间。这也与本章前面讨论的最佳实践相符,仅将相关的内容加入到镜像中,以及让镜像尽量的小。
你可以使用如下命令启动容器来测试该应用:
1 |
$ docker container run -d -p 8000:80 --name go-hello-world local:go-hello-world |
可通过浏览器访问该应用,它仅在每次加载页面时对计数器进行递增。在macOS和Linux上进行测试,你可以像如下这样使用curl命令:
1 |
$ curl http://localhost:8000/ |
这将会得到类似如下的结果:
Windows用户仅需在浏览器中访问http://localhost:8000/。要停止并删除所运行的容器,使用如下命令:
1 2 |
$ docker container stop go-hello-world $ docker container rm go-hello-world |
可以看到,使用多阶段构建是一个相对简单的过程,并与我们已经逐渐熟悉的指令保持一致。
总结
本章中,我们深入地探讨了Dockerfile、编写它的最佳实践、docker image build命令以及我们可以构建容器的不同方式。我们还学习了环境变量,可用于从Dockerfile将不同的内容传递到我们的容器中。
既然我们已经了解如何使用Dockerfile来构建镜像,在下一章中,我们会在学习Docker Hub以及使用仓库服务所带来的所有好处。我们还会来看Docker仓库,它是开源的,因此你可以搭建自己的仓库来存储镜像,无需像使用Docker企业版本或第三方仓库服务那样支付费用。
课后问题
- 是非题:LABEL指令在构建镜像时为其打标签?
- ENTRYPOINT和CMD指令之间的区别是什么?
- 是非题:在使用ADD指令时,你不能下载并自动解压外部托管的压缩包?
- 使用已有容器来作为你镜像的基础镜像的有效用法是什么?
- EXPOSE指令所暴露的是什么?
扩展阅读
在以下链接你可以找到官方容器镜像的指南:
帮助你从已有安装创建容器的部分工具如下:
- Debootstrap: https://wiki.debian.org/Debootstrap/
- Yumbootstrap: https://github.com/dozzie/yumbootstrap/
- Rinse: https://salsa.debian.org/debian/rinse/
- Docker contrib脚本: https://github.com/moby/moby/tree/master/contrib/
最后, Go HTTP Hello World应用的完整GitHub仓库如下: