OCI Image Specification 介绍


The Open Container Initiative is an open governance structure for the express purpose of creating open industry standards around container formats and runtimes. ── About the Open Container Initiative - Open Container Initiative

OCI(Open Container Initiative) 是在 2015年 由 Linux Foundation 支持,由 Docker, CoreOS 等其他容器行业内的领导者共同发起的 容器镜像实现的格式标准, 旨在推广容器格式(format) 和运行时(runtime) 的开放治理结构(open governance structure), 创建开放的工业标准。OCI 的成立主要是为了解决容器技术在快速发展过程中出现的标准化问题,确保容器的可移植性和互操作性,让容器能够在各种环境中无缝运行。

OCI 规定了关于容器的两部分标准:

  • Image Spec: 关于容器镜像如何实现(或者说打包) 的标准
  • Runtime Spec: 关于容器如何运行的标准

本文的内容聚焦于 Image Spec 相关介绍,主要来源为 image-spec/config.md at main · opencontainers/image-spec ,尝试结合实际例子以及个人认为更符合逻辑的理解顺序来梳理 Image Spec 的主要概念。

Demo

在有条件的情况下读者可以先按如下步骤进行相关 demo 的准备,结合 Demo 进行 Image Spec 相关实体的理解更能增强体感。

# 拉取 nginx:latest 镜像到本地,nginx:latest 是本文选择的符合 OCI image 的镜像
$ docker pull nginx:latest
# 以 tarball 形式保存 nginx 镜像
$ docker image save -o nginx.tar nginx:latest
# 创建并解压保存下来的 tarball 到指定文件夹
$ mkdir nginx && tar -xvf nginx.tar -C ./nginx

总览

image-20240214161500085

在开始深入介绍前我们先对 OCI Image Spec 有个大概的认识,为了方便理解我们不妨先将 OCI Image Spec 分为两个部分

  • Spec Context: 对 Image 的结构、格式、描述方法所做出的约束和规范。又包括
    • Layout: 对 Image 实现的文件夹结构的规范
    • Media Type: 对 Image 设计的文件(或者叫 component) 的类型的约定
    • Descriptor: 定义了 Image 中 component 之间进行关联的结构体
  • Spec components: Image 实现中需要包含的具体组成实体, 有各自对应的文件。

接下来本文将结合上文 nginx tarball 例子,先从 Spec Context 开始对 OCI Image 建立规范上的认识,再分别介绍各个 Spec Components, 每一块内容标题下都提供了对应的原始文档链接,有兴趣的读者可以点击进入深入了解。

Spec Context

Layout

image-spec/image-layout.md at main · opencontainers/image-spec

OCI Image Layout 规定了 Image 的文件夹组织方式,通过遵守 Layout, 相关的工具可以对一个镜像按照一致的路径处理成符合 OCI Runtime Spec 的 bundle。

Layout 规定了一个镜像需要包含 blobs 文件夹, oci-layout 文件,index.json 文件, 这三种文件类型在我们的 demo 中也可以看到:

$ ls
blobs         index.json    manifest.json     oci-layout    repositories

blobs 文件夹

blob 是 Binary Large Object 的缩写,通常指的是镜像中的二进制数据块。在 OCI Image Spec 的上下文中,我们可以直接将所有通过内容进行 hash 索引的文件理解成是 blob。

blobs 文件夹存储的就是使用特定 Hash 算法计算过的文件,它的子目录的结构为 <alg>/<encoded>, <alg> 为对应的 Hash 算法 ,<encoded> 为该 Hash 算法对内容进行过 Hash 后的值。

比如在我们的 demo 中, blobs 包含如下文件

$ tree
.
├── blobs
│   └── sha256
│       ├── 02eecd1b8ebb27cc1f576804168486c4c5c3180d22c50048fdaa546b581adec9
│       ├── 047c9d07f2a3abf9e7c546307dc69cb5147b1cb8d2f81b58d36d2be57fea6257
│       ├── 0655e46321e4ed76b200f52007cb59fcc3801faccd391f6795712028d7536c6f
│       ├── 11deb55301007d6bf1db2ce20cb5d12e447541969990af4a03e2af8141ebdbed
│       ├── 1adb92f0b5e98c8db798082a7877732ad1f5e273577b63ac9c1a505f3e8ed61c
│       ├── 31eaf3687d312550bf26d9d649c95283ebc46b03aeda439952d4fba2fd3187e6
│       ├── 37be4081a37453c9e9f11af799eb037c47664a0549ddfa236b3e57bab95ba64b
│       ├── 46dca80a0cef221684a9da329176ad5bc6d6f77c379ad234a9e5931795f975d3
│       ├── 5c23503565e9eb6c31f511199a6f80721b2750d00d174011bcc80248b2b896ff
│       ├── 8790436822597f6bbc1de71c6e9d25636fd687fb8a60dfc2c69e0bf3d330349a
│       ├── 8d80ad266f0138f5eedd03c130279b1059fed471346f1656c5bf0bfa1d3d12ed
│       ├── bb23c2def74f2ccbf902543ca6675687e5f662e3d991fbd9938dbea06c5faefc
│       ├── c356d79b264683ba6151321749af21974030d674d1a607e2c888c4598017620b
│       ├── ced4d951e1bfe83757aa885f51c2b48d081e0c23a87af7d01386a62cec11824c
│       ├── df91f10d317c00ef9f4ce4359c893a96e621ae6ee532649af2e42b774315e528
│       ├── f2b5466eadaed1957e9c445669168b5a90e9e259a2dd5990af5f3ebb7ad50df0
│       └── f63419ada981fd98f7a67208d219167f82c6097de0e747c9f42c6c86924afc47

即一系列由 sha256 对内容进行过 Hash 的文件。

对于这些文件,我们可以通过如下方式验证其内容与 Hash 值是匹配的

$ shasum -a 256 ./blobs/sha256/02eecd1b8ebb27cc1f576804168486c4c5c3180d22c50048fdaa546b581adec9
02eecd1b8ebb27cc1f576804168486c4c5c3180d22c50048fdaa546b581adec9  ./blobs/sha256/02eecd1b8ebb27cc1f576804168486c4c5c3180d22c50048fdaa546b581adec9

因此这些 blobs 是 content-addressable 的,即是由他们的内容决定存储位置的 blob, 这在 OCI Image Spec 中是一个相对比较重要的概念: 除了可以根据 Hash 值进行标识符进行基本的 ref 和 索引, 还可以起到校验的作用。

OCI Image 的 blobs 包含了镜像的大部分内容,在我们总览的 Spec Components 中,除了 Layout 里单独提到的 index 文件,其他 component 都保存在 blobs 文件夹下, 除此之外 blobs 也可能包含一些各个工具自身实现需要的(在 OCI Image Spec 之外的)额外的文件。

oci-layout 文件

oci-layout 文件的包含的内容很简单,通常只包含 imageLayoutVersion 字段信息,表明镜像所适配的 layout 版本号。

如我们 demo 中的 oci-layout 内容如下

{ "imageLayoutVersion": "1.0.0" }

index.json 文件

对应 spec components 中的 index 文件,index.json 文件是 OCI Image 的入口。我们在后文的 components 中会展开介绍,这里不再赘述。

Media Type

image-spec/media-types.md at main · opencontainers/image-spec

Media Type 定义了 OCI Image 各个组件的类型,对应到组件里的 mediaType 字段, 根据这个字段我们便可以知道某个文件对应的 component 及一些基础的元数据信息。

Media Type 基本是用户可读的, 比如如果一个 Image 文件对应的 mediaTypeapplication/vnd.oci.image.layer.v1.tar+gzip 从字面上我们就可以看出这个 文件对应的是 OCI v1 版本下的 layer 组件,通过 tar 进行了打包并且通过 gzip 进行了压缩。

除了定义类型,Media Type 还起到识别冲突和判断兼容性的作用。

  • 识别冲突: 当工具请求获得 blob 的信息时,返回的元数据信息中的类型与工具实现所期望的 mediaType 可能不匹配,这种情况下工具需要遵守 mediaType 对冲突的响应规范进行处理或报错。
  • 兼容性判断: OCI Image Spec 会尽可能地向前及向后兼容。同时对历史(OCI 之前)的 Image 配置类型,Spec 中也给出了明确的兼容性矩阵帮助进行判断。当出现兼容性问题时,就可能发生如镜像拉取/推送失败,或者容器启动失败等问题。

Descriptor

image-spec/descriptor.md at main · opencontainers/image-spec

如上文所说,OCI Image 包含了多个不同的 component, 这些 component 需要进行相互关联,这些 component 的关联关系就以 Content Descriptors(=Descriptor) 表示。

Descriptor 通常包含如下内容:

  • mediaType : 即上文介绍的 mediaType 内容
  • digest: 记得我们上面提到 blobscontent-addressable 的吗? digest 记代表了引用的组件的标识符,同时我们也能根据这个标识符在 blobs 文件夹中找到对应的资源
  • sizeblobs 原始内容的大小(bytes)

Spec Components

Index

image-spec/image-index.md at main · opencontainers/image-spec

index 是 OCI Image 的入口(entry point), 也是多架构镜像中指向多个单一平台镜像的更高视角的索引入口。

index 通常对应的文件便是上文 Layout 中提到的 index.json, 我们结合 nginx Demo 看一下 index.json 文件中包含的具体内容

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:f2b5466eadaed1957e9c445669168b5a90e9e259a2dd5990af5f3ebb7ad50df0",
      "size": 1356,
      "annotations": {
        "io.containerd.image.name": "docker.io/library/nginx:latest",
        "org.opencontainers.image.ref.name": "latest"
      },
      "platform": { "architecture": "arm64", "os": "linux" }
    }
  ]
}

如 Demo 所示,典型的 index.json 包含的 properties 有以下内容:

  • schemaVersion int: 标识了 image 的 manifest 的 schema version, 对于当前(20240214) 的 OCI Image Spec 版本,这个 property 的值为 2

  • mediaType string: 标识 index 的 mediaType 的字段, 值为 application/vnd.oci.image.index.v1+json

  • manifests array of objects: 包含特定 platform 对应的 manifest descriptor。结合 descriptor 的字段定义,我们对其中的关键字段可以进一步展开:

    • mediaType : application/vnd.oci.image.manifest.v1+json 表示引用的是 manifest.
    • digest: manifest 的 digest, 可以在 blobs/sha256 中找到对应的文件
  • platform: 表示对应的 manifest 需要满足的最小 runtime 要求

Manifest

manifest 组件提供了单一架构/操作系统下的镜像所需要的配置和 layers 信息,允许工具如 registry 和运行时确定需要拉取的配置内容和各 layers 层信息。

根据 index.json 文件的 manifests[*].digest , 我们可以在 blobs 文件夹中找到对应的manifest(可能有些工具为自身实现的需要会在根目录下直接冗余 manifest.jon 文件,OCI image spec 的定义中 manifest 是在 blobs 中保存的)。

# f2b5466eadaed1957e9c445669168b5a90e9e259a2dd5990af5f3ebb7ad50df0 是在 index.json 文件中获得的 manifest 的 digest 值
$  cat blobs/sha256/f2b5466eadaed1957e9c445669168b5a90e9e259a2dd5990af5f3ebb7ad50df0 | jq

文件内容如下

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:11deb55301007d6bf1db2ce20cb5d12e447541969990af4a03e2af8141ebdbed",
    "size": 7016,
    "platform": {
      "architecture": "arm64",
      "os": "linux"
    }
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar",
      "digest": "sha256:02eecd1b8ebb27cc1f576804168486c4c5c3180d22c50048fdaa546b581adec9",
      "size": 100168192
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar",
      "digest": "sha256:ced4d951e1bfe83757aa885f51c2b48d081e0c23a87af7d01386a62cec11824c",
      "size": 95961088
    },
    // ....
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar",
      "digest": "sha256:047c9d07f2a3abf9e7c546307dc69cb5147b1cb8d2f81b58d36d2be57fea6257",
      "size": 5120
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar",
      "digest": "sha256:8d80ad266f0138f5eedd03c130279b1059fed471346f1656c5bf0bfa1d3d12ed",
      "size": 7168
    }
  ]
}

我们来看看其中涉及的具体的 properties:

  • schemaVersionmediaType 基本和上文 index 文件中所述一致
  • config descriptor: 指向对应镜像的配置文件,后文对 config 本身会有具体介绍。
  • layers array of descriptors: 指向构成镜像的 layers 列表,layers 之间存在以下关系
    • index 0 位置的 layer 是 image 的 base layer
    • layers 的顺序符合镜像打包的堆叠顺序
    • image 最终构成的 filesystem 匹配在一个空的文件夹下 apply 所有的 layers

Config

image-spec/config.md at main · opencontainers/image-spec

对于一个 OCI Image, 我们可以认为它是在特定容器运行时环境下的 layers/changesets 和 运行时参数的组合。

config 文件描述了 OCI Image 运行时所需要的信息,包括环境变量、入口(Entrypoint) 等,主要作用就是告诉工具如何运行指定镜像。 除此之外 config 文件也包含了一些镜像自身的元数据信息,如 author、构建历史等。

我们从上文的 manifest 文件中可以获得对应的 config blobs 的 sha值, 在 blobs 文件夹中查找对应的文件

# 11deb55301007d6bf1db2ce20cb5d12e447541969990af4a03e2af8141ebdbed 是在 manifest blob 中获得的 config 的 digest 值
$  cat blobs/sha256/11deb55301007d6bf1db2ce20cb5d12e447541969990af4a03e2af8141ebdbed | jq

文件内容如下

{
  "architecture": "arm64",
  "config": {
    "ExposedPorts": {
      "80/tcp": {}
    },
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "NGINX_VERSION=1.25.3",
      "NJS_VERSION=0.8.2",
      "PKG_RELEASE=1~bookworm"
    ],
    "Entrypoint": ["/docker-entrypoint.sh"],
    "Cmd": ["nginx", "-g", "daemon off;"],
    "Labels": {
      "maintainer": "NGINX Docker Maintainers <docker-maint@nginx.com>"
    },
    "StopSignal": "SIGQUIT",
    "ArgsEscaped": true,
    "OnBuild": null
  },
  "created": "2023-10-24T22:44:45Z",
  "history": [
    {
      "created": "2023-10-24T22:44:45Z",
      "created_by": "/bin/sh -c #(nop) ADD file:ef6f078c1e72fcfafb9bfeeff0c1c771219dc2efe34650963106f63d32183b49 in / "
    },
    {
      "created": "2023-10-24T22:44:45Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"bash\"]",
      "empty_layer": true
    },
    // ....
    {
      "created": "2023-10-24T22:44:45Z",
      "created_by": "STOPSIGNAL SIGQUIT",
      "comment": "buildkit.dockerfile.v0",
      "empty_layer": true
    },
    {
      "created": "2023-10-24T22:44:45Z",
      "created_by": "CMD [\"nginx\" \"-g\" \"daemon off;\"]",
      "comment": "buildkit.dockerfile.v0",
      "empty_layer": true
    }
  ],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:02eecd1b8ebb27cc1f576804168486c4c5c3180d22c50048fdaa546b581adec9",
      "sha256:ced4d951e1bfe83757aa885f51c2b48d081e0c23a87af7d01386a62cec11824c",
      "sha256:5c23503565e9eb6c31f511199a6f80721b2750d00d174011bcc80248b2b896ff",
      "sha256:df91f10d317c00ef9f4ce4359c893a96e621ae6ee532649af2e42b774315e528",
      "sha256:1adb92f0b5e98c8db798082a7877732ad1f5e273577b63ac9c1a505f3e8ed61c",
      "sha256:047c9d07f2a3abf9e7c546307dc69cb5147b1cb8d2f81b58d36d2be57fea6257",
      "sha256:8d80ad266f0138f5eedd03c130279b1059fed471346f1656c5bf0bfa1d3d12ed"
    ]
  }
}

这里不再逐个对 config 文件的每个 property 展开描述,对于大部分 properties 大家可以直接根据 key 值推测出准确的含义。只单独说明一下 rootfs 字段。

rootfs 字段中的 diff_ids描述了 image 各层次的 layer hash 值,并且也是从 base layer 往上层 layer 的顺序排列。大家可能会发现这个列表中的内容和上文 manifest 中的 layers digest 是一致的,实际上这两者在内容上存在区别: manifest layer digest 可以是根据 layer blob 的 压缩或原始内容进行hash 计算,而 diff_ids 只能是根据未压缩的 tarball 内容进行的计算。通过在 rootfs 中也保存 diff_ids ,除了基本的校验能力,也能使 config blob 本身依赖于 layers, 增强镜像整体数据的一致性。

Layers

Layers 是构成镜像具体文件系统的 tarball 集合,也是镜像打包的主体内容。通过按 manifestlayers 所展示的顺序解压各个 layer blob, 我们需要能够获得与镜像容器大致匹配的目录结构。

我们仍然以 Demo 所示的 layers 进行操作演示,对比按顺序解压各个 layer blob 所获得的最终的文件结构和进入容器的文件结构情况。

解压各个 layer blob 到特定文件夹:

$ mkdir stack_layers
$ tar -xvf blobs/sha256/02eecd1b8ebb27cc1f576804168486c4c5c3180d22c50048fdaa546b581adec9 -C stack_layers
$ tar -xvf blobs/sha256/ced4d951e1bfe83757aa885f51c2b48d081e0c23a87af7d01386a62cec11824c -C stack_layers
$ tar -xvf blobs/sha256/5c23503565e9eb6c31f511199a6f80721b2750d00d174011bcc80248b2b896ff -C stack_layers
$ tar -xvf blobs/sha256/df91f10d317c00ef9f4ce4359c893a96e621ae6ee532649af2e42b774315e528 -C stack_layers
$ tar -xvf blobs/sha256/1adb92f0b5e98c8db798082a7877732ad1f5e273577b63ac9c1a505f3e8ed61c -C stack_layers
$ tar -xvf blobs/sha256/047c9d07f2a3abf9e7c546307dc69cb5147b1cb8d2f81b58d36d2be57fea6257 -C stack_layers
$ tar -xvf blobs/sha256/8d80ad266f0138f5eedd03c130279b1059fed471346f1656c5bf0bfa1d3d12ed -C stack_layers

export container tar & extract

$ docker export $(docker create nginx:latest) -o container_fs.tar
$ mkdir container_fs && tar -xvf container_fs.tar -C container_fs

简单对比两者的文件树差异

$ tree ./stack_layers > stack_tree
$ tree ./container_fs > container_tree
$ diff stack_tree container_tree

输出如下
1c1
< ./stack_layers
---
> ./container_fs
4a5,7
> │   ├── console
> │   ├── pts
> │   └── shm
144a148
> │   ├── hosts
167a172
> │   ├── mtab -> /proc/mounts
5774c5779
< 658 directories, 5113 files

可以看到就 nginx 镜像来说,完全基于 layers tar 包解压缩获得的文件树与 container export tar 解压的文件树存在少量文件差异。这些差异主要是由于容器运行时动态创建或挂载机制生成的,如 hosts 文件是用于容器内部的 DNS 解析使用的, 而 mtab 是一个符号链接,指向挂载的文件系统信息。

当然除此之外还有其他因为容器打包逻辑或是容器运行时造成的内容差异,在这里我们不(从 diff 分析角度) 展开,这里的主要目的是证明layers tar 包与容器 fs 之间的一致性。

接下来我们(以官方文档中的例子)介绍一下如何 from scratch 基于 fs 构造 OCI Image 各层的 tar 包,这有助于帮助我们理解镜像的运行机制。

Layer Tarballs Creation

1. Initial Root Filesystem & Populate Initial Filesystem

先定义好一个基本的 base layer,并且构建好基本的文件结构, 如下

# base layer 包含的文件信息
rootfs-c9d-v1/
  etc/
    my-app-config
  bin/
    my-app-binary
    my-app-tools

rootfs-c9d-v1 打成 tar 包,便是我们的 base layer tarball, 即 layers 中 index 0 位置的 layer。

2. Populate a Comparison Filesystem

准备好 base layer tarball 之后,便是重复往上堆叠 changeset, 每个 changeset 在 OCI Image 中对应一层 layer,在我们使用 Docker 进行镜像打包的过程中即对应 Dockerfile 里的文件变更。

基于上一层进行文件变更的方式如下:

  1. 创建一个新的文件夹, copy/snapshot 上一步构建好的 filesystem 到这个文件夹中。这一步有两个要求:

    1. 使用的 copy/snapshot 工具需要能保留上一层文件的 attributes
    2. 对于 copy/snapshot 后的文件的修改不能影响上一层的文件内容

    这一操作镜像打包工具通常使用如 CoW(copy-on-write)技术或 UnionFS 来更高效地实现。

3. Determining Changes

进行完成变更后,镜像构建工具需要进行修改的文件集合及修改内容的确定。最终确定的信息大概如下

Added:      /etc/my-app.d/
Added:      /etc/my-app.d/default.cfg
Modified:   /bin/my-app-tools
Deleted:    /etc/my-app-config

4. Representing Changes

以上步骤对应的变更即 changeset 文件, 会被单独打成该 layer 对应的 tarball, 打包的逻辑如下

  • Add/Modify 操作: 对对应的文件及文件夹进行打包
  • Delete: 对对应的文件/文件夹 标记为 whiteout , whiteout 存在两种标记方法
    • 单文件的 whiteout 形式,以 .wh. 作为文件前缀的空文件
    • 标识同文件夹下所有文件都被一处的 opaque whiteout 形式: 被删除文件内容的文件夹下存在 .wh..wh..opq 文件

在 apply layer 的时候,whiteout 文件会先被进行处理,即被标记 whiteout 的文件会先进行删除,再应用 layer 的其他变更, 通过顺序保证了镜像打包的时候不会将修改的内容删除。

从这里也可以推导出,如果镜像 layer 中存在删除操作,那么直接根据 tarball 解压获得的文件夹内容会比从容器中 export 的文件内容多出 whiteout 文件

Summary

以上差不多就是 OCI Image Spec 的大致内容,总的来说就是 OCI 对镜像打包在描述格式、文件结构、内容规范定义了标准,通过遵循这些标准使各个工具的镜像打包得以保持一致。

除了 Image Spec, OCI 还对 Runtime Spec 做出了规范,这部分内容后续在单独的文章中介绍。

comments powered by Disqus