アットランタイム

OCI Image Specification を調べてみた

はじめに

OCI Image Specification からどうやって OCI Bundle を作成するのか不思議に思ったので調べる。 また、containerd の snapshot でいつどうやって overlayfs などプラグインを適用しているかも気になったので調べる。

今回は Image Specification について、構成を見ていく。

OCI Image Specification

Docker イメージ然り、毎回どのような構成か忘れがちである。 内容は、Image Format Specification をベースにする。 例は、ctr image pull docker.io/library/redis:alpine した結果である。

どのファイルにおいても mediaType が重要で、そのファイルやエンティティが何を表しているか理解できる。

Image Index

manifest へのポインタと言える。 あるイメージが複数の OS やアーキテクチャに対応している場合、その数だけ manifest が存在する。 image index より、ある OS やアーキテクチャの manifest ファイルのダイジェストを取得することができる。

mediaType にあるように、manifest のリストで構成されている。

Note: anntationsanntations:org.opencontainers.image.ref.name でイメージタグを表すことができるが、埋め込むことには必須ではない。

$ sudo cat /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/58
132ff3162cf9ecc8e2042c77b2ec46f6024c35e83bda3cabde76437406f8ac  | jq .
{
  "manifests": [
    {
      "digest": "sha256:1934dc5d1837d8cd3aadf5794f4feaab56ab783e2625da4393badc322e922e1b",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      },
      "size": 1571
    },
    {
      "digest": "sha256:4780a10780e780046729cfe19515314e7c9e29bccc112449120e9789f2e23370",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "arm",
        "os": "linux",
        "variant": "v6"
      },
      "size": 1571
    },
    {
      "digest": "sha256:c443b4631696cd5f12260216e8b19be1ec6d02dc0f182e786e008a8f513f2511",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "arm",
        "os": "linux",
        "variant": "v7"
      },
      "size": 1571
    },
    {
      "digest": "sha256:9d5aa0c6fc8fc20f2406dd52669d9958d440614321213a9bca0ab3b1cb5dce22",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "arm64",
        "os": "linux",
        "variant": "v8"
      },
      "size": 1571
    },
    {
      "digest": "sha256:98f172de9a06352e72923fd36d2b6c8acfa820d47a2bb5d9defc963585424a0a",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "386",
        "os": "linux"
      },
      "size": 1571
    },
    {
      "digest": "sha256:bae348597002c5217864419b6a2832f30b46c5c1fa4db73e3d5a3fa51c076091",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "ppc64le",
        "os": "linux"
      },
      "size": 1571
    },
    {
      "digest": "sha256:84803df35ca0a1fdfc94928c545b2267ca9d4afcf4575e20cdca76111ed86b63",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "s390x",
        "os": "linux"
      },
      "size": 1571
    }
  ],
  "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
  "schemaVersion": 2
}

Manifest

manifest は、image config と image layer へのポインタ(ダイジェスト)を保持する。 config は config.json の元のようなものであり、コンテナの arguments や entrypoint のコマンドや環境変数、hostname などが含まれている。 layer はイメージレイヤーであり、レイヤーを元に Rootfs が作成される。

layer の mediaType を見ることで、そのイメージのレイヤーがどのように保持しているか判断できる。 今回の場合、gzip された tar であるとわかる。

$ sudo cat /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/19
34dc5d1837d8cd3aadf5794f4feaab56ab783e2625da4393badc322e922e1b | jq .
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 6388,
    "digest": "sha256:e24d2b9deaecf3a3b3a12ac519c878e5609d2baaa2ffa529d2ca4af9fca0147f"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 2814446,
      "digest": "sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e"
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 1266,
      "digest": "sha256:a04b0375051e2422ff71b1072403e9045bd2cd74b52979987f91609dc35eee00"
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 384449,
      "digest": "sha256:cdc2bb0f9590d43ba98c87744a747c0aa7ea8e0d381e874adc6cac5ffea14a1d"
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 7706378,
      "digest": "sha256:0aa2a8e7bd6524ad61ce3c5c71a0fb51bbadb315e5012e987b2e76dc07ba5950"
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 136,
      "digest": "sha256:f64034a16b58c5b17b57b438957fc61f6d573fe196b27dbdd30618eeb68506e6"
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 412,
      "digest": "sha256:7b9178a22893394ed5128f0bddce89b863f8bcfadc41ecc60e41d14af17da8de"
    }
  ]
}

Image Config

config は config.json の元のようなものであり、コンテナの arguments や entrypoint のコマンドや環境変数、hostname などが含まれている。 diff_ids は、レイヤーの unpack 後のダイジェストである。この値を使用して、unpack されたレイヤーが正しいかどうか検査できる。

$ sudo cat /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/e24d2b9deaecf3a3b3a12ac519c878e5609d2baaa2ffa529d2ca4af9fca0147f | jq .
{
  "architecture": "amd64",
  "config": {
    "Hostname": "",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "ExposedPorts": {
      "6379/tcp": {}
    },
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "REDIS_VERSION=6.2.6",
      "REDIS_DOWNLOAD_URL=http://download.redis.io/releases/redis-6.2.6.tar.gz",
      "REDIS_DOWNLOAD_SHA=5b2b8b7a50111ef395bf1c1d5be11e6e167ac018125055daa8b5c2317ae131ab"
    ],
    "Cmd": [
      "redis-server"
    ],
    "Image": "sha256:b78d5e5eadbe9c37c6c1771668d6c1639960c16d862ec72dc768402ce86beab2",
    "Volumes": {
      "/data": {}
    },
    "WorkingDir": "/data",
    "Entrypoint": [
      "docker-entrypoint.sh"
    ],
    "OnBuild": null,
    "Labels": null
  },
  "container": "5e04d231e12c1fbd8b151b6efacbb80ee68deff39e1cb86f7863efa0d1842797",
  "container_config": {
    "Hostname": "5e04d231e12c",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "ExposedPorts": {
      "6379/tcp": {}
    },
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "REDIS_VERSION=6.2.6",
      "REDIS_DOWNLOAD_URL=http://download.redis.io/releases/redis-6.2.6.tar.gz",
      "REDIS_DOWNLOAD_SHA=5b2b8b7a50111ef395bf1c1d5be11e6e167ac018125055daa8b5c2317ae131ab"
    ],
    "Cmd": [
      "/bin/sh",
      "-c",
      "#(nop) ",
      "CMD [\"redis-server\"]"
    ],
    "Image": "sha256:b78d5e5eadbe9c37c6c1771668d6c1639960c16d862ec72dc768402ce86beab2",
    "Volumes": {
      "/data": {}
    },
    "WorkingDir": "/data",
    "Entrypoint": [
      "docker-entrypoint.sh"
    ],
    "OnBuild": null,
    "Labels": {}
  },
  "created": "2021-10-06T00:45:48.595546936Z",
  "docker_version": "20.10.7",
  "history": [
    {
      "created": "2021-08-27T17:19:45.553092363Z",
      "created_by": "/bin/sh -c #(nop) ADD file:aad4290d27580cc1a094ffaf98c3ca2fc5d699fe695dfb8e6e9fac20f1129450 in / "
    },
    {
      "created": "2021-08-27T17:19:45.758611523Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\"]",
      "empty_layer": true
    },
    {
      "created": "2021-08-28T00:31:21.324096168Z",
      "created_by": "/bin/sh -c addgroup -S -g 1000 redis && adduser -S -G redis -u 999 redis"
    },
    {
      "created": "2021-08-28T00:31:29.093746584Z",
      "created_by": "/bin/sh -c apk add --no-cache \t\t'su-exec>=0.2' \t\ttzdata"
    },
    {
      "created": "2021-10-06T00:44:31.611509113Z",
      "created_by": "/bin/sh -c #(nop)  ENV REDIS_VERSION=6.2.6",
      "empty_layer": true
    },
    {
      "created": "2021-10-06T00:44:31.867256017Z",
      "created_by": "/bin/sh -c #(nop)  ENV REDIS_DOWNLOAD_URL=http://download.redis.io/releases/redis-6.2.6.tar.gz",
      "empty_layer": true
    },
    {
      "created": "2021-10-06T00:44:32.111113495Z",
      "created_by": "/bin/sh -c #(nop)  ENV REDIS_DOWNLOAD_SHA=5b2b8b7a50111ef395bf1c1d5be11e6e167ac018125055daa8b5c2317ae131ab",
      "empty_layer": true
    },
    {
      "created": "2021-10-06T00:45:46.34837367Z",
      "created_by": "snip"

    },
    {
      "created": "2021-10-06T00:45:47.21883165Z",
      "created_by": "/bin/sh -c mkdir /data && chown redis:redis /data"
    },
    {
      "created": "2021-10-06T00:45:47.443405896Z",
      "created_by": "/bin/sh -c #(nop)  VOLUME [/data]",
      "empty_layer": true
    },
    {
      "created": "2021-10-06T00:45:47.691143015Z",
      "created_by": "/bin/sh -c #(nop) WORKDIR /data",
      "empty_layer": true
    },
    {
      "created": "2021-10-06T00:45:47.955525526Z",
      "created_by": "/bin/sh -c #(nop) COPY file:c48b97ea65422782310396358f838c38c0747767dd606a88d4c3d0b034a60762 in /usr/local/bin/ "
    },
    {
      "created": "2021-10-06T00:45:48.180748603Z",
      "created_by": "/bin/sh -c #(nop)  ENTRYPOINT [\"docker-entrypoint.sh\"]",
      "empty_layer": true
    },
    {
      "created": "2021-10-06T00:45:48.391382263Z",
      "created_by": "/bin/sh -c #(nop)  EXPOSE 6379",
      "empty_layer": true
    },
    {
      "created": "2021-10-06T00:45:48.595546936Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"redis-server\"]",
      "empty_layer": true
    }
  ],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:e2eb06d8af8218cfec8210147357a68b7e13f7c485b991c288c2d01dc228bb68",
      "sha256:512970cfaf24aa0ae8dee2e748e89c56830b31f53b4ce2d624663bd007872065",
      "sha256:346615b02a36c76c40b83a78f8e620b79643aaaa89c0800ec8ad3350c4d99e80",
      "sha256:ba79e026e79d3b325af2fad493529dfbb77661fae102fa2133da3fa1637f690c",
      "sha256:2926f8581b1d00b0c905d4a6cdf04812b243e01b526eb50a22ebb6c0555573d1",
      "sha256:29b13f0def6283a985c4ea3b1e8f86332f8ecbe11dc47f12bc3a3525334e2a9b"
    ]
  }
}

Layer

Docker イメージの各レイヤーと同義である。 unpack すると、ディレクトリ、ファイル、パーミッションなどが復元される。

$ sudo tar -tf  /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e | head
bin/
bin/arch
bin/ash
bin/base64
bin/bbconfig
bin/busybox
bin/cat
bin/chgrp
bin/chmod
bin/chown
$ sudo tar -tf  /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/cdc2bb0f9590d43ba98c87744a747c0aa7ea8e0d381e874adc6cac5ffea14a1d | grep -i5 wh
usr/sbin/
usr/sbin/zdump
usr/sbin/zic
usr/share/
usr/share/zoneinfo/
usr/share/zoneinfo/.wh..wh..opq
usr/share/zoneinfo/Africa/
usr/share/zoneinfo/Africa/Abidjan
usr/share/zoneinfo/Africa/Accra
usr/share/zoneinfo/Africa/Addis_Ababa
usr/share/zoneinfo/Africa/Algiers

各レイヤーには差分(仕様ではチェンジセットと呼ばれる)が含まれている。 差分には、追加、変更、削除がある。そのうち、変更とファイルの追加は同じである。 ファイルの削除は、whiteout ファイルとして表される。 あるファイルやディレクトリの削除は、.wh.<ファイル名/ディレクトリ名> と表される(.wh. がプレフィックス)。 あるディレクトリのその配下のディレクトリやファイルの全削除は、.wh..wh..opq と表される。

上記 whiteout ファイルの意味は、前のレイヤーのファイルやディレクトリを現在のレイヤーで削除するという意味となる。

仕様にあるように、レイヤーを作る側が、.wh..wh..opq が最初に出現するようにするべきとある。 一方で、.wh..wh..opq を最初に処理すれば、以下仕様の例は同じになると書かれている。ただしこれは MUST でもなんでもない様子。

containerd の実装を見る限り、次のように正しく動作する様子。 あるレイヤーを for ループして、ファイル名や情報を取得し、

  • .wh.<ファイル名/ディレクトリ名> であれば、そのファイルやディレクトリ名を削除する。
  • .wh..wh..opq であれば、そのレイヤーで対象のファイルやディレクトリが作成されていない場合に、その配下を削除する。

.wh..wh..opq を最初に処理するのではなく、.wh..wh..opq に出逢ったら削除するかどうか判断するイメージである。

例えば、前のレイヤーで /a/b/c/bar が作成されている前提で、後者の具体的な動作は次のようになる。

  1. a/a/ba/b/ca/b/c/foo が作成される。
  2. a/.wh..wh..opq の順番が来ると filepath.Walk して a 配下のディレクトリ順次探査する。
    1. b
    2. b/c
    3. b/c/foo
    4. b/c/bar
  3. 対象のレイヤーで b/c/bar は作成されていないので、削除される。

結果、うまく動作する。

https://github.com/opencontainers/image-spec/blob/main/layer.md#whiteouts

a/
a/.wh..wh..opq
a/b/
a/b/c/
a/b/c/foo

When processing the second layer, a/.wh..wh..opq is applied first, before creating the new version of a/b, regardless of the ordering in which the whiteout file was encountered. For example, the following layer is equivalent to the layer above:

a/
a/b/
a/b/c/
a/b/c/foo
a/.wh..wh..opq

Implementations SHOULD generate layers such that the whiteout files appear before sibling directory entries.

https://github.com/containerd/containerd/blob/v1.5.7/archive/tar.go#L177

convertWhiteout = func(hdr *tar.Header, path string) (bool, error) {
    base := filepath.Base(path)
    dir := filepath.Dir(path)
    if base == whiteoutOpaqueDir {
        _, err := os.Lstat(dir)
        if err != nil {
            return false, err
        }
        err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
            if err != nil {
                if os.IsNotExist(err) {
                    err = nil // parent was deleted
                }
                return err
            }
            if path == dir {
                return nil
            }
            if _, exists := unpackedPaths[path]; !exists {
                err := os.RemoveAll(path)
                return err
            }
// Directory mtimes must be handled at the end to avoid further
// file creation in them to modify the directory mtime
if hdr.Typeflag == tar.TypeDir {
    dirs = append(dirs, hdr)
}
unpackedPaths[path] = struct{}{}

イメージタグ

ctr image pull した際のイメージタグ名は、どのファイル(特に、image index)にも含まれていなかった。 イメージのプル・プッシュいずれも、image index や manifest に含まれている必要はないためである。

イメージのプルの観点では、manifest もしくは image index を取得するために、イメージ名とタグ名を使用するので、そのタグのイメージを取得するために、manifest もしくは image index を使用しないからである。 言い換えると、イメージのプルは、タグ名というユーザフレンドリーな値から manifest や image index を取得し、それ以降はダイジェストを使用してデータを取得する。

イメージのプッシュの観点では、PUT メソッドで、タグ名 もしくはダイジェストを URL として指定してマニフェストを PUT する。 仕様には書かれていないが、Content-Type ヘッダを、application/vnd.docker.distribution.manifest.list.v2+json にすることで、Image Index を PUT できると思われる。 そのため、PUT のリクエスト URI でタグ名を指定するため、やはり Image Index にタグ名を含める必要はない。

Open Container Initiative Distribution Specification

Pull

The process of pulling an artifact centers around retrieving two components: the manifest and one or more blobs.
(snip)
To pull a manifest, perform a GET request to a URL in the following form: /v2/<name>/manifests/<reference>
Pushing Manifests

To push a manifest, perform a PUT request to a path in the following format, and with the following headers and body: /v2/<name>/manifests/<reference> end-7

Content-Type: application/vnd.oci.image.manifest.v1+json

手動での Docker プル

Accept ヘッダーを application/vnd.docker.distribution.manifest.v2+json にすると、ある一つの manifest を取得できる。 また、Accept ヘッダーを application/vnd.docker.distribution.manifest.list.v2+json にすると、image index を取得できる。

$ export TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/redis: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/library/redis/manifests/alpine
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 6388,
    "digest": "sha256:e24d2b9deaecf3a3b3a12ac519c878e5609d2baaa2ffa529d2ca4af9fca0147f"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 2814446,
      "digest": "sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e"
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 1266,
      "digest": "sha256:a04b0375051e2422ff71b1072403e9045bd2cd74b52979987f91609dc35eee00"
    }
    (snip)
  ]
}
$ curl -s -H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/library/redis/manifests/alpine | jq .
{
  "manifests": [
    {
      "digest": "sha256:1934dc5d1837d8cd3aadf5794f4feaab56ab783e2625da4393badc322e922e1b",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      },
      "size": 1571
    },
    {
      "digest": "sha256:4780a10780e780046729cfe19515314e7c9e29bccc112449120e9789f2e23370",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "arm",
        "os": "linux",
        "variant": "v6"
      },
      "size": 1571
    },
    {
      "digest": "sha256:c443b4631696cd5f12260216e8b19be1ec6d02dc0f182e786e008a8f513f2511",
      "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
      "platform": {
        "architecture": "arm",
        "os": "linux",
        "variant": "v7"
      },
      "size": 1571
    }
    (snip)
  ],
  "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
  "schemaVersion": 2
}