Containerd Diff 服务

概述

本文主要为从代码层面分析 Containerd diff 服务模块的实现逻辑,如下图 containerd 架构图所示:

Containerd Diff 服务_第1张图片
containerd-diff.png

Containerd diff 服务模块,实现了 diff 和 apply 两个主要的服务管理功能 :

  • Diff 服务计算所提供的上层/下层 mount 目录的差异,遵从 OCI 规范 Changesets (变化集)打包 tar diff 镜像层存储。

  • Apply 服务将所指定描述器( ocispec.Descriptor )的参照内容应用至指定的挂载目录。通常情况下,描述器指向一个 tar 格式的文件系统差异,此差异文件系统 tar 将被应用于挂载点的顶层。

简单点描述为 " Diff 功能实现 diff layer 生成 , Apply 功能实现 diff layer 挂载 "。

Containerd 被设计为一个类似微服务的架构, 针对各个组件都提供了 gRPC 接口来进行访问。Containerd 各组件以插件机制来组织管理,本文 diff 的服务将从插件注册开始展开。

Diff 服务注册链关系

上层 GRPC Plugin 注册 diff , 获取已注册 Diff 服务插件列表,返回 Diff RPC 服务实现类 service{} 对象

!FILENAME services/diff/service.go:29

func init() {
    plugin.Register(&plugin.Registration{
        Type: plugin.GRPCPlugin,
        ID:   "diff",
        Requires: []plugin.Type{
            plugin.ServicePlugin,
        },
        InitFn: func(ic *plugin.InitContext) (interface{}, error) {
            plugins, err := ic.GetByType(plugin.ServicePlugin)  // 获取所有服务插件 
            if err != nil {
                return nil, err
            }
            p, ok := plugins[services.DiffService]              // 获取 diff 服务插件
            if !ok {
                return nil, errors.New("diff service not found")
            }
            i, err := p.Instance()                           // 返回 diff 服务插件 initFn 的实例化对象
            if err != nil {
                return nil, err
            }
            return &service{local: i.(diffapi.DiffClient)}, nil
        },
    })
}

Service Plugin 服务插件 diff 服务注册,返回 Local{} 服务实现类对象

!FILENAME services/diff/local.go:49

func init() {
    plugin.Register(&plugin.Registration{
        Type: plugin.ServicePlugin,
        ID:   services.DiffService,
        Requires: []plugin.Type{
            plugin.DiffPlugin,
        },
        Config: defaultDifferConfig,    // unix 默认 Order: []string{"walking"}
        InitFn: func(ic *plugin.InitContext) (interface{}, error) {
            differs, err := ic.GetByType(plugin.DiffPlugin)
            if err != nil {
                return nil, err
            }

            orderedNames := ic.Config.(*config).Order
            ordered := make([]differ, len(orderedNames))
            for i, n := range orderedNames {
                differp, ok := differs[n]
                if !ok {
                    return nil, errors.Errorf("needed differ not loaded: %s", n)
                }
                d, err := differp.Instance()
                if err != nil {
                    return nil, errors.Wrapf(err, "could not load required differ due plugin init error: %s", n)
                }

                ordered[i], ok = d.(differ)
                if !ok {
                    return nil, errors.Errorf("differ does not implement Comparer and Applier interface: %s", n)
                }
            }

      return &local{         // 返回服务实现类 local{} 实例,即上层 RPC 注册时 p.Instance()
                differs: ordered,    // 指定了 differs 对象列表,底层实现 differ 接口方法 (walking)
            }, nil
        },
    })
}

底层实现类插件 Diff Plugin 注册 "walking" , 返回实例 diffPlugin{} 实现类对象

!FILENAME diff/walking/plugin/plugin.go:28

func init() {
    plugin.Register(&plugin.Registration{
        Type: plugin.DiffPlugin,
        ID:   "walking",
        Requires: []plugin.Type{
            plugin.MetadataPlugin,
        },
        InitFn: func(ic *plugin.InitContext) (interface{}, error) {
            md, err := ic.Get(plugin.MetadataPlugin)
            if err != nil {
                return nil, err
            }

            ic.Meta.Platforms = append(ic.Meta.Platforms, platforms.DefaultSpec())
      // 创建 metadata.DB 类型内容存储库,作为后面插件传入参数,用于保存 diff layer 元数据项。
            cs := md.(*metadata.DB).ContentStore()

            return diffPlugin{
                Comparer: walking.NewWalkingDiff(cs),         // 差异比对器操作对象
                Applier:  apply.NewFileSystemApplier(cs),     // 文件系统应用器操作对象
            }, nil
        },
    })
}

Diff gRPC Service 和 local diff 实现

前面 diff 服务插件注册时返回为 local{} 类对象,与此同时 diff 服务提供了下面两个方法 Apply() 、Diff()。而 service.local 属性为 diffapi.DiffClient 类型,代表diff client GRPC 请求来实现功能调用。而 diff 服务插件的注册时返回的实现了 diff 服务的类对象则是 local{} ,所有 s.local.Diff 的调用即是 local 类的 Diff 方法。此处应该注意 s.local 的 local 是一个 client 接口,而实现类也是一个同名的 local 类。

!FILENAME services/diff/service.go:65

func (s *service) Apply(ctx context.Context, er *diffapi.ApplyRequest) (*diffapi.ApplyResponse, error) {
    return s.local.Apply(ctx, er)      // RPC 请求 "/containerd.services.diff.v1.Diff/Apply"
}

func (s *service) Diff(ctx context.Context, dr *diffapi.DiffRequest) (*diffapi.DiffResponse, error) {
    return s.local.Diff(ctx, dr)      // RPC 请求 "/containerd.services.diff.v1.Diff/Diff"
}

local.Apply

!FILENAME services/diff/local.go:94

func (l *local) Apply(ctx context.Context, er *diffapi.ApplyRequest, _ ...grpc.CallOption) (*diffapi.ApplyResponse, error) {
    var (
        ocidesc ocispec.Descriptor
        err     error
        desc    = toDescriptor(er.Diff)
        mounts  = toMounts(er.Mounts)
    )

    var opts []diff.ApplyOpt
    if er.Payloads != nil {
        opts = append(opts, diff.WithPayloads(er.Payloads))
    }
 
  // differ 应用调用
    for _, differ := range l.differs {
        ocidesc, err = differ.Apply(ctx, desc, mounts, opts...)
        if !errdefs.IsNotImplemented(err) {
            break
        }
    }

    if err != nil {
        return nil, errdefs.ToGRPC(err)
    }

  // grpc响应,应用返回的 ocidesc.Descriptor 
    return &diffapi.ApplyResponse{
        Applied: fromDescriptor(ocidesc),
    }, nil

}

local.Diff

!FILENAME services/diff/local.go:124

func (l *local) Diff(ctx context.Context, dr *diffapi.DiffRequest, _ ...grpc.CallOption) (*diffapi.DiffResponse, error) {
    var (
        ocidesc ocispec.Descriptor
        err     error
        aMounts = toMounts(dr.Left)
        bMounts = toMounts(dr.Right)
    )
  
  // 参数配置
    var opts []diff.Opt
    if dr.MediaType != "" {
        opts = append(opts, diff.WithMediaType(dr.MediaType))
    }
    if dr.Ref != "" {
        opts = append(opts, diff.WithReference(dr.Ref))
    }
    if dr.Labels != nil {
        opts = append(opts, diff.WithLabels(dr.Labels))
    }

  // differ 差异比较调用
    for _, d := range l.differs {
        ocidesc, err = d.Compare(ctx, aMounts, bMounts, opts...)
        if !errdefs.IsNotImplemented(err) {
            break
        }
    }
    if err != nil {
        return nil, errdefs.ToGRPC(err)
    }

 // grpc响应,应用返回的 ocidesc.Descriptor 
    return &diffapi.DiffResponse{
        Diff: fromDescriptor(ocidesc),
    }, nil
}

我们可以看到上面的 gRPC 实现服务层 Local 调用 Apply 和 Diff , 最终会调用底层 diffPlugin 类实例化的两个属性值对象: walking.walkingDiff 和 apply.fsApplier ,下面将一一分解分析。

Comparer 接口和 walking 实现

Diff 的差异比对器的相关接口与结构定义

!FILENAME diff/diff.go:46

// Comparer allows creation of filesystem diffs between mounts
// Comparer 允许创建两个挂载点的文件系统差异
type Comparer interface {
  // Compare 计算两个挂载点的不同,返回计算差异 diff 的描述符。
  // 参数opts (可选项):
  // ref 参照ID -- 可被用于定位所创建的 diff 的实际内容
  // media 类型 -- 用于决定所创建实际内容的格式
    Compare(ctx context.Context, lower, upper []mount.Mount, opts ...Opt) (ocispec.Descriptor, error)
}

!FILENAME diff/diff.go:28

// Opt 用于配置 diff 操作,通过 func 的方式来操作 diff config
type Opt func(*Config) error

// Config is used to hold parameters needed for a diff operation
type Config struct {
  // 生成的 diff 层的类型如:“application/vnd.oci.image.layer.v1.tar+gzip”
    MediaType string

    // 内容的上传参照
    // 默认为随机字符串
    Reference string

  // 对生成的内容应用标签
    Labels map[string]string
}

Walking.NewWalkingDiff(cs) 返回 walkingDiff ,此类对象实现了 diff.Comparer 接口。walkingDiff store 属性为内容存储 content.Store 。

!FILENAME diff/walking/differ.go:52

func NewWalkingDiff(store content.Store) diff.Comparer {
    return &walkingDiff{
        store: store,        // 内容存储 
    }
}
// 类结构定义
type walkingDiff struct {
    store content.Store  
}

Compare() 基于指定的两个挂载目录创建 diff layer,并将其 diff 内容上传到内容存储库

walkingDiff Compare 方法实现:

  • upper 上层为变化层挂载目录,lower 下层为基线层挂载目录,上/下进行差异比对;
  • 按照 OCI changesets layer 规范打包计算差异结果集 diff tar 并存储至content store(注意两块存储: content 和 content matedata );
  • 通过对目录路径对比、文件属性对比、文件内容字节对比来分析与计算变化类型;
  • 变化类型主要包含:add 、modify 、delete 、unmodified。

!FILENAME diff/walking/differ.go:60

func (s *walkingDiff) Compare(ctx context.Context, lower, upper []mount.Mount, opts ...diff.Opt) (d ocispec.Descriptor, err error) {
  // 根据 opts 设置 diff 操作 Config 
    var config diff.Config
    for _, opt := range opts {
        if err := opt(&config); err != nil {
            return emptyDesc, err
        }
    }
 
  // 设置默认媒体类型
    if config.MediaType == "" {
        config.MediaType = ocispec.MediaTypeImageLayerGzip   //压缩镜像层类型
    }

    var isCompressed bool
    switch config.MediaType {           // 类型判断与 isCompressed 压缩标识
    case ocispec.MediaTypeImageLayer:   // 非压缩镜像层类型
    case ocispec.MediaTypeImageLayerGzip:
        isCompressed = true
    default:
        return emptyDesc, errors.Wrapf(errdefs.ErrNotImplemented, "unsupported diff media type: %v", config.MediaType)
    }

    var ocidesc ocispec.Descriptor
  // 临时目录挂载上 和 下镜像层
    if err := mount.WithTempMount(ctx, lower, func(lowerRoot string) error {
        return mount.WithTempMount(ctx, upper, func(upperRoot string) error {
            var newReference bool
            if config.Reference == "" {
                newReference = true
                config.Reference = uniqueRef()   // 生成 ref 唯一值 
            }

      // 获取 store.Writer 
            cw, err := s.store.Writer(ctx,
                content.WithRef(config.Reference),
                content.WithDescriptor(ocispec.Descriptor{
                    MediaType: config.MediaType, // most contentstore implementations just ignore this
                }))
            if err != nil {
                return errors.Wrap(err, "failed to open writer")
            }
          //...
            if !newReference {
                if err = cw.Truncate(0); err != nil {
                    return err
                }
            }

      // 压缩类型镜像层处理写入 diff 内容数据
            if isCompressed {
                dgstr := digest.SHA256.Digester()     // SHA256 算法摘要串生成器
                var compressed io.WriteCloser
                                              // 压缩 Gzip
                compressed, err = compression.CompressStream(cw, compression.Gzip)
                if err != nil {
                    return errors.Wrap(err, "failed to get compressed stream")
                }        
        
        // +计算差异并写入 diff 内容数据 tar 
                err = archive.WriteDiff(ctx, io.MultiWriter(compressed, dgstr.Hash()), lowerRoot, upperRoot)                                   
                compressed.Close()
                if err != nil {
                    return errors.Wrap(err, "failed to write compressed diff")
                }

                if config.Labels == nil {
                    config.Labels = map[string]string{}
                }                                     // 压缩标签,存入摘要字串
                config.Labels[uncompressed] = dgstr.Digest().String()
            } else {    
        // 非压缩类型 tar 镜像层处理写入 diff 内容数据
                if err = archive.WriteDiff(ctx, cw, lowerRoot, upperRoot); err != nil {
                    return errors.Wrap(err, "failed to write diff")
                }
            }
      // commit 标签选项的设置
            var commitopts []content.Opt
            if config.Labels != nil {
                commitopts = append(commitopts, content.WithLabels(config.Labels))
            }
 
      // 生成内容摘要值,并接交至内容元数据存储
            dgst := cw.Digest()
            if err := cw.Commit(ctx, 0, dgst, commitopts...); err != nil {
                if !errdefs.IsAlreadyExists(err) {
                    return errors.Wrap(err, "failed to commit")
                }
            }
      
      // 获取 dgst 对应的内容存储 info 信息
            info, err := s.store.Info(ctx, dgst)
            if err != nil {
                return errors.Wrap(err, "failed to get info from content store")
            }
            if info.Labels == nil {
                info.Labels = make(map[string]string)
            }
      
      // 内容存储 dgst 存在但无压缩标签时,设置 info 压缩标签
            if _, ok := info.Labels[uncompressed]; !ok {
                info.Labels[uncompressed] = config.Labels[uncompressed]
                if _, err := s.store.Update(ctx, info, "labels."+uncompressed); err != nil {
                    return errors.Wrap(err, "error setting uncompressed label")
                }
            }

      // 返回 diff 内容数据的描述器 Descriptor 
            ocidesc = ocispec.Descriptor{
                MediaType: config.MediaType,
                Size:      info.Size,
                Digest:    info.Digest,
            }
            return nil
        })
    }); err != nil {
        return emptyDesc, err
    }

    return ocidesc, nil
}

WriteDiff 计算所提供的两个目录(a/b)的差异并写入 tar 包数据流。fs.Changes() 遍历计算差异,回调函数HandleChange为 ChangeSets 差异集 tar 打包处理逻辑, 其所生成的 tar 使用 OCI 标准规范的的文件标记用于对删除的文件表示。删除的文件基于 AUFS whiteouts 规范以 ".wh." 作为前缀扩充命名文件。详细规范可以参考官方 image-spec 或 image-spec 中文

!FILENAME archive/tar.go:72

func WriteDiff(ctx context.Context, w io.Writer, a, b string) error {
    cw := newChangeWriter(w, b)    // +创建变化 writer 
    err := fs.Changes(ctx, a, b, cw.HandleChange)
    if err != nil {
        return errors.Wrap(err, "failed to create diff tar stream")
    }
    return cw.Close()
}

!FILENAME archive/tar.go:451

func newChangeWriter(w io.Writer, source string) *changeWriter {
    return &changeWriter{
        tw:        tar.NewWriter(w),          // 创建 Writer 写数据
        source:    source,                    // 变化目录
        whiteoutT: time.Now(),
        inodeSrc:  map[uint64]string{},
        inodeRefs: map[uint64][]string{},
        addedDirs: map[string]struct{}{},
    }
}

Changes 计算调用给定的变化计算函数 func 为每个变化类型进行处理。'a' 是指基线目录和 'b' 是更改的目录。变化回调按路径名排顺序调用和应用。正因此顺序,下面是情况为正确的:

  • 删除的目录树只为根目录创建一个更改目录已删除项。其余的更改是隐含的。

  • 一个目录修改为文件将不会有删除子路径项的条目,通过父目录删除条目来暗指这些条目删除。

隐藏目录不会被特殊处理,每个文件从基本目录中删除将显示为删除。

将对具有时间戳的文件进行文件内容比较可能存在被截断情况。如果正在比较的任意一个文件存在有一个零值纳秒值,将比较每个字节差异。如果两个文件具有相同的秒值但不同纳秒值如果其中一个值为零,则文件将如果内容相同,则视为未更改。这种行为是为了说明打包处理期间对时间戳截断的引起。

!FILENAME vendor/github.com/containerd/continuity/fs/diff.go:101

func Changes(ctx context.Context, a, b string, changeFn ChangeFunc) error {
    if a == "" {
    // 如果 'a' 基线目录为空,则直接以增加类型变化处理所有文件
        logrus.Debugf("Using single walk diff for %s", b)
        return addDirChanges(ctx, changeFn, b)
    
    } else if diffOptions := detectDirDiff(b, a); diffOptions != nil {
    // 有配置 diffOptions,当前版本 detectDirDiff() 直接返回 nil ,可忽略此逻辑(TODO项) 
        logrus.Debugf("Using single walk diff for %s from %s", diffOptions.diffDir, a)
        return diffDirChanges(ctx, changeFn, a, diffOptions)
    }
  
  // 使用上/下双目录同时遍历与计算 diff 
    logrus.Debugf("Using double walk diff for %s from %s", b, a)
    return doubleWalkDiff(ctx, changeFn, a, b)
}

HandleChange 处理逻辑 func ,作为 Changes 计算的输入参数,为每个改变类型进行处理。变化类型主要包含:add 、modify 、delete 、unmodified(未更改)、空。

针对删除类型处理方式为 whiteOut ,则对删除的文件或目录创建前缀 ".wh."+原始名的 whiteOut 文件。其它类型将内容进行 tar 打包。

!FILENAME archive/tar.go:462

func (cw *changeWriter) HandleChange(k fs.ChangeKind, p string, f os.FileInfo, err error) error {
    if err != nil {
        return err
    }
    if k == fs.ChangeKindDelete {
    // 变化类型为"删除"
        whiteOutDir := filepath.Dir(p)
        whiteOutBase := filepath.Base(p)
    // whiteOut 前缀 ".wh."  +  原始文件名 
        whiteOut := filepath.Join(whiteOutDir, whiteoutPrefix+whiteOutBase)
        hdr := &tar.Header{
            Typeflag:   tar.TypeReg,
            Name:       whiteOut[1:],   
            Size:       0,
            ModTime:    cw.whiteoutT,
            AccessTime: cw.whiteoutT,
            ChangeTime: cw.whiteoutT,
        }
    // whiteOut 父级目录不为 root 目录则作为修改变化类型处理 
        if err := cw.includeParents(hdr); err != nil {
            return err
        }
    // tar 写入 whiteout Header 
        if err := cw.tw.WriteHeader(hdr); err != nil {
            return errors.Wrap(err, "failed to write whiteout header")
        }
    } else {  
    // 其它变化类型
        var (
            link   string
            err    error
            source = filepath.Join(cw.source, p)
        )

        switch {
        case f.Mode()&os.ModeSocket != 0:   // 忽略 sockets 文件
            return nil 
        case f.Mode()&os.ModeSymlink != 0:  // 链接文件则获取链接的目标位置
            if link, err = os.Readlink(source); err != nil {
                return err
            }
        }

   // 创建一个部分填充的头,如果f为链接,将链接记录为链接目标。如果f为目录,则在名称后附加斜杠。
        hdr, err := tar.FileInfoHeader(f, link)
        if err != nil {
            return err
        }

    // 设置 header 权限与模式位
        hdr.Mode = int64(chmodTarEntry(os.FileMode(hdr.Mode)))

        name := p
        if strings.HasPrefix(name, string(filepath.Separator)) {
            name, err = filepath.Rel(string(filepath.Separator), name)
            if err != nil {
                return errors.Wrap(err, "failed to make path relative")
            }
        }
    // 返回系统支持的 Name 
        name, err = tarName(name)  
        if err != nil {
            return errors.Wrap(err, "cannot canonicalize path")
        }
        // suffix with '/' for directories
        if f.IsDir() && !strings.HasSuffix(name, "/") {
            name += "/"
        }
    // 设置 header Name 
        hdr.Name = name

        if err := setHeaderForSpecialDevice(hdr, name, f); err != nil {
            return errors.Wrap(err, "failed to set device headers")
        }

    // 链接处理
        var additionalLinks []string
        inode, isHardlink := fs.GetLinkInfo(f)
        if isHardlink {
      // 硬链接
            // If the inode has a source, always link to it
            if source, ok := cw.inodeSrc[inode]; ok {
                hdr.Typeflag = tar.TypeLink
                hdr.Linkname = source
                hdr.Size = 0
            } else {
                if k == fs.ChangeKindUnmodified {
                    cw.inodeRefs[inode] = append(cw.inodeRefs[inode], name)
                    return nil
                }
                cw.inodeSrc[inode] = name
                additionalLinks = cw.inodeRefs[inode]
                delete(cw.inodeRefs, inode)
            }
        } else if k == fs.ChangeKindUnmodified {
      // 无改变类型,直接返回
            return nil
        }

    // header 设置 security.capability 
        if capability, err := getxattr(source, "security.capability"); err != nil {
            return errors.Wrap(err, "failed to get capabilities xattr")
        } else if capability != nil {
            if hdr.PAXRecords == nil {
                hdr.PAXRecords = map[string]string{}
            }
            hdr.PAXRecords[paxSchilyXattr+"security.capability"] = string(capability)
        }

        if err := cw.includeParents(hdr); err != nil {
            return err
        }
    // tar 写入文件头 
        if err := cw.tw.WriteHeader(hdr); err != nil {
            return errors.Wrap(err, "failed to write file header")
        }

        if hdr.Typeflag == tar.TypeReg && hdr.Size > 0 {
            file, err := open(source)   // 打开文件
            if err != nil {
                return errors.Wrapf(err, "failed to open path: %v", source)
            }
            defer file.Close()
     
      // tar 写入文件内容数据
            n, err := copyBuffered(context.TODO(), cw.tw, file)
            if err != nil {
                return errors.Wrap(err, "failed to copy")
            }
            if n != hdr.Size {
                return errors.New("short write copying file")
            }
        }

    // 写入 tar header 附加链信息
        if additionalLinks != nil {
            source = hdr.Name
            for _, extra := range additionalLinks {
                hdr.Name = extra
                hdr.Typeflag = tar.TypeLink
                hdr.Linkname = source
                hdr.Size = 0

                if err := cw.includeParents(hdr); err != nil {
                    return err
                }
                if err := cw.tw.WriteHeader(hdr); err != nil {
                    return errors.Wrap(err, "failed to write file header")
                }
            }
        }
    }
    return nil
}

addDirChanges 直接以 " add " 增加类型变化处理所有文件

!FILENAME vendor/github.com/containerd/continuity/fs/diff.go:114

func addDirChanges(ctx context.Context, changeFn ChangeFunc, root string) error {
    return filepath.Walk(root, func(path string, f os.FileInfo, err error) error {
        if err != nil {
            return err
        }

        // Rebase path
        path, err = filepath.Rel(root, path)
        if err != nil {
            return err
        }

        path = filepath.Join(string(os.PathSeparator), path)

        // Skip root
        if path == string(os.PathSeparator) {
            return nil
        }

        return changeFn(ChangeKindAdd, path, f, nil)  // 增加变化类型处理
    })
}

doubleWalkDiff 遍历两个 A 、B 目录进行对比分析差异,并调用 changeFn 处理

!FILENAME vendor/github.com/containerd/continuity/fs/diff.go:234

// doubleWalkDiff walks both directories to create a diff
func doubleWalkDiff(ctx context.Context, changeFn ChangeFunc, a, b string) (err error) {
    g, ctx := errgroup.WithContext(ctx)

    var (
        c1 = make(chan *currentPath)
        c2 = make(chan *currentPath)

        f1, f2 *currentPath
        rmdir  string
    )
    g.Go(func() error {
        defer close(c1)
        return pathWalk(ctx, a, c1)                   // 遍历 A 目录,写入 chan C1
    }) 
    g.Go(func() error {
        defer close(c2)
        return pathWalk(ctx, b, c2)                   // 遍历 B 目录,写入 chan C2
    })
    g.Go(func() error {
        for c1 != nil || c2 != nil {      //循环比对
            if f1 == nil && c1 != nil {
                f1, err = nextPath(ctx, c1)
                if err != nil {
                    return err
                }
                if f1 == nil {
                    c1 = nil
                }
            }

            if f2 == nil && c2 != nil {
                f2, err = nextPath(ctx, c2)
                if err != nil {
                    return err
                }
                if f2 == nil {
                    c2 = nil
                }
            }
            if f1 == nil && f2 == nil {
                continue
            }

            var f os.FileInfo
            k, p := pathChange(f1, f2)   // +计算路径并返回变化类型
            switch k {
            case ChangeKindAdd: // 增加变化类型
                if rmdir != "" {
                    rmdir = ""
                }
                f = f2.f    
                f2 = nil
            case ChangeKindDelete:// 删除变化类型 
                if rmdir != "" && strings.HasPrefix(f1.path, rmdir) {
                    f1 = nil
                    continue
                } else if f1.f.IsDir() {                      // 如果是目录则赋值 rmdir 
                    rmdir = f1.path + string(os.PathSeparator)
                } else if rmdir != "" {
                    rmdir = ""
                }
                f1 = nil
            case ChangeKindModify:   // 修改变化类型 
                same, err := sameFile(f1, f2)                 // 计算两者文件是否有修改
                if err != nil {
                    return err
                }
                if f1.f.IsDir() && !f2.f.IsDir() {            // 如果文件修改为目录,删除目录
                    rmdir = f1.path + string(os.PathSeparator)  
                } else if rmdir != "" {
                    rmdir = ""
                }
                f = f2.f
                f1 = nil
                f2 = nil
                if same {                                    // 一致则为 Unmodified 未修改变化类型
                    if !isLinked(f) {
                        continue
                    }
                    k = ChangeKindUnmodified
                }
            }
      // 调用 changeFn 处理变化
            if err := changeFn(k, p, f, nil); err != nil {
                return err
            }
        }
        return nil
    })

    return g.Wait() 
}

pathChange() 计算路径是否改变

vendor/github.com/containerd/continuity/fs/path.go:39

func pathChange(lower, upper *currentPath) (ChangeKind, string) {
    // lower 不存在, upper 存在,则为 ADD 增加类型变化
  if lower == nil {
        if upper == nil {
            panic("cannot compare nil paths")
        }
        return ChangeKindAdd, upper.path
    }
  
  // 上面反之则为 Delete 删除类型变化
    if upper == nil {
        return ChangeKindDelete, lower.path
    }

  // 都存在的两者目录的路径长度大小比较或路径字符大小逐一比较
    switch i := directoryCompare(lower.path, upper.path); {
    case i < 0:
    // upper 不存在,lower 存在 
        return ChangeKindDelete, lower.path
    case i > 0:
    // upper 存在,lower 不存在 
        return ChangeKindAdd, upper.path
    default:
    // 默认
        return ChangeKindModify, upper.path
    }
}

func directoryCompare(a, b string) int {
    l := len(a)
    if len(b) < l {
        l = len(b)
    }
  
  // 路径字符逐一比较
    for i := 0; i < l; i++ {
        c1, c2 := a[i], b[i]
        if c1 == filepath.Separator {
            c1 = byte(0)
        }
        if c2 == filepath.Separator {
            c2 = byte(0)
        }
        if c1 < c2 {
            return -1
        }
        if c1 > c2 {
            return +1
        }
    }
  
  // 路径字符长度比较 
    if len(a) < len(b) {
        return -1
    }
    if len(a) > len(b) {
        return +1
    }
    return 0
}

sameFile 计算文件是否被修改。

!FILENAME vendor/github.com/containerd/continuity/fs/path.go:91

func sameFile(f1, f2 *currentPath) (bool, error) {
  // fileStat 比较是否一致
    if os.SameFile(f1.f, f2.f) {
        return true, nil
    }

  // 系统 Stat 是否一致
    equalStat, err := compareSysStat(f1.f.Sys(), f2.f.Sys())
    if err != nil || !equalStat {
        return equalStat, err
    }

  // security.capability 是否一致
    if eq, err := compareCapabilities(f1.fullPath, f2.fullPath); err != nil || !eq {
        return eq, err
    }


  // 如果不是目录检测文件大小、修改时间、和内容
    if !f1.f.IsDir() {
        if f1.f.Size() != f2.f.Size() { // 文件大小
            return false, nil
        }
        t1 := f1.f.ModTime()
        t2 := f2.f.ModTime()

        if t1.Unix() != t2.Unix() {   // 修改时间比对
            return false, nil
        }

    // 纳秒值存在截断情况,进一步检测两者内容差异
        if t1.Nanosecond() == 0 && t2.Nanosecond() == 0 {
            var eq bool
      // 链接类型比对链接目录是否一致
            if (f1.f.Mode() & os.ModeSymlink) == os.ModeSymlink {
                eq, err = compareSymlinkTarget(f1.fullPath, f2.fullPath)
            } else if f1.f.Size() > 0 {
        // 比对文件内容,两个文件内容每个字节进行对比是否存在差异
                eq, err = compareFileContent(f1.fullPath, f2.fullPath)
            }
            if err != nil || !eq {
                return eq, err
            }
        } else if t1.Nanosecond() != t2.Nanosecond() {
            return false, nil
        }
    }

    return true, nil
}

Applier 接口和 fsApplier 应用实现

Apply 将所指定描述器( ocispec.Descriptor )的参照内容应用至指定的挂载目录。 例如:通常情况下,描述器是一个 tar 格式的文件系统差异,此差异文件系统 tar 将被应用于挂载点的顶层。

OCI image-spec 规范对 Changesets (变化集)应用描述:

  • 媒体类型为 "application/vnd.oci.image.layer.v1.tar" 的变化集 layer 被应用不仅仅是解包 tar 文件
  • 变化集 layer 应用需要重点考虑 whiteout 文件处理
  • 在无 whiteout 文件的Changesets (变化集) 如同正常 tar 文件解包

应用 Changesets (变化集) 实体,如果目标路径文件已存在,则:

  • 移除文件路径
  • 基于变化集实体项内容与属性,重建文件路径

Applier 接口定义如下:

!FILENAME diff/diff.go:65

type Applier interface {
    Apply(ctx context.Context, desc ocispec.Descriptor, mount []mount.Mount, opts ...ApplyOpt) (ocispec.Descriptor, error)
}

fsApplier 是 Applier 接口的实现类,定义与对象构造如下,注意唯一参数 content.Provider 内容读取器

!FILENAME diff/apply/apply.go:37

func NewFileSystemApplier(cs content.Provider) diff.Applier {
    return &fsApplier{
        store: cs,              // 内容读取器
    }
}

type fsApplier struct {
    store content.Provider       
}

fsApplier.Apply 实现 Applier 接口的 Apply 方法。Apply 将所指定摘要值关联的内容应用至指定的挂载目录,打包文件将被解包或解压缩释放。

!FILENAME diff/apply/apply.go:52

func (s *fsApplier) Apply(ctx context.Context, desc ocispec.Descriptor, mounts []mount.Mount, opts ...diff.ApplyOpt) (d ocispec.Descriptor, err error) {
    t1 := time.Now()
  //...
  // apply 操作 opts 配置
    var config diff.ApplyConfig
    for _, o := range opts {
        if err := o(ctx, desc, &config); err != nil {
            return emptyDesc, errors.Wrap(err, "failed to apply config opt")
        }
    }

  // 基于指定的 desc 描述符读取 content 内容 
    ra, err := s.store.ReaderAt(ctx, desc)
    if err != nil {
        return emptyDesc, errors.Wrap(err, "failed to get reader from content store")
    }
    defer ra.Close()

    var processors []diff.StreamProcessor
  // 创建内容处理器
    processor := diff.NewProcessorChain(desc.MediaType, content.NewReader(ra))
    processors = append(processors, processor)
    for {
    // 获取 config 指定的类型内容处理器
        if processor, err = diff.GetProcessor(ctx, processor, config.ProcessorPayloads); err != nil {
            return emptyDesc, errors.Wrapf(err, "failed to get stream processor for %s", desc.MediaType)
        }
        processors = append(processors, processor)
        if processor.MediaType() == ocispec.MediaTypeImageLayer {
            break
        }
    }
    defer processor.Close()
  // 创建摘要生成器
    digester := digest.Canonical.Digester()
  // 从 processor 读写入至 digester.Hash() ,返回 reader;
  // readCounter 包含读取器对象和读取大小统计
    rc := &readCounter{
        r: io.TeeReader(processor, digester.Hash()),
    }

  // +将内容应用至挂载目录上 
    if err := apply(ctx, mounts, rc); err != nil {
        return emptyDesc, err
    }

    // Read any trailing data
    if _, err := io.Copy(ioutil.Discard, rc); err != nil {
        return emptyDesc, err
    }

    for _, p := range processors {
        if ep, ok := p.(interface {
            Err() error
        }); ok {
            if err := ep.Err(); err != nil {
                return emptyDesc, err
            }
        }
    }
  // 返回镜像层的 Descriptor
    return ocispec.Descriptor{
        MediaType: ocispec.MediaTypeImageLayer,
        Size:      rc.c,
        Digest:    digester.Digest(),
    }, nil
}

此处理关注于 linux 系统版本和 aufs 挂载文件系统方式 apply 实现逻辑。此处 aufs 联合文件系统基础知识可以参考

!FILENAME diff/apply/apply_linux.go:32

func apply(ctx context.Context, mounts []mount.Mount, r io.Reader) error {
    switch {
   // overlay 文件系统; mounts 长度为1
    case len(mounts) == 1 && mounts[0].Type == "overlay":
        path, parents, err := getOverlayPath(mounts[0].Options)
        if err != nil {
            if errdefs.IsInvalidArgument(err) {
                break
            }
            return err
        }
        opts := []archive.ApplyOpt{
            archive.WithConvertWhiteout(archive.OverlayConvertWhiteout),
        }
        if len(parents) > 0 {
            opts = append(opts, archive.WithParents(parents))
        }
        _, err = archive.Apply(ctx, path, r, opts...)
        return err
  // aufs 文件系统;mounts 长度为1
    case len(mounts) == 1 && mounts[0].Type == "aufs":
    // 返回 mounts 的(上层 path )读写层和(下层 parents )只读层
        path, parents, err := getAufsPath(mounts[0].Options)
        if err != nil {
            if errdefs.IsInvalidArgument(err) {
                break
            }
            return err
        }
    // 转化 whiteout 文件配置处理 func 
        opts := []archive.ApplyOpt{
            archive.WithConvertWhiteout(archive.AufsConvertWhiteout),
        }
        if len(parents) > 0 {
            opts = append(opts, archive.WithParents(parents))
        }
    // +diff tar 数据流应用至一个目录
        _, err = archive.Apply(ctx, path, r, opts...)
        return err
    }
  // +other 
    return mount.WithTempMount(ctx, mounts, func(root string) error {
        _, err := archive.Apply(ctx, root, r)
        return err
    })
}

应用 OCI 规范的 diff tar 数据流, OCI 规范变化集应用详情可参考applying-changesets

!FILENAME archive/tar.go:101

// Apply applies a tar stream of an OCI style diff tar.
func Apply(ctx context.Context, root string, r io.Reader, opts ...ApplyOpt) (int64, error) {
    root = filepath.Clean(root)
  // 应用选项配置
    var options ApplyOptions
    for _, opt := range opts {
        if err := opt(&options); err != nil {
            return 0, errors.Wrap(err, "failed to apply option")
        }
    }
    if options.Filter == nil {
        options.Filter = all
    }
    if options.applyFunc == nil {
        options.applyFunc = applyNaive   // 应用操作 func  
    }
  
  // +调用 applyFunc 为 applyNaive() 
    return options.applyFunc(ctx, root, tar.NewReader(r), options)
}

applyNaive 为原生 diff Apply 实现处理逻辑,将 OCI 规范 diff tar 数据流应用至一个目录上。 主要对 whiteout 文件进行删除处理 ,对其它文件进行 tar 解包并更新属性。

!FILENAME archive/tar.go:123

func applyNaive(ctx context.Context, root string, tr *tar.Reader, options ApplyOptions) (size int64, err error) {
    var (
        dirs []*tar.Header

        // Used for handling opaque directory markers which
        // may occur out of order
        unpackedPaths = make(map[string]struct{})

        convertWhiteout = options.ConvertWhiteout
    )

  // 当为nil时, 定义 Whiteout 转化 func 实现
    if convertWhiteout == nil {
    // convertWhiteout func , 通过删除目标文件作为 whiteouts 处理
        convertWhiteout = func(hdr *tar.Header, path string) (bool, error) {
            base := filepath.Base(path)
            dir := filepath.Dir(path)
      // 匹配 ".wh..wh..opq" ,处理隐式 whitout 目录
            if base == whiteoutOpaqueDir {
                _, err := os.Lstat(dir)
                if err != nil {
                    return false, err
                }
                err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
                    if err != nil {
                        if os.IsNotExist(err) {
                            err = nil // parent was deleted
                        }
                        return err
                    }
                    if path == dir {
                        return nil
                    }
                    if _, exists := unpackedPaths[path]; !exists {
                        err := os.RemoveAll(path)   // 删除操作
                        return err
                    }
                    return nil
                })
                return false, err
            }

      // 匹配前缀 ' .wh.* ' 
            if strings.HasPrefix(base, whiteoutPrefix) {
                originalBase := base[len(whiteoutPrefix):]
                originalPath := filepath.Join(dir, originalBase)

                return false, os.RemoveAll(originalPath)   // 删除操作
            }

            return true, nil
        }
    }

  // 迭代包内( tar.Reader )所有文件进行处理
    for {
        select {
        case <-ctx.Done():
            return 0, ctx.Err()
        default:
        }

        hdr, err := tr.Next()    //下一个文件 
      //...

    // Split 根目录的名称并解析符号链接。
        ppath, base := filepath.Split(hdr.Name)
        ppath, err = fs.RootPath(root, ppath)
        if err != nil {
            return 0, errors.Wrap(err, "failed to get root path")
        }

    // 在连接到父路径之前连接到 root,以确保在添加到父路径之前已经基于根解析了相对链接。
        path := filepath.Join(ppath, filepath.Join("/", base))
        if path == root {
            log.G(ctx).Debugf("file %q ignored: resolved to root", hdr.Name)
            continue
        }

    // 如果文件不在根目录下,请确保父目录存在或已创建。
        if ppath != root {
            parentPath := ppath
            if base == "" {
                parentPath = filepath.Dir(path)
            }
            if err := mkparent(ctx, parentPath, root, options.Parents); err != nil {
                return 0, err
            }
        }

    // 原生 whiteout 转化处理 func 实现是直接移除目标文件
        if err := validateWhiteout(path); err != nil {
            return 0, err
        }
        writeFile, err := convertWhiteout(hdr, path)
      
    //...
    // 内容读取 reader 
        srcData := io.Reader(tr)
        srcHdr := hdr

    // +创建 tar 文件
        if err := createTarFile(ctx, path, root, srcHdr, srcData); err != nil {
            return 0, err
        }

    // 必须在最后处理目录 mtime,以避免在其中进一步创建文件来修改目录 mtime
        if hdr.Typeflag == tar.TypeDir {
            dirs = append(dirs, hdr)
        }
        unpackedPaths[path] = struct{}{}
    }

    for _, hdr := range dirs {
        path, err := fs.RootPath(root, hdr.Name)
        if err != nil {
            return 0, err
        }
        if err := chtimes(path, boundTime(latestTime(hdr.AccessTime, hdr.ModTime)), boundTime(hdr.ModTime)); err != nil {
            return 0, err
        }
    }
    return size, nil
}

createTarFile 创建与写入 tar 的内容文件及目录, 此方法为正常 tar 解包文件处理逻辑

!FILENAME archive/tar.go:288

func createTarFile(ctx context.Context, path, extractDir string, hdr *tar.Header, reader io.Reader) error {
    hdrInfo := hdr.FileInfo()
 
  // 根据文件类型创建和写入内容
    switch hdr.Typeflag { 
    case tar.TypeDir:                   // Dir
        // Create directory unless it exists as a directory already.
        // In that case we just want to merge the two
        if fi, err := os.Lstat(path); !(err == nil && fi.IsDir()) {
            if err := mkdir(path, hdrInfo.Mode()); err != nil {
                return err
            }
        }

    case tar.TypeReg, tar.TypeRegA:    // Geg
        file, err := openFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, hdrInfo.Mode())
        if err != nil {
            return err
        }

        _, err = copyBuffered(ctx, file, reader)
        if err1 := file.Close(); err == nil {
            err = err1
        }
        if err != nil {
            return err
        }

    case tar.TypeBlock, tar.TypeChar:   // FIFO
        // Handle this is an OS-specific way
        if err := handleTarTypeBlockCharFifo(hdr, path); err != nil {
            return err
        }

    case tar.TypeFifo:                   // FIFO
        // Handle this is an OS-specific way
        if err := handleTarTypeBlockCharFifo(hdr, path); err != nil {
            return err
        }
            
    case tar.TypeLink:                  // Hard Link
        targetPath, err := hardlinkRootPath(extractDir, hdr.Linkname)
        if err != nil {
            return err
        }

        if err := os.Link(targetPath, path); err != nil {
            return err
        }

    case tar.TypeSymlink:               // Symlink 
        if err := os.Symlink(hdr.Linkname, path); err != nil {
            return err
        }

    case tar.TypeXGlobalHeader:
        log.G(ctx).Debug("PAX Global Extended Headers found and ignored")
        return nil

    default:
        return errors.Errorf("unhandled tar header type %d\n", hdr.Typeflag)
    }
  
  //...
  // xattr 属性设置 
    for key, value := range hdr.PAXRecords {
        if strings.HasPrefix(key, paxSchilyXattr) {
            key = key[len(paxSchilyXattr):]
            if err := setxattr(path, key, value); err != nil {
                if errors.Cause(err) == syscall.ENOTSUP {
                    log.G(ctx).WithError(err).Warnf("ignored xattr %s in archive", key)
                    continue
                }
                return err
            }
        }
    }

  // change owner 设置
    if err := handleLChmod(hdr, path, hdrInfo); err != nil {
        return err
    }

  // 创建时间
    return chtimes(path, boundTime(latestTime(hdr.AccessTime, hdr.ModTime)), boundTime(hdr.ModTime))
}

再来看一下 mount.WithTempMount() 的实现,将所有挂载目录列表挂载到临时目录,然后将此临时目录作为传参给回调func f处理,我们可以从上面的代码了解到回调 func 内的处理也就是调用 archive.Apply() 处理,此apply 上面已详细的分析了代码逻辑。此处我们关注如何 mount 所有挂载目录的过程逻辑。

!FILENAME mount/temp.go:33

func WithTempMount(ctx context.Context, mounts []Mount, f func(root string) error) (err error) {
  // 生成临时目录"/containerd-mount"
    root, uerr := ioutil.TempDir(tempMountLocation, "containerd-mount")
    if uerr != nil {
        return errors.Wrapf(uerr, "failed to create temp dir")
    }
  //...
  // 挂载所有到上面创建的临时目录
    if uerr = All(mounts, root); uerr != nil {
        return errors.Wrapf(uerr, "failed to mount %s", root)
    }
  // 回调func f ,传参为挂载后的目录
    return errors.Wrapf(f(root), "mount callback failed on %s", root)
}

All() 遍历挂载所有 mount 到指定的目标目录

!FILENAME mount/mount.go:33

func All(mounts []Mount, target string) error {
    for _, m := range mounts {       
        if err := m.Mount(target); err != nil {   // +遍历挂载
            return err
        }
    }
    return nil
}

mount.Mount() 为 linux OS 底层对 mount 的调用实现 ,调用 unix.Mount() 来完成挂载操作。

!FILENAME mount/mount_linux.go:38

// Mount to the provided target path
func (m *Mount) Mount(target string) error {
    var (
        chdir   string
        options = m.Options
    )

    // avoid hitting one page limit of mount argument buffer
    //
    // NOTE: 512 is a buffer during pagesize check.
    if m.Type == "overlay" && optionsSize(options) >= pagesize-512 {
        chdir, options = compactLowerdirOption(options)
    }

    flags, data := parseMountOptions(options)
    if len(data) > pagesize {
        return errors.Errorf("mount options is too long")
    }

    // propagation types.
    const ptypes = unix.MS_SHARED | unix.MS_PRIVATE | unix.MS_SLAVE | unix.MS_UNBINDABLE

    // Ensure propagation type change flags aren't included in other calls.
    oflags := flags &^ ptypes

    // In the case of remounting with changed data (data != ""), need to call mount (moby/moby#34077).
    if flags&unix.MS_REMOUNT == 0 || data != "" {
        // Initial call applying all non-propagation flags for mount
        // or remount with changed data
        if err := mountAt(chdir, m.Source, target, m.Type, uintptr(oflags), data); err != nil {
            return err
        }
    }

    if flags&ptypes != 0 {
        // Change the propagation type.
        const pflags = ptypes | unix.MS_REC | unix.MS_SILENT
        if err := unix.Mount("", target, "", uintptr(flags&pflags), ""); err != nil {
            return err
        }
    }

    const broflags = unix.MS_BIND | unix.MS_RDONLY
    if oflags&broflags == broflags {
        // Remount the bind to apply read only.
        return unix.Mount("", target, "", uintptr(oflags|unix.MS_REMOUNT), "")
    }
    return nil
}

前面已解析了 diff 服务( diff / apply )的底层实现代码逻辑,我们再来看看上层的对 diff 应用实例的分析,加深对 diff 的应用场景的理解。下面给出了对 contained cli 工具 ctr 命令 snapshot diff 的代码分析。

Snapshot diff 应用

ctr 工具的 snapshot diff 命令实现逻辑

!FILENAME cmd/ctr/commands/snapshots/snapshots.go:112

    Action: func(context *cli.Context) error {
        var (
            idA = context.Args().First()
            idB = context.Args().Get(1)
        )
        if idA == "" {
            return errors.New("snapshot id must be provided")
        }
        client, ctx, cancel, err := commands.NewClient(context)
        if err != nil {
            return err
        }
        defer cancel()

        ctx, done, err := client.WithLease(ctx)
        if err != nil {
            return err
        }
        defer done(ctx)

        var desc ocispec.Descriptor
        labels := commands.LabelArgs(context.StringSlice("label"))
        snapshotter := client.SnapshotService(context.GlobalString("snapshotter"))

        fmt.Println(context.String("media-type"))

        if context.Bool("keep") {
            labels["containerd.io/gc.root"] = time.Now().UTC().Format(time.RFC3339)
        }
        opts := []diff.Opt{
            diff.WithMediaType(context.String("media-type")),
            diff.WithReference(context.String("ref")),
            diff.WithLabels(labels),
        }

        if idB == "" {
      //  未指定参数 idB 则 CreateDiff 以“父”为比较对象求两者差异
            desc, err = rootfs.CreateDiff(ctx, idA, snapshotter, client.DiffService(), opts...)
            if err != nil {
                return err
            }
        } else {     // 指定参数 idB 则 Compare
      
      // 挂载快照 
            desc, err = withMounts(ctx, idA, snapshotter, func(a []mount.Mount) (ocispec.Descriptor, error) {
                return withMounts(ctx, idB, snapshotter, func(b []mount.Mount) (ocispec.Descriptor, error) {
          // DiffService Compare 调用进行比较 A/B 差异,返回 Descriptor 
                    return client.DiffService().Compare(ctx, a, b, opts...)
                })
            })
      
            if err != nil {
                return err
            } 
        }
    
    // 从内容存储库读取 desc 内容
        ra, err := client.ContentStore().ReaderAt(ctx, desc)
        if err != nil {
            return err
        }
    
    // 标准输出内容
        _, err = io.Copy(os.Stdout, content.NewReader(ra))

        return err
    },
}

rootfs.Creatediff() 从给定的快照的“父”与自身两者比较创建layer diff(镜像层差异)。

提供内容引用以跟踪内容创建的进度,提供的快照程序和挂载差异用于差异的计算,最后将返回layer diff(镜像层差异)的描述符。

!FILENAME rootfs/diff.go:32

func CreateDiff(ctx context.Context, snapshotID string, sn snapshots.Snapshotter, d diff.Comparer, opts ...diff.Opt) (ocispec.Descriptor, error) {
    info, err := sn.Stat(ctx, snapshotID)
    if err != nil {
        return ocispec.Descriptor{}, err
    }

    lowerKey := fmt.Sprintf("%s-parent-view", info.Parent)   // 指定的“父” key 引用
    lower, err := sn.View(ctx, lowerKey, info.Parent)        // snapshotter.View 
    if err != nil {
        return ocispec.Descriptor{}, err
    }
    defer sn.Remove(ctx, lowerKey)

    var upper []mount.Mount
    if info.Kind == snapshots.KindActive {
        upper, err = sn.Mounts(ctx, snapshotID)
        if err != nil {
            return ocispec.Descriptor{}, err
        }
    } else {
        upperKey := fmt.Sprintf("%s-view", snapshotID)   // 指定的 snapshot 引用
        upper, err = sn.View(ctx, upperKey, snapshotID)
        if err != nil {
            return ocispec.Descriptor{}, err
        }
        defer sn.Remove(ctx, upperKey)
    }

    return d.Compare(ctx, lower, upper, opts...)      // 比较两者差异并返回 layer diff Descriptor
}

~~ 本文 END ~~

你可能感兴趣的:(Containerd Diff 服务)