在镜像的构建过程中,Docker会根据Dockerfile指定的顺序执行每个指令。Dockerfile的每条指令都会将结果提交为新的镜像。然后,下一条指令基于上一条指令的镜像进行构建。
在执行每条指令之前,Docker都会在缓存中查找是否已经存在可重用的镜像,如果存在就使用现存的镜像,不再重复创建。
因此,为了有效地利用缓存,尽量保持Dockerfile一致,并且尽量在末尾修改:
FROM ubuntu
MAINTAINER author <somebody@company.com>
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中,构建缓存遵循的基本规则如下:
多阶段构建可以大幅度减小最终的镜像大小,而不需要去想办法减少中间层和文件的数量。因为镜像是在生成过程的最后阶段生成的,所以可以利用生成缓存来最小化镜像层。
例如,如果构建包含多个层,则可以将它们从变化频率较低(以确保生成缓存可重用)到变化频率较高的顺序排序:
比如构建一个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/ target=_blank class=infotextkey>Python"
始终通过 -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 /bin/echo
CMD ["/bin/echo"]
在第一种方式下,Docker会在命令前加上 /bin/sh -c,可能会导致一些意想不到的问题。在第二种方式下,CMD ENTRYPOINT是一个数组,执行的命令完全和期待的一样。
容器模型是进程而不是机器,不需要开机初始化。在需要时运行,不需要时停止,能够删除后重建,并且配置和启动的最小化。
在docker build的时候,对于一些不需要提交构建的文件用.dockerignore来进行忽略。忽略部分无用的文件和目录可以提高构建的速度。
不在容器中更新,更新交给基础镜像来处理。
每个容器只运行一个进程,每个容器应用只关心一个方面的事情。将多个应用解耦到不同容器中,容器起到了隔离应用隔离数据的作用,可以更轻松地保证容器的横向扩展和复用。
例如一个Web应用程序可能包含三个独立的容器:Web应用、数据库、缓存,每个容器都是独立的镜像,分开运行。但这并不是说一个容器就只能跑一个进程,因为有的程序可能会自行产生其他进程,比如Celery就可以有很多个工作进程。
虽然每个容器跑一个进程是一条很好的法则,但这并不是一条硬性的规定。主要是希望一个容器只关注一件事情,尽量保持干净和模块化。如果容器互相依赖,你可以使用 Docker 容器网络 来把这些容器连接起来。
在很早之前的版本中尽量减少镜像层数是非常重要的,不过现在的版本已经有了一定的改善了:
需要掌握好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指令部分的最佳实践。
尽可能使用当前的官方镜像作为基础镜像。推荐使用Debian镜像,大小保持在100MB上下,且仍是完整的发行版。
另外,根据情况也可考虑使用Alpine映像,因为它受到严格控制且较小(当前小于5MB),同时仍是完整的linux发行版。
可以给镜像添加标签来帮助组织镜像、记录许可信息、辅助自动化构建等。每个标签一行,由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:如果字符串包含空格,那么它必须被引用或者空格必须被转义。如果字符串包含内部引号字符("),则也可以将其转义。
为了保持Dockerfile文件的可读性以及可维护性,建议将过长的或复杂的RUN指令用反斜杠分割成多行,以提高可读性和可维护性。
RUN指令最常见的用法是安装包用的apt-get。因为RUN apt-get指令会安装包,所以有几个问题需要注意。
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指令用于指定容器将要监听的端口。因此,要为应用程序使用常见的端口。
例如,提供Apache web服务的镜像应该使用EXPOSE 80,而提供MongoDB服务的镜像使用EXPOSE 27017。
对于外部访问,用户可以在执行docker run时使用一个-p参数来指示如何将指定的端口映射到所选择的端口。
为了方便新程序运行,可以使用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指令是容器启动以后,默认的执行命令,需要重点理解下这个默认的含义,意思就是如果我们执行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才是用于定义容器启动以后的执行程序的,允许将镜像当成命令本身来运行(用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功能类似,但推荐使用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保存到host中。
强烈建议使用VOLUME来管理镜像中的可变部分和用户可以改变的部分。
如果服务不需要特权来运行,使用USER指令切换到非root用户。使用RUN groupadd -r MySQL && useradd -r -g mysql mysql之后用USER mysql切换用户。
要避免使用sudo来提升权限,因为它不可预期的TTY和信号转发行为可能造成的问题比它能解决的问题还多。如果你真的需要和sudo类似的功能(例如,以root权限初始化某个守护进程,以非root权限执行它),你可以使用gosu。我们可以去查看官方的一些镜像,很多都是使用的gosu。
最后,不要反复地切换用户,减少不必要的layers。
为了清晰性和可靠性,WORKDIR的路径应该始终使用绝对路径。同时,使用WORKDIR来替代RUN cd ... && do-something这样难以维护的指令。后者难以阅读、排错和维护。