关于容器那些你需要知道的--
接口标准
CRI
我们知道 Kubernetes 提供了一个 CRI 的容器运行时接口,那么这个 CRI 到底是什么呢?这个其实也和 Docker 的发展密切相关的。
在 Kubernetes 早期的时候,当时 Docker 实在是太火了,Kubernetes 当然会先选择支持 Docker,而且是通过硬编码的方式直接调用 Docker API,后面随着 Docker 的不断发展以及 Google 的主导,出现了更多容器运行时,Kubernetes 为了支持更多更精简的容器运行时,Google 就和红帽主导推出了 CRI 标准,用于将 Kubernetes 平台和特定的容器运行时(当然主要是为了干掉 Docker)解耦。
CRI
(Container Runtime Interface 容器运行时接口)本质上就是 Kubernetes 定义的一组与容器运行时进行交互的接口,所以只要实现了这套接口的容器运行时都可以对接到 Kubernetes 平台上来。不过 Kubernetes 推出 CRI 这套标准的时候还没有现在的统治地位,所以有一些容器运行时可能不会自身就去实现 CRI 接口,于是就有了 shim(垫片)
, 一个 shim 的职责就是作为适配器将各种容器运行时本身的接口适配到 Kubernetes 的 CRI 接口上,其中 dockershim
就是 Kubernetes 对接 Docker 到 CRI 接口上的一个垫片实现。
Kubelet 通过 gRPC 框架与容器运行时或 shim 进行通信,其中 kubelet 作为客户端,CRI shim(也可能是容器运行时本身)作为服务器。
CRI 定义的 API(https://github.com/kubernetes/kubernetes/blob/release-1.5/pkg/kubelet/api/v1alpha1/runtime/api.proto) 主要包括两个 gRPC 服务,ImageService
和 RuntimeService
,ImageService
服务主要是拉取镜像、查看和删除镜像等操作,RuntimeService
则是用来管理 Pod 和容器的生命周期,以及与容器交互的调用(exec/attach/port-forward)等操作,可以通过 kubelet 中的标志 --container-runtime-endpoint
和 --image-service-endpoint
来配置这两个服务的套接字。
kubelet cri
不过这里同样也有一个例外,那就是 Docker,由于 Docker 当时的江湖地位很高,Kubernetes 是直接内置了 dockershim
在 kubelet 中的,所以如果你使用的是 Docker 这种容器运行时的话是不需要单独去安装配置适配器之类的,当然这个举动似乎也麻痹了 Docker 公司。

dockershim
现在如果我们使用的是 Docker 的话,当我们在 Kubernetes 中创建一个 Pod 的时候,首先就是 kubelet 通过 CRI 接口调用 dockershim
,请求创建一个容器,kubelet 可以视作一个简单的 CRI Client, 而 dockershim 就是接收请求的 Server,不过他们都是在 kubelet 内置的。
dockershim
收到请求后, 转化成 Docker Daemon 能识别的请求, 发到 Docker Daemon 上请求创建一个容器,请求到了 Docker Daemon 后续就是 Docker 创建容器的流程了,去调用 containerd
,然后创建 containerd-shim
进程,通过该进程去调用 runc
去真正创建容器。
其实我们仔细观察也不难发现使用 Docker 的话其实是调用链比较长的,真正容器相关的操作其实 containerd 就完全足够了,Docker 太过于复杂笨重了,当然 Docker 深受欢迎的很大一个原因就是提供了很多对用户操作比较友好的功能,但是对于 Kubernetes 来说压根不需要这些功能,因为都是通过接口去操作容器的,所以自然也就可以将容器运行时切换到 containerd 来。
切换到containerd
切换到 containerd 可以消除掉中间环节,操作体验也和以前一样,但是由于直接用容器运行时调度容器,所以它们对 Docker 来说是不可见的。因此,你以前用来检查这些容器的 Docker 工具就不能使用了。
你不能再使用 docker ps
或 docker inspect
命令来获取容器信息。由于不能列出容器,因此也不能获取日志、停止容器,甚至不能通过 docker exec
在容器中执行命令。
当然我们仍然可以下载镜像,或者用 docker build
命令构建镜像,但用 Docker 构建、下载的镜像,对于容器运行时和 Kubernetes,均不可见。为了在 Kubernetes 中使用,需要把镜像推送到镜像仓库中去。
从上图可以看出在 containerd 1.0 中,对 CRI 的适配是通过一个单独的 CRI-Containerd
进程来完成的,这是因为最开始 containerd 还会去适配其他的系统(比如 swarm),所以没有直接实现 CRI,所以这个对接工作就交给 CRI-Containerd
这个 shim 了。
然后到了 containerd 1.1 版本后就去掉了 CRI-Containerd
这个 shim,直接把适配逻辑作为插件的方式集成到了 containerd 主进程中,现在这样的调用就更加简洁了。
与此同时 Kubernetes 社区也做了一个专门用于 Kubernetes 的 CRI 运行时 CRI-O,直接兼容 CRI 和 OCI 规范。

这个方案和 containerd 的方案显然比默认的 dockershim 简洁很多,不过由于大部分用户都比较习惯使用 Docker,所以大家还是更喜欢使用 dockershim
方案。
但是随着 CRI 方案的发展,以及其他容器运行时对 CRI 的支持越来越完善,Kubernetes 社区在2020年7月份就开始着手移除dockershim方案了,现在的移除计划是在 1.20 版本中将 kubelet 中内置的 dockershim 代码分离,将内置的 dockershim 标记为维护模式
,当然这个时候仍然还可以使用 dockershim,目标是在 1.23/1.24 版本发布没有 dockershim 的版本(代码还在,但是要默认支持开箱即用的 docker 需要自己构建 kubelet,会在某个宽限期过后从 kubelet 中删除内置的 dockershim 代码)。
那么这是否就意味着 Kubernetes 不再支持 Docker 了呢?
当然不是的,这只是废弃了内置的 dockershim
功能而已,Docker 和其他容器运行时将一视同仁,不会单独对待内置支持,如果我们还想直接使用 Docker 这种容器运行时应该怎么办呢?可以将 dockershim 的功能单独提取出来独立维护一个 cri-dockerd
即可,就类似于 containerd 1.0 版本中提供的 CRI-Containerd
,当然还有一种办法就是 Docker 官方社区将 CRI 接口内置到 Dockerd 中去实现。
但是我们也清楚 Dockerd 也是去直接调用的 Containerd,而 containerd 1.1 版本后就内置实现了 CRI,所以 Docker 也没必要再去单独实现 CRI 了,当 Kubernetes 不再内置支持开箱即用的 Docker 的以后,最好的方式当然也就是直接使用 Containerd 这种容器运行时,而且该容器运行时也已经经过了生产环境实践的。
CRI 运行时示例
以下是一些可与 Kubernetes 一起使用的 CRI 运行时。
Containerd
containerd
可能是当前最流行的 CRI 运行时。它将 CRI 实现为默认启用的插件。默认情况下,它侦听 unix 套接字,因此您可以像这样配置 crictl 以连接到 containerd:
cat <
这是一个有趣的高级运行时,因为它从 1.2 版开始通过称为“运行时处理程序”的东西支持多个低级运行时。运行时处理程序通过 CRI 中的一个字段传递,并基于该运行时处理程序containerd
运行一个名为 shim 的应用程序来启动容器。这可用于使用 runc 以外的低级运行时运行容器,例如gVisor、Kata Containers或Nabla Containers。运行时处理程序使用Kubernetes 1.12 中的 alpha 版RuntimeClass 对象在 Kubernetes API中公开。还有更多的是对containerd的垫片的概念在这里。
Docker
Docker 对 CRI 的支持是第一个开发出来的,并作为kubelet
Docker 和 Docker之间的垫片来实现。Docker 已经将它的许多功能分解为containerd
,现在通过containerd
. 安装现代版本的 Docker 后,containerd
会与它一起安装,CRI 会直接与containerd
. 因此,Docker 本身不需要支持 CRI。因此,您可以根据您的用例直接或通过 Docker 安装 containerd。
cri-o
cri-o 是一个轻量级的 CRI 运行时,作为 Kubernetes 特定的高级运行时。它支持OCI 兼容映像的管理,并从任何 OCI 兼容映像注册表中提取。它支持runc
和清除容器作为低级运行时。它在理论上支持其他 OCI 兼容的低级运行时,但依赖于与runc
OCI 命令行接口的兼容性,因此在实践中它不如containerd
shim API灵活。
cri-o 的端点/var/run/crio/crio.sock
默认为 at ,因此您可以crictl
像这样配置。
cat <
CRI 规范
CRI 是协议缓冲区和gRPC API。该规范在 kubelet 下的 Kubernetes 存储库中的protobuf 文件中定义。CRI 定义了几种远程过程调用 (RPC) 和消息类型。RPC 用于“拉取镜像”(ImageService.PullImage
)、“创建 pod”(RuntimeService.RunPodSandbox
)、“创建容器”(RuntimeService.CreateContainer
)、“启动容器”(RuntimeService.StartContainer
)、“停止容器”(RuntimeService.StopContainer
)等操作。
例如,通过 CRI 启动一个新的 Kubernetes Pod 的典型交互如下所示(以我自己的伪 gRPC 形式;每个 RPC 将获得一个更大的请求对象。为了简洁起见,我将其简化)。该RunPodSandbox
和CreateContainer
的RPC在其答复这是在后续请求中返回的ID:
ImageService.PullImage({image: "image1"})
ImageService.PullImage({image: "image2"})
podID = RuntimeService.RunPodSandbox({name: "mypod"})
id1 = RuntimeService.CreateContainer({
pod: podID,
name: "container1",
image: "image1",
})
id2 = RuntimeService.CreateContainer({
pod: podID,
name: "container2",
image: "image2",
})
RuntimeService.StartContainer({id: id1})
RuntimeService.StartContainer({id: id2})
我们可以使用该crictl
工具直接与 CRI 运行时交互。crictl
让我们直接从命令行将 gRPC 消息发送到 CRI 运行时。我们可以使用它来调试和测试 CRI 实现,而无需启动成熟的kubelet
集群或 Kubernetes 集群。您可以通过crictl
从GitHub 上的 cri-tools发布页面下载二进制文件来获取它。
您可以crictl
通过在/etc/crictl.yaml
. 在这里,您应该将运行时的 gRPC 端点指定为 Unix 套接字文件 ( unix:///path/to/file
) 或 TCP 端点 ( tcp://
)。我们将containerd
在这个例子中使用:
cat <
或者您可以在每个命令行执行时指定运行时端点:
crictl --runtime-endpoint unix:///run/containerd/containerd.sock …
让我们运行一个带有单个容器的 pod crictl
。首先,您会告诉运行时拉取nginx
您需要的图像,因为如果没有本地存储的图像,您将无法启动容器。
sudo crictl pull nginx
接下来创建一个 Pod 创建请求。您可以将其作为 JSON 文件执行。
cat <
然后创建 pod 沙箱。我们将沙箱的 ID 存储为SANDBOX_ID
.
SANDBOX_ID=$(sudo crictl runp --runtime runsc sandbox.json)
接下来我们将在 JSON 文件中创建容器创建请求。
cat <
然后我们可以在之前创建的 Pod 中创建并启动容器。
{
CONTAINER_ID=$(sudo crictl create ${SANDBOX_ID} container.json sandbox.json)
sudo crictl start ${CONTAINER_ID}
}
您可以检查正在运行的 pod
sudo crictl inspectp ${SANDBOX_ID}
...和正在运行的容器:
sudo crictl inspect ${CONTAINER_ID}
通过停止和删除容器来清理:
{
sudo crictl stop ${CONTAINER_ID}
sudo crictl rm ${CONTAINER_ID}
}
然后停止并删除 Pod:
{
sudo crictl stopp ${SANDBOX_ID}
sudo crictl rmp ${SANDBOX_ID}
}
OCI
容器技术随着 docker 的出现炙手可热,所有的技术公司都积极拥抱容器,促进了 docker 容器的繁荣发展。容器一词虽然口口相传,但却没有统一的定义,这不仅是个技术概念的问题,也给整个社区带来一个阴影:容器技术的标准到底是什么?由谁来决定?
很多人可能觉得 docker 已经成为了容器的事实标准,那我们以它作为标准问题就解决了。事情并没有那么简单,首先是否表示容器完全等同于 docker,不允许存在其他的容器运行时(比如 coreOS 推出的 rkt);其次容器上层抽象(容器集群调度,比如 kubernetes、mesos 等)和 docker 紧密耦合,docker 接口的变化将会导致它们无法使用。
总的来说,如果容器以 docker 作为标准,那么 docker 接口的变化将导致社区中所有相关工具都要更新,不然就无法使用;如果没有标准,这将导致容器实现的碎片化,出现大量的冲突和冗余。这两种情况都是社区不愿意看到的事情,OCI(Open Container Initiative) 就是在这个背景下出现的,它的使命就是推动容器标准化,容器能运行在任何的硬件和系统上,相关的组件也不必绑定在任何的容器运行时上。
官网上对 OCI 的说明如下:
An open governance structure for the express purpose of creating open industry standards around container formats and runtime.
– Open Containers Official Site
OCI 由 docker、coreos Google以及其他容器相关公司创建于 2015 年,目前主要有两个标准文档:容器运行时标准 (runtime spec)和 容器镜像标准(image spec)。
这两个协议通过 OCI runtime filesytem bundle 的标准格式连接在一起,OCI 镜像可以通过工具转换成 bundle,然后 OCI 容器引擎能够识别这个 bundle 来运行容器。

下面,我们来介绍这两个 OCI 标准。因为标准本身细节很多,而且还在不断维护和更新,如果不是容器的实现者,没有必须对每个细节都掌握。所以我以介绍概要为主,给大家有个主观的认知。
image spec
OCI 容器镜像主要包括几块内容:
- 文件系统:以 layer 保存的文件系统,每个 layer 保存了和上层之间变化的部分,layer 应该保存哪些文件,怎么表示增加、修改和删除的文件等
- config 文件:保存了文件系统的层级信息(每个层级的 hash 值,以及历史信息),以及容器运行时需要的一些信息(比如环境变量、工作目录、命令参数、mount 列表),指定了镜像在某个特定平台和系统的配置。比较接近我们使用
docker inspect
看到的内容 - manifest 文件:镜像的 config 文件索引,有哪些 layer,额外的 annotation 信息,manifest 文件中保存了很多和当前平台有关的信息
- index 文件:可选的文件,指向不同平台的 manifest 文件,这个文件能保证一个镜像可以跨平台使用,每个平台拥有不同的 manifest 文件,使用 index 作为索引
runtime spec
OCI 对容器 runtime 的标准主要是指定容器的运行状态,和 runtime 需要提供的命令。下图可以是容器状态转换图:

- init 状态:这个是我自己添加的状态,并不在标准中,表示没有容器存在的初始状态
- creating:使用
create
命令创建容器,这个过程称为创建中 - created:容器创建出来,但是还没有运行,表示镜像和配置没有错误,容器能够运行在当前平台
- running:容器的运行状态,里面的进程处于 up 状态,正在执行用户设定的任务
- stopped:容器运行完成,或者运行出错,或者
stop
命令之后,容器处于暂停状态。这个状态,容器还有很多信息保存在平台中,并没有完全被删除
Container times
什么是容器运行时?
运行时一般是用来支持程序运行的实现。例如JVM就是一种运行时。具体到容器运行时:就是运行容器所需要的一系列程序。
具体来说,运行容器中会遇到如下的一些问题:
- A container image format
- A method for building container images (Dockerfile/docker build)
- A way to manage container images (docker images, docker rm , etc.)
- A way to manage instances of containers (docker ps, docker rm , etc.)
- A way to share container images (docker push/pull)
- A way to run containers (docker run)
docker一开始是一把解决了所有这些问题,但是后来大家为了标准化,方便使用各种不同的实现,就分别进行了拆分,成立了Open Container Initiative (OCI)。推出了两个标准:
- 容器运行时标准:这个标准主要是指定容器的运行状态,和runtime需要提供的命令。docker公司给社区捐献了一个OCI容器的实现,就是 runc。
- 容器镜像标准:这个主要是说明容器的镜像的格式。这个一般是以OCI runtime filesytem bundle的形式存在。
可以看到OCI标准中的运行时只是完成了最最基础的创建、运行、销毁容器的功能,相较于容器完整的生命周期的需求,还有很多功能缺失(镜像创建、下载、管理)。
所以根据他们的功能的多少,就有只管最最基础的容器运行的low level的运行时,如runc,以及涵盖整个容器生命周期的high level运行时,如containerd,docker等。

低级和高级容器运行时?
当人们想到容器运行时,可能会想到一系列示例;runc、lxc、lmctfy、Docker、rkt、cri-o。这些中的每一个都是为不同的情况而构建的,并实现了不同的功能。有些,如 containerd 和 cri-o,实际上使用 runc 来运行容器,但在顶部实现镜像管理和 API。与 runc 的低级实现相比,你可以将这些功能(包括镜像传输、镜像管理、镜像解包和 API)视为高级功能。
考虑到这一点,你可以看到容器运行时空间相当复杂。每个运行时都涵盖了这个从低级到高级的频谱的不同部分。这是一个非常主观的图:
因此,出于实际目的,专注于运行容器的实际容器运行时通常被称为“低级容器运行时”。支持更多高级功能的运行时,如镜像管理和 gRPC/Web API,通常被称为“高级容器工具”、“高级容器运行时”或通常简称为“容器运行时”。我将它们称为“高级容器运行时”。需要注意的是,低级运行时和高级运行时是解决不同问题的根本不同的东西。
容器是使用Linux 命名空间和cgroups 实现的。命名空间让你可以为每个容器虚拟化系统资源,例如文件系统或网络。Cgroups 提供了一种方法来限制每个容器可以使用的资源量,例如 CPU 和内存。在最低级别,容器运行时负责为容器设置这些命名空间和 cgroup,然后在这些命名空间和 cgroup 中运行命令。低级运行时支持使用这些操作系统功能。
通常,想要在容器中运行应用程序的开发人员需要的不仅仅是低级运行时提供的功能。他们需要有关镜像格式、镜像管理和共享镜像的 API 和功能。这些功能由高级运行时提供。低级运行时只是没有为这种日常使用提供足够的功能。出于这个原因,真正使用低级运行时的人将是实现更高级别运行时和容器工具的开发人员。
实现低级运行时的开发人员会说,像 containerd 和 cri-o 这样的高级运行时实际上并不是容器运行时,因为从他们的角度来看,他们将运行容器的实现外包给了 runc。但是,从用户的角度来看,它们是提供运行容器能力的单一组件。一种实现可以换成另一种实现,因此从这个角度来看,将其称为运行时仍然是有意义的。尽管 containerd 和 cri-o 都使用 runc,但它们是非常不同的项目,具有不同的功能支持。
历史
在 2008 年 cgroups 发明之后,一个名为Linux Containers (LXC)的项目 开始流行起来。LXC 结合了 cgroup 和 namespace,为运行应用程序提供了一个隔离的环境。同时谷歌在 2007 年启动了自己的容器化项目,名为 Let Me Contain That For You ( LMCTFY ),主要工作在与 LXC 相同的级别。通过 LMCTFY,Google 尝试提供稳定且 API 驱动的配置,而无需用户了解 cgroups 及其内部结构的细节。
如果我们现在回顾 2013 年,我们会看到有一个名为Docker的工具 ,它构建在现有的 LXC 堆栈之上。Docker 的一项发明是用户现在能够将容器打包成镜像,以便在机器之间移动它们。Docker 是第一个尝试使容器成为标准软件单元的人,正如他们在“标准容器宣言”中所说的那样 。
几年后,Docker开始研究 libcontainer,这是 一种生成和管理容器的 Go原生方式。LMCTFY 在那段时间也被放弃了,而 LMCTFY 的核心概念和主要优点被移植到 libcontainer 和 Docker 中。
我们现在回到 2015 年,像 Kubernetes 这样的项目达到了 1.0 版。在那段时间里有很多事情在进行:CNCF 是作为Linux 基金会的一部分 成立的 ,其目标是推广容器。该 开口容器倡议(OCI)成立2015年好,因为容器的生态系统在一个开放的治理结构。

他们的主要目标是围绕容器格式和运行时创建开放的行业标准。我们现在处于一种使用容器的状态,就其受欢迎程度而言,与经典的虚拟机 (VM) 并排使用。需要一个关于容器应该如何运行的规范,这导致了 OCI 运行时规范。运行时开发人员现在应该能够拥有一个定义良好的 API 来开发他们的容器运行时。在那段时间里,libcontainer 项目被捐赠给了 OCI,而一个名为 runc 的新工具作为其中的一部分诞生了。使用 runc,现在可以直接与 libcontainer 交互,解释 OCI 运行时规范并从中运行容器。
时至今日,runc 是容器生态系统中最受欢迎的项目之一,并被用于许多其他项目,如 containerd(由 Docker 使用)、CRI-O 和 podman。其他项目也采用了 OCI 运行时规范。例如,Kata Containers 可以构建和运行安全容器,包括感觉和执行起来像容器的轻量级虚拟机,但使用硬件虚拟化技术作为第二层防御提供更强的工作负载隔离。
CRI-O

2016年,Kubernetes 项目推出了容器运行时接口Container Runtime Interface(CRI):该名称来自 CRI 和开放容器计划(OCI), 这是一个插件接口,它让 kubelet(用于创建 pod 和启动容器的集群节点代理)有使用不同的兼容 OCI 的容器运行时的能力,而不需要重新编译 Kubernetes。在这项工作的基础上,CRI-O 项目(原名 OCID)准备为 Kubernetes 提供轻量级的运行时。
CRI-O 允许你直接从 Kubernetes 运行容器,而不需要任何不必要的代码或工具。只要容器符合 OCI 标准,CRI-O 就可以运行它,去除外来的工具,并让容器做其擅长的事情:加速你的新一代原生云程序。
在引入 CRI 之前,Kubernetes 通过“一个内部的易失性接口”与特定的容器运行时相关联。这导致了上游 Kubernetes 社区以及在编排平台之上构建解决方案的供应商的大量维护开销。
使用 CRI,Kubernetes 可以与容器运行时无关。容器运行时的提供者不需要实现 Kubernetes 已经提供的功能。这是社区的胜利,因为它让项目独立进行,同时仍然可以共同工作。
当 Kubernetes 需要运行容器时,它会与 CRI-O 进行通信,CRI-O 守护程序与 runc(或另一个符合 OCI 标准的运行时)一起启动容器。当 Kubernetes 需要停止容器时,CRI-O 会来处理。它只是在幕后管理 Linux 容器,以便用户不需要担心这个关键的容器编排。

LXC(Linux Container)
容器相当于你运行了一个接近于裸机的虚拟机。这项技术始于2008年,LXC的大部分功能来自于Solaris容器(又叫做Solaries Zones)以及之前的FreeBSD jails技术。 LXC并不是创建一个成熟的虚拟机,而是创建了一个拥有自己进程程和网络空间的虚拟环境,使用命名空间来强制进程隔离并利用内核的控制组(cgroups)功能,该功能可以限制,计算和隔离一个或多个进程的CPU,内存,磁盘I / O和网络使用情况。 您可以将这种用户空间框架想像成是chroot
的高级形式。
chroot
是一个改变当前运行进程以及其子进程的根目录的操作。一个运行在这种环境的程序无法访问根目录外的文件和命令。
注意:LXC使用命名空间来强制进程隔离,同时利用内核的控制组来计算以及限制一个或多个进程的CPU,内存,磁盘I / O和网络使用。
但容器究竟是什么?简短的答案是容器将软件应用程序与操作系统分离,为用户提供干净且最小的Linux环境,与此同时在一个或多个隔离的“容器”中运行其他所有内容。容器的目的是启动一组有限数量的应用程序或服务(通常称为微服务),并使它们在独立的沙盒环境中运行。
图1 对比在传统环境以及容器环境运行的应用
这种隔离可防止在给定容器内运行的进程监视或影响在另一个容器中运行的进程。此外,这些集装箱化服务不会影响或干扰主机。能够将分散在多个物理服务器上的许多服务合并为一个的想法是数据中心选择采用该技术的众多原因之一。
容器有以下几个特点:
- 安全性:容器里可以运行网络服务,这可以限制安全漏洞或违规行为造成的损害。那些成功利用那个容器的一个或多个应用的安全漏洞的入侵者将会被限制在只能在那个容器中做一些操作。
- 隔离性:容器允许在同一物理机器上部署一个或多个应用程序,即使这些应用程序必须在不同的域下运行,每个域都需要独占访问其各自的资源。例如,通过将每个容器关联的不同IP地址,在不同容器中运行的多个应用程序可以绑定到同一物理网络接口。
- 虚拟化和透明性:容器为系统提供虚拟化环境,这个环境可以隐藏或限制系统底层的物理设备或系统配置的可见性。容器背后的一般原则是避免更改运行应用程序的环境,但解决安全性或隔离问题除外。
使用LXC的工具
对于大多数现代Linux发行版,内核都启用了控制组,但您很可能仍需要安装LXC工具。
如果您使用的是Red Hat或CentOS,则需要先安装EPEL仓库。对于其他发行版,例如Ubuntu或Debian,只需键入:
$ sudo apt-get install lxc
现在,在开始使用这些工具之前,您需要配置您的环境。在此之前,您需要验证当前用户是否同时在/ etc / subuid和/ etc / subgid中定义了uid和gid:
$ cat /etc/subuid
petros:100000:65536
$ cat /etc/subgid
petros:100000:65536
如果~/.config/lxc directory
不存在,则创建该目录,并且把配置文件/etc/lxc/default.conf
复制到~/.config/lxc/default.conf.
,将以下两行添加到文件末尾:
lxc.id_map = u 0 100000 65536
lxc.id_map = g 0 100000 65536
结果如下:
$ cat ~/.config/lxc/default.conf
lxc.network.type = veth
lxc.network.link = lxcbr0
lxc.network.flags = up
lxc.network.hwaddr = 00:16:3e:xx:xx:xx
lxc.id_map = u 0 100000 65536
lxc.id_map = g 0 100000 65536
将以下命令添加到/etc/lxc/lxc-usernet
文件末尾(把第一列换成你的username):
petros veth lxcbr0 10
最快使这些配置生效的方法是重启节点或者将用户登出再登入。
重新登录后,请验证当前是否已加载veth网络驱动程序:
$ lsmod|grep veth
veth 16384 0
如果没有,请输入:
$ sudo modprobe veth
现在您可以使用LXC工具集来下载,运行,管理Linux容器。
接下来,下载容器镜像并将其命名为“example-container”。当您键入以下命令时,您将看到一长串许多Linux发行版和版本支持的容器:
$ sudo lxc-create -t download -n example-container
将会有三个弹出框让您分别选择发行版名称(distribution),版本号(release)以及架构(architecture)。请选择以下三个选项:
Distribution: ubuntu
Release: xenial
Architecture: amd64
选择后点击Enter
,rootfs将在本地下载并配置。出于安全原因,每个容器不附带OpenSSH服务器或用户帐户。同时也不会提供默认的root密码。要更改root密码并登录,必须在容器目录路径中运行lxc-attach或chroot(在启动之后)。
启动容器:
$ sudo lxc-start -n example-container -d
-d
选项表示隐藏容器,它会在后台运行。如果您想要观察boot的过程,只需要将-d
换成-F
。那么它将在前台运行,登录框出现时结束。
你可能会遇到如下错误:
$ sudo lxc-start -n example-container -d
lxc-start: tools/lxc_start.c: main: 366 The container
failed to start.
lxc-start: tools/lxc_start.c: main: 368 To get more details,
run the container in foreground mode.
lxc-start: tools/lxc_start.c: main: 370 Additional information
can be obtained by setting the --logfile and --logpriority
options.
如果你遇到了,您需要通过在前台运行lxc-start服务来调试它:
$ sudo lxc-start -n example-container -F
lxc-start: conf.c: instantiate_veth: 2685 failed to create veth
pair (vethQ4NS0B and vethJMHON2): Operation not supported
lxc-start: conf.c: lxc_create_network: 3029 failed to
create netdev
lxc-start: start.c: lxc_spawn: 1103 Failed to create
the network.
lxc-start: start.c: __lxc_start: 1358 Failed to spawn
container "example-container".
lxc-start: tools/lxc_start.c: main: 366 The container failed
to start.
lxc-start: tools/lxc_start.c: main: 370 Additional information
can be obtained by setting the --logfile and --logpriority
options.
从以上示例,你可以看到模块veth
没有被引入,在引入之后,将会解决这个问题。
之后,打开第二个terminal窗口,验证容器的状态。
$ sudo lxc-info -n example-container
Name: example-container
State: RUNNING
PID: 1356
IP: 10.0.3.28
CPU use: 0.29 seconds
BlkIO use: 16.80 MiB
Memory use: 29.02 MiB
KMem use: 0 bytes
Link: vethPRK7YU
TX bytes: 1.34 KiB
RX bytes: 2.09 KiB
Total bytes: 3.43 KiB
也可以通过另一种方式来查看所有安装的容器,运行命令:
$ sudo lxc-ls -f
NAME STATE AUTOSTART GROUPS IPV4 IPV6
example-container RUNNING 0 - 10.0.3.28 -
但是问题是你仍然不能登录进去,你只需要直接attach到正在运行的容器,创建你的用户,使用passwd
命令改变相关的密码。
$ sudo lxc-attach -n example-container
root@example-container:/#
root@example-container:/# useradd petros
root@example-container:/# passwd petros
Enter new UNIX password:
Retype new UNIX password:
passwd: password updated successfully
更改密码后,您将能够从控制台直接登录到容器,而无需使用lxc-attach
命令:
$ sudo lxc-console -n example-container
如果要通过网络连接到此运行容器,请安装OpenSSH服务器:
root@example-container:/# apt-get install openssh-server
抓取容器的本地IP地址:
root@example-container:/# ip addr show eth0|grep inet
inet 10.0.3.25/24 brd 10.0.3.255 scope global eth0
inet6 fe80::216:3eff:fed8:53b4/64 scope link
然后在主机的新的控制台窗口中键入:
$ ssh 10.0.3.25
瞧!您现在可以SSH到正在运行的容器并键入您的用户名和密码。
在主机系统上,而不是在容器内,可以观察在启动容器后启动和运行的LXC进程:
$ ps aux|grep lxc|grep -v grep
root 861 0.0 0.0 234772 1368 ? Ssl 11:01
?0:00 /usr/bin/lxcfs /var/lib/lxcfs/
lxc-dns+ 1155 0.0 0.1 52868 2908 ? S 11:01
?0:00 dnsmasq -u lxc-dnsmasq --strict-order
?--bind-interfaces --pid-file=/run/lxc/dnsmasq.pid
?--listen-address 10.0.3.1 --dhcp-range 10.0.3.2,10.0.3.254
?--dhcp-lease-max=253 --dhcp-no-override
?--except-interface=lo --interface=lxcbr0
?--dhcp-leasefile=/var/lib/misc/dnsmasq.lxcbr0.leases
?--dhcp-authoritative
root 1196 0.0 0.1 54484 3928 ? Ss 11:01
?0:00 [lxc monitor] /var/lib/lxc example-container
root 1658 0.0 0.1 54780 3960 pts/1 S+ 11:02
?0:00 sudo lxc-attach -n example-container
root 1660 0.0 0.2 54464 4900 pts/1 S+ 11:02
?0:00 lxc-attach -n example-container
要停止容器,请键入(在主机):
$ sudo lxc-stop -n example-container
停止后,验证容器的状态:
$ sudo lxc-ls -f
NAME STATE AUTOSTART GROUPS IPV4 IPV6
example-container STOPPED 0 - - -
$ sudo lxc-info -n example-container
Name: example-container
State: STOPPED
要彻底销毁容器 - 即从主机system—type清除它:
$ sudo lxc-destroy -n example-container
Destroyed container example-container
销毁后,可以验证是否已将其删除:
$ sudo lxc-info -n example-container
example-container doesn't exist
$ sudo lxc-ls -f
注意:如果您尝试销毁正在运行的容器,该命令将失败并告知您容器仍在运行:
$ sudo lxc-destroy -n example-container
example-container is running
在销毁容器前必须先停止它。
高级配置
有时,可能需要配置一个或多个容器来完成一个或多个任务。 LXC通过让管理员修改位于/var/lib/ lxc中的容器配置文件来简化这一过程:
$ sudo su
# cd /var/lib/lxc
# ls
example-container
容器的父目录将包含至少两个文件:1)容器配置文件和2)容器的整个rootfs:
# cd example-container/
# ls
config rootfs
假设您想要在主机系统启动时自动启动名称为example-container
的容器。那么您需要将以下行添加到容器的配置文件`/ var / lib / lxc / example-container / config`的尾部:
# Enable autostart
lxc.start.auto = 1
重新启动容器或重新启动主机系统后,您应该看到如下内容:
$ sudo lxc-ls -f
NAME STATE AUTOSTART GROUPS IPV4 IPV6
example-container RUNNING 1 - 10.0.3.25 -
注意 AUTOSTART
字段现在被设置为“1”。
如果在容器启动时,您希望容器绑定装载主机上的目录路径,请将以下行添加到同一文件的尾部:
# 将挂载系统路径绑定到本地路径
lxc.mount.entry = /mnt mnt none bind 0 0
通过上面的示例,当容器重新启动时,您将看到容器本地的 / mnt目录可访问的主机/ mnt目录的内容。
特权与非特权容器
您经常会发现在与LXC相关的内容中讨论特权容器和非特权容器的概念。但它们究竟是什么呢?这个概念非常简单,并且LXC容器可以在任一配置下运行。
根据设计,无特权容器被认为比特权容器更安全,更保密。无特权容器运行时,容器的root UID映射到主机系统上的非root UID。这使得攻击者即使破解了容器,也难以获得对底层主机的root权限。简而言之,如果攻击者设法通过已知的软件漏洞破坏了您的容器,他们会立即发现自己无法获取任何主机权限。
特权容器可能使系统暴露于此类攻击。这就是为什么我们最好在特权模式下运行尽量少的容器。确定需要特权访问的容器,并确保付出额外的努力来定期更新并以其他方式锁定它们。
Docker
自首次推出以来,Docker已经风靡Linux计算世界。 Docker是一种Apache许可的开源容器化技术,旨在自动化在容器内创建和部署微服务这类重复性任务。 Docker将容器视为非常轻量级和模块化的虚拟机。最初,Docker是在LXC之上构建的,但它已经远离了这种依赖,从而带来了更好的开发人员和用户体验。与LXC非常相似,Docker继续使用内核cgroup
子系统。该技术不仅仅是运行容器,还简化了创建容器,构建映像,共享构建的映像以及对其进行版本控制的过程。
从 Docker 1.11 版本开始,Docker 容器运行就不是简单通过 Docker Daemon 来启动了,而是通过集成 containerd、runc 等多个组件来完成的。虽然 Docker Daemon 守护进程模块在不停的重构,但是基本功能和定位没有太大的变化,一直都是 CS 架构,守护进程负责和 Docker Client 端交互,并管理 Docker 镜像和容器。现在的架构中组件 containerd 就会负责集群节点上容器的生命周期管理,并向上为 Docker Daemon 提供 gRPC 接口。

当我们要创建一个容器的时候,现在 Docker Daemon 并不能直接帮我们创建了,而是请求 containerd
来创建一个容器,containerd 收到请求后,也并不会直接去操作容器,而是创建一个叫做 containerd-shim
的进程,让这个进程去操作容器,我们指定容器进程是需要一个父进程来做状态收集、维持 stdin 等 fd 打开等工作的,假如这个父进程就是 containerd,那如果 containerd 挂掉的话,整个宿主机上所有的容器都得退出了,而引入 containerd-shim
这个垫片就可以来规避这个问题了。
然后创建容器需要做一些 namespaces 和 cgroups 的配置,以及挂载 root 文件系统等操作,这些操作其实已经有了标准的规范,那就是 OCI(开放容器标准),runc
就是它的一个参考实现(Docker 被逼无耐将 libcontainer
捐献出来改名为 runc
的),这个标准其实就是一个文档,主要规定了容器镜像的结构、以及容器需要接收哪些操作指令,比如 create、start、stop、delete 等这些命令。runc
就可以按照这个 OCI 文档来创建一个符合规范的容器,既然是标准肯定就有其他 OCI 实现,比如 Kata、gVisor 这些容器运行时都是符合 OCI 标准的。
所以真正启动容器是通过 containerd-shim
去调用 runc
来启动容器的,runc
启动完容器后本身会直接退出,containerd-shim
则会成为容器进程的父进程, 负责收集容器进程的状态, 上报给 containerd, 并在容器中 pid 为 1 的进程退出后接管容器中的子进程进行清理, 确保不会出现僵尸进程。
而 Docker 将容器操作都迁移到 containerd
中去是因为当前做 Swarm,想要进军 PaaS 市场,做了这个架构切分,让 Docker Daemon 专门去负责上层的封装编排,当然后面的结果我们知道 Swarm 在 Kubernetes 面前是惨败,然后 Docker 公司就把 containerd
项目捐献给了 CNCF 基金会,这个也是现在的 Docker 架构。
Containerd
我们知道很早之前的 Docker Engine 中就有了 containerd,只不过现在是将 containerd 从 Docker Engine 里分离出来,作为一个独立的开源项目,目标是提供一个更加开放、稳定的容器运行基础设施。分离出来的 containerd 将具有更多的功能,涵盖整个容器运行时管理的所有需求,提供更强大的支持。
containerd 是一个工业级标准的容器运行时,它强调简单性、健壮性和可移植性,containerd 可以负责干下面这些事情:
- 管理容器的生命周期(从创建容器到销毁容器)
- 拉取/推送容器镜像
- 存储管理(管理镜像及容器数据的存储)
- 调用 runc 运行容器(与 runc 等容器运行时交互)
- 管理容器网络接口及网络
containerd 可用作 Linux 和 Windows 的守护程序,它管理其主机系统完整的容器生命周期,从镜像传输和存储到容器执行和监测,再到底层存储到网络附件等等。
containerd 架构
上图是 containerd 官方提供的架构图,可以看出 containerd 采用的也是 C/S 架构,服务端通过 unix domain socket 暴露低层的 gRPC API 接口出去,客户端通过这些 API 管理节点上的容器,每个 containerd 只负责一台机器,Pull 镜像,对容器的操作(启动、停止等),网络,存储都是由 containerd 完成。具体运行容器由 runc 负责,实际上只要是符合 OCI 规范的容器都可以支持。
为了解耦,containerd 将系统划分成了不同的组件,每个组件都由一个或多个模块协作完成(Core 部分),每一种类型的模块都以插件的形式集成到 Containerd 中,而且插件之间是相互依赖的,例如,上图中的每一个长虚线的方框都表示一种类型的插件,包括 Service Plugin、Metadata Plugin、GC Plugin、Runtime Plugin 等,其中 Service Plugin 又会依赖 Metadata Plugin、GC Plugin 和 Runtime Plugin。每一个小方框都表示一个细分的插件,例如 Metadata Plugin 依赖 Containers Plugin、Content Plugin 等。比如:
Content Plugin
: 提供对镜像中可寻址内容的访问,所有不可变的内容都被存储在这里。Snapshot Plugin
: 用来管理容器镜像的文件系统快照,镜像中的每一层都会被解压成文件系统快照,类似于 Docker 中的 graphdriver。
总体来看 containerd 可以分为三个大块:Storage、Metadata 和 Runtime。

? containerd 架构
安装
这里我使用的系统是 Linux Mint 20.2
,首先需要安装 seccomp
依赖:
apt-get update
apt-get install libseccomp2 -y
由于 containerd 需要调用 runc,所以我们也需要先安装 runc,不过 containerd 提供了一个包含相关依赖的压缩包 cri-containerd-cni-${VERSION}.${OS}-${ARCH}.tar.gz
,可以直接使用这个包来进行安装。首先从 release 页面下载最新版本的压缩包,当前为 1.5.5 版本:
wget https://github.com/containerd/containerd/releases/download/v1.5.5/cri-containerd-cni-1.5.5-linux-amd64.tar.gz
# 如果有限制,也可以替换成下面的 URL 加速下载
# wget https://download.fastgit.org/containerd/containerd/releases/download/v1.5.5/cri-containerd-cni-1.5.5-linux-amd64.tar.gz
可以通过 tar 的 -t
选项直接看到压缩包中包含哪些文件:
tar -tf cri-containerd-cni-1.4.3-linux-amd64.tar.gz
etc/
etc/cni/
etc/cni/net.d/
etc/cni/net.d/10-containerd-net.conflist
etc/crictl.yaml
etc/systemd/
etc/systemd/system/
etc/systemd/system/containerd.service
usr/
.....
直接将压缩包解压到系统的各个目录中:
tar -C / -xzf cri-containerd-cni-1.5.5-linux-amd64.tar.gz
当然要记得将 /usr/local/bin
和 /usr/local/sbin
追加到 ~/.bashrc
文件的 PATH
环境变量中:
export PATH=$PATH:/usr/local/bin:/usr/local/sbin
然后执行下面的命令使其立即生效:
source ~/.bashrc
containerd 的默认配置文件为 /etc/containerd/config.toml
,我们可以通过如下所示的命令生成一个默认的配置:
mkdir /etc/containerd
containerd config default > /etc/containerd/config.toml
由于上面我们下载的 containerd 压缩包中包含一个 etc/systemd/system/containerd.service
的文件,这样我们就可以通过 systemd 来配置 containerd 作为守护进程运行了,内容如下所示:
cat /etc/systemd/system/containerd.service
[Unit]
Description=containerd container runtime
Documentation=https://containerd.io
After=network.target local-fs.target
[Service]
ExecStartPre=-/sbin/modprobe overlay
ExecStart=/usr/local/bin/containerd
Type=notify
Delegate=yes
KillMode=process
Restart=always
RestartSec=5
# Having non-zero Limit*s causes performance problems due to accounting overhead
# in the kernel. We recommend using cgroups to do container-local accounting.
LimitNPROC=infinity
LimitCORE=infinity
LimitNOFILE=1048576
# Comment TasksMax if your systemd version does not supports it.
# Only systemd 226 and above support this version.
TasksMax=infinity
OOMScoreAdjust=-999
[Install]
WantedBy=multi-user.target
这里有两个重要的参数:
-
Delegate
: 这个选项允许 containerd 以及运行时自己管理自己创建容器的 cgroups。如果不设置这个选项,systemd 就会将进程移到自己的 cgroups 中,从而导致 containerd 无法正确获取容器的资源使用情况。 -
KillMode
: 这个选项用来处理 containerd 进程被杀死的方式。默认情况下,systemd 会在进程的 cgroup 中查找并杀死 containerd 的所有子进程。KillMode 字段可以设置的值如下。 -
control-group
(默认值):当前控制组里面的所有子进程,都会被杀掉process
:只杀主进程mixed
:主进程将收到 SIGTERM 信号,子进程收到 SIGKILL 信号none
:没有进程会被杀掉,只是执行服务的 stop 命令
我们需要将 KillMode 的值设置为 process,这样可以确保升级或重启 containerd 时不杀死现有的容器。
现在我们就可以启动 containerd 了,直接执行下面的命令即可:
systemctl enable containerd --now
启动完成后就可以使用 containerd 的本地 CLI 工具 ctr
了,比如查看版本:

ctr version
配置
我们首先来查看下上面默认生成的配置文件 /etc/containerd/config.toml
:
disabled_plugins = []
imports = []
oom_score = 0
plugin_dir = ""
required_plugins = []
root = "/var/lib/containerd"
state = "/run/containerd"
version = 2
[cgroup]
path = ""
[debug]
address = ""
format = ""
gid = 0
level = ""
uid = 0
[grpc]
address = "/run/containerd/containerd.sock"
gid = 0
max_recv_message_size = 16777216
max_send_message_size = 16777216
tcp_address = ""
tcp_tls_cert = ""
tcp_tls_key = ""
uid = 0
[metrics]
address = ""
grpc_histogram = false
[plugins]
[plugins."io.containerd.gc.v1.scheduler"]
deletion_threshold = 0
mutation_threshold = 100
pause_threshold = 0.02
schedule_delay = "0s"
startup_delay = "100ms"
[plugins."io.containerd.grpc.v1.cri"]
disable_apparmor = false
disable_cgroup = false
disable_hugetlb_controller = true
disable_proc_mount = false
disable_tcp_service = true
enable_selinux = false
enable_tls_streaming = false
ignore_image_defined_volumes = false
max_concurrent_downloads = 3
max_container_log_line_size = 16384
netns_mounts_under_state_dir = false
restrict_oom_score_adj = false
sandbox_image = "k8s.gcr.io/pause:3.5"
selinux_category_range = 1024
stats_collect_period = 10
stream_idle_timeout = "4h0m0s"
stream_server_address = "127.0.0.1"
stream_server_port = "0"
systemd_cgroup = false
tolerate_missing_hugetlb_controller = true
unset_seccomp_profile = ""
[plugins."io.containerd.grpc.v1.cri".cni]
bin_dir = "/opt/cni/bin"
conf_dir = "/etc/cni/net.d"
conf_template = ""
max_conf_num = 1
[plugins."io.containerd.grpc.v1.cri".containerd]
default_runtime_name = "runc"
disable_snapshot_annotations = true
discard_unpacked_layers = false
no_pivot = false
snapshotter = "overlayfs"
[plugins."io.containerd.grpc.v1.cri".containerd.default_runtime]
base_runtime_spec = ""
container_annotations = []
pod_annotations = []
privileged_without_host_devices = false
runtime_engine = ""
runtime_root = ""
runtime_type = ""
[plugins."io.containerd.grpc.v1.cri".containerd.default_runtime.options]
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes]
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
base_runtime_spec = ""
container_annotations = []
pod_annotations = []
privileged_without_host_devices = false
runtime_engine = ""
runtime_root = ""
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
BinaryName = ""
CriuImagePath = ""
CriuPath = ""
CriuWorkPath = ""
IoGid = 0
IoUid = 0
NoNewKeyring = false
NoPivotRoot = false
Root = ""
ShimCgroup = ""
SystemdCgroup = false
[plugins."io.containerd.grpc.v1.cri".containerd.untrusted_workload_runtime]
base_runtime_spec = ""
container_annotations = []
pod_annotations = []
privileged_without_host_devices = false
runtime_engine = ""
runtime_root = ""
runtime_type = ""
[plugins."io.containerd.grpc.v1.cri".containerd.untrusted_workload_runtime.options]
[plugins."io.containerd.grpc.v1.cri".image_decryption]
key_model = "node"
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = ""
[plugins."io.containerd.grpc.v1.cri".registry.auths]
[plugins."io.containerd.grpc.v1.cri".registry.configs]
[plugins."io.containerd.grpc.v1.cri".registry.headers]
[plugins."io.containerd.grpc.v1.cri".registry.mirrors]
[plugins."io.containerd.grpc.v1.cri".x509_key_pair_streaming]
tls_cert_file = ""
tls_key_file = ""
[plugins."io.containerd.internal.v1.opt"]
path = "/opt/containerd"
[plugins."io.containerd.internal.v1.restart"]
interval = "10s"
[plugins."io.containerd.metadata.v1.bolt"]
content_sharing_policy = "shared"
[plugins."io.containerd.monitor.v1.cgroups"]
no_prometheus = false
[plugins."io.containerd.runtime.v1.linux"]
no_shim = false
runtime = "runc"
runtime_root = ""
shim = "containerd-shim"
shim_debug = false
[plugins."io.containerd.runtime.v2.task"]
platforms = ["linux/amd64"]
[plugins."io.containerd.service.v1.diff-service"]
default = ["walking"]
[plugins."io.containerd.snapshotter.v1.aufs"]
root_path = ""
[plugins."io.containerd.snapshotter.v1.btrfs"]
root_path = ""
[plugins."io.containerd.snapshotter.v1.devmapper"]
async_remove = false
base_image_size = ""
pool_name = ""
root_path = ""
[plugins."io.containerd.snapshotter.v1.native"]
root_path = ""
[plugins."io.containerd.snapshotter.v1.overlayfs"]
root_path = ""
[plugins."io.containerd.snapshotter.v1.zfs"]
root_path = ""
[proxy_plugins]
[stream_processors]
[stream_processors."io.containerd.ocicrypt.decoder.v1.tar"]
accepts = ["application/vnd.oci.image.layer.v1.tar+encrypted"]
args = ["--decryption-keys-path", "/etc/containerd/ocicrypt/keys"]
env = ["OCICRYPT_KEYPROVIDER_CONFIG=/etc/containerd/ocicrypt/ocicrypt_keyprovider.conf"]
path = "ctd-decoder"
returns = "application/vnd.oci.image.layer.v1.tar"
[stream_processors."io.containerd.ocicrypt.decoder.v1.tar.gzip"]
accepts = ["application/vnd.oci.image.layer.v1.tar+gzip+encrypted"]
args = ["--decryption-keys-path", "/etc/containerd/ocicrypt/keys"]
env = ["OCICRYPT_KEYPROVIDER_CONFIG=/etc/containerd/ocicrypt/ocicrypt_keyprovider.conf"]
path = "ctd-decoder"
returns = "application/vnd.oci.image.layer.v1.tar+gzip"
[timeouts]
"io.containerd.timeout.shim.cleanup" = "5s"
"io.containerd.timeout.shim.load" = "5s"
"io.containerd.timeout.shim.shutdown" = "3s"
"io.containerd.timeout.task.state" = "2s"
[ttrpc]
address = ""
gid = 0
uid = 0
这个配置文件比较复杂,我们可以将重点放在其中的 plugins
配置上面,仔细观察我们可以发现每一个顶级配置块的命名都是 plugins."io.containerd.xxx.vx.xxx"
这种形式,每一个顶级配置块都表示一个插件,其中 io.containerd.xxx.vx
表示插件的类型,vx
后面的 xxx
表示插件的 ID,我们可以通过 ctr
查看插件列表:
ctr plugin ls
TYPE ID PLATFORMS STATUS
io.containerd.content.v1 content - ok
io.containerd.snapshotter.v1 aufs linux/amd64 ok
io.containerd.snapshotter.v1 btrfs linux/amd64 skip
io.containerd.snapshotter.v1 devmapper linux/amd64 error
io.containerd.snapshotter.v1 native linux/amd64 ok
io.containerd.snapshotter.v1 overlayfs linux/amd64 ok
io.containerd.snapshotter.v1 zfs linux/amd64 skip
.....
顶级配置块下面的子配置块表示该插件的各种配置,比如 cri 插件下面就分为 containerd、cni 和 registry 的配置,而 containerd 下面又可以配置各种 runtime,还可以配置默认的 runtime。比如现在我们要为镜像配置一个加速器,那么就需要在 cri 配置块下面的 registry
配置块下面进行配置 registry.mirrors
:
[plugins."io.containerd.grpc.v1.cri".registry]
[plugins."io.containerd.grpc.v1.cri".registry.mirrors]
[plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"]
endpoint = ["https://bqr1dr1n.mirror.aliyuncs.com"]
[plugins."io.containerd.grpc.v1.cri".registry.mirrors."k8s.gcr.io"]
endpoint = ["https://registry.aliyuncs.com/k8sxio"]
registry.mirrors."xxx"
: 表示需要配置 mirror 的镜像仓库,例如registry.mirrors."docker.io"
表示配置 docker.io 的 mirror。endpoint
: 表示提供 mirror 的镜像加速服务,比如我们可以注册一个阿里云的镜像服务来作为 docker.io 的 mirror。
另外在默认配置中还有两个关于存储的配置路径:
root = "/var/lib/containerd"
state = "/run/containerd"
其中 root
是用来保存持久化数据,包括 Snapshots, Content, Metadata 以及各种插件的数据,每一个插件都有自己单独的目录,Containerd 本身不存储任何数据,它的所有功能都来自于已加载的插件。
而另外的 state
是用来保存运行时的临时数据的,包括 sockets、pid、挂载点、运行时状态以及不需要持久化的插件数据。
使用
我们知道 Docker CLI 工具提供了需要增强用户体验的功能,containerd 同样也提供一个对应的 CLI 工具:ctr
,不过 ctr 的功能没有 docker 完善,但是关于镜像和容器的基本功能都是有的。接下来我们就先简单介绍下 ctr
的使用。
帮助
直接输入 ctr
命令即可获得所有相关的操作命令使用方式
镜像操作
拉取镜像
拉取镜像可以使用 ctr image pull
来完成,比如拉取 Docker Hub 官方镜像 nginx:alpine
,需要注意的是镜像地址需要加上 docker.io
Host 地址:
ctr image pull docker.io/library/nginx:alpine
也可以使用 --platform
选项指定对应平台的镜像。当然对应的也有推送镜像的命令 ctr image push
,如果是私有镜像则在推送的时候可以通过 --user
来自定义仓库的用户名和密码。
列出本地镜像
ctr image ls
使用 -q(--quiet)
选项可以只打印镜像名称。
检测本地镜像
ctr image check
主要查看其中的 STATUS
,complete
表示镜像是完整可用的状态。
重新打标签
同样的我们也可以重新给指定的镜像打一个 Tag:
ctr image tag docker.io/library/nginx:alpine harbor.k8s.local/course/nginx:alpine
harbor.k8s.local/course/nginx:alpine
ctr image ls -q
docker.io/library/nginx:alpine
harbor.k8s.local/course/nginx:alpine
删除镜像
不需要使用的镜像也可以使用 ctr image rm
进行删除:
ctr image rm harbor.k8s.local/course/nginx:alpine
harbor.k8s.local/course/nginx:alpine
ctr image ls -q
docker.io/library/nginx:alpine
加上 --sync
选项可以同步删除镜像和所有相关的资源。
将镜像挂载到主机目录
ctr image mount docker.io/library/nginx:alpine /mnt
sha256:c3554b2d61e3c1cffcaba4b4fa7651c644a3354efaafa2f22cb53542f6c600dc
/mnt
tree -L 1 /mnt
/mnt
├── bin
├── dev
├── docker-entrypoint.d
├── docker-entrypoint.sh
├── etc
├── home
├── lib
├── media
├── mnt
├── opt
├── proc
├── root
├── run
├── sbin
├── srv
├── sys
├── tmp
├── usr
└── var
18 directories, 1 file
将镜像从主机目录上卸载
ctr image unmount /mnt
/mnt
将镜像导出为压缩包
ctr image export nginx.tar.gz docker.io/library/nginx:alpine
从压缩包导入镜像
ctr image import nginx.tar.gz
容器操作
容器相关操作可以通过 ctr container
获取。
创建容器
ctr container create docker.io/library/nginx:alpine nginx
列出容器
ctr container ls
CONTAINER IMAGE RUNTIME
nginx docker.io/library/nginx:alpine io.containerd.runc.v2
同样可以加上 -q
选项精简列表内容:
ctr container ls -q
nginx
查看容器详细配置
类似于 docker inspect
功能。
ctr container info nginx
{
"ID": "nginx",
"Labels": {
"io.containerd.image.config.stop-signal": "SIGQUIT"
},
"Image": "docker.io/library/nginx:alpine",
"Runtime": {
"Name": "io.containerd.runc.v2",
"Options": {
"type_url": "containerd.runc.v1.Options"
}
},
"SnapshotKey": "nginx",
"Snapshotter": "overlayfs",
"CreatedAt": "2021-08-12T08:23:13.792871558Z",
"UpdatedAt": "2021-08-12T08:23:13.792871558Z",
"Extensions": null,
"Spec": {
......
删除容器
ctr container rm nginx
ctr container ls
CONTAINER IMAGE RUNTIME
除了使用 rm
子命令之外也可以使用 delete
或者 del
删除容器。
任务
上面我们通过 container create
命令创建的容器,并没有处于运行状态,只是一个静态的容器。一个 container 对象只是包含了运行一个容器所需的资源及相关配置数据,表示 namespaces、rootfs 和容器的配置都已经初始化成功了,只是用户进程还没有启动。
一个容器真正运行起来是由 Task 任务实现的,Task 可以为容器设置网卡,还可以配置工具来对容器进行监控等。
Task 相关操作可以通过 ctr task
获取,如下我们通过 Task 来启动容器:
ctr task start -d nginx
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
启动容器后可以通过 task ls
查看正在运行的容器:
ctr task ls
TASK PID STATUS
nginx 3630 RUNNING
同样也可以使用 exec
命令进入容器进行操作:
ctr task exec --exec-id 0 -t nginx sh
/ #
不过这里需要注意必须要指定 --exec-id
参数,这个 id 可以随便写,只要唯一就行。
暂停容器,和 docker pause
类似的功能:
ctr task pause nginx
暂停后容器状态变成了 PAUSED
:
ctr task ls
TASK PID STATUS
nginx 3630 PAUSED
同样也可以使用 resume
命令来恢复容器:
ctr task resume nginx
ctr task ls
TASK PID STATUS
nginx 3630 RUNNING
不过需要注意 ctr 没有 stop 容器的功能,只能暂停或者杀死容器。杀死容器可以使用 task kill
命令:
ctr task kill nginx
ctr task ls
TASK PID STATUS
nginx 3630 STOPPED
杀掉容器后可以看到容器的状态变成了 STOPPED
。同样也可以通过 task rm
命令删除 Task:
ctr task rm nginx
ctr task ls
TASK PID STATUS
除此之外我们还可以获取容器的 cgroup 相关信息,可以使用 task metrics
命令用来获取容器的内存、CPU 和 PID 的限额与使用量。
# 重新启动容器
ctr task metrics nginx
ID TIMESTAMP
nginx 2021-08-12 08:50:46.952769941 +0000 UTC
METRIC VALUE
memory.usage_in_bytes 8855552
memory.limit_in_bytes 9223372036854771712
memory.stat.cache 0
cpuacct.usage 22467106
cpuacct.usage_percpu [2962708 860891 1163413 1915748 1058868 2888139 6159277 5458062]
pids.current 9
pids.limit 0
还可以使用 task ps
命令查看容器中所有进程在宿主机中的 PID:
ctr task ps nginx
PID INFO
3984 -
4029 -
4030 -
4031 -
4032 -
4033 -
4034 -
4035 -
4036 -
ctr task ls
TASK PID STATUS
nginx 3984 RUNNING
其中第一个 PID 3984
就是我们容器中的1号进程。
命名空间
另外 Containerd 中也支持命名空间的概念,比如查看命名空间:
ctr ns ls
NAME LABELS
default
如果不指定,ctr 默认使用的是 default
空间。同样也可以使用 ns create
命令创建一个命名空间:
ctr ns create test
ctr ns ls
NAME LABELS
default
test
使用 remove
或者 rm
可以删除 namespace:
ctr ns rm test
test
ctr ns ls
NAME LABELS
default
有了命名空间后就可以在操作资源的时候指定 namespace,比如查看 test 命名空间的镜像,可以在操作命令后面加上 -n test
选项:
ctr -n test image ls
REF TYPE DIGEST SIZE PLATFORMS LABELS
我们知道 Docker 其实也是默认调用的 containerd,事实上 Docker 使用的 containerd 下面的命名空间默认是 moby
,而不是 default
,所以假如我们有用 docker 启动容器,那么我们也可以通过 ctr -n moby
来定位下面的容器:
ctr -n moby container ls
同样 Kubernetes 下使用的 containerd 默认命名空间是 k8s.io
,所以我们可以使用 ctr -n k8s.io
来查看 Kubernetes 下面创建的容器。后续我们再介绍如何将 Kubernetes 集群的容器运行时切换到 containerd
。
runc
runc 是 docker 捐赠给 OCI 的一个符合标准的 runtime 实现,目前 docker 引擎内部也是基于 runc 构建的。这部分我们就分析 runc 这个项目,加深对 OCI 的理解。

runc使用
使用 runc 运行 busybox 容器
先来准备一个工作目录,下面所有的操作都是在这个目录下执行的,比如 mycontainer
:
# mkdir mycontainer
接下来,准备容器镜像的文件系统,我们选择从 docker 镜像中提取:
# mkdir rootfs
# docker export $(docker create busybox) | tar -C rootfs -xvf -
# ls rootfs
bin dev etc home proc root sys tmp usr var
有了 rootfs 之后,我们还要按照 OCI 标准有一个配置文件 config.json 说明如何运行容器,包括要运行的命令、权限、环境变量等等内容,runc
提供了一个命令可以自动帮我们生成:
# runc spec
# ls
config.json rootfs
这样就构成了一个 OCI runtime bundle 的内容,这个 bundle 非常简单,就上面两个内容:config.json 文件和 rootfs 文件系统。config.json
里面的内容很长,这里就不贴出来了,我们也不会对其进行修改,直接使用这个默认生成的文件。有了这些信息,runc 就能知道怎么怎么运行容器了,我们先来看看简单的方法 runc run
(这个命令需要 root 权限),这个命令类似于 docker run
,它会创建并启动一个容器:
? runc run simplebusybox
/ # ls
bin dev etc home proc root sys tmp usr var
/ # hostname
runc
/ # whoami
root
/ # pwd
/
/ # ip addr
1: lo: mtu 65536 qdisc noqueue qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
/ # ps aux
PID USER TIME COMMAND
1 root 0:00 sh
11 root 0:00 ps aux
最后一个参数是容器的名字,需要在主机上保证唯一性。运行之后直接进入到了容器的 sh
交互界面,和通过 docker run
看到的效果非常类似。但是这个容器并没有配置网络方面的内容,只是有一个默认的 lo
接口,因此无法和外部通信,但其他功能都正常。
此时,另开一个终端,可以查看运行的容器信息:
? runc list
ID PID STATUS BUNDLE CREATED OWNER
simplebusybox 18073 running /home/cizixs/Workspace/runc/mycontainer 2017-11-02T06:54:52.023379345Z root
目前,在我的机器上,runc 会把容器的运行信息保存在 /run/runc
目录下:
? tree /run/runc/
/run/runc/
└── simplebusybox
└── state.json
1 directory, 1 file
除了 run 命令之外,我们也能通过create、start、stop、kill 等命令对容器状态进行更精准的控制。继续实验,因为接下来要在后台模式运行容器,所以需要对 config.json
进行修改。改动有两处,把 terminal
的值改成 false
,修改 args
命令行参数为 sleep 20
:
"process": {
"terminal": false,
"user": {
"uid": 0,
"gid": 0
},
"args": [
"sleep", "20"
],
...
}
接着,用 runc 子命令来控制容器的运行,实现各个容器状态的转换:
// 使用 create 创建出容器,此时容器并没有运行,只是准备好了所有的运行环境
// 通过 list 命令可以查看此时容器的状态为 `created`
? runc create mycontainerid
? runc list
ID PID STATUS BUNDLE CREATED OWNER
mycontainerid 15871 created /home/cizixs/Workspace/runc/mycontainer 2017-11-02T08:05:50.658423519Z root
// 运行容器,此时容器会在后台运行,状态变成了 `running`
? runc start mycontainerid
? runc list
ID PID STATUS BUNDLE CREATED OWNER
mycontainerid 15871 running /home/cizixs/Workspace/runc/mycontainer 2017-11-02T08:05:50.658423519Z root
// 等待一段时间(20s)容器退出后,可以看到容器状态变成了 `stopped`
? runc list
ID PID STATUS BUNDLE CREATED OWNER
mycontainerid 0 stopped /home/cizixs/Workspace/runc/mycontainer 2017-11-02T08:05:50.658423519Z root
// 删除容器,容器的信息就不存在了
? runc delete mycontainerid
? runc list
ID PID STATUS BUNDLE CREATED OWNER
把以上命令分开来虽然让事情变得复杂了,但是也有很多好处。可以类比 unix 系统 fork-exec 模式,在两者动作之间,用户可以做很多工作。比如把 create 和 start 分开,在创建出来容器之后,可以使用插件为容器配置多主机网络,或者准备存储设置等。
runc代码实现
看完了 runc 命令演示,这部分来深入分析 runc 的代码实现。要想理解 runc 是怎么创建 linux 容器的,需要熟悉 namespace 和 cgroup、 go 语言 、常见的系统调用。
分析的代码对应的 commit id 如下,这个代码是非常接近 v1.0.0 版本的:
? runc git:(master) git rev-parse HEAD
0232e38342a8d230c2745b67c17050b2be70c6bc
runc
的代码结构如下(略去了部分内容):
? runc git:(master) tree -L 1 -F --dirsfirst
.
├── contrib/
├── libcontainer/
├── man/
├── script/
├── tests/
├── vendor/
├── checkpoint.go
├── create.go
├── delete.go
├── Dockerfile
├── events.go
├── exec.go
├── init.go
├── kill.go
├── LICENSE
├── list.go
├── main.go
├── Makefile
├── notify_socket.go
├── pause.go
├── PRINCIPLES.md
├── ps.go
├── README.md
├── restore.go
├── rlimit_linux.go
├── run.go
├── signalmap.go
├── signalmap_mipsx.go
├── signals.go
├── spec.go
├── start.go
├── state.go
├── tty.go
├── update.go
├── utils.go
└── utils_linux.go
main.go
是入口文件,根目录下很多 .go
文件是对应的命令(比如 run.go
对应 runc run
命令的实现),其他是一些功能性文件。
最核心的目录是 libcontainer
,它是启动容器进程的最终执行者,runc
可以理解为对 libcontainer
的封装,以符合 OCI 的方式读取配置和文件,调用 libcontainer 完成真正的工作。如果熟悉 docker 的话,可能会知道 libcontainer 本来是 docker 引擎的核心代码,用以取代之前 lxc driver。
我们会追寻 runc run
命令的执行过程,看看代码的调用和实现。
main.go
使用 github.com/urfave/cli
库进行命令行解析,主要的思路是先声明各种参数解析、命令执行函数,运行的时候 cli
会解析命令行传过来的参数,把它们变成定义好的变量,调用指定的命令来运行。
func main() {
app := cli.NewApp()
app.Name = "runc"
...
app.Commands = []cli.Command{
checkpointCommand,
createCommand,
deleteCommand,
eventsCommand,
execCommand,
initCommand,
killCommand,
listCommand,
pauseCommand,
psCommand,
restoreCommand,
resumeCommand,
runCommand,
specCommand,
startCommand,
stateCommand,
updateCommand,
}
...
if err := app.Run(os.Args); err != nil {
fatal(err)
}
}
从上面可以看到命令函数列表,也就是 runc
支持的所有命令,命令行会实现命令的转发,我们关心的 runCommand
定义在 run.go
文件,它的执行逻辑是:
Action: func(context *cli.Context) error {
if err := checkArgs(context, 1, exactArgs); err != nil {
return err
}
if err := revisePidFile(context); err != nil {
return err
}
spec, err := setupSpec(context)
status, err := startContainer(context, spec, CT_ACT_RUN, nil)
if err == nil {
os.Exit(status)
}
return err
},
可以看到整个过程分为了四步:
- 检查参数个数是否符合要求
- 如果指定了 pid-file,把路径转换为绝对路径
- 根据配置读取
config.json
文件中的内容,转换成 spec 结构对象 - 然后根据配置启动容器
其中 spec 的定义在 github.com/opencontainers/runtime-spec/specs-go/config.go#Spec
,其实就是对应了 OCI bundle 中 config.json
的字段,最重要的内容在 startContainer
函数中:
utils_linux.go#startContainer
func startContainer(context *cli.Context, spec *specs.Spec, action CtAct, criuOpts *libcontainer.CriuOpts) (int, error) {
id := context.Args().First()
if id == "" {
return -1, errEmptyID
}
......
container, err := createContainer(context, id, spec)
if err != nil {
return -1, err
}
......
r := &runner{
enableSubreaper: !context.Bool("no-subreaper"),
shouldDestroy: true,
container: container,
listenFDs: listenFDs,
notifySocket: notifySocket,
consoleSocket: context.String("console-socket"),
detach: context.Bool("detach"),
pidFile: context.String("pid-file"),
preserveFDs: context.Int("preserve-fds"),
action: action,
criuOpts: criuOpts,
}
return r.run(spec.Process)
}
这个函数的内容也不多,主要分成两部分:
- 调用
createContainer
创建出来容器,这个容器只是一个逻辑上的概念,保存了 namespace、cgroups、mounts、capabilities 等所有 Linux 容器需要的配置 - 然后创建
runner
对象,调用r.run
运行容器。这才是运行最终容器进程的地方,它会启动一个新进程,把进程放到配置的 namespaces 中,设置好 cgroups 参数以及其他内容
我们先来看 utils_linux.go#createContainer
:
func createContainer(context *cli.Context, id string, spec *specs.Spec) (libcontainer.Container, error) {
config, err := specconv.CreateLibcontainerConfig(&specconv.CreateOpts{
CgroupName: id,
UseSystemdCgroup: context.GlobalBool("systemd-cgroup"),
NoPivotRoot: context.Bool("no-pivot"),
NoNewKeyring: context.Bool("no-new-keyring"),
Spec: spec,
Rootless: isRootless(),
})
....
factory, err := loadFactory(context)
....
return factory.Create(id, config)
}
它最终会返回一个 libcontainer.Container
对象,上面提到,这并不是一个运行的容器,而是逻辑上的容器概念,包含了 linux 上运行一个容器需要的所有配置信息。
函数的内容分为两部分:
- 创建 config 对象,这个配置对象的定义在
libcontainer/configs/config.go#Config
,包含了容器运行需要的所有参数。specconv.CreateLibcontainerConfig
这一个函数就是把 spec 转换成 libcontainer 内部的 config 对象。这个 config 对象是平台无关的,从逻辑上定义了容器应该是什么样的配置 - 通过 libcontainer 提供的 factory,创建满足
libcontainer.Container
接口的对象
libcontainer.Container
是个接口,定义在 libcontainer/container_linux.go
文件中:
type Container interface {
BaseContainer
// 下面这些接口是平台相关的,也就是 linux 平台提供的特殊功能
// 使用 criu 把容器状态保存到磁盘
Checkpoint(criuOpts *CriuOpts) error
// 利用 criu 从磁盘中重新 load 容器
Restore(process *Process, criuOpts *CriuOpts) error
// 暂停容器的执行
Pause() error
// 继续容器的执行
Resume() error
// 返回一个 channel,可以从里面读取容器的 OOM 事件
NotifyOOM() (<-chan struct{}, error)
// 返回一个 channel,可以从里面读取容器内存压力事件
NotifyMemoryPressure(level PressureLevel) (<-chan struct{}, error)
}
里面包含了 Linux 平台特有的功能,基础容器接口为 BaseContainer
,定义在 libcontainer/container.go
文件中,它定义了容器通用的方法:
type BaseContainer interface {
// 返回容器 ID
ID() string
// 返回容器运行状态
Status() (Status, error)
// 返回容器详细状态信息
State() (*State, error)
// 返回容器的配置
Config() configs.Config
// 返回运行在容器里所有进程的 PID
Processes() ([]int, error)
// 返回容器的统计信息,主要是网络接口信息和 cgroup 中能收集的统计数据
Stats() (*Stats, error)
// 设置容器的配置内容,可以动态调整容器
Set(config configs.Config) error
// 在容器中启动一个进程
Start(process *Process) (err error)
// 运行容器
Run(process *Process) (err error)
// 销毁容器,就是删除容器
Destroy() error
// 给容器的 init 进程发送信号
Signal(s os.Signal, all bool) error
// 告诉容器在 init 结束后执行用户进程
Exec() error
}
可以看到,上面是容器应该支持的命令,包含了查询状态和创建、销毁、运行等。
这里使用 factory 模式是为了支持不同平台的容器,每个平台实现自己的 factory ,根据运行平台调用不同的实现就行。不过 runc 目前只支持 linux 平台,所以我们看 libcontainer/factory_linux.go
中的实现:
func New(root string, options ...func(*LinuxFactory) error) (Factory, error) {
if root != "" {
if err := os.MkdirAll(root, 0700); err != nil {
return nil, newGenericError(err, SystemError)
}
}
l := &LinuxFactory{
Root: root,
InitPath: "/proc/self/exe",
InitArgs: []string{os.Args[0], "init"},
Validator: validate.New(),
CriuPath: "criu",
}
Cgroupfs(l)
for _, opt := range options {
if opt == nil {
continue
}
if err := opt(l); err != nil {
return nil, err
}
}
return l, nil
}
func (l *LinuxFactory) Create(id string, config *configs.Config) (Container, error) {
......
containerRoot := filepath.Join(l.Root, id)
if err := os.MkdirAll(containerRoot, 0711); err != nil {
return nil, newGenericError(err, SystemError)
}
......
c := &linuxContainer{
id: id,
root: containerRoot,
config: config,
initPath: l.InitPath,
initArgs: l.InitArgs,
criuPath: l.CriuPath,
newuidmapPath: l.NewuidmapPath,
newgidmapPath: l.NewgidmapPath,
cgroupManager: l.NewCgroupsManager(config.Cgroups, nil),
}
......
c.state = &stoppedState{c: c}
return c, nil
}
New
创建了一个 linux 平台的 factory,从 LinuxFactory
的 fields 可以看到,它里面保存了和 linux 平台相关的信息。
Create
返回的是 linuxContainer
对象,它是 libcontainer.Container
接口的实现。有了 libcontainer.Container
对象之后,回到 utils_linux.go#Runner
中看它是如何运行容器的:
func (r *runner) run(config *specs.Process) (int, error) {
// 根据 OCI specs.Process 生成 libcontainer.Process 对象
// 如果出错,运行 destroy 清理产生的中间文件
process, err := newProcess(*config)
if err != nil {
r.destroy()
return -1, err
}
......
var (
detach = r.detach || (r.action == CT_ACT_CREATE)
)
handler := newSignalHandler(r.enableSubreaper, r.notifySocket)
// 根据是否进入到容器终端来配置 tty,标准输入、标准输出和标准错误输出
tty, err := setupIO(process, rootuid, rootgid, config.Terminal, detach, r.consoleSocket)
defer tty.Close()
switch r.action {
case CT_ACT_CREATE:
err = r.container.Start(process)
case CT_ACT_RESTORE:
err = r.container.Restore(process, r.criuOpts)
case CT_ACT_RUN:
err = r.container.Run(process)
default:
panic("Unknown action")
}
......
status, err := handler.forward(process, tty, detach)
if detach {
return 0, nil
}
r.destroy()
return status, err
}
runner
是一层封装,主要工作是配置容器的 IO,根据命令去调用响应的方法。newProcess(*config)
将 OCI spec 中的 process 对象转换成 libcontainer 中的 process,process 的定义在 libcontainer/process.go#Process
,包括进程的命令、参数、环境变量、用户、标准输入输出等。
有了 process
,下一步就是运行这个进程 r.container.Run(process)
,Run
会调用内部的 libcontainer/container_linux.go#start()
方法:
func (c *linuxContainer) start(process *Process, isInit bool) error {
parent, err := c.newParentProcess(process, isInit)
if err := parent.start(); err != nil {
return newSystemErrorWithCause(err, "starting container process")
}
c.created = time.Now().UTC()
if isInit {
......
for i, hook := range c.config.Hooks.Poststart {
if err := hook.Run(s); err != nil {
return newSystemErrorWithCausef(err, "running poststart hook %d", i)
}
}
}
return nil
}
运行容器进程,在容器进程完全起来之前,需要利用父进程和容器进程进行通信,因此这里封装了一个 paerentProcess
的概念,
func (c *linuxContainer) newParentProcess(p *Process, doInit bool) (parentProcess, error) {
parentPipe, childPipe, err := utils.NewSockPair("init")
cmd, err := c.commandTemplate(p, childPipe)
......
return c.newInitProcess(p, cmd, parentPipe, childPipe)
}
parentPipe
和 childPipe
就是父进程和创建出来的容器 init 进程通信的管道,这个管道用于在 init 容器进程启动之后做一些配置工作,非常重要,后面会看到它们的使用。
最终创建的 parentProcess 是 libcontainer/process_linux.go#initProcess
对象,
type initProcess struct {
cmd *exec.Cmd
parentPipe *os.File
childPipe *os.File
config *initConfig
manager cgroups.Manager
intelRdtManager intelrdt.Manager
container *linuxContainer
fds []string
process *Process
bootstrapData io.Reader
sharePidns bool
}
cmd
是 init 程序,也就是说启动的容器子进程是runc init
,后面我们会说明它的作用paerentPipe
和childPipe
是父子进程通信的管道bootstrapDta
中保存了容器 init 初始化需要的数据process
会保存容器 init 进程,用于父进程获取容器进程信息和与之交互
有了 parentProcess
,接下来它的 start()
方法会被调用:
func (p *initProcess) start() error {
defer p.parentPipe.Close()
err := p.cmd.Start()
p.process.ops = p
p.childPipe.Close()
// 把容器 pid 加入到 cgroup 中
if err := p.manager.Apply(p.pid()); err != nil {
return newSystemErrorWithCause(err, "applying cgroup configuration for process")
}
// 给容器进程发送初始化需要的数据
if _, err := io.Copy(p.parentPipe, p.bootstrapData); err != nil {
return newSystemErrorWithCause(err, "copying bootstrap data to pipe")
}
// 等待容器进程完成 namespace 的配置
if err := p.execSetns(); err != nil {
return newSystemErrorWithCause(err, "running exec setns process for init")
}
// 创建网络 interface
if err := p.createNetworkInterfaces(); err != nil {
return newSystemErrorWithCause(err, "creating network interfaces")
}
// 给容器进程发送进程配置信息
if err := p.sendConfig(); err != nil {
return newSystemErrorWithCause(err, "sending config to init process")
}
// 和容器进程进行同步
// 容器 init 进程已经准备好环境,准备运行容器中的用户进程
// 所以这里会运行 prestart 的钩子函数
ierr := parseSync(p.parentPipe, func(sync *syncT) error {
......
return nil
})
// Must be done after Shutdown so the child will exit and we can wait for it.
if ierr != nil {
p.wait()
return ierr
}
return nil
}
这里可以看到管道的用处:父进程把 bootstrapData 发送给子进程,子进程根据这些数据配置 namespace、cgroups,apparmor 等参数;等待子进程完成配置,进行同步。
容器子进程会做哪些事情呢?用同样的方法,可以找到 runc init 程序运行的逻辑代码在 libcontainer/standard_init_linux.go#Init()
,它做的事情包括:
- 配置 namespace
- 配置网络和路由规则
- 准备 rootfs
- 配置 console
- 配置 hostname
- 配置 apparmor profile
- 配置 sysctl 参数
- 初始化 seccomp 配置
- 配置 user namespace
上面这些就是 linux 容器的大部分配置,完成这些之后,它就调用 Exec
执行用户程序:
if err := syscall.Exec(name, l.config.Args[0:], os.Environ()); err != nil {
return newSystemErrorWithCause(err, "exec user process")
}
NOTE:其实,init 在执行自身的逻辑之前,会被 libcontainer/nsenter 劫持,nsenter 是 C 语言编写的代码,目的是为容器配置 namespace,它会从 init pipe 中读取 namespace 的信息,调用setns 把当前进程加入到指定的 namespace 中。
之后,它会调用 clone 创建一个新的进程,初始化完成之后,把子进程的进程号发送到管道中,nsenter 完成任务退出,子进程会返回,让 init 接管,对容器进行初始化。
至此,容器的所有内容都 ok,而且容器里的用户进程也启动了。
runc 的代码调用关系如上图所示,可以在新页面打开查看大图。主要逻辑分成三块:
- 最上面的红色是命令行封装,这是根据 OCI 标准实现的接口,它能读取 OCI 标准的容器 bundle,并实现了 OCI 指定 run、start、create 等命令
- 中间的紫色部分就是 libcontainer,它是 runc 的核心内容,是对 linux namespace、cgroups 技术的封装
- 右下角的绿色部分是真正的创建容器子进程的部分
参考
一文搞定containerd的使用
https://cizixs.com/2017/11/05/oci-and-runc/ OCI 和 runc:容器标准化和 docker
https://www.cncf.io/blog/2019/07/15/demystifying-containers-part-ii-container-runtimes/ Demystifying containers – part II: container runtimes
https://zhuanlan.zhihu.com/p/30667806 CRI-O 1.0 简介
https://www.ianlewis.org/en/container-runtimes-part-1-introduction-container-r Container Runtimes Part 1: An Introduction to Container Runtimes
https://www.ianlewis.org/en/container-runtimes-part-4-kubernetes-container-run
http://dockone.io/article/8631 LXC-Linux Container 简介