Dockerfile最佳实践

Dockerfile包含一组指令集合。Docker镜像由每一条指令生成的只读层构成。镜像层是栈式的,并且每一层都基于上一层的变化。

FROM ubuntu:18.04
COPY . /app
RUN make /app
CMD python /app/app.py
  • FROM 创建一个基于ubuntu:18.04的镜像层

  • COPY 将当前目录下面的文件拷贝到镜像中

  • RUN 运行make指令构建应用

  • CMD 指定容器需要运行的命令

当使用Docker镜像生成一个docker容器时,会在容器的最上层添加一个可写层,这个可写层称为容器层。在容器中对文件的增删改都会写入容器层。

构建上下文

docker镜像的构建上下文与Dockerfile文件的位置无关。如果在构建镜像时,Dockerfile正好处于构建上下文之中,且文件的名字为Dockerfile,则可以在构建镜像时省略Dockerfile的指定。

例如:

docker build -t camelgem/test .

如果Dockerfile不在当前的构建上下文路径中,且Dockerfile文件的名字不为Dockerfile,则必须使用-f选项指定Dockerfile的路径和名称。

docker build -f MyDockerfile -t camelgem/test .

构建镜像时,不要将不需要的文件一起打包到镜像中,这些无用的文件增加镜像的大小以及镜像构建的速度。

当构建镜像时,终端会输出一条信息,提示当前构建镜像的构建上下文有多大。

Sending build context to Docker daemon  187.8MB

通过管道来构建镜像

除了通过创建Dockerfile文件来构建镜像以外,我们也可以使用管道来临时构建镜像。

例如下面的示例:

echo -e 'FROM busybox\nRUN echo "hello world"' | docker build -

docker build -<<EOF
FROM busybox
RUN echo "hello world"
EOF

使用下面的构建镜像的语法无需发送额外的文件给docker守护进程。- 让Docker从标准输入读取构建内容而不是目录。

docker build [OPTIONS] -

下面的示例展示了如何通过标准输入的Dockerfile来构建一个镜像:

docker build -t myimage:latest -<<EOF
FROM busybox
RUN echo "hello world"
EOF

上面的构建方式不会有文件作为构建内容发送给Docker守护进程。

当我们不需要拷贝文件到镜像中时,可以使用上面这种方式。它可以提高构建速度,并且不会有文件发送给Docker守护进程。

使用.dockerignore文件来指定要排除的文件,从而减少构建镜像的大小。

当使用标准输入构建镜像不支持COPY或ADD指令。

# create a directory to work in
mkdir example
cd example

# create an example file
touch somefile.txt

docker build -t myimage:latest -<<EOF
FROM busybox
COPY somefile.txt .
RUN cat /somefile.txt
EOF

# observe that the build fails
...
Step 2/3 : COPY somefile.txt .
COPY failed: stat /var/lib/docker/tmp/docker-builder249218248/somefile.txt: no such file or directory

使用来自标准输入的Dockerfile来构建一个使用本地构建上下文的镜像

使用下面这种语法可以实现这个目的:

docker build [OPTIONS] -f- PATH

下面的示例使用当前目录作为构建上下文,然后使用来自标准输入的Dockerfile构建镜像。

# create a directory to work in
mkdir example
cd example

# create an example file
touch somefile.txt

# build an image using the current directory as context, and a Dockerfile passed through stdin
docker build -t myimage:latest -f- . <<EOF
FROM busybox
COPY somefile.txt .
RUN cat /somefile.txt
EOF

使用来自标准输入的Dockerfile来构建一个使用远端github仓库构建上下文的镜像

见下面示例:

docker build -t myimage:latest -f- https://github.com/docker-library/hello-world.git <<EOF
FROM busybox
COPY hello.c .
EOF

使用远端git仓库构建镜像时,Docker会执行一个git clonemingl来将git仓库克隆到本地机器上,然后将仓库内容作为构建上下文发送给Docker守护进程。

使用这个特性需要确保本地机器安装了git命令。

使用.dockerignore文件来排除不需要的文件。文件支持的排除语法类似于.gitignore文件。

使用多阶段构建

由于镜像是在构建过程的最后阶段构建的,因此我们可以利用构建缓存最大限度地减少镜像层。

例如,如果我们的构建包含多个层,则可以将这些需要构建层根据更改频率的高低,从低到高排列,确保生成缓存可重复使用:

  • 安装构建应用程序所需的工具

  • 安装或更新库依赖关系

  • 生成应用程序

Go应用程序的多阶段构建看起来像下面这样:

FROM golang:1.11-alpine AS build

# Install tools required for project
# Run `docker build --no-cache .` to update dependencies
RUN apk add --no-cache git
RUN go get github.com/golang/dep/cmd/dep

# List project dependencies with Gopkg.toml and Gopkg.lock
# These layers are only re-built when Gopkg files are updated
COPY Gopkg.lock Gopkg.toml /go/src/project/
WORKDIR /go/src/project/
# Install library dependencies
RUN dep ensure -vendor-only

# Copy the entire project and build it
# This layer is rebuilt when a file changes in the project directory
COPY . /go/src/project/
RUN go build -o /bin/project

# This results in a single layer image
FROM scratch
COPY --from=build /bin/project /bin/project
ENTRYPOINT ["/bin/project"]
CMD ["--help"]

不要在构建镜像时安装不需要的包

解耦要构建的应用

每一个容器都应有保证职责的单一性。将应用解耦成多个不同的容器更容器水平扩展和复用容器。例如,一个应用栈可能由三个独立的容器构成,每一个容器都有自己独立的镜像。以解耦的方式来管理一个web应用,数据库和一个内存缓存。

限制容器只运行一个进程是好习惯,但是这并不是一成不变的规则。例如,一个容器可以spawn一个init进程,一些程序也会spawn多个进程。例如Apache可以为每一个请求创建一个进程。

根据自己的最佳判断,来尽可能保持容器的整洁和模块化。如果一个容器依赖其他容器,那么可以使用Docker网络来确保容器之间的通信。

最小化层的数量

在旧版本的Docker中,我们必须最大限度地减少镜像中的镜像层数量,以确保它们是高效的。添加了以下功能以减少此限制:

  • 只有RUN,COPY,ADD指令会创建层。其他指令创建临时的中间镜像层,它们不会增加镜像的大小。

  • 尽可能使用多阶段构建,只将需要的内容拷贝到最终的镜像中。我们可以在中间构建阶段来包含一些工具和调试信息,而不会增加最终镜像的大小。

使用按字母排序的多行参数

使用这种形式有助于使PR更加清晰易于阅读。也可以避免重复的包,使包列表的更新更容易。

见下面示例:

RUN apt-get update && apt-get install -y \
  bzr \
  cvs \
  git \
  mercurial \
  subversion \
  && rm -rf /var/lib/apt/lists/*

利用构建缓存

构建镜像时,Docker会根据Dockerfile中的指令按步骤和顺序执行。每一条指令都会被检查,Docker会从它可以重复使用的缓存中查找一个已经存在的镜像,而不是创建一个重复的镜像。

如果不想使用缓存,可以在构建镜像时使用--no-cache=true选项,如果想要让Docker使用自身的缓存,理解何时使用缓存,何时不能使用缓存非常重要。Docker遵循以下基本规则:

  • 从已处于缓存中的父镜像开始,将下一个指令与从该基础镜像中提取的所有子镜像进行比较,以查看其中一个指令是否使用完全相同的指令构建。如果没有,缓存将失效。

  • 在大部分情况下,将Dockerfile中的一条指令与子镜像中的其中一个比较完全够了。然而,有些特定的指令需要更多的检查和解释。

  • 对于ADDCOPY指令,将检查镜像中文件的内容,并为每个文件计算校验和。文件的上次修改和上次访问时间不会被计算进校验和中。在缓存查找过程中,校验和与现有镜像中的校验和进行比较。如果文件(如内容和元数据)中发生了任何更改,则缓存将失效。

  • 除了ADDCOPY指令外,缓存检查不会查看容器中的文件来确定缓存是否匹配。例如,在处理RUN apt-get-y 更新命令时,不会检查容器中更新的文件以确定是否存在缓存命中。在这种情况下,只有命令字符串本身用于查找匹配项。

Dockerfile指令

FROM

无论何时,都尽可能使用官方镜像来作为基础镜像。可以使用Alpine镜像。

LABEL

在镜像中添加标签来组织和管理项目。对于每一个标签,添加一个以LABEL开头的行并且带有一对或多对键值对。下面的示例展示了不同的LABEL使用语法。

带有空格的字符串必须加上引号或者对空格进行转义。内部引号字符也必须进行转义。

# Set one or more individual labels
LABEL com.example.version="0.0.1-beta"
LABEL vendor1="ACME Incorporated"
LABEL vendor2=ZENITH\ Incorporated
LABEL com.example.release-date="2015-02-12"
LABEL com.example.version.is-production=""

一个镜像可以有一个或多个标签。可以在单个LABEL指令中指定多个标签。这样可以防止创建额外的层。

# Set multiple labels on one line
LABEL com.example.version="0.0.1-beta" com.example.release-date="2015-02-12"

也可以使用下面这种形式

# Set multiple labels at once, using line-continuation characters to break long lines
LABEL vendor=ACME\ Incorporated \
      com.example.is-beta= \
      com.example.is-production="" \
      com.example.version="0.0.1-beta" \
      com.example.release-date="2015-02-12"

RUN

将复杂的或者很长的RUN语句使用反斜线分隔成多个独立的行来使Dockerfile易读,易理解,易维护。

在使用RUN指令时,可能会经常使用apt-get来安装软件包。使用apt-get安装软件包时,有一些需要注意的地方。

避免使用RUN apt-get upgrade和dist-upgrade,因为许多来自父镜像的软件包无法在一个非特权容器中进行升级。如果一个包含在父镜像中的软件包过期了,则需要联系镜像维护人员。如果需要更新一个特定的软件包foo,我们可以使用 apt-get install -y foo 来更新软件包。

在安装软件包时,总是应该在同一RUN指令中,使用apt-get update && apt-get install这样的组合。例如:

RUN apt-get update && apt-get install -y \
    package-bar \
    package-baz \
    package-foo  \
    && rm -rf /var/lib/apt/lists/*

在单独的RUN语句行中使用apt-get update会导致缓存问题以及后续apt-get install指令的失败。例如:

FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y curl

构建完镜像以后,Docker会缓存所有的构建层。如果此时我们在修改了apt-get install来安装一个额外的软件包:

FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y curl nginx

Docker将最初的和修改后的指令视为相同的指令,并重复使用以前步骤中的缓存。因此,由于构建镜像时使用缓存的版本,因此不会执行 apt-get update。由于apt-get update未运行,因此构建镜像时,可能会得到一个过时版本的curl和nginx软件包。

使用 RUN apt-get update && apt-get install - y 确保我们的Dockerfile安装最新的软件包版本,此技术称为"缓存破坏"。我们还可以通过指定软件包版本来实现缓存破坏。例如:

RUN apt-get update && apt-get install -y \
    package-bar \
    package-baz \
    package-foo=1.3.*

版本固定迫使构建检索特定版本,无论缓存中的内容如何。此技术还可以减少由于所需软件包中的意外更改而出现的失败。

下面的例子展示了RUN指令的好的使用方式:

RUN apt-get update && apt-get install -y \
    aufs-tools \
    automake \
    build-essential \
    curl \
    dpkg-sig \
    libcap-dev \
    libsqlite3-dev \
    mercurial \
    reprepro \
    ruby1.9.1 \
    ruby1.9.1-dev \
    s3cmd=1.1.* \
 && rm -rf /var/lib/apt/lists/*

s3cmd指定了软件包的安装版本,因此会导致缓存破坏,从而始终安装最新的软件。在上面的示例中,我们将需要安装的软件包用单独的行列出来,一方面易于阅读和维护,另一方面可以防止重复指定安装相同的软件包。

安装完软件包以后,我们可以通过删除/var/lib/lists目录下的内容来减小镜像的大小。apt缓存不是保存在镜像层中的。由于RUN语句以apt-get update开头,因此,包缓存总是会在执行apt-get install之前被刷新。

ubuntu和debin发行版默认会自动执行apt-get clean来清理缓存,因此不需要明确调用这个命令。

使用管道

一些RUN指令依靠管道来将输出的结果传递给另外一个命令,使用管道符|来实现这个目的。

RUN wget -O - https://some.site | wc -l > /number

Docker使用/bin/sh -c解释器来执行这些命令,命令执行的成功与否取决于管道中最后一条命令执行的退出状态码。上面的例子中,即使wget命令执行失败了,此构建步骤也会成功并生成新的镜像。

如果希望命令在任何一个阶段出现错误都会失败,可以使用set -o pipefail && 来确保一个非预期的错误不会导致构建的意外成功。见下面示例:

RUN set -o pipefail && wget -O - https://some.site | wc -l > /number

并不是所有shell都支持 -o pipefail选项。在这种情况下,可以考虑使用RUN指令来明确运行一个支持次选项的shell。

例如 RUN ["/bin/bash", "-c", "set -o pipefail && wget -O - https://some.site | wc -l > /number"]

CMD

CMD指令应用于运行镜像像中带有参数的软件。CMD应该始终使用CMD ["executable"、"param1"、"param 2"...]的形式。因此,如果镜像用于运行一个服务,如Apache和Rails,我么将运行类似CMD ["Apache2","-DFOREGROUND"]。事实上,对于任何基于服务的镜像,都建议使用这种形式的指令。

在大部分情况下,CMD应该被提供一个交互shell,例如bash,python和perl。例如,CMD ["perl", "de0"], CMD ["python"], 或者CMD ["php", "-a"]。使用这种形式意味着当我们执行某些命令例如docker run -it python时,我们将进入一个可用的shell终端。

CMD应该尽可能少的以 CMD["param1"、"param2"]的形式与ENTRYPOINT一起使用,除非用户已经非常熟悉ENTRYPOINT的工作原理。

EXPOSE

EXPOSE指令指示容器侦听连接的端口。因此,我们应该让我们的应用使用常见的传统端口。例如,包含 Apache Web 服务器的镜像将使用EXPOSE 80,而包含MongoDB的镜像将使用EXPOSE 27017等。

对于外部访问,用户可以执行带有flag的docker run,指示如何将指定端口映射到自己选择的端口。对于容器链接,Docker为从接收容器返回源的路径(即MYSQL_PORT_3306_TCP)提供环境变量。

ENV

可以使用ENV来为容器中安装的软件更新环境变量,ENV=/usr/local/nginx/bin:$PATH,确保CMD ["nginx"]能正常运行。

ENV也可以用于为需要容器化的应用提供需要的环境变量,例如Postgre’s PGDATA。

ENV还可以用于设置通过的版本号,以便版本号更容器维护,见下面的例子:

ENV PG_MAJOR=9.3
ENV PG_VERSION=9.3.4
RUN curl -SL https://example.com/postgres-$PG_VERSION.tar.xz | tar -xJC /usr/src/postgress && …
ENV PATH=/usr/local/postgres-$PG_MAJOR/bin:$PATH

Dockerfile中设置的ENV变量值,也可以在命令行指定新的值来覆盖老的值,见示例:

FROM perl

ENV name=camelgem
RUN echo $name

输出

CACHED [1/2] FROM docker.io/library/perl                                                                                                                                0.0s
 => [2/2] RUN echo camelgem   

通过命令行修改ENV设置的值

 docker % docker run -e name=kobe -it perl  /bin/sh
# echo $name
kobe # 值变为kobe

每一个ENV行会创建一个新的间接层,就是RUN指令那样。这意味着即使在后面的层unset 环境变量,它也依然会持久的保存在该层中,不会被丢弃。见下面示例:

FROM alpine
ENV ADMIN_USER="mark"
RUN echo $ADMIN_USER > ./mark
RUN unset ADMIN_USER
$ docker run --rm test sh -c 'echo $ADMIN_USER'

mark

为了防止这种情况,并真正取消设置环境变量,使用带有shell命令的 RUN 命令,将变量的设置、使用和取消设置为单个层。我们可以将命令使用;或&&分隔。如果使用第二种方法,并且其中一个命令执行失败,则docker build也会失败。这通常是个好主意。使用\作为Linux Dockerfile的行继续字符,可提高可读性。我们还可以将所有命令放入外壳脚本中,并让 RUN 命令运行该shell脚本。

FROM alpine
RUN export ADMIN_USER="mark" \
    && echo $ADMIN_USER > ./mark \
    && unset ADMIN_USER
CMD sh
$ docker run --rm test sh -c 'echo $ADMIN_USER'

ADD或COPY

ADD和COPY功能非常类似,通常来说,优先使用COPY。这是因为COPY比ADD更加透明。COPY只支持基本的将本地文件拷贝到容器中,而ADD拥有其他的一些特性,例如本地解压缩包或支持远端URL。ADD最佳使用方式是将本地的压缩文件解压到容器镜像中。

如果需要拷贝多个文件到容器镜像中,使用COPY指令单独拷贝它们,不要使用单个COPY指令拷贝多个文件。这样当后续文件内容修改以后,只确保每一个构建步骤中的缓存失效,这样更加高效。

例如:

COPY requirements.txt /tmp/
RUN pip install --requirement /tmp/requirements.txt
COPY . /tmp/

ENTRYPOINT

ENTRYPOINT的最佳使用方式是作为镜像的主命令。允许镜像指定里面指令的命令,并且以CMD中指定的参数作为默认参数。

见下面示例:

ENTRYPOINT ["s3cmd"]
CMD ["--help"]

现在镜像可以像下面这样显示命令的使用帮助:

$ docker run s3cmd

或使用正确的参数执行命令:

$ docker run s3cmd ls s3://mybucket

这很有用,因为镜像名称可以作为对二进制文件的引用,如上面的命令所示。

ENTRYPOINT指令还可以与helper脚本结合使用,允许它以类似于上述命令的方式运行,即使启动工具可能需要多个步骤。

例如,Postgres官方镜像使用以下脚本作为它的ENTRYPOINT:

#!/bin/bash
set -e

if [ "$1" = 'postgres' ]; then
    chown -R postgres "$PGDATA"

    if [ -z "$(ls -A "$PGDATA")" ]; then
        gosu postgres initdb
    fi

    exec gosu postgres "$@"
fi

exec "$@"

帮助脚本被拷贝容器镜像中,并通过ENTRYPOINT执行:

COPY ./docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["postgres"]

脚本为我们提供了简单的交互,可以像下面这样启动容器

$ docker run postgres

或者传递参数给它:

$ docker run postgres postgres --help

最后,也可以被用来启动一个不同的工具,比如Bash

$ docker run --rm -it postgres bash

VOLUME

应使用卷OLUME指令来暴露docker容器创建的任何数据库存储区域、配置存储或文件/文件夹。强烈建议将VOLUME用于镜像的任何可变和/或用户服务部分。

USER

如果一个应用不需要使用特权用户来运行,则可以使用USER来切换用户。在切换前,在Dockerfile中使用RUN指令来生成一个用户:

RUN groupadd -r postgres && useradd --no-log-init -r -g postgres postgres

镜像中的用户和组被分配一个不确定的UID/GID,因为无论镜像重新构建与否,都会分配“下一个”UID/GID。因此,有必要的话,我们应该指定一个明确的UID/GID。

WORKDIR

为了清晰度和可靠性,我们应该始终使用WORKDIR绝对路径进行工作。此外,我们应该使用WORKDIR,而不是像RUN cd..&&之类的指令,这些指令很难读取、不利于排除故障和维护。

ONBUILD

ONBUILD指令会在当前镜像构建完成以后被执行。ONBUILD会在使用当前镜像的子镜像中被执行。可以认为ONBUILD指令是父Dockerfile提供给子Dockerfile的指令。

ONBUILD会在任何子Dockerfile指令之前被执行。

ONBUILD对于那些将要使用FROM来指定一个镜像的镜像非常有用。例如,我们可以使用ONBUILD在Dockerfile中来构建一个基于特定语言的软件。

在ONBUILD中使用COPY或ADD指令时,需要特别注意,如果子镜像中的构建上下文中不存在COPY或ADD指令中指定的文件或目录,则构建会执行失败。

Last updated