手把手教你了解 OCI 镜像规范
在云原生应用的开发中,应用会被打包成容器镜像。容器镜像遵循 OCI 镜像规范。了解 OCI 镜像规范,对构建容器镜像有重要的作用。
在介绍 OCI 镜像规范之前,首先需要介绍内容可寻址的文件系统。
内容可寻址的文件系统
我们通常使用的文件系统是根据路径来查找文件的,比如 /etc/nginx/nginx.conf
指向的就是文件系统上的一个文件。这种寻址方式很容易理解,使用起来也简单。其中的问题在于,对于同一个路径,它所指向的文件的内容是可变的。在不同的时间点上访问同一个路径的文件,它所包含的内容会产生变化。
容器镜像的一个重要优势是不可变。为了满足这种需求,容器镜像使用的是内容可寻址的方式。基本的思路是对每一个文件,使用摘要算法得到该文件的摘要。使用该摘要作为该文件的寻址方式。SHA-256 和 SHA-512 是常用的摘要算法。
当文件的内容发生变化时,它的摘要也会发生变化。使用同一个摘要,总是可以找到同样的内容。在这种寻址方式下,可以把文件系统看成是一个巨大的哈希表。哈希表的键是摘要,而对应的值则是文件的内容。
工具支持
在开始之前,需要安装 skopeo 和 jq 两个工具。以 Ubuntu 为例,下面给出了相关的安装命令:
$ sudo apt-get update
$ sudo apt-get -y install skopeo
$ sudo apt-get -y install jq
复制镜像
作为示例的是 Nginx 的镜像。首先使用 skopeo copy
命令把 Nginx 镜像转换成 OCI 镜像的格式。skopeo copy
的源 docker://nginx
表示 Docker Hub 上的标签为 latest
的 Nginx 镜像,目标 oci:local_nginx
的前缀 oci:
表示 OCI 镜像格式,local_nginx
是本地目录的名称。
$ skopeo copy docker://nginx oci:local_nginx
下面是该命令的输出结果。
Getting image source signatures
Copying blob eff15d958d66 done
Copying blob 1e5351450a59 done
Copying blob 2df63e6ce2be done
Copying blob 9171c7ae368c done
Copying blob 020f975acd28 done
Copying blob 266f639b35ad done
Copying config 9d446b871e done
Writing manifest to image destination
Storing signatures
该命令运行完成之后,Nginx 镜像的内容就以 OCI 镜像规范的格式保存到了本地。
镜像的内容
转到 local_nginx
目录,使用 tree
命令查看该目录的内容。
$ tree --du .
在该目录中,根目录下有两个文件 oci-layout
和 index.json
。目录 blobs
中包含了文件的实际内容,使用的是内容寻址的方式。目录名称 sha256
表示内容摘要的算法,对应于 SHA-256。文件名是摘要。
.
├── [ 56737397] blobs
│ └── [ 56733301] sha256
│ ├── [ 668] 020f975acd28936c7ff43827238aed4771d14235dc983389ec149811f7e0b7cf
│ ├── [ 25347687] 1e5351450a593c3a3d7a5104f93c8b80d8dc00c827158cb3a5bf985916ea3f75
│ ├── [ 1394] 266f639b35ad602ee76c3b4d4cf88285a50adf8f561d8d96d331db732fe16982
│ ├── [ 602] 2df63e6ce2be0b3cefd3e659558e92b8085f032db96828343ec9cf0b7d4409fe
│ ├── [ 895] 9171c7ae368c6ca24dae913fce356801f624f656360c78ca956a92c3f0fe0ec7
│ ├── [ 6566] 9d446b871e5882110acf8dc0ab827425b8d25184f9426b12b2073186a0b2cdce
│ ├── [ 1126] b77780a5c0973c290799dea52ccbc975f61954907de8108d6f99e65a44fa7623
│ └── [ 31370267] eff15d958d664f0874d16aee393cc44387031ee0a68ef8542d0056c747f378e8
├── [ 187] index.json
└── [ 31] oci-layout
56741711 bytes used in 2 directories, 10 files
oci-layout
是 OCI 镜像规范的占位符,其中的内容如下所示。
{"imageLayoutVersion": "1.0.0"}
镜像索引文件
index.json
是 OCI 镜像索引文件,对应的规范是 OCI Image Index Specification,使用媒体类型 application/vnd.oci.image.index.v1+json
。
查看该文件的内容并使用 jq
进行格式化。
$ cat index.json | jq
输出的内容如下所示:
{
"schemaVersion": 2,
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:b77780a5c0973c290799dea52ccbc975f61954907de8108d6f99e65a44fa7623",
"size": 1126
}
]
}
在 manifests
中包含多个镜像清单文件的引用。每个清单文件通常对应一个平台。这里只有一个清单文件。每个清单描述的 digest
表示文件的摘要,在 blobs
目录中可以找到对应的文件。
镜像清单文件
清单文件对应的规范是 OCI Image Manifest Specification,使用媒体类型 application/vnd.oci.image.manifest.v1+json
。
把清单文件的 digest
值转换成路径,就可以查看清单文件的内容。sha256:b77780a5c0973c290799dea52ccbc975f61954907de8108d6f99e65a44fa7623
被转换成路径 blobs/sha256/b77780a5c0973c290799dea52ccbc975f61954907de8108d6f99e65a44fa762
。
$ cat blobs/sha256/b77780a5c0973c290799dea52ccbc975f61954907de8108d6f99e65a44fa7623 | jq
下面是清单文件的内容。
{
"schemaVersion": 2,
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:9d446b871e5882110acf8dc0ab827425b8d25184f9426b12b2073186a0b2cdce",
"size": 6566
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:eff15d958d664f0874d16aee393cc44387031ee0a68ef8542d0056c747f378e8",
"size": 31370267
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:1e5351450a593c3a3d7a5104f93c8b80d8dc00c827158cb3a5bf985916ea3f75",
"size": 25347687
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:2df63e6ce2be0b3cefd3e659558e92b8085f032db96828343ec9cf0b7d4409fe",
"size": 602
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:9171c7ae368c6ca24dae913fce356801f624f656360c78ca956a92c3f0fe0ec7",
"size": 895
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:020f975acd28936c7ff43827238aed4771d14235dc983389ec149811f7e0b7cf",
"size": 668
},
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:266f639b35ad602ee76c3b4d4cf88285a50adf8f561d8d96d331db732fe16982",
"size": 1394
}
]
}
在清单文件中,config
是镜像的配置文件,layers
是镜像中的层。mediaType
说明了文件的媒体类型。比如,配置文件使用的是 JSON 格式;每个层则是 gzip 压缩的 tar 文件。
镜像配置文件
镜像配置文件对应的规范是 OCI Image Configuration,使用媒体类型 application/vnd.oci.image.config.v1+json
。
可以按照同样的方式来查看配置文件的内容。
$ cat blobs/sha256/9d446b871e5882110acf8dc0ab827425b8d25184f9426b12b2073186a0b2cdce | jq
下面是配置文件的完整内容。
{
"created": "2021-11-17T10:38:14.652464384Z",
"architecture": "amd64",
"os": "linux",
"config": {
"ExposedPorts": {
"80/tcp": {}
},
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NGINX_VERSION=1.21.4",
"NJS_VERSION=0.7.0",
"PKG_RELEASE=1~bullseye"
],
"Entrypoint": [
"/docker-entrypoint.sh"
],
"Cmd": [
"nginx",
"-g",
"daemon off;"
],
"Labels": {
"maintainer": "NGINX Docker Maintainers <docker-maint@nginx.com>"
},
"StopSignal": "SIGQUIT"
},
"rootfs": {
"type": "layers",
"diff_ids": [
"sha256:e1bbcf243d0e7387fbfe5116a485426f90d3ddeb0b1738dca4e3502b6743b325",
"sha256:37380c5830feb5d6829188be41a4ea0654eb5c4632f03ef093ecc182acf40e8a",
"sha256:ff4c727794302b5a0ee4dadfaac8d1233950ce9a07d76eb3b498efa70b7517e4",
"sha256:49eeddd2150fbd14433ec1f01dbf6b23ea6cf581a50635554826ad93ce040b68",
"sha256:1e8ad06c81b6baf629988756d90fd27c14285da4d9bf57179570febddc492087",
"sha256:8525cde30b227bb5b03deb41bda41deb85d740b834be61a69ead59d840f07c13"
]
},
"history": [
{
"created": "2021-11-17T02:20:41.91188934Z",
"created_by": "/bin/sh -c #(nop) ADD file:a2405ebb9892d98be2eb585f6121864d12b3fd983ebf15e5f0b7486e106a79c6 in / "
},
{
"created": "2021-11-17T02:20:42.315994925Z",
"created_by": "/bin/sh -c #(nop) CMD [\"bash\"]",
"empty_layer": true
},
{
"created": "2021-11-17T10:37:39.564148274Z",
"created_by": "/bin/sh -c #(nop) LABEL maintainer=NGINX Docker Maintainers <docker-maint@nginx.com>",
"empty_layer": true
},
{
"created": "2021-11-17T10:37:39.941485145Z",
"created_by": "/bin/sh -c #(nop) ENV NGINX_VERSION=1.21.4",
"empty_layer": true
},
{
"created": "2021-11-17T10:37:40.256097748Z",
"created_by": "/bin/sh -c #(nop) ENV NJS_VERSION=0.7.0",
"empty_layer": true
},
{
"created": "2021-11-17T10:37:40.480423114Z",
"created_by": "/bin/sh -c #(nop) ENV PKG_RELEASE=1~bullseye",
"empty_layer": true
},
{
"created": "2021-11-17T10:38:11.674629445Z",
"created_by": "/bin/sh -c set -x && addgroup --system --gid 101 nginx && adduser --system --disabled-login --ingroup nginx --no-create-home --home /nonexistent --gecos \"nginx user\" --shell /bin/false --uid 101 nginx && apt-get update && apt-get install --no-install-recommends --no-install-suggests -y gnupg1 ca-certificates && NGINX_GPGKEY=573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62; found=''; for server in hkp://keyserver.ubuntu.com:80 pgp.mit.edu ; do echo \"Fetching GPG key $NGINX_GPGKEY from $server\"; apt-key adv --keyserver \"$server\" --keyserver-options timeout=10 --recv-keys \"$NGINX_GPGKEY\" && found=yes && break; done; test -z \"$found\" && echo >&2 \"error: failed to fetch GPG key $NGINX_GPGKEY\" && exit 1; apt-get remove --purge --auto-remove -y gnupg1 && rm -rf /var/lib/apt/lists/* && dpkgArch=\"$(dpkg --print-architecture)\" && nginxPackages=\" nginx=${NGINX_VERSION}-${PKG_RELEASE} nginx-module-xslt=${NGINX_VERSION}-${PKG_RELEASE} nginx-module-geoip=${NGINX_VERSION}-${PKG_RELEASE} nginx-module-image-filter=${NGINX_VERSION}-${PKG_RELEASE} nginx-module-njs=${NGINX_VERSION}+${NJS_VERSION}-${PKG_RELEASE} \" && case \"$dpkgArch\" in amd64|arm64) echo \"deb https://nginx.org/packages/mainline/debian/ bullseye nginx\" >> /etc/apt/sources.list.d/nginx.list && apt-get update ;; *) echo \"deb-src https://nginx.org/packages/mainline/debian/ bullseye nginx\" >> /etc/apt/sources.list.d/nginx.list && tempDir=\"$(mktemp -d)\" && chmod 777 \"$tempDir\" && savedAptMark=\"$(apt-mark showmanual)\" && apt-get update && apt-get build-dep -y $nginxPackages && ( cd \"$tempDir\" && DEB_BUILD_OPTIONS=\"nocheck parallel=$(nproc)\" apt-get source --compile $nginxPackages ) && apt-mark showmanual | xargs apt-mark auto > /dev/null && { [ -z \"$savedAptMark\" ] || apt-mark manual $savedAptMark; } && ls -lAFh \"$tempDir\" && ( cd \"$tempDir\" && dpkg-scanpackages . > Packages ) && grep '^Package: ' \"$tempDir/Packages\" && echo \"deb [ trusted=yes ] file://$tempDir ./\" > /etc/apt/sources.list.d/temp.list && apt-get -o Acquire::GzipIndexes=false update ;; esac && apt-get install --no-install-recommends --no-install-suggests -y $nginxPackages gettext-base curl && apt-get remove --purge --auto-remove -y && rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/nginx.list && if [ -n \"$tempDir\" ]; then apt-get purge -y --auto-remove && rm -rf \"$tempDir\" /etc/apt/sources.list.d/temp.list; fi && ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log && mkdir /docker-entrypoint.d"
},
{
"created": "2021-11-17T10:38:12.409891183Z",
"created_by": "/bin/sh -c #(nop) COPY file:65504f71f5855ca017fb64d502ce873a31b2e0decd75297a8fb0a287f97acf92 in / "
},
{
"created": "2021-11-17T10:38:12.732754797Z",
"created_by": "/bin/sh -c #(nop) COPY file:0b866ff3fc1ef5b03c4e6c8c513ae014f691fb05d530257dfffd07035c1b75da in /docker-entrypoint.d "
},
{
"created": "2021-11-17T10:38:13.174315469Z",
"created_by": "/bin/sh -c #(nop) COPY file:0fd5fca330dcd6a7de297435e32af634f29f7132ed0550d342cad9fd20158258 in /docker-entrypoint.d "
},
{
"created": "2021-11-17T10:38:13.510082553Z",
"created_by": "/bin/sh -c #(nop) COPY file:09a214a3e07c919af2fb2d7c749ccbc446b8c10eb217366e5a65640ee9edcc25 in /docker-entrypoint.d "
},
{
"created": "2021-11-17T10:38:13.827956179Z",
"created_by": "/bin/sh -c #(nop) ENTRYPOINT [\"/docker-entrypoint.sh\"]",
"empty_layer": true
},
{
"created": "2021-11-17T10:38:14.069756108Z",
"created_by": "/bin/sh -c #(nop) EXPOSE 80",
"empty_layer": true
},
{
"created": "2021-11-17T10:38:14.348754639Z",
"created_by": "/bin/sh -c #(nop) STOPSIGNAL SIGQUIT",
"empty_layer": true
},
{
"created": "2021-11-17T10:38:14.652464384Z",
"created_by": "/bin/sh -c #(nop) CMD [\"nginx\" \"-g\" \"daemon off;\"]",
"empty_layer": true
}
]
}
这个配置的内容比较多,介绍几个重要的属性:
config
表示从镜像运行容器时的参数。比如,Env
表示的环境变量,Entrypoint
表示的入口命令,ExposedPorts
表示的开放端口。rootfs
表示镜像的文件系统中包含的层。history
表示每个层的构建历史。有些历史记录对象的empty_layer
的值为true
,表示该历史记录并没有对层进行修改,而只是修改了配置。比如倒数第三条历史记录由EXPOSE 80
指令产生,只是修改了配置。
镜像中的层
下面查看一下层的内容。前面提到过,层的格式是 gzip 压缩的 tar 文件。下面的命令解压缩一个层的内容到 ~/files
目录。
$ tar -xf blobs/sha256/266f639b35ad602ee76c3b4d4cf88285a50adf8f561d8d96d331db732fe16982 -C ~/files
查看 ~/files
目录的内容可以发现,这个层中仅包含一个路径为 /docker-entrypoint.d/30-tune-worker-processes.sh
的文件。
$ tree --du ~/files/
/home/ubuntu/files/
└── [ 8709] docker-entrypoint.d
└── [ 4613] 30-tune-worker-processes.sh
12805 bytes used in 1 directory, 1 file
与 Nginx 镜像的 Dockerfile 对比之后可以发现,这个层是由下面的 COPY
指令生成的。
COPY 30-tune-worker-processes.sh /docker-entrypoint.d
这样就建立起来了 Dockerfile 的指令与镜像的层的对应关系。
解压之后的 OCI 镜像是可以直接运行的。在 Ubuntu 上可以用 podman
来运行。
podman run -d oci:/home/ubuntu/local_nginx