containerd storage模块分析

一、概述

containerd 的 storage 模块负责镜像的存储,容器 rootfs 的创建等工作。其主要包括三个子模块:

  • content: content 会在本地目录下保存镜像的内容。包括镜像的 manifest,config,以及镜像的层。每个层都是一个文件,格式是 tar+gzip,名称为层的 sha256sum 值。content 中存储的层都是不可变的。也就是使用的时候,并不会改变这里面的任何文件。
  • snapshot: snapshot 对容器运行时的层做了抽象。分为三种类型:Commited,Active,View。其中 Active 和 View 类似,不过前者可读写,后者只读。Active 和 View 类型的 snapshot 就是我们观察到的文件系统,一般是最上层。Commited 和另外两个相反,对用户不可见,作为 Active 或 View 的 parent 使用。
  • diff: diff 的主要功能有两个:Compare 和 Apply。Compare 负责计算 lower 和 upper 挂载的差异,然后使用 tar 打包差异生成新的镜像层。Apply 负责将镜像层挂载到文件系统上,生成容器运行时需要的 rootfs。

二、content 如何工作的

content 通过 GRPC 对外提供了以下接口:

// ContentServer is the server API for Content service.
type ContentServer interface {
    Info(context.Context, *InfoRequest) (*InfoResponse, error)
    Update(context.Context, *UpdateRequest) (*UpdateResponse, error)
    List(*ListContentRequest, Content_ListServer) error
    Delete(context.Context, *DeleteContentRequest) (*types.Empty, error)
    Read(*ReadContentRequest, Content_ReadServer) error
    Status(context.Context, *StatusRequest) (*StatusResponse, error)
    ListStatuses(context.Context, *ListStatusesRequest) (*ListStatusesResponse, error)
    Write(Content_WriteServer) error
    Abort(context.Context, *AbortRequest) (*types.Empty, error)
}

提供了 local 和 proxy 的实现,其中 proxy 是通过 GRPC 将具体实现解耦合,因此这里并不讨论,主要关注 local 的实现方式。local 的实现中,将以上接口再分为 4 个部分:

  • Manager: 提供了对 content 的查询,更新和删除操作
  • Provider:提供了对指定 content 内容的读取
  • IngestManager:提供了对 ingest 的状态查询和终止操作。
  • Ingester:提供了对 ingest 的写入操作。
// Store combines the methods of content-oriented interfaces into a set that
// are commonly provided by complete implementations.
type Store interface {
    Manager
    Provider
    IngestManager
    Ingester
}

// Manager provides methods for inspecting, listing and removing content.
type Manager interface {
    Info(ctx context.Context, dgst digest.Digest) (Info, error)
    Update(ctx context.Context, info Info, fieldpaths ...string) (Info, error)
    Walk(ctx context.Context, fn WalkFunc, filters ...string) error
    Delete(ctx context.Context, dgst digest.Digest) error
}

// Provider provides a reader interface for specific content
type Provider interface {
    ReaderAt(ctx context.Context, desc ocispec.Descriptor) (ReaderAt, error)
}

// IngestManager provides methods for managing ingests.
type IngestManager interface {
    Status(ctx context.Context, ref string) (Status, error)
    ListStatuses(ctx context.Context, filters ...string) ([]Status, error)
    Abort(ctx context.Context, ref string) error
}

// Ingester writes content
type Ingester interface {
    Writer(ctx context.Context, opts ...WriterOpt) (Writer, error)
}

为了防止理解上有歧义,这里对一些术语做一些详细的解释

  • content: content 中存储的最小单位可以是镜像的 manifest,config,或者是镜像的一层,通常还包含该层的大小,创建/修改时间,labels 等等

  • digest: 在 content 模块,digest 指的是镜像层的 sha256sum 值

  • ingest: 因为文件系统在写入文件时,是无法保证原子性的。所以一般的解决方案是是先写入中间文件,然后通过 rename 调用,把中间文件改成要写入的目标文件。ingest 就是这个中间文件集的统称,一个 ingest 对应一个 content。ingest 包含这几个文件:
    • data: content 的数据
    • ref: 根据 ref 找到 target location
    • startedat: 开始时间
    • updatedat: 更新时间
    • total: content 的总大小

content 的使用其实已经很底层了,所以这里不准备按照 content 提供的接口进行分析,而是通过 ctr image pull docker.io/library/nginx:latest 来说明 content 的工作原理。这条命令在 cmd/ctr/commands/images/pull 下。主要执行了以下几行代码:

client, ctx, cancel, err := commands.NewClient(context)
ctx, done, err := client.WithLease(ctx)
config, err := content.NewFetchConfig(ctx, context)
img, err := content.Fetch(ctx, client, ref, config)

commands.NewClient(context)会初始化 ctr 到 containerd 的连接参数。比如:

  • timeout: ctr 连接 containerd 的超时时间,默认 10s
  • defaultns: containerd 使用 namespace 进行租户隔离。默认值为 default
  • address: containerd 的地址,默认值是 /run/containerd/containerd.sock"
  • runtime: 默认是 io.containerd.runc.v2
  • platform: 指的是操作系统,CPU 架构 等
  • 还要一些 GRPC 连接数据等等

client.WithLease(ctx)会在 metadata 中记录该操作,当该操作结束后,也会从 metadata 中删除。

content.NewFetchConfig(ctx, context) 用来初始化这次拉取镜像的配置

  • 实例化 resolver,resolver 负责从远端 pull 到本地。containerd 使用的是 remotes/docker,应该是从 docker 那部分拿过来的代码。
  • 配置 platforms
  • 配置 max-concurrent-downloads,这个参数会限制同时下载的并发

content.Fetch(ctx, client, ref, config) 负责调用上一步实例化出的 client,根据 ref(镜像地址) 和 fetch config 来拉取远端镜像,然后使用 content 的接口存储到本地。

下面主要就 Fetch 展开分析。这里可以先了解一下,Fetch 会做以下的工作:

  • 设置 opts(RemoteOpt 数组),type RemoteOpt func(*Client, *RemoteContext) error
    • 应用到 image 上的 labels
    • 设置上面实例化的 resolver
    • 设置 BaseHandlers,BaseHandlers 在 dispatch 时调用
    • 设置 AllMetadata,AllMetadata 会下载所有的 manifest 和已知的配置文件
    • 设置 Platforms
  • 使用 resolver 来将我们提供的镜像名(ref) 解析成 name 和 descriptor
    • 如果镜像名的格式为: docker.io/library/nginx:latest,则会发送 Head请求到 https://registry-1.docker.io/v2/library/nginx/manifests/latest,从响应头中获取 docker-content-digest 的值。如果不存在,还会再发同样的请求,使用 GET 方法来获取 manifest,从 manifest 中获取 digest。最终会获取 digest,mediaType 和 size 三个值。
    • 如果镜像名的格式为:docker.io/library/nginx@sha256:df13abe416e37eb3db...,则@ 后面提供的是 digest 值。此时就使用 https://registry-1.docker.io/v2/library/nginx/manifests/sha256:df13abe416e37eb3db...来实现
  • 现在得到了 image digest,此时调用 images.Dispatch(ctx, handler, limiter, desc)方法。这个 Dispatch 方法是个递归调用
    1. 先通过 image digest,调用 dockerFetcher 和 content,将 image manifests 列表信息 store 到 content blobs 中。并找到符合当前 platform 的 descriptor。

    2. 再通过上一步获取的 digest,调用 dockerFetcher 和 content,获取该 platform 的 manifest,这次的内容中会包含 config 和 layers

    3. 根据 config 的 digest,调用 dockerFetcher 和 content,获取 config 的内容并存储
    4. 根据 layers 数组中每个 layer 的 digest,调用 dockerFetcher 和 content,获取 layer 内容并存储。
  • 调用 createNewImage 在 metadata 中创建 image 记录。记录中存储了 image 的 digest 和 lables。

第一步获取的 manifests 内容如下:

{
    "manifests": [
        {
            "digest": "sha256:eba373a0620f68ffdc3f217041ad25ef084475b8feb35b992574cd83698e9e3c",
            "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
            "platform": {
                "architecture": "amd64",
                "os": "linux"
            },
            "size": 1570
        }
    ],
    "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
    "schemaVersion": 2
}

第二步获取的 manifest 如下:

{
    "schemaVersion": 2,
    "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
    "config": {
        "mediaType": "application/vnd.docker.container.image.v1+json",
        "size": 7736,
        "digest": "sha256:f0b8a9a541369db503ff3b9d4fa6de561b300f7363920c2bff4577c6c24c5cf6"
    },
    "layers": [
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 27145915,
            "digest": "sha256:69692152171afee1fd341febc390747cfca2ff302f2881d8b394e786af605696"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 26576310,
            "digest": "sha256:49f7d34d62c18a321b727d5c05120130f72d1e6b8cd0f1cec9a4cca3eee0815c"
        }
    ]
}

第三步获取的 config 如下:

{
    "architecture": "amd64",
    "config": {
        "Hostname": "",
        "Domainname": "",
        "User": "",
        "AttachStdin": false,
        "AttachStdout": false,
        "AttachStderr": false,
        "ExposedPorts": {
            "80/tcp": {}
        },
        "Tty": false,
        "OpenStdin": false,
        "StdinOnce": false,
        "Env": [
            "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
            "NGINX_VERSION=1.19.10",
            "NJS_VERSION=0.5.3",
            "PKG_RELEASE=1~buster"
        ],
        "Cmd": [
            "nginx",
            "-g",
            "daemon off;"
        ],
        "Image": "sha256:f46ebb94fdef867c7f07f0b9c458ebe0ca97191f9fd6f91fd918ef71702cd755",
        "Volumes": null,
        "WorkingDir": "",
        "Entrypoint": [
            "/docker-entrypoint.sh"
        ],
        "OnBuild": null,
        "Labels": {
            "maintainer": "NGINX Docker Maintainers <docker-maint@nginx.com>"
        },
        "StopSignal": "SIGQUIT"
    },
    "container": "b728dbd6862a960807b78a68f3d1d6697d954ed2b53d05b1b4c440f4aa8574a3",
    "container_config": {
      ...
    },
    "created": "2021-05-12T08:40:31.711670345Z",
    "docker_version": "19.03.12",
    "history": [
        {
            "created": "2021-05-12T01:21:22.128649612Z",
            "created_by": "/bin/sh -c #(nop) ADD file:7362e0e50f30ff45463ea38bb265cb8f6b7cd422eb2d09de7384efa0b59614be in / "
        }
    ],
    "os": "linux",
    "rootfs": {
        "type": "layers",
        "diff_ids": [
            "sha256:02c055ef67f5904019f43a41ea5f099996d8e7633749b6e606c400526b2c4b33",
            "sha256:431f409d4c5a8f79640000705665407ff22d73e043472cb1521faa6d83afc5e8",
            "sha256:4b8db2d7f35aa38ac283036f2c7a453ebfdcc8d7e83a2bf3b55bf8847f8fafaf",
            "sha256:c9732df61184e9e8d08f96c6966190c59f507d8f57ea057a4610f145c59e9bc4",
            "sha256:eeb14ff930d4c2c04ece429112c16a536985f0cba6b13fdb52b00853107ab9c4",
            "sha256:f0f30197ccf95e395bbf4efd65ec94b9219516ae5cafe989df4cf220eb1d6dfa"
        ]
    }
}

第四步获取的就是每个 layer 的二进制数据了。

通过上面的分析可以知道,containerd 本身并没有实现镜像的 pull,但是通过暴露 storage 中的 content 和 matadata 中 image 接口,可以在调用方实现 image pull,并将数据按照 containerd 的要求进行存储,相当于 containerd 只提供了 image 存储的实现。总结一下流程如下:

containerd

三、snapshot 如何工作的

存储在 content 中的镜像层是不可变的,通常其存储格式也是没法直接使用的,常见的格式为 tar-gzip。为了使用 content 中存储的镜像层,containerd 抽象出了 snapshot,每个镜像层都会生成对应的 snapshot。

snapshot 有三种类型:committed,active 和 view。在启动容器前,镜像的每一层都会被创建成 committed snapshot,committed 表示该镜像层不可变。最后再创建出一层 active snapshot,这一层是可读写的。

下方展示了一个 nginx 镜像被 run 起来后生成的 snapshot。snapshot 之间是有 parent 关系的。第1层的 parent 为空。

# ctr snapshot ls
KEY                PARENT                       KIND
nginx              sha256:60f61ee7da08          Active
sha256:02c055ef                                 Committed
sha256:5c3e94c8    sha256:adda6567aeaa          Committed
sha256:60f61ee7    sha256:affa58c5a9d1          Committed
sha256:6b1533d4    sha256:5c3e94c8305f          Committed
sha256:adda6567    sha256:02c055ef67f5          Committed
sha256:affa58c5    sha256:6b1533d42f38          Committed

snapshots_of_nginx

下面针对执行 ctr run docker.io/library/nginx:latest nginx 来说明,不过 ctr run 还会涉及到很多 runtime 相关的内容,这里为了简单不做叙述。

当执行 ctr run 之后,会根据提供的 image 名,创建出多个 snapshot。主要的代码如下:

// 从 metadata 中查询 image 的信息
i, err := client.ImageService().Get(ctx, ref)
// 根据 image 信息初始化 image 实例
image = containerd.NewImage(client, i)
// 这个 image 是否 unpacked
unpacked, err := image.IsUnpacked(ctx, snapshotter)
if !unpacked {
  // unpack 镜像
  if err := image.Unpack(ctx, snapshotter); err != nil {
    return nil, err
  }
}

IsUnpacked 的实现如下:

func (i *image) IsUnpacked(ctx context.Context, snapshotterName string) (bool, error) {
    // 获取 snapshotter 实例,默认是 overlayfs
  sn, err := i.client.getSnapshotter(ctx, snapshotterName)
    if err != nil {
        return false, err
    }
  // 获取 content store 实例
    cs := i.client.ContentStore()
  // 这里是通过读取 image manifest,获取到 image layers 的 digest,也就是 diffs
    diffs, err := i.i.RootFS(ctx, cs, i.platform)
    if err != nil {
        return false, err
    }

  // 通过 diffs 计算出最上层的 chainID
    chainID := identity.ChainID(diffs)
  // 因为 snapshot 的名字就是 chainID,这里通过判断最上层的 snapshot 的 chainID 是否存在
  // 就可以知道这个 image 是否 unpack 了
    _, err = sn.Stat(ctx, chainID.String())
    if err == nil {
        return true, nil
    } else if !errdefs.IsNotFound(err) {
        return false, err
    }

    return false, nil
}

chainID 的计算方式参考之前的文章:chainID 计算方式

Unpack 的实现如下,为了展示方便,代码有删减:

func (i *image) Unpack(ctx context.Context, snapshotterName string, opts ...UnpackOpt) error {
    // 获取镜像的 manifest
  manifest, err := i.getManifest(ctx, i.platform)
  // 通过 manifest,获取 layers
  layers, err := i.getLayers(ctx, i.platform, manifest)

  // 默认是 overlayfs
  snapshotterName, err = i.client.resolveSnapshotterName(ctx, snapshotterName)
  // 获取 snapshotter 实例
  sn, err := i.client.getSnapshotter(ctx, snapshotterName)

  for _, layer := range layers {
    // apply layer,这里是 snapshot 的工作重点
    unpacked, err = rootfs.ApplyLayerWithOpts(ctx, layer, chain, sn, a, config.SnapshotOpts, config.ApplyOpts)
        // chainID 的计算需要之前的 diffID,所以这里报错了每一层的 digest。
    chain = append(chain, layer.Diff.Digest)
  }
  // 最上层的 snapshot 就是 rootfs,可以提供给 runc 使用。
  rootfs := identity.ChainID(chain).String()
  return err
}

Unpack 的过程,就是对每一层 apply layer 的过程。apply 一个 layer 的实现如下:

func applyLayers(ctx context.Context, layers []Layer, chain []digest.Digest, sn snapshots.Snapshotter, a diff.Applier, opts []snapshots.Opt, applyOpts []diff.ApplyOpt) error {
    for {
        key = fmt.Sprintf(snapshots.UnpackKeyFormat, uniquePart(), chainID)
        // prepare 会创建出一个 active snapshot
        mounts, err = sn.Prepare(ctx, key, parent.String(), opts...)
        break
    }
    // 使用 diff,将这一层应用到 prepare 的 layer 上
    diff, err = a.Apply(ctx, layer.Blob, mounts, applyOpts...)
    // Commit 会在 metadata 中将这个 snapshot 标记为 committed。
  // 对于 device mapper 设备,还会额外的使这个 snapshot 挂载不可见。
    if err = sn.Commit(ctx, chainID.String(), key, opts...); err != nil {
        err = errors.Wrapf(err, "failed to commit snapshot %s", key)
        return err
    }

    return nil
}

以上就是 snapshotter 通过 image layers 创建出 snapshots 的过程。不过这上面创建的都是 committed snapshot。所以在这之后还会单独在这之上创建出一个 active snapshot 供容器读写。

// WithNewSnapshot allocates a new snapshot to be used by the container as the
// root filesystem in read-write mode
func WithNewSnapshot(id string, i Image, opts ...snapshots.Opt) NewContainerOpts {
    return func(ctx context.Context, client *Client, c *containers.Container) error {
        diffIDs, err := i.RootFS(ctx)
        if err != nil {
            return err
        }

        parent := identity.ChainID(diffIDs).String()
        c.Snapshotter, err = client.resolveSnapshotterName(ctx, c.Snapshotter)
        if err != nil {
            return err
        }
        s, err := client.getSnapshotter(ctx, c.Snapshotter)
        if err != nil {
            return err
        }
        if _, err := s.Prepare(ctx, id, parent, opts...); err != nil {
            return err
        }
        c.SnapshotKey = id
        c.Image = i.Name()
        return nil
    }
}

四、diff 如何工作的

在上面对 content 和 snapshot 进行一些分析后,已经清楚了镜像的层是如何存储的,以及使用镜像是什么样的一个过程。但这其中还有两个细节没有说明:

  • 一个 image layer 如何被 mount 成一个 snapshot。
  • 一个 snapshot 如何被压缩成一个 image layer

这里就是 diff 子模块的作用了。diff 对外提供了两个接口:

  • Diff: 负责将 snapshot 打包成 image layer

  • Apply: 负责将 image layer 生成 snapshot 挂载

在说明 Diff 之前,需要先提一下 OCI 中 image spec 中的一个例子。假设现在有两个文件夹 rootfs-c9d-v1/ and rootfs-c9d-v1.s1/。对其进行字典序的递归比较,发现的变动如下:

Added:      /etc/my-app.d/
Added:      /etc/my-app.d/default.cfg
Modified:   /bin/my-app-tools
Deleted:    /etc/my-app-config

那么使用 OCI 的规范打包出来就是:

./etc/my-app.d/
./etc/my-app.d/default.cfg
./bin/my-app-tools
./etc/.wh.my-app-config

删除的文件使用 .wh. 前缀来表示。

那么 Diff 的时候,主要就是对 snapshot 和其 parent 做比较,比较时使用字典序来 walk dir。然后生成的 tar 包中,对 added 和 modified 文件,只需打包最新的即可。对 deleted 文件,生成 .wh.* 来代替。

在 Apply 的时候,如果是 .wh. 前缀的文件,就根据所使用文件系统的特点来生成,比如 overlayfs 中使用 whiteout 文件来表示删除。非 .wh. 文件原样输出即可。

containerd的启动流程

整体架构图如下:

  • 使用 github.com/urfave/cli启动,有 command
    • configCommand: 和 containerd 配置相关
    • publishCommand:event 相关
    • ociHook: 提供了 preStart, preStop 等 container hook
  • 未执行子 command,则执行默认的 action
    • 加载配置文件
    • 创建顶层文件夹:
    • root = “/var/lib/containerd”
    • state = “/run/containerd”
    • 创建 /var/lib/containerd/tmpmounts
    • 清理 tmpmounts 下的临时挂载点
  • 创建和初始化 containerd server
    • 将配置设置的 server 进程上
    • 如果设置了 OOMScore,则应用到进程上。OOMScore 越低,系统内存不足时越不会被 kill
    • 如果设置了 containerd 的 Cgroup path。则会将自己的进程加入到 cgroup 下。这里还会判断使用 cgroup v1 还是 v2。
    • 设置一系列超时参数
    • 加载 plugin,containerd 通过 plugin 来划分模块。
    • 通过设置的 plugin 目录(默认在 /var/lib/containerd/plugins)来加载。go1.8 之后就不支持了。
    • 注册 content plugin:containred 架构中 storage 部分的 content。负责镜像的存储
    • 注册 metadata plugin:使用的是 bolt 这个嵌入式的 key/value 数据库。依赖 content 和 snapshot plugin。
    • 注册 proxy plugin: proxy plugin 支持 content, snapshot 两种类型。相当于起了一个 GRPC 服务,替换掉内置的 content, snapshot plugin。
    • 还有很多 plugin 是在包的 init 方法中注册的
      • 大量 snapshot 的插件: aufs, btrfs, devmapper, native, overlayfs, zfs
      • diff 插件: walking
      • GC 插件
      • 大量 service 插件:introspection,containers,content,diff,images,leases,namespaces,snapshots,tasks
      • runtime 插件:linux,task
      • monitoring 插件:cgroups
      • internal 插件:restart,opt
      • GRPC 插件:containers,content,diff,events,healthcheck,images,leases,namespaces,snapshots,tasks,version,introspection
      • CRI 插件:实现 CRI,提供给 kubelet 调用
    • diff 模块注册 stream processor,支持两种
    • application/vnd.oci.image.layer.v1.tar+encrypted
    • application/vnd.oci.image.layer.v1.tar+gzip+encrypted
    • 启动 TTRPC server:/run/containerd/containerd.sock.ttrpc
    • 启动 GRPC server:/run/containerd/containerd.sock
    • 如果开启了 TCP,还会将 GRPC server 监听到 tcp 上。

此时,containerd 已经可以对外提供服务了。下面对上述提到的一些概念或模块做个简单的解释:

  • service: GRPC 服务依赖于对应的 service。service 是则用来封装内部的实现。
  • TTRPC: TTRPC 是为低内存环境做的优化,通过淘汰net/http, net/http2grpc 包,实现了更轻量的 framing protocol,实现了更小的二进制文件以及更少的常驻内存使用。
  • storage 模块:包含 content, snapshot 和 diff 三个子模块。
    • content: content 是负责整个镜像的存储流程的。
    • snapshot:为了保证镜像层的数据不可变,所以在运行容器时会从 layer 中创建 snapshot。
    • diff:diff 模块实现了两个功能: diff 和 apply。diff 用来比较上层和下层之间的差异,然后按照 OCI 规范对该层打包。apply 用来根据底层的联合文件系统,对某一层进行挂载。
  • metadata 模块: 包含 images 和 containers 两个子模板。
    • images: 存储镜像相关的元数据。
    • containers: 存储容器相关的元数据。
  • tasks 模块:一个 container 的运行被抽象成一个 task
  • event 模块:提供了事件的发布订阅功能。第三方可以通过 event 获取 containerd 中的事件,比如镜像的创建,更新,容器的创建,删除等等。

容器镜像是如何工作的

一、概述

容器技术近些年的发展非常迅速,其本身使用的技术并非多么高深,但是带来的生产力提升是业界公认的。容器技术的主要特点就是其资源限制和环境隔离,linux 容器主要使用 cgroup 和 namespace 技术,但如果要更深入的理解容器技术,还需要了解一下容器镜像是什么。

二、初探

docker 可以说是容器技术的代表了,下面会以 docker 为例。docker 支持 pull, build, push 镜像。

Pull 操作如下:

➜  docker pull ubuntu:20.04
20.04: Pulling from library/ubuntu
345e3491a907: Pull complete   # 镜像共有三层
57671312ef6f: Pull complete
5e9250ddb7d0: Pull complete
Digest: sha256:cf31af331f38d1d7158470e095b132acd126a7180a54f263d386da88eb681d93 # 镜像的摘要
Status: Downloaded newer image for ubuntu:20.04
docker.io/library/ubuntu:20.04 # 镜像地址

Build 镜像时,需要提供 Dockerfile,Dockerfile 如下:

FROM ubuntu:20.04
RUN touch hello
CMD ['sh', '-c', 'echo hello ubuntu']

然后开始构建我们自己的镜像

➜  docker build -t joyme/ubuntu-hello:0.1 .
Sending build context to Docker daemon  2.048kB
Step 1/3 : FROM ubuntu:20.04
 ---> 7e0aa2d69a15
Step 2/3 : RUN touch hello
 ---> Running in 5e2fcef8c28b
Removing intermediate container 5e2fcef8c28b
 ---> 4bacc0d61995
Step 3/3 : CMD ['sh', '-c', 'echo hello ubuntu']
 ---> Running in 802e795e28c4
Removing intermediate container 802e795e28c4
 ---> ff588b7779d8
Successfully built ff588b7779d8
Successfully tagged joyme/ubuntu-hello:0.1

push 我们自己的镜像:

➜  docker push joyme/ubuntu-hello:0.1
The push refers to repository [docker.io/joyme/ubuntu-hello]
59c67359ad17: Pushed
2f140462f3bc: Mounted from library/ubuntu # 下面三层都是来自于 ubuntu:20.04
63c99163f472: Mounted from library/ubuntu
ccdbb80308cc: Mounted from library/ubuntu
0.1: digest: sha256:0a1a5857cade488bfc60c7f5d2be2c7c5eee7f90edc1950c4c32214fada31a7d size: 1149

我们也可以把镜像保存成 tar 包,然后加载。比如:

➜  docker save -o ubuntu-hello.tar joyme/ubuntu-hello:0.1
➜  ls
Dockerfile  ubuntu-hello.tar
➜  tar xvf ubuntu-hello.tar
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/VERSION
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/json
1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/layer.tar
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/VERSION
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/json
15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/layer.tar
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/VERSION
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/json
2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/layer.tar
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/VERSION
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/json
6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/layer.tar
ff588b7779d8b10861c566538b885c379d633d059fc067c5ec6e1ab026427075.json
manifest.json
repositories

可以发现,镜像是分层的,比如上面的 ubuntu:20.04 镜像总共有三层,我们基于 ubuntu:20.04 制作的 ubuntu-hello:0.1 镜像是 4 层,层信息记录在 manifest.json 文件中。

➜  cat manifest.json | jq
[
  {
    "Config": "ff588b7779d8b10861c566538b885c379d633d059fc067c5ec6e1ab026427075.json",
    "RepoTags": [
      "joyme/ubuntu-hello:0.1"
    ],
    "Layers": [
      "15cbe1c29902a1020a4a47c835a82f0416f1896f02fac942fdd35d326c63fa22/layer.tar",
      "6d56becb66b184f78b25f61dc91f68fcfce4baeecb3a8dcb21ada2306091aab7/layer.tar",
      "1392a7609ae8d845eba5fbe95e266a6b104d55b30262a284c960583f91307420/layer.tar",
      "2a8749a3d9080a156a4381fea67ce47a478fb31d6acaa20e19fda1ba0c8f20e7/layer.tar"
    ]
  }
]

2a8749a3 是我们刚刚创建的最后一层,也就是 touch hello 生成的新文件。而最后一行 CMD 由于并不会改变文件信息,因此不会单独的存储成一层,而是记录在和 touch hello 同一层中的 json 配置文件中。

    "Cmd": [
      "/bin/sh",
      "-c",
      "#(nop) ",
      "CMD [\"/bin/sh\" \"-c\" \"['sh', '-c', 'echo hello ubuntu']\"]"
    ],

三、镜像的本地存储

当使用 docker pull 镜像到本地后,镜像的存储位置一般都在 /var/lib/docker/image/<storage_driver> 下。因为要考虑到镜像层的复用等等场景, pull 下来的镜像肯定不会是一个压缩包。镜像存储时基本要满足以下几个场景:

  • 存储当期机器上所有的镜像索引。
  • 存储镜像到层的映射,这样使用镜像时可以找到层的信息。
  • 按照层来存储,这样在 pull 镜像时,如果有重复的层,可以避免多次拉取。
  • 需要知道一个层被哪些镜像引用。这样在删除镜像时,可以确定这个镜像的某个层能不能被删除。

镜像存储的目录分布如下:

├── distribution
│   ├── diffid-by-digest
│   └── v2metadata-by-diffid
├── imagedb
│   ├── content
│   └── metadata
├── layerdb
│   ├── mounts
│   ├── sha256
│   └── tmp
└── repositories.json
  • repositories.json: 存了的镜像的 repo 信息以及镜像信息。
  • distribution 下存储了和镜像分发相关的信息。
    • diffid-by-digest:存储了 digest 到 diffid 的映射,digest 用来在拉取层的时候,对比远端的层本地是否有
    • v2metadata-by-diffid 存储了 diffid 到层的元数据信息。元数据中记录了层的 digest,repo 等
  • imagedb 下存储了和镜像相关的信息。
    • content 下存储的是镜像的配置信息。也是符合 OCI 的规范的。可以参考:https://github.com/opencontainers/image-spec/blob/master/config.md
    • metadata 存储了镜像之间的 parent 信息。
  • layerdb 下存储了和镜像层相关的信息
    • mounts: 存储了层的的挂载信息,包括 init-id, mount-id, parent
    • sha256: 存储了层的信息,根据层的 sha256 划分目录
    • tmp:

现在可以知道,镜像的 repo, name, tag, id 等信息,可以通过 repositories.json 知道,使用的镜像的时候,提供 image+tag 找到 id,或者直接提供 id 也可以。比如上面的 ubuntu-hello 的 id 为 ff588b7779d8...,然后通过这个 id,在 /var/lib/docker/image/overlay2/imagedb/content/sha256下就可以找到名为这个 id 的文件了,里面存储了镜像的具体信息。这个 id 就是这个文件的 sha256sum 后的值。比如 ubuntu-hello的 content 就存储了以下信息(有一定的精简):

{
  "architecture": "amd64",
  "config": {
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
    ],
    "Cmd": [
      "/bin/sh",
      "-c",
      "['sh', '-c', 'echo hello ubuntu']"
    ],
    "Image": "sha256:4bacc0d6199516a20039bc06ddaa7247a7553a39ae700ade279bd6ce78cd0a61"
  },
  "container": "802e795e28c4afced8fb6e6e703f0efeeaa6fa07a36c4400e242c4cd65e4bff5",
  "history": [
    {
      "created": "2021-05-15T10:32:23.665502338Z",
      "created_by": "/bin/sh -c touch hello"
    },
    {
      "created": "2021-05-15T10:32:23.925742465Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\" \"-c\" \"['sh', '-c', 'echo hello ubuntu']\"]",
      "empty_layer": true
    }
  ],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439",
      "sha256:63c99163f47292f80f9d24c5b475751dbad6dc795596e935c5c7f1c73dc08107",
      "sha256:2f140462f3bcf8cf3752461e27dfd4b3531f266fa10cda716166bd3a78a19103",
      "sha256:59c67359ad1702b424dcf3deefdf137e92ef13c13bec5b878b08fe66683a78f7"
    ]
  }
}

我们现在重点关注 rootfs 字段下的 diff_ids,这里存储了该镜像的每一层的 diff_id。接下来就可以通过这些 id 找到这些层的信息了。

层的信息存储在 /var/lib/docker/image/overlay2/layerdb/sha256 下,目录名是这一层的 chain_id。

chain_id 的计算中,用到了所有祖先 layer 的信息,从而能保证根据 chain_id 得到的 rootfs 是唯一的。比如我在debian和ubuntu的image基础上都添加了一个同样的文件,那么commit之后新增加的这两个layer具有相同的内容,相同的diff_id,但由于他们的父layer不一样,所以他们的chain_id会不一样,从而根据chainid能找到唯一的rootfs。

chain_id 的计算方式如下:

ChainID(L₀) =  DiffID(L₀)
ChainID(L₀|...|Lₙ₋₁|Lₙ) = Digest(ChainID(L₀|...|Lₙ₋₁) + " " + DiffID(Lₙ))

比如:第一层的 diff_id 是 ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439,第一层的 chain_id 是 ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439,第二层的 diff_id 是 63c99163f47292f80f9d24c5b475751dbad6dc795596e935c5c7f1c73dc08107,那么第二层的 chain_id 可以用下面的方法计算:

echo -n “sha256:ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439 sha256:63c99163f47292f80f9d24c5b475751dbad6dc795596e935c5c7f1c73dc08107” | sha256sum

也就是:8d8dceacec7085abcab1f93ac1128765bc6cf0caac334c821e01546bd96eb741

8d8dceacec7085abcab1f93ac1128765bc6cf0caac334c821e01546bd96eb741目录下的文件如下:

cache-id  diff  parent  size  tar-split.json.gz
  • cache-id 是该层存储目录的名字。比如该 cache-id 的内容为 52cb2ec8a0ac5a2418c568896fc079cc29a0da7d7b6b0c4b740d13241581d6e8,对应的位置是 /var/lib/docker/overlay2/52cb2ec8a0ac5a2418c568896fc079cc29a0da7d7b6b0c4b740d13241581d6e8,这里才是这一层文件的实际位置。cache-id 是随机生成的。
  • diff: 存储的是 diff_id,这里就是 ccdbb80308cc5ef43b605ac28fac29c6a597f89f5a169bbedbb8dec29c987439。diff_id 是 layer.tar 的 sha256sum 的值。
  • parent: 记录了上一层的 chain_id。
  • size: 这一层的大小。
  • tar-split.json.gz:vbatts/tar-split 这个库生成的文件,主要为了 image pull 之后,重新推送可能出现的 layer checksum 不一致的问题。具体可见:
    • https://github.com/moby/moby/pull/14067
    • https://github.com/moby/moby/issues/14018
    • https://github.com/distribution/distribution/issues/634

四、镜像的使用

根据上面的探索可以知道,提供 image+tag 或者 image_id,就可以找到关于这个镜像的所有信息。那么镜像提供的这些文件是如何被使用的呢?最简单的做法就是,将所有层的信息合并成 rootfs,提供给 runc 使用就可以了。

那么如何把这些层的信息高效的组合成 rootfs ?答案就是联合文件系统(UnionFS)。联合文件系统是一种分层、轻量级并且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。

这里我们还是拿 docker 来说,docker 目前支持的联合文件系统有:overlay2,aufs,fuse-overlayfs,devicemapper,btrfs,zfs,vfs。目前推荐的是 overlay2 ,下面会使用 overlay2 来讲解容器是如何使用镜像的。

overlayfs

如上图所示,overlay2 主要有三个目录

  • lowerdir 对应了 image layers,图上只花了一层,但实际上是支持多层的,lowerdir 是只读的,也就是说 image layers 中所有的文件都不会被修改。
  • upperdir 对应了 container layer,这个是我们运行容器时创建出来的。upperdir 是支持读写的
  • merged 对应了 container mount,这一层是容器内的文件系统视图。也就是说,会把 image layer 和 container layer 在逻辑上合并起来。

overlay2 其实还有一个目录 workdir,workdir 是为了在某些场景下,为了保证操作的原子性而设计的。具体的可以参考:深入理解overlayfs(二):使用与原理分析

这里我们举几个例子帮助理解。我们通过 image 运行容器后,此时 image layer 如上图所示,但 container layer 是空的。

  • 在容器内删除 file2,只会在 container layer 中创建一个名为 file2 的 whiteout 文件,image layer 的 file2 不会被真正的删除。容器内 file2 不可见。
  • 在容器内重新创建 file2,只会在 container layer 创建 file2。
  • 在容器内修改 file1,会触发 copy-up,将 image layer 的 file1 拷贝到 container layer 中并修改。

可见,在 container layer 的文件操作比普通的文件系统操作消耗更大,特别是修改 image layer 的大文件会触发复制。

我们可以使用 docker inspect 来看一下:

"GraphDriver": {
  "Data": {
    "LowerDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270-init/diff:/data00/docker/overlay2/a80f1234038ffa4720876215a77c16fd42d67fd0f110d9bd984dca73ea03b49b/diff:/data00/docker/overlay2/2aac2c4faa0be785d9d0faa5127f635bbc4dc65eb635bc442da4e03b46f05814/diff:/data00/docker/overlay2/afd88f520ed7fde849d8b7b136261348cd165bed4a6796382188529e08426685/diff:/data00/docker/overlay2/081774340b851380f09ad2d0286d8cfad388bf6738673ee089ed06efbed295c0/diff:/data00/docker/overlay2/651cf1972f63c01679beac3705e1d984d7dcae01ed4e6ae6c76bd7326b97d1c0/diff:/data00/docker/overlay2/2fc2f5bf53a5aaaec137666c6988573b3c3887a63eb5594ccbcd07995d0d3e5f/diff:/data00/docker/overlay2/0d323f1368ced5d62a56e7dd447db7cc4032b598a23473d31d5e812d2fed2376/diff:/data00/docker/overlay2/d07016559096eb4a59d4fe1e97ed7b26a492156f764542ff15ccbf53624ff7d2/diff:/data00/docker/overlay2/db070da7fe3eb36cd5a70fbcd5d72fed192d2d25d27cd5d9f7cfa26fb9de7bd0/diff:/data00/docker/overlay2/cabe60077f68da04a6d460cac3ef339bde6919fc267d808fd52950bbc5f4891d/diff:/data00/docker/overlay2/f1368e926209187e11312e4b805c9b1d912ac64776eada90e5115b245f5ab54a/diff",
    "MergedDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270/merged",
    "UpperDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270/diff",
    "WorkDir": "/data00/docker/overlay2/379f2fd25bc3e317b5f767ea1bf061174013c0aa80a9454c6f01d47722dbd270/work"
  },
  "Name": "overlay2"
}

了解了上述的知识后,我们就可以对 docker commit 的机制做一些猜想了:应该就是将 upperdir 作为新的 lowerdir 进行挂载。

参考:

深入理解overlayfs(二):使用与原理分析

Open Container Initiative Distribution Specification