官方资料加上自己的一点理解。

准则与建议

容器应该是短暂的

「短暂」意味着可以你可以很容易的停止、销毁、并创建你的容器。并且创建一个容器并部署好的所需的设置和配置工作量应该是极小的。
容器模型是进程而不是虚拟机,不需要开机初始化。在需要时运行,不需要时停止。能够删除后重建,并无需额外的配置。
比如,我们把数据目录、配置文件目录、缓存目录等应该都以数据卷、挂载主机目录或匿名卷的方式进行保存,这样你的容器就成为了一个无状态化的容器。重启、销毁、创建容器都是最简单的操作,无需进行额外的配置。
可以参考十二因素中的进程,以了解以无状态方式运行容器的意义。
了解镜像构建上下文

我们使用 docker image build 命令最后有一个路径

$ docker image build -t eureka:0.1 .

. 表示当前目录,而 Dockerfile 文件也在当前目录,因此有不少人会误认为这个路径是在制定 Dockerfile 文件所在路径,这么理解是不对的。
这个路径指定的是构建上下文路径,当构建的时候,用户会制定构建镜像的上下文路径,docker image build 命令得知这个路径后,会将上下文的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。
为什么会有人误认为 . 是制定 Dockerfile 文件所在的目录呢?这是因为默认情况下,如果不额外指定 Dockerfile 文件的话,会将上下文目录下的名为 Dockerfile 的文件作为 Dockerfile 来使用。
那么我们现在把 Dockerfile 和「构建上下文」分开来构建镜像:

$ ls /data/dockerfile/
 eureka.dockerfile
 $ ls /data/context/eureka/
 eureka-0.1.jar
 $ docker image build -f /data/dockerfile/eureka.dockerfile -t eureka:0.1 /data/context/eureka/
 Sending build context to Docker daemon  39.94MB
 Step 1/5 : FROM jugggao/java:8-jre
  ---> 384c422fc9af
 Step 2/5 : COPY eureka-0.1.jar /eureka-0.1.jar
  ---> Using cache
  ---> 1029efd7589e
 Step 3/5 : LABEL maintainer="Peng.Gao "       version="0.1"       description="XXXX Eureka 镜像"
  ---> Using cache
  ---> 0944bca371dc
 Step 4/5 : EXPOSE 25001
  ---> Using cache
  ---> d92974790cca
 Step 5/5 : CMD ["java", "-jar", "eureka-0.1.jar"]
  ---> Using cache
  ---> 40c9f61c22be
 Successfully built 40c9f61c22be
 Successfully tagged eureka:0.1

• -f:制定 Dockerfile 文件,默认为当前目录的 Dockerfile。
在构建的过程中,第一步就是讲上下文上传给 Docker 引擎:Sending build context to Docker daemon 39.94MB,Dockerfile 中后续指令中出现的相对路径是根据上下文路径作为当前目录来执行的。
比如说 COPY eureka-0.1.jar /eureka-0.1.jar,就是把上下文路径中的 eureka-0.1.jar 拷贝至容器内的根目录下;COPY target/eureka-0.1.jar /eureka-0.1.jar 就是把上下文路径中的 target 目录下的 eureka-0.1.jar 拷贝至容器内的根目录下。
理解构建上下文对于镜像构建是很重要的,可以避免一些不应该的错误。比如 COPY /opt/eureka-0.1.jar /eureka-0.1.jar 报文件未找到后,于是感触将 Dockerfile 文件放到了根目录去构建,结果发现 docker image build 执行后,发送几个 GB 甚至更大的内容给 Docker Daemon,极为缓慢并且容易构建失败,那是因为这种做法让 docker image build 打包整个硬盘。
一般情况下,应该会将 Dockerfile 文件置于一个空目录或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西不希望构建时传给 Docker 引擎,那么可以用 .gitignore 一样的语法写一个 .dockerignore,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的。
使用 .dockerignore 文件
使用 Dockerfile 构建镜像时最好是将 Dockerfile 文件放置在一个新建的空目录下,然后将构建镜像所需要的文件添加到该目录中。
为了提高构建镜像的效率,你可以在目录下新建一个 .dockerignore 文件来指定要忽略的文件和目录。.dockerignore 文件的排除模式语法和 Git 的 .gitignore 文件相似。
比如:

# comment
 */temp*
 */*/temp*
 temp?

使用多阶段构建
多阶段构建可以减少构建镜像的大小。
但多阶段构建并不适合 Java Maven 工程,因为每次构建都要下载大量的依赖包,很耽误时间。我们使用 Jenkins 可以直接先进行编译生成 Jar 包,然后直接拷贝至容器内构建镜像即可,没有必要使用多阶段构建镜像。
有兴趣的话可以参考资料自己写一个多阶段构建的 Dockerfile,我写了一个仅供参考:

$ tree
.
├── Dockerfile
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── example
    │   │           └── demo
    │   │               └── DemoApplication.java
    │   └── resources
    │       └── application.properties
    └── test
        └── java
            └── com
                └── example
                    └── demo
                        └── DemoApplicationTests.java

12 directories, 5 files
Dockerfile 文件内容如下:

# 第一阶段:编译阶段,生成 Jar 包
FROM harbor.ambow.com/official/maven:3.6 as BUILDER
WORKDIR /home/
 COPY pom.xml src/ ./
 RUN mvn -Dmaven.test.failure.ignore=true install
 # 第二阶段:拷贝 Jar 包,构建镜像
 FROM harbor.ambow.com/official/java:8-jre
 COPY --from=BUILDER /home/demo/target/demo-0.0.1-SNAPSHOT.jar /demo-0.0.1-SNAPSHOT.jar
 CMD ["java", "-jar", "demo-0.0.1-SNAPSHOT.jar"]

避免安装不必要的包

为了降低复杂性、减少依赖、减小文件大小、节约构建时间,你应该避免安装任何不必要的包。
例如,不要在数据库镜像中安装一个文本编辑器,也不要在 Java 镜像中安装一些调试工具等等。
一个容器只运行一个进程
应该保证在一个容器中只运行一个进程。将多个应用解耦到不同容器中,保证了容器的横向扩展和复用。
如果容器互相依赖,你可以使用 Docker 自定义网络来把这些容器连接起来。
最小化镜像层数
你需要在 Dockerfile 可读性(也包括长期的可维护性)和减少层数之间做一个平衡。
排序多行参数
只要有可能,通过以安装的软件包的字母数字来排序。 这将帮助你避免安装重复的包,并使列表更容易更新。同时也建议在反斜杠符号 \ 之前添加一个空格,以增加可读性。
比如:

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

合理利用构建缓存
在镜像的构建过程中,Docker 会遍历 Dockerfile 文件中的指令,然后按顺序执行。在执行每条指令之前,Docker 都会在缓存中查找是否已经存在可重用的镜像,如果有就使用现存的镜像,不再重复创建。如果你不想在构建过程中使用缓存,你可以在 docker image build 命令中使用 --no-cache=true 选项。
但是,如果你想在构建的过程中使用缓存,你得明白什么时候会,什么时候不会找到匹配的镜像,遵循的基本规则如下:
• 从一个基础镜像开始,下一条指令将和该基础镜像的所有子镜像进行匹配,检查这些子镜像被创建时使用的指令是否和被检查的指令完全一样。如果不是,则缓存失效。
• 在大多数情况下,只需要简单地对比 Dockerfile 中的指令和子镜像。然而,有些指令需要更多的检查和解释。
• 对于 ADD 和 COPY 指令,镜像中对应文件的内容也会被检查,每个文件都会计算出一个校验和。文件的最后修改时间和最后访问时间不会纳入校验。在缓存的查找过程中,会将这些校验和和已存在镜像中的文件校验和进行对比。如果文件有任何改变,比如内容和元数据,则缓存失效。
• 除了 ADD 和 COPY 指令,缓存匹配过程不会查看临时容器中的文件来决定缓存是否匹配。例如,当执行完 RUN apt-get -y update 指令后,容器中一些文件被更新,但 Docker 不会检查这些文件。这种情况下,只有指令字符串本身被用来匹配缓存。
一旦缓存失效,所有后续的 Dockerfile 指令都将产生新的镜像,缓存不会被使用。
Dockerfile 指令建议

下面针对 Dockerfile 中各种指令的最佳编写方式给出建议。
FROM
尽可能使用当前官方仓库作为你构建镜像的基础。推荐使用 Alpine 镜像,因为它被严格控制并保持最小尺寸(目前小于 5 MB),但它仍然是一个完整的 Linux 发行版。
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 指令中:

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 的可读性,可理解性,以及可维护性,建议将长的或复杂的 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/*

CMD
CMD 大多数情况下都应该以 CMD ["executable", "param1", "param2"...] 的形式使用。
因此,如果创建镜像的目的是为了部署某个服务(比如 Apache),你可能会执行类似于 CMD ["apache2", "-DFOREGROUND"] 形式的命令。建议任何服务镜像都使用这种形式的命令。
CMD 应该在极少的情况下才能以 CMD ["param", "param"] 的形式与 ENTRYPOINT 协同使用,除非你和你的镜像使用者都对 ENTRYPOINT 的工作方式十分熟悉。
EXPOSE

EXPOSE 指令用于指定容器将要监听的端口。因此,你应该为你的应用程序使用常见的端口。例如,提供 Web 服务的镜像应该使用 EXPOSE 80,而提供 MongoDB 服务的镜像使用 EXPOSE 27017。
对于外部访问,我们可以选择一个不常见的端口来进行映射保证服务的安全性,例如 docker container run -p 20080:80 nginx:1.14。
ENV

为了方便新程序运行,你可以使用 ENV 来为容器中安装的程序更新 PATH 环境变量。例如使用 ENV PATH /usr/local/nginx/bin:$PATH 来确保 CMD ["nginx"] 能正确运行。
同时 ENV 指令指定的环境变量也可以在后续的日常运维工作中使用,比如备份 MySQL 容器的数据库:
$ docker exec mysql sh -c 'exec mysqldump --all-databases -uroot -p"$MYSQL_ROOT_PASSWORD"' > /data/backup/all-databases.sql
ADD 和 COPY
虽然 ADD 和 COPY 功能类似,但一般优先使用 COPY。因为它比 ADD 更透明。
COPY 只支持简单将本地文件拷贝到容器中而 ADD 有一些并不明显的功能(比如本地 tar 提取和远程 URL 支持)。因此,ADD 的最佳用例是将本地 tar 文件自动提取到镜像中,例如 ADD rootfs.tar.xz。
为了让镜像尽量小,最好不要使用 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

上面使用的管道操作,所以没有中间文件需要删除。
对于其他不需要 ADD 的自动提取功能的文件或目录,你应该使用 COPY。
ENTRYPOINT
ENTRYPOINT 的最佳用处是设置镜像的主命令,允许将镜像当成命令本身来运行(用 CMD 提供默认选项)。
例如,下面的示例镜像提供了命令行工具 s3cmd:

ENTRYPOINT ["s3cmd"]
CMD ["--help"]
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 "$@"

注意:
该脚本使用了 Bash 的内置命令 exec,所以最后运行的进程就是容器的 PID 为 1 的进程。这样,进程就可以接收到任何发送给容器的 Unix 信号了。
该辅助脚本被拷贝到容器,并在容器启动时通过 ENTRYPOINT 执行:
COPY ./docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
该脚本可以让用户用几种不同的方式和 Postgres 交互:
• 简单的启动:$ docker container run postgres
• 也可以执行 Postgres 并传递参数:$ docker run postgres postgres --help
• 最后,你还可以启动另外一个完全不同的工具,比如 Bash:$ docker run --rm -it postgres bash
VOLUME
VOLUME 指令用于暴露任何数据库存储文件,配置文件,或容器创建的文件和目录。强烈建议使用 VOLUME 来管理镜像中的可变部分和用户可以改变的部分。
USER
如果某个服务不需要特权执行,建议使用 USER 指令切换到非 Root 用户。先在 Dockerfile 中使用类似RUN groupadd -r postgres && useradd -r -g postgres postgres 的指令创建用户和用户组。
你应该避免使用 sudo,因为它不可预期的 TTY 和信号转发行为可能造成的问题比它能解决的问题还多。如果你真的需要和 sudo 类似的功能(例如,以 root 权限初始化某个守护进程,以非 root 权限执行它),你可以使用 gosu。
为了减少层数和复杂度,避免频繁地使用 USER 来回切换用户。
WORKDIR
为了清晰性和可靠性,你应该总是在 WORKDIR 中使用绝对路径。另外,你应该使用 WORKDIR 来替代类似于 RUN cd ... && do-something 的指令,后者难以阅读、排错和维护。

总结
多看官方镜像,思考官网镜像是怎么写 Dockerfile 的,或者多模仿官方镜像来写,写着写着就熟练了。