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: anntations
の anntations: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
が作成されている前提で、後者の具体的な動作は次のようになる。
a/
、a/b
、a/b/c
、a/b/c/foo
が作成される。a/.wh..wh..opq
の順番が来るとfilepath.Walk
してa
配下のディレクトリ順次探査する。b
b/c
b/c/foo
b/c/bar
- 対象のレイヤーで
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
}