代码改变世界

Dockerfile编写规范

2019-06-18 14:13  Loull  阅读(3433)  评论(0编辑  收藏  举报

本文主要介绍在编写 docker 镜像的时候一些需要注意的事项和推荐的做法。

 

虽然 Dockerfile 简化了镜像构建的过程,并且把这个过程可以进行版本控制,但是不正当的
Dockerfile 使用也会导致很多问题: 

  1. docker 镜像太大。如果你经常使用镜像或者构建镜像,一定会遇到那种很大的镜像,甚至有些能达到 2G 以上
  1. docker 镜像的构建时间过长。每个 build 都会耗费很长时间,对于需要经常构建镜像(比如单元测试)的地方这可能是个大问题
  1. 重复劳动。多次镜像构建之间大部分内容都是完全一样而且重复的,但是每次都要做一遍,浪费时间和资源

 

希望读者能够对 docker 镜像有一定的了解,阅读这篇文章至少需要一下前提知识:

  1. 了解 docker 的基础概念,运行过容器
  1. 熟悉 docker 镜像的基础知识,知道镜像的分层结构
  1. 最好是负责过某个 docker 镜像的构建(使用 docker build 命令创建过自己的镜像)
  1. Dockerfile 和镜像构建
    Dockerfile 是由一个个指令组成的,每个指令都对应着最终镜像的一层。每行的第一个单词就是命令,后面所有的字符串是这个命令的参数,关于 Dockerfile 支持的命令以及它们的用法,可以参考官方文档,这里不再赘述。

 

当运行 docker build 命令的时候,整个的构建过程是这样的:

a. 读取 Dockerfile 文件发送到 docker daemon
b. 读取当前目录的所有文件(context),发送到 docker daemon
c. 对 Dockerfile 进行解析,处理成命令加上对应参数的结构
d. 按照顺序循环遍历所有的命令,对每个命令调用对应的处理函数进行处理
e. 每个命令(除了 FROM)都会在一个容器执行,执行的结果会生成一个新的镜像,为最后生成的镜像打上标签

 

编写 Dockerfile 的一些最佳实践
1.使用统一的 base 镜像

有些文章讲优化镜像会提倡使用尽量小的基础镜像,目前集团操作系统一级提供统一的基础镜像,一些BU也根据自己的技术规范定义了BU级的基础镜像,一般的应用只需要FROM自己BU提供的基础镜像即可,因为基础镜像只需要下载一次可以共享,并不会造成太多的存储空间浪费。它的好处是这些镜像的生态比较完整,方便我们安装软件,除了问题方便调试。

 

2.动静分离

经常变化的内容和基本不会变化的内容要分开,把不怎么变化的内容放在下层,创建出来不同基础镜像供上层使用。比如可以创建各种语言的基础镜像,这些镜像包含了最基本的语言库,每个组可以在上面继续构建应用级别的镜像。

 

3.最小原则:只安装必需的东西

很多人构建镜像的时候,都有一种冲动——把可能用到的东西都打包到镜像中。要遏制这种想法,镜像中应该只包含必需的东西,任何可以有也可以没有的东西都不要放到里面。因为镜像的扩展很容易,而且运行容器的时候也很方便地对其进行修改。这样可以保证镜像尽可能小,构建的时候尽可能快,也保证未来的更快传输、更省网络资源。

 

4.一个原则:每个镜像只有一个功能

不要在容器里运行多个不同功能的进程,每个镜像中只安装一个应用的软件包和文件,需要交互的程序通过 容器之间的网络进行交流。这样可以保证模块化,不同的应用可以分开维护和升级,也能减小单个镜像的大小。

 

5.使用更少的层

虽然看起来把不同的命令尽量分开来,写在多个命令中容易阅读和理解。但是这样会导致出现太多的镜像层,而不好管理和分析镜像,而且镜像的层是有限的。尽量把相关的内容放到同一个层,使用换行符进行分割,这样可以进一步减小镜像大小,并且方便查看镜像历史。

 

6.减少每层的内容

尽管只安装必须的内容,在这个过程中也可能会产生额外的内容或者临时文件,我们要尽量让每层安装的东西保持最小。
比如使用 --no-install-recommends 参数告诉 apt-get 不要安装推荐的软件包

 

7.不要在 Dockerfile 中单独修改文件的权限 

因为 docker 镜像是分层的,任何修改都会新增一个层,修改文件或者目录权限也是如此。如果有一个命令单独修改大文件或者目录的权限,会把这些文件复制一份,这样很容易导致镜像很大。

解决方案也很简单,要么在添加到 Dockerfile 之前就把文件的权限和用户设置好要么在容器启动脚本(entrypoint)做这些修改,或者拷贝文件和修改权限放在一起做(这样最终也只是增加一层)

 

8.利用 cache 来加快构建速度 

如果 Docker 发现某个层已经存在了,它会直接使用已经存在的层,而不会重新运行一次。如果你连续运行 docker build 多次,会发现第二次运行很快就结束了。

不过从 1.10 版本开始,Content Addressable Storage 的引入导致缓存功能的实效,目前引入了 --cache-from 参数可以手动指定一个镜像来使用它的缓存。

 

9.版本控制和自动构建 

最好把 Dockerfile 和对应的应用代码一起放到版本控制中,然后能够自动构建镜像。这样的好处是可以追踪各个版本镜像的内容,方便了解不同镜像有什么区别,对于调试和回滚都有好处。

另外,如果运行镜像的参数或者环境变量很多,也要有对应的文档给予说明,并且文档要随着 Dockerfile 变化而更新,这样任何人都能参考着文档很容易地使用镜像,而不是下载了镜像不知道怎么用。

 

10.使用一个.dockerignore文件
在大部分情况下,最好的做法是将每一个Dockerfile文件放到一个空的文件夹里。接着,把构建Dockerfile所需的文件添加到这个文件下。为了提高构建的效率,你可以在这个文夹下添加一个.dockerignore 文件来排除那些没用的文件和文件夹。这个文件支持类似 .gitignore 文件那样的排除模式。关于如何创建它,可以移步到dockerignore 文件。

 

Dockerfile 指令介绍:

 

更多信息请参考《Dockerfile 参考

 

FROM :
这个设置基本的镜像,为后续的命令使用,所以应该作为Dockerfile的第一条指令。
FROM <image>:<tag>
无论什么时候,尽可能使用BU提供的基础镜像,有利于技术规范化,简化你的Dockerfile。

 

RUN :
RUN命令会在上面FROM指定的镜像里执行任何命令,然后提交(commit)结果,提交的镜像会在后面继续用到。格式

 

RUN <command> (the command is run in a shell - `/bin/sh -c`)

 

一般,为让你的 Dockerfile 更加易读,易懂和便于维护,请将长的或者复杂的 RUN 语句用反斜杠()分割成多行。
RUN 一般都是搭配 apt-get一起使用。当使用 apt-get时,这里几个注意事项:
不要在单独一行上使用RUN apt-get update 。 这样会引起缓存问题,如果关联的归档文档被更新了,将会导致后续的 apt-get install 执行失败而没有任何提示。

 

避免 RUN apt-get upgrade 或dist-upgrade, 因为很多来自基础镜像的“底层”的包将会更新失败,在一个无特权的容器里。如果一个基础包已经过期,你应该通知它的维护人员。如果你知道这里一个特定的包,如 foo,它需要更新,可以直接使用apt-get install -y foo 让它自动更新。

 

应该这样编写你的指令:

 

RUN yum update && yum install -y \ package-bar \ package-baz \ package-foo
使用这样方法编写指令,不仅让它变得更加易读和可维护,而且,通过包含 apt-get update,确保绕开本地的缓存,安装最新的版本而不需要编写更多的指令和手动的干预。

 

绕开缓存可以实现包的版本定位(例如:package-foo=1.3.*)。这将强制去检索指定的版本,不管缓存里存储了什么。编写你的 apt-get 代码,这种方法将大大降低的维护难度和减少由未意料的的包而导致失败概率。
例子
下面是一段格式良好的 RUN 指令,它演示了上述的建议。注意最后的包 s3cmd,指定了一个版本 1.1.0*。如果这个镜像之前使用过一个旧的版本,指定的新版将引起 apt-get update 缓存失效,确保一个新的版本被安装(在这个应用场景中,需要这个特性)。

 

RUN yum update && yum 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*;yum clean all

 

使用这种方法编写指令也可以帮助你避免包的重复,因为这样写比下面的写法更加的易读:

 

RUN yum install -y package-foo && yum install -y package-bar;yum clean all

 

EXPOSE
EXPOSE 指令指定容器监听的端口。因此,你应该使用通用、惯例的端口到你的应用。例如,一个包含着Apacheweb服务端的镜像将使用80端口,当镜像包含是一个MangoDB应该使用EXPOSE 27017 等等。

 

为了提供外部访问,你的用户可以执行docker run 带上一个标志,表明如何映射指定的端口到他们选择的端口。为了容器的连接,Docker提供了环境变量来指定接受容器到源容器的路径(如,MYSQL_PORT_3306_TCP)。

 

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_MAJOR/bin:$PATH
和在编程时定义常亮类似(而不采用硬编码),使用这样方法,你只需修改一个ENV 指令,就能自动更新与之关联的数据。

 

ADD 或 COPY
虽然 ADD 和COPY 的功能类似,一般而言,推荐使用COPY  。因为它比ADD更加见名知意。COPY 只支持将本地本件拷贝到容器中,虽然ADD 拥有一些功能(例如,抽取本地tar文件内容和支持远程URL),但是这些功能不是很常用。因此,ADD 的最佳使用场景是,自动抽取一个本地tar的内容到镜像中,例如:ADD rootfs.tar.xz /。

 

如果你要执行多个Dockerfile 步骤且使用来自的环境中不同的文件,分开COPY 它们,而不是一次性的拷贝它们。这样可以确保每个步骤的构建缓存都是失效的(强制步骤的重做),如果指定需要的的文件更新了。

 

例如:

 

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

 

这样,RUN 步骤可以增加缓存的命中率,如果你把COPY . /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文件中提取内容时使用ADD,其他时候,你应该总是使用COPY。

COPY指令是以root身份执行的。但集团pouch在启动应用时会将/home/admin的属主置为admin,所以用户一般不需要额外的指令来处理COPY到/home/admin/目录下的文件属主权限。

 

ENTRYPOINT
ENTRYPOINT 最佳使用场景是设置镜像的主入口命令,允许镜像好像命令运行一样(使用 CMD 作为默认的标志)。

 

让我们启动一个带命令行工具 s3cmd的镜像:
ENTRYPOINT ["s3cmd"] CMD ["--help"]
现在,启动后的镜像与在命令行中执行命令的帮助类似:
$ docker run s3cmd
或在右边添加参数来执行一个命令:

 

$ docker run s3cmd ls s3://mybucket
这很用,如上所述,可以把镜像的名字当做一个二进制程序来使用。

 

ENTRYPOINT 指令也可以和一个辅助脚本结合使用,允许它和上述的类似方式运行,即使当启动工具命令超过一行时。

 

例如,Postgres官方镜像使用下面的脚本作为它的ENTRYPOINT:
#!/bin/bashset -eif [ "PGDATA"if [ -z "PGDATA")" ]; then
gosu postgres initdb
fiexec gosu postgres "@"
注意:这个脚本使用了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
创建一个挂载点用于共享目录。
VOLUME 指令应该用于暴露任何的数据库存储域、配置存储、文件/文件夹,在创建容器的时候。任何易变的或镜像的供用户使用的部分,建议使用VOLUME 。
docker run时会将宿主机的目录挂载到VOLUME目录下,以宿主机某个目录(此镜像独享的目录)覆盖docker容器中的对应目录,使得其中的数据修改在docker重启时仍然能保持;带来另一个后果是,如果你在Dockerfile中往VOLUME目录中写入了数据(即docker build阶段写入的数据),在启动容器的时候你会发现它不见了(因为它写到编译机上去了)。

 

USER
指定运行用户。
如果一个服务可以不需要权限就能运行,应该使用 USER 切换到一个非root用户。使用像这种命令 RUN groupadd -r postgres && useradd -r -g postgres postgres可以创建一个用户和用户组。

 

注意:镜像里的用户和组的UID/GID都是不确定的,不管它是否被重建。如果这些信息对你很重要,你应该显示的指定一个UID/GID。

 

你应该避免安装或使用 sudo ,因为这些操作带来不确定的TTY和信号的转发行为,是一个得不偿失的设置。如果你必需要使用类似 sudo 的功能(例如,在非root用户在初始化一个需要root权限的的守护进程),你可能需要使用“gosu”。

 

最后,为了减少层和复杂度,不建议频繁的来回切换 USER 。
WORKDIR
更多内容请移步《Dockerfile参考》的WORKDIR部分

 

为了清晰和可靠,你应该始终为你的WORKDIR指定一个绝度路径。另外,你因该使用WORKDIR 来替代类似RUN cd … && do-something指令,这样可以降低可读性、故障排除难度、维护成本。

 

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 和ENTRYPOINT 协同工作时,应该使用 CMD [“param”, “param”] 格式。这种方式尽量少用,除非你和你的用户对 ENTRYPOINT 实现机制都很了解。

 

ONBUILD
更多内容请移步《Dockerfile参考》的ONBUILD部分
一个ONBUILD 命令在当前的Dockerfile 构建完成后会被执行。当使用 FROM 为镜像个派生出子镜像时,ONBUILD 也会被执行。也可以简单的理解为,其实是将父Dockerfile 的ONBUILD 中的指令放到子Dockerfile中。

 

ONBUILD 命令会先于子Dockerfile中所有命令执行。

 

ONBUILD 对使用 FROM 基于指定镜像构建很有帮助。例如, ONBUILD 允许你在 Dockerfile里,基于某种语言栈构建任意的软件镜像,你可以参考Ruby的 ONBUILD 。.

 

ONBUILD 因该指定一个指定标志(tag),例如:ruby:1.9-onbuild 或 ruby:2.0-onbuild。

 

当你把 ADD 或 COPY 放到ONBUILD要注意。 如果新的构建环境缺少要添加的资源,会导致镜像的构建失败。添加一个分隔标签,如条建议一样,以供编写的 Dockerfile 可以选择合适他的构建环境。