# Nerdctl原生支持Nydus加速镜像 > OSPP 开源之夏是由中科院软件研究所“开源软件供应链点亮计划”发起并长期支持的一项暑期开源活动,旨在鼓励在校学生积极参与开源软件的开发维护,促进优秀开源软件社区的蓬勃发展,培养和发掘更多优秀的开发者。 > > 这是今年的开源活动中,李楠同学参加 Nydus 容器开源生态集成课题的相关工作。 在今年的开源之夏活动中,我参加了Nydus项目提交的《Nydus 容器开源生态集成》题目。其中,我主要完成了其中的“nerdctl支持运行/转换Nydus镜像”的开发工作。本文是对该工作的总结文档。 首先介绍一下项目的背景。 ## nerdctl [nerdctl](https://github.com/containerd/nerdctl)是一个对标docker cli和docker compose的、用于与[containerd](https://containerd.io)(当下最流行的容器运行时,Docker的后端也是调用的containerd,通常作为守护进程出现)交互的命令行工具。用户可以像使用docker cli一样使用nerdctl与containerd进行交互,比如使用`nerdctl pull <image_name>`来拉取镜像、使用`nerdctl run <image_name>`来运行容器等等。 相比于containerd本身提供的ctr工具,nerdctl默认提供了更友好的用户体验,并尽量保持其使用方式与docker一致。对于从docker迁移到containerd的用户,往往只需要`alias docker=nerdctl`就可以与之前获得一致的使用体验。 ## OCI镜像格式 OCI镜像格式是[OCI](https://opencontainers.org/)(Open Container Initiative,开放容器计划)的重要组成部分,它给出了一个厂商无关的镜像格式规范,即,一个镜像应该包含哪些部分、每个部分的数据结构是如何的、这些各个部分应该以怎样的方式进行组织,等等。OCI镜像格式脱胎于docker镜像格式,它与docker镜像格式有着非常类似的结构;但它比docker镜像格式有更好的兼容性,并得到了各个厂商的普遍认同。因此,在这里主要介绍一下OCI镜像格式的主要内容。 通常所说的“镜像文件”其实指的是一个包含了多个文件的“包”,这个“包”中的这些文件提供了启动一个容器所需要的所有需要信息,其中包括但不限于,容器所使用的文件系统等数据文件,镜像所适用的平台、数据完整性校验信息等配置文件。当我们使用`docker pull`或`nerdctl pull`从镜像中心拉取镜像时,其实就是在依次拉取该镜像所包含的这些文件。 例如,当我们使用`nerdctl pull`拉取一个OCI镜像时: ![](https://i.imgur.com/LD1lOpU.png) 从log中可以清晰地看到,nerdctl依次拉取了一个index文件、一个manifest文件、一个config文件和若干个layer数据文件。实际上,一个标准的OCI镜像通常就是由这几部分构成的。 其中,layer文件一般是tar包或者压缩后的tar包,其包含着镜像“具体的”数据文件。这些layer文件会共同组成一个完整的文件系统(也就是从该镜像启动容器后,进入容器中看到的文件系统)。 config文件是一个json文件,其中包含镜像的一些配置信息。比如镜像时间、修改记录、环境变量、镜像的启动命令等等。 manifest文件也是一个json文件,它可以看作是镜像文件的清单,即,其说明了该镜像包含了哪些layer文件和哪个config文件。下面是一个manifest文件的典型例子: ```json { "schemaVersion": 2, "mediaType": "application/vnd.oci.image.manifest.v1+json", "config": { "mediaType": "application/vnd.oci.image.config.v1+json", "digest": "sha256:0584b370e957bf9d09e10f424859a02ab0fda255103f75b3f8c7d410a4e96ed5", "size": 7636 }, "layers": [ { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:214ca5fb90323fe769c63a12af092f2572bf1c6b300263e09883909fc865d260", "size": 31379476 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:50836501937ff210a4ee8eedcb17b49b3b7627c5b7104397b2a6198c569d9231", "size": 25338790 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:d838e0361e8efc1fb3ec2b7aed16ba935ee9b62b6631c304256b0326c048a330", "size": 600 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:fcc7a415e354b2e1a2fcf80005278d0439a2f87556e683bb98891414339f9bee", "size": 893 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:dc73b4533047ea21262e7d35b3b2598e3d2c00b6d63426f47698fe2adac5b1d6", "size": 664 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:e8750203e98541223fb970b2b04058aae5ca11833a93b9f3df26bd835f66d223", "size": 1394 } ] } ``` index文件也是一个json文件,它是可选的,可以被认为是manifest的manifest。试想一下,一个tag标识的镜像,比如`docker.io/library/nginx:1.20`,会针对不同的架构平台(比如linux/amd, linux/arm64等等)有不同的镜像文件,每个不同平台的镜像文件都有一个manifest文件来描述,那么我们就需要有个更高层级的文件来索引这多个manifest文件。比如,`docker.io/library/nginx:1.20`的index文件就包含一个`manifests`数组,其中记录了多个不同平台的manifest的基本信息: ```json { "manifests": [ { "digest": "sha256:a76df3b4f1478766631c794de7ff466aca466f995fd5bb216bb9643a3dd2a6bb", "mediaType": "application\/vnd.docker.distribution.manifest.v2+json", "platform": { "architecture": "amd64", "os": "linux" }, "size": 1570 }, { "digest": "sha256:f46bffd1049ef89d01841ba45bb02880addbbe6d1587726b9979dbe2f6b556a4", "mediaType": "application\/vnd.docker.distribution.manifest.v2+json", "platform": { "architecture": "arm", "os": "linux", "variant": "v5" }, "size": 1570 }, { "digest": "sha256:d9a32c8a3049313fb16427b6e64a4a1f12b60a4a240bf4fbf9502013fcdf621c", "mediaType": "application\/vnd.docker.distribution.manifest.v2+json", "platform": { "architecture": "arm", "os": "linux", "variant": "v7" }, "size": 1570 }, { "digest": "sha256:acd1b78ac05eedcef5f205406468616e83a6a712f76d068a45cf76803d821d0b", "mediaType": "application\/vnd.docker.distribution.manifest.v2+json", "platform": { "architecture": "arm64", "os": "linux", "variant": "v8" }, "size": 1570 }, { "digest": "sha256:d972eee4f12250a62a8dc076560acc1903fc463ee9cb84f9762b50deed855ed6", "mediaType": "application\/vnd.docker.distribution.manifest.v2+json", "platform": { "architecture": "386", "os": "linux" }, "size": 1570 }, { "digest": "sha256:b187079b65b3eff95d1ea02acbc0abed172ba8e1433190b97d0acfddd5477640", "mediaType": "application\/vnd.docker.distribution.manifest.v2+json", "platform": { "architecture": "mips64le", "os": "linux" }, "size": 1570 }, { "digest": "sha256:ae93c7f72dc47dbd984348240c02484b95650b8b328464c62559ef173b64ce0d", "mediaType": "application\/vnd.docker.distribution.manifest.v2+json", "platform": { "architecture": "ppc64le", "os": "linux" }, "size": 1570 }, { "digest": "sha256:51f45f5871a8d25b65cecf570c6b079995a16c7aef559261d7fd949e32d44822", "mediaType": "application\/vnd.docker.distribution.manifest.v2+json", "platform": { "architecture": "s390x", "os": "linux" }, "size": 1570 } ], "mediaType": "application\/vnd.docker.distribution.manifest.list.v2+json", "schemaVersion": 2 } ``` 综上,组成镜像的各个文件相互之间形成了一个树状结构,树的上层节点持有对下层节点的引用。从最上层的index文件或manifest文件开始,就可以“顺藤摸瓜”地索引到镜像的所有文件。 ![](https://i.imgur.com/jahaud3.png) 需要注意的是,OCI镜像规范是一个开放的格式,它只规定了文件的组织形式,但没规定数据文件的具体内容。我们完全可以将一些其他类型的文件打包成一个OCI镜像的格式,当做OCI镜像进行分发,从而充分利用DockerHub等镜像注册中心的能力。 ## Nydus > [Nydus](https://nydus.dev/)是CNCF孵化项目Dragonfly的子项目,它提供了容器镜像,代码包,数据分析按需加载的能力,无需等待整个数据下载完成便可开始服务。 > > Nydus在生产环境已经支撑了每日百万级别的加速镜像容器创建,在启动性能,镜像空间优化,网络带宽效率,端到端数据一致性等方面相比OCIv1格式有着巨大优势,并可扩展至例如NPM包懒加载等数据分发场景。 > > 目前Nydus由蚂蚁集团,阿里云,字节跳动联合开发,Containerd,Podman社区接受了Nydus运行时作为其社区子项目,也是KataContainers以及Linux v5.19内核态原生支持的镜像加速方案。 ### Nydus镜像格式 [Nydus](https://nydus.dev/)镜像格式是对下一代OCI镜像格式的探索。它的出现是基于以下的事实。在用户启动容器时,容器运行时会首先从远程Registry中下载完整的镜像文件(通常这一过程是容器启动时最耗时的部分),然后才能对镜像的文件系统进行解包和挂载,最后完成容器的启动。但实际上,用户在运行容器过程中,并不会用到文件系统中的全部文件,[数据使用率通常只有6%左右](https://indico.cern.ch/event/567550/papers/2627182/files/6153-paper.pdf),也就是说,花费大量时间拉取的镜像文件,却大概率最终不会用到。因此,如果能在容器运行时,不提前拉取完整镜像,而只是在需要访问某些文件再动态拉取,将大大提高容器的启动效率,并带来网络带宽效率、镜像空间优化等更多好处。 下图是相同内容的Nydus镜像和OCI镜像在创建容器时的耗时对比: ![](https://i.imgur.com/qxtYFNF.png) Nydus镜像格式并没有对OCI镜像格式在架构上进行修改,而主要优化了其中的layer数据层的数据结构。Nydus将原本统一存放在layer层的文件数据和元数据(文件系统的目录结构、文件元数据等)分开,分别存放在“blob layer”和“bootstrap layer”中。并对blob layer中存放的文件数据进行分块(chunk),以便于懒加载(在需要访问某个文件时,只需要拉取对应的chunk即可,不需要拉取整个blob layer);同时,这些分块信息,包括每个chunk在blob layer的位置信息等也被存放在“bootstrap layer”这个元数据层中。这样,容器启动时,仅需拉取bootstrap layer层,当容器具体访问到某个文件时,再根据bootstrap layer中的元信息拉取对应blob layer中的对应的chunk即可。 ![](https://i.imgur.com/CXQdYc9.png) 从更上层的视角上,Nydus镜像格式相比于OCI镜像格式的变化: ![](https://i.imgur.com/fDuqVia.png) 可以看到,Nydus镜像对外依旧可以表现出OCI镜像格式的组织形式,因此,Nydus镜像可以充分利用原有的docker镜像和OCI镜像的存储分发的生态。 ### Nydus Daemon 从前文的讨论中可以看出,从Nydus镜像生成的容器,当其访问文件系统中的文件时,实际上访问的不是文件系统,而是一个“网络文件系统”——实际访问的是这个“网络文件系统”在本地的缓存或Registry等存储后端中的数据。Nydus中作为这个“网络文件系统”中的“本地客户端”的工具是[Nydus Daemon(简称nydusd)](https://github.com/dragonflyoss/image-service/blob/master/docs/nydusd.md)。 下图中的Nydus Framework中起主要作用的就是nydusd。 ![](https://i.imgur.com/62ojMtT.png) nydusd是一个用户态进程,它可以通过[FUSE](https://www.kernel.org/doc/html/latest/filesystems/fuse.html)、[VirtioFS](https://virtio-fs.gitlab.io/)或[EROFS](https://www.kernel.org/doc/html/latest/filesystems/erofs.html)等方式将“网络文件系统”挂载到容器的rootfs上。下图是使用FUSE的情况: ![](https://i.imgur.com/gVpYDQh.png) ### Nydus Snapshotter Nydus镜像格式虽然在架构上与OCI格式保持一致,但对数据的解析、具体的layer文件的mediaType等方面与OCI格式有很大区别。现有的容器运行时(典型的如containerd、Podman、CRI-O等)及其配套的工具并不能直接与拉取和运行Nydus镜像。 例如,对于containerd: 1. 在containerd拉取镜像时,会根据镜像的manifest中的描述将所有的层文件都拉取下来。这首先就失去了Nydus按需加载的意义。 2. containerd在完成镜像每个层文件的拉取后,会调用snapshot服务将每一层解包,以读取其中的文件。但Nydus镜像的blob layer使用了自定义的mediaType,containerd在处理时会直接报错。 3. containerd在运行容器时,会将镜像所属的各个解包后的snapshot目录作为overlayfs的lower dir,挂载到容器的rootfs上。Nydus必须能够“hack”这一过程,将nydusd提供的“网络文件系统”作为overlayfs的lower dir挂载。 幸运的是,[containerd在设计之初就考虑到了对多种文件系统的支持](https://blog.mobyproject.org/where-are-containerds-graph-drivers-145fc9b7255),支持用户自定义snapshot插件,并在拉取和运行镜像时指定使用对应的snapshot插件,以实现用户所期望的功能。Nydus所提供的这样的snapshot插件就是[Nydus Snapshotter](https://github.com/containerd/nydus-snapshotter)。 ## 我的工作 ### nerdctl支持直接运行Nydus镜像 在开源之夏的题目发布时,当需要使用nerdctl来运行Nydus镜像时,不能像普通的OCI镜像或Docker镜像一样,直接使用`nerdctl run`等来运行(执行`nerdctl run`会直接报错),必须首先使用Nydus自己提供的工具ctr-remote拉取镜像,然后才能进一步使用`nerdctl run`运行。 通过阅读和调试nerdctl的代码发现,当本地没有要运行的镜像时,nerdctl会执行pull命令从registry中拉取镜像,而直接使用`nerdctl run`引发的报错正是在这个pull阶段产生的。因此,问题转化为解决nerdctl pull的报错。同时,想到ctr-remote其实主要就是在做pull镜像的功能。于是,进一步阅读ctr-remote的代码可以发现,ctr-remote通过在镜像的拉取过程中,对manifest包含的各个layer添加相应的annotation,使nydus snapshotter可以正确处理拉取的镜像。因此,只需要将ctr-remote中的这部分逻辑抽离出来,并添加到nerdctl pull的工作流程中即可。 ### `nerdctl image convert`支持Nydus 相比于docker cli,nerdctl原生支持一些更多样化的命令,比如`nerdctl image convert`。顾名思义,该命令的作用是将一种格式的镜像转换为另一种格式。其最基础的用法是使用`nerdctl image convert --oci <src_image_tag> <target_image_tag>`将一个常见的[docker v2格式](https://docs.docker.com/registry/spec/manifest-v2-2/)的镜像(也就是大家平常用的镜像格式)转换为一个标准的[OCI格式](https://github.com/opencontainers/image-spec)的镜像。 除了docker v2和OCI,截止到开源之夏题目发布时,`nerdctl image convert`还支持将镜像转换到[estargz格式](https://github.com/containerd/stargz-snapshotter/blob/main/docs/estargz.md)。因此,“nerdctl image convert支持Nydus”这个题目的含义就是,**拓展`nerdctl image convert`命令,使其支持将常见的docker v2格式和OCI格式的镜像转换为Nydus格式的镜像**。这个题目是本次项目最重要的一部分工作。 `nerdctl image convert`的实现主要是借助containerd本身对外开放的API中的convert能力实现的。containerd对镜像转换的处理流程是,按照镜像组织的树形结构,从基础的layer和config开始,到index层结束,一层层进行转换,从而最终生成一个新的镜像;其中,调用者可以自定义数据层的转换逻辑,`nerdctl image convert`已有的对“estargz格式”的支持就是通过这种方式实现的。 但目前类似“estargz格式”的这种实现其实是默认转换后的镜像和转换前的镜像的各层之间是“一一对应”,而Nydus镜像除了有与转换前的OCI镜像中的数据层一一对应的“blob layer”之外,还有一个“bootstrap layer”。幸好containerd的处理流程中,在完成了每一层的转换后,会调用一个回调函数,给调用者机会做进一步的处理。因此,可以利用containerd对manifest层处理完的回调,在该回调中,额外生成一个“bootstrap layer”,并相应地修改manifest层和config层中的内容,从而最终构建出一个合法的Nydus镜像。 在开发完基本逻辑后,测试过程中发现了转换后的Nydus镜像文件生成后又被意外删除的现象。甚至在一步步调试时,在函数返回前,转换后的Nydus镜像文件依旧存在,但函数返回后文件“奇迹般的”消失了。对此,我依次尝试了以下排查思路: 1. nydus镜像文件的删除是在另一个协程中进行的,因此我在当前协程的断点没有调试到删除操作。但多次调试后发现,删除动作一定会发生在函数返回前,这与协程的“不可预测性”不符。 2. nydus镜像文件触发了containerd守护进程的某种GC操作。我使用[Inotify](https://en.wikipedia.org/wiki/Inotify)监控镜像文件的创建和删除操作对应的进程,发现确实是containerd守护进程的操作。但问题是,nerdctl执行的代码也会与containerd进行rpc通信,这一操作是containerd进程自己的内置逻辑呢,还是nerdctl通知containerd做的呢?不得而知。 在花费了一周时间排查bug后,发现是函数返回前执行了函数体前部分的defer操作触发了Nydus镜像文件的删除操作,而在defer的函数体中没有设置断点,因此没有调试到。最终,通过分析defer函数体中的逻辑,问题得以解决。总结下来,还是具体的编程经验不足,没有在一开始就想到所有可能得方面,导致绕了很大的弯路。 ### 小结 上述两项工作的PR都已合入了nerdctl的主分支,基本实现了nerdctl原生支持Nydus镜像加速的能力。大家可以移步文档作进一步了解:[https://github.com/containerd/nerdctl/blob/master/docs/nydus.md](https://github.com/containerd/nerdctl/blob/master/docs/nydus.md)。 ## 收益与展望 containerd是当前最流行的容器运行时之一,nerdctl作为containerd的社区中的核心项目提供了完善的使用体验。它们都是容器领域中非常重要的基础项目。本次项目的完成,将使得用户能非常方便地使用nerdctl和containerd来构造、拉取、运行Nydus镜像,这无疑会对Nydus镜像格式的普及和进一步发展起到非常好的推动作用。 ## 自我介绍 我是来自北京航空航天大学的2021级研究生李楠,对云原生技术很感兴趣,GitHub ID是[loheagn](https://github.com/loheagn)。在今年上半年,我参加了Linux基金会的春季实习,完成了[CNCF - Kubevela: Management of Terraform state](https://mentorship.lfx.linuxfoundation.org/project/2a182d3b-f5cd-4ca7-9ede-4e8b5158c6a2)项目的工作,并因此培养了对开源工作的兴趣。 在开源之夏2022开放题目后,我了解到了Nydus项目,感觉这是一个很酷的项目,并且由于我之前对容器和镜像的底层技术并不是很熟悉,觉得这是个很不错的学习机会。于是便尝试投递了简历申请,最终很幸运地通过了筛选,并在严松老师的帮助下顺利完成了题目。通过本次项目的开发工作,我逐渐了解了OCI镜像的组成部分,每部分的作用和基本格式;知道了以及Nydus镜像和OCI镜像之间的差异,并且理解了Nydus镜像中blob layer和bootstrap layer之间的区别;能够通过检查本地镜像相关文件排查一些简单的程序bug。 在项目进行的过程中,我阅读了nerdctl和containerd的代码,学到了一些实用的编程技巧,并最终向nerdctl提交并成功合入了两个PR。在向nerdctl提交和修改PR的过程中,nerdctl的maintainer们对待代码的严谨态度———他们甚至会review`go.sum`的每一行改动——让我大受裨益。不仅如此,这些的开源工作经历为我揭开了“顶级开源项目的神秘的面纱”,增强了我的信心,让我更有自信和兴趣进一步参与到云原生项目的相关工作中。 非常感谢项目组织老师赵新、本题目mentor严松老师和项目指导助理姚胤楠同学在本次项目进行过程指导和帮助,特别是严松老师细致入微的解答和指导,每次我的一个看起来很简单甚至很愚蠢的问题都能得到严松老师详细的解答,并且言辞中经常包含着肯定和鼓励,让人如沐春风。 在后续的学习和工作中,我希望能持续参与到Nydus相关的开发工作中,继续为社区贡献issue和代码。