前一篇文章中,讲了一些Docker的基本操作。

这里我们就只讲Docker的镜像构成、用Dockerfile定制镜像。

 

看这篇文章的前提是,假设你已经理解了Docker的基础概念。

这篇文章的主要内容如下:

  1. 理解镜像构成
  2. 使用Dockerfile定制镜像
  3. Dockerfile指令详解
  4. Dockerfile多阶段构建

 

初学者的话,建议还是看这篇:

【Docker】基础概念 & 操作指令

 

一、理解镜像构成

前面讲过,Docker的镜像是一层一层构建的,前一层是后一层的基础。

Docker镜像它由文件系统叠加而成。

最底端是引导文件系统bootfs,它像Linux/Unix的引导文件系统。第二层是root文件系统rootfs,它位于引导文件系统之上,rootfs可以是一种或多种操作系统(如Ubuntu文件系统)。然后是一个又一个的中间镜像,最后在镜像的最顶层加载一个读写文件系统,我们在Docker中运行的程序就是在这个读写层中执行的。

可以用下面这张图来表示 —— 来源于 第一本Docker书。

当Docker第一次启动一个容器时,初始的读写层是空的。当文件系统发生变化,这些变化都会应用到读写层。比如我们想修改一个文件,这个文件首先会从该读写层下面的只读层复制到该读写层,然后再修改。该文件的只读版本依然存在,但是已经被读写层中的该文件副本所隐藏。

通常这种机制称为写时复制,这也是使Docker如此强大的技术之一。

 

利用docker commit理解镜像构成

注意:docker commit命令除了学习之外,还可以用于保存被入侵现场。但是不要使用docker commit定制镜像,定制镜像应该使用Dockerfile来完成。

镜像是容器的基础,每次执行docker run都会指定用哪个镜像作为容器运行的基础。

之前我们讲到了,镜像是多层存储,每一层都是在前一层的基础上进行了修改。

容器呢,则是以镜像为基础层,在其基础上增加了一层读写层。

接下来我们定制一个web服务器,来理解镜像的构成。

1. 用nginx镜像启动一个容器

这条命令用nginx镜像启动一个容器,命名为webserver,并且绑定本地5002端口到容器80端口。

然后我们使用浏览器访问本地的5002端口,就可以看到nginx的欢迎页面(这里不截图了)

2. 修改nginx的欢迎页面

我们以交互式终端进入webserver容器,执行了bash命令,获得一个可操作的Shell。

本来想试试vi命令修改文件的,结果发现…没安装。

所以就用echo命令修改了 /usr/local/nginx/html/index.html 文件的内容。

接下来我们再刷新浏览器的内容,就发现内容改变了。

3. 用 docker diff 命令查看具体改动

我们修改了容器的读写层,可以看到具体改动:

现在我们定制好了这些改动,希望将它保存下来形成镜像。

4. 用 docker commit 保存镜像

语法格式使用:docker commit –help 命令查看。

我们可以用下面的命令保存当前的webserver:

这里 — author 是修改的作者, –message 则是本次修改的内容。

5. 用docker images命令查看新镜像

6. 查看新镜像 nginx:v2 的修改记录

使用 docker history 命令就可以查看镜像的历史记录了。

可以和 nginx:latest 比较,会发现新增的就是我们刚刚提交的这一层。

7. 运行我们定制的新镜像

这里新的服务命名为web2,并且映射到本地5003端口。

到这里,我们就第一次完成了定制镜像。

慎用 docker commit

使用docker commit命令虽然可以比较直观的帮助理解镜像分层存储的概念,但是实际环境中并不会这样使用。

docker diff webserver 命令(这里我发现 docker history 和 docker diff 都可以显示差异)可以发现,除了我们想要修改的 /usr/share/nginx/html/index.html 文件外,还有很多文件被改动或添加了。

这还仅仅是最简单的操作,如果是安装软件包、编译构建,那么会有大量无关的内容被添加进来。如果不小心清理,会导致镜像极为臃肿。

回忆之前提到的镜像分层存储的概念,除当前层外,之前的每一层都是不会发生改变的。如果使用docker commit制作镜像、后期修改,每一次修改都会让镜像臃肿一次,所删除的上一层的东西并不会丢失,而是一直跟着这个镜像,即使这个东西根本无法访问到。

此外,使用 docker commit 意味着所有对镜像的操作都是黑箱操作,生成的镜像也被称为黑箱镜像,换句话说就是除了制作镜像的人知道执行过什么命令外,别人根本无从得知。而且制作的人,一段时间后也无法记清具体的操作,维护工作非常痛苦。

 

二、使用Dockerfile定制镜像

从docker commit的学习中,我们可以了解到,镜像的定制实际上就是定制每一层所添加的配置、文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么之前提到的重复、构建透明性、镜像体积的问题都可以解决。

这个脚本就是Dockerfile。

 

FROM指定基础镜像

定制镜像,也要以一个镜像为基础,在其上进行定制。比如我们之前运行了一个nginx镜像,然后再进行修改。

FROM就用来指定基础镜像,它是Dockerfile里的第一条指令。

比如我们可以以nginx为基础镜像:FROM nginx

如果不想选择现有镜像作为基础镜像,还可以选择特殊镜像scratch,它表示空白镜像,那么接下来所写的指令就是镜像的第一层。

 

RUN执行命令

RUN指令是用来执行命令的,最常用的指令格式有两种:

  1. Shell格式,比如:RUN echo ‘<h1>Hello, Docker!</h1>’ > /usr/share/nginx/html/index.html
  2. exec格式,RUN [“可执行文件”, “参数1”, “参数2”]

接下来我们以debian操作系统镜像为基础,安装redis进行举例。

Dockerfile的每一条指令都会建立一层镜像,每一个RUN都会建立一层读写层。

这种写法就建立了7层镜像,而且很多编译的文件,更新的软件包等都被写入了镜像。

UnionFS是有最大层数限制的,目前是不能超过127层。

 

所以正确的Dockerfile写法应该是这样:

这样就只使用了一个RUN指令,将7层简化为1层。

完整流程就是:先更新软件包,然后安装Redis,之后还要进行相关清理工作。

 

Docker大体上会按如下流程执行Dockerfile中的指令:

  • Docker从基础镜像运行一个容器
  • 执行一条指令,对容器进行修改
  • 执行类似docker commit的操作,提交新镜像层
  • Docker再基于刚提交的镜像运行一个新容器
  • 执行下一条指令,直到所有指令都执行完毕。

 

Dockerfile也支持注释,以 # 开头的行都会被认为是注释。

 

构建镜像

我们有了自己的Dockerfile之后,使用docker build命令构建镜像。

我们以下面的Dockerfile为例,构建简单的 nginx:v3 版本(它和前面的v2其实一样)

代码如下:

-t 参数指定镜像名称和标签为 nginx:v3

然后我们接下来可以像运行 nginx:v2一样运行它。

 

镜像构建上下文(Context)

先看看docker build –help,这里给出使用格式:

这里可以指定PATH或者URL,其实它指定的不是Dockerfile所在的路径,而是上下文路径

 

认真看参数,-f才是指定Dockerfile路径的,如果不指定,默认PATH目录下的Dockerfile文件。

 

那上下文路径是干什么用的呢?节选部分前面的代码

该目录的内容被打包发送给Docker引擎,用来帮助构建镜像。

 

举个例子,假设我们使用COPY命令将文件拷贝到镜像内,代码如下:

它拷贝的并不是执行docker build命令当前目录下的package.json文件,也不是Dockerfile所在目录的文件,而是我们这里指定的上下文目录里的package.json文件。

 

简单来讲,这个上下文路径存储的是docker生成镜像需要的所有文件。

 

其他 docker build 用法

使用 git repo 进行构建

这行命令置顶了构建所需的git repo,并且指定默认的master分支,构建目录为/11.1/

然后Dcoker会自己去git clone这个项目,切换指定分支,进入指定目录进行构建

 

使用给定压缩文件进行构建

如果给出的URL是一个tar压缩包,那么Docker引擎会自动下载这个包,并且解压缩,作为上下文开始构建。

 

从标准输入中读取Dockerfile进行构建

从标准输入中读取有两种方法:

  1. docker build – < Dockerfile
  2. cat Dockerfile | docker build –

传入的文本文件被视为Dockerfile文件,这种方法没有上下文,所以不能将本地文件COPY到镜像内。

 

从标准输入中读取上下文压缩包进行构建

如果标准输入的是一个压缩包文件,则直接将其解压缩,将里面视为上下文开始构建。

 

三、Dockerfile指令详解

前面我们介绍了FROM用来指定基础镜像,RUN执行命令,提到了COPY拷贝文件。

接着我们继续讲讲详细命令。

COPY 复制文件

格式:

  • COPY [–chown=<user>:<group>] <源路径>… <目标路径>
  • COPY [–chown=<user>:<group>] [“<源路径1>”,… “<目标路径>”]

源路径可以是多个,通配符规则要满足Go的filepath.Match规则,如:

目标路径是容器内的绝对路径,也可以是相对于工作目录的相对路径。

工作目录可以用WORKDIR指令来指定

 

使用该指令的时候还可以加上 –chown=<user>:<group> 来改变文件所属用户及用户组。

 

ADD 更高级的复制文件

ADD指令和COPY指令的格式、性质基本一致。但是在COPY的基础上增加了一些功能。

ADD指令的源路径可以是一个URL,这种情况下Docker引擎会尝试下载文件放到目标路径去,下载之后文件权限自动设置为600(rw-|—|—),如果这不是想要的权限,则还需要额外的一层RUN去调整权限。如果下载的是个压缩包,还需要RUN解压缩。

所以还不如直接用RUN,wget或者curl下载文件,处理权限,解压缩,清理无用文件。

如果源路径为一个 tar 压缩文件的话,压缩格式为gzip, bzip2以及xz的情况下,ADD 指令会自动解压缩文件到目标路径。所以希望复制一个压缩文件到容器,不需要解压缩的情况,就不能使用ADD命令了。

所以建议就是:复制文件使用COPY命令,需要解压缩则使用ADD命令。

除此之外,可以使用 –chown=<user>:<group> 来改变文件所属用户及用户组。

 

CMD 容器启动命令

CMD指令的格式和RUN相似,也是两种格式:

  • shell格式:CMD <命令>
  • exec格式:CMD [“可执行文件”, “参数1”, “参数2″…]
  • 参数列表格式:CMD[“参数1”, “参数2″…]

在指定了ENTRYPOINT指令后,用CMD指定具体的参数。

在运行时可以指定新的命令来替代镜像设置中的这个默认命令,比如ubuntu镜像默认的CMD是/bin/bash,如果我们直接运行ubuntu容器 docker run -it ubuntu 的话,会直接进入bash。我们也可以在运行时指定运行别的命令。

如果我们执行 docker run -it ubuntu cat /etc/os-release,那么就用 cat /etc/os-release 命令替换了默认的 /bin/bash 命令,输出了系统版本信息。

在指令格式上,一般推荐使用exec模式,这类格式在解析时会被解析为Json数组,因此一定要用双引号,而不是单引号。

如果使用shell格式的话,实际的命令会被包装为 sh -c 的参数形式进行执行。比如:

在实际执行中,会将其变更为:

这就是为什么可以使用环境变量的原因,因为这些环境变量会被shell进行解析处理。

 

前台执行

提到CMD就不得不说容器中应用在前台执行和后台执行的问题。

Docker不是虚拟机,容器中的应用都应该以前台执行,而不是像虚拟机、物理机一样用systemd去启动后台服务,容器内没有后台服务的概念

下面是一个错误的示例:

这样执行的话,容器执行后就立即退出了,甚至在容器内去使用systemctl命令结果却发现根本执行不了。这就是因为没有搞明白前台后台的概念,没有区分容器和虚拟机的差异,依旧用传统虚拟机的角度去理解容器。

对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,一旦主进程退出,容器就失去了存在的意义从而退出,其他辅助进程不是它需要关心的东西。

那么我们怎么理解上面的示例呢?它会被解析成下面代码:

因此,主进程实际上是sh,当”service nginx start”执行完之后,sh就结束了,sh作为主进程退出了,容器自然就退出了。

正确的做法是:执行nginx可执行文件,并以前台形式运行。

 

ENTRYPOINT 入口点

ENTRYPOINT的格式和RUN指令格式一样,分为exec格式和shell格式。其目的和CMD一样,都是在指定容器启动程序和参数。

入口点(ENTRYPOINT)在运行时也可以替代,不过比CMD要稍微麻烦,需要通过 docker run –entrypoint来指定。

当我们指定了入口点之后,CMD的含义就发生了改变,不再是直接运行其命令,而是将CMD内容作为参数传递给ENTRYPOINT指令,实际执行时变为:

 

这样做的好处是什么呢?

场景一:让镜像像命令一样使用

假设我们需要一个得知自己当前公网IP的镜像,那么我们先用CMD来实现:

构建镜像命令如下:docker build -t myip . 

最后输出成功即可:

然后我们运行这个镜像:docker run

呃…我这边出问题了,没有返回内容,跟书上的内容不一致。

这里我们需要把域名地址: ip.cn 改为 cip.cc

所以新的Dockerfile最后一行应该就是:

然后再重新生成镜像,这次生成镜像的速度比上次快了很多很多,原因看下面:

先看当前目录结构:

然后在当前目录下重新生成镜像:

这次的生成速度极快,具体原因可以看  —> Using cache 这里,它使用了缓存镜像,所以相比上次的生成速度快了很多。

然后镜像生成完毕后,我们再运行一次:

这里有一个<none>镜像,它是虚悬镜像。因为新旧镜像同名,旧镜像的名称就被取消,从而出现了这类仓库名、标签都是<none>的镜像。它们已经失去了存在的价值,可以随意删除。

我们关注下面的 docker run myip 这个指令的结果。

接下来,我们希望能够把http的Header信息显示出来,根据curl –help,需要加上 -i 参数。

那么我们试试运行 docker run myip -i 命令。

发现 executable file not found。

因为: 跟在镜像名称后面的 -i 被认为是command,运行时会替换CMD的默认值。

所以: 这里的-i替换了原来的curl命令,而不是添加在curl后面,所以就找不到了。

如果要加入-i参数,就必须完整输入命令:

很明显这个解决方案一点都不好,但是我们可以用ENTRYPOINT解决这个问题。

修改Dockerfile,然后重新构建镜像

然后执行新的命令:

这次就成功了,因为ENTRYPOINT存在,CMD的内容就会作为参数传递的ENTRYPOINT。

这里的 -i 就是CMD,它作为参数传递给curl,达到预期效果。

 

场景二:应用运行前的准备工作

启动容器就是启动主进程,但是有些时候,启动主进程之前要准备一些工作。

比如mysq类的数据库,可能需要一些数据库配置、初始化的工作,这些工作要在最终的mysql服务运行之前解决。

此外,可能希望比年使用root用户去启动服务,从而提高安全性,而在启动服务前还需要以root身份执行一些必要的准备工作,最终切换到服务用户身份启动服务。或者除了服务之外,其他命令依旧以root用户执行,方便调试。

这些准备工作和容器的CMD无关,无论CMD是什么,都需要先进行一个预处理的工作。这种情况下可以写一个脚本放入到ENTRYPOINT里去执行,这个脚本会将接收到的参数(CMD)作为命令,在脚本最后执行。

比如官方镜像redis就是这样做的:

可以看到先创建了redis用户,然后指定了入口点为 docker-entrypoint.sh 脚本文件。

脚本文件内容如下:

而且脚本内容是根据CMD内容判断,如果传入的是redis-server,则切换到redis用户来执行,否则以root身份来执行。

 

可以运行一下nginx的镜像试试:

 

总结入口点的两个用法:

  1. 让镜像像命令一样使用
  2. 为应用运行前做准备工作

 

ENV 设置环境变量

ENV指令的格式很简单,就是设置环境变量而已。

  • ENV <key> <value>
  • ENV <key1>=<value1> <key2>=<value2>…

后面的指令,无论是RUN还是运行时的应用,都可以直接使用这里的环境变量。

举个例子:

它的写法也是支持换行的,对于含有空格的值,就用双引号括起来。

 

ARG 构建参数

ARG设置的是构建时使用的环境变量,容器运行时不会保存这些信息,指令格式如下:

ARG <参数名>[=<默认值>]

不要使用ARG保存密码信息,因为docker history是可以看见的。

Dockerfile中的ARG指令是定义参数名称和默认值。

这个默认值可以在构建命令 docker build 中用 –build-arg <参数名>=<值> 来覆盖。

 

VOLUME 定义匿名数据卷

容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,它们的数据库文件应该保存于数据卷中。

定义匿名数据卷的方式有两种:

  • VOLUME <路径>
  • VOLUME [“<路径1>”, “<路径2>” …]

举个例子: VOLUME /data

当容器运行时,容器的目录 /data 就会自动挂载为匿名数据卷,任何向 /data 中写入的信息都不会记录进容器的存储层,从而保证了容器存储层的无状态化。

当然了,运行的时候,我们可以覆盖这个挂载数据卷的设置,比如:

docker run -d -v mydata:/data xxxx

这里参数 -d 的意思是让容器在后台运行,参数 -v 的意思就是挂载数据卷。

在运行xxxx镜像的时候,我们将mydata这个数据卷挂载到了/data,这样就替代了Dockerfile中定义的匿名数据卷。

 

EXPOSE 暴露端口

指令格式: EXPOSE <端口1> [<端口2>…]

声明容器运行时提供服务端口,这只是一个声明。

运行时,应用并不会因为这个声明就开启对应端口的服务。

在Dockerfile中这样写有两个好处:

  1. 帮助镜像使用者理解这个镜像服务的守护端口,方便配置映射
  2. 在运行是使用随机端口映射时,会自动随机映射EXPOSE端口。

这里使用的时候要注意,即使Dockerfile里面有这个指令,也需要配置端口映射。

要将EXPOSE和运行时的-p参数区分开。-p参数是映射宿主机和容器的端口,而EXPOSE声明的是容器打算使用的端口,与宿主机无关,也不会自动配置映射。

 

WORKDIR 指定工作目录

指令格式: WORKDIR <工作目录路径>

WORKDIR 指令用来指定工作目录,如果目录不存在,则会自动创建。而且这个路径会覆盖后面各层的工作目录。P.s. 回忆一下,Dockerfile的每个命令都是一层镜像

所以如果把Dockerfile像Shell脚本一样书写:

这样构建之后,要么就是找不到 /app/world.ext 文件,要么就是它的内容不是 “hello”。

原因就是两个RUN运行时不在同一个环境,它们根本就是不同的容器。

所以如果需要改变以后各层的工作目录的位置,应该使用WORKDIR命令。

 

USER 切换当前用户

USER指令和WORKDIR是类似的,它改变了当前的用户并且影响以后的层。

USER改变的是执行 RUN、 CMD、 ENTRYPOINT 这类命令的身份。

存在的问题是USER只是切换用户,需要先建立好,如果用户不存在,则无法切换。

以 redis 为例:先创建用户,再切换用户,再执行命令。

 

如果以root执行的脚本,在执行期间希望改变身份,比如想以某个已经建立好的用户来运行某个服务进程,不要使用su或者sudo,他们在TTY缺失的环境下经常出错,建议使用gosu。

 

HEALTHCHECK 健康检查

HEALTHCHECK在Docker 1.12版本引入,用来告诉Docker如何判断容器的状态是否正常。

  • HEALTHCHECK [选项] CMD <命令>:设置检查容器健康状况的命令
  • HEALTHCHECK NONE:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令

在没有HEALTHCHECK指令前,Dcoker引擎只能通过容器内主进程是否退出来判断容器是否状态异常,很多情况下这样是没问题的,但是如果一旦进入死锁、死循环这种应用程序不会退出的情况,就没办法了。

在1.12以前,Docker无法检查容器的这种状态,从而不会重新调度,导致可能部分容器已经无法正常提供服务了,却还在接受用户请求。

从1.12版本以后,Docker提供了HEALTHCHECK指令,通过该指令指定一行命令,用这行命令判断容器主进程的服务状态是否还正常,从而比较真实的反应容器实际状态。

当一个镜像指定了HEALTHCHECK指令后,用其启动容器,初始状态会为starting,在HEALTHCHECK指令检查成功后变为healthy,如果连续一定次数失败,则会变为unhealthy。

HEALTHCHECK支持下列选项:

  • –interval=<间隔>:两次健康检查的间隔,默认为30秒
  • –timeout=<时长>:健康检查命令运行超时时间,如果超过这个时间本次检查就会被视为失败,默认30秒。
  • –retries=<次数>:当连续失败指定次数后,则将容器状态视为unhealthy,默认3次。

HEALTHCHECK 的 <命令> 分为shell格式和exec格式,命令的返回值决定了健康检查的成功与否,0表示成功,1表示失败,2表示保留,不要使用这个值。

如果有多个HEALTHCHECK,那么和CMD、ENTRYPOINT一样,只有最后一个生效。

 

假设我们有个镜像是最简单的web服务,我们希望增加健康检查来判断web服务是否正常工作,我们可以用curl来帮助判断,Dockerfile里的HEALTHCHECK可以这么写:

这里我们设置每5秒检查1次,超过3秒则视为失败,并且使用 curl -fs http://localhost/ || exit 1 作为检查命令。

使用 docker build 构建这个镜像:

然后启动一个容器:

再运行命令 docker container ls 查看健康状态变化:

 

如果健康检查连续失败超过重试次数,状态就会变为unhealthy。

健康检查命令的输出都会被存储在健康状态里(包括stdout和stderr信息),可以用docker inspect 命令来查看。

我这边输出的信息有点多,就只给出命令了:

 

 

ONBUILD 构建下层镜像时执行指令

ONBUILD是一个特殊指令,它后面跟着其他指令:

格式就是: ONBUILD <指令>

这里的指令可以是 RUN、COPY等等。

ONBUILD表明这些指令在当前镜像构建时不会执行,仅在以当前镜像为基础构建的下级镜像中才会执行。

举个例子,我们在镜像A中指定了ONBUILD指令(称为OA),镜像B FROM 镜像A,那么构造镜像B时就会执行我们在A中指定的指令OA,但是如果镜像C FROM 镜像B,构造C的时候是不会执行镜像A中的OA指令,但是如果镜像B中有ONBUILD指令OB,那么镜像C会执行指令OB。

Dockerfile中的指令大都是为定制当前镜像准备的,唯有ONBUILD是为了下层镜像准备的。

 

这里以Node.js项目为例,理解ONBUILD指令的用法。

Node.js使用npm进行包管理,所有的依赖、配置、启动信息都会放到package.json文件里。在拿到程序代码后,需要先进行npm install 才可以获得所有需要的依赖,然后再通过npm start来启动应用。

因此,我们会这样写Dockerfile:

然后我们将这个Dockerfile拷贝到我们的Node.js项目的根目录,构建镜像之后就可以运行了。

如果我们还有其他的Node.js项目,就再复制一份。

假设这个时候老板来了个变态需求,要求在 RUN mkdir /app 前面加上一行公司名字。

那完了,我们所有的项目都需要重新修改Dockerfile,构建镜像。

 

那我们能不能做一个基础镜像,我们各个Node.js项目基于这个基础镜像生成独立镜像,然后这种变态需求就可以更新在基础镜像里面,不需要依次修改Dockerfile,只需要重新构建就好了。

比如将我们的Dockerfile改成这样,构建基础镜像my-node:

这里我们在执行 RUN mkdir /app 前加入了 RUN echo “company” 完成了老板的要求。

然后我我们基于这个基础镜像my-node生成自己的镜像,Dockerfile就很简单:

这样看起来OK吗?好像可以,但是实际上有问题。

这样构建出来的镜像使用的 ./package.json 文件并不是我们项目的文件,而是当时我们生成这个基础镜像时使用的 package.json 文件。

 

我们需要将后面与项目相关的三行指令放到项目镜像Dockerfile中去。

基础镜像my-node的Dockerfile如下:

项目的Dockerfile如下:

这样我们轻松地完成了老板的需求,又能够使用自己的项目文件用于生成镜像。

 

看起来好像OK了?不不不,新修改又来了!!!

这次我们要修改 RUN [ “npm”, “install” ] 命令,加上一个编译参数。别管加什么参数,反正就是要加吧(主要是我也不会Node.js…胡编乱造不好)

那现在没办法,难道又要一个项目接一个项目地去修改Dockerfile文件?

我们最初的目的不就是:不愿意依次修改项目的Dockerfile文件吗?

 

于是我们使用ONBUILD命令重写一下基础镜像my-node的Dockerfile文件:

使用 ONBUILD 表明在构建基础镜像的时候这些指令不会运行,基于基础镜像再构建新镜像的时候它们才会执行。前面我们提到的对 RUN [ “npm”, “install” ] 命令的修改也可以在基础镜像中修改。

 

我们项目的Dockerfile就变成了简单的:

只需要这样一行,就既实现了老板修改的需求,又能够在构建镜像的时候成功地将当前项目的代码复制进镜像,执行 npm install命令,生成应用镜

 

一句话总结就是:通过ONBUILD命令,让拷贝的文件都是我们自己项目的文件。

 

 

四、Dockerfile多阶段构建

在Docker 17.05版本以前,我们构建Docker镜像时,通常会采用两种方式:

  1. 全部放入一个Dockerfile
  2. 分散到多个Dockerfile

全部放入一个Dockerfile的问题是镜像层次多,体积大,部署时间长,而且源代码存在泄漏风险。

 

单文件构建方式

比如我们以构建go语言 grpc网络Hello程序为例,编写 helloservice.go 文件,该程序监听5001网络端口提供服务。

基于上面的程序,我们编写一个dockerfile运行它。

以这种方式构建镜像,时间较长,而且生成的镜像大小也很大。

看第一行就是我们最新生成的镜像,它的大小是271MB。

 

如果想运行这个镜像,对应的命令应该是:

 

另外附上测试客户端代码,可能需要做一定修改:

这里可以手动修改服务端ip地址端口号,也可以通过命令行输入进来。

 

多文件构建方式

或者我们可以采用多个Dockerfile,一个Dockerfile负责将项目及其依赖库编译测试打包,另一个Dockerfile负责运行。

这样的话,我们需要两个Dockerfile和一个部署脚本文件,脚本文件负责将两个阶段整合起来。

helloservice.go 文件不做修改。

 

Dockerfile.build负责编译项目,内容如下:

 

还需要一个Dockerfile.copy负责拷贝文件,运行程序,内容如下:

 

然后我们用一个build.sh文件将这两个步骤整合。

 

接下来我们给脚本加上可执行权限,运行它。

P.s. 这里我碰上了一个问题,需要修改Docker的镜像源,修改方法看前面的基础教程,仓库部分。

 

可以使用 docker image ls 命令查看我们生成的镜像大小。

最终我们使用的应该是 go/helloservice:2 ,它的大小是 14.1MB。

可以看到,alpine的大小是6MB,那么多出来的8MB是什么呢?

这里存在的问题就是文件太多,如果可以的话,还是希望一个Dockerfile完成所有。

 

然后运行这个程序的方式:

我运行了两个容器,都用来跑这个程序。

接下来是我的客户端程序,它运行在Windows环境:

一切OK…那就开始下一阶段,一个Dockerfile完成所有。

 

Docker的多阶段构建

Docker自从v17.05版本开始支持多阶段构建(multistage builds)。

使用多阶段构建,就可以解决前面提到的一系列问题,并且只需要编写一个Dockerfile。

 

我基于原文精简了一些东西,编译阶段修改如下:

  1. 修改基础builder为 golang:alpine,它拥有最新的golang v1.15
  2. 对于 golang:alpine 环境,应该自带有git,不需要安装
  3. 工作路径修改为 $GOPATH/src ,它是 GO111MODULE=”auto” 下的工作路径
  4. 不需要的mysql驱动,不执行go get
  5. 编译参数不需要那么多,修改为 RUN go build -o app . 即可。

 

运行阶段修改如下:

  1. ca-certificates是CA证书,还是正常安装吧。
  2. COPY语句可以改为 –from=builder
  3. 编译阶段工作路径为 $GOPATH/src ,生成的app文件在builder的/go/src目录下

 

原文Dockerfile内容如下:

修改后Dockerfile如下(我使用的是这一版本):

 

执行命令,构建镜像

 

命令 docker images 查看对比3个镜像的大小。

这里忽然多了一个虚悬镜像,创建时间是6秒前,就是刚刚生成的,它对应的应该是 go/helloservice:build 这个镜像吧,就是编译阶段的镜像。

很明显使用多阶段构建的镜像体积小(比build2更小是因为换了编译源),同时也完美解决了前面的问题,删除虚悬镜像可以用 docker image prune 命令。

 

最后讲解两个命令

构建某一阶段的镜像:

$ docker build –target builder -t go/helloservice:builder .

它可以单独构建 builder阶段的镜像。比如之前我修改Dockerfile的时候,没有找到builder阶段的gopath路径,就先构建了builder然后进入bash,去查找app文件位置。

 

从其他镜像复制文件:

COPY –from=builder /go/src/app .

它的意思是从builder镜像拷贝文件到当前镜像。

我们也可以从其他镜像复制文件过来,比如:

COPY –from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

 

OK。 以上是Dockerfile相关的全部内容。

 

【Docker】镜像 与 Dockerfile
Tagged on:
0 0 投票数
Article Rating
订阅评论
提醒

0 评论
内联反馈
查看所有评论