
1.4 容器镜像的结构
容器有不可改变性(immutability)和可移植性(portability)。容器把应用的可执行文件、依赖文件及操作系统文件等打包成镜像,使应用的运行环境固定下来不再变化;同时,镜像可在其他环境下重现同样的运行环境。这些特性给运维和应用的发布带来极大的便利,这要归功于封装应用的镜像。
鉴于容器镜像的重要性,本节先以Docker镜像为例,介绍容器镜像的结构和机理,并在此基础上说明OCI镜像规范的细节。OCI的镜像规范已得到诸多云原生项目的支持和使用,甚至已经在其他领域应用。读者可以先了解Docker镜像的实现原理,然后以此理解OCI镜像规范。
1.4.1 镜像的发展
2013年,Docker推出容器管理工具,同时发布了封装应用的镜像。这是Docker与之前各种方案的重大区别,也是Docker得以胜出和迅速流传的主要原因。可以说,镜像体现了Docker容器的核心价值。由于历史原因,目前仍在使用的Docker镜像可能遵循了不同版本的镜像规范,因此本节介绍各个版本的镜像特点及相互关系,以便读者在实际应用中加以甄别。
2014年,Docker把其镜像格式归纳和定义为Docker镜像规范v1。在这个规范中,镜像的每个层文件(layer)都包含一个存放元数据的JSON文件,并且用父ID来指明上一层镜像。这个规范有两个缺点:镜像的ID是随机生成的,可近似认为具有唯一性,可以用来标识镜像,但是用相同内容构建出来的层文件的ID并不一样,通过ID无法确认完全相同的层,不利于层的共享;每层都绑定了父层,紧耦合的结构不利于独立存放层文件。
2016年,Docker制定了镜像规范v2,并在Docker 1.10中实现了这个规范。镜像规范v2分为Schema 1和Schema 2。Schema 1主要兼容使用v1规范的Docker客户端,如Docker 1.9及之前的客户端。Schema 2主要实现了两个功能:支持多体系架构的镜像和可通过内容寻址的镜像,其中最大的改进就是根据内容的SHA256摘要生成ID,只要内容相同,ID就是一样的,可区分相同的层文件(即可内容寻址)。Schema 2镜像的各层统一在manifest.json文件中描述,简化了分发和存储方面的流程。从2017年2月起,镜像规范v1不再被Registry支持,用户需要把已有的v1镜像转化为v2镜像才能推送到Registry中。
OCI在2017年7月发布了OCI镜像规范1.0。因为Docker v2的镜像规范已经成为事实上的标准,OCI镜像规范实质上是以Docker镜像规范v2为基础制定的,因此二者在绝大多数情况下是兼容或相似的。如Docker镜像规范中的镜像索引(image index)和OCI镜像规范中的清单索引(manifest index)是等价的。
1.4.2 Docker镜像的结构
Docker容器镜像主要包含的内容是应用程序所依赖的根文件系统(rootfs)。这个根文件系统是分层存储的,基础层通常是操作系统的文件,然后在基础层上不断叠加新的层文件,最终将这些层组合起来形成一个完整的镜像。当通过镜像启动容器时,镜像所有的层都转化成容器里的只读(read only)文件系统。同时,容器会额外增加一个读写层,给应用程序运行时读写文件使用。这样的层文件结构可由联合文件系统(UnionFS)实现。
Docker容器镜像可以用“docker commit”命令来生成。这种方法适用于试验性的镜像,用户在容器中执行各种操作,达到某种合乎要求的状态时,用“docker commit”命令把容器的状态固化下来成为镜像。由于该方法需要用户手动输入命令,因此不适合在自动化流水线里面使用。所以,通常镜像是由“docker build”命令依照Dockerfile构建的,Dockerfile描述了镜像包含的所有内容和配置信息(如启动命令等)。下面是一个简单的Dockerfile例子:

在这个例子中,容器镜像的基础镜像是操作系统Ubuntu 20.04,然后安装Python软件包,再安装Python库NumPy,最后增加应用程序myApp。在镜像构建完成之后会有4个层文件,如图1-9所示。

图1-9
图1-9中的镜像层在容器创建时作为只读文件系统加载到容器中,此外,容器运行时会为每个容器实例都创建一个可读写层,叠加在文件系统的最上层,用于应用读写文件。容器的不可改变性就是通过镜像的镜像层(只读)实现的。另外,无论镜像在哪种环境下启动,始终有相同的镜像层,从而实现了应用的可移植性。
Docker使用分层来管理镜像,有以下好处。
(1)方便基础层和依赖软件层的共享(如包含操作系统文件、软件包等),不同的镜像可以共享基础层或软件层,在同一台机器上存放公共层的镜像时只需保存一份层文件,可以大大减少文件存储空间。
(2)在构建镜像时,已构建过的层会被保存在缓存中,再次构建时如果下面的层不变,则可以通过构建缓存来缩短构建时间。
(3)因为很多时候同一个应用的镜像更新时变化的只是最上层(应用层),所以分层可以减少同种镜像的分发时间。
(4)分层可以更加方便地跟踪镜像的变化,因为每一层都是和构建命令关联的,所以可以更好地管理镜像的变化历史。
Docker容器的文件系统分层机制主要靠联合文件系统(UnionFS)来实现。联合文件系统保证了文件的堆叠特性,即上层通过增加文件来修改依赖层文件,在保证镜像的只读特性时还能实现容器文件的读写特性。
联合文件系统是一种堆叠文件系统,通过不停地叠加文件实现对文件的修改,对文件的操作一般包含增加、删除、修改。其中,增加操作很容易通过在新的读写层增加新的文件实现,而删除操作一般通过添加额外的删除属性文件实现。比如,删除a.file文件时,只需在读写层增加一个a.file.delete文件即可屏蔽(删除)该文件。修改只读层文件时,需要先复制一份文件到读写层,然后修改复制的文件。
目前主要的联合文件系统有AUFS(Advanced Multi-Layered Unification Filesystem)和OverlayFS等。OverlayFS是第一个被合并到Linux内核的联合文件系统,在Linux内核4.0以上的发行版中,OverlayFS得到越来越多的应用。Docker也使用OverlayFS 2.0的驱动,OverlayFS的2.0版本效率更高,做了很多优化。
OverlayFS 2.0由LowerDir、UpperDir和MergedDir组成,其中LowerDir可以有多个,对应容器文件系统的结构是只读层;UpperDir 是读写层,可以记录容器中的修改;MergedDir则是这些文件目录合并的结果,是容器最终挂载的文件目录,也是用户实际看到的文件目录,如图1-10所示。

图1-10
在图1-10中,LowerDir文件的目录合并是有顺序的,LowerDir和UpperDir的目录合并也有先后关系。优先级顺序是LowerDir底层最先合并,然后是上层的LowerDir,最后是UpperDir。LowerDir1和LowerDir2都有File2文件,合并之后LowerDir2的File2文件覆盖LowerDir1的同名文件;若UpperDir和LowerDir2中同时有File3文件,则合并之后UpperDir的File3文件覆盖LowerDir2的同名文件。而LowerDir1中的File1文件因为没被上层覆盖,会被完全合并到最终的目录下。所以合并之后,MergedDir中的文件如图1-10所示,这也是用户能看到的目录结构。如果用户修改UpperDir中的文件,则会直接修改对应的文件;如果用户尝试修改LowerDir中的文件,则会先在UpperDir中复制这个文件,然后在复制的文件中进行修改。
1.4.3 Docker镜像的仓库存储结构
Docker容器镜像的存储分为本地存储和镜像仓库(Registry)存储。其中,本地存储指镜像下载到本地后是如何在本地文件系统中存储的;镜像仓库存储指镜像以什么方式存储在远端的镜像仓库中。镜像存储的本质还是分层存储,但是本地存储和镜像仓库存储的方式不完全一样,最大的区别是,镜像仓库存储的核心是方便镜像快速上传和拉取,所以镜像存储使用了压缩格式,并且按照镜像层独立压缩和存储,然后使用镜像清单(manifest)包含所有的层,通过镜像摘要(digest)和Tag关联起来;镜像在本地存储的核心是快速加载和启动容器,镜像层存储是非压缩的(即源文件)。另外,容器在启动时需要将镜像层按照顺序堆叠作为容器的运行环境,所以镜像在本地存储中需要使用非压缩形式存放。
在说明镜像的存储格式之前,先介绍拉取同一个Docker镜像时可使用的两种不同命令格式。如下所示,“latest”是镜像的Tag,“sha256:46d659…a3ee9a”是镜像的摘要,在支持Docker镜像规范v2 Schema 2的镜像仓库中,二者都标识同一个镜像:

在镜像仓库上存储容器镜像的简化结构如图1-11所示,主要由三部分组成:清单文件(manifest)、镜像文件(configuration)和层文件(layers)。上面命令中的镜像摘要就是依据镜像清单文件内容计算SHA256哈希值而来的,在镜像清单文件中存放了配置文件的摘要和层文件的摘要,这些摘要都是通过具体的文件内容计算而来的,所以镜像存储也叫作内容寻址。这样做的好处是,除了可以唯一标识不同的文件,还可以在传输过程中通过摘要做文件校验。在文件下载完成后,计算所下载文件的摘要值,然后与下载时的摘要标识进行对比,如果二者一致,即可判断下载的文件是正确的。需要指出的是,由于文件在镜像仓库端是以压缩形式存放的,所以摘要值也是基于压缩文件计算而来的。

图1-11
下面是Docker镜像清单的示例,使用的是v2 Schema 2规范,在镜像清单中包含一个配置文件(config属性)和3个层文件(layers属性)的引用信息,都是通过文件的摘要值digest来标识的。在镜像清单中有个重要的概念——媒体类型(mediaType),在客户端下载镜像时,通过媒体类型可获得摘要所指向的文件类型,从而做出相应的处理。如以下示例中的媒体类型是 application/vnd.docker.container.image.v1+json 时,可知摘要sha256:d646ab…7537bc7引用的是配置文件,从而可以按照配置文件的格式来解析。

下面的示例是Docker镜像配置文件中关于rootfs的片段,包含了未压缩层文件的摘要(DIFF_ID):


在docker镜像规范v2 Schema 2中还定义了适用于发布多平台支持的镜像索引,可指向同一组镜像适配不同平台的镜像清单(如amd64和ppc64le等),示例如下:

最后简单说明镜像的 Tag。镜像的 Tag 主要用于对镜像赋予一定的标记,格式是“<repository>:<Tag>”,可以标识镜像的版本或其他信息,也可以标识一个镜像,如ubuntu:20.0、centos:latest等。Tag在镜像仓库中可与镜像清单或者镜像索引关联,多个Tag可以对应同一个镜像清单或镜像索引,由镜像仓库维护着它们的映射关系,可参考图1-11(图中未包含镜像索引)。当客户端拉取镜像时,既可用Tag,也可用镜像摘要获取同样的镜像。
1.4.4 Docker镜像的本地存储结构
Docker客户端从镜像仓库拉取一个镜像并存储到本地文件系统的过程大约如下。
(1)向镜像仓库请求镜像的清单文件。
(2)获取镜像ID,查看镜像ID是否在本地存在。
(3)若不存在,则下载配置文件config,在config文件中含有每个层文件未压缩的文件摘要DIFF_ID。
(4)检查层文件是否在本地存在,若不存在,则从镜像仓库中拉取每一层的压缩文件。
(5)拉取时,使用镜像清单中压缩层文件的摘要作为内容寻址下载。
(6)下载完一层的文件后,解压并按照摘要校验。
(7)当所有层文件都拉取完毕时,镜像就下载完成了。
下载镜像后,在本地查看镜像debian:latest的信息,结果如下:

在IMAGE ID(镜像ID)列显示的1b686a95ddbf是本地镜像的唯一标识ID,可以在“docker”命令中使用。这个ID和镜像仓库中镜像摘要(sha256:46d659…a3ee9a)的形式类似,但是数值不一样,这是因为该ID是镜像配置文件的摘要,所以和镜像仓库使用的清单文件摘要不同。
使用配置文件的摘要作为本地镜像的标识,主要是因为本地镜像存放的文件都是非压缩的文件,而镜像仓库存放的是压缩文件,因此层文件在本地和镜像仓库中有不同的摘要值。因为压缩文件的内容会受到压缩算法等因素的影响,所以同样内容的层无法保证压缩后摘要的唯一性,而镜像清单文件包含压缩层文件的摘要(参考上文示例),因此通过镜像清单文件的摘要(即镜像摘要)无法确定镜像的唯一性。配置文件则不同,其中包含的层信息是未压缩的摘要值,因此相同镜像的各层内容必然相同,配置文件的摘要值是唯一确定的。
另外,在本地存储镜像时,镜像的存储格式和其使用方式息息相关。镜像是按照堆叠目录存放的,堆叠目录的存放是从底层开始,上一层的标识会由下面所有层的DIFF_ID计算而来,这个计算而来的标识叫作CHAIN_ID,计算公式如下:

计算CHAIN_ID标识的好处是,在镜像实际使用过程中,镜像层之间都是有关联的,所以通过这个标识可以快速知道当前镜像层及所有依赖层是否一致,避免仅仅镜像层一致但依赖层不一致的问题,也保证了镜像的有效性。本地存储的镜像结构如图1-12所示。

图1-12
1.4.5 OCI镜像规范
OCI镜像规范是以Docker镜像规范v2为基础制定的,它定义了镜像的主要格式及内容,主要用于镜像仓库存放镜像及分发镜像等场景,与正在制定的OCI分发规范(参见1.5.2节)密切相关。OCI运行时在创建容器前,要把镜像下载并解压成符合运行时规范的文件系统包,并且把镜像中的配置转化成运行时配置,然后启动容器。
OCI 定义的镜像包括4个部分:镜像索引(Image Index)、清单(Manifest)、配置(Configuration)和层文件(Layers)。其中,清单是JSON格式的描述文件,列出了镜像的配置和层文件。配置是JSON格式的描述文件,说明了镜像运行的参数。层文件则是镜像的内容,即镜像包含的文件,一般是二进制数据文件格式(Blob)。一个镜像可以有一个或多个层文件。镜像索引不是必需的,如果存在,则指明了一组支持不同架构平台的相关镜像。镜像的4个部分之间是通过摘要(digest)来相互引用(reference)的。镜像各部分的关系如图1-13所示。

图1-13
下面详细讲解各部分的结构和作用。
1.镜像索引
镜像索引是镜像中可选择的部分,一个镜像可以不包括镜像索引。如果镜像包含了镜像索引,则其作用主要指向镜像不同平台的版本,代表一组同名且相关的镜像,差别只在支持的体系架构上(如i386和arm64v8、Linux和Windows等)。索引的优点是在不同的平台上使用镜像的命令无须修改,如在amd64架构的Windows和ARM架构的Linux上,采用同样的“docker”命令即可运行Nginx服务:

用户无须指定操作系统和平台,就可完全依赖客户端获取正确版本的镜像。OCI的索引已经被CNAB等工具广泛用来管理与云平台无关的分布式应用程序。
下面是一个索引示例:


以上示例中主要属性的意义如下。
◎ schemaVersion:必须是2,主要用于兼容旧版本的Docker。
◎ manifests:清单数组,在上面的例子中含有两个清单,每个清单都代表某个平台上的镜像。mediaType指媒体类型,其值为application/vnd.oci.image.manifest.v1+json时,表明是清单文件。size指清单文件的大小。digest指清单文件的摘要。platform指镜像所支持的平台,包括CPU架构和操作系统。
◎ annotations:键值对形式的附加信息(可选项)。
客户端在获得上述镜像索引后,解析后可发现该索引指向两个不同平台架构的镜像,因此可根据自身所在的平台拉取相应的镜像。如Linux amd64平台上的客户端会拉取第2个镜像,因为该镜像的platform.architecture属性为amd64,platform.os属性为Linux。
索引文件中的mediaType和digest属性是OCI镜像规范中的重要概念,下面详细讲解这两个属性。
(1)mediaType属性是描述镜像所包含的各种文件的媒体属性,客户端从Registry等服务中下载镜像文件时,可从HTTP的头部属性Content-Type中获得下载文件的媒体类型,从而决定如何处理下载的文件。比如,镜像的索引和清单都是JSON格式的文件,它们的区别就是媒体类型不同。OCI镜像规范定义的媒体类型见表1-1,可以看到上面例子中的清单的媒体类型是 application/vnd.oci.image.manifest.v1+json,索引本身的媒体类型则是application/vnd.oci.image.index.v1+json。
表1-1

(2)digest属性是密码学意义上的摘要,充当镜像内容的标识符,实现内容的可寻址(content addressable)。OCI镜像规范中镜像的内容(如文件等)大多是通过摘要来标识和引用的。
摘要的生成是根据文件内容的二进制字节数据通过特定的哈希(Hash)算法实现的。哈希算法需要确保字节的抗冲突性(collision resistant)来生成唯一标识,只要哈希算法得当,不同文件的哈希值几乎不会重复,如SHA256算法发生冲突的概率大约只有1/2256。因此,可以近似地认为每个文件的摘要都是唯一的。这种唯一性使摘要可以作为内容寻址的标识。同时,如果摘要以安全的方式传递,则接收方可以通过重新计算摘要来确保内容在传输过程中未被修改,从而杜绝来自不安全来源的内容。在OCI的镜像规范中也要求用摘要值校验所接收的内容。
摘要值是由算法和编码两部分组成的字符串,算法部分指定使用的哈希函数和算法标识,编码部分则包含哈希函数的编码结果,具体格式为“<算法标识> : <编码结果>”。
目前OCI镜像规范认可的哈希算法有两种,分别是SHA-256和SHA-512,它们的算法标识如表1-2所示。
表1-2

上面索引中的两个镜像清单摘要值分别对应两个清单文件,分别是blobs/sha256/d81ae89b30523f5152fe646c1f9d178e5d10f28d00b70294fca965b7b96aa3db 和blobs/sha256/2ef4e3904905353a0c4544913bc0caa48d95b746ef1f2fe9b7c85b3badff987e。
2.镜像清单
镜像清单(简称清单)是说明镜像包含的配置和内容的文件,分析镜像一般从镜像清单开始。镜像清单主要有三个作用:支持内容可寻址的镜像模型,在该模型中可以对镜像的配置进行哈希处理,以生成镜像及其唯一标识;通过镜像索引包含多体系结构镜像,通过引用镜像清单获取特定平台的镜像版本;可转换为OCI运行时规范以运行容器。
镜像清单主要包括配置和层文件的信息,示例如下:


其中主要属性的意义如下。
◎ schemaVersion:必须是2,主要用于兼容旧版本的Docker。
◎ config:镜像配置文件的信息。mediaType的值“application/vnd.oci.image.config.v1+json”表示镜像配置的媒体类型。size指镜像配置文件的大小。digest指镜像配置文件的哈希摘要。
◎ layers:层文件数组。在以上示例中包含3个层文件,分别代表容器根文件系统的一个层。容器在运行时,会把各个层文件依次按顺序叠加,第1层在底层(参见图1-9)。mediaType指媒体类型,其值“application/vnd.oci.image.layer.v1.tar+gzip”表示层文件。size指层文件的大小。digest指层文件的摘要。
◎ annotations:键值对形式的附加信息(可选项)。
3.镜像配置
镜像配置主要描述容器的根文件系统和容器运行时使用的执行参数,还有一些镜像的元数据。
在配置规范里定义了镜像的文件系统的组成方式。镜像文件系统由若干镜像层组成,每一层都代表一组tar格式的层格式,除了底层(base image),其余各层的文件系统都记录了其父层(向下一层)文件系统的变化集(changeset),包括要添加、更改或删除的文件。
通过基于层的文件、联合文件系统(如AUFS)或文件系统快照的差异,文件系统的变化集可用于聚合一系列镜像层,使各层叠加后仿佛是一个完整的文件系统。
下面是镜像配置的一个示例:


其中主要属性的意义如下,具体说明可以参考OCI规范。
◎ created:镜像的创建时间(可选项)。
◎ author:镜像的作者(可选项)。
◎ architecture:镜像支持的CPU架构。
◎ os:镜像的操作系统。
◎ config:镜像运行的一些参数,包括服务端口、环境变量、入口命令、命令参数、数据卷、用户和工作目录等(可选项)。
◎ rootfs:镜像的根文件系统,由一系列层文件的变化集组成。
◎ history:镜像每层的历史信息(可选项)。
4.层文件
在镜像清单和配置信息中可以看到,镜像的根文件系统由多个层文件叠加而成。每个层文件在分发时都必须被打包成一个tar文件,可选择压缩或者非压缩的方式,压缩工具可以是gzip或者zstd。把每层的内容打包为一个文件的好处是除了发布方便,还可以生成文件摘要,便于校验和按内容寻址。在镜像清单和配置信息里面需要根据tar文件是否压缩和压缩工具等信息声明媒体类型,使镜像客户端可以识别文件类型并进行相应的处理。
每个层文件都包含了对上一层(父层)的更改,包括增加、修改和删除文件三种操作类型,底层(第1层)可以被看作对空层文件的增加。因此在每个tar文件里面除了该层的文件,还可以包含对上一层中文件的删除操作,用whiteout的方式标记。在叠加层文件时,可以根据whiteout的标记,把上一层删除的文件在本层屏蔽。
在表1-1中还有几个层文件的媒体类型为不可分发(non-distributable),这是为了说明该层文件因为法律等原因无法公开分发,需要从分发商那里获得该层文件。
5.镜像的文件布局
前面介绍了OCI镜像内容的组成部分,本节将具体讲解这些组成部分在实际文件系统中的布局和关联关系。OCI定义的镜像文件和目录结构如图1-14所示。

图1-14
(1)在镜像的根目录下必须有JSON格式的index.json文件,作为镜像索引。
(2)在同一目录下必须有一个JSON格式的oci_layout文件,作为OCI格式的标记和OCI镜像规范版本说明。该文件的媒体类型为application/vnd.oci.layout.header.v1+json,表示布局文件。该文件的内容如下:

(3)必须存在blobs目录,但该目录可以为空。在该目录下,按照摘要哈希算法的标识生成子目录,并存放用该算法寻址(查找)的内容。如果内容的摘要是<算法标识> : <编码结果>,那么该内容的哈希必须等于<编码结果>,并且存放于这个路径的文件名中:blobs/<算法标识>/<编码结果>。
这样的布局方法使根据内容的摘要很容易找到内容的实际文件,即按内容寻址。
如图1-15所示为镜像文件布局之间的引用关系,读者可以根据之前的内容进行理解。

图1-15