oshiro の日記

I love good unittest, low level programing and nice team

stylish な dockerfile にしよう

この記事は Docker Advent Calendar 20日目の記事になります.
Advent Calendar もいよいよ終盤ですが, 皆様の docker life の一助になれば幸いです.

dockerfile が stylish でなければいけないわけ

まず docker image を何故使用するのかという話なんですが, 基本的にはイメージを使ってビルドや CI を回したいからですよね.
すると docker pull に時間かけるのって凄いもったいないんです.
アプリで何とかしてテストケース絞ったり並列化したりして実行時間を減らそうとしてるのに, コンテナビルドでその時間を食い潰したくないわけですね.

じゃあどうやってビルドを高速化するかということなんですが, もちろん方法はいろいろあります.
今回は dockerfile をダイエットさせて高速化を図ろうという企画です.

予め期待感を明らかにしておくと, 超高速化したり10GB のイメージを100MBにするような銀の弾丸を紹介することはできません.
しかし, 1 コンテナのビルドを 30 秒高速化したとして, 10 コンテナあればそれは5分の改善になります.
小さな改善をたくさん行うことが重要だという思想で本記事を執筆しています.

let's breaking 2 !!

docker コンテナが構築される仕組み(essential ver)

まずは簡単に docker image の仕組みを説明します.
結論を言うと, docker image は複数のレイヤー(イメージ)が重なって構成されています. docker pull をすると

$ docker pull ubuntu
Using default tag: latest
latest: Pulling from library/ubuntu
7ddbc47eeb70: Already exists
c1bbdc448b72: Already exists
8c3b70e39044: Already exists
45d437916d57: Already exists
Digest: sha256:6e9f67fa63b0323e9a1e587fd71c561ba48a034504fb804fd26fd8800039835d
Status: Downloaded newer image for ubuntu:latest
docker.io/library/ubuntu:latest

ubuntu のイメージを pull すると, この時4つのイメージ(7ddbc47eeb70 c1bbdc448b72 8c3b70e39044 45d437916d57)がpull されていることが分かります. これを docker history で見てみると下記のように各コマンドを実行した形跡(レイヤー)が残っています.

$ docker history ubuntu
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
775349758637        6 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B
<missing>           6 weeks ago         /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B
<missing>           6 weeks ago         /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B
<missing>           6 weeks ago         /bin/sh -c [ -z "$(apt-get indextargets)" ]     987kB
<missing>           6 weeks ago         /bin/sh -c #(nop) ADD file:a48a5dc1b9dbfc632…   63.2MB

中間イメージはタグが切られているので, missing になってしまっていますが, ubuntu の公式イメージが上記で pull してきたであろう 4 イメージと CMD による 実行レイヤーによって構成されているであろうことがみてとれます.
dockerfile でいうと dockefile 内に書かれた命令一つ一つが新しいイメージのレイヤーを生成することになります.
新しいレイヤーはコンテナを起動する際にも作成されます. この新しいレイヤーはそれまでのレイヤーのイメージを使い Dockerfile の命令を実行して新しいイメージでコンテナを起動する際ことで生成されます.
そして基本的には中間コンテナを破棄して最終的な静的コンテナを生成します. つまり我々が docker pull なり build なりをして得ているものはこの最終生成されたイメージに過ぎないが, その過程ではレイヤー数分の中間コンテナやイメージを作成したりプルしたりしているということです.

軽量な dockerfile はきれいとは限らない

装飾を捨てる

そして, docker に過剰なパワーは必要ありません.
Base イメージを見直して見ましょう.
結論を先に述べると, alpine linux を使うと幸せになれる確率は高いです. 以下に主な Base イメージのサイズを示します.

image time size
ubuntu 7.5s 64.2MB
debian 10s 114MB
fedora 12s 194MB
alpine 4.8s 5.55MB

やはり alpine が劇的に高速 & 軽量ですね. alpine は非常に軽量ではありますが, apk というパッケージマネージャーを持っているので, apt ほどパワフルではないですがパッケージを使用することも可能です. 詳細を知りたい方はぜひ調べてみてください.

これは公式イメージにも当てはまります. 例えば ruby では 無印の rubydebian ベースで作成されていますが, ruby:alpine を使用したものも配布されています.

image time size
ruby:2.6 39s 840MB
ruby:2.6-alpine 7.8s 50.9MB
python:3.7 40s 917MB
python:3.7-alpine 8.1s 98.4MB

これを全て実行するとわかるんですが, pythonruby の公式イメージの最初の 5 レイヤーは同じレイヤーを使用しており, ここが 30s 以上使用しています.

これはあくまで運用のコンテキストでのお話です.
開発環境を docker で提供している場合には, alpine ではパワー不足に陥る可能性もあります. ちょっとリッチなデバッグツールや測定ツールを使いたいとなった際に apk で提供されていなかったり, 最新バージョンの配布がされておらず, ソースからコンパイルしてインストールする必要性に迫られる可能性は結構あります.

筆者は開発環境ごと docker で提供していて ubuntu ベースのコンテナもあります.
筆者の入社前から稼働しているコンテナなどは ubuntu ベース ですが, 特に alpine に置き換えて困ることもなさそうです.
ある程度成熟してるコンテナだとリプレースコストに成果が見合わないかも知れません.

レイヤを結合する

上で説明したように, docker イメージはレイヤー構造をもっています.
そしてレイヤー自体もイメージなので, レイヤーを減らすとサイズを小さくすることができます.
例えば RUN を一つにまとめることができます.

RUN apt update \
  && apt install -y \
    git \
    curl \
    gcc

これだと5つのコマンドを実行していますが, 生成されるレイヤーは一つだけになります.

そして, curl や git などはパッケージやプロジェクトのソースを取得するのには必要だが, その後の運用には必要ないので削除したい.
しかし, 普通に削除するだけでは軽量化にはならないことに気を付けなければいけません.
このデモを書くために ubuntu でビルドしてるんですけど, 2回くらい apt update してもう辟易しています.
キャッシュを使わずに使い捨てる環境であれば, 重いイメージ使うのはやめましょう、、、

例えば下記の dockerfile を比べてみます.
10MB のファイルを削除した分軽量化できていそうです.

# version1
FROM ubuntu:latest

RUN dd if=/dev/zero of=/10megafile bs=1M count=10 && rm /10megafile

# version2
FROM ubuntu:latest

RUN dd if=/dev/zero of=/10megafile bs=1M count=10
RUN rm /10megafile
$ docker images
REPOSITORY     SIZE
ver1              64 MB
ver2              74 MB

どっちもファイルを削除していますが, ver2 の方がちょうど10MB大きくなっています.
更にレイヤーを調べてみます.

$ docker history ver1
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
5ff21765e225        2 minutes ago       /bin/sh -c dd if=/dev/zero of=/10megafile bs…   0B
775349758637        6 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B
<missing>           6 weeks ago         /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B
<missing>           6 weeks ago         /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B
<missing>           6 weeks ago         /bin/sh -c [ -z "$(apt-get indextargets)" ]     987kB
<missing>           6 weeks ago         /bin/sh -c #(nop) ADD file:a48a5dc1b9dbfc632…   63.2MB

$ docker history ver2
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
7dca59e53772        3 minutes ago       /bin/sh -c rm /10megafile                       0B
58645d76c7a7        3 minutes ago       /bin/sh -c dd if=/dev/zero of=/10megafile bs…   10.5MB
775349758637        6 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/bash"]            0B
<missing>           6 weeks ago         /bin/sh -c mkdir -p /run/systemd && echo 'do…   7B
<missing>           6 weeks ago         /bin/sh -c set -xe   && echo '#!/bin/sh' > /…   745B
<missing>           6 weeks ago         /bin/sh -c [ -z "$(apt-get indextargets)" ]     987kB
<missing>           6 weeks ago         /bin/sh -c #(nop) ADD file:a48a5dc1b9dbfc632…   63.2MB

ver2 ではファイルを作成するレイヤーと削除するレイヤーがそれぞれ作成されており, 10.5MB のレイヤー作成レイヤーの上に0MBの削除レイヤーが乗っているだけで軽量化されていないことがわかります. つまり clone が終わった後の git や fetch した後の curl なんかも同じ RUN コマンドの中で削除してやるとイメージサイズを増やさずに使うことができます.

運用ルートを理解する

docker -v HOST_DIR:DOCKER_DIR で docker のマウントポイントを明示的にホストのディレクトリにすることで, Dockerfile の仕事をかなり減らすことができる可能性もあります.
ポータビリティは下がってしまいますが, CI のように想定したパスに存在していることが保障されているのであればポータビリティの低下はさほど問題にはなりません.

その代わりに git の install や git clone を実施する手間を削減できる方がおいしい場合が多いです.

dockerfile のデバッグ

以上が dockerfile スタイリッシュ計画になります.
ただ, dockerfile を作るのに何度もビルドを繰り返しながら進めていくのは大変骨が折れる上にキャッシュとの折り合いがつかなくて泥仕合を繰り広げかねないので, そこだけ最後に追記して本記事の結びといたします.

dockerfile のデバッグは雑に言うと失敗の直前のレイヤを実行すればいいよというただそれだけの話です.

FROM busybox:latest

RUN echo "Hello world!"
RUN /bin/bash -c echo "goodbye world"

上記 dockerfile を実行すると, error で止まります.

$ docker build . -t test
Sending build context to Docker daemon  2.048kB
Step 1/3 : FROM busybox:latest
latest: Pulling from library/busybox
322973677ef5: Pull complete
Digest: sha256:1828edd60c5efd34b2bf5dd3282ec0cc04d47b2ff9caa0b6d4f07a21d1c08084
Status: Downloaded newer image for busybox:latest
 ---> b534869c81f0
Step 2/3 : RUN echo "Hello world!"
 ---> Running in df9276262164    <--- docker が作った一時コンテナ
Hello world!
Removing intermediate container df9276262164    <--- 一時コンテナを削除
 ---> 19a3306dc73f     <--- コンテナから作成されたイメージID
Step 3/3 : RUN /bin/bash -c echo "goodbye world"
 ---> Running in e9cb02ef7052
/bin/sh: /bin/bash: not found
The command '/bin/sh -c /bin/bash -c echo "goodbye world"' returned a non-zero code: 127

なのでこの場合は最後に作成されたコンテナを実行してみます.

$ docker run -it 19a3306dc73f
/ # /bin/bash -c echo "goodbye world"
sh: /bin/bash: not found

同じログが出ている例で申し訳ありませんでしたが, これで sh が bash を呼び出せずに build が失敗していたことがわかります. これで毎回 docker build を実行して try & error で泥臭く進める必要がなくなりました!

是非皆さんも快適な docker life をお過ごしください!