Docker Dockerfile 定制镜像的方法
使用Dockerfile定制镜像
镜像的定制实际上就是定制每一层所添加的配置、文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么无法重复的问题、镜像构建透明性的问题、体积的问题就都会解决。这个脚本就是Dockerfile。
Dockerfile是一个文本文件,其内包含了一条条的指令(Instruction),每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。
此处以定制nginx镜像为例,使用Dockerfile来定制。
在一个空白目录中,建立一个文本文件,并命名为Dockerfile:
$mkdirmynginx $cdmynginx $touchDockerfile
其内容为:
FROMnginx RUNecho'Hello,Docker!
'>/usr/share/nginx/html/index.html
这个Dockerfile很简单,一共就两行。涉及到了两条指令,FROM和RUN。
Dockerfile指令详解
FROM指定基础镜像
所谓定制镜像,那一定是以一个镜像为基础,在其上进行定制。而FROM就是指定基础镜像,因此一个Dockerfile中FROM是必备的指令,并且必须是第一条指令。
在DockerStore上有非常多的高质量的官方镜像,有可以直接拿来使用的服务类的镜像,如nginx、redis、mongo、mysql等;也有一些方便开发、构建、运行各种语言应用的镜像,如node、openjdk、python等。可以在其中寻找一个最符合我们最终目标的镜像为基础镜像进行定制。
如果没有找到对应服务的镜像,官方镜像中还提供了一些更为基础的操作系统镜像,如ubuntu、debian、centos等,这些操作系统的软件库为我们提供了更广阔的扩展空间。
除了选择现有镜像为基础镜像外,Docker还存在一个特殊的镜像,名为scratch。这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像。
FROMscratch ...
如果你以scratch为基础镜像的话,意味着你不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在。
不以任何系统为基础,直接将可执行文件复制进镜像的做法并不罕见,比如swarm、coreos/etcd。对于Linux下静态编译的程序来说,并不需要有操作系统提供运行时支持,所需的一切库都已经在可执行文件里了,因此直接FROMscratch会让镜像体积更加小巧。使用Go语言开发的应用很多会使用这种方式来制作镜像,这也是为什么有人认为Go是特别适合容器微服务架构的语言的原因之一。
RUN执行命令
RUN指令是用来执行命令行命令的。由于命令行的强大能力,RUN指令在定制镜像时是最常用的指令之一。其格式有两种:
shell格式:RUN<命令>,就像直接在命令行中输入的命令一样。刚才写的Dockerfile中的RUN指令就是这种格式。
RUNecho'Hello,Docker!
'>/usr/share/nginx/html/index.html
exec格式:RUN["可执行文件","参数1","参数2"],这更像是函数调用中的格式。
既然RUN就像Shell脚本一样可以执行命令,那么我们是否就可以像Shell脚本一样把每个命令对应一个RUN呢?比如这样:
FROMdebian:jessie RUNapt-getupdate RUNapt-getinstall-ygcclibc6-devmake RUNwget-Oredis.tar.gz"http://download.redis.io/releases/redis-3.2.5.tar.gz" RUNmkdir-p/usr/src/redis RUNtar-xzfredis.tar.gz-C/usr/src/redis--strip-components=1 RUNmake-C/usr/src/redis RUNmake-C/usr/src/redisinstall
之前说过,Dockerfile中每一个指令都会建立一层,RUN也不例外。每一个RUN的行为,就和刚才我们手工建立镜像的过程一样:新建立一层,在其上执行这些命令,执行结束后,commit这一层的修改,构成新的镜像。
而上面的这种写法,创建了7层镜像。这是完全没有意义的,而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等。结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。这是很多初学Docker的人常犯的一个错误(我也不能原谅自己ε=(´ο`*)))唉)。
UnionFS是有最大层数限制的,比如AUFS,曾经是最大不得超过42层,现在是不得超过127层。
上面的Dockerfile正确的写法应该是这样:
FROMdebian:jessie RUNbuildDeps='gcclibc6-devmake'\ &&apt-getupdate\ &&apt-getinstall-y$buildDeps\ &&wget-Oredis.tar.gz"http://download.redis.io/releases/redis-3.2.5.tar.gz"\ &&mkdir-p/usr/src/redis\ &&tar-xzfredis.tar.gz-C/usr/src/redis--strip-components=1\ &&make-C/usr/src/redis\ &&make-C/usr/src/redisinstall\ &&rm-rf/var/lib/apt/lists/*\ &&rmredis.tar.gz\ &&rm-r/usr/src/redis\ &&apt-getpurge-y--auto-remove$buildDeps
首先,之前所有的命令只有一个目的,就是编译、安装redis可执行文件。因此没有必要建立很多层,这只是一层的事情。因此,这里没有使用很多个RUN对一一对应不同的命令,而是仅仅使用一个RUN指令,并使用&&将各个所需命令串联起来。将之前的7层,简化为了1层。在撰写Dockerfile的时候,要经常提醒自己,这并不是在写Shell脚本,而是在定义每一层该如何构建。
并且,这里为了格式化还进行了换行。Dockerfile支持Shell类的行尾添加\的命令换行方式,以及行首#进行注释的格式。良好的格式,比如换行、缩进、注释等,会让维护、排障更为容易,这是一个比较好的习惯。
此外,还可以看到这一组命令的最后添加了清理工作的命令,删除了为了编译构建所需要的软件,清理了所有下载、展开的文件,并且还清理了apt缓存文件。这是很重要的一步,之前有说过,镜像是多层存储,每一层的东西并不会在下一层被删除,会一直跟随着镜像。因此镜像构建时,一定要确保每一层只添加真正需要添加的东西,任何无关的东西都应该清理掉。
很多人初学Docker制作出了很臃肿的镜像的原因之一,就是忘记了每一层构建的最后一定要清理掉无关文件。
构建镜像
好了,让我们再回到之前定制的nginx镜像的Dockerfile来。现在我们明白了这个Dockerfile的内容,那么让我们来构建这个镜像吧。
在Dockerfile文件所在目录执行:
$dockerbuild-tnginx:v3. SendingbuildcontexttoDockerdaemon2.048kB Step1:FROMnginx --->e43d811ce2f4 Step2:RUNecho'Hello,Docker!
'>/usr/share/nginx/html/index.html --->Runningin9cdc27646c7b --->44aa4490ce2c Removingintermediatecontainer9cdc27646c7b Successfullybuilt44aa4490ce2c
从命令的输出结果中,我们可以清晰的看到镜像的构建过程。在Step2中,如同我们之前所说的那样,RUN指令启动了一个容器9cdc27646c7b,执行了所要求的命令,并最后提交了这一层44aa4490ce2c,随后删除了所用到的这个容器9cdc27646c7b。
这里我们使用了dockerbuild命令进行镜像构建。其格式为:
dockerbuild[选项]<上下文路径/URL/->
在这里我们指定了最终镜像的名称-tnginx:v3,构建成功后,我们可以直接运行这个镜像,其结果就是我们的主页被改变成了Hello,Docker!。
镜像构建上下文(Context)
如果注意,会看到dockerbuild命令最后有一个.。.表示当前目录,而Dockerfile就在当前目录,因此不少初学者以为这个路径是在指定Dockerfile所在路径,这么理解其实是不准确的。如果对应上面的命令格式,你可能会发现,这是在指定上下文路径。那么什么是上下文呢?
首先我们要理解dockerbuild的工作原理。Docker在运行时分为Docker引擎(也就是服务端守护进程)和客户端工具。Docker的引擎提供了一组RESTAPI,被称为DockerRemoteAPI,而如docker命令这样的客户端工具,则是通过这组API与Docker引擎交互,从而完成各种功能。因此,虽然表面上我们好像是在本机执行各种docker功能,但实际上,一切都是使用的远程调用形式在服务端(Docker引擎)完成。也因为这种C/S设计,让我们操作远程服务器的Docker引擎变得轻而易举。
当我们进行镜像构建的时候,并非所有定制都会通过RUN指令完成,经常会需要将一些本地文件复制进镜像,比如通过COPY指令、ADD指令等。而dockerbuild命令构建镜像,其实并非在本地构建,而是在服务端,也就是Docker引擎中构建的。那么在这种客户端/服务端的架构中,如何才能让服务端获得本地文件呢?
这就引入了上下文的概念。当构建的时候,用户会指定构建镜像上下文的路径,dockerbuild命令得知这个路径后,会将路径下的所有内容打包,然后上传给Docker引擎。这样Docker引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。
如果在Dockerfile中这么写:
COPY./package.json/app/
这并不是要复制执行dockerbuild命令所在的目录下的package.json,也不是复制Dockerfile所在目录下的package.json,而是复制上下文(context)目录下的package.json。
因此,COPY这类指令中的源文件的路径都是相对路径。这也是初学者经常会问的为什么COPY../package.json/app或者COPY/opt/xxxx/app无法工作的原因,因为这些路径已经超出了上下文的范围,Docker引擎无法获得这些位置的文件。如果真的需要那些文件,应该将它们复制到上下文目录中去。
现在就可以理解刚才的命令dockerbuild-tnginx:v3.中的这个.,实际上是在指定上下文的目录,dockerbuild命令会将该目录下的内容打包交给Docker引擎以帮助构建镜像。
如果观察dockerbuild输出,我们其实已经看到了这个发送上下文的过程:
$dockerbuild-tnginx:v3. SendingbuildcontexttoDockerdaemon2.048kB ...
理解构建上下文对于镜像构建是很重要的,避免犯一些不应该的错误。比如有些初学者在发现COPY/opt/xxxx/app不工作后,于是干脆将Dockerfile放到了硬盘根目录去构建,结果发现dockerbuild执行后,在发送一个几十GB的东西,极为缓慢而且很容易构建失败。那是因为这种做法是在让dockerbuild打包整个硬盘,这显然是使用错误。
一般来说,应该会将Dockerfile置于一个空目录下,或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给Docker引擎,那么可以用.gitignore一样的语法写一个.dockerignore,该文件是用于剔除不需要作为上下文传递给Docker引擎的。
那么为什么会有人误以为.是指定Dockerfile所在目录呢?这是因为在默认情况下,如果不额外指定Dockerfile的话,会将上下文目录下的名为Dockerfile的文件作为Dockerfile。
这只是默认行为,实际上Dockerfile的文件名并不要求必须为Dockerfile,而且并不要求必须位于上下文目录中,比如可以用-f../Dockerfile.php参数指定某个文件作为Dockerfile。
当然,一般大家习惯性的会使用默认的文件名Dockerfile,以及会将其置于镜像构建上下文目录中。
其它dockerbuild的用法
直接用Gitrepo进行构建
或许你已经注意到了,dockerbuild还支持从URL构建,比如可以直接从Gitrepo中构建:
$dockerbuildhttps://github.com/twang2218/gitlab-ce-zh.git#:8.14 dockerbuildhttps://github.com/twang2218/gitlab-ce-zh.git\#:8.14 SendingbuildcontexttoDockerdaemon2.048kB Step1:FROMgitlab/gitlab-ce:8.14.0-ce.0 8.14.0-ce.0:Pullingfromgitlab/gitlab-ce aed15891ba52:Alreadyexists 773ae8583d14:Alreadyexists ...
这行命令指定了构建所需的Gitrepo,并且指定默认的master分支,构建目录为/8.14/,然后Docker就会自己去gitclone这个项目、切换到指定分支、并进入到指定目录后开始构建。
用给定的tar压缩包构建
$dockerbuildhttp://server/context.tar.gz
如果所给出的URL不是个Gitrepo,而是个tar压缩包,那么Docker引擎会下载这个包,并自动解压缩,以其作为上下文,开始构建。
从标准输入中读取Dockerfile进行构建
dockerbuild-或
catDockerfile|dockerbuild-如果标准输入传入的是文本文件,则将其视为Dockerfile,并开始构建。这种形式由于直接从标准输入中读取Dockerfile的内容,它没有上下文,因此不可以像其他方法那样可以将本地文件COPY进镜像之类的事情。
从标准输入中读取上下文压缩包进行构建
$dockerbuild-如果发现标准输入的文件格式是gzip、bzip2以及xz的话,将会使其为上下文压缩包,直接将其展开,将里面视为上下文,并开始构建。
COPY复制文件
格式:
- COPY<源路径>...<目标路径>
- COPY["<源路径1>",..."<目标路径>"]
和RUN指令一样,也有两种格式,一种类似于命令行,一种类似于函数调用。COPY指令将从构建上下文目录中<源路径>的文件/目录复制到新的一层的镜像内的<目标路径>位置。比如:
COPYpackage.json/usr/src/app/<源路径>可以是多个,甚至可以是通配符,其通配符规则要满足Go的filepath.Match规则,如:
COPYhom*/mydir/ COPYhom?.txt/mydir/<目标路径>可以是容器内的绝对路径,也可以是相对于工作目录的相对路径(工作目录可以用WORKDIR指令来指定)。目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。
此外,还需要注意一点,使用COPY指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用Git进行管理的时候。
ADD更高级的复制文件
ADD指令和COPY的格式和性质基本一致。但是在COPY基础上增加了一些功能。比如<源路径>可以是一个URL,这种情况下,Docker引擎会试图去下载这个链接的文件放到<目标路径>去。下载后的文件权限自动设置为600,如果这并不是想要的权限,那么还需要增加额外的一层RUN进行权限调整,另外,如果下载的是个压缩包,需要解压缩,也一样还需要额外的一层RUN指令进行解压缩。所以不如直接使用RUN指令,然后使用wget或者curl工具下载,处理权限、解压缩、然后清理无用文件更合理。因此,这个功能其实并不实用,而且不推荐使用。
如果<源路径>为一个tar压缩文件的话,压缩格式为gzip,bzip2以及xz的情况下,ADD指令将会自动解压缩这个压缩文件到<目标路径>去。
在某些情况下,这个自动解压缩的功能非常有用,比如官方镜像ubuntu中:
FROMscratch ADDubuntu-xenial-core-cloudimg-amd64-root.tar.gz/ ...但在某些情况下,如果我们真的是希望复制个压缩文件进去,而不解压缩,这时就不可以使用ADD命令了。
在Docker官方的Dockerfile最佳实践文档中要求,尽可能的使用COPY,因为COPY的语义很明确,就是复制文件而已,而ADD则包含了更复杂的功能,其行为也不一定很清晰。最适合使用ADD的场合,就是所提及的需要自动解压缩的场合。
另外需要注意的是,ADD指令会令镜像构建缓存失效,从而可能会令镜像构建变得比较缓慢。
因此在COPY和ADD指令中选择的时候,可以遵循这样的原则,所有的文件复制均使用COPY指令,仅在需要自动解压缩的场合使用ADD。
CMD容器启动命令
CMD指令的格式和RUN相似,也是两种格式:
- shell格式:CMD<命令>
- exec格式:CMD["可执行文件","参数1","参数2"...]
- 参数列表格式:CMD["参数1","参数2"...]。在指定了ENTRYPOINT指令后,用CMD指定具体的参数。
之前介绍容器的时候曾经说过,Docker不是虚拟机,容器就是进程。既然是进程,那么在启动容器的时候,需要指定所运行的程序及参数。CMD指令就是用于指定默认的容器主进程的启动命令的。
在运行时可以指定新的命令来替代镜像设置中的这个默认命令,比如,ubuntu镜像默认的CMD是/bin/bash,如果我们直接dockerrun-itubuntu的话,会直接进入bash。我们也可以在运行时指定运行别的命令,如dockerrun-itubuntucat/etc/os-release。这就是用cat/etc/os-release命令替换了默认的/bin/bash命令了,输出了系统版本信息。
在指令格式上,一般推荐使用exec格式,这类格式在解析时会被解析为JSON数组,因此一定要使用双引号",而不要使用单引号。
如果使用shell格式的话,实际的命令会被包装为sh-c的参数的形式进行执行。比如:
CMDecho$HOME在实际执行中,会将其变更为:
CMD["sh","-c","echo$HOME"]这就是为什么我们可以使用环境变量的原因,因为这些环境变量会被shell进行解析处理。提到CMD就不得不提容器中应用在前台执行和后台执行的问题。这是初学者常出现的一个混淆。
Docker不是虚拟机,容器中的应用都应该以前台执行,而不是像虚拟机、物理机里面那样,用upstart/systemd去启动后台服务,容器内没有后台服务的概念。
初学者一般将CMD写为:
CMDservicenginxstart然后发现容器执行后就立即退出了。甚至在容器内去使用systemctl命令结果却发现根本执行不了。这就是因为没有搞明白前台、后台的概念,没有区分容器和虚拟机的差异,依旧在以传统虚拟机的角度去理解容器。
对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就失去了存在的意义,从而退出,其它辅助进程不是它需要关心的东西。
而使用servicenginxstart命令,则是希望systemd来以后台守护进程形式启动nginx服务。而刚才说了CMDservicenginxstart会被理解为CMD[“sh”,“-c”,“servicenginxstart”],因此主进程实际上是sh。那么当servicenginxstart命令结束后,sh也就结束了,sh作为主进程退出了,自然就会令容器退出。
正确的做法是直接执行nginx可执行文件,并且要求以前台形式运行。比如:
CMD["nginx","-g","daemonoff;"]ENTRYPOINT入口点
ENTRYPOINT的格式和RUN指令格式一样,分为exec格式和shell格式。
ENTRYPOINT的目的和CMD一样,都是在指定容器启动程序及参数。ENTRYPOINT在运行时也可以替代,不过比CMD要略显繁琐,需要通过dockerrun的参数–entrypoint来指定。
当指定了ENTRYPOINT后,CMD的含义就发生了改变,不再是直接的运行其命令,而是将CMD的内容作为参数传给ENTRYPOINT指令,换句话说实际执行时,将变为:
" " 那么有了CMD后,为什么还要有ENTRYPOINT呢?这种
" "有什么好处么?让我们来看几个场景。 场景一:让镜像变成像命令一样使用
假设我们需要一个得知自己当前公网IP的镜像,那么可以先用CMD来实现:
FROMubuntu:16.04 RUNapt-getupdate\ &&apt-getinstall-ycurl\ &&rm-rf/var/lib/apt/lists/* CMD["curl","-s","http://ip.cn"]假如我们使用dockerbuild-tmyip.来构建镜像的话,如果我们需要查询当前公网IP,只需要执行:
$dockerrunmyip当前IP:61.148.226.66来自:北京市联通
嗯,这么看起来好像可以直接把镜像当做命令使用了,不过命令总有参数,如果我们希望加参数呢?比如从上面的CMD中可以看到实质的命令是curl,那么如果我们希望显示HTTP头信息,就需要加上-i参数。那么我们可以直接加-i参数给dockerrunmyip么?
$dockerrunmyip-i docker:Errorresponsefromdaemon:invalidheaderfieldvalue"ociruntimeerror:con tainer_linux.go:247:startingcontainerprocesscaused\"exec:\\\"-i\\\":executable filenotfoundin$PATH\"\n".我们可以看到可执行文件找不到的报错,executablefilenotfound。之前我们说过,跟在镜像名后面的是command,运行时会替换CMD的默认值。因此这里的-i替换了原来的CMD,而不是添加在原来的curl-shttp://ip.cn后面。而-i根本不是命令,所以自然找不到。
那么如果我们希望加入-i这参数,我们就必须重新完整的输入这个命令:
$dockerrunmyipcurl-shttp://ip.cn-i这显然不是很好的解决方案,而使用ENTRYPOINT就可以解决这个问题。现在我们重新用ENTRYPOINT来实现这个镜像:
FROMubuntu:16.04 RUNapt-getupdate\ &&apt-getinstall-ycurl\ &&rm-rf/var/lib/apt/lists/* ENTRYPOINT["curl","-s","http://ip.cn"]这次我们再来尝试直接使用dockerrunmyip-i:
$dockerrunmyip当前IP:61.148.226.66来自:北京市联通
$dockerrunmyip-i HTTP/1.1200OK Server:nginx/1.8.0 Date:Tue,22Nov201605:12:40GMT Content-Type:text/html;charset=UTF-8 Vary:Accept-Encoding X-Powered-By:PHP/5.6.24-1~dotdeb+7.1 X-Cache:MISSfromcache-2 X-Cache-Lookup:MISSfromcache-2:80 X-Cache:MISSfromproxy-2_6 Transfer-Encoding:chunked Via:1.1cache-2:80,1.1proxy-2_6:8006 Connection:keep-alive当前IP:61.148.226.66来自:北京市联通
可以看到,这次成功了。这是因为当存在ENTRYPOINT后,CMD的内容将会作为参数传给ENTRYPOINT,而这里-i就是新的CMD,因此会作为参数传给curl,从而达到了我们预期的效果。
场景二:应用运行前的准备工作
启动容器就是启动主进程,但有些时候,启动主进程前,需要一些准备工作。比如mysql类的数据库,可能需要一些数据库配置、初始化的工作,这些工作要在最终的mysql服务器运行之前解决。
此外,可能希望避免使用root用户去启动服务,从而提高安全性,而在启动服务前还需要以root身份执行一些必要的准备工作,最后切换到服务用户身份启动服务。或者除了服务外,其它命令依旧可以使用root身份执行,方便调试等。
这些准备工作是和容器CMD无关的,无论CMD为什么,都需要事先进行一个预处理的工作。这种情况下,可以写一个脚本,然后放入ENTRYPOINT中去执行,而这个脚本会将接到的参数(也就是)作为命令,在脚本最后执行。比如官方镜像redis中就是这么做的:
FROMalpine:3.4 ... RUNaddgroup-Sredis&&adduser-S-Gredisredis ... ENTRYPOINT["docker-entrypoint.sh"] EXPOSE6379 CMD["redis-server"]可以看到其中为了redis服务创建了redis用户,并在最后指定了ENTRYPOINT为dockerentrypoint.sh脚本。
#!/bin/sh ... #allowthecontainertobestartedwith`--user` if["$1"='redis-server'-a"$(id-u)"='0'];then chown-Rredis. execsu-execredis"$0""$@" fi exec"$@"该脚本的内容就是根据CMD的内容来判断,如果是redis-server的话,则切换到redis用户身份启动服务器,否则依旧使用root身份执行。比如:
$dockerrun-itredisid uid=0(root)gid=0(root)groups=0(root)ENV设置环境变量
格式有两种:
- ENV
- ENV
= = ... 这个指令很简单,就是设置环境变量而已,无论是后面的其它指令,如RUN,还是运行时的应用,都可以直接使用这里定义的环境变量。
ENVVERSION=1.0DEBUG=on\ NAME="HappyFeet"这个例子中演示了如何换行,以及对含有空格的值用双引号括起来的办法,这和Shell下的行为是一致的。
定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。比如在官方node镜像Dockerfile中,就有类似这样的代码:
ENVNODE_VERSION7.2.0 RUNcurl-SLO"https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.ta r.xz"\ &&curl-SLO"https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc"\ &&gpg--batch--decrypt--outputSHASUMS256.txtSHASUMS256.txt.asc\ &&grep"node-v$NODE_VERSION-linux-x64.tar.xz\$"SHASUMS256.txt|sha256sum-c-\ &&tar-xJf"node-v$NODE_VERSION-linux-x64.tar.xz"-C/usr/local--strip-components=1\ &&rm"node-v$NODE_VERSION-linux-x64.tar.xz"SHASUMS256.txt.ascSHASUMS256.txt\ &&ln-s/usr/local/bin/node/usr/local/bin/nodejs在这里先定义了环境变量NODE_VERSION,其后的RUN这层里,多次使用$NODE_VERSION来进行操作定制。可以看到,将来升级镜像构建版本的时候,只需要更新7.2.0即可,Dockerfile构建维护变得更轻松了。
下列指令可以支持环境变量展开:
ADD、COPY、ENV、EXPOSE、LABEL、USER、WORKDIR、VOLUME、STOPSIGNAL、ONBUILD。可以从这个指令列表里感觉到,环境变量可以使用的地方很多,很强大。通过环境变量,我们可以让一份Dockerfile制作更多的镜像,只需使用不同的环境变量即可。
ARG构建参数
格式:ARG<参数名>[=<默认值>]
构建参数和ENV的效果一样,都是设置环境变量。所不同的是,ARG所设置的构建环境的环境变量,在将来容器运行时是不会存在这些环境变量的。但是不要因此就使用ARG保存密码之类的信息,因为dockerhistory还是可以看到所有值的。
Dockerfile中的ARG指令是定义参数名称,以及定义其默认值。该默认值可以在构建命令dockerbuild中用--build-arg<参数名>=<值>来覆盖。
在1.13之前的版本,要求–build-arg中的参数名,必须在Dockerfile中用ARG定义过了,换句话说,就是–build-arg指定的参数,必须在Dockerfile中使用了。如果对应参数没有被使用,则会报错退出构建。从1.13开始,这种严格的限制被放开,不再报错退出,而是显示警告信息,并继续构建。这对于使用CI系统,用同样的构建流程构建不同的Dockerfile的时候比较有帮助,避免构建命令必须根据每个Dockerfile的内容修改。
VOLUME定义匿名卷
格式为:
- VOLUME["<路径1>","<路径2>"...]
- VOLUME<路径>
之前我们说过,容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中。为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在Dockerfile中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。
VOLUME/data这里的/data目录就会在运行时自动挂载为匿名卷,任何向/data中写入的信息都不会记录进容器存储层,从而保证了容器存储层的无状态化。当然,运行时可以覆盖这个挂载设置。比如:
dockerrun-d-vmydata:/dataxxxx在这行命令中,就使用了mydata这个命名卷挂载到了/data这个位置,替代了Dockerfile中定义的匿名卷的挂载配置。
EXPOSE声明端口
格式为EXPOSE<端口1>[<端口2>...]。
EXPOSE指令是声明运行时容器提供服务端口,这只是一个声明,在运行时并不会因为这个声明应用就会开启这个端口的服务。在Dockerfile中写入这样的声明有两个好处,一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射;另一个用处则是在运行时使用随机端口映射时,也就是dockerrun-P时,会自动随机映射EXPOSE的端口。
此外,在早期Docker版本中还有一个特殊的用处。以前所有容器都运行于默认桥接网络中,因此所有容器互相之间都可以直接访问,这样存在一定的安全性问题。于是有了一个Docker引擎参数--icc=false,当指定该参数后,容器间将默认无法互访,除非互相间使用了--links参数的容器才可以互通,并且只有镜像中EXPOSE所声明的端口才可以被访问。这个--icc=false的用法,在引入了dockernetwork后已经基本不用了,通过自定义网络可以很轻松的实现容器间的互联与隔离。
要将EXPOSE和在运行时使用-p<宿主端口>:<容器端口>区分开来。-p,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而EXPOSE仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射。
WORKDIR指定工作目录
格式为WORKDIR<工作目录路径>。
使用WORKDIR指令可以来指定工作目录(或者称为当前目录),以后各层的当前目录就被改为指定的目录,如该目录不存在,WORKDIR会帮你建立目录。
之前提到一些初学者常犯的错误是把Dockerfile等同于Shell脚本来书写,这种错误的理解还可能会导致出现下面这样的错误:
RUNcd/app RUNecho"hello">world.txt如果将这个Dockerfile进行构建镜像运行后,会发现找不到/app/world.txt文件,或者其内容不是hello。原因其实很简单,在Shell中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在Dockerfile中,这两行RUN命令的执行环境根本不同,是两个完全不同的容器。这就是对Dockerfile构建分层存储的概念不了解所导致的错误。
之前说过每一个RUN都是启动一个容器、执行命令、然后提交存储层文件变更。第一层RUNcd/app的执行仅仅是当前进程的工作目录变更,一个内存上的变化而已,其结果不会造成任何文件变更。而到第二层的时候,启动的是一个全新的容器,跟第一层的容器更完全没关系,自然不可能继承前一层构建过程中的内存变化。
因此如果需要改变以后各层的工作目录的位置,那么应该使用WORKDIR指令。
USER指定当前用户
格式:USER<用户名>
USER指令和WORKDIR相似,都是改变环境状态并影响以后的层。WORKDIR是改变工作目录,USER则是改变之后层的执行RUN,CMD以及ENTRYPOINT这类命令的身份。当然,和WORKDIR一样,USER只是帮助你切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换。
RUNgroupadd-rredis&&useradd-r-gredisredis USERredis RUN["redis-server"]如果以root执行的脚本,在执行期间希望改变身份,比如希望以某个已经建立好的用户来运行某个服务进程,不要使用su或者sudo,这些都需要比较麻烦的配置,而且在TTY缺失的环境下经常出错。建议使用gosu。
#建立redis用户,并使用gosu换另一个用户执行命令 RUNgroupadd-rredis&&useradd-r-gredisredis #下载gosu RUNwget-O/usr/local/bin/gosu"https://github.com/tianon/gosu/releases/download/1.7/ gosu-amd64"\ &&chmod+x/usr/local/bin/gosu\ &&gosunobodytrue #设置CMD,并以另外的用户执行 CMD["exec","gosu","redis","redis-server"]HEALTHCHECK健康检查
格式:
- HEALTHCHECK[选项]CMD<命令>:设置检查容器健康状况的命令
- HEALTHCHECKNONE:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令
HEALTHCHECK指令是告诉Docker应该如何进行判断容器的状态是否正常,这是Docker1.12引入的新指令。
在没有HEALTHCHECK指令前,Docker引擎只可以通过容器内主进程是否退出来判断容器是否状态异常。很多情况下这没问题,但是如果程序进入死锁状态,或者死循环状态,应用进程并不退出,但是该容器已经无法提供服务了。在1.12以前,Docker不会检测到容器的这种状态,从而不会重新调度,导致可能会有部分容器已经无法提供服务了却还在接受用户请求。
而自1.12之后,Docker提供了HEALTHCHECK指令,通过该指令指定一行命令,用这行命令来判断容器主进程的服务状态是否还正常,从而比较真实的反应容器实际状态。
当在一个镜像指定了HEALTHCHECK指令后,用其启动容器,初始状态会为starting,在HEALTHCHECK指令检查成功后变为healthy,如果连续一定次数失败,则会变为unhealthy。
HEALTHCHECK支持下列选项:
- interval=<间隔>:两次健康检查的间隔,默认为30秒;
- timeout=<时长>:健康检查命令运行超时时间,如果超过这个时间,本次健康检查就被视为失败,默认30秒;
- retries=<次数>:当连续失败指定次数后,则将容器状态视为unhealthy,默认3次。
和CMD,ENTRYPOINT一样,HEALTHCHECK只可以出现一次,如果写了多个,只有最后一个生效。
在HEALTHCHECK[选项]CMD后面的命令,格式和ENTRYPOINT一样,分为shell格式,和exec格式。命令的返回值决定了该次健康检查的成功与否:0:成功;1:失败;2:保留,不要使用这个值。
假设我们有个镜像是个最简单的Web服务,我们希望增加健康检查来判断其Web服务是否在正常工作,我们可以用curl来帮助判断,其Dockerfile的HEALTHCHECK可以这么写:
FROMnginx RUNapt-getupdate&&apt-getinstall-ycurl&&rm-rf/var/lib/apt/lists/* HEALTHCHECK--interval=5s--timeout=3s\ CMDcurl-fshttp://localhost/||exit1这里我们设置了每5秒检查一次(这里为了试验所以间隔非常短,实际应该相对较长),如果健康检查命令超过3秒没响应就视为失败,并且使用curl-fshttp://localhost/||exit1作为健康检查命令。
使用dockerbuild来构建这个镜像:
$dockerbuild-tmyweb:v1.构建好了后,我们启动一个容器:
$dockerrun-d--nameweb-p80:80myweb:v1当运行该镜像后,可以通过dockercontainerls看到最初的状态为(health:starting):
$dockercontainerls CONTAINERIDIMAGECOMMANDCREATEDSTATUSPORTSNAMES 03e28eb00bd0myweb:v1"nginx-g'daemonoff"3secondsagoUp2seconds(health:starting)80/tcp,443/tcpweb在等待几秒钟后,再次dockercontainerls,就会看到健康状态变化为了(healthy):
$dockercontainerls CONTAINERIDIMAGECOMMANDCREATEDSTATUSPORTSNAMES 03e28eb00bd0myweb:v1"nginx-g'daemonoff"18secondsagoUp16seconds(health:healthy)80/tcp,443/tcpweb如果健康检查连续失败超过了重试次数,状态就会变为(unhealthy)。
为了帮助排障,健康检查命令的输出(包括stdout以及stderr)都会被存储于健康状态里,可以用dockerinspect来查看。
$dockerinspect--format'{{json.State.Health}}'upbeat_allen|python-mjson.tool { "FailingStreak":0, "Log":[ { "End":"2018-06-14T04:55:37.477730277-04:00", "ExitCode":0, "Output":"\n\n\nWelcometonginx! \n\n\n\nWelcometonginx!
\nIfyouseethispage,thenginxwebserverissuccessfullyinstalledand\nworking.Furtherconfigurationisrequired.
\n\nForonlinedocumentationandsupportpleasereferto\n
\n\nnginx.org.
\nCommercialsupportisavailableat\nnginx.com. Thankyouforusingnginx.
\n\n\n", "Start":"2018-06-14T04:55:37.408045977-04:00" }, { "End":"2018-06-14T04:55:42.553816257-04:00", "ExitCode":0, "Output":"\n\n\nWelcometonginx! \n\n\n\nWelcometonginx!
\nIfyouseethispage,thenginxwebserverissuccessfullyinstalledand\nworking.Furtherconfigurationisrequired.
\n\nForonlinedocumentationandsupportpleasereferto\n
\n\nnginx.org.
\nCommercialsupportisavailableat\nnginx.com. Thankyouforusingnginx.
\n\n\n", "Start":"2018-06-14T04:55:42.480940888-04:00" }, { "End":"2018-06-14T04:55:47.631694051-04:00", "ExitCode":0, "Output":"\n\n\nWelcometonginx! \n\n\n\nWelcometonginx!
\nIfyouseethispage,thenginxwebserverissuccessfullyinstalledand\nworking.Furtherconfigurationisrequired.
\n\nForonlinedocumentationandsupportpleasereferto\n
\n\nnginx.org.
\nCommercialsupportisavailableat\nnginx.com. Thankyouforusingnginx.
\n\n\n", "Start":"2018-06-14T04:55:47.557214953-04:00" }, { "End":"2018-06-14T04:55:52.708195002-04:00", "ExitCode":0, "Output":"\n\n\nWelcometonginx! \n\n\n\nWelcometonginx!
\nIfyouseethispage,thenginxwebserverissuccessfullyinstalledand\nworking.Furtherconfigurationisrequired.
\n\nForonlinedocumentationandsupportpleasereferto\n
\n\nnginx.org.
\nCommercialsupportisavailableat\nnginx.com. Thankyouforusingnginx.
\n\n\n", "Start":"2018-06-14T04:55:52.63499573-04:00" }, { "End":"2018-06-14T04:55:57.795117794-04:00", "ExitCode":0, "Output":"\n\n\nWelcometonginx! \n\n\n\nWelcometonginx!
\nIfyouseethispage,thenginxwebserverissuccessfullyinstalledand\nworking.Furtherconfigurationisrequired.
\n\nForonlinedocumentationandsupportpleasereferto\n
\n\nnginx.org.
\nCommercialsupportisavailableat\nnginx.com. Thankyouforusingnginx.
\n\n\n", "Start":"2018-06-14T04:55:57.714289056-04:00" } ], "Status":"healthy" }ONBUILD为他人做嫁衣裳
格式:ONBUILD<其它指令>。
ONBUILD是一个特殊的指令,它后面跟的是其它指令,比如RUN,COPY等,而这些指令,在当前镜像构建时并不会被执行。只有当以当前镜像为基础镜像,去构建下一级镜像的时候才会被执行。
Dockerfile中的其它指令都是为了定制当前镜像而准备的,唯有ONBUILD是为了帮助别人定制自己而准备的。
假设我们要制作Node.js所写的应用的镜像。我们都知道Node.js使用npm进行包管理,所有依赖、配置、启动信息等会放到package.json文件里。在拿到程序代码后,需要先进行npminstall才可以获得所有需要的依赖。然后就可以通过npmstart来启动应用。因此,一般来说会这样写Dockerfile:
FROMnode:slim RUNmkdir/app WORKDIR/app COPY./package.json/app RUN["npm","install"] COPY./app/ CMD["npm","start"]把这个Dockerfile放到Node.js项目的根目录,构建好镜像后,就可以直接拿来启动容器运行。但是如果我们还有第二个Node.js项目也差不多呢?好吧,那就再把这个Dockerfile复制到第二个项目里。那如果有第三个项目呢?再复制么?文件的副本越多,版本控制就越困难,让我们继续看这样的场景维护的问题。
如果第一个Node.js项目在开发过程中,发现这个Dockerfile里存在问题,比如敲错字了、或者需要安装额外的包,然后开发人员修复了这个Dockerfile,再次构建,问题解决。第一个项目没问题了,但是第二个项目呢?虽然最初Dockerfile是复制、粘贴自第一个项目的,但是并不会因为第一个项目修复了他们的Dockerfile,而第二个项目的Dockerfile就会被自动修复。
那么我们可不可以做一个基础镜像,然后各个项目使用这个基础镜像呢?这样基础镜像更新,各个项目不用同步Dockerfile的变化,重新构建后就继承了基础镜像的更新?好吧,可以,让我们看看这样的结果。那么上面的这个Dockerfile就会变为:
FROMnode:slim RUNmkdir/app WORKDIR/app CMD["npm","start"]这里我们把项目相关的构建指令拿出来,放到子项目里去。假设这个基础镜像的名字为mynode的话,各个项目内的自己的Dockerfile就变为:
FROMmy-node COPY./package.json/app RUN["npm","install"] COPY./app/基础镜像变化后,各个项目都用这个Dockerfile重新构建镜像,会继承基础镜像的更新。
那么,问题解决了么?没有。准确说,只解决了一半。如果这个Dockerfile里面有些东西需要调整呢?比如npminstall都需要加一些参数,那怎么办?这一行RUN是不可能放入基础镜像的,因为涉及到了当前项目的./package.json,难道又要一个个修改么?所以说,这样制作基础镜像,只解决了原来的Dockerfile的前4条指令的变化问题,而后面三条指令的变化则完全没办法处理。
ONBUILD可以解决这个问题。让我们用ONBUILD重新写一下基础镜像的Dockerfile:
FROMnode:slim RUNmkdir/app WORKDIR/app ONBUILDCOPY./package.json/app ONBUILDRUN["npm","install"] ONBUILDCOPY./app/ CMD["npm","start"]这次我们回到原始的Dockerfile,但是这次将项目相关的指令加上ONBUILD,这样在构建基础镜像的时候,这三行并不会被执行。然后各个项目的Dockerfile就变成了简单地:
FROMmy-node是的,只有这么一行。当在各个项目目录中,用这个只有一行的Dockerfile构建镜像时,之前基础镜像的那三行ONBUILD就会开始执行,成功的将当前项目的代码复制进镜像、并且针对本项目执行npminstall,生成应用镜像。
参考:https://github.com/yeasy/docker_practice
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持毛票票。
声明:本文内容来源于网络,版权归原作者所有,内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:czq8825#qq.com(发邮件时,请将#更换为@)进行举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。