Dockerfile 最佳实践

标签: dockerfile 最佳实践 | 发表时间:2020-01-14 00:27 | 作者:玻璃樽
出处:http://weekly.dockone.io

在容器领域,Docker 公司提出的容器镜像已经成为目前容器打包交付的事实标准。构建镜像需要编写 Dockerfile,如何编写一个优雅的 Dockerfile 呢?在 Docker 公司的官方文档中给出了一篇:《 Best practices for writing Dockerfiles》。

本文在此基础上做了一些删减,力图让大家在短时间内写出一份不错的 Dockerfile。

本文分为三个部分,首先会直接给出一份 Dockerfile 的参考模板,然后说明如和构建高效的镜像并解释这个模板这样组织的原因,最后会补充说明一些编写过程中的常见问题。

一份简单的 Dockerfile 参考模板

Docker 官方给出的参考文档中给出的 Dockerfile 指令接近 20 个,而我们平时在编写的时候,经常用到的不超过 10 个。因此,这里给出了一份 Dockerfile 的参考模板,几乎可以覆盖大部分的使用场景。
FROM base_image:tag    # 引用基础镜像 *必要*  

ARG arg_key[=default_value1]     # 声明变量
ENV env_key=value2     # 声明环境变量

# 构建几乎不变的部分,例如整体的目录结构,build 时依赖的文件和工具包等
COPY src dst
RUN command1 && command2 ...

WORKDIR /path/to/work/dir   # 设置工作目录 

# 构建较少变动的部分,例如应用的依赖的文件、依赖的包等
COPY src dst
RUN command3 && command4 ...

# 构建经常变动的部分,例如应用的编译生成
COPY src dst
RUN command5 && command6 ...

# 容器入口  *必要*
ENTRYPOINT ["/entry.app"]  # 指定容器启动时默认执行的命令
CMD ["--options"] # 指定容器启动时默认命令的默认参数

构建高效镜像生命周期

容器的一个重要的特点就是能够快速迭代,因此在容器镜像迭代的各个环节也应该尽量做到简洁高效。

镜像build

  • 精简 context:每次 build,context 都会复制给 docker daemon,因此要去掉 context 中无关的部分
  • 多层镜像:如果镜像很复杂,通常将其分成基础镜像(适用于多种应用,内容基本不变的部分)和应用镜像,应用镜像通过 FROM 基础镜像来减少 build 的步骤
  • 利用构建缓存(build cache):每次在 build 时,docker daemon 会默认从已在缓存中的父镜像开始,将下一条指令与从该基本镜像派生的所有子镜像进行比较,以查看是否其中一个是使用完全相同的指令构建的。如果不是,则缓存无效。因此,为了能够提高缓存的命中率,在编写 Dockerfile 时,应该尽量按照变动的频率来组织(如上文中的模板)
  • 减少 layers:RUN,COPY,ADD 等指令会在 build 时产生对应的 layer,在较旧的 Docker 版本中,需要最小化镜像中的层数以确保其性能。因此,使用&&来连接多个 RUN 命令是一个常用的方法(如上文中的模板)
  • 使用 multi-stage builds:新特性,后文会详细介绍


镜像pull

Docker 官方详细描述了 Docker 镜像和容器在宿主机上的存储方式: https://docs.docker.com/storage/storagedriver/,简单来说就是:

  • 镜像层 ,只读,使用相同镜像的多个容器共用一份。镜像又按照 layers 分层:
    • 每层都有独立的 ID
    • 不同镜像如果有相同 ID 的 layer 时,共用一份

  • 容器层,可写,采用写时复制,容器在运行时修改的内容会在这一层


根据镜像的存储方式,我们也可以加快镜像的 pull 过程:
  • 多层镜像:和 build 时的分层镜像一样,利用本地已经存储的基础镜像来减少需要 pull 的 size
  • 利用 image layer 复用相同层:和 build 时利用缓存类似,利用本地已经存储的 layer 来减少需要 pull 的 size
  • 镜像预热:提前或空闲时 pull 镜像


常见问题

注意Dockerfile中的指令是逐条执行,且相互独立

# 下面这种写法会报错,第二个 RUN 执行时的 WORKDIR 依旧是原来的目录,不是 /some/dir  
RUN cd /some/dir
RUN bash script.sh

# 改成下面两种之一
RUN cd /some/dir && bash script.sh
RUN bash /some/dir/script.sh

提防“过度”缓存

前文也提到过,Dockerfile 中每条指令逐条执行,且相互独立。大部分的指令在 build 时会生成对应的一层(layer),并被缓存。这种机制在绝大部分的情况下都工作的很好,但是有时也会产生问题:
# Dockerfile1  
FROM ubuntu:18.04
RUN apt-get update
RUN apt-get install -y nginx

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

如上,原 Dockerfile1 使用一段时间之后修改成 Dockerfile2(只修改了install这一行)。由于缓存机制(假设之前 build 的缓存还存在),Dockerfile2 在 build 时,update这一行不会真的执行,而是直接拿之前的缓存。此时安装的 nginx 和 curl 可能就不是当前的最新版本。
# 官方推荐的apt-get使用方式:  
RUN apt-get update && apt-get install -y \
curl \
nginx=1.16.* \
&& rm -rf /var/lib/apt/lists/*

ARG 与 ENV

两种指令都可以用来定义变量,但是使用上有很多要注意的点:

FROM 前的 ARG 只能在 FROM 中使用,如果在 FROM 后也要使用,需要重新声明:
ARG key=value  
FROM xxx${key}xxxx
ARG key # 这里需要再次声明才能使用

ARG 变量的作用范围是 build 阶段 ARG 之后的指令,不会带入镜像。

ENV 环境变量作用范围是 build 阶段 ENV 声明的指令,并且会编入镜像,容器运行时也会这些环境变量也生效。

CMD 和 ENTRYPOINT 中不能使用 ARG 和 ENV 定义的变量。

当 ARG 和 ENV 变量同名时(无论是谁先定义),ENV 环境变量的值会覆盖 ARG 变量。

ENV 会产生中间层(layer),被编入镜像,即使使用 unset 也无法去掉,例如:
FROM alpine  
ENV ADMIN_USER="mark"  # 此时产生了l ayer
RUN echo $ADMIN_USER > ./mark
RUN unset ADMIN_USER # 使用 unset 只是去掉了 build 时的环境变量,但是最终生成的镜像中还是会有这个变量

# 运行镜像还是会打印环境变量
docker run --rm test sh -c 'echo $ADMIN_USER'
mark

# 如果想要消除这种影响,可以改成:
FROM alpine
RUN export ADMIN_USER="mark" \
&& echo $ADMIN_USER > ./mark \
&& unset ADMIN_USER
CMD sh

COPY 与 ADD

两个指令几乎相同,当你只想复制本地 context 中的文件到镜像中时,请无脑用 COPY。

COPY 与 ADD 使用时,注意以下规则:
  • 注意文件的属性,复制时可以同时修改属主和属组 COPY/ADD [--chown=:]

  • 如果不清楚目录与反斜线对这两个指令的影响,对所有目录都加上反斜线就比较好理解了,如COPY / /,因为:
    • <src> 是目录时,是否带反斜线都只会复制目录下的所有文件,不会复制目录本身,如果要复制目录本身,需要使用``的父目录
    • <dest> 是目录时,必须带反斜线才会把文件复制到dest下
      必须在 context 下,不能使用../跳出 context


ADD 指令除了 COPY 的所有功能外,还有以下特性,如非必要,尽量少用:
  • <src> 是本地 tar 文件(常见的压缩格式)时,会自动解包
  • <src> 可以是 url,支持从远程拉取


CMD与ENTRYPOINT

又是一对很类似的指令,使用时需要注意:
  • CMD 单独使用时,用来指定容器启动时默认执行的命令
  • ENTRYPOINT 单独使用时,可以完全取代 CMD
  • ENTRYPOINT 和 CMD 一起使用时,CMD 变成 ENTRYPOINT 的默认参数
  • 推荐使用 ENTRYPOINT/CMD 的 exec 书写形式:即ENTRYPOINT ["entry.app", "arg"],因为 shell 书写形式(ENTRYPOINT entry.app arg)会额外启动 shell 进程


下表列出了 CMD 与 ENTRYPOINT 的各种组合时的效果:

另外,通过在 docker run 最后的添加字段,可以指定 ENTRYPOINT 的实际参数:
# 镜像 test_entrypoint  
ENTRYPOINT ["./entry.app"]
CMD ["--help"]

# 运行 test_entrypoint
docker run test_entrypoint # 即./entry.app --help
# 带参数运行
docker run test_entrypoint -a -t  # 即 ./entry.app -a -t

multi-stage builds

Docker 17.05 之后的版本支持一种新的 build 方式:多阶段构建(multi-stage builds)。与传统方式的区别在与,多阶段构建能够使用多个 FROM 将整个 build 阶段分成多个阶段:
  • 通过为不同阶段命名,可以通过一份 Dockerfile 来管理 debug、test、product 等多种环境的镜像
  • 通过COPY --from=stage_name,来复制中间 stage 的文件到目标阶段,使得最终生成更小的镜像


例如,上文提到的模板就可以通过多阶段构建的方式来优化。假设我们最终只想得到 entry.app 及其运行环境,而不需要它的编译环境,那么可以通过如下方式优化最终生成的镜像的大小:
# 使用多阶段构建,这里命名一个builder阶段,生成编译后的app  
FROM base_image:tag AS builder   

ARG arg_key[=default_value1]     # 声明变量
ENV env_key=value2     # 声明环境变量

# 构建整体的目录结构,build时依赖的文件和工具包等
COPY src dst
RUN command1 && command2 ...

WORKDIR /path/to/work/dir   # 设置工作目录 

# 构建编译环境
COPY src dst
RUN command3 && command4 ...

# 编译生成entry.app
COPY src dst
RUN compile_entry_app

# 构建最终镜像的阶段,只保留应用和其运行环境,编译的依赖都不需要
FROM base_image:tag
COPY src dest    # 复制运行环境
WORKDIR /path/to/work/dir   # 设置工作目录 
COPY --from=builder entry.app . # 从builder阶段复制app
# 容器入口
ENTRYPOINT ["/entry.app"]  # 指定容器启动时默认执行的命令
CMD ["--options"] # 指定容器启动时默认命令的默认参数


原文链接: https://mp.weixin.qq.com/s/hPalvdjs0zRkqetq_dOR8A

相关 [dockerfile 最佳实践] 推荐:

Dockerfile 最佳实践

- - DockOne.io
在容器领域,Docker 公司提出的容器镜像已经成为目前容器打包交付的事实标准. 构建镜像需要编写 Dockerfile,如何编写一个优雅的 Dockerfile 呢. 在 Docker 公司的官方文档中给出了一篇:《 Best practices for writing Dockerfiles》.

jQuery最佳实践

- andi - 阮一峰的网络日志
上周,我整理了《jQuery设计思想》. 那篇文章是一篇入门教程,从设计思想的角度,讲解"怎么使用jQuery". 今天的文章则是更进一步,讲解"如何用好jQuery". 我主要参考了Addy Osmani的PPT《提高jQuery性能的诀窍》(jQuery Proven Performance Tips And Tricks).

PHP最佳实践

- xiangqian - 阮一峰的网络日志
虽然名字叫《PHP最佳实践》,但是它主要谈的不是编程规则,而是PHP应用程序的合理架构. 它提供了一种逻辑和数据分离的架构模式,属于MVC模式的一种实践. 我觉得,这是很有参考价值的学习资料,类似的文章网上并不多,所以一边学习,一边就把它翻译了出来. 根据自己的理解,我总结了它的MVC模式的实现方式(详细解释见译文):.

MongoDB最佳实践

- - NoSQLFan
将 MongoDB加入到我们的服务支持列表中,是整个团队年初工作计划中的首要任务. 但我们感觉如果先添加一项对NoSQL存储的支持,而不是先升级已支持的关系型数据库,可能对用户不太好,毕竟目前的用户都使用关系型数据库. 所以我们决定将引入MongoDB这项工作放到升级MySQL和PostgreSQL之后来做.

文章: Grails最佳实践

- - InfoQ cn
我在IntelliGrape工作,这是一家专门使用Groovy & Grails进行开发的公司. 本文是我们Grails项目遵循的最佳实践的基本清单,收集自邮件列表、Stack Overflow、博文, 播客和 IntelliGrape的内部讨论. 它们分为控制器、服务、Domain、视图、TagLib、测试和其他.

PHP最佳实践(译)

- - CSDN博客Web前端推荐文章
原文:  PHP Best Practices-A short, practical guide for common and confusing PHP tasks. 译者: youngsterxyf. 本文档最后审阅于2013年3月8日. 由我, Alex Cabal,维护该文档. 我编写PHP程序已有很长一段时间了,当前我 经营着 Scribophile,由认真作家组成的一个在线写作团体,  Writerfolio,为自由职业者提供的一个易用写作工具集,以及  Standard Ebooks,一个图文并茂、无数字版权管理的公共领域电子书出版商.

Log4j最佳实践(原) - Mainz

- - 博客园_Mainz's Blog
本文是结合项目中使用 Log4j总结的最佳实践,非转载. 网上可以找到的是这一篇《 Log4j最佳实践》. 本来 Log4j使用是非常简单的,无需多介绍其用法,这只是在小型项目中;但在 大型的项目中使用 log4j不太一样. 大型项目非常依赖日志,因为解决线上问题必须依靠log,依靠大量的日志.

再谈RestAPI最佳实践

- - 企业架构 - ITeye博客
http://www.javacodegeeks.com/2014/05/rest-api-best-practices-reloaded.html ,仅供学习和参考,转载请注明出处. 近一年半,我参与了2到3个项目的工作,这些项目涉及到大量供“外部”使用的Rest API,稍后我们再来解释为什么要将“外部”这个词放在引号之中.

一些 REST 最佳实践

- - 鸟窝
原文: Some REST best practices, 作者: Pierre-Olivier Bourgeois. 译文: 一些REST最佳实践, 译者: yongx. 如今,REST APIs 已经非常普遍,几乎所有WEB应用都用到了它们. 提供简单,一致,实用的API是种义务,方便其它人很容易的使用.

Redis最佳实践 | kikoroc

- -
redis是一款开源的内存数据存储系统,可以用作数据库、缓存甚至是消息中间件(pub/sub)来使用. 与memcache相比,redis支持更多的数据结构,比如string,hash,list,set,bit map,sorted set甚至是geo等等,基本覆盖了日常开发中使用到的数据结构. 而且redis十分高效,当然是建立在合理使用的前提下.