Docker コンテナのレイヤー構造について

Docker コンテナのレイヤー構造をざっくりと理解する。その過程で Docker のイメージ構造(Image Manifest V 2, Schema 2)もながめる。

概要

About storage drivers によると、Docker は、ユニオンファイルシステムを利用し、複数のレイヤーを重ね合わせることでファイルシステムを実現している。

例えば 次のような Dockerfile をビルドして実行し、

# cat Dockerfile
FROM alpine:latest
COPY apple.txt /
RUN touch banana.txt /

# docker build -t sample -f Dockerfile .
Sending build context to Docker daemon  3.072kB
Step 1/3 : FROM alpine:latest
latest: Pulling from library/alpine
540db60ca938: Already exists
Digest: sha256:69e70a79f2d41ab5d637de98c1e0b055206ba40a8145e7bddb55ccc04e13cf8f
Status: Downloaded newer image for alpine:latest
 ---> 6dbb9cc54074
Step 2/3 : COPY apple.txt /
 ---> 6ad08ed3b839
Step 3/3 : RUN touch banana.txt /
 ---> Running in 37ed66a952a2
Removing intermediate container 37ed66a952a2
 ---> 42d7a98af75f
Successfully built 42d7a98af75f
Successfully tagged sample:latest

# docker run -d sample sleep 1000
e33ab5f4410ebcadf535a589549682f18b7eb908789f68dbda2fd0a9a3a30308

実行したコンテナの GraphDriver の設定を確認すると、ファイルシステムの設定が表示される(今回利用したバージョンだと overlay2 が利用される)。

# docker inspect e33ab5f4410ebcadf535a589549682f18b7eb908789f68dbda2fd0a9a3a30308  | jq ".[0].GraphDriver"
{
  "Data": {
    "LowerDir": "/var/lib/docker/overlay2/6e7ed57f2937b0bdb3d347a1d63878bc6240dd9a22f21d99ce1f36bf57160978-init/diff:/var/lib/docker/overlay2/c4d7eb4d35150b1300390ff8d43ca8f48d05a216a3bd0d651a4c5f732cd3d3ce/diff:/var/lib/docker/overlay2/cfc311bf4a17198e070699d866ba4075c5d8973a7d5e4829f880517f92cdf25d/diff:/var/lib/docker/overlay2/19610454da2e99c19184a385d76f2256d0da2b7210d0ccbf4edc0b9d438022f9/diff",
    "MergedDir": "/var/lib/docker/overlay2/6e7ed57f2937b0bdb3d347a1d63878bc6240dd9a22f21d99ce1f36bf57160978/merged",
    "UpperDir": "/var/lib/docker/overlay2/6e7ed57f2937b0bdb3d347a1d63878bc6240dd9a22f21d99ce1f36bf57160978/diff",
    "WorkDir": "/var/lib/docker/overlay2/6e7ed57f2937b0bdb3d347a1d63878bc6240dd9a22f21d99ce1f36bf57160978/work"
  },
  "Name": "overlay2"

各パラメータの意味は、次のとおり。

  • LowerDir: レイヤーとして重ね合わせるものを指定する。ReadOnly で良い。
  • MergedDir: すべてのレイヤー(LowerDir/UpperDir) を重ね合わせたディレクトリ。コンテナから見えるファイル。
  • UpperDir: LowerDir に対する変更を差分で持つディレクトリ。コンテナを停止すると消える。
  • WorkDir: overlayfs が動作するときに利用する。

Use the OverlayFS storage driver の図がわかりやすかった。

LowerDir で指定されたディレクトリをみると、レイヤーごとのファイルが確認できる。いちばん下層のレイヤーから、alpine:latestCOPY apple.txt /RUN touch banana.txt / になっている様子がわかる。

# ls /var/lib/docker/overlay2/19610454da2e99c19184a385d76f2256d0da2b7210d0ccbf4edc0b9d438022f9/diff
bin  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

# ls /var/lib/docker/overlay2/cfc311bf4a17198e070699d866ba4075c5d8973a7d5e4829f880517f92cdf25d/diff
apple.txt

# ls /var/lib/docker/overlay2/c4d7eb4d35150b1300390ff8d43ca8f48d05a216a3bd0d651a4c5f732cd3d3ce/diff
banana.txt

また、dockerd が追加した(と思われる)レイヤーも確認できる。これは設定でコンテナのホスト名や DNS サーバの設定に差し替える機能のために、dockerd が差し込むようす。

# ls /var/lib/docker/overlay2/6e7ed57f2937b0bdb3d347a1d63878bc6240dd9a22f21d99ce1f36bf57160978-init/diff
dev  etc

# ls /var/lib/docker/overlay2/6e7ed57f2937b0bdb3d347a1d63878bc6240dd9a22f21d99ce1f36bf57160978-init/diff/etc/
hostname  hosts  mtab  resolv.conf

これを図示すると、次のようになる。

f:id:kimulla:20210607223127p:plain

イメージ構造

Docker のイメージ構造は Image Manifest V 2, Schema 2 に定義されている。

イメージは主に 3 種類のファイルから構成されている。

  • manifest
    • 後述する config や layers の位置が記載されたもの。
  • config
    • コンテナ実行に必要な情報が記載されたファイル。例えばコンテナ実行時の Cmd は何、とか、WorkDir は何、とか、hostname は何、とか。
  • layer
    • (tar.gz アーカイブされた)ファイル。レイヤーの実体っぽいもの。

Docker Registry HTTP API V2を利用し、各レイヤーのデータを取得して中身をのぞいてみる。 なお、データ構造とイメージのファイル構造はあまり関係がないので、HTTP による取得方法はまあそういうもんかで流してください。

はじめに、先ほどのイメージを Dockerhub に push する。

# docker tag 42d7a98af75f kimullaa/sample:latest
# docker push kimullaa/sample:latest
# docker rmi -f $(docker images -aq)

manifest

manifest を取得してみる。config と layers のポインタになっているのが分かる。

# token=$(curl "https://auth.docker.io/token?service=registry.docker.io&scope=repository:kimullaa/sample:pull" | jq -r '.token')
# curl -s -H 'Accept: application/vnd.docker.distribution.manifest.v2+json' -H "Authorization: Bearer $token" "https://registry-1.docker.io/v2/kimullaa/sample/manifests/latest" | jq
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 1849,
    "digest": "sha256:42d7a98af75fe404665d7c4fb12dca643c7246cdefab2f6124a7e5c6082b4505"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 2811969,
      "digest": "sha256:540db60ca9383eac9e418f78490994d0af424aab7bf6d0e47ac8ed4e2e9bcbba"
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 109,
      "digest": "sha256:c3f2e40648c0e80cf46a41c0d5af743b1528c02af35445ed4add23f7f69b7347"
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 97,
      "digest": "sha256:4ea489072183231ead86738894a2977745afd7103169f9c328417087062b6dfd"
    }
  ]
}

config

config を取得してみる。こちらも実行時に必要な情報が入ってそうなことが分かる。 なお、このファイルの構造は moby/moby spec/v1.2.md で定義されているっぽい。

# token=$(curl "https://auth.docker.io/token?service=registry.docker.io&scope=repository:kimullaa/sample:pull" | jq -r '.token')
# curl -s -L -H 'Accept: application/vnd.docker.container.image.v1+json' -H "Authorization: Bearer $token" "https://registry-1.docker.io/v2/kimullaa/sample/blobs/sha256:42d7a98af75fe404665d7c4fb12dca643c7246cd
efab2f6124a7e5c6082b4505" | jq
{
  "architecture": "amd64",
  "config": {
    "Hostname": "",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/sh"
    ],
    "Image": "sha256:6ad08ed3b839dd3a96463078a6397f639c6bd07809a2edcd39ca121f696fdf87",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": null,
    "OnBuild": null,
    "Labels": null
  },
  "container": "37ed66a952a20943294885caecbbfd357c1980f00de5b057d78e93b654dc6d4d",
  "container_config": {
    "Hostname": "",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/sh",
      "-c",
      "touch banana.txt /"
    ],
    "Image": "sha256:6ad08ed3b839dd3a96463078a6397f639c6bd07809a2edcd39ca121f696fdf87",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": null,
    "OnBuild": null,
    "Labels": null
  },
  "created": "2021-06-07T01:07:31.224378431Z",
  "docker_version": "20.10.2",
  "history": [
    {
      "created": "2021-04-14T19:19:39.267885491Z",
      "created_by": "/bin/sh -c #(nop) ADD file:8ec69d882e7f29f0652d537557160e638168550f738d0d49f90a7ef96bf31787 in / "
    },
    {
      "created": "2021-04-14T19:19:39.643236135Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\"]",
      "empty_layer": true
    },
    {
      "created": "2021-06-07T01:07:28.009923628Z",
      "created_by": "/bin/sh -c #(nop) COPY file:4610a3197c00b0b8e77052a7da9ec139f7ea4c2bc5729dc4216ae30c25d0a50c in / "
    },
    {
      "created": "2021-06-07T01:07:31.224378431Z",
      "created_by": "/bin/sh -c touch banana.txt /"
    }
  ],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:b2d5eeeaba3a22b9b8aa97261957974a6bd65274ebd43e1d81d0a7b8b752b116",
      "sha256:641288b48190f3b6cab0f777036c0b8346b6abd2c7ff8bf71b4b7c3cebfd5a11",
      "sha256:a51ef6f18be9d4eff3c77e16f184e348cd61a637b6bddc2c2e9f25540c4989fe"
    ]
  }
}

ちなみに、config の情報をもとにコンテナの実行に必要なファイル(oci runtime の config.json)を作成するが、 実装上は OCI の出してる image-spec の構造体に変換されていた(というか OCI の application/vnd.oci.image.config.v1+jsonapplication/vnd.docker.container.image.v1+json が同一の処理になっていた)。
参考 containerd/containerd WithImageConfigArgs()

OCI の image-spec は、Image Manifest V 2, Schema 2をベースに策定されたようなので、このファイルについてはそのまま採用されたものと思われる。
参考 OCI Image Support Comes to Open Source Docker Registry

layer

layer を取得してみる。tar.gz を解凍すると、それぞれのレイヤーに必要なファイルが突っ込まれていることが分かる。 これらの各レイヤーのイメージをユニオンファイルシステムで重ねて、ファイルシステムを実現しているっぽい(なお tar を単純に解凍するだけではなく、ストレージドライバがもろもろ処理をするっぽい。例えばファイルの削除を表す whiteout file を tar.gz 内の .wh. ごとに作成したりする)。

# token=$(curl "https://auth.docker.io/token?service=registry.docker.io&scope=repository:kimullaa/sample:pull" | jq -r '.token')
# curl -L -H 'Accept: application/vnd.docker.image.rootfs.diff.tar.gzip' -H "Authorization: Bearer $token" "https://registry-1.docker.io/v2/kimullaa/sample/blobs/sha256:540db60ca9383eac9e418f78490994d0af424aab7bf6d0e47ac8ed4e2e9bcbba" -o layer1.tar.gz
# curl -L -H 'Accept: application/vnd.docker.image.rootfs.diff.tar.gzip' -H "Authorization: Bearer $token" "https://registry-1.docker.io/v2/kimullaa/sample/blobs/sha256:c3f2e40648c0e80cf46a41c0d5af743b1528c02af35445ed4add23f7f69b7347" -o layer2.tar.gz
# curl -L -H 'Accept: application/vnd.docker.image.rootfs.diff.tar.gzip' -H "Authorization: Bearer $token" "https://registry-1.docker.io/v2/kimullaa/sample/blobs/sha256:4ea489072183231ead86738894a2977745afd7103169f9c328417087062b6dfd" -o layer3.tar.gz

# mkdir layer{1..3}
# tar -xf layer1.tar.gz -C layer1
# tar -xf layer2.tar.gz -C layer2
# tar -xf layer3.tar.gz -C layer3

# ls layer1
bin  dev  etc  home  lib  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

# cat layer1/etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.13.5
PRETTY_NAME="Alpine Linux v3.13"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://bugs.alpinelinux.org/"

# ls layer{2..3}
layer2:
apple.txt

layer3:
banana.txt

ちなみに URL で指定した sha256:xxx という値は、ファイルを検証可能にするための仕組みらしい。
参考 Digest Parameter

URL で指定した sha256:xxx と、ダウンロードしたファイルの sha256 が一致すれば、問題なくダウンロードできたと判断できる。 試しにダウンロードしたファイルに sha256sum を実行すると、URL で指定した値と一致した。

# sha256sum layer1.tar.gz
540db60ca9383eac9e418f78490994d0af424aab7bf6d0e47ac8ed4e2e9bcbba  layer1.tar.gz

# sha256sum layer2.tar.gz
c3f2e40648c0e80cf46a41c0d5af743b1528c02af35445ed4add23f7f69b7347  layer2.tar.gz

# sha256sum layer3.tar.gz
4ea489072183231ead86738894a2977745afd7103169f9c328417087062b6dfd  layer3.tar.gz

ちなみにx2、レイヤーのダウンロードを sha256 で指定するということは、レイヤーに含まれるファイルが一緒なら、 異なるイメージだとしても同じ sha256 になり、同じ URL からダウンロードされる。ということは docker registry が持っておく tar.gz は 1 つで済むので、すごいディスクイメージの節約になりそう。

ちなみにx3、FROM で指定した alpine:latest という情報はコンテナのレイヤー構造に含まれていないので、 ということは、FROM で指定したベースイメージが更新されても、docker build しないと反映されなさそう。ビルドを自動化する作り込みが必要な様子。
参考 Dockerfile をベースイメージの更新に自動で追従させる - 詩と創作・思索のひろば
参考 Docker Hubの自作イメージを自動アップデートしてリリースする - Qiita