インフラ

Dockerイメージのレイヤ構造を理解する

ryo

よく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.testtestfileという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イメージのレイヤ構造がどのようになっているのか確認を行いました。
レイヤ構造や仕組みがわかるとより安全なイメージを作る方法や、よりイメージサイズを小さくする方法がより理解しやすくなると思います。
この記事がどなたかの参考になれば幸いです。

AUTHOR
ryo
ryo
記事URLをコピーしました