探究OCI镜像与Docker镜像的联系
探究OCI镜像与Docker镜像的联系
从熟知的Docker出发
目前,容器化技术已经广泛普及了,而谈到容器,我想大多数人的第一反应就是Docker了。
Docker给我们带来了很多好处,优化了CI/CD流程,可以实现云原生,纳入容器编排等等。
回想下Docker中的概念,我们是如何使用Docker的?一般开发人员会编写Dockerfile,用来定义一个镜像,然后将Dockerfile生成的镜像推送到远程仓库,最终根据需要通过pull镜像生成容器运行起来。如果是云原生应用,那这个pull的动作可能是k8s做的。同时,k8s集群的node环境中需要有Docker守护进程。
可以说,Docker的功能十分强大,包裹了整个云原生应用的开发生命周期,似乎我们如果要使用容器技术就离不开它,可事实真的是这样么?
最近,容器编排界的事实标准k8s发布了这样一则公告:自 v1.20 起放弃对 Docker 的支持,届时用户将收到 Docker 弃用警告,并需要改用其他容器运行时。
Docker support in the kubelet is now deprecated and will be removed in a future release. The kubelet uses a module called “dockershim” which implements CRI support for Docker and it has seen maintenance issues in the Kubernetes community. We encourage you to evaluate moving to a container runtime that is a full-fledged implementation of CRI (v1alpha1 or v1 compliant) as they become available. (#94624, @dims) [SIG Node]
引用自K8S官方文档
k8s放弃了kubelet中对Docker的支持,鼓励我们替换为个完整实现CRI的容器运行时。虽然只是放弃了“Docker的容器运行时”,但显而易见,Docker并非不可取代的。
容器运行时只是整个云原生应用生命周期依赖的一个点而已,如果以后整个生命周期都弃用了Docker,那原先构建的镜像还能使用么?如何能继续运行呢?
这就需要容器技术将每个功能点剥离,并形成行业规范,镜像应该怎么描述、容器运行时应该如何解析镜像与运行、网络应该如何构建,都需要一个统一的接口,
早在好几年前,业界就意识到了这个问题,从而诞生了OCI。
OCI简介
OCI全称Open Container Initiative,翻译过来就是开放容器标准。为了减少项目的摩擦而导致的内耗,以及用户渴求一个容器技术不强绑定于一个具体厂商,且能跨越各种硬件、操作系统、CPU架构、公有云,2015年Docker和当时其他“友商”进行了讨论,大家开发容器技术,都得按照同一个模子来,就像软件开发时定一个接口,具体实现交由开发人员一样。Docker构建的镜像,也可以发布到其他容器厂商的镜像仓库,并run在其他厂商的运行时,得益于大家都是先了这个接口。
OCI包含了以下两部分:
镜像规范(image-spec)
规定应该以何种格式存储、分发镜像。
也就是,被打成的镜像内容具体需要长什么样。目录结构,结构信息元文件、版本等等,应该如何去定义,在哪定义。
运行时规范(runtime-spec)
规定了如何运行在磁盘上解压的“文件系统包”。
什么是文件系统包?比如,将linux整个系统,用tar工具打成了一个tar.gz包。这个包就是“文件系统包”。它将包含在镜像当中,当解压出来可被OCI Runtime运行了,就被称为“OCI Runtime Bundle”。
内容可寻址文件存储
在讲镜像规范前,需要学习一个知识点:内容可寻址文件存储,简称“CAS”。
我们平时使用的系统,如windows,linux,均是通过路径来确定一个文件内容,比如D:/temp/temp.txt
。访问文件的方式也是路径加上文件名。这样会有一个问题,就是无法识别这个文件是否被修改了,它还是最初的它么?当然,文件右键可以看到修改时间,但这并非存储本省的寻址提供的能力,如果我没有权限查看它的修改时间呢?是不是就不能知道了。于是,存在这样一种存储方式,我们通过路径就可以确定一个文件,且这个文件一定是我们需要的,未被修改的文件,就是内容可寻址文件存储。
CAS如何实现
如何唯一标识一个文件?一般会想到使用文件摘要算法。常见的算法如SHA256,SHA512。
1 | [root@skywater ~]# sha256sum temp.sh |
CAS就是使用了文件摘要算法,如果我们将系统中所有文件的摘要作为key,而文件内容作为value,就可以形成一张hash表。对整个系统目录进行打包后再hash,作为一个tag,那么这个tag将会保证内容是不变的。
而换句话来说,如果一个系统经常发生变化,那么就不适合使用这样的存储方式,应为变动会引起hash变化,导致重新计算的负担。
容器技术中的镜像文件就恰恰有这样一个需求,那就是保证不可变性。我今天使用了这个镜像作为基础建立了另一个镜像,明天这个基础居然被人改了??导致我的应用出现了完全不可预知的问题,这是绝对不允许的。所以,CAS很适合作为镜像的存储方式。
image-spec(镜像标准)
OCI image-spec最早就是由当时的容器领头羊Docker贡献出来的,所以在后来的演化过程中,Docker镜像本身也是符合OCI规范的。
整体来看OCI image-spec,需要包含以下内容:
-
描述构成容器镜像的组件的清单文件
-
图像清单的注释索引
-
表示图像内容的文件系统布局
-
描述容器文件系统的变更集
-
定义了运行时bundle中的layer顺序以及配置信息
-
将定义标签转换成实际的运行时字段以及指令,比如image中的
Config.WorkingDir
转换为运行时的process.cwd
-
元数据描述对象,使用key-value形式表示。
必须存在的属性包括:
mediaType
,digest
,size
。可选属性包括
urls
,annotations
,data
。
当然,作为具体的OCI image-spec实现,Docker也会有一些不一样的点。
这里将OCI标准与Docker镜像进行对比,把OCI image-spec中的标识符一一对应到Docker镜像,这样可以更好的理解OCI。
媒体类型(mediaType)
媒体类型常见于http请求的hearder中,用来定义所指向的资源类型。通过mediaType,我们就可以使用正确的方式去解析数据。如果是gzip类型,那就需要用tar工具解压,如果是excel类型,就需要用Office、WPS之类的软件打开。镜像文件、以及镜像文件中包含的各种内容都有它们对应的mediaType。
后续涉及到容器内容各个点时再明确每个内容对应的媒体类型。
各媒体类型间的依赖关系如下:
镜像结构
Docker镜像由Dockerfile生成,通常会选择一个镜像作为基础镜像,如图上的ubuntu:14.04,随后根据我们的指令,向上依次堆叠层(layer)。最顶层为Read-write Layer,即读写层,可以挂在到宿主机。
或许这么讲还是有些抽象,我们直接把Docker的image下载到本地来分析。
构建使用的Dockerfile如下。
1 | FROM openjdk:8-jdk-alpine |
执行
1 | [root@skywater docker]# docker save 106.15.92.100:5000/lts/springboot-demo:v2 | gzip > springboot-demo.tar.gz |
解压后目录结构
可以看到,镜像内容中包含了一个manifest,以及多个包含了layer.tar的文件夹。这里每个layer.tar就对应了图上的每一层,排列顺序,以及层的配置信息,就在manifest.json
以及每个文件夹中的json
当中。
了解了镜像文件的大概结构,我们再对照OCI规范深入去看一下每一个文件的作用。
Image Layout
这时一个整体结构的定义,OCI定义中,image内容结构需要包含以下内容:
index.json文件,oci-layout文件,以及blobs目录。
结构类似于:
1 | blobs |
然而,从解压后的镜像内容来看,Docker目前没有适配这块规范,所以并没有这些文件与目录。
虽然Docker镜像并不完全符合OCI规范,譬如这里的image-layout,但并不意味着与OCI不兼容,OCI需要的信息,会以其他的结构展现在Docker镜像结构中。
如果需要获取的话,可以通过工具将docker镜像转换为oci镜像,使用skopeo工具:
1 | skopeo copy docker://springboot-demo:v1 oci:springboot-demo |
查看本地目录springboot-demo
1 | [root@skywater tmp]# tree springboot-demo/ --du |
index.json
其中将包含一个Image index对象,后续再详解Image Index。目前只需要知道,通过index.json就可以找到Image Index的hash位置即可。
oci-layout
1
2[root@skywater springboot-demo]# cat oci-layout
{"imageLayoutVersion": "1.0.0"}内容很简单,只有一个版本号,这个版本为整个OCI image文件目录布局结构的规划版本,目前使用的是1.0.0。
blobs
包含了使用hash进行查找的镜像配置文件、层内容等信息
通过index.sjon,可以定位到各平台对应的manifest的hash位置。再通过manifest,可以找到image-config,而image-config中,又将包含各个layer的具体配置信息,这样就可以将所有内容串联起来,runtime也就知道了容器运行需要的所有基本信息。
Image Index
镜像索引,这个内容是可选的,一个镜像可能适用于多个平台架构,单个manifest并不能满足要求,所以我们可以提供多个manifest组成一个Image Index,来实现跨平台。
OCI对Image Index的定义概览如下:
1 | { |
虽然Docker没有符合Image-Layout规范,但仍然可以找到Image-Layout中指向的Image Index。
在我们上面springboot-demo的例子中体现不出来,没有做跨平台,我们可以换一个dockerhub中的镜像看下其中的内容。
1 | [root@skywater ~]# docker manifest inspect nginx |
以Nginx镜像为例,可以看到一个媒体类型为application/vnd.docker.distribution.manifest.list.v2+json
的manifest列表,其实就是对应了Image index,其中属性可以一一对应上。
mediaType
Docker OCI application/vnd.docker.distribution.manifest.list.v2+json application/vnd.oci.image.index.v1+json 表示该文件是镜像清单文件。
manifests
manifest列表,对应特定平台的manifest,其中会包含platform信息,不同平台架构、系统需要有不同的镜像清单。
mediaType
即为上文提到的Image Manifest媒体类型
platform
描述镜像运行的环境要求,类似于定义一个软件必须是windows 10 再能用的一样。
Image Manifest
通过Image Index,我们可以找到该镜像在当前平台系统对应的manifest。
Image Index是为了跨平台,而相反的,manifest的主要目的,是为特定架构和操作系统的单个容器映像提供配置和镜像层集。
OCI对manifest的定义概览如下:
必须属性
schemaVersion
,mediaType
,**config
** ,**layers
** ,layers下的mediaType
可选属性
annotations
OCI Image Manifest示例如下:
1 | { |
我们来找一找,Docker中的manifest.json是否包含了这些内容。
读取save到本地的Docker image中的manifest.json内容:
1 | [root@skywater docker]# cat springboot-demo/manifest.json | jq |
可以看到,配置文件以及layer目录均为hash值,也对应了之前所说的“内容可寻址文件存储”。
或者,可以使用docker的manifest命令:
1 | [root@skywater springboot-demo]# docker manifest inspect --insecure 106.15.92.100:5000/lts/springboot-demo:v1 |
schemaVersion
代表为v2版本的manifest。ps:v1版本已废弃。
mediaType
第二个标识为上一章节提到的mediaType,为
application/vnd.docker.distribution.manifest.v2+json
,而在OCI标准中,manifest的mediaType标准值为
application/vnd.oci.image.manifest.v1+json
,略有差别,但OCI兼容Docker的这种mediaType。
config
对应OCI标准中的Image Configuration(镜像配置文件)。
这里镜像配置文件的格式为
application/vnd.docker.container.image.v1+json
,对应OCI标准中的镜像格式为
application/vnd.oci.image.index.v1+json
。OCI兼容Docker镜像配置文件格式。
通过manifest,可以定位到image config文件hash位置。这里获取到的hash为
4a030f9340dfd2456fe7ddaa23dd65571eb2987c8c038e1482adf46fd48b3aa4.json
,可以在镜像内容中找到该文件。后续再进行细节分析。layers
一个Descriptor Array(描述符数组),描述了文件系统layer的布局,且必须符合堆栈顺序。
可以发现,下载下来的manifest.json中的layer的hash,和使用
docker manifest inspect
命令解析出来的不一样,原因是下载下来的是tar格式的bundle,而inspect的是tar.gzip格式。mediaType
可以看到,其mediaType为
application/vnd.docker.image.rootfs.diff.tar.gzip
。根据其提供的hash,可以在下载下来的镜像内容目录中,按顺序找到每个对应的layer.tar文件。
annotations
这项在Docker的manifest中没有体现,可以理解为自己给manifest打上一些标记,内容自行定义,但需要符合key-value格式,以及annotation rules规范。
回顾image manifest(镜像清单)的内容,其中定义了这个镜像清单的版本,镜像配置文件的地址,以及每个layer的地址、排列顺序。是一个镜像的整体概览文件。其中每个文件都有他们的mediaType,由于是docker镜像,所以mediaType中都含有docker的标识符(xxx.docker.xxx)。
Image Configuration
根据manifest内容,我们可以查找到该镜像的Image configuration文件。
OCI中,Image Configuration样例如下:
1 | { |
大体了解的OCI中的Image Configuration,我们来看下Docker中的有何区别。
首先需要找到Docker中的Image Configuration在哪,根据Manifest中的描述,其位置在sha256:4a030f9340dfd2456fe7ddaa23dd65571eb2987c8c038e1482adf46fd48b3aa4
1 | [root@skywater springboot-demo]# ls -l|grep 4a030f9340d |
可以看到,springboot-demo目录下确实有一个这个文件。
查看其内容:
1 | { |
可以发现其与OCI中的Image Configuration定义基本一致。
关注其中几个属性:
config
参照我们最开始的Dockerfile,config里面对应了容器暴露的端口、Endpoint配置。
history
empty_layer如果为true,则代表只是修改了配置信息,而未变动文件内容,所以并不需要生成新的layer以及对应hash,只要在这里记录配置操作即可。
首先,Dockerfile中定义了
From openjdk:8-jdk-alpine
,所以会有相关jdk1.8的一些指令。我们每定义一次RUN、COPY等引起layer内容发生变化的操作时,就会生成新的layer,empty_layer属性也将会是默认的false。
从这里可以看出,Dockerfile指令与Docker Image内容形成了完整的联系。
rootfs.diff_ids
如果仔细观察diff_ids中的hash,发现无论是image manifest中的layers,还是说本地解压镜像文件的blobs中,都从未出现过这些ID。
其实,这些ID是layer归档文件的ID,而非layer压缩归档的ID。我们只要计算下layer.tar的hash值,就可以和这里对应上了。
譬如diff_ids[0] = “sha256:f1b5933fe4b5f49bbe8258745cf396afe07e625bdab3168e364daf7c956b6b81”
1
2[root@skywater springboot-demo]# sha256sum 1a58e6937db044ef6f2e2962a0dc7bef16a6c33fdfc5a0318c39092612a1bd1a/layer.tar
f1b5933fe4b5f49bbe8258745cf396afe07e625bdab3168e364daf7c956b6b81 1a58e6937db044ef6f2e2962a0dc7bef16a6c33fdfc5a0318c39092612a1bd1a/layer.tar可以看到该layer.tar的hash值就是
f1b5933...
实际在docker本地环境中的存储位置为
/var/lib/docker/image/overlay2/layerdb/sha256/f1b5933fe4b5f49bbe8258745cf396afe07e625bdab3168e364daf7c956b6b81
镜像是由layer构成的,layer文件以hash摘要寻址存放,所以,不同的image可以复用相同的layer。
Filesystem Layer
Image Manefest中除了Config信息,还有一块内容就是对Layers的描述。
OCI对Layer的mediaType定义与Docker中略有不同。
OCI | Docker |
---|---|
application/vnd.oci.image.layer.v1.tar application/vnd.oci.image.layer.v1.tar+gzip application/vnd.oci.image.layer.v1.tar+zstd application/vnd.oci.image.layer.nondistributable.v1.tar application/vnd.oci.image.layer.nondistributable.v1.tar+gzip application/vnd.oci.image.layer.nondistributable.v1.tar+zstd |
application/vnd.docker.image.rootfs.diff.tar.gzip |
可以通过解压layer.tar查看每一层的具体内容。
Docker容器实质上就是层与层的叠加,上方层可以通过覆盖的方式实现对下方层内容的增删改。最上方的层为读写层,会在从镜像转变为容器时生成,用于与宿主机频繁交互。其余下方的层只有只读权限,无法进行修改。
最后,需要有一个联合挂载系统(Docker一般使用overlay),将各个层联合起来,是指成为一个整体,用户视图将只存在一个。
总结
Docker作为OCI最初的贡献者,随着时间演化,其具体实现与OCI标准略有不同,但互相兼容。均通过manifest list来查找合适的镜像文件,并采用分层结构,使用manifest来定义层的顺序与位置,并指向层配置文件。通过读取层配置文件,就可以获取到所有具体的层配置信息了。从而构建了完整的镜像内容架构,提供给具体镜像实现工具一个统一的流程规范。