编写Dockerfiles的最佳实践
Docker 可以通过从 Dockerfile 中读取指令来自动构建镜像,这是一个包含所有指令的文本文件,执行它,会按指令顺序构建一个用户自定义镜像。Dockerfile 的指令集遵循特定的格式。您可以参考 Dockerfile Reference 页面来了解基本知识。如果你刚开始写 Dockerfiles,你应该从那里开始。
本文档涵盖了Docker官方和Docker社区推荐的最佳实践和方法,用于创建易于使用的、有效的Dockerfiles。我们强烈建议您遵循这些建议(事实上,如果您要创建一个高效的映像,您必须坚持这些做法)。
您可以在参考 buildpack-deps 项目的Dockerfile文件,您将看到许多实践和建议。
指导方针和建议
容器应该是短暂的
由Dockerfile 文件构建的镜像生成的容器应该尽可能地短暂。通过“短暂”,我们的意思是它可以停止和销毁,新的建造的镜像应该是在一个绝对最小的设置和配置的前提下产生的。您可能想看一看12个因素应用程序方法的流程部分,以了解以这种无状态方式运行容器的动机。
使用 .dockerignore 文件
在大多数情况下,最好将每个Dockerfile放在一个空目录中。然后,仅在该目录中添加构建Dockerfile所需的文件。为了增加构建的性能,可以通过添加一个 .dockerignore 文件来排除不需要的文件或目录。该文件支持类似的排除模式 .gitignore文件。
避免不必要的安装包
为了减化复杂性、依赖性、文件大小和构建时间,您应该避免安装额外或不必要的包。例如,您不需要在数据库映像中包含文本编辑器。(当然这个只是建议,可以根据实际情况酌情添加任何有帮助的工具)
每个容器应该只有一个关注点
将应用程序解耦到多个容器中这样可以使水平扩展重用容器,这样更容易伸缩。例如,web应用程序可能由三个独立的容器组成,每个容器都有自己独特的镜像,以一个解耦的方式管理web应用程序、数据库和内存缓存。
您可能听说过“每个容器都有一个进程”。虽然这个口号很棒,但不一定是每个容器只有一个操作系统进程。除了可以通过init进程生成容器的事实之外,一些程序还可能产生它们自己的其他进程。例如,Celery可以产生多个工作进程,或者Apache可以为每个请求创建一个进程。虽然“每个容器一个进程”通常是一个很好的经验法则,但它并不是一个硬性的规则。所以,结合您的实际保持容器尽可能干净和模块化。
如果容器相互依赖,则可以使用Docker container networks 来确保这些容器能够通信。
最小化层数
您需要找到 Dockerfile 的可读性、可维护性之间的平衡,并最小化它使用的层数。所以对你使用的层数要有一个全局的高度和谨慎的态度。
多行参数排序
在可能的情况下,通过对多行参数的排序来减少后面的更改。这将帮助您避免重复的包,并使列表更容易更新。这也使得PRs更容易阅读和评论。在反斜杠(\)之前添加空格也有帮助。
这里有一个来自 buildpack-deps 镜像的示例:
- RUN apt-get update && apt-get install -y \
- bzr \
- cvs \
- git \
- mercurial \
- subversion
建立缓存
在构建一个镜像Docker的过程中,在您的 Dockerfile 将逐步执行每一个指定。当每个指令被检查时,Docker将在其缓存中寻找可以重用的现有映像,而不是创建一个新的(副本的)映像。如果您不想使用缓存,您可以在 docker build 命令中使用 --no-cache=true 选项。
但是,如果您想让Docker使用缓存,那么了解它何时使用缓存是非常重要。Docker 缓存的使用将遵循下列基本规则:
- 从一个已经在缓存中的基本镜像开始,下一条指令将与从该基础镜像中派生的所有子镜像进行比较,以确定其中一个镜像是否使用了完全相同的指令。否则,缓存将失效。
- 在大多数情况下,只需将Dockerfile中的指令与其中一个子映像进行比较就足够了。然而,某些指令需要更多的检查和解释。
- 对于 ADD 和 COPY 指令,将检查镜像中的文件的内容,并为每个文件计算校验和。在这些校验和中不考虑文件的最后修改和最后访问时间。在缓存查找期间,将校验和与现有映像中的校验和进行比较。如果文件中有任何更改(如内容和元数据),则缓存将失效
- 除了 ADD 和 COPY 指令之外,缓存检查不会查看容器中的文件来确定缓存匹配。例如,当运行RUN apt-get -y update 命令时,在容器中更新的文件将不会被检查以确定是否存在缓存命中。在这种情况下,只使用命令字符串来查找匹配。
一旦缓存失效,所有后续的 Dockerfile 命令将生成新的镜像,而缓存将不被使用。
Dockerfile指令
在下面,您将找到编写 Dockerfile 文件中使用的各种指令的最佳方法。
FROM
在可能的情况下,使用当前的官方存储库作为镜像的基础。我们推荐Debian映像,因为它非常严格地控制并保持最小(目前在150MB以下),同时仍然是一个完整的发行版。
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 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
一如既往,要使您的Dockerfile更易读、可理解和可维护性,将长的或复杂的语句分隔为带反斜杠的多行。
APT-GET
apt-get 可能是最常用的指令。运行 RUN 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
在运行语句中单独使用 api-get update 会导致缓存问题,并且后续的 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将初始和修改的指令视为相同的,并从之前的步骤中重新使用缓存。因此,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.*
版本锁强制构建来检索特定的版本,而不管缓存中有什么。这种技术还可以减少由于需要的包的预期更改而导致的失败。
下面是一个格式良好的运行指令,它演示了所有的apt-get建议。
- 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 指令指定一个版本1.1。如果先前的镜像使用较旧的版本,指定新版本会导致apt-get update 的缓存崩溃,并确保新版本的安装。每一行上的清单包还可以防止包重复中的错误。
此外,当您清理apt缓存时,删除 /var/lib/apt/list 会降低镜像大小,因为apt-get update 缓存不会存储在一个层中。因为运行语句从 apt-get install,包缓存将在安装之前刷新。
注意:官方的Debian和Ubuntu映像自动运行 apt-get clean,所以不需要显式调用。
USING PIPES
一些运行命令依赖于通过管道字符(|)将一个命令的输出与另一个命令的输出进行管道的能力,如下面的例子:
RUN wget -O - https://some.site | wc -l > /number
Docker使用 /bin/sh -c 解释器执行这些命令,它只对管道最后一次操作的退出代码进行评估,以确定成功。在这个构建步骤上面的示例中,只要 wc -l 命令成功,即使wget命令失败,也会成功生成一个新映像。
如果您希望命令在管道的任何阶段由于错误而失败,那么预先考虑 set -o pipefail && 确保一个意外的错误阻止了构建在无意中成功。例如:
RUN set -o pipefail && wget -O - https://some.site | wc -l > /number
注意:不是所有的shell都支持 -o pipefail 选项。在这种情况下(例如,在基于debian的镜像中默认shell的dash shell),可以考虑使用exec形式来显式选择支持pipefail选项的shell。例如:
RUN ["/bin/bash", "-c", "set -o pipefail && wget -O - https://some.site | wc -l > /number"]
CMD
CMD指令应该用于运行您的镜像所包含的软件,以及任何参数。CMD几乎总是应使用 CMD[(“executable”,“param1”,“param2”…]。因此,如果镜像是用于服务的,例如Apache和Rails,那么您将运行类似CMD[" apache2 ","-DFOREGROUND"]的东西。实际上,这种形式的指令被推荐用于任何基于服务的镜像。
在大多数其他情况下,CMD应该被赋予一个交互式shell,比如bash、python和perl。例如,CMD[" perl ","-de0"],CMD["python"],或CMD["php","-a"]。使用这种形式意味着当您执行一些类似于docker run -it python,您将会被放入一个可用的shell中,准备开始。CMD(“param”,“param”)和ENTRYPOINT之间很少使用,除非您和您的预期用户已经非常熟悉ENTRYPOINT的工作方式。
EXPOSE
EXPOSE指令指示容器将监听连接的端口。因此,您应该为应用程序使用公共的、传统的端口。例如,包含Apache web服务器的映像将使用 EXPOSE 80,而包含MongoDB的镜像将使用 EXPOSE 27017等等。 对于外部访问,您的用户可以执行docker run,该标志指示如何将指定端口映射到他们选择的端口。对于容器链接,Docker为从接收方提供的路径提供环境变量
ENV
为了使新软件更容易运行,可以使用ENV更新容器安装软件的路径环境变量。例如,ENV路径/ 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指令,以自动地将软件的版本与容器中的软件相碰撞。
ADD or COPY
尽管添加和复制在功能上是相似的,一般来说,复制是首选的。这是因为它比ADD更透明,复制只支持将本地文件的基本复制到容器中,同时添加一些不明显的特性(比如本地的tar提取和远程URL支持)。因此,添加的最佳用法是将本地tar文件自动提取到映像中,如添加rootfs . tar。xz /。
如果您有多个Dockerfile步骤,可以从上下文使用不同的文件,而不是一次性地复制它们。这将确保每个步骤的构建缓存只有在特定需要的文件发生变化时才会失效(强制执行步骤重新运行)。
例如:
- COPY requirements.txt /tmp/
- RUN pip install --requirement /tmp/requirements.txt
- COPY . /tmp/
结果减少了运行步骤的缓存失效,而不是将副本放入。/ tmp /。
因为镜像大小很重要,使用ADD来从远程url获取包是非常不容易的;您应该使用curl或wget来代替。这样你就可以删除你不再需要的文件,因为它们已经被提取出来了,你不必在你的镜像中添加一个图层。
例如,你应该避免做这样的事情:
- ADD http://example.com/big.tar.xz /usr/src/things/
- RUN tar -xJf /usr/src/things/big.tar.xz -C /usr/src/things
- RUN make -C /usr/src/things all
相反,做一些类似的事情:
- RUN mkdir -p /usr/src/things \
- && curl -SL http://example.com/big.tar.xz \
- | tar -xJC /usr/src/things \
- && make -C /usr/src/things all
对于不需要添加tar自动提取功能的其他项目(文件、目录),您应该始终使用COPY。
ENTRYPOINT
入口点指令的Dockerfile引用 ENTRYPOINT的最佳用法是设置镜像的主命令,允许将镜像运行为该命令(然后使用CMD作为默认标志)。
让我们从一个命令行工具s3cmd的映像示例开始:
- ENTRYPOINT ["s3cmd"]
- CMD ["--help"]
现在镜像可以这样运行,以显示命令的帮助:
$ docker run s3cmd
或者使用正确的参数来执行命令:
$ docker run s3cmd ls s3://mybucket
这是有用的,因为镜像名称可以作为对二进制的引用的两倍,如上面的命令所示。
ENTRYPOINT指令还可以与助手脚本结合使用,允许它以类似于上面的命令的方式运行,即使启动该工具可能需要多个步骤。
例如,Postgres的官方镜像使用以下脚本作为其入口点:
- #!/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 "$@"
注意:此脚本使用exec Bash命令,以便最终的运行应用程序成为容器的PID 1。这允许应用程序接收发送到容器的任何Unix信号。查看ENTRYPOINT有助于了解更多细节。
助手脚本被复制到容器中,并通过容器启动的入口点运行:
- COPY ./docker-entrypoint.sh /
- ENTRYPOINT ["/docker-entrypoint.sh"]
这个脚本允许用户以多种方式与Postgres交互。
它可以简单地开始Postgres:
$ docker run postgres
或者,它可以用来运行Postgres并且传递参数给服务器:
$ docker run postgres postgres --help
最后,它还可以用于启动完全不同的工具,例如Bash:
$ docker run --rm -it postgres bash
VOLUME
应该使用 VOLUME 指令来公开您的docker容器创建的任何数据库存储区域、配置存储或文件/文件夹。强烈建议您对镜像的任何可变或用户可使用的部分使用VOLUME指令创建数据卷。
USER
如果一个服务可以不设置权限运行,使用 USER 指令更改为 non-root 用户。首先在Dockerfile中创建用户和组,可以参考如下指令:
RUN groupadd -r postgres && useradd --no-log-init -r -g postgres postgres.
注意:在镜像中,用户和组得到一个非确定性的UID / GID,因为在“next”UID / GID被分配,而不考虑镜像重建。因此,如果它是关键的,您应该指定一个显式的UID / GID。
注意:由于在 Go archive/tar 包处理稀疏文件的过程中出现了一个未解决的错误,试图在一个Docker容器内创建一个足够大的UID的用户会导致磁盘耗尽,因为容器层中的/var/ log/faillog中充满了NUL(\0)字符。将 --no-log-init 参数传递给 useradd 解决这个问题。Debian/Ubuntu adduser 包装器不支持 --no-log-init 参数。
您应该避免安装或使用sudo,因为它具有不可预知的TTY和信号转发行为,这会导致更多的问题。如果您需要类似于sudo的功能(例如:初始化守护进程作root用户,但将它作为non-root来运行,您可以使用“gosu”。
最后,要减少层次和复杂性,避免频繁地切换用户。
WORKDIR
对于清晰性和可靠性,您应该始终使用绝对路径作为您的WORKDIR。并且应该使用WORKDIR代替 RUN指令运行 cd … & do-something。
ONBUILD
在当前Dockerfile构建完成之后,将执行ONBUILD命令。ONBUILD在任何来自当前镜像的子镜像中执行。
将ONBUILD命令视为父Dockerfile向子Dockerfile提供的指令。
在子Dockerfile中任何命令之前,Docker构建先执行ONBUILD命令。
ONBUILD对于将从给定镜像构建的镜像非常有用。例如,您将使用ONBUILD为一种语言镜像构建在Dockerfile中使用该语言编写的任意用户软件,正如您在Ruby的ONBUILD 项目中看到的那样。
从ONBUILD构建的镜像应该得到一个单独的标签,例如:ruby:1.9-onbuild 或 ruby:2.0-onbuild。
添加或复制时要小心。如果新构建的上下文缺少添加的资源,那么“onbuild”镜像将会失败。如上面所建议的那样,添加一个单独的标记将有助于减轻这个问题,允许Dockerfile作者做出选择。
提示:本文是对Docker官方文档Best practices for writing Dockerfiles的翻译整理,并略作删改
欢迎大家关注懒也要有正确的方式公众号