运行时指的是程序的生命周期阶段或使用特定语言来执行程序。容器运行时的功能与它类似——它是运行和管理容器所需组件的软件。这些工具可以更轻松地安全执行和高效部署容器,是容器管理的关键组成部分。在容器化架构中,容器运行时负责从存储库加载容器镜像、监控本地系统资源、隔离系统资源以供容器使用以及管理容器生命周期。
容器运行时的常见示例是 runC、containerd 和 Docker。容器运行时主要分为三种类型——低级运行时、高级运行时以及沙盒或虚拟化运行时。 在容器技术中,容器运行时可以分为三种类型:低级运行时、高级运行时以及沙盒或虚拟化运行时。- 1. 低级运行时:指的是负责容器隔离和生命周期管理的基本运行时组件。在这种运行时中,容器是通过Linux内核的cgroups和namespace机制进行隔离和管理的。常见的低级运行时包括Docker的runc、lxc等。这种运行时通常具有轻量化和高性能的优点,但缺乏高级特性和管理工具。
- 2. 高级运行时:是在低级运行时的基础上,提供了更丰富的特性和管理工具的容器运行时。这些特性可以包括容器网络、存储、监控、镜像传输、镜像管理、镜像API等功能,以及各种管理工具等。常见的高级运行时包括Docker、Containerd和CRI-O等。这种运行时通常具有更为丰富的特性和管理工具,但也带来了更高的复杂性和资源消耗。
- 3. 沙盒或虚拟化运行时:是在容器运行时中使用沙盒技术或虚拟化技术实现容器隔离和管理的运行时。这种运行时通常具有更强的隔离性和安全性,但也会带来更高的性能开销和复杂性。常见的沙盒或虚拟化运行时包括gVisor、Kata Containers等。
1 低级容器运行时
低级容器运行时 (Low level Container Runtime),一般指按照 OCI 规范实现的、能够接收可运行文件系统(rootfs) 和 配置文件(config.json)并运行隔离进程的实现。
这种运行时只负责将进程运行在相对隔离的资源空间里,不提供存储实现和网络实现。但是其他实现可以在系统中预设好相关资源,低级容器运行时可通过 config.json 声明加载对应资源。 低级运行时的特点是底层、轻量、灵活,限制也很明显:- • 只认识 rootfs 和 config.json,不认识镜像 (下文简称 image),不具备镜像存储功能,也不能执行镜像的构建、推送、拉取等(我们无法使用 runC, kata-runtime 处理镜像)
- • 不提供网络实现,所以真正使用时,往往需要利用 CNI 之类的实现为容器添加网络
- • 不提供持久实现,如果容器是有状态应用需要使用文件系统持久状态,单机环境可以挂载宿主机目录,分布式环境可以自搭 NFS,但多数会选择云平台提供的 CSI 存储实现
- • 与特定操作系统绑定无法跨平台,比如 runC 只能在 Linux 上使用;runhcs 只能在 Windows 上使用
2 高级容器运行时第一要务
高级容器运行时首先要做的是打通 OCI image spec 和 runtime spec,直白来说就是高效处理 image 到 rootfs 和 config.json 的转换。config.json 的生成比较简单,运行时可以结合 image config 和请求方需求直接生成;较为复杂的部分是 image 到 rootfs 的转换,这涉及镜像拉取、镜像存储、镜像 layer 解压、解压 layer 文件系统(fs layer) 的存储、合并 fs layer 为 rootfs。
镜像拉取模块先从 image registry 获取清单(manifest)文件,处理过程不仅需要兼容 OCI image 规范,考虑到 Docker 生态,也需兼容 Docker image 规范(所幸两者区别并不大)。运行时实现先从 manifest 获取 layer list,先检查对应 layer 在本地是否存在,如果不存在则下载对应 layer。下载的 layer tar 或者 tar.gz 一般直接存储磁盘,为实现快速处理,需要建立索引,比如从 reference:tag (如 docker.io/library/redis:6.0.5-alpine) 到 manifest 存储路径的映射;当然,layer 的访问比 image 高频,layer sha256 值到对应存储路径也会被索引。因此 ,运行时一般会围绕 image 索引和 image layer 存储组织独立模块对其他模块提供服务。
- • 第一个问题一般使用 image config 文件中的 diffID 解决,每解压一层 layer,就使用上一层 fs layer id 和 本层 diffID 的拼接串做 sha256 hash,输出结果作为本层对应的 fs layer id(最里层 id 为其 diffID),接着建立 id 到磁盘路径索引。因此只要通过 image manifest 文件找到 image config 文件,即可找到所有 fs layers,详细实现方式见 OCI image spec layer chain id。
- • 第二个问题解决方式很简单,在每个 fs layer 索引存储上一层 fs layer id 即可。
- • 第三个问题,一般通过 UnionFS 提供的 CopyOnWrite 技术解决,简单来说,就是使用空文件夹,在镜像对应 fs layer 最外层之上再生成一层 layer,使用 UnionFS 合并(准确来说是挂载 mount)时将其声明为 work 目录(或者说 upper 目录)。UnionFS 挂载出 rootfs 之后,隔离进程所做的任何写操作(包括删除)都只体现在 work layer,而不会影响其他 fs layer。(详细介绍可以参考 陈皓的介绍文章)
3 Containerd
containerd 是一个高度模块化的高级运行时,所有模块均以 RPC service 形式加载(gRPC 或者 TTRPC),所有模块均可插拔。不同插件通过声明互相依赖,由 containerd 核心实现统一加载,使用方可以使用 Go 语言实现编写插件实现更丰富的功能。不过这种设计使得 containerd 拥有强大的跨平台能力,并能够作为一个组件轻松嵌入其他软件,也带来一个弊端,模块之间功能互调也从简单的函数调用,变成了更为昂贵的 RPC 调用。
注:TTRPC 是一种基于 gRPC 的改良通信协议。 containerd 架构图- • Content,以 image layer 哈希值(一般使用 sha256 算法生成)为索引,支持快速 layer 快速查找和读取,并支持对 layer 添加 label。索引和 label 信息存储在 boltDB。
- • Images,在 boltDB 中存储了 reference 到 manifest layer 的映射,结合 Content 可以组织完整的 image 信息。
- • Snapshot,存储、处理解压后的 fs layers 和容器 work layer,索引信息同样存储在 boltDB。Snapshot 内置支持多种 UnionFS(如 overlay,aufs,btrfs)。
- • Containers,以 container ID 为索引,在 boltDB 中存储了低级运行时描述、 snapshot 文件系统类型、 snapshotKey(work layer id)、image reference 等信息。
- • Diff,可用于比对 image layer tar 和 fs layers 差异输出 diffID,可以校验 image config 中的 diffID,同样也能比对 fs layers 之间的差异。
containerd 运行容器,一般先从 Images 模块触发,结合 Snapshot 模块建立新的容器 fs layer,加上低级运行时信息,组合成 container 结构体。containerd 利用 container 结构体,将之前的所有 Snapshots 转换为 Mounts 对象(声明了所有子文件夹的位置和挂载方式),结合低级运行时、OCI spec、镜像等信息在请求体中,向 Tasks 模块提交任务请求。Tasks 模块 Manager 根据任务低级运行时信息(如 io.containerd.runc.v1),组合出统一的 containerd-shim 进程运行命令,通过系统调用启动 shim 进程,并同步建立与 shim 进程的 TTRPC 通信。随后将任务交给 shim 进程管理。shim 进程接到请求后,判知 Mounts 长度大于 0,则会按照 Mounts 声明的挂载方式,使用 overlay、aufs 等联合文件系统将所有子文件夹组成容器运行需要的 rootfs,结合 OCI spec 调用低级运行时运行容器进程并将结果返回给 containerd 进程。
使用 shim 进程管理容器进程好处很多,containerd clash,containerd-shim 进程和容器进程不会受影响,containerd 恢复后只需读取运行目录的 socket 文件及 pid 恢复与 shim 进程通信即可快速还原 Tasks 信息(Unix 平台),同一容器进程出现问题,对于其他进程来说是隔离。最重要的是,通过统一的 shim 接口,同一套 containerd 代码可以同时兼容多个不同的运行时,也能同时兼容不同操作系统平台。
containerd 不提供容器网络和容器应用状态存储解决方案,而是把它们留给了更高层的实现。
container 在其 **介绍** 中提到:其设计目的是成为大系统中的一个组件(如 Kubernetes, Docker),而非直接供用户使用。
containerd is designed to be embedded into a larger system, rather than being used directly by developers or end-users。 下文会展示这意味着什么。4 CRI-O
相比 containerd,CRI-O 的高级运行时功能基于若干开源库实现,不同模块之间为纯粹 Go 语言依赖,而非通信协议:
- • containers/image 库用于 Image 下载,下载过程类似 2 阶段提交。不同来源的镜像(如 Docker, Openshift, OCI)先被统一为 ImageSource 通用抽象,接着被分为 3 部分进行处理:blob 被放置在系统临时文件夹,manifest 和 signature 缓存在内存(Put*)。之后,镜像内容 Commit 至 containers/storage 库。
- • CRI-O 大部分业务逻辑集中在 containers/storage 之上 - • LayerStore 接口统一处理 image layer(不包括 config layer) 和 fs layer,镜像 Commit 存储时,LayerStore 先调用 fs 驱动实现(如 overlay)在磁盘创建 fs layer 目录并记录层次关系,接着调用 ApplyDiff 方法,解压内容被存放在 layer 目录(经驱动实现),未解压内容被存放在 image layer 目录,fs layer 层次关系存储在 json 文件。 - • ImageStore 接口处理 image meta,包括 manifest、config 和 signature,meta 与 layer 关联索引存储在 json 文件。 - • ContainerStore 接口管理 container meta,创建 container 的步骤和存储 image layer 代码路径近乎重合,只不过前者被限制为 read 模式,后者为 readWrite,且没有 ApplyDiff(diff 送空),meta 与 layer 关联索引也存储在 json 文件。
CRI-O 运行容器进程时,先确保对应 image 存在(不存在则尝试下载),随之基于 image top layer 创建 UnionFS,同时生成 OCI spec config.json,之后,根据请求方提供的低级运行时信息(RuntimeHandler),使用不同包装实现操作容器进程。
- • 如果 RuntimeHandler 为非 VM 类型,创建并委托监视进程 **conmon** 操作低级运行时创建容器。之后,conmon 在特定路径提供一个可与容器进程通信的 socket 文件,并负责持续监视容器进程并负责将其 stream 写入指定日志文件。容器进程创建成功之后,CRI-O 直接与低级运行时交互执行 start、delete、update 等操作,或者通过 socket 文件直接与容器进程交互。
- • 如果 RuntimeHandler 为 VM,则创建并委托 containerd-shim 进程处理间接容器进程(请求包含完整 rootfs,Mounts 为 空)。与非 VM 类型不同,此后所有容器进程相关操作均通过 shim 完成。
CRI-O 依靠 CNI 插件(默认路径 /opt/cni/bin)为容器进程提供网络实现。其逻辑一般在低级运行时创建完隔离进程返回后,获取 pid 后将对应的 network namespace path(/proc/{pid}/ns/net)交给 CNI 处理,CNI 会根据配置会往对应 namespace 添加好网卡。一般地,容器进程会在 cni 网桥上获得一个独立 IP 地址,这个 IP 地址能与宿主机通信,如果 CNI 配置了 flannel 之类的 overlay 实现,容器甚至能够与其他主机的同一网段容器进程通信,具体视配置而定。细节方面可以参考 这篇介绍。
如果指定由其管理 network namespace 生命周期(配置 manage_ns_lifecycle),则会在创建 sandbox 容器时采用类似 理解 OCI#给 runC 容器绑定虚拟网卡 的方式创建虚拟网卡,随后通过 OCI json config 传递对应路径给低级运行时。同样地,当 sandbox 容器销毁时,CRI-O 会自动回收对应 namespace 资源。这部分逻辑的网络相关代码使用 C 语言实现,在 CRI-O 中以名为 pinns 的二进制程序发行。 需要指出的是,CRI-O 使用文件挂载方式配置容器 hostname, dns server 等,而非 CNI 插件。5 Docker
Docker 是一个大而完备的高级运行时,其用户端核心叫做 Docker Engine,由 3 部分构成:Docker Server (docker daemon, 简称 dockerd)、REST API 和 Docker cli。借助 Docker Engine 既能便捷地运行容器进程进行集成开发、也能快速构建分发镜像。- • 镜像下载时,dockerd 先自 registry 获取 manifest 文件,随后并行下载存储 image layers 和 config layer。与 containers/storag 类似,image layer 解压内容由 fs 驱动实现(如 overlay) 存储至新建的子目录中(如 /var/lib/docker/overlay2/{new-dir}),不同的是,随后 dockerd 只是以 layer chainID 为索引,存储 fs new-dir、diffID、parent chainID、size 等必要信息,并不存储未解压 tar 或 tar.gz。image layers 和 config layer 均存储完成后,再以 image reference 为索引,建立 reference 至 image ID 映射。作为镜像分发模块的一部分,dockerd 还会以 manifest layer digest 为索引,建立 digest 至 diffID 映射;以 diffID 为索引,建立 diffID 至 repository 和 digest 映射。
- • 镜像推送不过是镜像下载的逆过程。dockerd 先使用 reference 获取 imageID(也即 image config),随后以 imageID 为中心组织出目标 manifest,对应的 layer fs 开始被压缩成目标格式(一般是 tar.gz)。layers 开始上传时,自分发模块获取 diffID 至 repository 和 digest 信息,发起远程请求确认对应 layer 是否已存在,存在则跳过上传,最终以 manifest 为中心的镜像被分发至对应 Registry 实现。
Docker 容器创建运行相较 containerd 和 CRI-O 有更多高层的存储和网络抽象,如使用 **-v,—volume 命令即可声明运行时需挂载的文件系统,使用 -p,—publish** 即可声明 host 网络至容器网络映射,这些声明信息会被持久在 docker 工作目录下的 containers 子目录。
执行运行命令之际,dockerd 首先生成容器读写层并通过 UnionFS 与 fs layers 一道转化为 rootfs。接着,image config 中的环境、启动参数等信息被转化为 OCI runtime spec 参数。同时类似 CRI-O,dockerd 会为容器生成一些特殊的文件,如 /etc/hosts, /etc/hostname, /etc/resolv.conf, /dev/shim 等,随之这些特殊文件与 volume 声明或者 mount 声明一起作为 dockerd Mount 抽象转化为 OCI runtime spec Mount 参数。最后,rootfs、OCI runtime spec 和低级运行时信息通过 RPC 请求传递给 containerd,剧情变得和 containerd 运行容器一致。 不难发现,虽然持久挂载驱动各异,但对运行时而言,本质都是将宿主机某类型的文件目录映射到容器文件系统中。因此对于低级运行时而言,挂载逻辑可以统一。dockerd 在此之上发展了丰富的持久业务层,以便于用户使用。mount 用于直接将宿主机目录挂载至容器文件系统;volume 相对 bind mounts 优势是对应文件持久在 dockerd 的工作目录,由 dockerd 管理,同时具有跨平台能力。tmpfs 则由操作系统提供容器读写层之外的临时存储能力。 dockerd 支持多种网络驱动,其基础抽象叫做 endpoint,可以简单将 endpoint 理解为网卡背后的网络资源。对于每一 endpoint,dockerd 都会通过 IPAM 实现在 docker0 网桥上分配 IP 地址,接着通过 bridge 等驱动为容器创建网卡,如果使用 publish 参数配置了容器至宿主机的 port 映射,dockerd 会往宿主机 iptable 添加对应网络规则,同时还可能会启动 docker proxy 服务 forward 流量到容器。容器的所有 endpoints 被放置在 sandbox 抽象中。准备好网络资源后,dockerd 调用 containerd 运行容器时,会在 OCI spec 中设置 Prestart Hook 命令,该命令包含了设置网络的必要信息(容器ID,容器进程ID,sandbox ID)。低级运行时实现如 runC 会在容器进程被创建但未被运行前调用该命令,该命令最终将容器ID,容器进程ID,sandbox ID 传递给 dockerd,dockerd 随即将 sandbox 中的所有 endpoint 资源绑定到容器网络 namespace 中(也是 /proc/{ctr-pid}/ns/net)。