「Dockerのイメージサイズが減らない、何故だろう？」を追ってみた。

## はじめに
Dockerのイメージサイズに関して一つ勘違いしていたことがあったので、それについて書いておこうと思う。例として、Go言語で書いたプログラムでRedisを叩くことを考える。

(2017/11/18追記)
ここでの問題を解決するには、Docker 17.05から追加された [マルチステージビルド](
https://docs.docker.com/engine/userguide/eng-image/multistage-build/)を使うのが正解みたいです（@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も含めて今回の題材は[ここ](https://github.com/ksato9700/go-redis-test)にあげてあります。

## 題材の説明
二つのコンテナを用意する。一つはredisを動かし、もう一つはそれにアクセスするプログラムが走る。まず、`docker-compose.yml`はこんな感じ。

```docker-compose.yml
version: "3"
services:
  redis:
    image: redis:latest

  goclient:
    build:
      context: ./gclient
    links:
      - redis
```

で、`./gclient/`の下にはGoのソースと`Dockerfile`が入っているわけだが、それぞれこんな感じ。

```gclient/main.go
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
}
```

```gclient/Dockerfile
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](https://github.com/go-redis/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`で本体をコンパイル。
* コンパイルに必要だったパッケージをアンインストールしてキャッシュも全消去。
* で、コンパイルしたバイナリを実行

実際にビルド&実行してみる

```console:
$ 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で実行形式を作ってくれるっぽい。以下で確認。

```console:
$ 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以下にインストールされたライブラリも消してしまって大丈夫。

## イメージのサイズを確認してみよう

不要なものはがっつり消しているのでかなりイメージサイズが小さくなっているだろうなと期待して確認してみる。

```console:
$ docker images goredistest_goclient
REPOSITORY             TAG                 IMAGE ID            CREATED             SIZE
goredistest_goclient   latest              4bfef36a6d56        15 minutes ago      313MB
```

**「え" 300MB?」**

思わず、変な声でました。

なにか消し忘れているかなと思いながら、中味を確認してみると、

```console:
$ 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コマンドで確認してみる。

```console:
$ 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`ステップで行っている。

これで再ビルドしてサイズを見てみる

```console:
$ docker images goredistest_goclient
REPOSITORY             TAG                 IMAGE ID            CREATED             SIZE
goredistest_goclient   latest              869660660f23        30 minutes ago      10MB
```

おー、ほぼ中味と一緒のサイズ。これでようやく納得。

## 最後に

とは言え、パッケージのインストールから毎回やっていたら遅くて仕方ない。階層に分けることで変っていないステップはキャッシュが効いてビルドが早く済むという利点があるのでステップをまとめるのも実は良し悪しがある。

たぶん、開発時とかには時間のかかる処理は別ステップにしてビルドの高速化の恩恵を受け、実際にデプロイする段になったらまとめるとかすると良いのかな。その変更でエンバグしてしまったら元も子もないので自動でできると最高だが、コンテナやイメージのサイズが気になるプロジェクトでは人手で手間をかけても良いのかもしれない。

それから、そもそもビルドをコンテナの中でしなくて良いという考え方もあるのかな。コンパイル自体は別コンテナでしておいて、実際に稼働させるコンテナは他でできたバイナリをコピーして動かすだけ。構成管理が難しくなる気がするがやってできないことはないかな。