はじめに
Dockerのイメージサイズに関して一つ勘違いしていたことがあったので、それについて書いておこうと思う。例として、Go言語で書いたプログラムでRedisを叩くことを考える。
(2017/11/18追記)
ここでの問題を解決するには、Docker 17.05から追加された マルチステージビルドを使うのが正解みたいです(@AkihiroSudaさん、ありがとうございます)。
マルチビルドしたイメージのhistoryを取ってみるとこんな感じ。2回目の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
に書いたようにLinkで結びつけているので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はほぼほぼstatic linkで実行形式を作ってくれるっぽい。以下で確認。
$ 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)
dynamic linkしているのは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"]
やっていることは基本的に同じだが順番を入れ替え、そしてパッケージのインストール、コンパイル、そしてパッケージのアンインストールとキャッシュの削除を一つのRUN
ステップで行っている。
これで再ビルドしてサイズを見てみる
$ docker images goredistest_goclient
REPOSITORY TAG IMAGE ID CREATED SIZE
goredistest_goclient latest 869660660f23 30 minutes ago 10MB
おー、ほぼ中味と一緒のサイズ。これでようやく納得。
最後に
とは言え、パッケージのインストールから毎回やっていたら遅くて仕方ない。階層に分けることで変っていないステップはキャッシュが効いてビルドが早く済むという利点があるのでステップをまとめるのも実は良し悪しがある。
たぶん、開発時とかには時間のかかる処理は別ステップにしてビルドの高速化の恩恵を受け、実際にデプロイする段になったらまとめるとかすると良いのかな。その変更でエンバグしてしまったら元も子もないので自動でできると最高だが、コンテナやイメージのサイズが気になるプロジェクトでは人手で手間をかけても良いのかもしれない。
それから、そもそもビルドをコンテナの中でしなくて良いという考え方もあるのかな。コンパイル自体は別コンテナでしておいて、実際に稼働させるコンテナは他でできたバイナリをコピーして動かすだけ。構成管理が難しくなる気がするがやってできないことはないかな。