Dockerfile最佳实践

注:

因水平有限,难免有
不准确或过时 之内容,点击每节标题自动跳转至原文该节位置,强烈建议阅读官方文档!!!

二流运维,三流英语,译于2017年6月,发于2019年2月,偶尔更新。感谢阅读,欢迎反馈~

Docker 可以从 Dockerfile 中读取指令自动构建镜像,Dockerfile是一个包含构建指定镜像所有命令的文本文件。Docker坚持使用特定的格式并且使用特定的命令。你可以在 Dockerfile参考 页面学习基本知识。如果你刚接触Dockerfile 你应该从哪里开始学习。

这个文档囊括了Docker公司和Docker社区推荐的创建易于使用且实用的Dockerfile 的最佳实践和方法。我们强烈建议你遵循这些规范(事实上,如果你创建一个官方镜像,你必须坚持这些实践。)

你可以从 buildpack-deps Dockerifle看到许多这种实践和建议。

注:本文档提到的Dockerfile命令的更详细的解释见
Dockerfile参考 页面。

通用参考和建议

容器应该是临时性的

从你的Dockerfile定义的镜像启动的容器应该尽可能短暂。这里的『短暂』我们是说它可以被停止和销毁并且一个新容器的构建和替换可以绝对最小化的变更和配置下完成。你可能想看下 应用方法论的12个事实中进程 一节来了解以无状态方式运行容器的动机。

使用 .dockerignore文件

在大多数情况下,最好把Dockerfile放在一个空目录里。然后,只把构建Dockerfile需要的文件追加到该目录中。为了改进构建性能,你也可以增加一个.dockerignore 文件来排除文件和目录。该文件支持与 .gitignore 类似的排除模式。更多创建.dockerignore信息,见 .dockerignore

避免安装不需要的包

为了减少复杂性,依赖,文件大小,和构建时间,你应该避免仅仅因为他们很好用而安装一些额外或者不必要的包。例如,你不需要在一个数据库镜像中包含一个文本编辑器。

每个容器只关心一个问题

解耦应用为多个容器使水平扩容和复用容器更容易。例如,一个web应用栈会包含3个独立的容器,每个都有自己独立的镜像,以解耦的方式来管理web应用,数据库。

你可能听说过”一个容器一个进程”。这种说法有很好的意图,一个容器应该有一个操作系统进程并非真的必要。除此之外,事实上现在容器可以 被init进程启动, 一些程序可能会自己产生其他额外的进程。例如,Celery 可以产生多个工作进程,或者 Apache 可能为每个请求创建一个进程。当然”一个容器一个进程”通常是一个很好的经验法则,??但它不是一个很难和快速的规则(it is not a hard and fast rule)?? 用你最好的判断来保持容器尽可能的干净和模块化。

如果容器之间相关依赖,你可以使用 Docker容器网络 来取吧哦容器之间可以通信。

最小化层数

你需要在Dockerfile可读性(从而可以长时间维护)和它用的层数最小化之间找到平衡。Be strategic 关注你使用的层数(and cautious about the number of layers you use).

对多行参数排序

无论何时,以排序多行参数来缓解以后的变化(Whenever possible, ease later changes by sorting multi-line arguments alphanumerically. )。这将帮助你避免重复的包并且使里列表更容易更新。这也使得PR更容易阅读和审查。在反斜线()前加一个空格也很有帮助。

这里有个来自 buildpack-deps 镜像的实例:

RUN apt-get update && apt-get install -y \
  bzr \
  cvs \
  git \
  mercurial \
  subversion

构建缓存

在构建镜像的过程中,Docker会逐句读取你Dockerfile中的指令按指定的顺序执行。因为每个指令都会被检查Docker会在它的缓存中查找可以重用的现有镜像(As each instruction is examined Docker will look for an existing image in its cache that it can reuse),而不是创建一个新的(重复的)镜像。如果你根本不像使用缓存,你可以对 docker build 命令使用 --no-cache=ture参数。

然而,如果你使Docker使用缓存,那么理解它什么时候找到一个匹配的镜像以及什么不找就非常重要了。Docker将遵循的基本规则如下:

  • 以一个已经在缓存中的付镜像开始,下一个指令与所有源自该基础镜像的子镜像做对比,来查看镜像中是否有一个使用了完全相同的镜像构建。如果没有,缓存不可用。
  • 大多数情况下简单对比Dockfile中的指令与子镜像就足够了。然而,一些特定的指令需要更多的检查和解释。
  • 比如ADDCOPY指令,镜像中的文件内容被检查并且为每个文件计算校验和。这些文件的最终修改和访问时间将不被考虑到校验和内。在查找缓存期间,校验和将被用于与已存在的镜像校验和进行对比。如果文件中有任何变化,比如内容或者元数据,那么缓存失效。
  • 除了ADDCOPY命令以外,缓存检查将不会检查容器中的文件来确定缓存匹配。比如,当处理一个RUN apt-get -y update容器中的文件更新将不会被检查来确定是否命中已存在缓存。在这种情况下只有命令字符串自己将被用来查找匹配。

一旦缓存失效,所有的后面的Dockerfile命令将会生成新的镜像而且不会使用缓存。

Dcokerfile指令

下面你会找到写Dockerfile里可用的各种指令的建议以及最佳方法。

FROM

Dockerfile参考之FROM指令

无论何时只要可能使用当前官方仓库镜像作为你的基础镜像。我们推荐Debian镜像, 因为它被严格控制并且保持最小(目前小于5MB),同时是一个完整的发行版。

LABEL

理解labels对象

你可以给你的镜像增加标签(labels)来协助通过项目组织镜像,记录授权信息,帮助自动化,或者其他原因。每一个标签都以LABEL开头并且跟着一对或多对键值对。以下实例展示了可接受的不同格式。解释性意见也包括在内(Explanatory comments are included inline.)。

注:如果你的字符串包含空格,它必须被引号引起来或者空格必须被转义。如果你的字符串包含内部引号字符(“),他们需要转义。

# 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"

查看 理解labels对象 获取可接受的标签键和值指导。
For information about querying labels, refer to the items related to filtering in Managing labels on objects.

RUN

Dockerfile参考 之 RUN 指令

跟之前一样,为了让你的Dockerfile具有更高的可读性,更易于理解和维护,使用反斜线()将较长的或者复杂的RUN语句拆分为多行。

APT-GET

可能RUN最常见的使用场景就是apt-get的应用程序了。RUN apt-get命令,因为使用它安装软件包有几个需要注意的问题。

你应该避免使用RUN apt-get upgrade或者dis-upgrade, 因为父镜像中许多”基本的”(essential)包不能在容器中升级。如果父镜像中有个软件包过期了,你应该联系它的维护者。如果你知道有个特定的软件包,foo,需要升级,使用apt-get install -y foo来自动升级。

通常把RUN apt-get updateapt-get install合并到一个相同的RUN语句中,例如:

RUN apt-get update && apt-get install -y \
        package-bar \
        package-baz \
        package-foo

在一个RUN语句中单独试用apt-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没有执行因为使用了缓存的版本进行构建。因为apt-get update没有执行,你的构建可能会安装一个过时版本的curlngin

使用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指令,实践了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的缓存破解以确保安装新版本。每行列出一个软件包可以避免包重复错误。

另外,你可以通过删除 /var/lib/apt/lists 清理apt缓存来减小镜像大小,因为apt缓存不会保存在层里。由于RUN语句以apt-get update开头,所以在缓存apt-get之前,包缓存将始终被刷新。

注:Debian和Ubuntu的镜像自动运行
apt-get clean,所以不需要显式调用。

USING PIPES

一些RUN命令依赖使用管道符号(|)把一个命令的输出到另外一个命令的能力,比如以下实例:

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 pipefaile选项。在这种情况下(比如
dash shell, 它是基于Debian镜像的默认shell),考虑使用
RUN的exec形式来显式选择一个支持pipefail选项的shell。例如:

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

CMD

Dockerfile参考 之 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(you’ll get dropped into a usable shell, ready to go)。CMD应该很少以CMD [“param”, “param”]的形式和 ENTRYPOINT一起试用,除非你和你的目标用户已经非常熟悉ENTRYPOINT工作原理。

EXPOSE

Dockerfile参考之 EXPOSE 指令

EXPOSE指令指示容器将监听链接的端口。因此,你应该为你的应用程序试用通用的传统的端口。例如,一个包含Apache Web服务器的镜像应该EXPOSE 80, 而一个包含MongoDB的镜像应该使用EXPOSE 27017等。

对于外部访问,您的用户可以使用指示如何将指定端口映射到所选端口的标志来执行docker run
???For container linking, Docker provides environment variables for the path from the recipient container back to the source (ie, MYSQL_PORT_3306_TCP).???

ENV

Dockerfile参考 之 ENV指令

为了让软件更便于运行,你可以使用ENV来修改环境变量将软件安装目录加到PATH。例如:ENV PATH /usr/local/nginx/bin:$PATH将使 CMD [“nginx”] 可以工作。

ENV指令也可用于给要容器化的服务所需的环境变量,比如Postgre的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

Dockerfile参考之 ADD指令
Dockerfile参考之 COPY指令

尽管ADDCOPY指令功能相似,一般而言,最好使用COPY。是因为它比ADD更透明。COPY只支持最基本的从本地复制文件到容器中,而ADD有更多功能(比如本地tar解压和远程URL支持)并不是即刻课件的。因此,用ADD最好的方式是本地tar文件自动提取到镜像,比如:ADD rootfs.tar.xz /

如果你有多个Dockerfile步骤在你的上下文使用不同的文件,单独COPY他们,而不是一次复制所有。这将确保每一步的构建缓存(强制这一步重新运行)只有当它特定的依赖文件变化时失效。

例如:

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

结论就是如果把COPY . /tmp/放在RUN之前失效缓存更少。

因为镜像大小很重要,使用ADD来获取远程URLs是强烈反对的;你应该使用curlwget替代。这种方式你可以在解压后不需要时删除这些文件并且你不会在你的镜像增加额外一层。例如,你应该避免这么做:

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

对于不需要ADD tar自动提取功能的其他项目(文件,目录),应始终使用COPY。

ENTRYPOINT

Dockerfile参考 之 ENTRYPOINT指令

使用ENTRYPOINT最好的方式是设置镜像主命令,允许镜像把它作为命令运行(然后使用CMD作为默认标识)。

我们从一个命令行工具s3cmd镜像的例子开始:

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

现在可以像这样运行镜像来显示命令的帮助:

$ docker run s3cmd

或者使用正确的参数来执行一个命令:

$ docker run s3cmd ls s3://mybucket

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

ENTRYPOINT指令也可以与辅助脚本组合使用,允许其以类似于上述命令的方式运行,即使启动工具可能需要多于一个步骤。

例如,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 "$@"

注:这个脚本使用
exec
Bash命令 以便最终运行的应用程序成为容器PID 1。这样做允许应用程序接受发送给容器的Unix信号。查看
ENTRYPOINT帮助获取更多细节。

帮助脚本被拷贝到容器并且当容器启动时通过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

Dockerfile参考 之 VOLUME指令

VOLUME指令应该用于暴露任意数据库存储区,配置存储,或者docker容器创建的文件/目录等。强烈建议您将VOLUME用于镜像的任何可变和/或用户可维护的部分。

USER

Dockerfile参考 之 USER指令

如果服务可以没有权限运行,使用USER变为一个非root用户。像如下命令一样开始在Dockerfile中创建用户和组:

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

注:
??? (Users and groups in an image get a non-deterministic UID/GID in that the “next” UID/GID gets assigned regardless of image rebuilds. )???所以,如果很重要的话,你需要显式指定UID/GID。

注:由于Go存档/ tar包处理稀疏文件中的一个未解决的bug, 在docker容器里创建一个UID足够大的用户会在容器层中将/var/log/faillog写满NUL (\0)而导致磁盘耗尽。传--no-log--init标记来创建用户可以绕开这个问题。Debian/Ubuntu的adduser包不支持--no-log-init标记所以应该避免使用。

你应该避免安装和使用sudo,因为它不可预知的TTY和信号转发行为带来的问题比解决的问题多。如果你确实需要类似sudo的功能(例如:以root用户初始化但是以非root用户运行),你可以使用”gosu“。

最后,减少你的层和复杂性,避免切换用户(Lastly, to reduce layers and complexity, avoid switching USER back and forth frequently.)。

WORKDIR

Dockerfile参考之 WORKDIR

为了清晰可靠,你应该在使用WORDDIR时应该一直使用绝对路径。你也应该使用WORKDIR而不是使用像RUN cd .. && do-something这样难以阅读、调错和维护的增量指令。

ONBUILD

Dockerfile参考 之 ONBUILD指令

ONBUILD命令在当前Dockerfile构建完成之后执行。ONBUILD会在任意一个从当前镜像派生的子镜像执行。可以把ONBUOLD命令想象成为一个父级Dockerfile赋予子Dockerfile的指令。

Docker构建在子Dockerfile中的任何命令之前执行ONBUILD命令。

ONBUILD is useful for images that are going to be built FROM a given image. For example, you would use ONBUILD for a language stack image that builds arbitrary user software written in that language within the Dockerfile, as you can see in Ruby’s ONBUILD variants.

Images built from ONBUILD should get a separate tag, for example: ruby:1.9-onbuild or ruby:2.0-onbuild.

当在ONBUILD中使用ADD或者COPY时要小心。如果新构建的上下文丢失了增加的资源,”onbuild”的镜像将会严重失败。如上所述,添加单独的标签,允许Dockerfile的作者自己选择有助于缓解这种情况。

官方仓库实例

这些官方仓库有典型的示范(These Official Repositories have exemplary Dockerfiles):

其他资源

附:以下相关内容为本人补充非原文内容

清理缓存

  • Alpine

    apk cache clean
    rm -rf /var/cache/apk/* ~/.cache/* /usr/local/share/man
  • Debian/Ubuntu

    apt-get autoremove
    rm -rf /var/lib/apt/lists/* ~/.cache/* /usr/local/share/man
  • RedHat/CentOS

    yum clean all
    rm -rf /var/cache/yum/* ~/.cache/* /usr/local/share/man

设置时区

很多镜像默认使用UTC时间,但是面向中国用户的的大多应用,在获取系统时间时直接取系统时间并不会做一个校对,这个时候就会出现程序获取的时间或者日志时间和实际不一致的情况。

分享个例子,曾接到研发同事反馈容器内时间不对导致的小问题,于是着手修复。完成后开始检查其他生产环境中容器时间和时区,发现生产环境中有多大31个应用时区不对(某些应用是同一个镜像仓库,大约涉及20多个镜像,及十多个代码仓库),虽然其他应用暂时没有导致严重的问题,但是必然是个隐患,于是开始着手修复,修改Dockerfile -> 提交 -> 构建 -> 部署,老实说,这是纯体力活。。。。

所以一开始就应该做这件事。

设置时区(v0.3)

针对全球环境而言,保证时间一致性还是建议统一使用UTC时间,包括但不限于内容:

  • 操作系统,参考:设置时区(v0.1);
  • 数据库,如常用的MySQLPostgreSQL;
  • 应用容器,一般默认为UTC,不必修改;
  • 应用本身,建议应用本身对时间进行校对;

如果出现数据、系统日志、应用日志无法对齐的情况,有很多场景会让人焦头烂额,如:数据整合、统计、日志分析等等。

设置时区(v0.2)

前提宿主机时区正确,详情参考:设置时区(v0.1)

不建议修改容器及镜像内时区,若需要保证容器时区与宿主机保持一致,通过以下参数将宿主机时区文件挂载到容器即可。

# docker run
docker run -v /etc/localtime:/etc/localtime:ro xxxx

# docker-compose
...
  volume:
    - /etc/localtime:/etc/localtime:ro
...

设置时区(v0.1)

  • Alpine修改时区

    apk update && add tzdata ca-certificates
    ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime 
  • Debian/Ubuntu修改时区

    ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
    dpkg-reconfigure -f noninteractive tzdata
  • Centos/RedHat修改时区

    # CentOS的时区配置文件是:/etc/sysconfig/clock
    ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
    
    # CentOS/ RHEL 7 Only
    timedatectl set-timezone /etc/localtime

时区配置文件

  1. CentOS的时区配置文件是:/etc/sysconfig/clock,配置文件有如下几个配置选项:

    • UTC,指定BIOS中保存的时间是否是GMT/UTC时间,true表示BIOS里面保存的时间是UTC时间,false表示BIOS里面保存的时间是本地时间
    • ZONE,指定时区,ZONE的值是一个文件的相对路径名,这个文件是相对 /usr/share/zoneinfo 目录下的一个时区文件。比如ZONE的值可以是:Asia/Shanghai, US/Pacific, UTC
    • ARC,这个选项一般配置false,在一些特殊硬件(Alpha)下才配置该选项为true
    • SRM,它同ARC,该选项一般配置false,在一下特殊硬件下才配置该选项为false
  2. /etc/sysconfig/clock 的配置实例

    ZONE="Asia/Shanghai"
    UTC=true
    ARC=false

说明:这个配置文件里面的参数和
hwclock 命令关系很大,系统在启动的时候读取
/etc/sysconfig/clock 文件的内容,根据这些内容调用
hwclock 命令

    原文作者:teachmyself
    原文地址: https://segmentfault.com/a/1190000018108361
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞