Dockerイメージのレイヤ構造を理解する
よくDockerイメージはレイヤ構造として説明されています。
この記事では具体的にコンテナイメージがどのような構造になっているかの解説を行います。
ユニオンファイルシステム(AUFS)
ユニオンファイルシステムは複数のファイル・ディレクトリを重ねることで1つのファイルシステムに見せる技術を指します。コンテナを構成する主要技術の1つです。
はじめの何もファイルが存在しない状態から、ファイルやディレクトリの追加・削除などの変更差分が各レイヤに格納されていて、それらをマージすることで1つの最終的なファイル構造を構成していると言えます。
ユニオンファイルシステムには多くの実装がありますが、デフォルトだとoverlayfs2
が使われます(参考)
以下で説明するレイヤ構造は最終的にはこの技術を用いてコンテナのファイル構造となります。
実際にコンテナイメージのレイヤ確認する
実際にコンテナイメージの中身を覗いて、各レイヤがどのような構造をしているかを確認します。
以下のDockerfileとコマンドでイメージを作成します。
このDockerfileでは10MBのファイルを2つ作成して最後に消しています。
この構成だとFROM命令とRUN命令でレイヤが4つ出来る構成になります。
FROM alpine
# 10MBのファイル作成
RUN dd if=/dev/zero of=testfile1 bs=1M count=10
RUN dd if=/dev/zero of=testfile2 bs=1M count=10
# ファイル削除
RUN rm testfile1
以下のコマンドでビルドを行います。
$ docker build -t dump-image .
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
dump-image latest a358bd634e4b 40 seconds ago 26.3MB
以下のコマンドでdocker-archive形式でコンテナイメージをローカルに出力します。
$ mkdir dump
$ docker save dump-image | tar -xC ./dump
出力したコンテナイメージを確認します。
(以下に示すコマンド例はハッシュ値を含むので本記事と同じとは限らないです。)
$ cd dump
# ハッシュ値を含むディレクトリがレイヤが4つ存在する
$ tree .
.
├── 0528ed25478fac3bf85e87e2448bde89a1406b75dd56cebadbce6c972434d31e
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── 8aafe7f7ffdfefbb5d8085df43626e791db0afd7b495c013407ce9b356b5db04
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── a358bd634e4bee89ac5b409a088c13559ed5eaa5b5b203559baba8d7fb68670e.json
├── d3f9cb55d5cbda1a9db2257306dd7b767f6ae25dffb80064e377ee680aa25b58
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── fb38efe4df72b03bb09691bc23189309203b2e384619fc692dec0fd1122d3f8d
│ ├── VERSION
│ ├── json
│ └── layer.tar
├── manifest.json
└── repositories4 directories, 15 files
ここでは4つあるlayer.tar
に着目します。4つ存在するということで4つのレイヤーが存在することになります。
試しにアーカイブされているファイルを確認してみます。以下のコマンドを実行すると大量のファイルが出力されます。Linuxで見覚えのあるファイルがたくさんあるので、FROM alpine
のレイヤにてalpineのイメージに含まれていたファイルがアーカイブされていたことが分かります。
$ tar --list -f
0528ed25478fac3bf85e87e2448bde89a1406b75dd56cebadbce6c972434d31e/layer.tar
var/
var/cache/
var/cache/apk/
var/cache/misc/
... # 以下略
異なるレイヤのアーカイブを見てみると、testfile1というRUN
命令にて作成したファイルが含まれていることから、RUN dd if=/dev/zero of=testfile1 bs=1M count=10
で作成されたレイヤーだということが分かります。RUN rm testfile1
にてDockerfile上では最終的に削除したはずなのにイメージには含まれていることがポイントです。
$ tar --list -f
fb38efe4df72b03bb09691bc23189309203b2e384619fc692dec0fd1122d3f8d/layer.tar
etc/
testfile1
最後にd3f9cb55d5cbda1a9db2257306dd7b767f6ae25dffb80064e377ee680aa25b58
を確認してみます。
$ tar --list -f d3f9cb55d5cbda1a9db2257306dd7b767f6ae25dffb80064e377ee680aa25b58/layer.tar
etc/
.wh.testfile
.wh.testfile1
というファイルはtestfile1
というファイルを削除したことを表します。
今回のDockerfileではRUN rm testfile1
にてファイルの削除を実行したレイヤに該当します。.wh.ファイル名
はDockerイメージの仕様ではwhiteout file
と呼ばれていて、.wh
から始まるディレクトリやファイルはコンテナのルートファイルシステムに作成することは出来ません。
https://github.com/moby/moby/blob/master/image/spec/v1.2.md#creating-an-image-filesystem-changeset
以下のDockerfileを利用して.wh
から始まるファイル名がどう扱われるかを確認してみます。
RUN命令を利用して/
に.wh.test
とtestfile
という2つのファイルを作成しました。
FROM alpine
RUN touch .wh.test
RUN touch testfile
ビルドとdocker runにてコンテナを実行し/
に2つのファイルが存在しているかを確認してみます。ls /
でファイルを確認するとtestfile
のみ存在している結果となりました。
/ # ls /
bin dev etc home lib media mnt opt proc root run sbin srv sys testfile tmp usr var
この結果からも.wh
から始まるファイルは起動したコンテナのファイルシステムには含まれないことが分かります。
ここから分かることとしては、Dockerfileにて追加したファイルはその後削除したとしても、削除したという変更をレイヤに残すだけで、イメージ自体にファイルは含まれたままであるということがあります。
よく機密情報をイメージに含めてはいけないと言いますがこのためです。
作成者は削除したつもりでも、イメージをプルされてしまった場合、誰でも機密情報の確認が出来てしまいます。
あくまで仕組み上では、同じレイヤー内で追加と削除を行えばファイルはイメージに残らないので1つのRUN命令で全て完結させる方法も存在します。
しかしBuildkitのRUN --mount=type=secret
やマルチステージビルドなど、イメージのビルド時には機密情報を扱うための色々な方法があるのでそちらが推奨です。
また同様に、ファイルの追加と削除を異なるレイヤーで行った場合は、削除したつもりでもイメージサイズは変わらないという点も注意が必要です。
実際に確かめるために2つのRUN命令で作成と削除を行うものと、1つのRUN命令で作成と削除を行うものを用意します。
FROM alpine
RUN dd if=/dev/zero of=testfile1 bs=1M count=10
RUN rm testfile1
FROM alpine
RUN dd if=/dev/zero of=testfile1 bs=1M count=10 && rm testfile1
実際にビルドしてみると2つのイメージサイズに約10MBの差があることが分かります。
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
test1 latest 84e1874d1a7c 20 seconds ago 18.1MB
test2 latest c61a1504b54b 2 seconds ago 7.66MB
最後に1つのRUN命令にて複数のコマンドを実行せず、異なるレイヤで作成したファイルの編集をするとどうなるでしょうか。
以下のDockerfile上にて、①レイヤにて作成したファイルに②レイヤから文字列を書き込んでみます。
FROM alpine
# ① 10MBのファイルを作成
RUN dd if=/dev/zero of=testfile1 bs=1M count=10
# ② 上記で作成したファイルに1MBのデータを書き込みサイズのかさまし
RUN dd if=/dev/urandom bs=1M count=1 >> testfile1
今までと同じようにビルドとイメージの出力を行いレイヤを確認してみます。
$ tar -tvf 5fc576ad5c5b50659d7634e9b880264623c840c00985297d07b4f5774b984916/layer.tar
drwxr-xr-x 0 0 0 0 8 23 00:57 etc/
-rw-r--r-- 0 0 0 11534336 8 23 00:57 testfile1
$ tar -tvf
f6ee1c0e452d3ba7cf07b3f568f85ce4cecaf05e3ea6539514be4e2641a755fb/layer.tar
drwxr-xr-x 0 0 0 0 8 23 00:36 etc/
-rw-r--r-- 0 0 0 10485760 8 23 00:36 testfile1
2つのレイヤにてtestfile1
のファイルサイズに1048606 bytes(約1MB)の差があることが分かります。
②でtestfile1に文字列の追記をする際は①のレイヤにあるtestfile1
を②レイヤにコピーをしてから編集を行うという挙動になります(コピーオンライト)。
つまりファイルの書き込み時に上位レイヤーにファイルをコピーするため、ビルドしたイメージサイズにはコピーされた分も含まれる形になります。
まとめ
この記事ではDockerイメージのレイヤ構造がどのようになっているのか確認を行いました。
レイヤ構造や仕組みがわかるとより安全なイメージを作る方法や、よりイメージサイズを小さくする方法がより理解しやすくなると思います。
この記事がどなたかの参考になれば幸いです。