05 什么是runtime

Docker、Containerd、RunC 间的联系和区别

一、OCI的由来

容器技术起源于1979年,发展至今已经超过40年,docker 只能说是目前为止,其中一种比较著名而流行的实现。
Docker 于 2013 年发布,解决了开发人员在端到端运行容器时遇到的许多问题,让应用分发变得十分便捷。这里是docker包含的所有东西:

  • 容器镜像格式
  • 一种构建容器镜像的方法(Dockerfile/docker build);
  • 一种管理容器镜像(docker image、docker rm等);
  • 一种管理容器实例的方法(docker ps, docker rm 等);
  • 一种共享容器镜像的方法(docker push/pull);
  • 一种运行容器的方式(docker run);

这里需要强调的是,在当时,Docker是一个单体系统,并没有考虑到要与其他系统比如k8s对接,所以docker本身提供了尽可能完善的功能。
但是,docker设计巧妙之处在于,上述这些功能中没有一个是真正相互依赖的、都是解耦合的。也就是说这些功能中的每一个都能够在、可以一起使用的、更小、更集中的工具中实现。每个工具都可以通过使用一种通用格式、一种容器标准来协同工作。

自从docker发布之后,便一炮而红,很快用的人越来越多,
大家都意识到容器时代的到来,很多人从这里面嗅到了金钱的味道,你能发布一个docker容器技术,我也能发布一个fucker容器技术,就好比是CS游戏火了,我立马抄一个穿越火线一个道理
于是很多组织或个人都参与到了容器的开发工作中来,导致容器由很多不同的实现,但长此以往必然会引起混乱、不兼容。“为了解决这一问题”

Docker、Google、CoreOS 和其他供应商于 2015-6-22 建立的一个开源组织,名为OCI,全称 Open Container Initiative/开放容器倡议 ,隶属于Linux基金会,其目的主要是为了制定容器技术的通用技术标准。

俗话说得好,勇士战胜恶龙之日,自身亦化作恶龙。
不管是Docker 公司后来各种神操作(把项目改名 Moby ,docker swarm 的弱鸡编排)也好,CoreOS 的崛起也罢,
开源世界的战争,是一种技术标准的全球话语权争夺,这种争夺远比你想象的要残酷。

目前OCI旗下主要有两个标准文档:

1、容器运行时标准 (runtime spec)

2、容器镜像标准(image spec)。

OCI 对容器 runtime 的标准主要是指定容器的运行状态,和 runtime 需要提供的命令。下图可以是容器状态转换图:

  • init 状态:该状态并不在标准中,表示没有容器存在的初始状态
  • creating:使用 create 命令创建容器,这个过程称为创建中
  • created:容器创建出来,但是还没有运行,表示镜像和配置没有错误,容器能够运行在当前平台
  • running:容器的运行状态,里面的进程处于 up 状态,正在执行用户设定的任务
  • stopped:容器运行完成,或者运行出错,或者 stop 命令之后,容器处于暂停状态。这个状态,容器还有很多信息保存在平台中,并没有完全被删除

二、docker的分层

2.1 docker的构成

前文提到OCI组织,为了让容器技术得到更好的发展,或许也是为了掌握技术标准的全球话语权,
docker没有选择一家独大,
从 Docker 1.11 之后,Docker Daemon由当年的单体实现被分成了多个标准的模块。标准化的目的是模块是可被其他实现替换的,不由任何一个厂商控制。

拆分之后,docker的结构分成了以下5个部分/模块:

  • 1、docker-client:客户端命令

  • 2、dockerd守护进程,全称docker daemon
    提供客户端命令接口

  • 3、containerd服务
    containerd 独立负责容器运行时和生命周期(如创建、启动、停止、中止、信号处理、删除等),其他一些如镜像构建、卷管理、日志等由 dockerd(docker daemon) 的其他模块处理。

  • 4、containerd-shim进程:该进程由containerd服务创建
    每创建一个容器,都会启动一个containerd-shim进程,然后由该进程调用runc来具体创建容器

  • 5、runc组成:由container-shim进程调用runc创建出的容器进程
    最早docker只是把RunC 单拿出来,捐赠给 OCI 作为OCI 容器运行时标准的参考实现(这个你得服,因为docker的设计就是比大多数容器技术的实现优秀一些,所以docker说我把我的实现分享出来,大家也就别创造了,照着我的来就行),即runc就是安装oci标准实现的,使用runc可以创建一个符合oci规范的容器。

2.2 为何需要有containerd-shim这个进程

现在创建一个docker容器的时候(注意我说的是docker容器而不是其他种类的容器),Docker Daemon 并不能直接帮我们创建了,而是请求 containerd 服务来创建一个容器
。当containerd 收到请求后,也不会直接去操作容器,而是创建一个叫做 containerd-shim 的进程。让这个进程去操作容器,我们指定容器进程是需要一个父进程来做状态收集、维持 stdin 等 fd 打开等工作的,假如这个父进程就是 containerd,那如果 containerd 挂掉的话(例如重启docker服务时),整个宿主机上所有的容器都得退出了,而引入 containerd-shim 这个垫片就可以来规避这个问题了,我们之前学习的配置参数live-restore就是基于该设计而来。

也就说真正启动容器是通过 containerd-shim 去调用 runc 来启动容器的,
启动容器需要做一些 namespaces 和 cgroups 的配置,以及挂载 root 文件系统等操作。runc 会按照OCI 标准来创建一个符合规范的容器。

需要强调的是:
runc 启动完容器后本身会直接退出,containerd-shim 则会成为容器进程的父进程, 负责收集容器进程的状态, 上报给 containerd, 并在容器中 pid 为 1 的进程退出后接管容器中的子进程进行清理, 确保不会出现僵尸进程。

2.3 containerd、containerd-shim及容器进程的关系

基于2.2小节我们知道
containerd,containerd-shim和容器进程(即容器主进程)三个进程,是有依赖关系的。

层级关系总结如下

dockerd
    containerd
        containerd-shim----runc---->容器进程
        containerd-shim----runc---->容器进程
        containerd-shim----runc---->容器进程

三 runtime容器运行时

3.1 容器运行时分类

runtime翻译为容器运行时,指的是用来管理镜像或容器的软件,之所以起名为“容器运行时“,大概是想表达此类软件是用于容器运行时期的涉及到管理操作,例如管理镜像、容器等

从docker的构成图中,只标注了runc为runtime容器运行时,但事实上,用于管理容器运行时的诸多操作的软件都可以称之为为runtime软件,所以你看到的docker构成中的dockerd、containerd都应该属于runtime,

但dockerd、containerd还有runc各自负责的事情不同,所以为了更好的区分开他们,我们将容器运行时软件分为两大类
1、Low-Level容器运行时:比如lxc、runc、gvisor、kata等,只涉及到容器运行的一些基础细节,比如namespace创建、cgroup设置,
2、High-Level容器运行时:比如docker、containerd、podman等,支持更多高级功能(如镜像管理和gRPC / Web API),对于高级别运行时来说,他们是通过调用低级别运行时来管理容器(可以简单的理解为高级别是在低级别基础上的上层封装),一般可以是runc作为低级别运行时

通常情况下,开发人员想要运行一个容器不仅仅需要Low-Level容器运行时提供的这些特性,同时也需要与镜像格式、镜像管理和共享镜像相关的API接口和特性,而这些特性一般由High-Level容器运行时提供。就日常使用来说,Low-Level容器运行时提供的这些特性可能满足不了日常所需,因为这个缘故,唯一会使用Low-Level容器运行时的人是那些实现High-Level容器运行时以及容器工具的开发人员。那些实现Low-Level容器运行时的开发者会说High-Level容器运行时比如containerd和cri-o不像真正的容器运行时,因为从他们的角度来看,他们将容器运行的实现外包给了runc。但是从用户的角度来看,它们只是提供容器功能的单个组件,可以被另一个的实现替换
关于Low-Level和High-Level容器运行时的详细介绍请看3.2小节

3.2、Low-Level和High-Level容器运行时

当人们想到容器运行时,可能会想到一系列示例;runc、lxc、lmctfy、Docker(容器)、rkt、cri-o。这些中的每一个都是为不同的情况而构建的,并实现了不同的功能。有些,如 containerd 和 cri-o,实际上使用 runc 来运行容器,在High-Level实现镜像管理和 API。与 runc 的Low-Level实现相比,可以将这些功能(包括镜像传输、镜像管理、镜像解包和 API)视为High-Level功能。考虑到这一点,您可以看到容器运行时空间相当复杂。每个运行时都涵盖了这个Low-Level到High-Level频谱的不同部分。这是一个非常主观的图表:

因此,从实际出发,通常只专注于正在运行的容器的runtime通常称为“Low-Level容器运行时”,支持更多高级功能(如镜像管理和gRPC / Web API)的运行时通常称为“High-Level容器运行时”,“High-Level容器运行时”或通常仅称为“容器运行时”,我将它们称为“High-Level容器运行时”。值得注意的是,Low-Level容器运行时和High-Level容器运行时是解决不同问题的、从根本上不同的事物。

  • Low-Level容器运行时:容器是通过Linux nanespace和Cgroups实现的,Namespace能让你为每个容器提供虚拟化系统资源,像是文件系统和网络,Cgroups提供了限制每个容器所能使用的资源的如内存和CPU使用量的方法。在最低级别的运行时中,容器运行时负责为容器建立namespaces和cgroups,然后在其中运行命令,Low-Level容器运行时支持在容器中使用这些操作系统特性。目前来看低级容器运行时有:runc :我们最熟悉也是被广泛使用的容器运行时,代表实现Docker。runv:runV 是一个基于虚拟机管理程序(OCI)的运行时。它通过虚拟化 guest kernel,将容器和主机隔离开来,使得其边界更加清晰,这种方式很容易就能帮助加强主机和容器的安全性。代表实现是kata和Firecracker。runsc:runsc = runc + safety ,典型实现就是谷歌的gvisor,通过拦截应用程序的所有系统调用,提供安全隔离的轻量级容器运行时沙箱。截止目前,貌似并不没有生产环境使用案例。wasm : Wasm的沙箱机制带来的隔离性和安全性,都比Docker做的更好。但是wasm 容器处于草案阶段,距离生产环境尚有很长的一段路。
  • High-Level容器运行时:通常情况下,开发人员想要运行一个容器不仅仅需要Low-Level容器运行时提供的这些特性,同时也需要与镜像格式、镜像管理和共享镜像相关的API接口和特性,而这些特性一般由High-Level容器运行时提供。就日常使用来说,Low-Level容器运行时提供的这些特性可能满足不了日常所需,因为这个缘故,唯一会使用Low-Level容器运行时的人是那些实现High-Level容器运行时以及容器工具的开发人员。那些实现Low-Level容器运行时的开发者会说High-Level容器运行时比如containerd和cri-o不像真正的容器运行时,因为从他们的角度来看,他们将容器运行的实现外包给了runc。但是从用户的角度来看,它们只是提供容器功能的单个组件,可以被另一个的实现替换,因此从这个角度将其称为runtime仍然是有意义的。即使containerd和cri-o都使用runc,但是它们是截然不同的项目,支持的特性也是非常不同的。dockershim, containerd 和cri-o都是遵循CRI的容器运行时,我们称他们为高层级运行时(High-level Runtime)。

Kubernetes 只需支持 containerd 等high-level container runtime即可。由containerd 按照OCI 规范去对接不同的low-level container runtime,比如通用的runc,安全增强的gvisor,隔离性更好的runv。

3.3 Low-Level容器运行时之Runc

RunC 是从 Docker 的 libcontainer 中迁移而来的,实现了容器启停、资源隔离等功能。Docker将RunC捐赠给 OCI 作为OCI 容器运行时标准的参考实现。Docker 默认提供了 docker-runc 实现。事实上,通过 containerd 的封装,可以在 Docker Daemon 启动的时候指定 RunC的实现。最初,人们对 Docker 对 OCI 的贡献感到困惑。他们贡献的是一种“运行”容器的标准方式,仅此而已。它们不包括镜像格式或注册表推/拉格式。当你运行一个 Docker 容器时,这些是 Docker 实际经历的步骤:

  • 下载镜像
  • 将镜像文件解开为bundle文件,将一个文件系统拆分成多层
  • 从bundle文件运行容器

Docker标准化的仅仅是第三步。在此之前,每个人都认为容器运行时支持Docker支持的所有功能。最终,Docker方面澄清:原始OCI规范指出,只有“运行容器”的部分组成了runtime。这种“概念失联”一直持续到今天,并使“容器运行时”成为一个令人困惑的话题。希望我能证明双方都不是完全错误的,并且在本文中将广泛使用该术语。RunC 就可以按照这个 OCI 文档来创建一个符合规范的容器,既然是标准肯定就有其他 OCI 实现,比如 Kata、gVisor 这些容器运行时都是符合 OCI 标准的。

怎么使用 runc

 create the bundle
$ mkdir -p /mycontainer/rootfs

# [ab]use Docker to copy a root fs into the bundle
$ docker export $(docker create busybox) | tar -C /mycontainer/rootfs -xvf -

# create the specification, by default sh will be the entrypoint of the container
$ cd /mycontainer
$ runc spec

# launch the container
$ sudo -i
$ cd /mycontainer
$ runc run mycontainerid

# list containers
$ runc list

# stop the container
$ runc kill mycontainerid

# cleanup
$ runc delete mycontainerid

在命令行中使用 runc,我们可以根据需要启动任意数量的容器。但是,如果我们想自动化这个过程,我们需要一个容器管理器。为什么这样?想象一下,我们需要启动数十个容器来跟踪它们的状态。其中一些需要在失败时重新启动,需要在终止时释放资源,必须从注册表中提取图像,需要配置容器间网络等等。就需要有Low-Level和High-Level容器运行时,runc就是Low-Level实现的实现。

3.3 High-Level容器运行时之containred

当我们安装好containerd之后,会有一个服务器进程containerd,他所使用的配置文件是/etc/containerd/config.toml。这个服务器进程会生成一个接口并客户端来连接(通过gRPC协议),这个接口就是/var/run/containerd/containerd.sock。

因为在操作系统里目录/var/run是/run的软连接(快捷方式),所以/run/containerd/containerd.sock和/var/run/containerd/containerd.sock是同一个文件。

[kubernetes1.20]()的kubelet就是通过gRPC协议连接/var/run/containerd/containerd.sock,是它的一个客户端。

与RunC一样,我们又可以在这里看到一个docker公司的开源产品containerd曾经是开源docker项目的一部分。尽管_containerd_是另一个自给自足的软件。

  • 一方面,它称自己为容器运行时,但是与运行时__RunC不同。不仅_containerd_和_runc_的职责不同,组织形式也不同。显然_runc_是只是一个命令行工具,_containerd_是一个长期居住守护进程。_runc_的实例不能超过底层容器进程。通常它在create调用时开始它的生命,然后只是在容器的 rootfs 中的指定文件去运行。
  • 另一方面,_containerd _可以管理超过数千个_runc_容器。它更像是一个服务器,它侦听传入请求以启动、停止或报告容器的状态。在引擎盖下_containerd_使用RunC。然而,_containerd_不仅仅是一个容器生命周期管理器。它还负责镜像管理(从注册中心拉取和推送镜像,在本地存储镜像等)、跨容器网络管理和其他一些功能。

containerd 是一个工业级标准的容器运行时,它强调简单性健壮性可移植性,containerd 可以负责干下面这些事情:

  • 管理容器的生命周期(从创建容器到销毁容器)
  • 拉取/推送容器镜像
  • 存储管理(管理镜像及容器数据的存储)
  • 调用 runc 运行容器(与 runc 等容器运行时交互)
  • 管理容器网络接口及网络

上图是 Containerd 整体的架构。由下往上,Containerd支持的操作系统和架构有 Linux、Windows 以及像 ARM 的一些平台。在这些底层的操作系统之上运行的就是底层容器运行时,其中有上文提到的runc、gVisor 等。在底层容器运行时之上的是Containerd 相关的组件,比如 Containerd 的 runtime、core、API、backend、store 还有metadata 等等。构筑在 Containerd 组件之上以及跟这些组件做交互的都是 Containerd 的 client,Kubernetes 跟 Containerd 通过 CRI 做交互时,本身也作为 Containerd 的一个 client。Containerd 本身有提供了一个 CRI,叫 ctr,不过这个命令行工具并不是很好用。

在这些组件之上就是真正的平台,Google Cloud、Docker、IBM、阿里云、微软云还有RANCHER等等都是,这些平台目前都已经支持 containerd, 并且有些已经作为自己的默认容器运行时了。

从 k8s 的角度看,选择 containerd作为运行时的组件,它调用链更短,组件更少,更稳定,占用节点资源更少。

第一条链接是k8s1.20以前的

第二条链路是k8s1.20以后的

这就是我们所说的k8s抛弃docker的原因

调用链

  • 1、Docker 作为 k8s 容器运行时,调用关系如下:
    kubelet –> docker shim (在 kubelet 进程中) –> dockerd –> containerd
  • 2、Containerd 作为 k8s 容器运行时,调用关系如下:
    kubelet –> cri plugin(在 containerd 进程中) –> containerd

3.4 扩展阅读:k8s为何抛弃docker

原因如下:
docker比k8s发布的早;
Docker 本身不兼容 CRI 接口,官方并没有实现 CRI 的打算,同时也不支持容器的一些新需求,社区想要摆脱Dockershim的高维护成本,。
k8s不能直接与docker通信,只能与 CRI 运行时通信,要与 Docker 通信,就必须使用桥接服务(dockershim),k8s要与docker通信是通过节点代理Kubelet的Dockershim(k8s社区维护的)将请求转发给管理容器的 Docker 服务。
Dockershim 一直都是 Kubernetes 为了兼容 Docker 获得市场采取的临时方案(决定)。
k8s在过去因为 Docker 的热门而选择它,现在又因为高昂的维护成本而放弃它,我们能够从这个过程中体会到容器领域的发展和进步。
对于已经统治市场的k8s来说,Docker 的支持显得非常鸡肋,移除代码也就顺理成章。
在集群中运行的容器运行时往往不需要docker这么复杂的功能,k8s需要的只是 CRI 中定义的那些接口。
不用担心,Mirantis公司未来会和Docker共同维护Dockershim,并作为一个开源组件提供;对于正式生产环境还是建议采用兼容CRI的containerd之类的底层运行时。

下面详细聊聊知识点
K8s决定在 1.20 开始放弃 Docker,并在1.21完全抛弃 Docker 的支持。

2020 年 12 月,Kubernetes 社区决定着手移除仓库中 Dockershim 相关代码,对于k8s和 Docker 两个社区来说都意义重大。

如上图所示,Kubernetes节点代理 Kubelet为了访问Docker提供的服务需要先经过社区维护的 Dockershim,Dockershim 会将请求转发给管理容器的 Docker 服务。

可扩展性

Kubernetes 通过引入新的容器运行时接口将容器管理与具体的运行时解耦,不再依赖于某个具体的运行时实现。
Kubernetes 通过下面的一系列接口为不同模块提供扩展性:

Kubernetes 在较早期的版本中就引入了 CRD、CNI、CRI 和 CSI 等接口,只有用于扩展调度器的调度框架是 Kubernetes 中比较新的特性。

Kubernetes 早在 1.3 就在代码仓库中同时支持了 rkt 和 Docker 两种运行时。

但这些代码为 Kubelet 组件的维护带来了很大的困难,不仅需要维护不同的运行时,接入新的运行时也很困难。

容器运行时接口(Container Runtime Interface、CRI)是 Kubernetes 在 1.5 中引入的新接口,Kubelet 可以通过这个新接口使用各种各样的容器运行时。

其实 CRI 的发布就意味着 Kubernetes 一定会将 Dockershim 的代码从仓库中移除。

CRI 是一系列用于管理容器运行时和镜像的 gRPC 接口,我们能在它的定义中找到 RuntimeService 和 ImageService 两个服务。

不兼容接口

与容器运行时相比,Docker 更像是一个复杂的开发者工具,它提供了从构建到运行的全套功能。

开发者可以很快地上手 Docker 并在本地运行并管理一些 Docker 容器,然而在集群中运行的容器运行时往往不需要这么复杂的功能,Kubernetes 需要的只是 CRI 中定义的那些接口。

虽然 Docker 中包含 CRI 需要的所有功能,但是都需要实现一层包装以兼容 CRI。

社区提出的很多新功能都没有办法在 Dockershim 中实现,例如 cgroups v2 以及用户命名空间。

Kubernetes 作为比较松散的开源社区,每个成员尤其是各个 SIG 的成员都只会在开源社区上花费有限的时间。

而维护 Kubelet 的 sig-node 又尤其繁忙,很多新的功能都因为维护者没有足够的精力而被搁置。

既然 Docker 社区看起来没有打算支持 Kubernetes 的 CRI 接口,维护 Dockershim 又需要花费很多精力,就能理解为什么 Kubernetes 会移除 Dockershim 了。

Kubelet 之前使用一个名为 dockershim 的模块,用以实现对 Docker 的 CRI 支持。但 Kubernetes 社区发现了与之相关的维护问题,建议大家考虑使用包含 CRI 完整实现(兼容 v1alpha1 或 v1)的可用容器运行时。

Docker 并不支持 CRI(容器运行时接口)这一 Kubernetes 运行时 API,而 Kubernetes 用户一直以来所使用的其实是名为“dockershim”的桥接服务。Dockershim 能够转换 Docker API 与 CRI。

Docker 本身也是一款非常强大的工具,可用于创建开发环境。为了解造成当前状况的原因,需要全面分析 Docker 在现有 Kubernetes 架构中的作用。

Docker公司把containerd和runc拆出来变成了开源项目,docker的底层是containerd+runc .

推荐阅读
https://containerd.io/
https://cloud.tencent.com/document/product/457/35747
https://jiajunhuang.com/articles/2018_12_22-docker_components.md.html

上一篇
下一篇
Copyright © 2022 Egon的技术星球 egonlin.com 版权所有 帮助IT小伙伴学到真正的技术