Skip to main content
  1. Docs/

containerd

·11 mins· ·
Owl Dawn
Author
Owl Dawn
Table of Contents

Containerd
#

Containerd 是一款容器运行时,其功能是高度模块化的,启动后,主进程会在 grpc server 下注册一系列的 grpc service,如 CRIService、TasksService 等

Containerd 的具体功能基本都是通过调用这些 gRPC service 完成的,启动后会启动一个 gRPC server,默认监听位置为 /run/containerd/containerd.sock

CRI Service
#

kubelet 通过 CRI 调用 containerd 来创建 sandbox(pod)、container 等。这里的 CRI 指 containerd 的 CRIService 暴露出来的 gRPC 接口

Runtime
#

containerd 不负责创建或删除容器等操作,这些操作交给下层的 runtime 来做

创建容器的顺序为:

  1. containerd daemo 接收来自 client 的创建 container 的请求(可能是 kubelet 通过 cri 发起的,或者 ctr、nerdctl 这些 client 发起的)
  2. containerd daemon 准备容器所需的 rootfs 和 container 配置信息(OCI runtime config)
  3. containerd daemon 将 rootfs、配置信息发送给 runtime
  4. runtime 接收来自 containerd daemon 的创建容器的请求。runtime 执行系统调用,创建容器进程,并启动一个守护进程(containerd shim),暴露并监听一个 unix socket,同时将这个 socket 信息写到 stdout
  5. containerd daemon 从 stdout 中读到这个 unix socket 信息,并在后续通过这个 socket 与 runtime 交流,以获取 container 中的状态信息等

在具体实现中 containerd 的 runtime 分为两个部分

  • runtime shim
    • shim 是与 containerd daemon 直接沟通的结构。是一个二进制文件,在 v1.7 版本中为 containerd-shim-runc-v2,containerd daemon 会直接调用这个二进制文件,创建一个 containerd shim 进程
  • runtime engine
    • 即我们常说的狭义上的容器运行时,如 runc。以二进制形式存在,runtime engine 是真正负责调用操作系统接口以创建、启动和删除容器的模块。它只会和 runtime shim 通信

未命名文件 (1)

创建 pod 流程
#

RunPodSandbox
#

  1. kubelet 通过内置的 CRI client 调用 containerd 的 cri service 的 RunPodSandbox(gRPC call)
  2. cri service 根据 gRPC Request(RunPodSandboxRequest)中描述创建 SandboxInfo,存储到本地的 metadata 数据库中(blot 数据库,一个本地键值对存储)
  3. cri service 创建 network namespace,并调用 CNI 插件初始化 container network
  4. cri service 启动 sandbox,主要把 pause container 跑起来。这里启动 pause container 的步骤与后续启动业务 container 的步骤基本相同

CreateContainer
#

准备 container 的运行环境,并不会在系统中真正创建 container

  1. kubelet 通过其内置的 CRI Client 调用 containerd 的 cri service 的 CreateContainer(gRPC call)
  2. cri service 从 gRPC Request (CreateContainerRequest)的描述中,得到 sandbox id,并据此从 metadata 数据库中找到之前创建的 sandbox 对象
  3. cri service 根据 CreateContainerRequest 中的描述,创建并配置 Container 对象。其中重要的配置步骤包括
    1. 根据 snapshotter 的配置,对镜像 unpack 以构建 container 的 rootfs
    2. 生成 oci runtime spec
  4. cri service 将 Container 对象写入到 metadata 数据库中

StartContainer
#

  1. kubelet 通过其内置的 CRI client 调用 containerd 的 cri service 的 startContainer
  2. cri service 从 gRPC Request(StartContainerRequest)的描述,得到 sandbox id,并据此从 metadata 数据库中找到之前创建的 sandbox 对象
  3. cri service 从 metadata 数据库中找到之前创建的 container 对象
  4. cri service 创建 Task 对象。一个运行态的 container 在 containerd 的内存中是使用 Task 对象来描述的。所以。创建实际运行的 container 的过程,其实就是创建并启动 Task 的过程。具体过程:
    1. cri service 会通过 gRPC call 调用 TaskService(也是一个监听 /run/containerd/containerd.sock 的 gRPC service)尝试创建 Task 对象
    2. TaskService 调用 PlatformRuntime(PlatformRuntime 是个接口,具体的实现是 TaskManager)创建 runtime.Task
    3. TaskManager 尝试启动一个 containerd shim 进程
      1. 如果当前要创建的 container(Task)属于某个已创建的 Sandbox(比如,创建业务 container 时,本质上是向之前已经创建了 pause container 的 sandbox 中添加 container,此时,已经存在当时创建 pause container 时创建的 shim 进程),那么不创建新的 shim 进程(即,同一个 Pod sandbox 下的 container 会复用同一个 shim 进程)
      2. 否则,执行 containerd-shim-runc-v2 这个二进制,启动一个 shim 进程,该 shim 进程会启动一个 ttrpc service,并把其所监听的 unix socket 地址返回给 TaskManager
    4. TaskManager 通过 ttrpc 接口调用 shim 进程
    5. shim 进程调用 runc(或者其他的 OCI runtime engine)以创建容器进程

Snapshot
#

OCI 镜像格式分为多层(layer1、layer2、layer3…), 在创建 $snapshot_2$ 时,需要首先将 $snapshot_1$ 整个 copy 到一个目录中,然后再 apply $layer_2$ 中的 diff。这会导致:

  1. 不必要的文件拷贝带来的耗时
  2. 文件反复拷贝带来的存储冗余。$snapshot_1$ 中的内容要被存储 n 次…… 为了避免上述问题,我们可以使用 AUFS、overlayfs 这样的联合文件系统,也可以使用 btrfs、ZFS 等支持快照的文件系统——它们都可以保证在不对上一级 snapshot 进行全量复制的前提下,提供一个可以包含上一级 snapshot 中的文件内容的视图。

由此可见,将 “根据 layer 创建 snapshot” 这一行为抽象出来是有必要的,使用者可以根据不同的场景选择合适的存储驱动来创建和管理 snapshot,进而决定如何构建 rootfs。于是,containerd 把 “根据 layer 创建 snapshot”这一行为抽象出来了 Snapshotter 接口。用户可以根据需要,实现这一接口,然后将其包装成一个 gRPC 服务,然后注册到 containerd 中即可。用户所包装的这样一个服务就称为一个 “Snapshotter”。

Snapshotter
#

Snapshotter 接口定义:

type Snapshotter interface {
	// Stat returns the info for an active or committed snapshot by name or
	// key.
	//
	// Should be used for parent resolution, existence checks and to discern
	// the kind of snapshot.
	Stat(ctx context.Context, key string) (Info, error)

	// Update updates the info for a snapshot.
	//
	// Only mutable properties of a snapshot may be updated.
	Update(ctx context.Context, info Info, fieldpaths ...string) (Info, error)

	// Usage returns the resource usage of an active or committed snapshot
	// excluding the usage of parent snapshots.
	//
	// The running time of this call for active snapshots is dependent on
	// implementation, but may be proportional to the size of the resource.
	// Callers should take this into consideration. Implementations should
	// attempt to honor context cancellation and avoid taking locks when making
	// the calculation.
	Usage(ctx context.Context, key string) (Usage, error)

	// Mounts returns the mounts for the active snapshot transaction identified
	// by key. Can be called on a read-write or readonly transaction. This is
	// available only for active snapshots.
	//
	// This can be used to recover mounts after calling View or Prepare.
	Mounts(ctx context.Context, key string) ([]mount.Mount, error)

	// Prepare creates an active snapshot identified by key descending from the
	// provided parent.  The returned mounts can be used to mount the snapshot
	// to capture changes.
	//
	// If a parent is provided, after performing the mounts, the destination
	// will start with the content of the parent. The parent must be a
	// committed snapshot. Changes to the mounted destination will be captured
	// in relation to the parent. The default parent, "", is an empty
	// directory.
	//
	// The changes may be saved to a committed snapshot by calling Commit. When
	// one is done with the transaction, Remove should be called on the key.
	//
	// Multiple calls to Prepare or View with the same key should fail.
	Prepare(ctx context.Context, key, parent string, opts ...Opt) ([]mount.Mount, error)

	// View behaves identically to Prepare except the result may not be
	// committed back to the snapshot snapshotter. View returns a readonly view on
	// the parent, with the active snapshot being tracked by the given key.
	//
	// This method operates identically to Prepare, except the mounts returned
	// may have the readonly flag set. Any modifications to the underlying
	// filesystem will be ignored. Implementations may perform this in a more
	// efficient manner that differs from what would be attempted with
	// `Prepare`.
	//
	// Commit may not be called on the provided key and will return an error.
	// To collect the resources associated with key, Remove must be called with
	// key as the argument.
	View(ctx context.Context, key, parent string, opts ...Opt) ([]mount.Mount, error)

	// Commit captures the changes between key and its parent into a snapshot
	// identified by name.  The name can then be used with the snapshotter's other
	// methods to create subsequent snapshots.
	//
	// A committed snapshot will be created under name with the parent of the
	// active snapshot.
	//
	// After commit, the snapshot identified by key is removed.
	Commit(ctx context.Context, name, key string, opts ...Opt) error

	// Remove the committed or active snapshot by the provided key.
	//
	// All resources associated with the key will be removed.
	//
	// If the snapshot is a parent of another snapshot, its children must be
	// removed before proceeding.
	Remove(ctx context.Context, key string) error

	// Walk will call the provided function for each snapshot in the
	// snapshotter which match the provided filters. If no filters are
	// given all items will be walked.
	// Filters:
	//  name
	//  parent
	//  kind (active,view,committed)
	//  labels.(label)
	Walk(ctx context.Context, fn WalkFunc, filters ...string) error

	// Close releases the internal resources.
	//
	// Close is expected to be called on the end of the lifecycle of the snapshotter,
	// but not mandatory.
	//
	// Close returns nil when it is already closed.
	Close() error
}

在 OCI 镜像格式中,对于镜像层,记其 diff id 分别为 $(diffid_1, diffid_2, diffid_3, …, diffid_n)$,那么 chain id 的计算方式如下: $$chain_id_1 = diff_id_1$$ $$chain_id_n = f_{digest}(f_{append}(chain_id_{n-1}, diff_id_n)) $$ 可以看到, $layer_n$ 的 chain id 是由 $(layer_1, layer_2, layer_3, …, layer_n)$ 共同决定的,即包含了所有的“祖先” layer。

构建过程
#

以 native snapshotter 为例

  • 处理 $layer1$
    • containerd 调用 Snapshotter 的 Prepare 方法 sn.Prepare(ctx, key, parent.String(), opts...) 通知snapshotter为 $snapshot_1$ “准备”一个工作目录。说明一下这个函数的参数:

      1. key 是个包含了当前了 $layer_1$ 的 chain id 的 $chain_id_1$ 的随机字符串
      2. parent.String() 是当前正在处理的 layer 的 parent layer 的 id,由于当前处理的是 $layer_1$,它没有 parent,所以这里的 parent.String() 是 ""
    • native snapshotter 接收到 Prepare 的通知后,发现传入的 parent 是空,于是准备一个全新的空白目录,并返回之。注意,这里返回的不是一个路径名字符串,而是一个 Mount 结构体数组。表示其中的 Mount 定义如下:

      // Mount is the lingua franca of containerd. A mount represents a
      // serialized mount syscall. Components either emit or consume mounts.
      type Mount struct {
      	// Type specifies the host-specific of the mount.
      	Type string
      	// Source specifies where to mount from. Depending on the host system, this
      	// can be a source path or device.
      	Source string
      	// Target specifies an optional subdirectory as a mountpoint. It assumes that
      	// the subdirectory exists in a parent mount.
      	Target string
      	// Options contains zero or more fstab-style mount options. Typically,
      	// these are platform specific.
      	Options []string
      }
      

      即 snapshotter 告诉 containerd 备好了一个目录 Source,你应该使用 Type 类型的挂载方式挂载到某个目录下面来用。同时,你挂载的具体位置,应该是 ${你自定义的目录}/Target,挂载的时候记得用我给你的 Options

      比如,native snapshotter 接收到 Prepare 的通知后,准备了一个路径为 /path/snapshot/1 的空目录,那么它应该返回的 Mount 结构体数组大致是这样的:

      mounts := []Mount{
      	{
      		Type: "bind",
      		Source: "/path/snapshot/1",
      		Target: "",
      		Options: []string{"rbind", "rw"},
      	},
      }
      
    • 接着,containerd 拿到了 native snapshotter 返回的 mounts,然后尝试使用 mountpackage 下的 All(mounts, root) 方法,将 mounts 中的各个 Source 挂载到 root 下。 细节如下:

      1. containerd 创建临时目录,记为 root,我们假设其为 /path/to/tmp/root/1
      2. 遍历 mounts 数组,发现只有一个,去除这个唯一一个元素 mount
      3. 取出 mount 中的 target,将其拼接到 /path/to/tmp/root/1 后面,得到最终的挂载路径 finalTarget。由于 Target"",所以 finalTarget 就是 /path/to/tmp/root/1
      4. containerd 调用 mount,根据 mount 中的 Type,将 mountSource 挂载到 finalTarget 中。本例中,相当于执行了:mount --bind /path/snapshot/1 /path/to/tmp/root/1
    • 现在 containerd 知道 /path/to/tmp/root/1 就是 $snapshot_1$ 的工作目录。它将 $layer_1$ 拆包解压,并 apply 到 /path/to/tmp/root/1 中。 由于 /path/to/tmp/root/1 被挂载了一个空目录,所以这一步相当于把 $layer_1$ 拆包后的所有文件 copy 到 /path/to/tmp/root/1

    • containerd 调用 snapshotter 的 Conmmit 方法 sn.Commit(ctx, chainID.String(), key, opts...),通知 snapshotter ”提交“ 当前的 snapshot。 这一步时机是 containerd 通知 snapshooter 已经处理完成当前 snapshot,需要持久话它的信息到 metadata 数据库中,以备后续使用 这个函数的各参数:

      1. chainID.String() 是 containerd 希望 snapshotter 持久话这个 snapshot 时使用的 Name,后续 containerd 都将使用这个 Name 来索引这个 snapshot。这个例子中的值就是 $layer_1$ 的 chain\_id_1
      2. key 是最开始 containerd 调用 Prepare· 函数时给定的随机字符串,用以告诉 snapshotter 要持久化的是哪个 snapshot
  • 处理 $layer_2$
    • containerd 调用 Snapshotter 的 Prepare 方法 sn.Prepare(ctx, key, parent.String(), opts...),通知 snapshotter 为 $snapshot_2$ “准备”一个工作目录。此时函数参数:
      1. key 是个包含了当前了 $layer_2$ 的 chain id 的 $chain_id_2$ 的随机字符串($layer_1$ 和 $layer_2$ 的 diff id 总和哈希之后的结果)
      2. parent.String() 当前处理的是 $layer_2$,它的 parent 是 $layer_1$,所以这里的 parent.String() 是 $layer_1$ 的 chain\_id_1(即 Commit $snapshot_1$ 时使用的 Name
    • native snapshotter 接收到 Prepare 的通知,首先创建一个空白目录 dir2,根据传入的 Parent $chain_id_1$ 从 metadata 数据库中查到 $snapshot_1$ 的目录 dir1,接着将 dir1 中的内容 cpy 到 dir2 中,然后使用 dir2 作为 Source,和处理 $layer_1$ 时一样,组装 Mount 数据并返回 假设 dir2/path/to/tmp/root/2,那么返回的 Mount 数据大致是这样的:
      mounts := []Mount{
      	{
      		Type: "bind",
      		Source: "/path/snapshot/2",
      		Target: "",
      		Options: []string{"rbind", "rw"},
      	},
      }
      
    • containerd 拿到 native snapshotter 返回的 Mount,尝试使用 mount package 下的 All(mounts, root) 方法,将 mounts 中的各个 Source 挂载到 root 下。
      1. containerd 创建临时目录,记为 root,我们假设其为 /path/to/tmp/root/2
      2. 遍历 mounts 数组,发现只有一个,取出这个唯一一个元素 mount
      3. 取出 mount 中的 target,将其拼接到 /path/to/tmp/root/2 后面,得到最终的挂载路径 finalTarget。由于 Target"",所以 finalTarget 就是 /path/to/tmp/root/2
      4. containerd 调用 mount,根据 mount 中的 Type,将 mountSource 挂载到 finalTarget 中。本例中,相当于执行了:mount --bind /path/snapshot/2 /path/to/tmp/root/2
    • 现在 containerd 知道 /path/to/tmp/root/2 就是 $snapshot_2$ 的工作目录,且 /path/to/tmp/root/2 中应包含和 $snapshot_2$ 中相同的内容。它将 $layer_2$ 拆包解压,并 apply 到 /path/to/tmp/root/2 中。这样 /path/to/tmp/root/2 将是 $layer_1$ 和 $layer_2$ 的联合。
    • Commit
  • 处理 $layer_3$ 已经剩余所有的 $layer$。处理完成后,我们得到一个目录 $snapshot_n$,其中包含所有 layer 合并之后的内容,即当前镜像中的所有内容

overlay snapshotter
#

native snapshotter 每层文件都会被来回 copy,有大量的运行时开销和存储空间占用。而 overlay snapshotter 则解决了这个问题

  • 在处理 $layer_1$ 是 overlay 和 native 基本没有区别
  • 当处理 $layer_2$ 时,overlay snapshotter 在创建了那个为 $snapshot_2$ 准备的临时目录后,overlay 不会将 $snapshot_1$ 中的文件都 copy 到这个临时目录中,而是直接返回 Mount 数组。数组中的唯一的 mount 元素告诉 containerd:使用 overlay 方式挂载,lowerdir 是 $snapshot_1$ 的目录,upperdir 是准备的这个临时目录。通过这样的命令,containerd 最终得到的 mergeddir 将会展现出 lowerdir(即 $snapshot_1$ 的目录)的样子,而 containerd 在后续 apply $layer_2$ 拆包后的文件时,对文件系统的更改会反应在 upperdir(即 $snapshot_2$)的目录中。
  • 后续 layer 处理相同

snapshot 到 rootfs
#

最终得到的 snapshot 是 $snapshot_n$,但是容器进程不能直接在 $snapshot_n$ 的目录进行读写,因为多个容器需要共享同一个镜像

contaienrd 的做法:创建容器时依次:

  • unpack 镜像,得到 $snapshot_n$
  • 调用 snapshotter 的 Prepare 方法,此时传入的 parent 是 $layer_n$ 的 chain id
    • 如果是 native snapshotter,会创建一个新的目录,并将 parent,也就是 $snapshot_n$ 的目录中的所有文件都 copy 到这个新的目录中,然后返回一个 Source 就是这个新的目录 new_dirTypebindMount 结构。这样 containerd 可以直接将 new_dir mount 到 path/to/runtime/bundle/rootfs 上。容器进程实际是在 new_dir 上读写
    • 对于 overlay snapshotter,会创建一个新的目录,但是不做 copy 动作。而是创建一个新的目录 new_dir 之后直接返回一个 Mount 结构,这个 Mount 结构指明 lowerdir 是 $snapshot_1$ 到 $snapshot_n$ 的所有的目录,upperdir 是 new_dir,并且需要使用 overlay 类型 mount 到 path/to/runtime/bundle/rootfs 上。这样 /path/to/runtime/bundle/rootfs 作为 mergeddir,可以看到 $snapshot_1$ 到 $snapshot_n$ 的所有目录合并后的文件系统(即 image 的所有层合并后的结果),同时也是可写的。容器进程所有执行的对文件系统的更改都将反应在 upperdir 也就是 new_dir
  • 使用上述 mount 后的目录,即 /path/to/runtime/bundle/rootfs 作为 bundle 的 rootfs,并结合生成的 config 文件,调用 runc 以创建容器