はじめに
Dockerfileを書く際のベストプラクティスにもあるように、Dockerfileを作成する際の一般的なガイドラインとして「レイヤの数を最小にすること」というのがあります。普段Dockerfileを書く際にこういったことは意識していますが、そもそもDockerにおけるレイヤとはどのようなものなのか理解していませんでした。
そこで、本記事では**「Dockerにおけるレイヤとは何なのか」, 「我々はどういった恩恵を受けているのか」**を実際にDockerを操作しながら理解していくことを目的とします。
コンテナイメージのレイヤ構造
Dockerfileを書く際にはまず、FROM
命令でベースとなるイメージを指定します。それに続いて、アプリのインストールやホストからコンテナ内のファイルシステムへのファイルの追加などの変更を加えていきます。このようにコンテナは**「変更差分(レイヤ)の集まり」**としてみなされます。
作成されたファイルイメージから実行されるコンテナは初め、実行環境中のファイルシステムに何も格納されていない状態です。これに変更差分を加えていき、結果として得られるファイル群をルートファイルシステムとしてコンテナは実行されます。
続いて、コンテナイメージの中身を覗き、レイヤ構造について見ていきます。
例として、Hello World!
を返すコンテナを作成します。
①helloディレクトリと"Hello World!"を返すシェルスクリプトの作成
$ mkdir hello
$ cd hello
$ cat <<EOF > ./hello.sh
#!/bin/bash
echo "Hello World!"
exec sleep infinity
EOF
$ chmod +x hello.sh
echo "Hello World!"
でHello World!を出力しますが、これだけだとコンテナが直ぐに終了してしまうため、exec sleep infinity
によりこれを防いでいます。
②Dockerfileの作成
$ cat <<EOF > ./Dockerfile
FROM ubuntu:20.04
COPY ./hello.sh /hello.sh
ENTRYPOINT [ "/hello.sh" ]
EOF
- FROM命令でベースイメージとしてubuntuの20.04を指定
- COPY命令で実行したいホスト側の
hello.sh
をコンテナ内にコピー - ENTRYPOINT [ "/hello.sh" ]でコンテナが起動したら、
hello.sh
を実行するように指示
③コンテナイメージのビルド
$ cd hello
$ docker build -t hello:v1 ./
$ docker image ls
- helloディレクトリに移動
- コンテナイメージのビルド
- コンテナイメージ(hello:v1)ができていることを確認
④作成したコンテナイメージをtar形式で出力する
docker save
コマンドを使ってコンテナイメージを出力します。
$ mkdir dump_hello
$ cd dump_hello
$ docker save hello:v1 | tar -xC ./
$ tree ./
./
├── 07d67c3173ab032b5395d7e292f9c78e669af58c220b94e432784fba43c11adf
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── 5367b0805440d84bce8b0c815a92c29f2c74b0de13f5b82ec524837f496d96dd
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── 6621c71ed3a4a388a2b282106392b5e0d50e1475a53ab64387b1fe5907e05ce4
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── 8d0b9a80b84810110bed2c2ab8d1aa61e84cb4a28e552a1373e80ed210ebdf24.json
├── 9677226adbb823011a9b00fccba48935af627e2f1c3c8ffd4c033319f8fc07ef
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── manifest.json
└── repositories
4 directories, 15 files
- dump_helloディレクトリを作成
- コンテナイメージhello:v1をtar形式で出力
- treeコマンドでコンテナイメージに含まれるファイル群を表示
イメージに含まれるファイル群はおおよそ次のように分類されます。
- layer.tar:コンテナが用いるルートファイルシステムのデータ
- 8d0b9a8...bdf24.json:実行コマンドや環境変数など、実行環境を再現するため情報
- manifest.json, repositories:イメージの構成などに関する情報
- VERSION, json:その他(過去の仕様との互換性のために保持されるファイル群)
このようにコンテナイメージを出力した中身を確認するとlayer.tar
というtarファイルがいくつか存在していることが確認できます。これがコンテナイメージを構成するレイヤ群であり、一つ一つがコンテナのルートファイルシステムへの変更差分となっています。
次のコマンドでlayer.tarの中身を確認することができます。
$ tar --list -f ./9677226adbb823011a9b00fccba48935af627e2f1c3c8ffd4c033319f8fc07ef/layer.tar | head -n 10
bin
boot/
dev/
etc/
etc/.pwd.lock
etc/adduser.conf
etc/alternatives/
etc/alternatives/README
etc/alternatives/awk
etc/alternatives/nawk
このようにbinやetcなどのディレクトリなどコンテナのルートファイルシステムを構成するディレクトリがあることが確認できます。
よって、コンテナイメージはコンテナのルートファイルシステムを構成するための変更差分であるレイヤの集合であるということが確認できました。
コンテナのビルドとレイヤ構造
先の例ではコンテナのビルド時にDockerfileを使用しました。コンテナのレイヤ構造はこのDockerfileとも深く関係しています。
Dockerfileとは先のようにコンテナイメージの作成手順を記述するファイルであり、命令 引数
のようにDockerfileがサポートする命令を行ごとに記述していきます。Dockerはこれを先頭から実行していくことでコンテナイメージを作成します。
先のHello World!
を出力するコンテナイメージに変更を加え、アスキーアートでHello World!
と出力するようにします。
アスキーアートを出力するためにはfigletコマンド
を使用します。このコマンドでは標準ではubuntuに組み込まれていないため、後からインストールする必要があります。これを踏まえて先のDockerfileを元に新たなDockerfileを作成します。
FROM ubuntu:20.04
RUN apt-get update && apt-get install -y figlet #追加
COPY ./hello.sh /hello.sh
ENTRYPOINT [ "/hello.sh" ]
このDockerfile
を見ると、FROM命令
によりベースイメージとなるubuntu:20.04を指定し、後に続く命令によりベースイメージに変更差分を加えていっていることがわかります。各命令の実行による変更差分はそれぞれレイヤとして結果のイメージに格納されていきます。
①コンテナイメージのビルド
次に作成したDockerfile
を元にしてコンテナイメージのビルドを行います。
ビルドを行う前にhello.sh
を次のように変更してください。
#!/bin/bash
figlet "Hello World!" #変更
exec sleep infinity
$ chmod +x hello.sh
イメージのビルドを行います。
$ docker build -t hello:v2 ./
Sending build context to Docker daemon 3.072kB
Step 1/4 : FROM ubuntu:20.04
---> 4dd97cefde62
Step 2/4 : RUN apt-get update && apt-get install -y figlet
---> Using cache
---> 1801f21b7c5d
Step 3/4 : COPY ./hello.sh /hello.sh
---> db5c2b9ffbad
Step 4/4 : ENTRYPOINT [ "/hello.sh" ]
---> Running in a796121286fe
Removing intermediate container a796121286fe
---> eff04550fd9d
Successfully built eff04550fd9d
Successfully tagged hello:v2
このようにビルドが完了しました。
②イメージのキャッシュ
Dockerは、Dockerfileの各命令を実行するたびに各変更差分をキャッシュしながらビルドを進めていきます。次回以降ビルドを行う際に、同じ変更差分が期待される場合キャッシュを利用してその実行を省略します。
①で作成したhello.sh
に変更を加え、出力する文字を変更してみます。
#!/bin/bash
figlet "Hello Docker!" #変更
exec sleep infinity
$ chmod +x hello.sh
イメージのタグをv3にして再度ビルドを行います。
$ docker build -t hello:v3 ./
Step 1/4 : FROM ubuntu:20.04
---> 4dd97cefde62
Step 2/4 : RUN apt-get update && apt-get install -y figlet
---> Using cache
---> 1801f21b7c5d
Step 3/4 : COPY ./hello.sh /hello.sh
---> da3936217988
Step 4/4 : ENTRYPOINT [ "/hello.sh" ]
---> Running in 3f89d674ba12
Removing intermediate container 3f89d674ba12
---> bc37b1bc7c06
Successfully built bc37b1bc7c06
Successfully tagged hello:v3
ビルドのログを見るとStep 2/4 でUsing cache
と出力されていることが確認でき、この時RUN命令は省略されていることがわかります。
作成したhello:v2とhello:v3イメージを元にコンテナを起動すると次の出力を得ます。
$ docker run --rm --name hello2 -t hello:v2
_ _ _ _ __ __ _ _ _
| | | | ___| | | ___ \ \ / /__ _ __| | __| | |
| |_| |/ _ \ | |/ _ \ \ \ /\ / / _ \| '__| |/ _` | |
| _ | __/ | | (_) | \ V V / (_) | | | | (_| |_|
|_| |_|\___|_|_|\___/ \_/\_/ \___/|_| |_|\__,_(_)
$ docker run --rm --name hello3 -t hello:v3
_ _ _ _ ____ _ _
| | | | ___| | | ___ | _ \ ___ ___| | _____ _ __| |
| |_| |/ _ \ | |/ _ \ | | | |/ _ \ / __| |/ / _ \ '__| |
| _ | __/ | | (_) | | |_| | (_) | (__| < __/ | |_|
|_| |_|\___|_|_|\___/ |____/ \___/ \___|_|\_\___|_| (_)
このようにどちらも期待した結果を出力できていることが確認できました。
sleep infinity
をさせているため、別ウインドウを開き、コンテナを終了させます。
$ docker kill hello2
$ docker kill hello3
このようにコンテナのビルドはイメージのレイヤ構造と深い関係があり、ビルダはこのレイヤ構造を利用したキャッシュの仕組みも備えていることがわかります。
Dockerfileを作成する際にレイヤを意識することは重要で、レイヤをまとめることでイメージを軽量にしたりベースイメージとしてより軽量なものを用いたりとDockerfileを書く際のベストプラクティスとして様々な方法が議論されています。
コンテナ実行時のレイヤ構造
同じコンテナイメージから作成されるコンテナが並列に稼働する場合があります。
例として、先のコンテナイメージhello:v2
を元に2つのコンテナをバックグラウンドで立ち上げてみます。
$ docker run -d --rm --name hello2 hello:v2
$ docker run -d --rm --name hello3 hello:v2
hello2コンテナに変更を加えて、それぞれのコンテナを参照してみます。
$ docker exec hello2 /bin/bash -c 'echo "New File!" > /newfile.txt'
$ docker exec hello2 cat /newfile.txt
New File!
$ docker exec hello3 cat /newfile.txt
cat: /newfile.txt: No such file or directory
このように、共通のイメージから作成されたコンテナ同士でも、片方のコンテナがルートファイルシステムに施した変更は他のコンテナからは見えないようになっています。
リソースを無駄にせずにこれを実現するためには、「コンテナ同士がデータの重複を起こさずにコンテナ同士の環境が影響し合わないようにする」ということが要求されており、これを可能にするのがレイヤ構造です。
ホスト上でDockerはコンテナイメージをそのレイヤ構造を保ったまま保持しており、イメージからコンテナを実行する際にはそのレイヤ群を重ね合わせた結果をコンテナのルートファイルシステムとして使用していました。
また、Dockerでは、共通のコンテナイメージからコンテナを実行する際、共通のレイヤ群をコピーせず共有しています。この時、イメージを構成していたレイヤ群は読み取り専用としてコンテナ間で共有されるため、そのレイヤの内容が他のコンテナにより意図せず書き換えられることがありません。
一方で、先の例のようにルートファイルシステムに書き込みを行った場合はコンテナの実行時にはレイヤ群を重ね合わせた一段上にそのコンテナ専用の読み書き可能レイヤを新たに作成することで書き込みを行います。
この読み書き可能レイヤにはコンテナの実行に伴うルートファイルシステムへの変更差分のみが格納されています。つまり、あるファイルが作成された場合、その作成されたファイルのみが読み書き可能レイヤに格納されます。また、読み取り専用である下位のレイヤに含まれるファイルに変更を加える場合、変更対象のファイルのみ読み書き可能レイヤにコピーされ変更が加えられます。
このようにコンテナのレイヤ構造により、それぞれのコンテナは他のコンテナによって意図せず書き換えられることなく、ファイルの重複が起こらないようになっているため、効率良くリソースを使うことができています。
まとめ
本記事では、Dockerコンテナのレイヤ構造に注目し、レイヤとはどういったものであるのか、どのような恩恵を得ることができるのかについて調べました。コンテナイメージ、コンテナのビルド、コンテナの実行とレイヤ構造の関係をそれぞれ調べ、以下の結論を得ました。
- コンテナイメージはコンテナのルートファイルシステムを構成するための変更差分であるレイヤの集合である
- コンテナのビルドはイメージのレイヤ構造と深い関係があり、ビルダはこのレイヤ構造を利用したキャッシュの仕組みも備えている
- コンテナのレイヤ構造により、それぞれのコンテナは他のコンテナによって意図せず書き換えられることなく、ファイルの重複が起こらないようになっている
また、レイヤ構造のイメージからルートファイルシステムを作成する際に用いられる方法など、より詳細な点については筆者が参考文献として利用したイラストでわかるDockerとKubernetessを参照されると良いかと思います。