runc 的输入输出

概述

runc 是一个基于 OCI 规范实现的程序。在基于 containerd 的容器技术栈上,runc 是一个非常重要但又容易忽略的组件。当执行 docker, nerdctl, ctr 等命令时,其实底层都要调用 runc 去运行容器的进程。不过这篇文章仅会涉及到 runc 的输入输出。

因为在日常的开发中,大多数开发者接触到的都是 docker,因此下面会以 docker 为例,结合底层的 runc 来说明容器进程的输入输出。

标准输入输出

在 linux 上,所有的进程都会打开三个文件描述符:

  • 0: stdin 标准输入
  • 1: stdout 标准输出
  • 2: stderr 标准错误输出

在容器技术上,同样离不开这三个基本概念。在使用 docker run/exec 时,其实就是在 linux cgroup 和 namespace 下运行了一个普通的进程而已。所以这样的进程同样会有输入输出,当我们使用 docker run nginx 的时候,nginx 的日志就会打印到终端上,其实就是将 nginx 进程的标准输入输出通过各种手段输出到终端上了。

-i 参数

docker 提供了 -i 参数,来运行交互式的命令,文档上的说明是:

-i,  --interactive                    Keep STDIN open even if not attached

也就是说即使没有使用 docker attach,也会保持 STDIN 打开。这样我们在终端的 STDIN 就会定向到容器内 sh 进程的 STDIN,这样就可以在终端上输入命令行了。

比如 docker run -i nginx sh。这里就是使用 nginx 镜像启动容器,并运行了 sh 命令,然后终端的 bash 的 STDIN 定向到容器内的 sh。如下图所示:

https://www.myway5.com/wp-content/uploads/2023/03/Snipaste_2023-03-08_13-01-18.png

不过我们也可以发现,这里的输出格式似乎和在终端里 ls 的输出不同。这里就要引入一个新的知识点:tty 与 pty 以及我们的 -t 参数。

-t 参数

官方文档上的说明是:

-t, --tty                            Allocate a pseudo-TTY

tty 得名于早期的电传打字机(Teletype),随着技术的发展,tty 的概念变得更宽泛,它不再指打字机这样的物理设备,很多时候指的是 linux 内核中的 tty 驱动。当我们使用无 GUI 的 linux 时,比如 server 版本的 ubuntu,我们使用 ctrl+alt+f1~f6就可以在 tty1~6 之间进行切换。这里的 tty 就是由 linux 内核模拟出来的。

https://www.myway5.com/wp-content/uploads/2023/03/Snipaste_2023-03-08_13-18-38.png

不过在 GUI 场景中,我们打开的终端并不是使用 tty,而是 pseudo-TTY(下文称之为 pty)。pty 也称为伪终端,也就是说它并不是真实的 tty 设备。引入了 pty 之后,终端的数量就不会再有限制了。我们可以打开任意多的 GUI 终端程序,每打开一个 GUI 终端,就会在 /dev/pts/ 下生成一个数字的文件,这个文件就是 pty slave,另外全局还有共用的一个 pty master,位于 /dev/ptmx。

  1. 通过 GUI 终端,我们可以输入字符,输入的字符会发到 pty master 上,pty master 文件描述符,发送到对应的 pty slave。
  2. pty slave 将输入发送给终端上的 shell 程序,比如 bash。
  3. bash 上执行的任何命令,都会将输出发送给 bash,bash 再发送给 pty slave,之后再经过 tty driver, pty master 到 gui 终端上显示。

pty 的工作原理如下图:

https://www.myway5.com/wp-content/uploads/2023/03/Snipaste_2023-03-08_13-28-22.png

这时候我们在回到 -t 参数,这里其实就是为容器内的 sh 进程分配了一个 pty slave。当 sh 感知到自己被分配了 pty slave 后,它会采用不同的工作模式。比如会在终端上输出提示符,对输出的格式进行调整,像 bash 之类的还是加上颜色等等。

Untitled

# 下面这段程序会输出很多行,也说明了在 sh 里执行 ls 时,原本的输出是很多行的。
root@5f96c3475e20:/# ls | while read line; do echo $line;done
bin
boot
dev
docker-entrypoint.d
docker-entrypoint.sh
etc
home
lib
lib64
media
mnt
opt
proc
root
run
sbin
srv
sys
tmp
usr
var

回到 runc

上述提到 stdio,pty 等概念,很好的解决了运行容器时的输入输出的处理。而这些能力也是 runc 本身就提供的,docker 是在其之上进行了封装。

runc 的输入输出处理有四种模式,由 -d(detach),-t 进行组合得到。

terminal && detached

https://www.myway5.com/wp-content/uploads/2023/03/Snipaste_2023-03-08_13-44-19.png

  1. 上层的管控程序,比如 runc-shim 会创建 unix socket
  2. runc 运行在容器内,接管容器的 stdio。然后通过 unix socket 将 pty master 和 fd 发送给上层管控程序。
  3. 将自己的 stdio 通过 pty slave 输入输出。

passthrough && detached

https://www.myway5.com/wp-content/uploads/2023/03/Snipaste_2023-03-08_14-02-40.png

passthrough && foreground

https://www.myway5.com/wp-content/uploads/2023/03/Snipaste_2023-03-08_14-03-28.png

terminal && foreground

https://www.myway5.com/wp-content/uploads/2023/03/Snipaste_2023-03-08_14-03-22.png

参考资料

http://www.wowotech.net/tty_framework/tty_concept.html

http://www.wowotech.net/tty_framework/tty_architecture.html

http://www.wowotech.net/tty_framework/application_view.html

https://blog.51cto.com/u_14592069/5824829

https://dev.to/napicella/linux-terminals-tty-pty-and-shell-192e

https://github.com/opencontainers/runc/blob/main/docs/terminals.md

cgroup cpu子系统

概述

cgroup 全名是 control groups,在 linux 上负责对进程的一系列资源进行管控。比如 CPU,Memory,Huge Pages 等。cgroup 下通过子系统(subsystem)来划分模块,每种资源都通过一个子系统来实现。

cgroup 通过文件系统的方式对外提供调用,并可以用层级的方式进行组合。这种层级通过文件系统目录的方式进行呈现。比如在 cgroup cpu 目录下创建子目录,就相当于在根 cpu cgroup 下创建了一个子 cgroup。并且子 cgroup 会继承父 cgroup 的限制。

cgroup 目前有两个版本:v1 和 v2,并且两个版本的设计差异较大。但是理念类似,因此即使版本不同,也可以一样来理解。下面会以 cgroup v1 cpu 子系统进行讲解。

cpu 子系统的使用

cgroup 描述起来一直是一个比较抽象的概念。下面用一个简单的例子来帮助认识 cgroup 是如何工作的。

首先在机器上启动一个 stress 进程,分配一个 cpu,然后查看该进程 cpu 占用情况:

$ stress -c 1

$ pidstat -p 480164 1
Linux 4.14.81.bm.26-amd64 (n251-254-159)    06/01/2021  _x86_64_    (8 CPU)
02:36:56 PM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
02:36:57 PM  1001    480164  100.00    0.00    0.00  100.00     6  stress

可以看到,stress 进程已经占用了 1 cpu。现在我们创建一个名叫 stress 的 cgroup 来限制 cpu:

$ cd /sys/fs/cgroup/cpu

$ mkdir stress && cd stress

# 将 pid 写入到 cgroup.procs 中,就等同于将这个进程移到该 cgroup 中
$ echo 480164 > cgroup.procs

$ echo 100000 > cpu.cfs_period_us

$ echo 50000 > cpu.cfs_quota_us

# 再看看当前的 CPU 占用
$ pidstat -p 480164 1
Linux 4.14.81.bm.26-amd64 (n251-254-159)    06/04/2021  _x86_64_    (8 CPU)

05:17:49 AM   UID       PID    %usr %system  %guest    %CPU   CPU  Command
05:17:50 AM  1001   480164   50.00    0.00    0.00   50.00     6  stress

上述操作通过配置 cpu.cfs_period_uscpu.cfs_quota_us 参数达到了限制进程使用 CPU 的目的。

cgroup 还提供了一个 cpu.shares 参数,当 CPU 资源繁忙时,这个参数可以配置进程使用 CPU 的权重。下面我们在 cpu 为 1 的虚拟机演示。 在 cgroup 下创建两个子 cgroup 来展示这个参数的效果。

$ cd /sys/fs/cgroup/cpu,cpuacct
$ mkdir stress1 && cd stress1
$ stress -c 1
$ echo 3475127 > cgroup.procs
$ echo 1024 > cpu.shares

此时 PID 3475127 的 stress 进程 CPU 占用率接近 100%。在新的终端中执行以下命令:

$ mkdir stress2 && cd stress2
$ stress -c 1
$ echo 3479833 > cgroup.procs

此时两个 stress 进程的 CPU 占用大致相等,接近 50%。因为 stress2 cgroup 中没有设置 cpu.shares,所以取默认值为 1024。现在设置 stress2 cgroup 的 cpu.shares 参数:

$ echo 512 > cpu.shares

# 使用 top 查看
    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM
  3475127 root      20   0    7948     96      0 R  65.1   0.0
  3479833 root      20   0    7948     92      0 R  32.2   0.0

stress1 中的进程 CPU 占用率大概是 stress2 中的两倍。这是因为 stress1 中 cpu.shares 的值是 stress2 中的两倍。当然上述情况必须在 CPU 资源不够时,cpu.shares 才会起作用。如果这是一个 2 cpu 的虚拟机,那么 stress1 和 stress2 都会占用 100%。

参数说明

上述出现了一些 cpu 的参数,这里统一解释一下:

  • cpu.cfs_period_us: 重新分配 CPU 资源的时间周期长度,单位是 us。cfs 是 linux 进程调度器的一种,全称为完全公平调度器。因此这个参数只针对使用 cfs 调度的进程。

  • cpu.cfs_quota_us: 进程在设置的时间周期长度内,可以使用的 CPU 时间上限。结合 cpu.cfs_period_us 就可以限制一个进程可以使用的总 CPU 时间了。计算方式为 (cpu.cfs_quota_us / cpu.cfs_period_us)*count(cpu)。这个参数只针对使用 cfs 调度的进程。

  • cpu.shares: 这个参数只有在 CPU 资源忙时才生效,它可以用来设置进程使用的 CPU 权重。上面的例子中,虚拟机只有 1 CPU,进程 1,2 都会占用一个 CPU,因此根据设置进程 1 的 cpu.shares 为 1024,进程 2 的 cpu.shares 为 512,就可以将 2/3 的 cpu 分配给进程 1,1/3 的 cpu 分配给进程 2 了。

除了上述例子中的几个参数,cgroup cpu 子系统还提供了以下的参数:

  • cpu.rt_period_us: 重新分配 CPU 资源的时间周期长度。 针对使用了实时调度器的进程
  • cpu.rt_runtime_us: 进程在设置的时间周期长度内,可以使用的 CPU 时间上限。这个和上面说的 cfs 的两个参数类似。
  • cpu.nr_periods: 这是一个统计参数。用来表示已经过去的 cpu 周期数(使用 cpu.cfs_period_us 来指定)
  • cpu.nr_throttled: cgroup 中进程被限制的次数(因为这些进程用完了分配的 cpu 时间)。
  • cpu.throttled_time: cgroup 中进程被限制的总时间(单位是 ns)。

参考

Linux进程调度:完全公平调度器CFS

redhat cfs cpu

containerd CRI 简要分析

概述

Containerd 在 release1.5 之后内置了 cri。通过暴露 CRIService 供 kubelet 调用。CRI 的封装并不复杂,都是利用了 containerd 本身的功能模块。通过 CRI 管理 pod 主要分为三个模块:

  • Sandbox 的管理:RunPodSandbox、StopPodSandbox、RemovePodSandbox、PodSandboxStatus、ListPodSandbox
  • Container 的管理:CreateContainer、StartContainer、StopContainer、RemoveContainer、ListContainers、UpdateContainerResources、ContainerStats、ListContainerStats、UpdateRuntimeConfig、Status
  • 容器其他方面的管理:ReopenContainerLog、ExecSync、Exec、Attach、PortForward

Sandbox 的管理

Sandbox 和 Container 在实现上类似,通过启动一个特殊的 pause 容器来创建一个沙箱环境。通常情况下,这个沙箱环境具有和主机隔离的 pid,uts,mount,network,ipc,user。

以 RunPodSandbox 为例,containerd 会执行以下操作:

  1. Containerd CRI 在 RunPodSandbox 时,会先保证 sandbox 的镜像是否存在。如果不存在则会进行 pull 操作。
  2. 创建 pod network namespace,并调用 CNI
  3. 使用 container task 来创建容器
  4. 启动 container task,也就是执行二进制文件 pause
  5. 更新 sandbox 的状态,包括 pid 置为 task pid,state 置为 ready 以及更新创建时间。
  6. 在新的 goroutine 中启动 sandbox exit monitoring。这样就可以在 sandbox 进程退出时,执行清理工作。然后更新 sandbox 的状态。

以 StopPodSandbox 为例,containerd 会执行以下操作:

  1. 使用 containerStore 来 list 出所有 container,并使用 container.SandboxID 过滤出属于当期 sandbox 的 container
  2. 依次停止 sandbox 下的 container
  3. 清理 sandbox 的文件,比如 unmount dev shm
  4. 如果 sandbox container(pause) 的状态是 Ready 或 Unknown,使用 SiGKILL 终止 sandbox container
  5. 调用 cni 清理 network namespace,然后移除。

Container 管理

在 sandbox 创建好之后,kubelet 就可以通过 CRI 来创建 container 了。CreateContainer 的逻辑如下:

  1. 获取 sandbox 的信息,后面创建的容器需要使用和 sandbox 同样的 runtime,namespace。
  2. 获取 container mounts,包括 /etc/hosts/etc/resolv.condev shm。然后设置
  3. 设置 container log path。
  4. 设置 container io,包括 stdin, stdout, stderr, terminal。
  5. 在 container store(metadata) 中创建容器记录。此时 container 处于 CREATED 状态。

创建好 container 后,调用 StartContainer 即可运行容器。步骤如下:

  1. 更新 container 状态为 Running,防止重复 start。
  2. 设置 container stdout,stderr 到 log 文件上。
  3. 启动 task 来运行 container 进程
  4. 更新 container status 的 Pid 和 StartedAt
  5. 在新的 goroutine 中启动 sandbox exit monitoring 来监控 container 的进程状态。

StopContainer 的步骤如下:

  1. 如果 container 设置了 stop timeout,使用 task.Kill 发送 SIGTERM 信号。然后等待 timeout 时间
  2. 使用 task.Kill 发送 SIGKILL 信号。

RemoveConrtainer 的步骤如下:

  1. 如果当前 container 处于 RUNNING 或者 UNKNOWN 状态,则使用 timeout 为 0 的 stopContainer 来强制停止容器。
  2. 设置 container 的状态为 removing。
  3. 从 store 中删除 container,checkpoint 以及一些缓存信息。

容器管理

Containerd CRI 除了实现了 sandbox 和 container 的生命周期管理,也提供了对容器其他方面的管理。比如:

  1. 在 kubelet 对 logfile rotate 之后,调用 ReopenContainerLog 来将 container log 输出到新的日志文件中
  2. 通过 GRPC 提供 GetAttach,返回 attach http endpoint 和 token,供 kubelet 通过 http stream 连接到 process 的 stdin,stdout 和 stderr 上。
  3. 通过 GRPC 提供 GetExec,返回 exec http endpoint 和 token,供 kubelet 通过 http stream 在容器命名空间内执行命令。
  4. 通过 GRPC 提供 GetPortforward,返回 portforward http endpoint 和 token。kubelet 通过 http stream 连接上后,使用 netns 在 sandbox network namespace 下 dial 容器内的端口,使用 io.Copy 转发输入输出流。

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

容器中程序的信号捕捉

一、问题描述

项目中使用了 argo 在 kubernetes 集群中做工作流的调度。argo 提供了工作流的停止功能,其原理大致是检查正在运行的 Pod,向该 Pod 中的 wait 容器发送 USR2 信号,wait 容器收到 USR2 信号后,在主机上的调用 docker kill --signal TERM main_container_id 来停止我们的程序容器, 如果 10s 后容器还未停止,则发送 SIGKILL 来强制终止。但是我在实现 argo 工作流中调度 tfjob 时出现了一些问题。

argo_scheduler_tfjob

在argo停止工作流时,正在运行的 step2 中的 manager 监听了 TERM 信号,以便在工作流停止时同步停止 tfjob。但是事实情况却是 manager 退出了,但是没有收到任何的 TERM 信号。

二、问题剖析

检查这个问题的第一步是弄清楚 docker kill 背后发生了什么,官网的资料中有以下的描述:

Note: ENTRYPOINT and CMD in the shell form run as a subcommand of /bin/sh -c, which does not pass signals. This means that the executable is not the container’s PID 1 and does not receive Unix signals.

当我们用 sh 执行一段 shell script 时,在 shell script 中的可执行文件的 PID 不是1,并且 sh 也不会帮忙转发 TERM 信号,导致我们的可执行文件无法接收到终止信号,并执行清理逻辑。

我们的 manager 确实是用了一段 shell script 来启动的,可能就是因为这个原因导致无法收到 TERM 信号。

三、问题复现

我写了一段很简单的 go 程序,监听了 TERM 信号,然后打印一段文字。

package main

import (
    "log"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT)

    s, ok := <-sigs
    if !ok {
        log.Println("信号接收出错")
        os.Exit(1)
    }

    log.Println("收到信号:", s.String())
}

我的 Dockerfile 如下:

FROM alpine:latest
LABEL maintainr="jiangpengfei <jiangpengfei12@gmail.com>"

COPY main /usr/bin/main
COPY run.sh /usr/bin/run.sh
RUN chmod +x /usr/bin/main && chmod +x /usr/bin/run.sh

CMD ["sh", "-c", "/usr/bin/run.sh"]

run.sh 如下:

#!/bin/sh
/usr/bin/main

执行这个容器后,查看容器内的进程:

PID   USER     TIME  COMMAND
    1 root      0:00 {busybox} ash /usr/bin/run.sh
    6 root      0:00 /usr/bin/main
   12 root      0:00 sh
   17 root      0:00 ps

可以发现,run.sh 是 PID 为1, main 程序是6。此时我们使用 docker kill --signal TERM main_container_id 来停止容器,发现确实是没有反应的。因为 TERM 信号会发送给 PID 为 1 的进程。同时也因为 sh 不响应 TERM 信号,也不会转发该信号给子进程,所以容器也不会退出。如果我们使用 docker stop 退出的话,会发现很慢,这是因为 docker stop 会尝试先用 TERM 信号来终止进程,一段时间后发现没有退出的话再使用 KILL 信号。

四、解决方案

这个问题的解决方案有很多,要么让我们的程序进程成为 PID 1,要么让 PID 为 1 的进程转发这个 TERM 信号给我们的子进程。

方法一: 在 shell script 中使用 exec

将我们的 run.sh 改成如下:

#!/bin/sh
exec /usr/bin/main

然后再查看容器内的进程列表:

PID   USER     TIME  COMMAND
    1 root      0:00 /usr/bin/main
   11 root      0:00 sh
   16 root      0:00 ps

可以发现,main 进程的PID 是 1, 我们使用 docker kill --signal TERM main_container_id 来杀死进程,出现如下打印语句:

2020/01/17 23:46:24 收到信号: terminated

可见,exec 可以让我们的 main 进程成为 PID 为 1, 关于 exec 的作用描述如下:

The exec() family of functions replaces the current process image with a new process image.

即使用新进程的镜像替换当前进程的镜像数据,可以理解为exec系统调用并没有创建新的进程,只是替换了原来进程上下文的内容。原进程的代码段,数据段,堆栈段被新的进程所代替。这样我们的 main 进程就顺利成章的替换了 sh 进程成为 PID 为 1 的进程了。

方法二: 直接使用 main 作为镜像入口

这是最简单的方法了,但是很多时候会有限制,因为我们希望在 shell script 中写一些逻辑来调用程序。

方法三: 借助第三方程序

一些第三方的程序专门提供了这样的作用,以它们作为启动的入口,这些第三方程序会 watch 所有它产生的子进程,在这些子进程退出后自动退出,并且在其收到 TERM 信号后发送给子进程。

这里我们用 smell-baron 这个应用作为例子

修改 Dockerfile:

FROM alpine:latest
LABEL maintainr="jiangpengfei <jiangpengfei12@gmail.com>"

COPY main /usr/bin/main
COPY run.sh /usr/bin/run.sh
RUN chmod +x /usr/bin/main && chmod +x /usr/bin/run.sh
RUN wget -O /usr/bin/smell-baron https://github.com/insidewhy/smell-baron/releases/download/v0.4.2/smell-baron.musl && chmod +x /usr/bin/smell-baron

CMD ["/usr/bin/smell-baron", "/usr/bin/run.sh"]

查看容器内的进程:

PID   USER     TIME  COMMAND
    1 root      0:00 /usr/bin/smell-baron /usr/bin/run.sh
    6 root      0:00 /usr/bin/main
   14 root      0:00 sh
   19 root      0:00 ps

使用 docker kill 发现 main 收到了 TERM 信号。

1.Multiple commands can be run, smell-baron will exit when all the watched processes have exited.

2.Whether a spawned process is watched can be configured.

3.smell-baron can be told to signal all child processes on termination, this allows it to cleanly deal with processes that spawn a subprocess in a different process group then fail to clean it up on exit.

容器标准化

概述

我认为容器标准化可以分为两个角度去讲:

一个是容器的使用和镜像的格式需要规范,这叫做OCI(open container initiative),也就是说,不同技术实现的容器,都可以使用同一种方式运行,同一个镜像也可以在不同的容器技术上运行。

另外一个就是因为Kubernetes的流行,Kubernetes推出了一个CRI(container runtime interface)的接口规范,凡是直接或间接实现了这个接口规范的容器都可以作为Kubernetes的默认容器运行时。

OCI和CRI的制定也意味着容器技术迎来了高速发展。

CRI: container runtime interface

CRI是kubernetes推出的容器运行时接口,有了CRI,不论各种容器化技术是如何实现的,都可以用一个共同的接口对外提供服务。CRI中定义了容器和镜像的接口的接口,基于gRPC调用。具体的可以查看api.proto。下面简单的列一下:

// Runtime service defines the public APIs for remote container runtimes
service RuntimeService {
    // Version returns the runtime name, runtime version, and runtime API version.
    rpc Version(VersionRequest) returns (VersionResponse) {}

    // RunPodSandbox creates and starts a pod-level sandbox. Runtimes must ensure
    // the sandbox is in the ready state on success.
    rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse) {}
    // StopPodSandbox stops any running process that is part of the sandbox and
    // reclaims network resources (e.g., IP addresses) allocated to the sandbox.
    // If there are any running containers in the sandbox, they must be forcibly
    // terminated.
    // This call is idempotent, and must not return an error if all relevant
    // resources have already been reclaimed. kubelet will call StopPodSandbox
    // at least once before calling RemovePodSandbox. It will also attempt to
    // reclaim resources eagerly, as soon as a sandbox is not needed. Hence,
    // multiple StopPodSandbox calls are expected.
    rpc StopPodSandbox(StopPodSandboxRequest) returns (StopPodSandboxResponse) {}
    // RemovePodSandbox removes the sandbox. If there are any running containers
    // in the sandbox, they must be forcibly terminated and removed.
    // This call is idempotent, and must not return an error if the sandbox has
    // already been removed.
    rpc RemovePodSandbox(RemovePodSandboxRequest) returns (RemovePodSandboxResponse) {}
    // PodSandboxStatus returns the status of the PodSandbox. If the PodSandbox is not
    // present, returns an error.
    rpc PodSandboxStatus(PodSandboxStatusRequest) returns (PodSandboxStatusResponse) {}
    // ListPodSandbox returns a list of PodSandboxes.
    rpc ListPodSandbox(ListPodSandboxRequest) returns (ListPodSandboxResponse) {}

    // CreateContainer creates a new container in specified PodSandbox
    rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse) {}
    // StartContainer starts the container.
    rpc StartContainer(StartContainerRequest) returns (StartContainerResponse) {}
    // StopContainer stops a running container with a grace period (i.e., timeout).
    // This call is idempotent, and must not return an error if the container has
    // already been stopped.
    // TODO: what must the runtime do after the grace period is reached?
    rpc StopContainer(StopContainerRequest) returns (StopContainerResponse) {}
    // RemoveContainer removes the container. If the container is running, the
    // container must be forcibly removed.
    // This call is idempotent, and must not return an error if the container has
    // already been removed.
    rpc RemoveContainer(RemoveContainerRequest) returns (RemoveContainerResponse) {}
    // ListContainers lists all containers by filters.
    rpc ListContainers(ListContainersRequest) returns (ListContainersResponse) {}
    // ContainerStatus returns status of the container. If the container is not
    // present, returns an error.
    rpc ContainerStatus(ContainerStatusRequest) returns (ContainerStatusResponse) {}
    // UpdateContainerResources updates ContainerConfig of the container.
    rpc UpdateContainerResources(UpdateContainerResourcesRequest) returns (UpdateContainerResourcesResponse) {}
    // ReopenContainerLog asks runtime to reopen the stdout/stderr log file
    // for the container. This is often called after the log file has been
    // rotated. If the container is not running, container runtime can choose
    // to either create a new log file and return nil, or return an error.
    // Once it returns error, new container log file MUST NOT be created.
    rpc ReopenContainerLog(ReopenContainerLogRequest) returns (ReopenContainerLogResponse) {}

    // ExecSync runs a command in a container synchronously.
    rpc ExecSync(ExecSyncRequest) returns (ExecSyncResponse) {}
    // Exec prepares a streaming endpoint to execute a command in the container.
    rpc Exec(ExecRequest) returns (ExecResponse) {}
    // Attach prepares a streaming endpoint to attach to a running container.
    rpc Attach(AttachRequest) returns (AttachResponse) {}
    // PortForward prepares a streaming endpoint to forward ports from a PodSandbox.
    rpc PortForward(PortForwardRequest) returns (PortForwardResponse) {}

    // ContainerStats returns stats of the container. If the container does not
    // exist, the call returns an error.
    rpc ContainerStats(ContainerStatsRequest) returns (ContainerStatsResponse) {}
    // ListContainerStats returns stats of all running containers.
    rpc ListContainerStats(ListContainerStatsRequest) returns (ListContainerStatsResponse) {}

    // UpdateRuntimeConfig updates the runtime configuration based on the given request.
    rpc UpdateRuntimeConfig(UpdateRuntimeConfigRequest) returns (UpdateRuntimeConfigResponse) {}

    // Status returns the status of the runtime.
    rpc Status(StatusRequest) returns (StatusResponse) {}
}

// ImageService defines the public APIs for managing images.
service ImageService {
    // ListImages lists existing images.
    rpc ListImages(ListImagesRequest) returns (ListImagesResponse) {}
    // ImageStatus returns the status of the image. If the image is not
    // present, returns a response with ImageStatusResponse.Image set to
    // nil.
    rpc ImageStatus(ImageStatusRequest) returns (ImageStatusResponse) {}
    // PullImage pulls an image with authentication config.
    rpc PullImage(PullImageRequest) returns (PullImageResponse) {}
    // RemoveImage removes the image.
    // This call is idempotent, and must not return an error if the image has
    // already been removed.
    rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse) {}
    // ImageFSInfo returns information of the filesystem that is used to store images.
    rpc ImageFsInfo(ImageFsInfoRequest) returns (ImageFsInfoResponse) {}
}

共包含了两个服务:
– RuntimeService:容器和Sandbox运行时管理。
– ImageService:提供了从镜像仓库拉取、查看、和移除镜像的RPC。

再看一下CRI的架构图:

cri architecture

在kubernetes中,CRI扮演了kubelet和container runtime的通信桥梁。也因为CRI的存在,container runtime和kubelet解耦,就有了多种选择,比如: docker、 CRI-O、containerd、frakti等等。

OCI: open container initiative

这个是由docker和其他的公司推动的容器标准,为了围绕容器格式和运行时制定一个开放的工业化标准,目前主要有两个标准文档:容器运行时标准 (runtime spec)和 容器镜像标准(image spec)。这两个协议通过 OCI runtime filesytem bundle 的标准格式连接在一起,OCI 镜像可以通过工具转换成 bundle,然后 OCI 容器引擎能够识别这个 bundle 来运行容器

oci

下面引用一下其他博客的文字(https://www.jianshu.com/p/62e71584d1cb):

设计考量

操作标准化:容器的标准化操作包括使用标准容器创建、启动、停止容器,使用标准文件系统工具复制和创建容器快照,使用标准化网络工具进行下载和上传。

内容无关:内容无关指不管针对的具体容器内容是什么,容器标准操作执行后都能产生同样的效果。如容器可以用同样的方式上传、启动,不管是PHP应用还是MySQL数据库服务。

基础设施无关:无论是个人的笔记本电脑还是AWS S3,亦或是OpenStack,或者其它基础设施,都应该对支持容器的各项操作。
为自动化量身定制:制定容器统一标准,是的操作内容无关化、平台无关化的根本目的之一,就是为了可以使容器操作全平台自动化。

工业级交付:制定容器标准一大目标,就是使软件分发可以达到工业级交付成为现实

image spec(容器标准包)

OCI 容器镜像主要包括几块内容:

文件系统:以 layer 保存的文件系统,每个 layer 保存了和上层之间变化的部分,layer 应该保存哪些文件,怎么表示增加、修改和删除的文件等

config 文件:保存了文件系统的层级信息(每个层级的 hash 值,以及历史信息),以及容器运行时需要的一些信息(比如环境变量、工作目录、命令参数、mount 列表),指定了镜像在某个特定平台和系统的配置。比较接近我们使用 docker inspect 看到的内容

manifest 文件:镜像的 config 文件索引,有哪些 layer,额外的 annotation 信息,manifest 文件中保存了很多和当前平台有关的信息

index 文件:可选的文件,指向不同平台的 manifest 文件,这个文件能保证一个镜像可以跨平台使用,每个平台拥有不同的 manifest 文件,使用 index 作为索引

runtime spec(容器运行时和生命周期)

容器标准格式也要求容器把自身运行时的状态持久化到磁盘中,这样便于外部的其它工具对此信息使用和演绎。该运行时状态以JSON格式编码存储。推荐把运行时状态的JSON文件存储在临时文件系统中以便系统重启后会自动移除。

基于Linux内核的操作系统,该信息应该统一地存储在/run/opencontainer/containers目录,该目录结构下以容器ID命名的文件夹(/run/opencontainer/containers//state.json)中存放容器的状态信息并实时更新。有了这样默认的容器状态信息存储位置以后,外部的应用程序就可以在系统上简便地找到所有运行着的容器了。

state.json文件中包含的具体信息需要有:

版本信息:存放OCI标准的具体版本号。

容器ID:通常是一个哈希值,也可以是一个易读的字符串。在state.json文件中加入容器ID是为了便于之前提到的运行时hooks只需载入state.json就- – 可以定位到容器,然后检测state.json,发现文件不见了就认为容器关停,再执行相应预定义的脚本操作。

PID:容器中运行的首个进程在宿主机上的进程号。

容器文件目录:存放容器rootfs及相应配置的目录。外部程序只需读取state.json就可以定位到宿主机上的容器文件目录。

容器创建:创建包括文件系统、namespaces、cgroups、用户权限在内的各项内容。

容器进程的启动:运行容器进程,进程的可执行文件定义在的config.json中,args项。

容器暂停:容器实际上作为进程可以被外部程序关停(kill),然后容器标准规范应该包含对容器暂停信号的捕获,并做相应资源回收的处理,避免孤儿进程的出现。

CRI和OCI的对比

OCI是容器技术的开放性标准,而CRI是Kubernetes为了更方便的支持不同的容器技术,而推出的接口标准,与CRI类似的还有CNI和CSI,分别是网络和存储的接口。

可以看一下这张图:

kubelet cri runtime

kubelet有了CRI的接口,可以通过cri-containerd和containerd通信,也可以通过docker-shim和docker通信。注意这里的cri-containerd在containerd v1.2的时候就已经不再使用了,因为containerd本身就支持了CRI的规范。

同时kubernetes还孵化了cri-o这个项目,cri-o直接打通了cri和oci。runc和kata都是oci的具体实现。

所以,用一句话理解:实现了CRI就可以保证被kubernetes使用,实现了OCI就可以在各种设备上无差别的使用各种镜像。

docker、containerd和runc

containerd从docker中分出来的一部分。containerd是负责管理容器生命周期的常驻进程,而runc则是真正负责容器运行的部分。可以通过以下的图来看三者之间的关系:

docker-containerd-runc

containerd会调用多个runc实例来管理多个容器。docker engine则是提供接口给用户使用。

kubernetes当前支持的CRI后端

containerd

containerd的地址:https://github.com/containerd/containerd

先用官网的图片来看一下containerd的架构:

containerd architecture

containerd处于os和clients之间,它使用CRI API提供给Kubelet调用,使用containerd API提供给containerd client调用,使用Metrics API提供给Prometheus监控数据。然后有一层containerd Service Interfaces提供给上层api使用。注意到其中还有一个container-shim打通了Runtime managerOCI runtime的具体实现,比如runcrunhcskata

containerd实现了以下的特性:

  • OCI Image规范的支持
  • OCI Runtime规范的支持(通过runc等)
  • Image的上传和下载
  • 容器运行时和生命周期的支持
  • 创建、修改和删除网络
  • 管理网络命名空间以及将容器加入到现有的网络命名空间
  • 全部镜像的CAS存储的多租户模式支持

cri-o

项目地址:https://github.com/cri-o/cri-o

cri-o

cri-o项目是Kubernetes CRI接口的实现,同时可以兼容OCI标准的容器运行时。这样的能力就使得它可以作为Docker的轻量级的容器运行时的替代方案,使得Kubernetes可以接入符合OCI标准的所有容器运行时,同时也减少了容器开发者们的额外工作量(只需实现OCI标准即可)。

frakti

项目地址:https://github.com/kubernetes/frakti

frakti

frakti是Kubernetes官方推出的一个容器运行时,但是不同于docker这样的利于linux namespace的技术,它是基于虚拟化技术的容器,因此可以带来更好的环境隔离以及独享的内核。

rkt

项目地址:https://github.com/rkt/rkt/

rkt-vs-docker-process-model

rkt是coreos推出的和Docker抗衡的容器产品,不同于现在的Docker往更大更全的方向,不仅仅是容器功能,更集成了Swarm这样的集群方案,rkt注重的是作为运行在linux系统上的容器组件。上图可以看出Docker的架构要更加的复杂。

docker

官网地址: https://docker.com

docker作为Kubernetes的默认容器运行时,其本身在容器领域也占据了绝对的领导地位。

实现了OCI,可以通过cri-o接入kubernetes的项目

runc

项目地址: https://github.com/opencontainers/runc

opencontainers组织推出了OCI的规范,同时也开发了runc作为OCI规范的实现。runc是docker贡献出来的容器运行时,runc不仅是containerd的默认运行时,同时也可以接入到cri-o中。

Clear Containers

https://github.com/clearcontainers/runtime,项目已经不在维护,推荐迁移到Kata Containers

Kata Containers

https://github.com/kata-containers/runtime

Kata Containers和runc这种技术栈是不同的。runc使用的是linux namespace和cgroup来做环境隔离和资源限制,缺点在于使用的仍然是宿主机的内核,这样一旦受到了内核层的影响,会扩散到所有的容器。而Kata Containers使用的是虚拟化的技术,它实际上是一个虚拟机,但是可以像容器那样使用。

Kata Containers是2017年12月启动的项目,结合了Intel Clear Containers和 Hyper.sh RunV的优点,支持不同的主流架构,除x86_64外,还支持AMD64, ARM, IBM p-series and IBM z-series。

下图是kata Containers和传统容器技术的对比:

katacontainers_traditionalvskata_diagram

主要特点如下:

  • 安全性: 使用专用内核,提供了网络、IO和内存的独立,在虚拟化VT扩展的基础上利用硬件强制隔离
  • 性能: 提供与标准Linux容器一致的性能;提高隔离度,而无需增加标准虚拟机的性能。
  • 兼容性: 支持行业标准,包括OCI容器格式,Kubernetes CRI接口以及旧版虚拟化技术。
  • 简单: 消除了在完整的虚拟机内部嵌套容器的要求;标准接口使插入和入门变得容易

下图是Kata Containers的架构:

katacontainers_architecture_diagram

Kubernetes可以通过Hypervisor VSOCK Socket和容器交互。

gVisor

gVisor提供的是一个沙箱容器环境,可以说是传统容器技术和虚拟机容器技术的折中。它使用Go编写了一个可以作为普通非特权进程运行的内核,这个内核实现了大多数的系统调用。所以相比于namespace和cgroup实现的容器,它可以屏蔽掉容器内应用程序的内核调用。相比于虚拟机实现的容器,它更轻量级(作为系统的一个进程运行)。

gvisor