本章中我们将开始构建容器镜像。我们将学习使用原生Docker工具定义和构建镜像的5种方式。
我们还将讨论定义和构建自身镜像的推荐方式,以及一种虽不被看为最佳实践但有其用途的方式。
本章涵盖的内容主要有:
- Dockerfile简介
- 构建Dockerfile镜像
下面就开始我们的学习吧!
技术准备
本章中,我们将使用所安装的Docker来构建镜像。其中的少部分支持命令,仅能用于macOS和基于Linux的操作系统。
虽然本章中的截屏来自我个人偏好的操作系统macOS,这些Docker命令可以运行在我们所安装的这三种操作系统中。但其中的一些非常少的支持命令,仅能用于macOS和基于Linux的操作系统。
通过如下视频可查看实时代码运行效果:https://bit.ly/3h7oDX5
ℹ️虽然本章中的截屏来自我个人偏好的操作系统macOS,这些Docker命令可以运行在我们所安装的这三种操作系统中。
Dockerfile简介
这一部分中,我们将深入讨论Dockerfile,以及一些使用中的最佳实践。那么什么是Dockerfile呢?
Dockerfile是一个包含一组用户定义指令的普通文本文件。下面我们就会看到,在docker image build
命令调用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的官方镜像到底有多小,我们来对比写本书时市面上的其它发行版本:
图2.1 对比主流基础镜像的大小
可以从以上Terminal的输出中看到,Alpine Linux 仅占用 5.59 MB,远小于最大的镜像CentOS,占用了237 MB。在裸机上安装一个全新的Alpine Linux ,会占用约130 MB的空间,这也仅是CentOS容器镜像的一半大小。
深入回顾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> |
下图中可以看到CentOS镜像的标签展示:
图2.2 查看镜像标签
在示例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 |
你可能已经猜到了,我们从构建镜像主机的 files目录中拷贝了两个文件。第一个文件为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指令呢?
本例的Dockerfile中,ADD命令如下:
1 |
ADD files/html.tar.gz /usr/share/nginx/ |
可以看出,我们在添加一个名为html.tar.gz的文件,但在Dockerfile中却没有执行对压缩包的解压。这是国为ADD会自动上传、解压并添加结果文件夹和文件到所指定的路径中,本例中路径为/usr/share/nginx/。这样我们web 根路径就变为了/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指令
以上示例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文件。本单的构建Docker 镜像一节中我们会讲解 .dockerignore文件;如果你习惯于使用 .gitignore文件,它们非常的类似。它会在构建过程中忽略你所在文件中指定的内容。
- 记住在一个文件夹内仅使用一个Dockerfile来组织容器。
- 对Dockerfile使用版本控制系统,如Git;和其它文本类文件一样,版本控制有助于向前开发以及在必要时向后回退。
- 对镜像尽可能最小化包的数量。其中最大的一个目标是实现构建尽量小、尽量安全的镜像。不安装非必要的包将极大地有助于实现这一目标。
- 确保每个容器仅有一个应用进程。每当需要一个新的应用进程时,最佳实践是使用一个新的容器来运行该应用。
- 保持简洁;过度复杂的Dockerfile会导致臃肿,同时也会在不断使用中带来一些潜在的问题。
- 通过示例学习。Docker自身有详细的样式指南来指导在 Docker Hub上发布官方镜像。可在本章结束处的扩展阅读一节中找到这一链接 。
构建容器镜像
这一部分中,我们将讲解docker image build
命令。这就是他们所说的新车上路了。是时候构建基础镜像来开始以后的镜像创建了。我们会看实现这一目标的不同方法。把它当成在早期通过虚拟机器创建的一个模板。它通过完成复杂工作来节省时间,我们将只需创建需添加到新镜像中的应用。
在使用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。事实上,我们会使用前面小节的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命令时指定–file参数。我们还将利用–tag参数来给新的镜像一个唯一名称:
1 |
$ docker image build --file <path_to_Dockerfile> --tag <REPOSITORY>:<TAG> . |
这里,<REPOSITORY>通常为用于登录Docker Hub的用户名。我们会在第三章 存储和发布镜像中详细了解;但现在我们将使用local。<TAG>是你想要指定的唯一容器值。通常这会是版本号或其它描述符。
因为我们有一个Dockerfile文件,可以省略–file参数的使用。这就是构建镜像的第二种方法。以下实现的代码:
1 |
$ docker image build --tag local:dockerfile-example . |
最重要的是要记得最后面的那个点号(英文句号)。这告诉docker image build命令在当前文件中构建镜像。在构建镜像时,你应该会看到类似如下这样的Terminal输出:
图2.3 通过自己的Dockerfile构建镜像
一旦完成了构建 ,你应该可以运行如下命令来查看镜像是否可用,以及该镜像的大小:
1 |
$ docker image ls |
可以在下面的Terminal输出中看到我们镜像的大小为7.15 MB:
图2.4 查看容器镜像的大小
可以通过如下这条命令来用新构建的镜像启动一个容器:
1 |
$ docker container run -d --name dockerfile-example -p 8080:80 local:dockerfile-example |
这会启动一个名为dockerfile-example的容器。可以通过如下命令查看如下命令是否在运行:
1 |
$ docker container ls |
打开浏览器并访问http://localhost:8080/应该会显示一个很简单的网页,类似下面这样:
图2.5 在浏览器中查看容器
接下来,我们快速运行本章前面Dockerfile简介小节中所提到的几条命令,先从下面这条命令开始:
1 |
$ docker container run --name nginx-version local:dockerfile-example -v |
可以在如下的Terminal输出中看出,当前运行的nginx版本为1.16.1:
图2.6 查看Nginx 的版本
下面一条命令可运行过程中查看构建时所加入的标签。运行如下命令来查看这一信息:
1 |
$ docker image inspect -f {{.Config.Labels}} local:dockerfile-example |
从以下输出中可以看出,这条命令显示了我们所输入的信息:
图2.7 查看新构建镜像的标签
在继续后面的学习之前,你可以运行如下命令来停止并删除所启动的容器:
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输出中看到:
图2.8 查看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 |
这会生成一个7.9 MB的名为broken-container.tar的文件。你可以解压这个文件来查看其中的结构如下:
图2.9 JSON文件、文件夹和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文件开放。你可在本书配套GitHub仓库或者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.11.3-x86_64.tar.gz / CMD ["/bin/sh"] |
你可能在想,为什么要下载alpine-minirootfs-3.11.3-x86_64.tar.gz文件呢?是否可以直接使用http://dl-cdn.alpinelinux.org/alpine/v3.11/releases/x86_64/alpine-minirootfs-3.11.3-x86_64.tar.gz?
别忘了远程的压缩包被看作文件,只执行下载。通常这不是什么问题,因为我们可以添加一个RUN命令来解压缩文件,但因为我们使用的是scratch,还没有安装操作系统,也就是说RUN还没有命令可执行。
既然已经有了自己的Dockerfile,我们可以像其它Docker镜像一样通过运行如下命令来构建你自己的镜像:
1 |
$ docker image build --tag local:fromscratch . |
应该会显示如下的输出:
图2.10 从0构建
你可以通过运行如下命令与我们所构建的其它镜像对比镜像的大小:
1 |
$ docker image ls |
从下图中可以看出,我们构建的镜像与所使用的Docker Hub中的Alpine Linux的大小完全一致:
图2.11 查看镜像大小
我们已经构建了自己的镜像,可通过运行如下命令来对其进行测试:
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输出:
图2.12 从0开始运行镜像
这一切看起来非常的直接,主要归功于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版本
- 安装Apache2和所选的PHP版本
- 设置镜像来让Apache2可正常启动
- 删除默认的index.html并添加一个index.php文件来显示phpinfo命令的结果
- 在容器中暴露80端口
- 设置Apache来让其成为默认进程
ℹ️请注意新版对PHP5已不再支持。因此,我们需要使用更老的Alpine Linux版本3.8,因为这是支持PHP5包的最后一个版本。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
FROM alpine:3.8 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"] |
如你所见,我们选择了安装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 |
Sending build context to Docker daemon 2.56kB Step 1/8 : FROM alpine:3.8 3.8: Pulling from library/alpine 486039affc0a: Pull complete Digest: sha256:2bb501e6173d9d006e56de5bce2720eb06396803300fe1687b58a7ff32bf4c14 Status: Downloaded newer image for alpine:3.8 ---> c8bccc0af957 Step 2/8 : LABEL maintainer="Russ McKendrick <russ@mckendrick.io>" ---> Running in 1ad3d5e1196e Removing intermediate container 1ad3d5e1196e ---> 27e71ec74fd8 Step 3/8 : LABEL description="This example Dockerfile installs Apache & PHP." ---> Running in 3342529597b7 Removing intermediate container 3342529597b7 ---> 6bba08827be0 Step 4/8 : ENV PHPVERSION 7 |
可以从前面的输出看到,环境变量PHPVERSION设为了7:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
---> Running in 608c74bb3084 Removing intermediate container 608c74bb3084 ---> e5a26526e55b Step 5/8 : 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 e3972f3222ce 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 (1/14) Installing libuuid (2.32-r0) (2/14) Installing apr (1.6.3-r1) (3/14) Installing expat (2.2.8-r0) (4/14) Installing apr-util (1.6.1-r3) (5/14) Installing pcre (8.42-r0) (6/14) Installing apache2 (2.4.43-r0) Executing apache2-2.4.43-r0.pre-install |
至此,我们仅引用了环境变量。从下面的输出中可以看出,开始安装所需的php7包了:
1 2 3 4 5 6 7 8 9 10 11 |
(7/14) Installing php7-common (7.2.26-r0) (8/14) Installing ncurses-terminfo-base (6.1_p20180818-r1) (9/14) Installing ncurses-terminfo (6.1_p20180818-r1) (10/14) Installing ncurses-libs (6.1_p20180818-r1) (11/14) Installing libedit (20170329.3.1-r3) (12/14) Installing libxml2 (2.9.8-r2) (13/14) Installing php7 (7.2.26-r0) (14/14) Installing php7-apache2 (7.2.26-r0) Executing busybox-1.28.4-r3.trigger OK: 26 MiB in 27 packages Removing intermediate container e3972f3222ce |
现在所有的包已完成安装,构建会开始一些清理并最终完成:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
---> 37814849d5be Step 6/8 : EXPOSE 80/tcp ---> Running in 3fd77f8f0d3d Removing intermediate container 3fd77f8f0d3d ---> 83ea23b2e29e Step 7/8 : ENTRYPOINT ["httpd"] ---> Running in 335443a371a0 Removing intermediate container 335443a371a0 ---> 8ba0d3a13a8f Step 8/8 : CMD ["-D", "FOREGROUND"] ---> Running in 142312ab40e4 Removing intermediate container 142312ab40e4 ---> 36e14ed740cf Successfully built 36e14ed740cf 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的页面:
图2.13 查看PHP版本
小贴士:不要与下一部分产生混淆,不存在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 |
Sending build context to Docker daemon 2.56kB Step 1/8 : FROM alpine:3.8 ---> c8bccc0af957 Step 2/8 : LABEL maintainer="Russ McKendrick <russ@mckendrick.io>" ---> Running in a30c963c7063 Removing intermediate container a30c963c7063 ---> ac4582d108d2 Step 3/8 : LABEL description="This example Dockerfile installs Apache & PHP." ---> Running in e0130df4baee Removing intermediate container e0130df4baee ---> 4d71a2f1fff2 Step 4/8 : ENV PHPVERSION 5 |
这里可以看到PHPVERSION环境变量的值为5。从此处开始,整个构建会像前例中那样:
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 |
---> Running in ac64912c56f9 Removing intermediate container ac64912c56f9 ---> 1f2c21dd5b25 Step 5/8 : 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 5cc75e6c7db6 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 (1/15) Installing libuuid (2.32-r0) (2/15) Installing apr (1.6.3-r1) (3/15) Installing expat (2.2.8-r0) (4/15) Installing apr-util (1.6.1-r3) (5/15) Installing pcre (8.42-r0) (6/15) Installing apache2 (2.4.43-r0) Executing apache2-2.4.43-r0.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-r2) (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 5cc75e6c7db6 |
同样,所有包都进行了安装,会像之前一样完成整个镜像的构建:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
---> 35f75366ac34 Step 6/8 : EXPOSE 80/tcp ---> Running in 3a919708818b Removing intermediate container 3a919708818b ---> 6432c58a2070 Step 7/8 : ENTRYPOINT ["httpd"] ---> Running in 99686ccfecde Removing intermediate container 99686ccfecde ---> cb7702b6560e Step 8/8 : CMD ["-D", "FOREGROUND"] ---> Running in a13a0451d58e Removing intermediate container a13a0451d58e ---> 93fa817fa311 Successfully built 93fa817fa311 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:
图2.14 使用PHP5运行
最后,我们可以通过运行如下命令来对比镜像的大小:
1 |
$ docker image ls |
在Terminal中应该会看到如下输出:
图2.15 对比镜像大小
这里显示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.7.1 ENV CONSUL_SHA256 09f3583c6cd7b1f748c0c012ce9b3d96de95a6c0d2334327b74f7d72b1fa5054 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"] |
ℹ️小贴士:注意这里我们使用ADD加URL,因为希望下载一份未压缩的源码,而不是 zip压缩包。
因为我们的静态二进行有内置的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 |
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 |
这里已拉取了构建环境容器镜像,就可以准备环境来构建代码了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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 |
准备好环境后,我们就可以从GitHub上下载源码并进行编译了:
1 2 3 4 5 6 7 8 |
---> 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 |
这里编译代码成为了一个可执行二进制文件,也即可以使用scratch创建一个新构建镜像并将前面的二进制文件拷贝到新构建镜像中了。
1 2 3 4 5 6 7 8 9 10 |
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 |
如你所见,我们的二进制被进行了编译,并且包含构建器环境的容器被删除了,仅保留了存储我们二进制的镜像。如果你运行如下命令,就会明白为什么不使用带有完整构建环境的应用了:
1 |
$ docker image ls |
以下截图显示我们输出中golang镜像为809MB;加入了我们的源代码和基础程序,大小上升至862MB:
图2.16 查看镜像大小
但是,最终的镜像仅为7.41MB。我相信你一定会同意这节省了大量的空间。这也与本章前面讨论的最佳实践相符,仅将相关的内容加入到镜像中,以及让镜像尽量的小。
你可以使用如下命令启动容器来测试该应用:
1 |
$ docker container run -d -p 8000:80 --name go-hello-world local:go-hello-world |
可通过浏览器访问该应用,它仅在每次加载页面时对计数器进行递增。在macOS和Linux上进行测试,你可以像如下这样使用curl命令:
1 |
$ curl http://localhost:8000/ |
这将会得到类似如下的结果:
图2.17 运行容器并使用curl调用页面
Windows用户仅需在浏览器中访问http://localhost:8000/。要停止并删除所运行的容器,使用如下命令:
1 2 |
$ docker container stop go-hello-world $ docker container rm go-hello-world |
可以看到,使用多阶段构建是一个相对简单的过程,并与我们已经逐渐熟悉的指令保持一致。
总结
本章中我们学习了Dockerfile,相应你也会觉得定义自己的Docker镜像是一种直截了当的方式。
在深入地探讨了Dockerfile之后,我们学习了构建镜像的5种方式。首先学习了使用Dockerfile这一最常见的方式来构建镜像,全书中将会使用到它。
然后我们探讨了在Docker刚开始时使用已有的容器,这是人们最初构建镜像的方式。这已不再是最佳实践,仅在需要创建运行中或崩溃容器以供调试时才这样使用。
接着我们讲到了使用scratch作为基础镜像。这可能是创建镜像最为流线型的方式,因为是从0开始。
再后我们将话题转向了环境变量。这里我们介绍了使用变量的试,如将版本号加入到Dockerfile中,这样在更新时无需修改多处。
最后,我们讲解了多阶段构建。使用了前面构建应用的一些技巧,然后将编译后的代码拷贝到scratch容器中,得到最小可用镜像。
既然我们已经了解如何使用Dockerfile来构建镜像,在下一章中,我们会在学习Docker Hub以及使用仓库服务所带来的所有好处。我们还会讲到Docker仓库,它是开源的,因此你可以搭建自己的仓库或第三方托管的仓库服务来存储镜像,都可以实现对自有容器镜像的发布。
课后问题
- 是非题:LABEL指令在构建镜像时为其打标签。
- ENTRYPOINT和CMD指令之间的区别是什么?
- 是非题:在使用ADD指令时,你不能下载并自动解压外部托管的压缩包。
- 使用已有容器来作为你镜像的基础镜像的有效用法是什么?
- EXPOSE指令所暴露的是什么?
扩展阅读
- 查看官方容器镜像的指南:https://github.com/docker-library/official-images/
- 帮助你从已有安装创建容器的部分工具如下:
-
- Debootstrap: https://wiki.debian.org/Debootstrap/
- Yumbootstrap: https://github.com/dozzie/yumbootstrap/
- Rinse: https://packages.debian.org/sid/admin/rinse
- Docker contrib脚本: https://github.com/moby/moby/tree/master/contrib/
- Go HTTP Hello World应用的完整GitHub仓库:https://github.com/geetarista/go-http-hello-world/