在执行每条指令之前,Docker都会在缓存中查找是否已经存在可重用的镜像,如果存在就使用现存的镜像,不再重复创建。
因此,为了有效地利用缓存,尽量保持Dockerfile一致,并且尽量在末尾修改:
FROM ubuntu
MAINTAINER author
RUN echo "deb http://archive.ubuntu.com/ubuntu precise main universe"
RUN apt-get update
RUN apt-get upgrade -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可能类似于这样:
FROM golang:1.11-alpine AS build
# 安装项目需要的工具
# 运行 `docker build --no-cache .` 来更新依赖
RUN apk add --no-cache git
RUN go get github.com/golang/dep/cmd/dep
# 通过 Gopkg.toml 和 Gopkg.lock 获取项目的依赖
# 仅在更新 Gopkg 文件时才重新构建这些层
COPY Gopkg.lock Gopkg.toml /go/src/project/
WORKDIR /go/src/project/
# 安装依赖库
RUN dep ensure -vendor-only
# 拷贝整个项目进行构建
# 当项目下面有文件变化的时候该层才会重新构建
COPY . /go/src/project/
RUN go build -o /bin/project
# 将打包后的二进制文件拷贝到 scratch 镜像下面,将镜像大小降到最低
FROM scratch
COPY --from=build /bin/project /bin/project
ENTRYPOINT ["/bin/project"]
CMD ["--help"]
使用标签
除非是在用Docker做实验,否则你应当通过 -t 选项来 docker build 新的镜像以便于标记构建的镜像。一个简单可读的标签可以帮助管理每个创建的镜像。
docker build -t="tuxknight/luckypython"
始终通过 -t 标记来构建镜像。
公开端口
Docker的核心概念是可重复和可移植,镜像应该可以运行在任何主机上并运行尽可能多的次数。在Dockerfile中可以映射私有和公有端口,但永远不要通过Dockerfile映射公有端口。这样运行多个镜像的情况下会出现端口冲突的问题。
EXPOSE 80:8080 # 80映射到host的8080,不提倡这种用法
EXPOSE 80 # 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。
多行参数排序
只要有可能,就将多行参数按字母顺序排序。这可以避免重复包含同一个包,更新包列表时也更容易,也更容易阅读和审查。建议在反斜杠符号 \ 之前添加一个空格,可以增加可读性。
RUN apt-get update && apt-get install -y \
bzr \
cvs \
git \
mercurial \
subversion
Dockerfile指令最佳实践
关于这些指令的使用建议可以帮助我们创建高效且可维护的Dockerfile。以下内容为Dockerfile指令部分的最佳实践。
FROM
尽可能使用当前的官方镜像作为基础镜像。推荐使用Debian镜像,大小保持在100MB上下,且仍是完整的发行版。
另外,根据情况也可考虑使用Alpine映像,因为它受到严格控制且较小(当前小于5MB),同时仍是完整的Linux发行版。
LABEL标签
可以给镜像添加标签来帮助组织镜像、记录许可信息、辅助自动化构建等。每个标签一行,由LABEL开头加上一个或多个标签对。
下面的示例展示了各种不同的可能格式。#开头的行是注释内容。
# Set one or more individual labels
LABEL com.example.version="0.0.1-beta"
LABEL vendor="ACME Incorporated"
LABEL com.example.release-date="2015-02-12"
LABEL com.example.version.is-production=""
一个镜像可以包含多个标签,当然以上内容也可以写成下面这样,但是不是必须的:
# Set multiple labels at once, using line-continuation characters to break long lines
LABEL vendor=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这种方式,先更新,之后安装最新的软件包。
RUN apt-get update && apt-get install -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文件:
FROM ubuntu:14.04
RUN apt-get update
RUN apt-get install -y curl
构建镜像后,所有的层都在Docker的缓存中。假设后来又修改了其中的apt-get install添加了一个包:
FROM ubuntu:14.04
RUN apt-get update
RUN apt-get install -y curl nginx
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也能用于设置常见的版本号,比如下面的示例:
ENV PG_MAJOR 9.3
ENV PG_VERSION 9.3.4
RUN curl -SL http://example.com/postgres-$PG_VERSION.tar.xz | tar -xJC /usr/src/postgress && …
ENV PATH /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 的默认参数
CMD command param1 param2 # shell 形式
CMD推荐使用CMD ["executable","param1","param2"]这样的格式。如果镜像是用来运行服务,需要使用CMD["apache2","-DFOREGROUND"],这种格式的指令适用于任何服务性质的镜像。
ENTRYPOINT 指令
根据官方定义来说ENTRYPOINT才是用于定义容器启动以后的执行程序的,允许将镜像当成命令本身来运行(用CMD提供默认选项),从名字也可以理解,是容器的入口。
ENTRYPOINT 一共有两种用法:
ENTRYPOINT ["executable", "param1", "param2"] (exec 形式)
ENTRYPOINT command param1 param2 (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来获取文件,并且在文件不需要的时候删除文件。
RUN mkdir -p /usr/src/things \
&& curl -SL http://example.com/big.tar.gz \
| tar -xJC /usr/src/things \
&& make -C /usr/src/things all
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这样难以维护的指令。后者难以阅读、排错和维护。