从contaienrd pull镜像流程分析oci distribution spec

oci组织成立以来已经形成了关于image和runtime的两个spec。2018年4月,作为与registry交互的镜像分发协议也进入了oci标准化的工作范围。oci以当前被广泛采用的Docker Registry HTTP API V2为基础,构建了oci distribution spec。
containerd当前同时支持docker版的和oci版的registry api。为了了解oci所定义的镜像分发协议,本文分析v1.1.0的containerd代码pull流程,tag创建时间为2018年4月23日。

pull操作的cli命令解释

关于containerd cli(即ctr)的pull操作指令,由于containerd代码中语焉不详,我们参照docker官方文档,pull镜像的指令的操作格式如下:

也就是说,我们pull镜像时,可以用 :tag也可以用@digest。
其中平时不常用的digest形式用来pull从某个layer开始的包含其所有直接间接父layer的镜像,例子如下:

其中digest一般形式如下

另外,根据containerd/ctr/commands/images/pull.go,ctr image pull命令还支持几个flag:

  1. registry相关flag:
    1. skip-verify: 跳过SSL证书验证过程
    2. plain-http: 是否允许只用http协议(而非https)连接registry
    3. user:user[:password]形式的registry用户名密码,如果flag中没有包含password,会在后续执行中要求用户在console中输入
    4. refresh:refresh token for authorization server
  2. snapshotter:todo 虽然可以提供自定义值,但目前一律使用default snapshotter
  3. label: 对于像manifest list那些包含子资源的资源,containerd默认会给它们,表达父子资源之间的关系,详见后面对manifest list的处理流程。通过label flag,可以给下载之后给镜像打额外自定义label。
  4. platform: 只下载适应某平台的镜像,比如linux/amd64,linux/arm等
  5. all-platform:下载适用所有平台的镜像

ctr image pull执行流程

整体流程可以参考官方流程图

1. Instruct the Distribution layer to pull a particular image. The distribution layer places the image content into the content store. The image name and root manifest pointers are registered with the metadata store.
2. Once the image is pulled, the user can instruct the bundle controller to unpack the image into a bundle. Consuming from the content store, layers from the image are unpacked into the snapshot component.
3. When the snapshot for the rootfs of a container is ready, the bundle controller can use the image manifest and config to prepare the execution configuration. Part of this is entering mounts into the execution config from the snapshot module.
4. The prepared bundle is then passed off to the runtime subsystem for execution. It reads the bundle configuration to create a running container.

跟ctr image pull操作执行逻辑定义在containerd/cmd/ctr/commands/images/pull.go定义的匿名函数中,步骤如下:

  1. resolve用户需要下载的镜像
  2. 从registry pull镜像,把镜像层内容和config保存进content服务,把镜像相关的元数据保存进images元数据服务
  3. unpack进snapshot服务

这里说的content、images等服务都是指containerd提供的gRPC服务

containerd/api/services/images/v1/images.proto文件里说images服务can really be considered a “metadata service”。所以上面把它称为“images元数据服务”。

下面主要分析fetch和unpack这两个主要步骤。

fetch步骤

fetch步骤直接调用了content服务的实现逻辑(调函数,不是调用gRPC接口),即containerd/cmd/ctr/commands/cotent/fetch.go里的Fetch方法,所以我们实际上也可以在通过执行ctr images fetch来单独执行fetch步骤,并跳过pull命令的unpack部分。

fetch步骤会创建containerd包下的client和remote context对象。
其中client负责与containerd的gRPC server联系,里面embed了services,即containerd的各种gRPC服务。
而remote context负责与registry的联系,封装了:

  1. base handlers:设置为fetch方法中创建的imagehandler
  2. labels:pull命令的flag label的值
  3. Platform:pull命令传入的platform、all-platform flag信息
  4. resolver:设置为fetch方法中创建的resolver。
    resolver(dockerResolver类型),其中包括pull命令的一些flag,包括credential(user+password),plainHTTP,httpclient,track(NewInMemoryTrack函数创建)。其中的httpclient连接被hardcode了一些配置,比如从环境变量里读取的proxy设置,timeout时间等。
  5. unpack:没被利用
  6. snapshotter:hardcode成native。pull命令有flag,但是这里被hardcode
  7. convert schema1:是否需要处理schema1版本的镜像,hardcode成true

fetch流程如下:

将reference resolve为oci规范里descriptor

reference只是用户输入的字符串,比如docker.io/library/redis:alpine,而descriptor代表了保存在registry中的资源,也可以说是一个待执行的下载任务。

  1. 将reference解析为locator和object两部分。reference为用户在ctr cli中输入的希望拉取镜像的名字,比如ctr image pull docker.io/library/redis:alpine中的docker.io/library/redis:alpine部分,又比如ctr image pull docker.io/library/redis@sha256:xxx中的docker.io/library/redis@sha256:xxx部分。
    locator代表repo的名称比如前面例子中的docker.io/library/redisdocker.io/library/ubuntu。而object则指代repo中某个具体的镜像layer,可以用tag标注,也可以用digest标注。比如前面例子中的tag alpine和digest @sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2

    注意docker cli中registry的host可以省略,但是ctr不会帮我们自动补充。registry的host地址,也就是前面例子中的docker.io,是必须有的。
    xxx指代镜像层对应的sha256值,因为长度过长,在本文中用xxx表示
    descriptor定义在github.com/opencontainers/images-spec中,属于oci规范的实现

  2. 根据locator和object信息构建dockerBase对象,并将之封装在dockerFetcher对象中。dockerBase包含一系列成员:
    1. base:url类型。如过plain-http flag的值为true或者host地址为localhost,url的scheme为http,否则为https。url的Host为registry的host地址,如果是docker.io会被替换为registry-1.docker.io。base的path为/v2+locator中的path部分,比如/library/ubuntu
      从定义上看base就是为了方便后期组装发送给registry的URL,只包含repo地址。后期发送者的给registry的请求都在此基础上加上一段。
    2. client: 设置为resolver中的httpclient
    3. username/secret:resolver中的对应值
  3. 根据是否object中是否是digest,定义后续resolve reference过程中要向registry发出的http请求。如果object为digest,先尝试向registry发送registry-1.docker.io/v2/library/ubuntu/manifest/sha256:xxx请求,如果未能查询到结果,则再发送registry-1.docker.io/v2/library/ubuntu/blobs/sha256:xxx。如果object不是digest,而是tag,则发送的请求形如registry-1.docker.io/v2/library/redis/manifest/alpine
  4. 根据上一步构建的URL,向registry的发起HTTP协议的查询请求,并根据response构建descriptor对象。descriptor包含3个成员:
    1. digest为response header”Docker-Content-Digest”的值
    2. MediaType为response “Content-Type” header的值
    3. size为response “Content-Length”的值

      构建descriptor过程不依赖registry返回的response body中信息

至此完成了用户输入的reference到descriptor的转化,主要功臣就是containerd/remotes/docker/resolver.go中定义的dockerResolver对象。它实现了remotes包下的Resolver接口,负责与registry之间的交互。前面描述的resolve reference成descriptor的流程就在dockerResolverResolve方法中实现。

有了descriptor,我们就有了下载镜像任务的描述,接下来就可以开始按照registry api v2将docker镜像从registry pull到本地,我们继续按照流程走下去。

3个handler一场戏:镜像数据下载

假设我们使用ctr image pull docker.io/library/redis:alpine来pull一个redis镜像到本地,在上步中我们已经完成了将reference,也就是docker.io/library/redis:alpine转化为descriptor对象。在这个例子中descriptor包含3个成员:

  1. digest
    为response header”Docker-Content-Digest”的值sha256:e57274dac037e5b0c7680717fcaaa0efeffb23430e54e839c50819c9d842a38c
  2. MediaType
    response “Content-Type” header的值application/vnd.docker.distribution.manifest.list.v2+json
  3. size
    response “Content-Length”的值2035

registry返回这个response给cli的含义就是让我们再发后续的请求给registry,去下载类型为manifest list,大小为2035字节,digest(作为唯一识别标志)为sha256:xxx的资源。
我们知道一个manifest对应一个镜像,会包含镜像中各个层的信息,用户在拿到manifest之后就可以并行下载镜像中包含的各个层。然而这里的Mediatype application/vnd.docker.distribution.manifest.list.v2+json并非代表一个manifest,实际它上代表了一个manifest的集合。为什么pull redis:alpine这样一个镜像会对应一个manifest集合,也就是多个manifest呢?那是因为同一个镜像可以对应不同的平台,包括CPU架构、操作系统。所以registry在接收到我们的resolve请求时,通过MediaType为application/vnd.docker.distribution.manifest.list.v2+json的response告诉我们这个镜像存在适应多个平台的版本。而客户端可以进一步下载manifest list之后,再去选择下载其中的某个平台的镜像。

client.go中的Pull方法调用images.Dispatch(ctx, handler, desc),从而启动镜像所有数据,包括元数据和镜像层(元数据和镜像层分别对应镜像manifest中的config和layer部分,详见本文后面对manifest的描述和例子),的下载流程。其中的desc参数就是前面步骤resolve出来的descriptor,而这个流程的执行的重大职责交给handler参数。handler实际上是一个函数变量:

也就是对于每个需要处理的descriptor,顺序调用handler中包含的每个handler(是的,两个变量同一个名字),而每个handler如果在处理descriptor完成之后发现有更多的子 descriptor需要处理,则可以将子descriptor返回出来。最终在一轮调用结束之后,所有handler返回的子descriptor会合并一处,返回给更上一级。所谓的“更上一级”就是images.Dispatch,让我们看看它的定义。images.Dispatch是一个深度优先的算法,假设有1,2,3,…N个descriptor需要处理,它会先处理1, 然后处理1.1,在处理1.1.1直到1扩散开的子树全处理完,然后开始处理2:

现在我们看看整个处理流程的关键问题:1. 每个handler分别做什么,2. handler执行顺序如何排列:

images.Dispatch(ctx, handler, desc)中的handler参数定义在client.go中。这里一共定义了3个handler,其中第一个BaseHandlers只是负责将当前的descriptor处理任务登记在正在执行的job列表中,定义如下:

而第2个handler通过remotes.FetchHandler(store, fetcher)构建。从函数的名称及参数看,就知道是利用fetcher参数(前面定义的dockerFetcher对象)将根据descriptor下载镜像元数据或镜像层,然后保存在store参数中(descriptor只是下载任务的描述,保存在content服务中的是按照descriptor下载下来的内容)。我们称第二个handler为fetch handler,这里我们简单分析fetch handler如何实现“读”、“写”这两步骤。

  1. 如何从registry读数据
    首先根据descriptor生成需要向registry发送请求的URL。fetch handler会从一系列URL里按照成功可能性的高低逐一挑选后发送出去。一旦能通过其中一个URL得到registry的回复,则停止这个过程。
    URL优先级分几个等级,从高到低分别是:

    1. 首先是在构建descriptor时往里面的URLs成员添加的URL,这些URL会被优先考虑。在当前ctr pull redis镜像的例子中,我们并没有在构建descriptor的过程中添加这类URL。
    2. 其次如果需要获取的资源类型为manifest或者manifest list,则优先向registry发送https://registry-1.docker.io/v2/library/redis/manifests/sha256:e57274dac037e5b0c7680717fcaaa0efeffb23430e54e839c50819c9d842a38c这样的请求。
    3. 如果如果需要获取的资源类型为manifest或者manifest list,在发送https://registry-1.docker.io/v2/library/redis/manifests/sha256:e57274dac037e5b0c7680717fcaaa0efeffb23430e54e839c50819c9d842a38c不成功的情况下,尝试发送https://registry-1.docker.io/v2/library/redis/blobs/sha256:e57274dac037e5b0c7680717fcaaa0efeffb23430e54e839c50819c9d842a38c
    4. 如果需要获取的并非是manifest或者manifest list,则直接发送https://registry-1.docker.io/v2/library/redis/blobs/sha256:e57274dac037e5b0c7680717fcaaa0efeffb23430e54e839c50819c9d842a38c
  2. 如何向store里写数据
    store参数类型为remoteContent(定义在content_store.go中),fetch handler利用封装在remoteContent内部的containerd的content gRPC服务客户端(ContentClient对象),调用containerd的content gRPC服务,实现了InfoDeleteReaderAt等实现镜像数据(下载好的元数据及镜像层内容)在content服务中的读写。在这里,我们跳过与content gRPC服务交互的大部分细节,只简单地说fetch handler从registry获取镜像相关数据之后,会给containerd的content gRPC服务发送WriteContentRequest请求,将数据写入content服务中。

以上优先级定义在containerd/remotes/docker/fetcher.go中定义的getV2URLPaths方法里,感兴趣的读者可以前往阅读。

相对于前两个handler执行逻辑比较直白,与registry定义的交互逻辑比较简单,第三个handler childrenHandler则与registry有更为丰富的交互,它通过以下“3步走”的方式构建出来:

其中第一步images.ChildrenHandler(store)构建了一个具备解析manifest list和manifest类型数据的handler,具体流程如下:

我们继续看childhandler是如何manifest list和manifest类型的资源。

manifest list处理

在fetch handler的帮助下,我们已经从registry下载了redis:alpine对应的manifest list,具体内容如下,我们可以看到这个manifest list中嵌套了适应多个平台的manifest的descriptor,比如适应linux/amd64、linux/armv6、linux/arm64等。按照前面“3步走”中第一步对childrenhandler定义,接下来会从containerd的content gRPC服务中把这个manifest list读取出来,然后把所有的适应各种平台的manifest对应的descriptor全都加入到待处理子descriptor队列中(第3步会按照ctr命令行里的flag过滤删掉无关的descriptor,第一步会先把所有平台的全都加入队列)。

对于manifest list这类包含子descriptor的数据,childrenHandler还会在“3步走”的第二步SetChildrenLabels中为保存在content服务中的manifest list加上label,以表达manifest list和manifest之间的父子关系。

经过“3步走”的第二步处理之后,我们查看containerd中的content服务中的数据,会发现digest为sha256:e57274dac037e5b0c7680717fcaaa0efeffb23430e54e839c50819c9d842a38c的资源,也就是manifest list,被加上了6个label,逐一对应redis:alpine的manifest list中包含的6个manifest。

childrenHandler的“3步走”中的第三步,则按照用户调用ctr pull镜像时输入的platform flag来过滤前面第一步中找到的子descriptor,这样下次一迭代调用images.Dispatch时就不会去下载被过滤掉的那些子descriptor对应的资源。第3步逻辑比较简单,这里不再详述。感兴趣的读者可以阅读images/handlers.go中定义的FilterPlatforms函数。

manifest处理

假设当前我们需要下载linux/amd64平台之上的redis:alpine镜像,那么我们会将以下子descriptor从manifest list中解析出来做下一步处理:

  1. mediaType: application/vnd.docker.distribution.manifest.v2+json
  2. size: 1568
  3. digest: sha256:d5f25b8d0f6125579cd3ac00a5a6e017ed55721d1b0850a3915da501fe7fd571
  4. platform:linux/amd64

我们会进入Dispatch方法的嵌套调用中,将这个descriptor对应的manifest下载下来,内容如下。我们可以看到在这个manifest中包含了1个config("application/vnd.docker.container.image.v1+json")和多个镜像层(application/vnd.docker.image.rootfs.diff.tar.gzip)对应的descriptor。可以想象Dispatch方法抽取子descriptor,并进一步嵌套调用,把config和镜像层分别下载,然后写入content gRPC服务中,这些过程在这里不再描述。

manifest中的config信息描述如何基于镜像构建容器及其中的进程,内容如下:

unpack步骤

TODO

Yiqun Ding

作者介绍:丁轶群,浙大SEL实验室创始人&带队老师,Cloud Native Computing Foundation会员。浙江省第一批青年科学家。《Docker容器与容器云》主要作者

浙江大学SEL实验室是本网站上所有页面设计、页面内容的著作权人,对该网站所载的作品,包括但不限于网站所载的文字、数据、图形、照片、有声文件、动画文件、音视频资料等拥有完整的版权,受著作权法保护。严禁任何媒体、网站、个人或组织以任何形式或出于任何目的在未经本实验室书面授权的情況下抄袭、转载、摘编、修改本网站內容,或链接、转帖或以其他方式复制用于商业目的或发行,或稍作修改后在其它网站上使用,前述行为均将构成对本网站版权之侵犯,本网站將依法追究其法律责任。
本网站与他人另有协议授权下载的或法律另有规定的,在下载使用时必须注明“稿件来源:浙江大学SEL实验室”。

Leave a Reply

Your email address will not be published. Required fields are marked *