Dockerfile的好处

  • 易于版本化管理,Dockerfile 本身是一个文本文件,方便存放在代码仓库做版本管理,可以很方便地找到各个版本之间的变更历史;
  • 过程可追溯,Dockerfile 的每一行指令代表一个镜像层,根据 Dockerfile 的内容即可很明确地查看镜像的完整构建过程;
  • 屏蔽构建环境异构,使用 Dockerfile 构建镜像无须考虑构建环境,基于相同 Dockerfile 无论在哪里运行,构建结果都一致。

虽然有这么多好处,但是如果你 Dockerfile 使用不当也会引发很多问题。比如镜像构建时间过长,甚至镜像构建失败;镜像层数过多,导致镜像文件过大。

Dockerfile 书写原则

(1)单一职责

由于容器的本质是进程,一个容器代表一个进程,因此不同功能的应用应该尽量拆分为不同的容器,每个容器只负责单一业务进程。

(2)提供注释信息

Dockerfile 也是一种代码,我们应该保持良好的代码编写习惯,晦涩难懂的代码尽量添加注释,让协作者可以一目了然地知道每一行代码的作用,并且方便扩展和使用。

(3)保持容器最小化

应该避免安装无用的软件包,比如在一个 nginx 镜像中,我并不需要安装 vim 、gcc 等开发编译工具。这样不仅可以加快容器构建速度,而且可以避免镜像体积过大。

(4)合理选择基础镜像

容器的核心是应用,因此只要基础镜像能够满足应用的运行环境即可。例如一个Java类型的应用运行时只需要JRE,并不需要JDK,因此我们的基础镜像只需要安装JRE环境即可。

(5)使用 .dockerignore 文件

在使用git时,可以使用.gitignore文件忽略一些不需要做版本管理的文件。同理,使用.dockerignore文件允许在构建时,忽略一些不需要参与构建的文件,从而提升构建效率。.dockerignore的定义类似于.gitignore。

.dockerignore的本质是文本文件,Docker 构建时可以使用换行符来解析文件定义,每一行可以忽略一些文件或者文件夹。具体使用方式如下:
规则 含义

开头的表示注释,# 后面所有内容将会被忽略
/tmp 匹配当前目录下任何以 tmp 开头的文件或者文件夹
*.md 匹配以 .md 为后缀的任意文件
tem? 匹配以 tem 开头并且以任意字符结尾的文件,?代表任意一个字符
!README.md ! 表示排除忽略。

例如 .dockerignore 定义如下:
*.md
!README.md
表示除了 README.md 文件外所有以 .md 结尾的文件。

(6)尽量使用构建缓存

Docker 构建过程中,每一条 Dockerfile 指令都会提交为一个镜像层,下一条指令都是基于上一条指令构建的。如果构建时发现要构建的镜像层的父镜像层已经存在,并且下一条命令使用了相同的指令,即可命中构建缓存。

Docker 构建时判断是否需要使用缓存的规则如下:

  1. 从当前构建层开始,比较所有的子镜像,检查所有的构建指令是否与当前完全一致,如果不一致,则不使用缓存;
  2. 一般情况下,只需要比较构建指令即可判断是否需要使用缓存,但是有些指令除外(例如ADDCOPY);
  3. 对于ADDCOPY指令不仅要校验命令是否一致,还要为即将拷贝到容器的文件计算校验和(根据文件内容计算出的一个数值,如果两个文件计算的数值一致,表示两个文件内容一致 ),命令和校验和完全一致,才认为命中缓存。

因此,基于 Docker 构建时的缓存特性,可以把不轻易改变的指令放到 Dockerfile 前面(例如安装软件包),而可能经常发生改变的指令放在 Dockerfile 末尾(例如编译应用程序)。

例如,想要定义一些环境变量并且安装一些软件包,可以按照如下顺序编写 Dockerfile:

  1. FROM centos:7
  2. # 设置环境变量指令放前面
  3. ENV PATH /usr/local/bin:$PATH
  4. # 安装软件指令放前面
  5. RUN yum install -y make
  6. # 把业务软件的配置,版本等经常变动的步骤放最后
  7. ...

按照上面原则编写的 Dockerfile 在构建镜像时,前面步骤命中缓存的概率会增加,可以大大缩短镜像构建时间。

(7)正确设置时区

从 Docker Hub 拉取的官方操作系统镜像大多数都是 UTC 时间(世界标准时间)。如果想要在容器中使用中国区标准时间(东八区),请根据使用的操作系统修改相应的时区信息,下面介绍几种常用操作系统的修改方式:

  1. Ubuntu Debian 系统

Ubuntu 和Debian 系统可以向 Dockerfile 中添加以下指令:

  1. RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
  2. RUN echo "Asia/Shanghai" >> /etc/timezone
  3. CentOS系统

CentOS 系统则向 Dockerfile 中添加以下指令:

  1. RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

(8)使用国内软件源加快镜像构建速度

由于我们常用的官方操作系统镜像基本都是国外的,软件服务器大部分也在国外,所以构建镜像的时候想要安装一些软件包可能会非常慢。

这里我以 CentOS 7 为例,介绍一下如何使用 163 软件源(国内有很多大厂,例如阿里、腾讯、网易等公司都免费提供的软件加速源)加快镜像构建。

首先在容器构建目录创建文件 CentOS7-Base-163.repo,文件内容如下:

  1. # CentOS-Base.repo
  2. #
  3. # The mirror system uses the connecting IP address of the client and the
  4. # update status of each mirror to pick mirrors that are updated to and
  5. # geographically close to the client. You should use this for CentOS updates
  6. # unless you are manually picking other mirrors.
  7. #
  8. # If the mirrorlist= does not work for you, as a fall back you can try the
  9. # remarked out baseurl= line instead.
  10. #
  11. #
  12. [base]
  13. name=CentOS-$releasever - Base - 163.com
  14. #mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=os
  15. baseurl=http://mirrors.163.com/centos/$releasever/os/$basearch/
  16. gpgcheck=1
  17. gpgkey=http://mirrors.163.com/centos/RPM-GPG-KEY-CentOS-7
  18. #released updates
  19. [updates]
  20. name=CentOS-$releasever - Updates - 163.com
  21. #mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=updates
  22. baseurl=http://mirrors.163.com/centos/$releasever/updates/$basearch/
  23. gpgcheck=1
  24. gpgkey=http://mirrors.163.com/centos/RPM-GPG-KEY-CentOS-7
  25. #additional packages that may be useful
  26. [extras]
  27. name=CentOS-$releasever - Extras - 163.com
  28. #mirrorlist=http://mirrorlist.centos.org/?release=$releasever&arch=$basearch&repo=extras
  29. baseurl=http://mirrors.163.com/centos/$releasever/extras/$basearch/
  30. gpgcheck=1
  31. gpgkey=http://mirrors.163.com/centos/RPM-GPG-KEY-CentOS-7
  32. #additional packages that extend functionality of existing packages
  33. [centosplus]
  34. name=CentOS-$releasever - Plus - 163.com
  35. baseurl=http://mirrors.163.com/centos/$releasever/centosplus/$basearch/
  36. gpgcheck=1
  37. enabled=0
  38. gpgkey=http://mirrors.163.com/centos/RPM-GPG-KEY-CentOS-7

然后在 Dockerfile 中添加如下指令:

  1. COPY CentOS7-Base-163.repo /etc/yum.repos.d/CentOS7-Base.repo

上条可让yum install命令安装软件时就会默认从 163 获取软件包,这样可以大大提升构建速度。

(9)最小化镜像层数

在构建镜像时尽可能地减少 Dockerfile 指令行数。例如在 CentOS 系统中安装make和net-tools两个软件包,应该在 Dockerfile 中使用以下指令:

  1. RUN yum install -y make net-tools

而不应该写成这样:

  1. RUN yum install -y make
  2. RUN yum install -y net-tools

Dockerfile 指令书写建议

(1)RUN

RUN指令在构建时将会生成一个新的镜像层并且执行RUN指令后面的内容。
使用RUN指令时应该尽量遵循以下原则:

  • RUN指令后面跟的内容比较复杂时,建议使用反斜杠(\) 结尾并且换行;
  • RUN指令后面的内容尽量按照字母顺序排序,提高可读性。

例如,我想在官方的 CentOS 镜像下安装一些软件,一个建议的 Dockerfile 指令如下:

  1. FROM centos:7
  2. RUN yum install -y automake \
  3. curl \
  4. python \
  5. vim

(2)CMD 和 ENTRYPOINT

CMDENTRYPOINT指令都是容器运行的命令入口,这两个指令使用中有很多相似的地方,但是也有一些区别。
这两个指令的相同之处,CMDENTRYPOINT的基本使用格式分为两种。

  • 第一种为CMD/ENTRYPOINT[“command” , “param”]。这种格式是使用 Linux 的exec实现的, 一般称为exec模式,这种书写格式为CMD/ENTRYPOINT后面跟 json 数组,也是Docker 推荐的使用格式。
  • 另外一种格式为CMD/ENTRYPOINTcommand param ,这种格式是基于 shell 实现的, 通常称为shell模式。当使用shell模式时,Docker 会以 /bin/sh -c command 的方式执行命令。

    使用 exec 模式启动容器时,容器的 1 号进程就是 CMD/ENTRYPOINT 中指定的命令,而使用 shell 模式启动容器时相当于把启动命令放在了 shell 进程中执行,等效于执行 /bin/sh -c “task command” 命令。因此 shell 模式启动的进程在容器中实际上并不是 1 号进程。

这两个指令的区别:

  • Dockerfile 中如果使用了ENTRYPOINT指令,启动 Docker 容器时需要使用 —entrypoint 参数才能覆盖 Dockerfile 中的ENTRYPOINT指令 ,而使用CMD设置的命令则可以被docker run后面的参数直接覆盖。
  • ENTRYPOINT指令可以结合CMD指令使用,也可以单独使用,而CMD指令只能单独使用。

看到这里你也许会问,我什么时候应该使用ENTRYPOINT,什么时候使用CMD呢?
如果你希望你的镜像足够灵活,推荐使用CMD指令。如果你的镜像只执行单一的具体程序,并且不希望用户在执行docker run时覆盖默认程序,建议使用ENTRYPOINT
最后再强调一下,无论使用CMD还是ENTRYPOINT,都尽量使用exec模式。

(3)ADD 和 COPY

ADDCOPY指令功能类似,都是从外部往容器内添加文件。但是COPY指令只支持基本的文件和文件夹拷贝功能,ADD则支持更多文件来源类型,比如自动提取 tar 包,并且可以支持源文件为 URL 格式。
那么在日常应用中,我们应该使用哪个命令向容器里添加文件呢?你可能在想,既然ADD指令支持的功能更多,当然应该使用ADD指令了。然而事实恰恰相反,推荐使用COPY指令,因为COPY指令更加透明,仅支持本地文件向容器拷贝,而且使用COPY指令可以更好地利用构建缓存,有效减小镜像体积。
当你想要使用ADD向容器中添加 URL 文件时,请尽量考虑使用其他方式替代。例如你想要在容器中安装 memtester(一种内存压测工具),你应该避免使用以下格式:
复制代码

  1. ADD http://pyropus.ca/software/memtester/old-versions/memtester-4.3.0.tar.gz /tmp/
  2. RUN tar -xvf /tmp/memtester-4.3.0.tar.gz -C /tmp
  3. RUN make -C /tmp/memtester-4.3.0 && make -C /tmp/memtester-4.3.0 install

下面是推荐写法:

  1. RUN wget -O /tmp/memtester-4.3.0.tar.gz http://pyropus.ca/software/memtester/old-versions/memtester-4.3.0.tar.gz \
  2. && tar -xvf /tmp/memtester-4.3.0.tar.gz -C /tmp \
  3. && make -C /tmp/memtester-4.3.0 && make -C /tmp/memtester-4.3.0 install

(4)WORKDIR

为了使构建过程更加清晰明了,推荐使用 WORKDIR 来指定容器的工作路径,应该尽量避免使用 RUN cd /work/path && do some work 这样的指令。

多阶段构建

创建一个go web

  1. $ cd /root/demo
  2. $ cat main.go
  3. package main
  4. import (
  5. "fmt"
  6. "net/http"
  7. )
  8. func hello(w http.ResponseWriter, req *http.Request) {
  9. fmt.Fprintf(w, "hello world!\n")
  10. }
  11. func main() {
  12. http.HandleFunc("/", hello)
  13. http.ListenAndServe(":8080", nil)
  14. }

通常编译打包为两个步骤

Dockerfile.build

  1. FROM golang:1.13
  2. WORKDIR /go/src/github.com/wilhelmguo/multi-stage-demo/
  3. COPY main.go .
  4. RUN CGO_ENABLED=0 GOOS=linux go build -o http-server .

Dockerfile

  1. FROM alpine:latest
  2. WORKDIR /root/
  3. COPY http-server .
  4. CMD ["./http-server"]

执行shell如下:

  1. #!/bin/sh
  2. echo Building http-server:build
  3. docker build -t http-server:build . -f Dockerfile.build
  4. docker create --name builder http-server:build
  5. # 从上面运行的容器中拷贝二进制文件
  6. docker cp builder:/go/src/github.com/wilhelmguo/multi-stage-demo/http-server ./http-server
  7. docker rm -f builder
  8. echo Building http-server:latest
  9. docker build -t http-server:latest .
  10. rm ./http-server

使用这种方式虽然可以实现分离镜像的编译和运行环境,但是需要额外引入一个 build.sh 脚本文件,而且构建过程中,还需要创建临时容器 builder 拷贝编译后的 http-server 文件,这使得整个构建过程比较复杂,并且整个构建过程也不够透明。

使用多阶段构建

Docker 允许在 Dockerfile 中使用多个 FROM 语句,而每个 FROM 语句都可以使用不同基础镜像。最终生成的镜像,是以最后一条 FROM 为准,所以可以在一个 Dockerfile 中声明多个 FROM,然后选择性地将一个阶段生成的文件拷贝到另外一个阶段中,从而实现最终的镜像只保留需要的环境和文件。多阶段构建的主要使用场景是分离编译环境和运行环境。

—from=0 是从第一个阶段copy文件

  1. FROM golang:1.13
  2. WORKDIR /go/src/github.com/wilhelmguo/multi-stage-demo/
  3. COPY main.go .
  4. RUN CGO_ENABLED=0 GOOS=linux go build -o http-server .
  5. FROM alpine:latest
  6. WORKDIR /root/
  7. COPY --from=0 /go/src/github.com/wilhelmguo/multi-stage-demo/http-server .
  8. CMD ["./http-server"]

第一个构建阶段,使用 AS 指令将这个阶段命名为 builder。然后在第二个构建阶段使用 —from=builder 指令,即可从第一个构建阶段中拷贝文件,使得 Dockerfile 更加清晰可读。

  1. FROM golang:1.13 AS builder
  2. WORKDIR /go/src/github.com/wilhelmguo/multi-stage-demo/
  3. COPY main.go .
  4. RUN CGO_ENABLED=0 GOOS=linux go build -o http-server .
  5. FROM alpine:latest
  6. WORKDIR /root/
  7. COPY --from=builder /go/src/github.com/wilhelmguo/multi-stage-demo/http-server .
  8. CMD ["./http-server"]