Nydus 源码解读:nydusify convert

这是 nydus 源码学习的笔记,主要是介绍 nydusify convert 命令的流程,该命令主要用于转换 nydus 镜像,比如将 Docker Hub 上的镜像转换为 nydus 镜像,并保存到 registry。

本例使用了一个非常简单的例子,所需要的命令为:

1
2
3
$ nydusify  convert \
    --source liubin/nydus:two-layers \
    --target localhost:5000/test-nydus

其中源镜像 liubin/nydus:two-layers 是一个基于 alpine 的镜像,一共有两层。

启动 registry

转换后的 target 镜像会保存到自己创建的 registry 里,所以我们先在本地启动一个 registry 容器。

1
$ docker run -d -p 5000:5000 --restart=always --name registry registry:2

镜像转换流程

nydusify 命令转换镜像大概流程很简单:

  • 解析源镜像,得的 manifest/config/layers 信息
  • 下载每个 layer,并使用 nydus-image create 命令基于 OCI 镜像层目录进行转换,上传 bootstrap 和 blob 文件
  • 组装 nydus 的 manifest 或 index 文件,上传到 target registry

这里我们就以 v2.0.0-rc.5 为基础,看看具体的转换是怎么执行的。

nydusify 的代码在 contrib/nydusify 目录下,真正的、主要的执行在 contrib/nydusify/pkg/converter/converter.go 中的 convert 函数。这里是它的主要流程,我们删掉了一些不妨碍本文主旨的部分代码,比如 cache 相关的内容,以方面解说。

同时为方便阅读,解说也都以注释的方式放到了代码中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 解析源镜像,获得一个 layer 数组
// 注意,这里镜像其实已经解析过了,此处只是基于解析的镜像从新组装了
// 一个 []SourceLayer{} 数组。
sourceLayers, err := sourceProvider.Layers(ctx)

// 两个 worker,一个用于 pull 源镜像,一个用于 push nydus 的 blob 和 bootstrap 。
pullWorker := utils.NewQueueWorkerPool(PullWorkerCount, uint(len(sourceLayers)))
pushWorker := utils.NewWorkerPool(PushWorkerCount, uint(len(sourceLayers)))

// build nydus 镜像的主要数据结构,对应镜像的一层
buildLayers := []*buildLayer{}

// Pull and mount source layer in pull worker
var parentBuildLayer *buildLayer
for idx, sourceLayer := range sourceLayers {
  buildLayer := &buildLayer{
      index:          idx,
      buildWorkflow:  buildWorkflow,
      bootstrapsDir:  bootstrapsDir,
      remote:         cvt.TargetRemote,
      source:         sourceLayer,
      parent:         parentBuildLayer,
      referenceBlobs: blobLayers,
  }
  // 由于镜像层有父子关系,所以这个关联关系还是需要在构建的时候传递下去的
  parentBuildLayer = buildLayer
  buildLayers = append(buildLayers, buildLayer)
  job := mountJob{
      ctx:   ctx,
      layer: buildLayer,
  }

  // 添加到 pull worker 队列
  if err := pullWorker.Put(&job); err != nil {
      return errors.Wrap(err, "Put layer pull job to worker")
  }
}

for _, jobChan := range pullWorker.Waiter() {
  select {
  case _job := <-jobChan:
      job := _job.(*mountJob)

      // 从队列中拿到一个 job ,然后调用 job 的 Build 方法,这也是 build 的主要执行体
      err := job.layer.Build(ctx)

      // 然后把这个 job 的 Push 方法添加到 push 队列
      pushWorker.Put(func() error {
          return job.layer.Push(ctx)
      })
  case err := <-pushWorker.Err():
      // 这里是为了快速拿到 push 错误后结束的特殊处理
  }
}

// 等待所有层 push 成功完成
if err := <-pushWorker.Waiter(); err != nil {
  return errors.Wrap(err, "Push Nydus layer in wait")
}

// Push OCI manifest, Nydus manifest and manifest index
mm := &manifestManager{
  sourceProvider: sourceProvider,
  remote:         cvt.TargetRemote,
}

if err := mm.Push(ctx, buildLayers); err != nil {
  // ... ...
}

这个函数虽然比较长,但是逻辑流程还是很清晰的。

下面我们再深入到细节看看具体不同步骤所完成的工作。

获取源镜像的 manifest 信息

这里是在初始化 sourceProvider 的时候做的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func DefaultSource(ctx context.Context, remote *remote.Remote, workDir, platform string) ([]SourceProvider, error) {

  parser, err := parser.New(remote, arch)
  parsed, err := parser.Parse(ctx)

  sp := []SourceProvider{
      &defaultSourceProvider{
          workDir: workDir,
          image:   *parsed.OCIImage,
          remote:  remote,
      },
  }

  return sp, nil
}

这里我们就不再深入解释 parser 相关代码,有兴趣可以参考这里

我们的测试镜像 liubin/nydus:two-layers 没有 index,只有 manifest,它的解析过程以及信息如下面小节介绍。

获取 image

请求 https://registry-1.docker.io/v2/liubin/nydus/manifests/two-layers ,获得数据为:

1
2
3
4
5
{
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "digest": "sha256:00e2afc814fb9c3c48858dc5b17758e49ca2619f2bdd544c6f9810c397d83137",
  "size": 735
}

获取 manifest

请求 https://registry-1.docker.io/v2/liubin/nydus/manifests/sha256:00e2afc814fb9c3c48858dc5b17758e49ca2619f2bdd544c6f9810c397d83137 ,得到该镜像的 manifest 信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "digest": "sha256:85f3e7735ea66cc3df14a38419b199913b84d616298fb38d1a4a5c92cf885d49",
    "size": 1674
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "digest": "sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e",
      "size": 2814446
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "digest": "sha256:f2afd747bba4e4e1ae68a143d8f610cc44ae712e988a50ef47c9dfb2fddbc290",
      "size": 123
    }
  ]
}

这个文件很简单,说明该镜像由 1 个 config 对象和 2 个层 组成。

获取 config 对象

请求 https://registry-1.docker.io/v2/liubin/nydus/blobs/sha256:85f3e7735ea66cc3df14a38419b199913b84d616298fb38d1a4a5c92cf885d49

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
    "created": "2021-11-05T07:39:26.692325137Z",
    "architecture": "amd64",
    "os": "linux",
    "config": {
      "Env": [
        "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
      ],
      "Cmd": [
        "/bin/sh"
      ]
    },
    "rootfs": {
      "type": "layers",
      "diff_ids": [
        "sha256:e2eb06d8af8218cfec8210147357a68b7e13f7c485b991c288c2d01dc228bb68",
        "sha256:f66394ac63df4047b5f85b83ace284471b145ca9d0c029e204318c5641b3fa43"
      ]
    },
    "history": [
      {
        "created": "2021-08-27T17:19:45.553092363Z",
        "created_by": "/bin/sh -c #(nop) ADD file:aad4290d27580cc1a094ffaf98c3ca2fc5d699fe695dfb8e6e9fac20f1129450 in / "
      },
      {
        "created": "2021-08-27T17:19:45.758611523Z",
        "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\"]",
        "empty_layer": true
      },
      {
        "created": "2021-11-05T07:39:26.692325137Z",
        "created_by": "/bin/sh -c #(nop) ADD file:d46e26bb98315616de9963c6399d128eabb29c54472e7633417d95a13ee6a287 in / "
      }
    ]
}

“节外生枝”的镜像下载

一般来说,可能我们会按照解析镜像、下载镜像、转换镜像的步骤来做,但是 nydusify 镜像下载却稍微偏离了主处理流程,是在将 job 添加到 pullWorker 中的时候调用的。

具体来说, pullWorker 是一个 QueueWorkerPool 类型的队列,它所接受的任务必须是这样的数据结构:

1
2
3
4
type RJob interface {
  Do() error
  Err() error
}

而在任务添加到这个队列的时候,会执行该任务的 Do 方法:

1
2
3
job, ok := <-pool.jobs
// ... ...
err := job.Do()

而我们看到前面的 mountJob ,实际上就是 buildLayer 的一个包装:

1
2
3
4
5
6
type mountJob struct {
  err    error
  ctx    context.Context
  layer  *buildLayer
  umount func() error
}

在这个任务添加到队列的时候,会执行 mountJob 的 Do() 方法:

1
2
3
4
5
6
func (job *mountJob) Do() error {
  var umount func() error
  umount, job.err = job.layer.Mount(job.ctx)
  job.umount = umount
  return job.err
}

也就是说,在将任务添加到 pullWorker 队列的时候,实际上会调用 buildLayer 的 Mount() 方法。

1
2
3
4
5
6
7
8
9
10
func (layer *buildLayer) Mount(ctx context.Context) (func() error, error) {
  bootstrapName := strconv.Itoa(layer.index+1) + "-" + layer.source.Digest().String()
  layer.bootstrapPath = filepath.Join(layer.bootstrapsDir, bootstrapName)

  mounts, umount, err := layer.source.Mount(ctx)

  layer.sourceMount, err = parseSourceMount(mounts)

  return umount, mountDone(nil)
}

mount 方法还会调用 provider.SourceLayer 的 Mount 方法,我们来看一下这个 Mount 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func (sl *defaultSourceLayer) Mount(ctx context.Context) ([]mount.Mount, func() error, error) {
  digestStr := sl.desc.Digest.String()

  if err := utils.WithRetry(func() error {
      reader, err := sl.remote.Pull(ctx, sl.desc, true)
      defer reader.Close()

      // Decompress layer from source stream
      if err := utils.UnpackTargz(ctx, sl.mountDir, reader); err != nil {
          return errors.Wrap(err, fmt.Sprintf("Decompress source layer %s", digestStr))
      }

      return nil
  }); err != nil {
      return nil, nil, err
  }

  umount := func() error {
      return os.RemoveAll(sl.mountDir)
  }

  mounts := []mount.Mount{
      {
          Type:   "oci-directory",
          Source: sl.mountDir,
      },
  }

  return mounts, umount, nil
}

我们可以看到,对于本例的 OCI 镜像来说,这里会执行真正的 pull 操作来下载一个 layer,然后解压缩,最后返回这个所谓的 mount 目录。其格式类似 tmp/source/sha256:ce91720a155e2c10eba7a0a7e13a64efb22e3545b25f360e0cca426ee5881d59

转换镜像

转换镜像的操作是按层进行的,主要处理是在 buildLayer(contrib/nydusify/pkg/converter/layer.go) 和 Workflow (contrib/nydusify/pkg/build/workflow.go) 和 Builder (contrib/nydusify/pkg/build/builder.go)中实现的。

简单来说,是 buildLayer 的 Build 方法会调用 Workflow 的 Build 方法,而 Workflow 的 Build 方法则会调用 Builder 的 Run 方法,Builder 最终调用 nydus-image 创建 nydus blob 和 bootstrap。

这中间虽然是层层调用,但多数为组装各种参数,我们直接看到最里层的 Builder 的 Run 函数。其实 Run 函数也很简单,就是组装调用 nydus-image 的参数,然后调用该命令创建 blob 和 bootstrap 文件。

我们就不看具体代码了,实际执行 nydus-image 的命令类似下面这样:

1
2
3
4
5
6
7
8
9
$ nydus-image create \
  --parent-bootstrap tmp/bootstraps/1-sha256:a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074c9fa7035982e \
  --bootstrap tmp/bootstraps/2-sha256:f2afd747bba4e4e1ae68a143d8f610cc44ae712e988a50ef47c9dfb2fddbc290 \
  --log-level warn \
  --whiteout-spec oci \
  --output-json tmp/bootstraps/2-sha256:f2afd747bba4e4e1ae68a143d8f610cc44ae712e988a50ef47c9dfb2fddbc290-output.json \
  --blob tmp/blobs/d46cfc7b-1a5f-4101-b4bd-1016cf46979f \
  --prefetch-policy fs \
  tmp/source/sha256:ce91720a155e2c10eba7a0a7e13a64efb22e3545b25f360e0cca426ee5881d59

我们看到,最后一个参数,也就是文件系统的目录,正好是前面 defaultSourceLayer 的 Mount 方法返回的 mount 位置。

上传 blob/bootstrap/manifest 文件

通过 nydus-image 为所有镜像层都创建完 blob 和 bootstrap 之后,就可以将这些构建物传到 target 里了,这里是我们在本地运行的一个 registry。

具体来说要上传的内包括:

  • bootstrap
  • blob
  • manifest
  • config
  • index(如果有的话)

实现代码在 contrib/nydusify/pkg/converter/manifest.go 文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
func (mm *manifestManager) Push(ctx context.Context, buildLayers []*buildLayer) error {
  var (
      // 用于保存所有 blob 的 ID 列表,
      // 最后这个列表会保存到 bootstrap 的 
      // annotations["containerd.io/snapshot/nydus-blob-ids"] 里
      blobListInAnnotation []string
      referenceBlobs       []string
      layers               []ocispec.Descriptor
  )

  for idx, _layer := range buildLayers {
      record := _layer.GetCacheRecord()
      blobListInAnnotation = appendBlobs(blobListInAnnotation, layersHex(_layer.referenceBlobs))
      referenceBlobs = appendBlobs(referenceBlobs, layersHex(_layer.referenceBlobs))
      // try add reference blob layers to manifest
      if mm.backend.Type() == backend.RegistryBackend {
          for _, blobDesc := range _layer.referenceBlobs {
              if !containsLayer(layers, blobDesc.Digest) {
                  layers = append(layers, blobDesc)
              }
          }
      }

      // Only need to write lastest bootstrap layer in nydus manifest
      if idx == len(buildLayers)-1 {
          // 将 blob ID 列表序列化后保存到 layer 的 annotation 里
          blobListBytes, err := json.Marshal(blobListInAnnotation)
          record.NydusBootstrapDesc.Annotations[utils.LayerAnnotationNydusBlobIDs] = string(blobListBytes)
          // bootstrap 层也加入 layers 数组
          layers = append(layers, *record.NydusBootstrapDesc)
      }
  }

  // 更新 OCI config 属性
  ociConfig, err := mm.sourceProvider.Config(ctx)
  ociConfig.RootFS.DiffIDs = []digest.Digest{}
  ociConfig.History = []ocispec.History{}

  for idx, desc := range layers {
      layerDiffID := digest.Digest(desc.Annotations[utils.LayerAnnotationUncompressed])
      if layerDiffID == "" {
          layerDiffID = desc.Digest
      }
      ociConfig.RootFS.DiffIDs = append(ociConfig.RootFS.DiffIDs, layerDiffID)
  }

  // 序列化 config 对象然后 push 到 registry
  configMediaType := ocispec.MediaTypeImageConfig
  configDesc, configBytes, err := utils.MarshalToDesc(ociConfig, configMediaType)

  if err := mm.remote.Push(ctx, *configDesc, true, bytes.NewReader(configBytes)); err != nil {
      return errors.Wrap(err, "Push Nydus image config")
  }

  // 构建 manifest 对象资源
  manifestMediaType := ocispec.MediaTypeImageManifest
  nydusManifest := struct {
      MediaType string `json:"mediaType,omitempty"`
      ocispec.Manifest
  }{
      MediaType: manifestMediaType,
      Manifest: ocispec.Manifest{
          Versioned: specs.Versioned{
              SchemaVersion: 2,
          },
          Config:      *configDesc,
          Layers:      layers,
          Annotations: mm.buildInfo.Dump(),
      },
  }

  // 序列化 manifest 对象资源
  nydusManifestDesc, manifestBytes, err := utils.MarshalToDesc(nydusManifest, manifestMediaType)
  if err != nil {
      return errors.Wrap(err, "Marshal Nydus image manifest")
  }

  // 设置 Platform 属性
  p, err := mm.CloneSourcePlatform(ctx, utils.ManifestOSFeatureNydus)
  nydusManifestDesc.Platform = p

  if !mm.multiPlatform {
      // 构建 manifest 对象资源
      if err := mm.remote.Push(ctx, *nydusManifestDesc, false, bytes.NewReader(manifestBytes)); err != nil {
          return errors.Wrap(err, "Push nydus image manifest")
      }
      return nil
  }

  // mm.multiPlatform == true 的话,则在 push manifest 后,还要构造 index 对象
  if err := mm.remote.Push(ctx, *nydusManifestDesc, true, bytes.NewReader(manifestBytes)); err != nil {
      return errors.Wrap(err, "Push nydus image manifest")
  }

  // 拿到老的 manifest
  ociManifestDesc, err := mm.sourceProvider.Manifest(ctx)

  // 添加 plotform 属性
  if ociManifestDesc != nil {
      p, err := mm.CloneSourcePlatform(ctx, "")
      ociManifestDesc.Platform = p
  }

  // 获取已有 manifest 列表
  existManifests, err := mm.getExistsManifests(ctx)
  // 根据 nydusManifestDesc 和 ociManifestDesc 来组装 index
  _index, err := mm.makeManifestIndex(ctx, existManifests, nydusManifestDesc, ociManifestDesc)
  indexMediaType := ocispec.MediaTypeImageIndex
  index := struct {
      MediaType string `json:"mediaType,omitempty"`
      ocispec.Index
  }{
      MediaType: indexMediaType,
      Index:     *_index,
  }

  // 序列化 index 后 push 到 registry
  indexDesc, indexBytes, err := utils.MarshalToDesc(index, indexMediaType)
  if err := mm.remote.Push(ctx, *indexDesc, false, bytes.NewReader(indexBytes)); err != nil {
  }

  return nil
}

这就是最后的 manifest push 流程。

方便理解,这里我们把 nydus 镜像的 manifest 和 config 一起 dump 出来方便对比观看。

manifest:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
{
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "schemaVersion": 2,
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:9aed3854ac516fda6913a6e5de3f9d4dd5838f49b89fb956a94227585fdc8c7b",
    "size": 447
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.nydus.blob.v1",
      "digest": "sha256:d542d4a146037df4e940d6921f0e16e9e0307ea60090e71281655c16ce048f8a",
      "size": 3687032,
      "annotations": {
        "containerd.io/snapshot/nydus-blob": "true"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.layer.nydus.blob.v1",
      "digest": "sha256:0bb9d4bf5810ee24b997ce82186a6ec43c3b6e89ba0e1c8e9288157bb704cdd1",
      "size": 17,
      "annotations": {
        "containerd.io/snapshot/nydus-blob": "true"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:df520c0bae065336a455543acd7abd1ff6437dff7424dfdab327dd50576d6d59",
      "size": 18174,
      "annotations": {
        "containerd.io/snapshot/nydus-blob-ids": "[\"d542d4a146037df4e940d6921f0e16e9e0307ea60090e71281655c16ce048f8a\",\"0bb9d4bf5810ee24b997ce82186a6ec43c3b6e89ba0e1c8e9288157bb704cdd1\"]",
        "containerd.io/snapshot/nydus-bootstrap": "true"
      }
    }
  ],
  "annotations": {
    "nydus.trace.builder-version": "2.0.0-acaf55d8d6e4f6aecbbe2fc63443680623d998b2",
    "nydus.trace.nydusify-version": "acaf55d8d6e4f6aecbbe2fc63443680623d998b2.20220510.0340",
    "nydus.trace.source-digest": "sha256:00e2afc814fb9c3c48858dc5b17758e49ca2619f2bdd544c6f9810c397d83137",
    "nydus.trace.source-reference": "liubin/nydus:two-layers"
  }
}

config:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "created": "2021-11-05T07:39:26.692325137Z",
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/sh"
    ]
  },
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:d542d4a146037df4e940d6921f0e16e9e0307ea60090e71281655c16ce048f8a",
      "sha256:0bb9d4bf5810ee24b997ce82186a6ec43c3b6e89ba0e1c8e9288157bb704cdd1",
      "sha256:1ad6993081526b32b2ba95d94d23808d91ec5f4bac546eb328d539336d66a34a"
    ]
  }
}

到这里,只是梳理了 nydusify convert 命令的处理流程,下一步,计划再进入到 nydus-image create 命令,看看它是如何将指定的一个层(文件夹)转换为 blob 和 bootstrap 的。

结束

这里只是看了大致的流程,相对 nydus-image 来说,nydusify 代码还是相对简单写。

关于 nydus-image 的源码分析,可以参考 这篇文章