Kubernetes要从容器化开始,而容器又需要从Dockerfile开始,本文将介绍如何写出一个优雅的Dockerfile文件。
文章主要内容包括:

  • Docker容器
  • Dockerfile
  • 使用多阶构建

一、Docker容器

1.1 容器的特点

我们都知道容器就是一个标准的软件单元,它有以下特点:

  • 随处运行:容器可以将代码与配置文件和相关依赖库进行打包,从而确保在任何环境下的运行都是一致的。
  • 高资源利用率:容器提供进程级的隔离,因此可以更加精细地设置CPU和内存的使用率,进而更好地利用服务器的计算资源。
  • 快速扩展:每个容器都可作为单独的进程予以运行,并且可以共享底层操作系统的系统资源,这样一来可以加快容器的启动和停止效率。

1.2 Docker容器

目前市面上的主流容器引擎有Docker、Rocket/rkt、OpenVZ/Odin等等,而独霸一方的容器引擎就是使用最多的Docker容器引擎。

Docker容器是与系统其他部分隔离开的一系列进程,运行这些进程所需的所有文件都由另一个镜像提供,从开发到测试再到生产的整个过程中,Linux 容器都具有可移植性和一致性。相对于依赖重复传统测试环境的开发渠道,容器的运行速度要快得多,并且支持在多种主流云平台(PaaS)和本地系统上部署。Docker容器很好地解决了“开发环境能正常跑,一上线就各种崩”的尴尬。

Docker容器的特点:

  • 轻量:容器是进程级的资源隔离,而虚拟机是操作系统级的资源隔离,所以Docker容器相对于虚拟机来说可以节省更多的资源开销,因为Docker容器不再需要GuestOS这一层操作系统了。
  • 快速:容器的启动和创建无需启动GuestOS,可以实现秒级甚至毫秒级的启动。
  • 可移植性:Docker容器技术是将应用及所依赖的库和运行时的环境技术改造包成容器镜像,可以在不同的平台运行。
  • 自动化:容器生态中的容器编排工作(如:Kubernetes)可帮助我们实现容器的自动化管理。

二、Dockerfile

Dockerfile是用来描述文件的构成的文本文档,其中包含了用户可以在使用行调用以组合Image的所有命令,用户还可以使用Docker build实现连续执行多个命令指今行的自动构建。

通过编写Dockerfile生磁镜像,可以为开发、测试团队提供基本一致的环境,从而提升开发、测试团队的效率,不用再为环境不统一而发愁,同时运维也能更加方便地管理我们的镜像。

Dockerfile 指令 说明
FROM 指定基础镜像,用于后续的指令构建。
MAINTAINER 指定Dockerfile的作者/维护者。(已弃用,推荐使用LABEL指令)
LABEL 添加镜像的元数据,使用键值对的形式。
RUN 在构建过程中在镜像中执行命令。
CMD 指定容器创建时的默认命令。(可以被覆盖)
ENTRYPOINT 设置容器创建时的主要命令。(不可被覆盖)
EXPOSE 声明容器运行时监听的特定网络端口。
ENV 在容器内部设置环境变量。
ADD 将文件、目录或远程URL复制到镜像中。
COPY 将文件或目录复制到镜像中。
VOLUME 为容器创建挂载点或声明卷。
WORKDIR 设置后续指令的工作目录。
USER 指定后续指令的用户上下文。
ARG 定义在构建过程中传递给构建器的变量,可使用 “docker build” 命令设置。
ONBUILD 当该镜像被用作另一个构建过程的基础时,添加触发器。
STOPSIGNAL 设置发送给容器以退出的系统调用信号。
HEALTHCHECK 定义周期性检查容器健康状态的命令。
SHELL 覆盖Docker中默认的shell,用于RUN、CMD和ENTRYPOINT指令。

2.1 编写优雅地Dockerfile

编写优雅的Dockerfile主要需要注意以下几点:

  • Dockerfile文件不宜过长,层级越多最终制作出来的镜像也就越大。
  • 构建出来的镜像不要包含不需要的内容,如日志、安装临时文件等。
  • 尽量使用运行时的基础镜像,不需要将构建时的过程也放到运行时的Dockerfile里。

只要记住以上三点就能写出不错的Dockerfile。

为了方便大家了解,我们用两个Dockerfile实例进行简单的对比:

1
2
3
4
5
6
7
FROM ubuntu:16.04
RUN apt-get update
RUN apt-get install -y apt-utils libjpeg-dev \
python-pip
RUN pip install --upgrade pip
RUN easy_install -U setuptools
RUN apt-get clean
1
2
3
4
5
6
FROM ubuntu:16.04
RUN apt-get update && apt-get install -y apt-utils \
libjpeg-dev python-pip \
&& pip install --upgrade pip \
&& easy_install -U setuptools \
&& apt-get clean

我们看第一个Dockerfile,乍一看条理清晰,结构合理,似乎还不错。再看第二个Dockerfile,紧凑,不易阅读,为什么要这么写?

  • 第一个Dockerfile的好处是:当正在执行的过程某一层出错,对其进行修正后再次Build,前面已经执行完成的层不会再次执行。这样能大大减少下次Build的时间,而它的问题就是会因层级变多了而使镜像占用的空间也变大。
  • 第二个Dockerfile把所有的组件全部在一层解决,这样做能一定程度上减少镜像的占用空间,但在制作基础镜像的时候若其中某个组编译出错,修正后再次Build就相当于重头再来了,前面编译好的组件在一个层里,得全部都重新编译一遍,比较消耗时间。

从下表可以看出两个Dockerfile所编译出来的镜像大小:

1
2
3
4
$ docker images | grep ubuntu      
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu 16.04 9361ce633ff1 1 days ago 422MB
ubuntu 16.04-1 3f5b979df1a9 1 days ago 412MB

呃…. 好像并没有特别的效果,但若Dockerfile非常长的话可以考虑减少层次,因为Dockerfile最高只能有127层。

三、使用多阶构建

Docker在升级到Docker 17.05之后就能支持多阶构建了,为了使镜像更加小巧,我们采用多阶构建的方式来打包镜像。在多阶构建出现之前我们通常使用一个Dockerfile或多个Dockerfile来构建镜像。

3.1单文件构建

在多阶构建出来之前使用单个文件进行构建,单文件就是将所有的构建过程(包括项目的依赖、编译、测试、打包过程)全部包含在一个Dockerfile中之下:

1
2
3
4
5
6
7
8
9
FROM golang:1.11.4-alpine3.8 AS build-env
ENV GO111MODULE=off
ENV GO15VENDOREXPERIMENT=1
ENV BUILDPATH=github.com/lattecake/hello
RUN mkdir -p /go/src/${BUILDPATH}
COPY ./ /go/src/${BUILDPATH}
RUN cd /go/src/${BUILDPATH} && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go install –v

CMD [/go/bin/hello]

这种的做法会带来一些问题:

  • Dockerfile文件会特别长,当需要的东西越来越多的时候可维护性指数级将会下降;
  • 镜像层次过多,镜像的体积会逐步增大,部署也会变得越来越慢;
  • 代码存在泄漏风险。

以Golang为例,它运行时不依赖任何环境,只需要有一个编译环境,那这个编译环境在实际运行时是没有任务作用的,编译完成后,那些源码和编译器已经没有任务用处了也就没必要留在镜像里。

REPOSITORY TAG IMAGE ID CREATED SIZE
hello latest b9dbcf4999414 5 minutes ago 312MB

上表可以看到,单文件构建最终占用了312MB的空间。

3.2 多文件构建

在多阶构建出来之前有没有好的解决方案呢?有,比如采用多文件构建或在构建服务器上安装编译器,不过在构建服务器上安装编译器这种方法我们就不推荐了,因为在构建服务器上安装编译器会导致构建服务器变得非常臃肿,需要适配各个语言多个版本、依赖,容易出错,维护成本高。所以我们只介绍多文件构建的方式。

多文件构建,其实就是使用多个Dockerfile,然后通过脚本将它们进行组合。假设有三个文件分别是:Dockerfile.run、Dockerfile.build、build.sh。

  • Dockerfile.run就是运行时程序所必须需要的一些组件的Dockerfile,它包含了最精简的库;
  • Dockerfile.build只是用来构建,构建完就没用了;
  • build.sh的功能就是将Dockerfile.run和Dockerfile.build进行组成,把Dockerfile.build构建好的东西拿出来,然后再执行Dockerfile.run,算是一个调度的角色。

Dockerfile.build

1
2
3
4
5
6
7
FROM golang:1.11.4-alpine3.8 AS build-env
ENV GO111MODULE=off
ENV GO15VENDOREXPERIMENT=1
ENV BUILDPATH=github.com/lattecake/hello
RUN mkdir -p /go/src/${BUILDPATH}
COPY ./ /go/src/${BUILDPATH}
RUN cd /go/src/${BUILDPATH} && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go install –v

Dockerfile.run

1
2
3
4
5
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root
ADD hello .
CMD ["./hello"]

Build.sh

1
2
3
4
5
6
7
#!/bin/sh
docker build -t --rm hello:build . -f Dockerfile.build
docker create --name extract hello:build
docker cp extract:/go/bin/hello ./hello
docker rm -f extract
docker build --no-cache -t --rm hello:run . -f Dockerfile.run
rm -rf ./hello

执行build.sh完成项目的构建。

REPOSITORY TAG IMAGE ID CREATED SIZE
hello - b9dbcf4999414 5 minutes ago 312MB
hello2 latest e5193fe01bbc 22 seconds ago 7.23MB

从上表可以看到,多文件构建大大减小了镜像的占用空间,但它有三个文件需要管理,维护成本也更高一些。

3.3 多阶构建

最后我们来看看万众期待的多阶构建。

完成多阶段构建我们只需要在Dockerfile中多次使用FORM声明,每次FROM指令可以使用不同的基础镜像,并且每次FROM指令都会开始新的构建,我们可以选择将一个阶段的构建结果复制到另一个阶段,在最终的镜像中只会留下最后一次构建的结果,这样就可以很容易地解决前面提到的问题,并且只需要编写一个Dockerfile文件。这里值得注意的是:需要确保Docker的版本在17.05及以上。下面我们来说说具体操作。

在Dockerfile里可以使用as来为某一阶段取一个别名”build-env”:

1
FROM golang:1.11.2-alpine3.8 AS build-env

然后从上一阶段的镜像中复制文件,也可以复制任意镜像中的文件:

1
COPY --from=build-env /go/bin/hello /usr/bin/hello

看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM golang:1.11.4-alpine3.8 AS build-env

ENV GO111MODULE=off
ENV GO15VENDOREXPERIMENT=1
ENV GITPATH=github.com/lattecake/hello
RUN mkdir -p /go/src/${GITPATH}
COPY ./ /go/src/${GITPATH}
RUN cd /go/src/${GITPATH} && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go install -v

FROM alpine:latest
ENV apk –no-cache add ca-certificates
COPY --from=build-env /go/bin/hello /root/hello
WORKDIR /root
CMD ["/root/hello"]

执行docker build -t –rm hello3 .后再执行docker images ,然后我们来看镜像的大小:

REPOSITORY TAG IMAGE ID CREATED SIZE
hello - b9dbcf4999414 5 minutes ago 312MB
hello2 - e5193fe01bbc 22 seconds ago 7.23MB
hello3 latest 61b39840fad1 8 seconds ago 7.2MB

多阶构建给我们带来很多便利,最大的优势是在保证运行镜像足够小的情况下还减轻了Dockerfile的维护负担,因此我们极力推荐使用多阶构建来将你的代码打包成Docker 镜像。