来源:程序新视界
构建缓存
在镜像的构建过程中,Docker会根据Dockerfile指定的顺序执行每个指令。Dockerfile的每条指令都会将结果提交为新的镜像。然后,下一条指令基于上一条指令的镜像进行构建。
在执行每条指令之前,Docker都会在缓存中查找是否已经存在可重用的镜像,如果存在就使用现存的镜像,不再重复创建。
因此,为了有效地利用缓存,尽量保持Dockerfile一致,并且尽量在末尾修改:
FROMubuntu
MAINTAINERauthor<somebody@company.com>
RUNecho"deb
RUNapt-getupdate
RUNapt-getupgrade-y
更改MAINTAINER
指令会使Docker强制执行run
指令来更新apt,而不是使用缓存。
如不希望使用缓存,在执行 docker build
时需加上参数--no-cache=true
。
Docker中,构建缓存遵循的基本规则如下:
从缓存中存在的基础镜像(FROM指令指定)开始,下一条指令将和该基础镜像的所有子镜像进行匹配,检查这些子镜像被创建时使用的指令是否和被检查的指令完全一样。如果不是,则缓存失效。 多数情况中,使用其中一个子镜像来比较Dockerfile中的指令是足够的。然而,特定的指令需要做更多的判断。 对于 ADD
和COPY
指令,镜像中对应文件的内容也会被检查,每个文件都会计算出一个校验值,通常是检查文件的校验和(checksum)。在缓存的查找过程中,会将这些校验和已存在镜像中的文件校验值进行对比。如果文件有任何改变,则缓存失效。除了 ADD
和COPY
指令,缓存匹配检查并不检查临时容器中的文件。例如,当使用RUN apt-get -y update
命令更新了容器中的文件,并不会被缓存检查策略作为缓存匹配的依据。一旦缓存失效,所有后续的Dockerfile指令都将产生新的镜像,缓存不会被使用。
使用多阶段构建
多阶段构建可以大幅度减小最终的镜像大小,而不需要去想办法减少中间层和文件的数量。因为镜像是在生成过程的最后阶段生成的,所以可以利用生成缓存来最小化镜像层。
例如,如果构建包含多个层,则可以将它们从变化频率较低(以确保生成缓存可重用)到变化频率较高的顺序排序:
安装构建应用程序所需的依赖工具 安装或更新依赖项 构建你的应用
比如构建一个Go应用程序的Dockerfile可能类似于这样:
FROMgolang:1.11-alpineASbuild
#安装项目需要的工具
#运行`dockerbuild--no-cache.`来更新依赖
RUNapkadd--no-cachegit
RUNgogetgithub.com/golang/dep/cmd/dep
#通过Gopkg.toml和Gopkg.lock获取项目的依赖
#仅在更新Gopkg文件时才重新构建这些层
COPYGopkg.lockGopkg.toml/go/src/project/
WORKDIR/go/src/project/
#安装依赖库
RUNdepensure-vendor-only
#拷贝整个项目进行构建
#当项目下面有文件变化的时候该层才会重新构建
COPY./go/src/project/
RUNgobuild-o/bin/project
#将打包后的二进制文件拷贝到scratch镜像下面,将镜像大小降到最低
FROMscratch
COPY--from=build/bin/project/bin/project
ENTRYPOINT["/bin/project"]
CMD["--help"]
使用标签
除非是在用Docker做实验,否则你应当通过 -t
选项来 docker build
新的镜像以便于标记构建的镜像。一个简单可读的标签可以帮助管理每个创建的镜像。
dockerbuild-t="tuxknight/luckypython"
始终通过 -t
标记来构建镜像。
公开端口
Docker的核心概念是可重复和可移植,镜像应该可以运行在任何主机上并运行尽可能多的次数。在Dockerfile中可以映射私有和公有端口,但永远不要通过Dockerfile映射公有端口。这样运行多个镜像的情况下会出现端口冲突的问题。
EXPOSE80:8080#80映射到host的8080,不提倡这种用法
EXPOSE80#80会被docker随机映射一个端口
EXPOSE指令用于声明容器将监听的端口。在EXPOSE指令中,端口号的格式为<容器端口>/<协议>
。其中,容器端口是指在容器内部应用程序监听的端口,而协议是可选的,默认为TCP。
示例中,EXPOSE 80:8080
表示容器将监听容器端口80,而宿主机可以使用端口8080来访问容器的80端口。也就是,容器的80端口映射到了宿主机的8080端口。
请注意,EXPOSE指令仅仅是声明容器将监听的端口,并不会自动进行端口映射。要实际进行端口映射,需要在运行容器时使用-p
或-P
选项。
CMD ENTRYPOINT语法
CMD
和ENTRYPOINT
支持两种语法:
CMD/bin/echo
CMD["/bin/echo"]
在第一种方式下,Docker会在命令前加上 /bin/sh -c
,可能会导致一些意想不到的问题。在第二种方式下,CMD ENTRYPOINT
是一个数组,执行的命令完全和期待的一样。
容器是短暂的
容器模型是进程而不是机器,不需要开机初始化。在需要时运行,不需要时停止,能够删除后重建,并且配置和启动的最小化。
.dockerignore 文件
在docker build
的时候,对于一些不需要提交构建的文件用.dockerignore来进行忽略。忽略部分无用的文件和目录可以提高构建的速度。
不要在构建中升级版本
不在容器中更新,更新交给基础镜像来处理。
应用解耦
每个容器只运行一个进程,每个容器应用只关心一个方面的事情。将多个应用解耦到不同容器中,容器起到了隔离应用隔离数据的作用,可以更轻松地保证容器的横向扩展和复用。
例如一个Web应用程序可能包含三个独立的容器:Web应用、数据库、缓存,每个容器都是独立的镜像,分开运行。但这并不是说一个容器就只能跑一个进程,因为有的程序可能会自行产生其他进程,比如Celery就可以有很多个工作进程。
虽然每个容器跑一个进程是一条很好的法则,但这并不是一条硬性的规定。主要是希望一个容器只关注一件事情,尽量保持干净和模块化。如果容器互相依赖,你可以使用 Docker 容器网络 来把这些容器连接起来。
最小化镜像层数
在很早之前的版本中尽量减少镜像层数是非常重要的,不过现在的版本已经有了一定的改善了:
只有RUN、COPY和ADD指令会创建层,其他指令会创建临时的中间镜像,但是不会直接增加构建的镜像大小了。 多阶段构建的支持,允许我们把需要的数据直接复制到最终的镜像中,这就允许在中间阶段包含一些工具或者调试信息了,而且不会增加最终的镜像大小。
需要掌握好Dockerfile的可读性和文件系统层数之间的平衡。控制文件系统层数时会降低Dockerfile的可读性。而Dockerfile可读性高时,往往会导致更多的文件系统层数。
避免安装不必要的包
为了降低复杂性、减少依赖、减小文件大小和构建时间,应该避免安装额外的或者不必要的软件包。例如,不要在数据库镜像中包含一个文本编辑器。
使用特定标签
Dockerfile中FROM
应始终包含依赖的基础镜像的完整仓库名和标签,如使用FROM debian:jessie
而不是FROM debian
。
多行参数排序
只要有可能,就将多行参数按字母顺序排序。这可以避免重复包含同一个包,更新包列表时也更容易,也更容易阅读和审查。建议在反斜杠符号 \
之前添加一个空格,可以增加可读性。
RUNapt-getupdate&&apt-getinstall-y\
bzr\
cvs\
git\
mercurial\
subversion
Dockerfile指令最佳实践
关于这些指令的使用建议可以帮助我们创建高效且可维护的Dockerfile。以下内容为Dockerfile指令部分的最佳实践。
FROM
尽可能使用当前的官方镜像作为基础镜像。推荐使用Debian
镜像,大小保持在100MB上下,且仍是完整的发行版。
另外,根据情况也可考虑使用Alpine映像,因为它受到严格控制且较小(当前小于5MB),同时仍是完整的Linux发行版。
LABEL标签
可以给镜像添加标签来帮助组织镜像、记录许可信息、辅助自动化构建等。每个标签一行,由LABEL开头加上一个或多个标签对。
下面的示例展示了各种不同的可能格式。#
开头的行是注释内容。
#Setoneormoreindividuallabels
LABELcom.example.version="0.0.1-beta"
LABELvendor="ACMEIncorporated"
LABELcom.example.release-date="2015-02-12"
LABELcom.example.version.is-production=""
一个镜像可以包含多个标签,当然以上内容也可以写成下面这样,但是不是必须的:
#Setmultiplelabelsatonce,usingline-continuationcharacterstobreaklonglines
LABELvendor=ACME\Incorporated\
com.example.is-production=""\
com.example.version="0.0.1-beta"\
com.example.release-date="2015-02-12"
PS:如果字符串包含空格,那么它必须被引用或者空格必须被转义。如果字符串包含内部引号字符("),则也可以将其转义。
RUN
为了保持Dockerfile文件的可读性以及可维护性,建议将过长的或复杂的RUN
指令用反斜杠\
分割成多行,以提高可读性和可维护性。
RUN
指令最常见的用法是安装包用的apt-get
。因为RUN apt-get
指令会安装包,所以有几个问题需要注意。
避免运行 apt-get upgrade
或dist-upgrade
,在无特权的容器中,很多必要的包不能正常升级。如果基础镜像过时了,应当联系维护者。如果你确定某个特定的包,比如foo,需要升级,使用apt-get install -y foo
就行,该指令会自动升级foo包。永远将 apt-get update
和apt-get install
一起执行,否则apt-get install
会出现异常。推荐 apt-get update && apt-get install -y package-a package-b
这种方式,先更新,之后安装最新的软件包。
RUNapt-getupdate&&apt-getinstall-y\
aufs-tools\
automake\
btrfs-tools\
build-essential\
curl\
dpkg-sig\
git\
iptables\
libapparmor-dev\
libcap-dev\
libsqlite3-dev\
lxc=1.0*\
mercurial\
parallel\
reprepro\
ruby1.9.1\
ruby1.9.1-dev\
s3cmd=1.1.0*
将apt-get update
放在一条单独的RUN
声明中会导致缓存问题以及后续的apt-get install
失败。比如,假设有一个Dockerfile文件:
FROMubuntu:14.04
RUNapt-getupdate
RUNapt-getinstall-ycurl
构建镜像后,所有的层都在Docker的缓存中。假设后来又修改了其中的apt-get install
添加了一个包:
FROMubuntu:14.04
RUNapt-getupdate
RUNapt-getinstall-ycurlnginx
Docker发现修改后的RUN apt-get update
指令和之前的完全一样。所以,apt-get update
不会执行,而是使用之前的缓存镜像。因为apt-get update
没有运行,后面的apt-get install
可能安装的是过时的curl和nginx版本。
使用RUN apt-get update && apt-get install -y
可以确保Dockerfiles每次安装的都是包的最新的版本,而且这个过程不需要进一步的编码或额外干预。这项技术叫做cache busting(缓存破坏)。
EXPOSE 指令
EXPOSE指令用于指定容器将要监听的端口。因此,要为应用程序使用常见的端口。
例如,提供Apache web服务的镜像应该使用EXPOSE 80
,而提供MongoDB服务的镜像使用EXPOSE 27017
。
对于外部访问,用户可以在执行docker run
时使用一个-p
参数来指示如何将指定的端口映射到所选择的端口。
ENV 指令
为了方便新程序运行,可以使用ENV指令来为容器中安装的程序更新PATH环境变量。例如使用ENV PATH /usr/local/nginx/bin:$PATH
来确保CMD ["nginx"]
能正确运行。
ENV指令也可用于为容器化的服务提供必要的环境变量,比如Postgres需要的PGDATA。最后,ENV也能用于设置常见的版本号,比如下面的示例:
ENVPG_MAJOR9.3
ENVPG_VERSION9.3.4
RUNcurl-SLhttp://example.com/postgres-$PG_VERSION.tar.xz|tar-xJC/usr/src/postgress&&…
ENVPATH/usr/local/postgres-$PG_MAJOR/bin:$PATH
类似于程序中的常量,这种方法可以只需改变ENV指令来自动的改变容器中的软件版本。
CMD
CMD指令是容器启动以后,默认的执行命令,需要重点理解下这个默认的含义,意思就是如果我们执行docker run
没有指定任何的执行命令或者Dockerfile里面也没有指定ENTRYPOINT,那么就会使用CMD指定的执行命令执行了。这也说明了ENTRYPOINT才是容器启动以后真正要执行的命令。
所以经常遇到CMD会被覆盖的情况。为什么会被覆盖呢?主要还是因为CMD的定位就是默认,如果不额外指定,那么才会执行CMD命令,但是如果我们指定了的话那就不会执行CMD命令了,也就是说CMD会被覆盖。
CMD总共有三种用法:
CMD["executable","param1","param2"]#exec形式
CMD["param1","param2"]#作为ENTRYPOINT的默认参数
CMDcommandparam1param2#shell形式
CMD推荐使用CMD ["executable","param1","param2"]
这样的格式。如果镜像是用来运行服务,需要使用CMD["apache2","-DFOREGROUND"]
,这种格式的指令适用于任何服务性质的镜像。
ENTRYPOINT 指令
根据官方定义来说ENTRYPOINT
才是用于定义容器启动以后的执行程序的,允许将镜像当成命令本身来运行(用CMD提供默认选项),从名字也可以理解,是容器的入口。
ENTRYPOINT 一共有两种用法:
ENTRYPOINT["executable","param1","param2"](exec形式)
ENTRYPOINTcommandparam1param2(shell形式)
对应命令行exec模式,也就是带中括号的,和CMD的中括号形式是一致的。但是这里貌似是在shell的环境下执行的,与cmd有区别。
如果run命令后面有执行命令,那么后面的全部都会作为ENTRYPOINT的参数。如果run后面没有额外的命令,但是定义了CMD,那么CMD的全部内容就会作为ENTRYPOINT的参数,这同时是上面我们提到的CMD的第二种用法。
所以说ENTRYPOINT不会被覆盖。当然如果要在run里面覆盖,也是有办法的,使用--entrypoint参数即可。
一般会用ENTRYPOINT的中括号形式作为Docker容器启动以后的默认执行命令,里面放的是不变的部分,可变部分比如命令参数可以使用CMD的形式提供默认版本,也就是run里面没有任何参数时使用的默认参数。如果我们想用默认参数,就直接run,否则想用其他参数,就run里面加上参数。
ADD COPY
虽然ADD与COPY功能类似,但推荐使用COPY。因为它比 ADD 更透明。COPY只支持基本的文件拷贝功能,更加的可控。而ADD具有更多特定,比如tar文件自动提取,支持URL。通常需要提取tarball中的文件到容器的时候才会用到ADD。
如果在Dockerfile中使用多个文件,每个文件应使用单独的COPY指令。这样,只有出现文件变化的指令才会不使用缓存。
为了控制镜像的大小,不建议使用ADD指令获取URL文件。正确的做法是在RUN指令中使用wget或curl来获取文件,并且在文件不需要的时候删除文件。
RUNmkdir-p/usr/src/things\
&&curl-SL\
|tar-xJC/usr/src/things\
&&make-C/usr/src/thingsall
VOLUME
VOLUME指令用于声明容器中的目录将被持久化保存,即在容器中创建的目录将被挂载到宿主机或其他容器中,以便数据可以在容器之间共享。
VOLUME指令应当暴露出数据库的存储位置,配置文件的存储以及容器中创建的文件或目录。由于容器结束后并不保存任何更改,应该把所有数据通过VOLUME保存到host中。
强烈建议使用VOLUME来管理镜像中的可变部分和用户可以改变的部分。
USER
如果服务不需要特权来运行,使用USER
指令切换到非root用户。使用RUN groupadd -r mysql && useradd -r -g mysql mysql
之后用USER mysql
切换用户。
要避免使用sudo
来提升权限,因为它不可预期的TTY和信号转发行为可能造成的问题比它能解决的问题还多。如果你真的需要和sudo类似的功能(例如,以root权限初始化某个守护进程,以非root权限执行它),你可以使用gosu。我们可以去查看官方的一些镜像,很多都是使用的gosu。
最后,不要反复地切换用户,减少不必要的layers。
WORKDIR
为了清晰性和可靠性,WORKDIR的路径应该始终使用绝对路径。同时,使用WORKDIR来替代RUN cd ... && do-something
这样难以维护的指令。后者难以阅读、排错和维护。