## 追求极简:Docker 镜像构建演化史
文/白明
>对于已经接纳和使用 Docker 技术在日常开发工作中的开发者而言,构建 Docker 镜像已经是家常便饭。但如何更高效地构建以及构建出 Size 更小的镜像却是很多 Docker 技术初学者心中常见的疑问,甚至是一些老手都未曾细致考量过的问题。本文将从一个 Docker 用户角度来阐述 Docker 镜像构建的演化史,希望能起到一定的解惑作用。
自从2013年 dotCloud 公司(现已改名为 Docker Inc)发布 Docker 容器技术以来,到目前为止已经有四年多的时间了。这期间 Docker 技术飞速发展,并催生出一个生机勃勃的、以轻量级容器技术为基础的庞大的容器平台生态圈。作为 Docker 三大核心技术之一的镜像技术在 Docker 的快速发展之路上可谓功不可没:镜像让容器真正插上了翅膀,实现了容器自身的重用和标准化传播,使得开发、交付、运维流水线上的各个角色真正围绕同一交付物,“test what you write, ship what you test”成为现实。
### 镜像:继承中的创新
谈镜像构建之前,我们先来简要说一下镜像。
Docker 技术从本质上说并不是一种新技术,而是将已有技术进行了更好地整合和包装。内核容器技术以一种完整形态最早出现在 Sun 公司的 Solaris 操作系统上,Solaris 是当时最先进的服务器操作系统。2005年 Sun 发布了 Solaris Container 技术,从此开启了内核容器之门。
2008年,以 Google 公司开发人员为主导实现的 Linux Container(即 LXC)功能在被 merge 到 Linux 内核中。LXC 是一种内核级虚拟化技术,主要基于 Namespaces 和 Cgroups 技术,实现共享一个操作系统内核前提下的进程资源隔离,为进程提供独立的虚拟执行环境,这样的一个虚拟的执行环境就是一个容器。本质上说,LXC 容器与现在的 Docker 所提供容器是一样的。 Docker 也是基于 Namespaces 和 Cgroups 技术之上实现的。但 Docker 的创新之处在于其基于 Union File System 技术定义了一套容器打包规范,真正将容器中的应用及其运行的所有依赖都封装到一种特定格式的文件中去,而这种文件就被称为镜像(即image),原理见图1(引自 Docker 官网)。
图1: Docker 镜像原理
镜像是容器的“序列化”标准,这一创新为容器的存储、重用和传输奠定了基础,并且容器镜像“坐上了巨轮”传播到世界每一个角落,助力了容器技术的飞速发展。
与 Solaris Container、LXC 等早期内核容器技术不同, Docker 还为开发者提供了开发者体验良好的工具集,这其中就包括了用于镜像构建的 Docker file 以及一种用于编写 Docker fil 的领域特定语言。采用 Docker file 方式构建成为镜像构建的标准方法,其可重复、可自动化、可维护以及分层精确控制等特点是采用传统采用 Docker commit 命令提交的镜像所不能比拟的。
### “镜像是个筐”:初学者的认知
“镜像是个筐,什么都往里面装”这句俏皮话可能是大部分 Docker 初学者对镜像最初认知的真实写照。这里我们用一个例子来生动地展示一下。
我们现在将 httpserver.go 这个源文件编译为 httpd 程序并通过镜像发布。源文件的内容如下:
```
//httpserver.go
package main
import (
"fmt"
"net/http"
)
func main() {
fmt.Println("http daemon start")
fmt.Println(" -> listen on port:8080")
http.ListenAndServe(":8080", nil)
}
```
接下来,我们来编写用于构建目标镜像的 Docker file:
```
// Dockerfile
From ubuntu:14.04
RUN apt-get update \
&& apt-get install -y software-properties-common \
&& add-apt-repository ppa:gophers/archive \
&& apt-get update \
&& apt-get install -y golang-1.9-go \
git \
&& rm -rf /var/lib/apt/lists/*
ENV GOPATH /root/go
ENV GOROOT /usr/lib/go-1.9
ENV PATH="/usr/lib/go-1.9/bin:${PATH}"
COPY ./httpserver.go /root/httpserver.go
RUN go build -o /root/httpd /root/httpserver.go \
&& chmod +x /root/httpd
WORKDIR /root
ENTRYPOINT ["/root/httpd"]
```
执行镜像构建:
```
# docker build -t repodemo/httpd:latest .
//...构建输出这里省略...
# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd latest 183dbef8eba6 2 minutes ago 550MB
ubuntu 14.04 dea1945146b9 2 months ago 188MB
```
整个镜像的构建过程因环境而定。如果您的网络速度一般,这个构建过程可能会花费你10多分钟甚至更多。最终如我们所愿,基于 repodemo/httpd:latest 这个镜像的容器可以正常运行:
```
# docker run repodemo/httpd
http daemon start
-> listen on port:8080
```
一个 Docker file 产出一个镜像。Docker file 由若干 Command 组成,每个 Command 执行结果都会单独形成一个层(layer)。我们来探索一下构建出来的镜像:
```
# docker history 183dbef8eba6
IMAGE CREATED CREATED BY SIZE COMMENT
183dbef8eba6 21 minutes ago /bin/sh -c #(nop) ENTRYPOINT ["/root/httpd"] 0B
27aa721c6f6b 21 minutes ago /bin/sh -c #(nop) WORKDIR /root 0B
a9d968c704f7 21 minutes ago /bin/sh -c go build -o /root/httpd /root/h... 6.14MB
... ...
aef7700a9036 30 minutes ago /bin/sh -c apt-get update && apt-get... 356MB
.... ...
2 months ago /bin/sh -c #(nop) ADD file:8f997234193c2f5... 188MB
```
我们去除掉那些 Size为0 或很小的 layer,我们看到三个 size 占比较大的 layer,见下图:
图2: Docker 镜像分层探索
虽然 Docker 引擎利用缓存机制可以让同主机下非首次的镜像构建执行得很快,但是在 Docker 技术热情催化下的这种构建思路让 Docker 镜像在存储和传输方面的优势荡然无存,要知道一个 Ubuntu-server 16.04的虚拟机 ISO 文件的大小也就不过600多 MB 而已。
### “理性的回归”:builder 模式的崛起
Docker 使用者在新技术接触初期的热情“冷却”之后迎来了“理性的回归”。根据上面分层镜像的图示,我们发现最终镜像中包含构建环境是多余的,我们只需要在最终镜像中包含足够支撑 httpd 运行的运行环境即可,而 base image 自身就可以满足。于是我们应该剔除不必要的中间层:
图3:去除不必要的分层
现在问题来了!如果不在同一镜像中完成应用构建,那么在哪里、由谁来构建应用呢?至少有两种方法:
- 在本地构建并 COPY 到镜像中;
- 借助构建者镜像(builder image)构建。
不过方法1本地构建有很多局限性,比如:本地环境无法复用、无法很好融入持续集成/持续交付流水线等。而借助 builder image 进行构建已经成为 Docker 社区的一个最佳实践,Docker 官方为此也推出了各种主流编程语言的官方 base image,包括 go、java、nodejs、python 以及 ruby 的等。借助 builder image 进行镜像构建的流程原理如图4。
图4:借助 builder image 进行镜像构建的流程图
通过原理图,我们可以看到整个目标镜像的构建被分为了两个阶段:
- 第一阶段:构建负责编译源码的构建者镜像;
- 第二阶段:将第一阶段的输出作为输入,构建出最终的目标镜像。
我们选择 golang:1.9.2 作为 builder base image,构建者镜像的 Docker file.build 如下:
```
// Dockerfile.build
FROM golang:1.9.2
WORKDIR /go/src
COPY ./httpserver.go .
RUN go build -o httpd ./httpserver.go
```
执行构建:
```
# docker build -t repodemo/httpd-builder:latest -f Dockerfile.build .
```
构建好的应用程序 httpd 放在了镜像 repodemo/httpd-builder 中的 /go/src 目录下,我们需要一些“胶水”命令来连接两个构建阶段,这些命令将 httpd 从构建者镜像中取出并作为下一阶段构建的输入:
```
# docker create --name extract-httpserver repodemo/httpd-builder
# docker cp extract-httpserver:/go/src/httpd ./httpd
# docker rm -f extract-httpserver
# docker rmi repodemo/httpd-builder
```
通过上面的命令,我们将编译好的 httpd 程序拷贝到了本地。下面是目标镜像的 Docker file:
```
// Dockerfile.target
From ubuntu:14.04
COPY ./httpd /root/httpd
RUN chmod +x /root/httpd
WORKDIR /root
ENTRYPOINT ["/root/httpd"]
```
接下来我们来构建目标镜像:
```
```
我们来看看这个镜像的“体格”:
```
# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd latest e3d009d6e919 12 seconds ago 200MB
```
200MB,目标镜像的 Size 降为原来的1/2还多。
### “像赛车那样减去所有不必要的东西”:追求最小镜像
前面我们构建出的镜像的 Size 已经缩小到 200MB,但这还不够。200MB 的“体格”在我们的网络环境下缓存和传输仍然很难令人满意。我们要为镜像进一步减重,减到尽可能的小,就像赛车那样,为了能减轻重量将所有不必要的东西都拆除掉:我们仅保留能支撑我们的应用运行的必要库、命令,其余的一律不纳入目标镜像。当然不仅仅是 Size 上的原因,小镜像还有额外的好处,比如:内存占用小,启动速度快,更加高效;不会因其他不必要的工具、库的漏洞而被攻击,减少了“攻击面”,更加安全等。
图5 目标镜像还能更小些么
一般应用开发者不会从 scratch 镜像从头构建自己的 base image 以及目标镜像的,开发者会挑选适合的 base image。一些“蝇量级”甚至是“草量级”的官方 base image 的出现为这种情况提供了条件。
从图6看,我们可以有两个选择:busybox 和 alpine。
图6 一些 base image 的 Size 比较(来自 imagelayers.io 截图)
单从镜像的 size 上来说,busybox 更小。不过 busybox 默认的 libc 实现是 uClibc,而我们通常运行环境使用的 libc 实现都是 glibc,因此我们要么选择静态编译程序,要么使用 busybox:glibc 镜像作为 base image。
而 alpine image 是另外一种蝇量级 base image,它使用了比 glibc 更小更安全的 musl libc 库。不过和 busybox image 相比,alpine image 体积还是略大。除了因为 musl 比 uClibc 大一些之外,alpine 还在镜像中添加了自己的包管理系统 apk,开发者可以使用 apk 在基于 alpine 的镜像中添加需要的包或工具。因此,对于普通开发者而言,alpine image 是更佳的选择。不过 alpine 使用的 libc 实现为 musl,与基于 glibc 上编译出来的应用程序并不兼容。如果直接将前面构建出的 httpd 应用塞入 alpine,在容器启动时会遇到下面错误,因为加载器找不到 glibc 这个动态共享库文件:
standard_init_linux.go:185: exec user process caused "no such file or directory"
对于 Go 应用来说,我们可以采用静态编译的程序,但一旦采用静态编译,也就意味着我们将失去一些 libc 提供的原生能力,比如:在 linux 上,你无法使用系统提供的 DNS 解析能力,只能使用 Go 自实现的 DNS 解析器。
我们还可以采用基于 alpine 的 builder image,golang base image 就提供了 alpine 版本。接下来,我们就用这种方式构建出一个基于 alpine base image 的极小目标镜像。
我们新建两个用于 alpine 版本目标镜像构建的 Docker file: Docker file.build.alpine 和 Docker file.target.alpine:
```
//Dockerfile.build.alpine
FROM golang:alpine
WORKDIR /go/src
COPY ./httpserver.go .
RUN go build -o httpd ./httpserver.go
// Dockerfile.target.alpine
From alpine
COPY ./httpd /root/httpd
RUN chmod +x /root/httpd
WORKDIR /root
ENTRYPOINT ["/root/httpd"]
```
构建 builder 镜像:
```
http://ipad-cms.csdn.net/cms/article/code/3949
```
执行“胶水”命令:
```
# docker create --name extract-httpserver repodemo/httpd-alpine-builder
# docker cp extract-httpserver:/go/src/httpd ./httpd
# docker rm -f extract-httpserver
# docker rmi repodemo/httpd-alpine-builder
```
构建目标镜像:
```
# docker build -t repodemo/httpd-alpine -f Dockerfile.target.alpine .
# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd-alpine latest 895de7f785dd 13 seconds ago 16.2MB
```
16.2MB,目标镜像的 Size 降为不到原来的十分之一,我们得到了预期的结果。
### “要有光,于是便有了光”:对多阶段构建的支持
至此,虽然我们实现了目标 Image 的最小化,但是整个构建过程却是十分繁琐,我们需要准备两个 Docker file、需要准备“胶水”命令、需要清理中间产物等。作为 Docker 用户,我们希望用一个 Docker file就能解决所有问题,于是就有了 Docker 引擎对多阶段构建(multi-stage build)的支持。注意:这个特性非常新,只有 Docker 17.05.0-ce 及以后的版本才能支持。
现在我们就按照“多阶段构建”的语法将上面的 Docker file.build.alpine 和 Docker file.target.alpine 合并到一个 Docker file 中:
```
//Dockerfile
FROM golang:alpine as builder
WORKDIR /go/src
COPY httpserver.go .
RUN go build -o httpd ./httpserver.go
From alpine:latest
WORKDIR /root/
COPY --from=builder /go/src/httpd .
RUN chmod +x /root/httpd
ENTRYPOINT ["/root/httpd"]
```
Docker file 的语法还是很简明和易理解的,即使是你第一次看到这个语法也能大致猜出六成含义。与之前 Dockefile 最大的不同在于在支持多阶段构建的 Docker file 中我们可以写多个“From baseimage”的语句,每个 From 语句开启一个构建阶段,并且可以通过“as”语法为此阶段构建命名(比如这里的 builder)。我们还可以通过 COPY 命令在两个阶段构建产物之间传递数据,比如这里的传递的 httpd 应用,这个工作之前我们是使用“胶水”代码完成的。
构建目标镜像:
```
# docker build -t repodemo/httpd-multi-stage .
# docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
repodemo/httpd-multi-stage latest 35e494aa5c6f 2 minutes ago 16.2MB
```
我们看到通过多阶段构建特性构建的 Docker Image 与我们之前通过 builder 模式构建的镜像在效果上是等价的。
### 来到现实
沿着时间的轨迹,Docker 镜像构建走到了今天。追求又快又小的镜像已成为了 Docker 社区的共识。社区在自创 builder 镜像构建的最佳实践后终于迎来了多阶段构建这柄利器,从此构建出极简的镜像将不再困难。