我追踪了一下为什么Docker镜像的大小没有减小
首先
我之前对Docker镜像大小有一个误解,所以我想写一下关于这个问题。举个例子,考虑使用Go语言编写的程序来访问Redis。
解决这个问题的正确方法似乎是使用从Docker 17.05开始添加的多阶段构建(谢谢AkihiroSuda)。
尝试获取Multi-build的图像历史,情况如下。在第二个FROM之前的部分完全消失了,感觉好像是从某个地方复制了可执行二进制文件过来。
$ docker history goredistest_goclient_msb
IMAGE CREATED CREATED BY SIZE COMMENT
d66839f3edc3 25 minutes ago /bin/sh -c #(nop) CMD ["./gclient"] 0B
bac5f184aa19 25 minutes ago /bin/sh -c #(nop) COPY file:5a43c9652e61e9... 6.03MB
053cde6e8953 2 weeks ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 2 weeks ago /bin/sh -c #(nop) ADD file:1e87ff33d1b6765... 3.97MB
本次主题包括了包括多个构建的Dockerfile在内的内容。
题材说明
准备两个容器。一个是运行Redis的容器,另一个是运行访问该容器的程序的容器。首先,docker-compose.yml文件如下所示。
version: "3"
services:
redis:
image: redis:latest
goclient:
build:
context: ./gclient
links:
- redis
所以,在./gclient目录下放有Go源文件和Dockerfile,分别如下所示。
package main
import "fmt"
import "github.com/go-redis/redis"
func main() {
var err error
client := redis.NewClient(&redis.Options{
Addr: "redis:6379",
Password: "", // no password set
DB: 0, // use default DB
})
pong, err := client.Ping().Result()
fmt.Println(pong, err)
err = client.Set("key", "value", 0).Err()
if err != nil {
panic(err)
}
val, err := client.Get("key").Result()
if err != nil {
panic(err)
}
fmt.Println("key", val)
val2, err := client.Get("key2").Result()
if err == redis.Nil {
fmt.Println("key2 does not exists")
} else if err != nil {
panic(err)
} else {
fmt.Println("key2", val2)
}
// Output: key value
// key2 does not exists
}
FROM alpine:3.6
MAINTAINER Kenichi Sato <ksato9700@gmail.com>
RUN apk --update add go git musl-dev
RUN mkdir /gclient
COPY main.go /gclient
WORKDIR /gclient
RUN go get -u github.com/go-redis/redis
RUN go build
RUN apk del go git musl-dev && \
rm -rf ~/go /var/cache/apk/*
CMD ["./gclient"]
Go的源代码基本上就是go-redis的示例,只是根据docker-compose.yml中的写法将其链接到了redis,将redis的主机名修改为了”redis”。而在Dockerfile中,则采用了以下方式进行处理。
-
- 最小構成が4MBくらいしかないAlpine Linuxをベースに。
-
- プログラムをコンパイルするのに必要なgoとmusl-dev(Alpineで採用しているlibcであるmuslの開発ライブラリ)、そしてredisにアクセスするライブラリ(go-redis)を引っ張ってくるのに必要なgitをインストール。
go getでライブラリをインストールしてからgo buildで本体をコンパイル。
コンパイルに必要だったパッケージをアンインストールしてキャッシュも全消去。
で、コンパイルしたバイナリを実行
试着构建并执行一下,以实际验证。
$ docker-compose build
$ docker-compuse up
Starting goredistest_redis_1 ...
Starting goredistest_redis_1 ... done
Starting goredistest_goclient_1 ...
Starting goredistest_goclient_1 ... done
Attaching to goredistest_redis_1, goredistest_goclient_1
redis_1 | 1:C 16 Nov 12:33:54.184 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
redis_1 | 1:C 16 Nov 12:33:54.184 # Redis version=4.0.2, bits=64, commit=00000000, modified=0, pid=1, just started
redis_1 | 1:C 16 Nov 12:33:54.184 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
redis_1 | 1:M 16 Nov 12:33:54.186 * Running mode=standalone, port=6379.
goclient_1 | PONG <nil>
goclient_1 | key value
goclient_1 | key2 does not exists
显示为goclient_1的是访问Redis的客户端。但是结果与预期一致。奇怪的是,即使在编译后卸载了Go包,它仍然能够正常运行。看起来Go几乎是通过静态链接来创建可执行文件的。下面进行确认。
$ docker run -it --rm goredistest_goclient ldd ./gclient
/lib/ld-musl-x86_64.so.1 (0x7f0db59a2000)
libc.musl-x86_64.so.1 => /lib/ld-musl-x86_64.so.1 (0x7f0db59a2000)
只有musl(libc)是动态链接的。所以可以删除go包和安装在~/go目录下的库也没问题。
イメージのサイズを確認してみよう
不要なものはがっつり消しているのでかなりイメージサイズが小さくなっているだろうなと期待して確認してみる。
$ docker images goredistest_goclient
REPOSITORY TAG IMAGE ID CREATED SIZE
goredistest_goclient latest 4bfef36a6d56 15 minutes ago 313MB
「哇,300MB?」
下意识地,我不由自主地突然发出了奇怪的声音。
当我心里想着是否忘记了什么东西时,我查看了里面的内容。
$ docker run -it --rm goredistest_goclient du / | sort -n | tail
72 /usr/share
72 /var
180 /usr/bin
220 /sbin
280 /usr
292 /etc
812 /bin
2668 /lib
5904 /gclient
10292 /
哎呀,只有10MB。
所以,仔细想一想,Docker引入了层次化的文件系统,在每个构建步骤上一层层地堆叠。尝试使用history命令进行确认。
$ docker history goredistest_goclient
IMAGE CREATED CREATED BY SIZE COMMENT
4bfef36a6d56 2 hours ago /bin/sh -c #(nop) CMD ["./gclient"] 0B
fa3fdb7464df 2 hours ago /bin/sh -c apk del go git musl-dev && ... 18.7kB
8b97eb8df841 47 hours ago /bin/sh -c go build 6.03MB
8d91581ab916 47 hours ago /bin/sh -c go get -u github.com/go-redis/r... 5.09MB
b1f5b21016b5 47 hours ago /bin/sh -c #(nop) WORKDIR /gclient 0B
3e5acd08fa48 47 hours ago /bin/sh -c #(nop) COPY file:4d3c94ee002426... 704B
d6f9fc3c5595 47 hours ago /bin/sh -c mkdir /gclient 0B
b27657866a42 47 hours ago /bin/sh -c apk --update add go git musl-dev 298MB
1cb4b532ed14 2 days ago /bin/sh -c #(nop) MAINTAINER Kenichi Sato... 0B
053cde6e8953 12 days ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 12 days ago /bin/sh -c #(nop) ADD file:1e87ff33d1b6765... 3.97MB
果然。无论是通过apk add安装包的方式,还是容量都会大幅增加。每添加一个层次,都会被覆盖,也就是说只是追加而已。所以,即使在下一个步骤中删除了已经堆叠的层次,镜像的大小也不会减小。
借鉴以上的基础上,尝试重新编写Dockerfile。
FROM alpine:3.6
MAINTAINER Kenichi Sato <ksato9700@gmail.com>
RUN mkdir /gclient
COPY main.go /gclient
WORKDIR /gclient
RUN apk --update add go git musl-dev && \
go get -u github.com/go-redis/redis && \
go build && \
apk del go git musl-dev && \
rm -rf ~/go /var/cache/apk/*
CMD ["./gclient"]
做的事情基本上是一样的,但是顺序被调换了,并且安装包、编译以及卸载包和清除缓存被在一个运行步骤中完成。
我用这个版本重新构建了一下,看一下大小。
$ docker images goredistest_goclient
REPOSITORY TAG IMAGE ID CREATED SIZE
goredistest_goclient latest 869660660f23 30 minutes ago 10MB
哦,几乎就是和内容一样的大小。现在总算满意了。
最后
话虽如此,如果每次都从安装软件包开始操作,那肯定会很慢。但是,将操作步骤分成层级,有利于缓存和加速构建,这也意味着将步骤合并在一起实际上有利有弊。
也许,在开发时,对于耗时的处理可以分别放在另一个步骤中,以便享受构建加速的好处,当到达实际部署阶段时再汇总处理可能是一个好方法。如果在这个改变过程中发生了错误,那就是得不偿失,所以最好能够自动完成,但是对于关注容器或镜像大小的项目来说,即使需要人工耗费一些时间也是可行的。
然后,也许可以考虑在容器中不需要构建整个构建过程。 将编译本身放在另一个容器中进行,然后只需复制在其他地方构建的二进制文件并运行即可。 尽管配置管理可能会变得更加复杂,但我认为这是可行的。