容器运行时分析
什么是RunC
Docker、Google、CoreOS 和其他供应商创建了OCI 开放容器计划。目前有两个标准文档:
- 容器运行时标准(runtime spec)
- 容器镜像标准(image spec)
OCI 对容器runtime的标准主要是指定容器的运行状态,如runtime需要提供的命令。下图是容器状态转换图:
- init 状态:这个状态并不再标准中,仅表示没有容器存在的初始状态
- creating:使用create 命令创建容器,状态为创建中
- created:容器创建出来,但是还没运行,表示镜像和配置没有错误,容器可以运行在当前平台
- running: 容器的运行状态,里面的进程处理up状态
- stopped:容器运行完成、出错或者stop命令之后,容器处于暂停状态。
RunC就是一个命令行工具,可以操作宿主机的内核来管理Namespace和CGroups。使用runc,我们可以创建,启动,停止,删除容器。
Runc的由来
runc是从docker的libcontianer中迁移出来的。实现了容器启动,停止,资源隔离等功能。Docker讲runc捐赠给OCI作为OCI容器运行时标准的参考实现。当我们用Docker运行一个容器时,经历了如下步骤:
图片来源:《Kubernetes In Action》 2.1
- Docker会检查
busybox:latest
镜像是否己经存在于本机。如果没有, Docker会从http://docker.io的Docker镜像中心拉取镜像。 - 将镜像下载到本地并解压为符合OCI标准的
bundle
文件, 将一个文件系统拆分成多层(overlay) - Docker基于这个镜像
bundle
文件创建一个容器并在容器中运行命令。echo命令打印文字到标准输出流, 然后进程终止,容器停止运行。
RunC标准化的仅仅是第三步,也是Docker 贡献出来的部分。
怎么使用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启动一个容器后,我们要怎么去跟踪他们的状态。我们要启动其他几个容器来跟踪他们的状态,其中一些需要在失败时重新启动,需要在终止时释放资源,且要从远程仓库中拉去镜像,并配置容器的网络和内存资源。如果我们要自动话这个过程,我们就需要一个容器管理器。如何用实现一个简单的runc "Building a container from scratch in Go"
Low-level & High-level
当我们讨论容器运行时的时候,我们可能会想到:runc、lxc、lmctfy、docker、rkt、cri-o。这些中的每一个都是为不同的情况构建的,实现了不同的特性功能。如 contianerd,cri-o,实际上时使用runc来运行容器,在High-level实现了镜像管理和API层。如,镜像推送,镜像拉去,镜像管理,镜像解包和API。这些被视为High-level的功能。每一个High-level的实现都囊括了low-level。
从实际出发,通常只关注于正在运行的容器的runtime通常称为“low-level”容器运行时,支持更多高级功能如(镜像管理和gRPC/REST API)的运行时通常称为“High-leve”容器运行时。二者在根本上是解决不同的问题。
Low-level容器运行时
容器是通过Linux namespace和Cgroups实现的。Namespace能让你为每个容器提供虚拟化系统资源,如文件系统,网络;Cgroups提供了限制每个容器所能使用的资源的方法,如CPU和内存。在低级别容器运行时中,其主要负责为容器建立ns和cgroups,然后在其中运行命令。一个健壮的低级容器运行时会做更多的事情,比如允许在 cgroup 上设置资源限制,设置根文件系统,以及将容器的进程 chroot 到根文件系统。
以下为几种Low-level容器运行时实现
- runc: 最常见且被广泛使用容器运行时,代表实现就是Docker runc
- runv: runv是一个基于虚拟机管理程序的运行时,它通过虚拟化guest kernel,将容器和主机隔离开,使其边界更加清晰,这种方式很容器就能帮助加强主机和容器的安全性,代表实是
kata
和Firecracker
- runsc: runc+safety 典型就是google的
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)。
以下为几种High-level容器运行时实现
dockerd
Docker 是一个容器运行时,它包含生成、打包、共享和运行容器。Docker 是C/S架构,dockerd为服务端是一个守护进程,docker client客户端负责接收命令发送给dockerd。守护程序提供了构建容器、管理映像和运行容器的大部分逻辑,以及 API。可以运行命令行客户端来发送命令并从守护程序获取信息。
现在创建一个docker容器的时候,Docker Daemon 并不能直接帮我们创建了,而是请求 containerd 来创建一个容器。当containerd 收到请求后,也不会直接去操作容器,而是创建一个叫做 containerd-shim 的进程。让这个进程去操作容器,我们指定容器进程是需要一个父进程来做状态收集、维持 stdin 等 fd 打开等工作的,假如这个父进程就是 containerd,那如果 containerd 挂掉的话,整个宿主机上所有的容器都得退出了,而引入 containerd-shim 这个垫片就可以来规避这个问题了,就是提供的live-restore的功能。这里需要注意systemd的 MountFlags=slave。
然后创建容器需要做一些 namespaces 和 cgroups 的配置,以及挂载 root 文件系统等操作。runc 就可以按照这个 OCI 文档来创建一个符合规范的容器。
真正启动容器是通过 containerd-shim 去调用 runc 来启动容器的,runc 启动完容器后本身会直接退出,containerd-shim 则会成为容器进程的父进程, 负责收集容器进程的状态, 上报给 containerd, 并在容器中 pid 为 1 的进程退出后接管容器中的子进程进行清理, 确保不会出现僵尸进程.
containerd
containerd也是从docker中分离出来的项目。与docker-runc不同的是,containerd是一个常驻守护进程,负责管理由runc创建的容器。监听上层请求,来启动、停止或者上报容器的状态。负责管理容器的生命周期。除此之外,还负责镜像的推拉和镜像的本地存储,跨容器网络管理等其他功能。containerd的底层Low-level是runc,但并不局限于runc。可以用其他Low-level实现来替代runc。contianerd是一个工业级标准容器运行时,强调简单性、健壮性和可移植性。主要负责如下事情:
- 管理容器的生命周期
- 拉去和推送容器镜像
- 存储管理
- 调用runc运行容器及其他交互
- 管理容器网络接口和请求
contianerd与docker的区别是contianerd专注容器的管理,而docker关注于用户端的使用。支持编译构建镜像并定义了镜像的格式。
从k8s的角度看,选择containerd作为运行时组建,它的调用链更短,组建更少,更稳定,占用节点资源更少。